Provenance signing — design
Class:
design· siblings:signing-guide.md·plugins-design.md·iceberg-design.md. Strict: this file states only the shape we run — the merkle fold, the signers, the manifest sections. No alternatives, no why.
f0 — detached authenticity over blake3 integrity (the whole)
Every znippy chunk already carries a 32-byte blake3 checksum in the base index — that is integrity. Signing adds authenticity on top: an optional, detached CMS signature, computed as a merkle fold of the per-chunk hashes already in hand — no content is re-read.
| law | statement | enforced by |
| S-1 | signing folds the existing per-chunk blake3 hashes; no content re-read | file_digest* fold ordered chunk hashes (sign.rs:108,:122) |
| S-2 | signing is off by default; unsigned archives are byte-unchanged | feature sign; blob/compress path untouched |
| S-3 | any change to a chunk hash or artifact changes the archive root | archive_root fold (sign.rs:141) |
| S-4 | the merkle root is stable across seal |
seal_signed_archive_round_trip |
Reuse map: signing depends on the CMS/PKCS#8/X.509 stack and the blake3 hashes the writer already computed. It is used by the writer (via with_signer) and by verify. It touches no blob bytes — the compression path is entirely independent.
f1 — the merkle fold
znippy-common/src/sign.rs. Three folds, bottom-up, over hashes already computed:
| fn | at | folds |
file_digest(file) |
sign.rs:108 |
a file's ordered chunk blake3 hashes → one file digest |
file_digest_from_parts(relative_path, ordered_chunks, count) |
sign.rs:122 |
same, from explicit parts (path + ordered chunks + count) |
archive_root(file_digests, footer) |
sign.rs:141 |
all file digests + the footer → the single archive root |
The signature is over the archive root, so tampering with any chunk hash or artifact changes the root and breaks verification (tamper_chunk_hash_breaks_verification, archive_root_changes_when_an_artifact_changes, tampering_a_signed_archive_is_detected).
f2 — the signers
znippy-common/src/sign.rs:
| item | at | is |
trait ArchiveSigner |
sign.rs:167 |
the signing seam — signs the archive root |
Ed25519Signer |
sign.rs:174 |
ed25519 signer |
EcdsaP256Signer |
sign.rs:202 |
p256 signer (default algorithm) |
signer_from_pkcs8(…) |
sign.rs:231 |
builds a signer from a PKCS#8 DER key + X.509 cert |
Algorithm is p256 (default) or ed25519.
f3 — where the signature lives
The detached CMS signature is written as reserved manifest sections through znippy-common/src/meta_sink.rs:107 ArrowIpcSink::with_signer(signer). Because the sections are reserved and detached, an unsigned archive is byte-identical whether or not the sign feature is compiled in — the signer only adds sections, never rewrites blobs.
Signed archives round-trip through seal: the merkle root is content-derived and stable, so a sealed signed archive still verifies (seal_signed_archive_round_trip, holger_verifies_a_served_artifact).