Skip to content

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:

  1. Strip comments — whole-line #… and inline #… outside quoted spans.
  2. Reject anchors/aliases (&, * at value start) — canonical form must be alias-free.
  3. Parse as YAML 1.2 into a JSON-like value (timestamps remain strings).
  4. In policy_identity, blank the self-referential fields: signature"", bim_digest"", and ed25519_signature"" (when present). ed25519_pubkey is not blanked — the signature covers the key binding.
  5. Normalize recursively: null stays a distinct JSON null (the canonical form is injective — a signature over field: null must not verify field: ""); 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/ 010 are strings, not bool/int) — an independent verifier must match this.
  6. Serialize as compact JSON (sorted keys, ,/: separators, UTF-8). Numbers render exactly as Rust serde_json (see litatoli/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:

  1. registry — parse registry.json; canonical bytes = sorted-key compact JSON of payload; verify signature with payload.root_pubkey; require payload.root_pubkey == pinned root.
  2. bim — recompute the canonical bytes (§2); verify the embedded Ed25519 signature; require the signer key to be registry-authorized for role bim at attestation_proof.signed_at (fallback: registry issued_at); recompute bim_digest.
  3. chain — litatoli FORMAT.md §1–§4: strict seq, prev_hash linkage, per-entry Ed25519 over the canonical signing payload, each signer registry-authorized for role evidence at the entry's timestamp, any checkpoint entry's Merkle root recomputed.
  4. head_checkpoint (anti-truncation) — the log MUST end with a checkpoint entry 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.
  5. cross_anchor — all of:
  6. manifest.model_digest == BIM workload_identity.model_digest;
  7. when the model is bundled: blake3(model file) == manifest.model_digest;
  8. every ai_inference entry's payload.bim_digest == recomputed BIM digest and payload.model_digest == manifest.model_digest;
  9. at least one ai_inference entry 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:

  1. Each head is published as an in-toto Statement whose single subject.digest.sha256 is 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.
  2. The entry with the highest logIndex is the log's latest head.
  3. 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 lower logIndexrollback; 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.