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.

{"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).

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)
2 unverifiable: not a readable export (bad args, missing file, malformed header/JSON)

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> 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).