Signed multi-approval bundles in znippy — design
Status: design, not yet built. Drives five concrete additions to
znippy-common/src/sign.rs(featuresign) plus one new plugin. Source-of-truth order applies: executable tests outrank this document.
1. Why
holger's Secure Update Gateway MVP (holger/docs/secure-update-gateway-mvp.md) moves an approved release across an airgap as one *.holger.znippy file. Its security rests on a two-person rule: two distinct humans in distinct roles each sign the release candidate, and a third distinct release signer seals it.
holger implements that today with its own ed25519-dalek + x509-parser stack and a tree of loose JSON/.sig/.crt files inside the archive. znippy already has the harder half — detached CMS, M-of-N threshold, distinct-signer counting, chain-to-roots, replay-proofing — so holger is carrying a second implementation of znippy's own mechanism.
This design moves the mechanism wholly into znippy and leaves holger only the policy (which roles are required, who may hold them, bundle lifecycle).
2. What already works — do not rebuild
| capability | where |
detached CMS SignedData, eContent absent (RFC 5652 §5.2) |
sign.rs |
ed25519 (RFC 8419) + P-256, algorithm read from SignerInfo |
sign.rs |
sign_multi / verify_threshold — M-of-N |
sign.rs:850 |
| distinct-signer counting (the two-person control) | verify_threshold_inner |
chain leaf → CertStore roots, pure Rust, no OpenSSL |
sign.rs:497 |
loud M=0 opt-out (verify_threshold_allowing_unsigned) |
sign.rs:909 |
| replay-proofing — a CMS over a different root is not counted | verify_threshold_inner |
SignatureSet sidecar blob, backward compatible with bare CMS |
sign.rs:793 |
| append into a sealed archive, blobs reused verbatim | append_files / znippy append |
The load-bearing property: archive_root folds ARCHIVE_TAG, the footer kind (not its offset), and the sorted (path, digest) set. It is therefore independent of layout — re-sealing, re-laying-out, moving the manifest all leave the root untouched. Only a change to the file set moves it.
3. The five additions
3.1 Caller-supplied signed attributes
build_signed_attrs is hardwired to contentType + messageDigest. Add a variant taking extra Attributes so a claim (e.g. an approval role) is signed, not merely adjacent:
pub fn sign_digest_with_attrs(
root: &[u8; 32],
extra: &[Attribute],
signer: &dyn ArchiveSigner,
) -> Result<Vec<u8>>;
Backward compatible: verification asserts only messageDigest and contentType and ignores unknown attributes, so old verifiers accept new signatures and old signatures keep verifying.
3.2 fingerprint on SignerId
pub struct SignerId {
pub fingerprint: [u8; 32], // NEW — sha256(cert DER)
pub subject: String,
pub common_name: String,
}
verify_threshold_inner currently dedups on subject. Two certificates can share a subject, so subject-distinctness is weaker than it looks for a two-person control. Key the BTreeMap on fingerprint.
3.3 Verify returns what it verified
verify_digest validates signedAttrs and then discards them. A caller cannot read a role claim it can trust. Return them:
pub struct VerifiedSigner { pub id: SignerId, pub attrs: SetOfVec<Attribute> }
pub fn verify_digest(root: &[u8;32], cms: &[u8], roots: &CertStore) -> Result<VerifiedSigner>;
3.4 Per-signer results out of the threshold check
ThresholdReport reports a count. A policy layer needs the identities:
pub struct ThresholdReport {
pub threshold: u32,
pub signers: Vec<VerifiedSigner>, // was: Vec<SignerId>
pub distinct_valid: usize,
pub accepted_without_signatures: bool,
}
Role logic stays out of znippy: "one security-reviewer AND one system-owner" is policy, and policy belongs to the caller.
3.5 The holger-bundle plugin — approvals as Arrow rows
Modelled on znippy-plugin-skidbladnir (airgap-bundle metadata; classifies member paths, contributes Arrow columns).
fn schema_fields() -> Vec<Field> {
vec![
Field::new("bundle_role", DataType::Utf8, true), // security-reviewer | system-owner | release
Field::new("signer_fp", DataType::FixedSizeBinary(32), true),
Field::new("signer_subject", DataType::Utf8, true),
Field::new("approved_at", DataType::Utf8, true), // RFC 3339 UTC
Field::new("decision", DataType::Utf8, true),
Field::new("comment", DataType::Utf8, true), // NEVER authorization input
Field::new("cms", DataType::Binary, true), // the detached CMS blob
Field::new("evidence_kind", DataType::Utf8, true), // sbom | vex | scan | advisory | exception
]
}
Why rows and not files. archive_root folds only (path, digest) pairs. An approval stored as a row is not a file, so it never enters file_digests — the root is untouched by construction when an approval is appended. No exclusion-prefix, no second "core root" variant. This is the whole reason the append-signing scheme is sound.
4. The resulting flow
prepare → znippy pack → archive_root fixed, forever
approve #1 → sign_digest_with_attrs(root, role=security-reviewer)
→ znippy append: one ROW carrying the CMS
approve #2 → same, role=system-owner
finalize → same, role=release
Each append re-seals the index and reuses every existing blob byte verbatim. The root never moves, so every earlier signature still verifies — the archive carries an append-only approval history, and nothing is ever rewritten.
Verification:
let root = znippy::archive_root_of(&path)?;
let report = znippy::verify_threshold(&root, &set, &roots)?; // M distinct, by fingerprint
// everything past this line is the CALLER's policy
5. What this deletes from holger
| holger has today | after |
ed25519-dalek, x509-parser + its own chain check |
gone — znippy's |
| its own distinct-signer logic | gone — verify_threshold |
release-manifest.json ({path,size,sha256,blake3} per member) |
gone — the Arrow index IS that |
signatures/<role>.<fp>.crt |
gone — CMS carries a CertificateSet |
signatures/<role>.<fp>.sig |
a cms Binary column |
approvals/<role>.<fp>.approval.json |
Arrow rows |
holger keeps exactly two things: authorize (trust policy → fingerprints → roles, distinct-role rule, release-signer-distinct) and admit (sequence, expires_at, product/model allowlists, evidence presence, anti-rollback).
6. A property Henrik's design could not offer
Every sub-index is a valid Arrow IPC file, so on the protected node:
SELECT bundle_role, signer_subject, approved_at FROM 'bundle.znippy';
Who approved what, and when — a query in DuckDB / Polars / DataFusion, with no unpack, no JSON parse, and no holger binary present. JSON stays available as an output format, not a storage format.
7. Non-goals
Roles, trust policy, anti-rollback, evidence semantics and bundle lifecycle stay in holger. znippy gains no notion of "approval" beyond a signed attribute it faithfully returns. znippy must not learn what a system-owner is.
8. Test obligation (LAW 2 — never accept a green you have not seen red)
Each must be proved to FAIL before it is trusted to pass:
- two certs sharing a subject but with different fingerprints count as two distinct signers (fails today — dedup is by subject);
- a CMS over a different root is not counted (guard the existing property);
- appending an approval row leaves
archive_rootbyte-identical; - a signature made before an append still verifies after it;
- an unknown signed attribute does not break an old verifier, and a new verifier returns it;
- M=0 cannot pass through
verify_threshold; - the anchor: a bundle with only ONE approval must FAIL a 2-of-N policy — without this the whole suite can pass vacuously.