# B5 evidence export format — `b5-evidence/1`

The published specification for a decision-evidence export. Written so a third party can verify
an export **without B5 software, without network access, and without asking us anything.** If
that is not true of a format, the evidence it carries is a vendor's assertion.

## The export

A directory (or a zip of one) containing exactly two files:

```
manifest.json     the declaration: what this export claims to be
records.jsonl     one decision per line, in chain order
```

## Canonicalisation — the rule everything depends on

Two parties must derive **byte-identical** input from the same record or every digest disagrees
for innocent reasons. The rule is:

```
digest_input = canonical_body  ‖ "\n" ‖ prev_hash ‖ "\n" ‖ sequence
canonical_body = JSON( record minus "decision_evidence" ),
                 sorted keys, separators (",", ":"), UTF-8, no insignificant whitespace
hash = SHA-256( digest_input ), lowercase hex
```

`decision_evidence` is excluded from `canonical_body` because a digest cannot cover itself.

### Why `prev_hash` and `sequence` are inside the digest

**The first draft of this format left them out, and it was wrong.** With the whole evidence block
excluded, a record's hash depended only on its own body — so an attacker could alter record 2,
recompute its hash, re-point record 3's `prev_hash`, and the chain relinked cleanly. The head only
moved if the *last* record changed, so the signature still verified.

The tamper suite caught it (case 05 verified when it should have failed). Binding the linkage into
the digest means any alteration changes every subsequent hash, which moves the head, which breaks
the signature. That is what a hash chain is for, and a format specification that has not been
attacked is a draft.

## `records.jsonl`

One JSON object per line, no trailing commas, newline-separated. Each record is an **OCSF
Authority `authorization_decision` event** — see the `authority` extension. Required for
verification:

| Field | Why the verifier needs it |
|---|---|
| `time` | window and ordering |
| `is_permitted` | the decision |
| `reason_code` | why — a denial without a reason is not auditable |
| `is_shadow` | whether this was a simulation. **Required, not optional** |
| `tenant_uid` | partition; one chain covers one tenant |
| `decision_evidence.hash` | the digest |
| `decision_evidence.prev_hash` | linkage (absent on the first record only) |
| `decision_evidence.sequence` | position, contiguous, increasing by one |

`is_shadow` is required because a consumer that cannot exclude simulated decisions will build
detections and metrics on traffic that never happened.

## `manifest.json`

```json
{
  "format": "b5-evidence/1",
  "tenant_uid": "t-8f21c0",
  "records": "records.jsonl",
  "record_count": 5,
  "window": { "start": 1785225600123, "end": 1785225605222 },
  "canonicalisation": "…the rule above, restated…",
  "chain": {
    "uid": "chain-t-8f21c0",
    "head_hash": "…",
    "signing_key_uid": "key-2026-07-ed25519",
    "signature": "…base64…"
  },
  "key": { "uid": "key-2026-07-ed25519", "alg": "ed25519", "public_b64": "…" }
}
```

The signature covers this payload and nothing else:

```
JSON({format, tenant_uid, record_count, head_hash, chain_uid}),
sorted keys, separators (",", ":")
```

Signing the head plus the count is what makes truncation detectable: dropping the last record
changes the head, and dropping any record changes the count.

## Signature algorithms

| `alg` | Verifies with | Use |
|---|---|---|
| `ed25519` | one optional library | **production.** Manifest carries a public key only |
| `hmac-sha256` | the standard library alone | demonstration |

**The HMAC mode is deliberately weaker and we say so.** A shared secret in the manifest is what
lets the worked example verify with zero dependencies — and it also means anyone holding that
export could re-sign it. `example-export/` is therefore *demonstrably forgeable*, and
`example-export-ed25519/` is not. Shipping only the HMAC example while claiming signatures
protect the chain would be a claim the artefact itself contradicts.

## What the verifier checks

1. manifest present, parseable, known format version
2. record count matches; every line parses; required fields present
3. every record hashes to its stated digest
4. every `prev_hash` equals the previous record's hash
5. sequence contiguous and increasing; timestamps non-decreasing
6. the head is the last record's hash, and is signed by the key the manifest names
7. every record inside the declared window
8. every record states its simulation status

**A deletion trips checks 2, 4 and 5 independently.** That redundancy is deliberate: repairing
one is not enough, and repairing all three plus the signature requires the private key.

## Attacks the suite runs, all rejected

flip a denial to a permit · delete a record from the middle · reorder two records · tamper then
fix the record's own hash · re-chain everything after a tamper · forge the head signature · move
a record outside the window · strip the simulation flag · re-chain an ed25519 export without the
private key.

## Usage

```
python3 b5verify.py path/to/export          # human-readable
python3 b5verify.py path/to/export --json   # machine-readable
# exit 0 verified · 1 verification failed · 2 could not read the export
```

Python 3.8+, standard library only. `cryptography` is optional and needed only for `ed25519`;
without it the tool reports the signature check as **skipped** rather than passing it silently.

## Versioning

`b5-evidence/1`. A change that would make an older verifier reject a valid newer export
increments the number. Adding an optional field does not.
