Skip to content

Export format v1

This page specifies the interchange contract between the writer and the verifier: the run_*.jsonl export file that behalf-verify checks offline. A later revision of the format will add checkpoint and inclusion-proof material from the transparency log under a new format string; files in this format remain readable.

Two invariants from the receipt schema govern everything: stored bytes are the signed bytes are the hashed bytes (no canonicalization step exists — DSSE/PAE), and the verifier needs no network and no call to behalf.

One deliberate deviation from standard DSSE: the payload is embedded as plaintext JSON, not base64, so that tampering with the file directly edits receipt content — which the verifier must catch. Signing and hashing are still over exact bytes — the byte span of the payload value as it appears in the file — so there is still no canonicalization: the writer emits the payload bytes it signed, verbatim, and the verifier extracts that exact span from the raw line before doing anything else.

UTF-8, one JSON object per line (\n separators, no CR). Three line kinds, in order:

  1. Header — exactly one, first line.
  2. Leaf — one per receipt, index strictly ascending from 0, no gaps.
  3. Head — exactly one, last line.
{"kind":"header","format":"behalf.sh/export/v1","log_origin":"<origin string>","keys":[{"jkt":"<RFC 7638 thumbprint, base64url>","jwk":{"kty":"OKP","crv":"Ed25519","x":"<base64url>"}}]}

keys carries every Ed25519 public key referenced by any signature in the file, keyed by its RFC 7638 JWK thumbprint (SHA-256, base64url, no padding). Verification checks signatures against these embedded keys; key provenance (a published key log) is a later milestone, and the verifier’s output must not claim it.

tokens — delegation hop tokens (optional)

Section titled “tokens — delegation hop tokens (optional)”
{"kind":"header", "…":"…", "tokens":{"sha256:<hex>":"<compact JWS>"}}

Each entry is one delegation hop’s compact JWS, keyed by exactly the string that hop’s verification.evidence_ref carries. Keying by the whole reference rather than by the bare digest is deliberate: a reader looks the token up by the value the receipt already holds, with no string surgery in between, so there is no opportunity to reconstruct the key slightly differently on one side and miss.

The address is the integrity property. A reader MUST check that each token digests to the key it sits under — sha256: + lowercase-hex SHA-256 over the compact JWS’s ASCII bytes, all three segments and both dots included, the same preimage as a hop’s par_hash. A mismatch makes the file unverifiable (exit 2), not merely suspicious: a token stored at an address that is not its own digest is an attempt to keep a receipt’s verified claim while swapping the evidence underneath it.

The member is optional and hops deduplicate — a run’s 47 receipts typically share three hops, so the section grows with distinct hops rather than with receipts. An export written before this section existed carries none, and §2’s rule that unknown members MUST be ignored means such a file verifies exactly as it always did. The format string does not move.

An entry is absent for two legitimate reasons, neither of which is a break: a hop that arrived caller-asserted has no token and carries no evidence_ref at all, and a depth-0 root’s evidence_ref may name the signed login statement rather than a hop token. A verifier reports these as unchecked, never as findings.

{"kind":"leaf","index":31,"payloadType":"application/vnd.behalf.receipt+json","payload":{…receipt JSON…},"sig":{"keyid":"<emitter jkt>","sig":"<base64std>"},"leaf_hash":"<hex>"}
  • payload is the receipt object per receipt-v1.schema.json, embedded as plaintext.
  • payload_bytes is defined as the exact byte span of the payload value in the raw line — from its opening { to its matching closing } inclusive. The writer MUST splice the payload bytes it signed into the line unmodified (no re-serialization, no re-indentation). The verifier MUST extract the span from the raw line bytes with a scanner that respects JSON strings and escapes — it MUST NOT parse-and-reserialize.
  • pae = PAE(payloadType, payload_bytes) per the DSSE spec: "DSSEv1" SP LEN(payloadType) SP payloadType SP LEN(payload_bytes) SP payload_bytes where LEN is the decimal ASCII byte length and SP is a single 0x20.
  • sig.sig = Ed25519 signature over pae, base64 (standard alphabet, padded), by the emitter key identified by sig.keyid.
  • leaf_hash = lowercase hex SHA-256 over pae. (This is the v1 leaf definition; when the format gains transparency-log material, the leaf hashes the stored envelope per the receipt schema — the format string is the switch.)
{"kind":"head","head":{"format":"behalf.sh/export/v1","log_origin":"<origin>","count":47,"chain":"<hex>"},"sig":{"keyid":"<jkt>","sig":"<base64std>"}}
  • Chain rule: chain_start = SHA-256("behalf.sh/chain/v1\n" + log_origin); chain_i = SHA-256(chain_{i-1} || leaf_hash_i_raw) for i = 0…count−1, where leaf_hash_i_raw is the 32 raw bytes (not hex). head.chain is the final value, hex.
  • head_bytes = the exact byte span of the head value in the raw line (same span rule).
  • sig.sig = Ed25519 over PAE("application/vnd.behalf.chain-head+json", head_bytes).

An export can be read back to rebuild a local log from it. Two properties govern that, and the second one is the honest half:

  • Every leaf survives byte for byte. The envelope is reassembled from the payload span and the emitter’s original signature, both verbatim — the same assembly the capture surface performed. Re-export an imported log and the leaf lines come back identical. That is what makes it an import rather than a re-signing: a re-signed receipt would verify beautifully and mean nothing, because it would be attested by whoever ran the import.
  • The head does not, and must not. The imported log signs its own checkpoint. The original log’s checkpoint key is not in an export and could not be — a local process able to mint a checkpoint under another log’s identity is precisely the forgery this format exists to make detectable. So the export file remains the artefact to check against the head that actually signed it, and the imported log stands on the emitter signature each receipt still carries.

The reader is deliberately not a second verifier. It parses structure and checks each leaf’s leaf_hash against its own payload span — an importer that ingested a self-inconsistent line would launder a broken record into a fresh log — and stops there. Signatures, the chain, the head and the ordering rules belong to the verifier, and keeping the two separate is what stops a second implementation of the verification contract quietly drifting from the first.

2. Verifier contract (behalf-verify <file>)

Section titled “2. Verifier contract (behalf-verify <file>)”

Checks, in order, reporting the first failure class but continuing where meaningful:

  1. Parse header; unknown format → unverifiable. Unknown extra JSON fields anywhere MUST be ignored (production logs grease unknown line and field types; verifiers must tolerate them).
  2. Per leaf: extract payload_bytes span, recompute pae, check leaf_hash, verify sig against the header key for keyid. A mismatch is content tampering at index N; every later receipt is reported unverifiable (chain breaks at N; receipts N+1..count-1 unverifiable).
  3. Index sequence: a gap is a dropped receipt; a non-ascending index is reordering.
  4. head.count vs actual leaf count: fewer leaves is truncation. This check runs before the chain compare — a truncated file must classify as truncation, not as the trivially-mismatched chain it also implies. More leaves than head.count classifies as chain.
  5. Recompute the chain (over the header’s log_origin); mismatch with head.chain (with counts matching and all leaves individually valid) is a chain mismatch. An edited head.chain therefore surfaces as class chain, since this runs before the head signature check.
  6. Verify the head signature; failure is head tampering. An unknown or undecodable sig.keyid/signature on a leaf classifies as content at that leaf (a swapped-in foreign key is tampering, not unverifiability); on the head, as head.
  7. Missing head line entirely is truncation.

Malformed JSON, wrong types, or missing required fields anywhere in the file — not just the header — make the export unreadable: exit 2. A duplicated top-level payload/head key inside a line (span-smuggling) is likewise exit 2.

Duplicate receipt_id inside payload is classified as duplicate, never as tampering — report it, exit 0 if everything else verifies.

Exit codes (stable, documented, load-bearing for CI)

Section titled “Exit codes (stable, documented, load-bearing for CI)”
code meaning
0 verified: all receipts intact, chain and head verify
1 tampering detected (any class above: content, drop, reorder, chain, truncation, head, duplicate, delegation)
2 unverifiable: not a readable export (bad args, missing file, malformed header/JSON)

Verifies the tlog-tiles directory the transparency log writes, with the same class vocabulary and the same exit codes. The differences from file mode are worth stating:

  • Check order. The checkpoint note signature comes first — an edited checkpoint root classifies head here, where file mode’s edited head.chain classifies chain. The checkpoint is the size oracle and must authenticate before anything else is interpreted. Then the stale-restore check, bundle coverage, per-entry content, root recomputation, and prefix-root consistency (a fork classifies chain).
  • A missing checkpoint file is exit 2, not truncation: without it there is no authenticated size to measure truncation against.
  • A stale restore verifies clean in isolation, deliberately. Only a known earlier checkpoint — and, in production, a witness — makes restore-as-truncation detectable. The test suite carries this case as a passing verification on purpose, because pretending otherwise would be the overclaim.
  • Emitter keys have no distribution channel inside the tile directory. Envelopes carry key thumbprints only. Supplying the key set enables full signature verification; without it, envelope checks are structural and the output says so rather than implying more.

The same crate, built for wasm32-unknown-unknown, exposing file mode only. It returns the structured result as JSON — verdict, exit code, receipt count, chain head, and the failure list with the same classes and indices as the command line.

Log mode does not cross, and is absent rather than stubbed. It walks a tile directory, which needs a filesystem; letting the browser appear to support something it cannot would be the same overclaim the exit codes exist to avoid, so the page says what is missing.

Nothing is uploaded: the file is read locally and verified in the page.

Human output on success: ✔ 47/47 receipts intact chain head <first4>…<last4> (e.g. 4f0c…a19e); on tampering: the class, the index, and the unverifiable range.

The verifier also emits a machine-readable line per failure on stderr: class=<content|drop|reorder|chain|truncation|head|duplicate|delegation> index=<N>, with these index conventions: content/sig failures → the tampered leaf’s index; drop → the first missing index; reorder → the first position where ascending order breaks; truncation → the first missing index; chain/head/missing-head → -1 (no leaf index); delegation → the index of the receipt whose hop failed one of the re-checked invariants (§1.1 tokens).