update-bundle/src/tests/testkit.rs
//! Test-only PKI + bundle builders. Uses rcgen (a dev-dependency) to mint ed25519
//! signer certificates in-process — never a committed private key.
use chrono::{DateTime, Duration, Utc};
use rcgen::{BasicConstraints, CertificateParams, DnType, IsCa, KeyPair, PKCS_ED25519};
use crate::core::{EvidenceKind, PolicyResult, SourceProvenance};
use crate::digest::{hex_lower, sha256_hex};
use crate::finalize::{add_approval, finalize};
use crate::policy::{ApprovedSigner, CertificateValidity, ExpiredPosture, TrustMode, TrustPolicy};
use crate::prepare::{EvidenceInput, PayloadInput, Preparation};
use crate::schema::POLICY_SCHEMA;
use crate::MemberMap;
pub struct TestSigner {
pub cert_der: Vec<u8>,
pub key_pkcs8: Vec<u8>,
pub fingerprint: String,
}
#[allow(dead_code)] // fingerprint kept for symmetry with TestSigner; not every test reads it
pub struct TestCa {
/// rcgen 0.14 (2026-10-02, W3): the CA's params + key, which signs leaves —
/// what 0.13 kept inside its `Certificate`.
pub issuer: rcgen::Issuer<'static, KeyPair>,
pub der: Vec<u8>,
pub fingerprint: String,
}
/// A self-signed ed25519 signer (certificate-pinning mode).
pub fn self_signed(cn: &str) -> TestSigner {
let key = KeyPair::generate_for(&PKCS_ED25519).unwrap();
let mut params = CertificateParams::new(vec![]).unwrap();
params.distinguished_name.push(DnType::CommonName, cn);
let cert = params.self_signed(&key).unwrap();
let der = cert.der().to_vec();
TestSigner {
fingerprint: sha256_hex(&der),
cert_der: der,
key_pkcs8: key.serialize_der(),
}
}
/// A self-signed ed25519 CA.
pub fn ca(cn: &str) -> TestCa {
let key = KeyPair::generate_for(&PKCS_ED25519).unwrap();
let mut params = CertificateParams::new(vec![]).unwrap();
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params.distinguished_name.push(DnType::CommonName, cn);
let cert = params.self_signed(&key).unwrap();
let der = cert.der().to_vec();
TestCa {
fingerprint: sha256_hex(&der),
der,
issuer: rcgen::Issuer::new(params, key),
}
}
/// A leaf ed25519 certificate issued by `ca`.
pub fn issue_leaf(cn: &str, ca: &TestCa) -> TestSigner {
let key = KeyPair::generate_for(&PKCS_ED25519).unwrap();
let mut params = CertificateParams::new(vec![]).unwrap();
params.distinguished_name.push(DnType::CommonName, cn);
let cert = params.signed_by(&key, &ca.issuer).unwrap();
let der = cert.der().to_vec();
TestSigner {
fingerprint: sha256_hex(&der),
cert_der: der,
key_pkcs8: key.serialize_der(),
}
}
/// A pinned-fingerprint critical policy: 2 distinct approvers (security-reviewer +
/// system-owner), a distinct trusted release signer, SBOM required, expiry required.
pub fn pinned_policy(
reviewer: &TestSigner,
owner: &TestSigner,
release: &TestSigner,
) -> TrustPolicy {
TrustPolicy {
schema_version: POLICY_SCHEMA.to_string(),
// Pinning is what these bundles are built for, and it is now SAID rather
// than inferred from an empty root list.
trust_mode: Some(TrustMode::PinnedSigners),
trusted_roots: vec![],
certificate_validity: CertificateValidity::Enforce,
approved_signers: vec![
ApprovedSigner {
fingerprint: reviewer.fingerprint.clone(),
subject: None,
roles: vec!["security-reviewer".into()],
},
ApprovedSigner {
fingerprint: owner.fingerprint.clone(),
subject: None,
roles: vec!["system-owner".into()],
},
],
required_approval_roles: vec!["security-reviewer".into(), "system-owner".into()],
minimum_distinct_approvers: 2,
trusted_release_signers: vec![release.fingerprint.clone()],
release_signer_must_be_distinct: true,
maximum_bundle_age_secs: None,
require_expiry: true,
on_expired_metadata: Some(ExpiredPosture::ReadOnly),
require_sbom: true,
require_vex: false,
require_scan: false,
allowed_products: vec![],
allowed_models: vec![],
}
}
#[allow(dead_code)] // the signer handles are available to tests that need them
pub struct BuiltBundle {
pub members: MemberMap,
pub policy: TrustPolicy,
pub reviewer: TestSigner,
pub owner: TestSigner,
pub release: TestSigner,
pub candidate_digest: String,
pub bundle_id: String,
pub now: DateTime<Utc>,
}
/// Build a valid, two-approval, release-signed bundle (pinned mode) at `sequence`.
pub fn valid_bundle_seq(sequence: u64, bundle_id: &str) -> BuiltBundle {
build_valid_bundle(sequence, bundle_id, None, "pass")
}
/// A bundle that is signed, approved and released exactly like [`valid_bundle`],
/// except that its recorded policy verdict is `verdict`. Every signature in it is
/// genuine, so the verdict is the ONLY reason a verifier may refuse it.
#[allow(dead_code)] // used by tests::verdict only
pub fn valid_bundle_with_verdict(verdict: &str) -> BuiltBundle {
build_valid_bundle(5, "bundle-0005", None, verdict)
}
/// A valid bundle whose single payload is `payload` instead of the default
/// `b"firmware-v1"` — for the bomb-shape tests, which need a payload whose SIZE
/// (or whose stored bytes) is the thing under test.
#[allow(dead_code)] // used by tests::bomb only
pub fn valid_bundle_with_payload(payload: Vec<u8>) -> BuiltBundle {
build_valid_bundle(5, "bundle-0005", Some(payload), "pass")
}
fn build_valid_bundle(
sequence: u64,
bundle_id: &str,
payload: Option<Vec<u8>>,
verdict: &str,
) -> BuiltBundle {
let reviewer = self_signed("security-reviewer");
let owner = self_signed("system-owner");
let release = self_signed("release-signer");
let now = Utc::now();
let mut prep = base_preparation(sequence, bundle_id, now);
if let Some(bytes) = payload {
prep.payloads[0].bytes = bytes;
}
prep.policy_result.verdict = verdict.to_string();
let candidate = prep.build().unwrap();
let candidate_digest = candidate.candidate_digest.clone();
let mut members = candidate.members;
add_approval(
&mut members,
"security-reviewer",
&reviewer.cert_der,
&reviewer.key_pkcs8,
None,
now,
)
.unwrap();
add_approval(
&mut members,
"system-owner",
&owner.cert_der,
&owner.key_pkcs8,
None,
now,
)
.unwrap();
let policy = pinned_policy(&reviewer, &owner, &release);
if verdict == "pass" {
finalize(
&mut members,
&policy,
&release.cert_der,
&release.key_pkcs8,
now,
)
.unwrap();
} else {
// `finalize` refuses to release-sign a bundle whose reviewers did not
// pass it, so a hostile or careless release signer is modelled by hand:
// the same three members `finalize` writes, with a genuine signature.
members.insert(
crate::schema::RELEASE_CRT.to_string(),
release.cert_der.clone(),
);
let manifest =
crate::manifest::ReleaseManifest::build(&members, bundle_id, &candidate_digest);
let manifest_bytes = manifest.to_json_bytes().unwrap();
let sig = crate::signer::sign_pkcs8(&release.key_pkcs8, &manifest_bytes).unwrap();
members.insert(
crate::schema::RELEASE_MANIFEST_JSON.to_string(),
manifest_bytes,
);
members.insert(crate::schema::RELEASE_SIG.to_string(), sig);
}
BuiltBundle {
members,
policy,
reviewer,
owner,
release,
candidate_digest,
bundle_id: bundle_id.to_string(),
now,
}
}
pub fn valid_bundle() -> BuiltBundle {
valid_bundle_seq(5, "bundle-0005")
}
pub fn base_preparation(sequence: u64, bundle_id: &str, now: DateTime<Utc>) -> Preparation {
Preparation {
bundle_id: bundle_id.to_string(),
sequence,
created_at: now,
expires_at: Some(now + Duration::days(30)),
producer: "intake-station".into(),
supplier: "ExampleCorp".into(),
product: "ExampleDrive".into(),
release_version: "1.2.3".into(),
supported_hardware_models: vec!["ACS880".into()],
supported_hardware_revisions: vec!["R4".into()],
source_provenance: SourceProvenance {
source_kind: "vendor-portal".into(),
source_uri: Some("https://vendor.example/downloads/fw".into()),
source_repository: None,
source_asset_path: None,
fetched_at: Some(now),
reported_size: Some(11),
vendor_checksum: None,
vendor_signature_status: Some("unverified".into()),
tls_origin: Some("vendor.example".into()),
transfer_receipt_digest: None,
},
policy_result: PolicyResult {
policy_digest: sha256_hex(b"trust-policy-v1"),
advisory_snapshot_digest: None,
verdict: "pass".into(),
non_production: true,
},
supersedes: vec![],
withdraws: vec![],
payloads: vec![PayloadInput {
relative_path: "firmware.bin".into(),
bytes: b"firmware-v1".to_vec(),
media_type: "application/octet-stream".into(),
}],
evidence: vec![
EvidenceInput {
relative_path: "sbom/app.cdx.json".into(),
bytes: br#"{"bomFormat":"CycloneDX","specVersion":"1.5"}"#.to_vec(),
media_type: "application/json".into(),
kind: EvidenceKind::Sbom,
},
EvidenceInput {
relative_path: "source-receipt.json".into(),
bytes: br#"{"mode":"Strict","complete":true}"#.to_vec(),
media_type: "application/json".into(),
kind: EvidenceKind::SourceReceipt,
},
],
}
}
/// Hex-encode a byte slice (used to embed a CA DER into a trust policy).
pub fn hex(bytes: &[u8]) -> String {
hex_lower(bytes)
}