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/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:?}"
    );
}