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)
}