Provenance Trust Model (WS-D)¶
Zeroth records two kinds of provenance:
- Deployment attestations — a stable snapshot digest per deployment version
(
deployment_versions.attestation_digest), signed with a keyed signature. - Audit chain — each
NodeAuditRecordhashes its own content and pins its predecessor's digest, and each record's digest is signed.
Both layers separate two independent guarantees. Read them as a stack, not a single "verified" bit.
The two axes¶
| Axis | Mechanism | Catches | Does not catch |
|---|---|---|---|
| Digest (tamper-evident) | unkeyed SHA-256 recomputed from the live snapshot / chained across records | accidental corruption, a naive edit, a broken predecessor link | a malicious writer who edits the data and recomputes the digest |
| Signature (tamper-resistant) | keyed signature over the digest | forgery by anyone without the signing key | anything the key holder chooses to sign (see custody, below) |
Neither masks the other. A verification result reports both:
verified/digest_verified— the digest axis.signature_verified— the signature axis, three-state:true— every signed record/attestation in scope verified under a trusted key;false— a signature is present and failed (tampered or wrong key);null— unsigned-legacy: nothing in scope was signed. Render this neutral, never green and never red. It asserts only that the digest chain is intact.
What a valid signature asserts, per mode¶
Configured via ZEROTH_PROVENANCE__MODE (env | kms | off). The signing key
resolves through the shared WS-F SecretProvider (logical name
signing.deployment), never a second env reader.
Note on
algorithm.ZEROTH_PROVENANCE__ALGORITHMis advisory — the effective algorithm is derived frommode(env→ HS256,kms→ Ed25519). Setting it inconsistently withmodedoes not change the algorithm actually used; it exists for documentation/telemetry only.
mode="env" — HMAC-SHA256 (default, dev)¶
- Key location: the shared secret provider resolves
signing.deployment(env:SIGNING_DEPLOYMENT, or the tenant-scopedZEROTH_SECRET__{TENANT}__SIGNING_DEPLOYMENT). With the Vault backend the same logical name resolves from the KV mount. - What a valid signature asserts: "a holder of the shared HMAC key produced this." It is key-custody-bounded.
- What it is NOT: this is a symmetric secret. Anyone who can read the key can forge an identical signature. It is NOT PKI and NOT non-repudiation — it cannot prove which party signed, only that someone with the key did. Do not present env-HMAC provenance as cryptographic non-repudiation to a third party.
- No key configured: the process runs unsigned-legacy after a startup
warning. Records verify as
signature_verified = null(neutral). This keeps dev boxes and the existing test suite working without minting rows that read as signed. It is intentionally not silent — the warning is the tell.
mode="kms" — Ed25519 (the strong claim)¶
- Key location: the private key resolves through the secret provider (e.g. a
Vault mount); the public verify keys are carried in
ZEROTH_PROVENANCE__PUBLIC_KEYS_JSONas{key_id: public-key-hex}. - What a valid signature asserts: "the holder of the private key for
key_idsigned this digest," and — because verification needs only the public key — a verifier who never holds the private key cannot forge. This is the path that backs a "signed & verifiable" claim to an external party. - Non-repudiation in the full sense requires the private key to live in an
external KMS/HSM that signs without exposing the key material. The
Ed25519Signerinterface supports that split (public keys for verify, private key held elsewhere); wiring a specific managed-KMS backend that signs remotely is deferred — a locally-held Ed25519 private key is asymmetric-forgery-resistant but its custody story is only as strong as the store it resolves from.
mode="off" — signing disabled¶
- Uses a
NullSigner: it produces no signature (records stay unsigned-legacy) andverifyalways returnsfalse, so a misconfiguration can never mint a row that reads as verified. Records verify assignature_verified = null(neutral).
Fail-closed posture¶
- The signature is taken over
signable_bytes(digest, key_id, algorithm)— the key id and algorithm are inside the signed bytes. An attacker who rewrites the storedsigning_key_idto point at a weaker or attacker-held key cannot make the old signature re-verify (downgrade/substitution is closed). - The digest is byte-identical whether or not a record is signed (signature fields are excluded from the digest input). Adding a signature never retroactively breaks a pre-signing record's stored digest.
- Missing key material on the strong path (
mode="kms") fails closed at startup (raises), mirroring the Vault secret backend. The dev path (mode="env"with no key) degrades to unsigned-legacy + warning rather than crashing.
Key rotation¶
Signers verify by the key_id a signature claims, so a retired key can stay
verify-only after rotation: point the active signing_key_id at the new key
and keep the old one available to verify, and previously-signed records continue
to verify while new records are signed under the new key.
Signing and verification are built as separate providers, because they have different lifetimes. The signer answers "what do we sign with now", and rotation moves that answer forward. The verifier answers "was this signed by a key we recognise", which stays true for keys that stopped signing long ago. Building one provider for both would make a rotation retroactively unverify every record written before it.
Name the retired keys per mode:
mode="kms"—public_keys_jsonalready carries every acceptable verify key; add the retiredkey_idthere and it keeps verifying.mode="env"—retired_keys_json, a{key_id: key-material}object. This is verify-only: signing always usessigning_key_id, so a key listed here can never mint a new record.
The verifier is built even when signing is off (mode="off", or env with no
resolvable key). A deployment that stops signing still holds records it signed
earlier, and those do not become unverifiable because new ones are unsigned.
Retention is not cosmetic. Verification is what the run-attestation 409 uses to
decide whether a stored row's digest and expiry may be disclosed at all, so a
rotation performed without naming the retired key makes legitimate conflicts
answer opaquely — fail-closed, but it costs a caller evidence it was entitled to.
Rounding out the honest claim¶
- "Deployment provenance" and "tamper-evident audit" are true at the digest layer today for every deployment.
- "Signed & verifiable" provenance is true under
mode="env"bounded by key custody (not non-repudiation), and reaches third-party-verifiable / non-repudiation strength only on themode="kms"Ed25519 + external-KMS path. - The console badge and the API responses reflect exactly this: unsigned-legacy is shown neutral, a present-and-valid signature green, a present-and-invalid signature red.