update-bundle/src/tests/detached.rs
//! The detached-signature primitive the backup path authenticates with.
//!
//! `holger-server backup` used to store a hash per file INSIDE the archive those
//! hashes describe: anyone who could write the backup rewrote a member and its
//! hash together, and `verify` and `restore` both reported success. Hashes catch a
//! failing disk; only a signature over the hash list, made by a key the attacker
//! does not hold, catches a person.
//!
//! These tests are the crypto half of that fix. They live here rather than beside
//! the CLI because minting a throwaway ed25519 certificate needs `rcgen`, which is
//! this crate's dev-dependency; the CLI-side policy (unsigned refused, opt-out
//! named, a present signature never skipped) is proven in `secure_update.rs`.
use super::testkit::*;
use crate::error::BundleError;
use crate::policy::{ApprovedSigner, CertificateValidity, ExpiredPosture, TrustMode, TrustPolicy};
use crate::schema::POLICY_SCHEMA;
use crate::signer::sign_pkcs8;
use crate::verify::verify_detached_by_trusted_signer;
const ROLE: &str = "backup-signer";
/// A hash list of exactly the shape `backup create` writes.
fn backup_manifest() -> Vec<u8> {
br#"{"schema":"holger.backup/v1","files":{"bundles/a.znippy":{"sha256":"aa"}}}"#.to_vec()
}
/// A pinned-fingerprint policy authorizing `signer` for `ROLE` and nothing else.
fn policy_trusting(signer: &TestSigner, role: &str) -> TrustPolicy {
TrustPolicy {
schema_version: POLICY_SCHEMA.to_string(),
trust_mode: Some(TrustMode::PinnedSigners),
trusted_roots: vec![],
certificate_validity: CertificateValidity::Enforce,
approved_signers: vec![ApprovedSigner {
fingerprint: signer.fingerprint.clone(),
subject: None,
roles: vec![role.to_string()],
}],
required_approval_roles: vec![],
minimum_distinct_approvers: 0,
trusted_release_signers: vec![],
release_signer_must_be_distinct: true,
maximum_bundle_age_secs: None,
require_expiry: false,
on_expired_metadata: Some(ExpiredPosture::ReadOnly),
require_sbom: false,
require_vex: false,
require_scan: false,
allowed_products: vec![],
allowed_models: vec![],
}
}
#[test]
fn a_backup_manifest_signed_by_a_trusted_signer_is_accepted() {
let signer = self_signed("backup-signer");
let policy = policy_trusting(&signer, ROLE);
let manifest = backup_manifest();
let sig = sign_pkcs8(&signer.key_pkcs8, &manifest).unwrap();
let who = verify_detached_by_trusted_signer(&manifest, &sig, &signer.cert_der, &policy, ROLE)
.unwrap();
assert_eq!(who.fingerprint, signer.fingerprint);
assert!(who.subject.contains("backup-signer"), "{}", who.subject);
}
#[test]
fn a_backup_manifest_altered_after_signing_fails_its_signature() {
let signer = self_signed("backup-signer");
let policy = policy_trusting(&signer, ROLE);
let manifest = backup_manifest();
let sig = sign_pkcs8(&signer.key_pkcs8, &manifest).unwrap();
// The tamper the finding describes: a member's recorded hash is swapped for
// the attacker's. The signature was made over the OLD bytes and cannot follow.
let tampered = String::from_utf8(manifest)
.unwrap()
.replace("\"aa\"", "\"bb\"")
.into_bytes();
let err = verify_detached_by_trusted_signer(&tampered, &sig, &signer.cert_der, &policy, ROLE)
.expect_err("a rewritten hash list must not verify");
match err {
BundleError::InvalidApprovalSignature { role, .. } => assert_eq!(role, ROLE),
other => panic!("expected a signature failure, got {other:?}"),
}
}
#[test]
fn a_backup_manifest_signed_by_an_untrusted_key_is_refused_even_though_the_signature_is_valid() {
// The whole point: an attacker who rewrites a backup can also re-sign it — with
// THEIR key. The signature is real; the signer is not ours. Authorization, not
// cryptography, is what refuses this.
let ours = self_signed("backup-signer");
let theirs = self_signed("attacker");
let policy = policy_trusting(&ours, ROLE);
let manifest = backup_manifest();
let sig = sign_pkcs8(&theirs.key_pkcs8, &manifest).unwrap();
// The signature genuinely verifies under the attacker's own certificate...
crate::signer::SignerCert::parse(&theirs.cert_der)
.unwrap()
.verify(&manifest, &sig)
.expect("the attacker's signature is cryptographically valid");
// ...and is still refused, because the policy does not name that fingerprint.
let err = verify_detached_by_trusted_signer(&manifest, &sig, &theirs.cert_der, &policy, ROLE)
.expect_err("a valid signature by an untrusted key must be refused");
match err {
BundleError::UntrustedSigner { fingerprint, role } => {
assert_eq!(fingerprint, theirs.fingerprint);
assert_eq!(role, ROLE);
}
other => panic!("expected an untrusted-signer refusal, got {other:?}"),
}
}
#[test]
fn a_signer_authorized_for_a_different_role_cannot_sign_a_backup() {
let signer = self_signed("security-reviewer");
// Authorized — but to approve bundles, not to vouch for backups.
let policy = policy_trusting(&signer, "security-reviewer");
let manifest = backup_manifest();
let sig = sign_pkcs8(&signer.key_pkcs8, &manifest).unwrap();
let err = verify_detached_by_trusted_signer(&manifest, &sig, &signer.cert_der, &policy, ROLE)
.expect_err("a role the policy did not grant must not be assumed");
assert!(
matches!(err, BundleError::UntrustedSigner { .. }),
"{err:?}"
);
}
#[test]
fn a_backup_signer_that_does_not_chain_to_a_trusted_root_is_refused() {
let root = ca("Holger Test Root CA");
let other = ca("Some Other CA");
let signer = issue_leaf("backup-signer", &root);
let manifest = backup_manifest();
let sig = sign_pkcs8(&signer.key_pkcs8, &manifest).unwrap();
let mut policy = policy_trusting(&signer, ROLE);
// Rooted-chain posture, stated: listing roots is only meaningful under it, and
// a policy that lists roots while claiming pinning is now itself a refusal.
policy.trust_mode = Some(TrustMode::RootedChain);
policy.trusted_roots = vec![hex(&root.der)];
verify_detached_by_trusted_signer(&manifest, &sig, &signer.cert_der, &policy, ROLE)
.expect("a leaf from the trusted root is accepted");
// Same fingerprint in `approved_signers`, but the chain no longer reaches a
// root the policy anchors: fingerprint authorization alone is not enough.
policy.trusted_roots = vec![hex(&other.der)];
let err = verify_detached_by_trusted_signer(&manifest, &sig, &signer.cert_der, &policy, ROLE)
.expect_err("a signer that chains nowhere trusted must be refused");
assert!(
matches!(err, BundleError::UntrustedSigner { .. }),
"{err:?}"
);
}