Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

update-bundle/src/finalize.rs

//! Approval and finalization.
//!
//! `add_approval` writes a detached-signed approval statement bound to the candidate
//! digest. `finalize` verifies every approval against the trust policy, enforces the
//! two-person rule, then builds and signs the release manifest — and self-verifies
//! the result so a bundle that would not verify is never emitted.

use chrono::{DateTime, Utc};

use crate::approval::{ApprovalStatement, Decision};
use crate::core::BundleCore;
use crate::digest::{blake3_hex, sha256_hex};
use crate::error::{BundleError, Result};
use crate::manifest::ReleaseManifest;
use crate::policy::TrustPolicy;
use crate::schema::{
    APPROVALS_PREFIX, CORE_JSON, RELEASE_CRT, RELEASE_MANIFEST_JSON, RELEASE_SIG, SIGNATURES_PREFIX,
};
use crate::signer::{chains_to_root, sign_pkcs8, SignerCert};
use crate::verify::{
    collect_roots, enforce_two_person, verify_approvals, verify_member_map, ApprovalRecord,
    TargetSpec, VerifyReport,
};
use crate::MemberMap;

fn sanitize_role(role: &str) -> String {
    let mut out: String = role
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
                c
            } else {
                '_'
            }
        })
        .collect();
    if out.is_empty() {
        out = "role".to_string();
    }
    out
}

/// SHA-256 of the exact `core.json` bytes — the candidate digest.
pub fn candidate_digest(members: &MemberMap) -> Result<String> {
    let core = members
        .get(CORE_JSON)
        .ok_or_else(|| BundleError::MissingMember(CORE_JSON.to_string()))?;
    Ok(sha256_hex(core))
}

/// Add a signed approval to a candidate member map. Signs the exact approval
/// statement bytes with the approver's ed25519 key (PKCS#8); the private key never
/// enters the bundle and never appears in any output.
pub fn add_approval(
    members: &mut MemberMap,
    role: &str,
    signer_cert_der: &[u8],
    signer_key_pkcs8: &[u8],
    comment: Option<String>,
    approved_at: DateTime<Utc>,
) -> Result<ApprovalRecord> {
    let digest = candidate_digest(members)?;
    let cert = SignerCert::parse(signer_cert_der)?;
    let fp = cert.fingerprint().to_string();
    let base = format!("{}.{}", sanitize_role(role), fp);
    let sig_path = format!("{SIGNATURES_PREFIX}{base}.sig");
    let crt_path = format!("{SIGNATURES_PREFIX}{base}.crt");
    let stmt_path = format!("{APPROVALS_PREFIX}{base}.approval.json");

    let stmt = ApprovalStatement {
        candidate_digest: digest,
        approval_role: role.to_string(),
        signer_certificate_fingerprint: fp.clone(),
        signer_subject: cert.subject().to_string(),
        approved_at,
        decision: Decision::Approve,
        optional_comment: comment,
        signature_algorithm: "ed25519".to_string(),
        signature_path: sig_path.clone(),
    };
    let stmt_bytes = stmt.to_json_bytes()?;
    let sig = sign_pkcs8(signer_key_pkcs8, &stmt_bytes)?;
    // The key must be the certificates: an approval in the holders name that their
    // public key cannot verify is refused here, before the candidate is touched.
    cert.verify(&stmt_bytes, &sig)
        .map_err(|_| BundleError::InvalidApprovalSignature {
            role: role.to_string(),
            fingerprint: fp.clone(),
        })?;

    members.insert(stmt_path, stmt_bytes);
    members.insert(sig_path, sig);
    members.insert(crt_path, signer_cert_der.to_vec());
    Ok(ApprovalRecord {
        role: role.to_string(),
        fingerprint: fp,
        subject: cert.subject().to_string(),
    })
}

/// Verify approvals + policy, then build and sign the release manifest, mutating
/// `members` into a finalized bundle. Returns the verification report of the
/// finalized bundle (finalize refuses to emit anything that does not verify).
pub fn finalize(
    members: &mut MemberMap,
    policy: &TrustPolicy,
    release_cert_der: &[u8],
    release_key_pkcs8: &[u8],
    now: DateTime<Utc>,
) -> Result<VerifyReport> {
    // Finalize exactly once.
    if members.contains_key(RELEASE_MANIFEST_JSON) || members.contains_key(RELEASE_SIG) {
        return Err(BundleError::Policy("bundle is already finalized".into()));
    }

    // Preflight: the candidate must be internally consistent (recorded digests
    // match the actual member bytes).
    let core_bytes = members
        .get(CORE_JSON)
        .ok_or_else(|| BundleError::MissingMember(CORE_JSON.to_string()))?
        .clone();
    let core = BundleCore::from_json_bytes(&core_bytes)?;
    // The release signer never signs a candidate its reviewers did not pass.
    core.require_pass_verdict()?;
    for p in &core.payloads {
        let b = members
            .get(&p.relative_path)
            .ok_or_else(|| BundleError::MissingMember(p.relative_path.clone()))?;
        if p.size_bytes != b.len() as u64 {
            return Err(BundleError::DigestMismatch {
                path: p.relative_path.clone(),
                which: "size",
            });
        }
        if p.sha256 != sha256_hex(b) {
            return Err(BundleError::DigestMismatch {
                path: p.relative_path.clone(),
                which: "sha256",
            });
        }
        if p.blake3 != blake3_hex(b) {
            return Err(BundleError::DigestMismatch {
                path: p.relative_path.clone(),
                which: "blake3",
            });
        }
    }
    for e in &core.evidence {
        let b = members
            .get(&e.relative_path)
            .ok_or_else(|| BundleError::MissingMember(e.relative_path.clone()))?;
        if e.sha256 != sha256_hex(b) || e.blake3 != blake3_hex(b) || e.size_bytes != b.len() as u64
        {
            return Err(BundleError::DigestMismatch {
                path: e.relative_path.clone(),
                which: "content",
            });
        }
    }

    let digest = sha256_hex(&core_bytes);
    let mode = policy.trust_mode()?;
    let roots = collect_roots(policy)?;

    // Approvals + policy must already be satisfiable (clear early errors, no mutation).
    let approvals: Vec<ApprovalRecord> =
        verify_approvals(members, policy, &digest, &roots, mode, now)?;
    let release_cert = SignerCert::parse(release_cert_der)?;
    // The PRODUCER refuses to sign with a lapsed key too: a bundle sealed by an
    // expired certificate is one that every downstream verifier will reject, and
    // finding that out at the intake station is cheaper than at the airgapped site.
    release_cert.ensure_valid_under(now, policy.certificate_validity)?;
    if !policy.is_release_signer(release_cert.fingerprint()) {
        return Err(BundleError::UntrustedReleaseSigner(
            release_cert.fingerprint().to_string(),
        ));
    }
    if !chains_to_root(&release_cert, &roots, mode) {
        return Err(BundleError::UntrustedReleaseSigner(
            release_cert.fingerprint().to_string(),
        ));
    }
    enforce_two_person(policy, &approvals, release_cert.fingerprint())?;

    // Build + sign the release manifest. The release cert is a member (but not
    // covered by the manifest — a signature cannot cover its own certificate).
    members.insert(RELEASE_CRT.to_string(), release_cert_der.to_vec());
    let manifest = ReleaseManifest::build(members, &core.bundle_id, &digest);
    let manifest_bytes = manifest.to_json_bytes()?;
    let sig = sign_pkcs8(release_key_pkcs8, &manifest_bytes)?;
    members.insert(RELEASE_MANIFEST_JSON.to_string(), manifest_bytes);
    members.insert(RELEASE_SIG.to_string(), sig);

    // Self-verify: never emit a bundle that does not verify against its own policy.
    verify_member_map(members, policy, &TargetSpec::default(), None, now)
}