# Signed multi-approval bundles in znippy — design
> **Status:** design, not yet built. Drives five concrete additions to
> `znippy-common/src/sign.rs` (feature `sign`) 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 `Attribute`s so a claim (e.g. an approval role) is signed,
not merely adjacent:
```rust
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`
```rust
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:
```rust
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:
```rust
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).
```rust
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:
```rust
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:
```sql
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:
1. two certs sharing a subject but with different fingerprints count as **two**
distinct signers (fails today — dedup is by subject);
2. a CMS over a *different* root is not counted (guard the existing property);
3. appending an approval row leaves `archive_root` **byte-identical**;
4. a signature made before an append still verifies after it;
5. an unknown signed attribute does not break an old verifier, and a new
verifier returns it;
6. M=0 cannot pass through `verify_threshold`;
7. the anchor: a bundle with only ONE approval must FAIL a 2-of-N policy —
without this the whole suite can pass vacuously.