# Provenance signing — design
> **Class:** `design` · siblings: [`signing-guide.md`](signing-guide.md) · [`plugins-design.md`](plugins-design.md) · [`iceberg-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`).