Proof bundle format — specification (v1)¶
A bundle is the portable, auditor-facing artifact: one directory that a
third party verifies offline, pinning only a root public key, with no
shared secret and no dependency on the tooling that produced it. The chain
and registry semantics inside are specified in litatoli/docs/FORMAT.md; this
document adds the bundle layout, the BIM canonicalization, and the
cross-anchoring rules.
An independent reference verifier — no pimatika/litatoli code, ~900 lines of
Python (blake3 + cryptography) — lives at tools/verify_bundle.py and
implements exactly this document.
1. Layout¶
bundle/
manifest.json # index + cross-check digest
bim.yaml # the signed Build Intent Manifest
evidence.jsonl # the chained evidence log (litatoli format)
registry.json # root-signed key registry (rotation mode)
model.onnx # optional: the model artifact itself
manifest.json:
{ "bundle_version": "1",
"bim": "bim.yaml",
"evidence": "evidence.jsonl",
"registry": "registry.json",
"model": "model.onnx",
"model_digest": "blake3:<hex>",
"created_at": "YYYY-MM-DDTHH:MM:SSZ" }
registry and model are optional; model_digest is always present so the
cross-check runs even when the artifact itself is not shipped.
2. BIM canonicalization (what the BIM's Ed25519 signature signs)¶
Starting from the YAML text:
- Strip comments — whole-line
#…and inline#…outside quoted spans. - Reject anchors/aliases (
&,*at value start) — canonical form must be alias-free. - Parse as YAML 1.2 into a JSON-like value (timestamps remain strings).
- In
policy_identity, blank the self-referential fields:signature→"",bim_digest→"", anded25519_signature→""(when present).ed25519_pubkeyis not blanked — the signature covers the key binding. - Normalize recursively:
nullstays a distinct JSONnull(the canonical form is injective — a signature overfield: nullmust not verifyfield: ""); finite floats are forbidden (reject;.inf/.nan→ null); strings NFC-normalized; object keys NFC-normalized and sorted. Scalars are resolved with YAML 1.2 core-schema semantics (e.g.off/yes/1_000/010are strings, not bool/int) — an independent verifier must match this. - Serialize as compact JSON (sorted keys,
,/:separators, UTF-8). Numbers render exactly as Rustserde_json(seelitatoli/docs/FORMAT.md§2); a numeric literal that overflows f64 falls back to a string (as serde_yaml does), integers outside [i64::MIN, u64::MAX] are rejected, and a literal tab outside quotes is forbidden (use spaces — a strict YAML scanner rejects it). Duplicate fields in the typed structs (manifest, registry, evidence entry top level) are rejected; the registry's canonical bytes come from its schema (null Options and unknown fields dropped).
Then:
- bim_digest = blake3:<hex of canonical bytes>;
- ed25519_signature (in policy_identity) = Ed25519 over the canonical
bytes by ed25519_pubkey;
- the signing time is attestation_proof.signed_at (stamped at signing).
3. Verification algorithm (root/rotation mode)¶
Pin: the root public key, obtained out of band. Then:
- registry — parse
registry.json; canonical bytes = sorted-key compact JSON ofpayload; verifysignaturewithpayload.root_pubkey; requirepayload.root_pubkey== pinned root. - bim — recompute the canonical bytes (§2); verify the embedded
Ed25519 signature; require the signer key to be registry-authorized for
role
bimatattestation_proof.signed_at(fallback: registryissued_at); recomputebim_digest. - chain — litatoli
FORMAT.md§1–§4: strict seq,prev_hashlinkage, per-entry Ed25519 over the canonical signing payload, each signer registry-authorized for roleevidenceat the entry'stimestamp, anycheckpointentry's Merkle root recomputed. - head_checkpoint (anti-truncation) — the log MUST end with a
checkpointentry covering every preceding entry. This rejects dropping the tail (the last entry is then not a checkpoint) and any count/Merkle mismatch (step 3). Freshness (no rollback to an earlier checkpoint) is a separate, witnessed property — see §5.--expect-head <merkle>pins the true head out of band and rejects any prior-checkpoint head with no service. - cross_anchor — all of:
manifest.model_digest== BIMworkload_identity.model_digest;- when the model is bundled:
blake3(model file)==manifest.model_digest; - every
ai_inferenceentry'spayload.bim_digest== recomputed BIM digest andpayload.model_digest==manifest.model_digest; - at least one
ai_inferenceentry exists.
Any failed check ⇒ the bundle is rejected. Every byte that a third party can
verify is signature-bound: flipping one byte anywhere in a signature-covered
field (including an entry's key_id/algorithm, which the signing payload
covers) breaks the Ed25519 chain; dropping the tail breaks head_checkpoint.
4. What this does and does not prove¶
Proves (provenance): these inferences, on these inputs, produced these outputs, under this signed build intent, with this exact model artifact, signed by keys a pinned root authorized at the time — and none of it was altered afterwards.
Does not prove: that the decisions are correct or safe (provenance ≠ correctness); nor that every inference was recorded (integrity of what was recorded, not exhaustiveness — the cooperative-host boundary).
5. Two verification tiers: integrity/provenance vs freshness¶
Verification comes in two tiers, by design — the same split every transparency log makes (Certificate Transparency, Rekor, …):
-
Offline (this document). With only the bundle + the pinned root, a verifier proves integrity and provenance: the entries present are authentic, consistent, cross-anchored to the signed build, and unaltered. This needs no network and no trust in us. It cannot prove freshness — that no later evidence was withheld by rolling the log back to an earlier checkpoint. That is not a gap in the design; proving the absence of hidden entries is impossible from a self-contained file (the truncated prefix is itself a valid state), which is exactly why transparency logs rely on an external witness.
-
Witnessed / online (freshness). To also prove freshness, the producer publishes each checkpoint head to an append-only external witness, and the auditor compares the bundle's terminal checkpoint against the witnessed latest head — a rolled-back bundle is behind the witness and is rejected. The witness is pluggable and the end user chooses whom to trust for freshness: a managed / self-hosted head-witness or a third-party public transparency log.
--expect-head <merkle>is the minimal bridge: pin the true head obtained from any trusted channel and rollback is rejected with no service.
5.1 Witness backends (--witness)¶
Both CLIs and the independent Python verifier accept the same backend specs:
| Spec | Backend | Trust for freshness | Pin (--witness-pubkey) |
|---|---|---|---|
file:<path> |
append-only JSONL registry on disk | the file (shared channel) | not enforceable — the registry stores raw heads with no witness signature; the Python verifier fails closed if a pin is supplied (the Rust CLI checks it against its local signing key) |
http(s)://<url> |
self-hosted or managed head-witness | the witness operator's key | witness Ed25519 pubkey (required) |
rekor:[url] |
public Sigstore transparency log (bare rekor: = production) |
with a pin: the public log only; unpinned: TOFU on the queried endpoint | log key fingerprint / logID (optional but strongly recommended) |
The producer submits with litatoli-cli witness-submit --witness <spec> --log-file
<evidence.jsonl> (after checkpoint); the auditor adds --witness <spec> to
verify-bundle.
Head-witness model (file: / http(s)://). The witness is a monotonic,
per-workload registry of heads; latest returns the recorded [Head] plus the
witness's own Ed25519 signature. The auditor pins the witness key and rejects
witnessed.seq > bundle.seq (rollback), a differing head at the same seq (fork),
or a missing record (no_record).
Rekor model (rekor:). A Rekor dsse entry stores only hashes, so freshness
is decided by hash + global ordering, not a returned signed head:
- Each head is published as an in-toto Statement whose single
subject.digest.sha256is a stable per-workload anchor =SHA-256("litatoli-head-witness\0" ‖ workload_id), signed into a DSSE envelope by the workload's evidence key. Rekor indexes entries by subject digest, so the anchor enumerates every head ever published for that workload. - The entry with the highest
logIndexis the log's latest head. - The auditor recomputes the bundle head's
payloadHash(SHA-256 of the canonical Statement bytes) and compares: equals the latest entry → fresh; present at a lowerlogIndex→ rollback; absent → not_witnessed.
For each relied-upon entry the auditor verifies the log's SET (Signed Entry
Timestamp: ECDSA-P256-SHA256 over the canonical {body,integratedTime,logID,
logIndex}) with the log's public key (GET /api/v1/log/publicKey). The pin
(--witness-pubkey = the log's logID) is enforced against the SHA-256 of the
fetched key's DER — the key actually used for SET verification — so a MITM
endpoint cannot substitute its own key and still satisfy the pin. With a pin, the
only trust assumption is that the public log does not equivocate — the standard
transparency-log assumption its own witness network exists to catch — not any
trust in the tooling vendor or the TLS connection. Unpinned, the log key is
taken TOFU-style from the queried endpoint, so the verdict only proves that
endpoint's self-consistency (the report says so explicitly); pin whenever the
verdict matters. Since the per-workload anchor is computable by anyone from the
public workload_id, enumeration only considers entries whose DSSE verifier is
the bundle's own evidence key (the verifier list is inside the SET-signed
canonical body), so a third party publishing under the same anchor cannot force a
false rollback/not_witnessed verdict. The
canonical Statement bytes are byte-identical across the Rust and Python
implementations (locked by a cross-impl payloadHash test), so an auditor can pick
either verifier.
So: offline = integrity + provenance; witnessed = + freshness. The residual is the boundary between the tiers, not a defect.