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.
1. File layout
Section titled “1. File layout”UTF-8, one JSON object per line (\n separators, no CR). Three line kinds, in order:
- Header — exactly one, first line.
- Leaf — one per receipt,
indexstrictly ascending from 0, no gaps. - Head — exactly one, last line.
1.1 Header line
Section titled “1.1 Header 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.
1.2 Leaf line
Section titled “1.2 Leaf line”{"kind":"leaf","index":31,"payloadType":"application/vnd.behalf.receipt+json","payload":{…receipt JSON…},"sig":{"keyid":"<emitter jkt>","sig":"<base64std>"},"leaf_hash":"<hex>"}payloadis the receipt object perreceipt-v1.schema.json, embedded as plaintext.payload_bytesis defined as the exact byte span of thepayloadvalue 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_byteswhere LEN is the decimal ASCII byte length and SP is a single 0x20.sig.sig= Ed25519 signature overpae, base64 (standard alphabet, padded), by the emitter key identified bysig.keyid.leaf_hash= lowercase hex SHA-256 overpae. (This is the v1 leaf definition; when the format gains transparency-log material, the leaf hashes the stored envelope per the receipt schema — theformatstring is the switch.)
1.3 Head line
Section titled “1.3 Head line”{"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, whereleaf_hash_i_rawis the 32 raw bytes (not hex).head.chainis the final value, hex. head_bytes= the exact byte span of theheadvalue in the raw line (same span rule).sig.sig= Ed25519 overPAE("application/vnd.behalf.chain-head+json", head_bytes).
1.4 Reading an export back
Section titled “1.4 Reading an export back”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:
- 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). - Per leaf: extract
payload_bytesspan, recomputepae, checkleaf_hash, verifysigagainst the header key forkeyid. A mismatch is content tampering at index N; every later receipt is reported unverifiable (chain breaks at N; receipts N+1..count-1 unverifiable). - Index sequence: a gap is a dropped receipt; a non-ascending index is reordering.
head.countvs 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 thanhead.countclassifies as chain.- Recompute the chain (over the header’s
log_origin); mismatch withhead.chain(with counts matching and all leaves individually valid) is a chain mismatch. An editedhead.chaintherefore surfaces as classchain, since this runs before the head signature check. - 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. - 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) |
2a. Log mode: behalf-verify log <dir>
Section titled “2a. Log mode: behalf-verify log <dir>”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
headhere, where file mode’s editedhead.chainclassifieschain. 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 classifieschain). - A missing
checkpointfile 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.
2b. Browser mode: the WASM verifier
Section titled “2b. Browser mode: the WASM verifier”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.
3. Output formats
Section titled “3. Output formats”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).