Skip to main content

WAI Extension: Audit Chain

Mirrored from the canonical text at commit 0d3f0d7c ().

Status: Draft. Registers the record wai.audit.chain: an account’s log of what changed who may do what, kept by the service that holds the account, which the account’s holder can check without the service. An entry (§3) states that an actor took an action on a target at an instant; each entry names the one before it by its link, SHA-256 over a domain and the entry’s canonical form, so no entry can be changed, removed, reordered or added from another chain without the chain failing (§4). A checkpoint (§5) is the keeper’s Ed25519 signature over the chain’s head, signed at an interval and at every export, and registrable with a transparency service unchanged. An audit export (§6) is a header naming the entry the first one follows, the entries in order, and a checkpoint over the last, as JSON Lines. A verifier checks an export alone, and holds an earlier checkpoint to a later export: the entry it names is the later export’s, so the chain was only appended to between the two (§7). WAI says what a record states, not what an operator records or why: an account, an actor and a target are opaque identifiers, and WAI sets no rule on who may act or on what an operator must record. The reference implementation is the audit_chain module of wai-rs (feature audit_chain); the corpus is live-conformance/audit-chain/, and its second implementation is live-conformance/audit_chain_verify.py. Keywords MUST, MUST NOT, SHOULD, MAY are RFC 2119/8174.

Texts read for this revision: the WAI texts this one cites; RFC 8259; RFC 8785; RFC 8032; FIPS 180-4; the JSON Lines convention (one JSON text a line, each ended by a newline), which this text restates in full (§6) and does not cite.

1. Scope

In scope: an entry, its members and its link (§3); the rules of a chain of entries (§4); the checkpoint, its signature and its registration (§5); the audit export (§6); and what a verifier checks of an export alone and of an earlier checkpoint against a later export (§7).

Out of scope: which actions an operator records, which actors and targets it names and how, what an actor is permitted to do, how long an operator keeps entries before retention drops them, and how an export is requested or delivered: each is the operator’s. What an entry’s action means is the operator’s too; WAI reads its form only.

This is not stream-audit. wai.delivery.audit is the evidence players sign of what they presented on a stream; an audit chain is the record of who changed an account. The two share no record and no domain.

An export of an account’s records that carries its audit chain beside its other records is data-export’s: its entries are record lines of kind 34, its anchor is in the bundle’s header and its checkpoint in the bundle’s trailer, held to the same rules (§4, §5.4).

Relationship to the core spec: wai.audit.chain is a record (SPEC §5): a record a WAI tool emits and verifies. It names no payload format and is not dispatchable.

2. Terms and encodings

3. The entry

3.1 Members

An entry is a document of exactly these members:

memberformmeaning
kindstring"wai-audit-entry"
versioninteger1
accounttokenthe chain’s account
seqinteger, at least 1its place in the chain: 1 for the account’s first entry, one more for each after
prevhashthe link (§3.2) of the entry before it; zeros for entry 1
timeinstantwhen the action was taken
actorpartywho took it
actionactionwhat it was
targetparty or nullwhat it was taken on; null for an action on nothing but the account
requesttoken or nullthe request it came in, as the operator names its requests; null for none
detailobject0 to 64 members, each named by a word, each value a string of at most 1024 bytes, an integer, true, false or null: the values the operator records of the action

Every action that changes who may do what in an account, or that spends, SHOULD be an entry: what that covers is the operator’s to say. An operator that records more about an action (where a request came from, which client made it) puts it in detail; WAI sets no rule on what it records and none on whether it records anything there.

An entry’s link is SHA-256 (FIPS 180-4) over the 15 bytes wai:audit-entry (ASCII) and 0x01, followed by the entry’s canonical form, as a hash. It names the entry: the next entry’s prev is it, and receipt-ledger §5.2’s record kind 34 names an entry by it.

3.3 What an entry holds, and what no verifier can check

An entry is signed under its checkpoint and handed to whoever the account’s holder hands its export to, and kept for as long as the chain is. An operator MUST NOT put a secret in an entry: a credential, the private half of a key, a token that grants access, or anything that lets its reader act as the account. No verifier can tell a secret from an identifier by its bytes, and none of this text’s verifiers tries; a rule on the shape of a value would refuse honest identifiers and pass secrets that do not match it. The rule is the operator’s to keep, and this text names it so that an operator cannot read its silence as leave.

4. The chain

A chain is an account’s entries in order of seq, from its anchor: the entry its first held entry follows, stated by its sequence number, its link and its instant (§6), or sequence 0, zeros and instant 0 for a chain held from entry 1. Each entry, in order, is held to these rules, in this order:

  1. its account is the chain’s (audit_account_mismatch);
  2. its seq is one more than the entry before it, or the anchor’s (audit_seq_gap);
  3. its prev is the link of the entry before it, or the anchor’s link (audit_prev_mismatch);
  4. its time is at or after the entry before it’s, or the anchor’s (audit_time_reversed).

So an entry changed after the fact breaks the next entry’s prev, or, when it is the last, the checkpoint’s head (§5.4); an entry removed or two swapped break the numbering; and an entry of another account’s chain breaks rule 1 wherever it is put. A chain renumbered and re-linked after an entry was removed holds these rules, which is why a checkpoint signs its head and why a later export is held to an earlier checkpoint (§7.2).

5. The checkpoint

5.1 Members

A checkpoint is a document of exactly these members:

memberformmeaning
kindstring"wai-audit-checkpoint"
versioninteger1
accounttokenthe chain’s account
seqintegerthe last entry’s seq; 0 for a chain with no entry yet
headhashthe last entry’s link; zeros exactly when seq is 0
timeinstantwhen it was signed
signerkeythe keeper’s key
sigsignature§5.2

The keeper SHOULD sign a checkpoint at an interval of its choosing, and MUST sign one at every export, over the export’s last entry (§6).

5.2 Signature and digest

A checkpoint’s sig is Ed25519 (RFC 8032) by signer over the 20 bytes wai:audit-checkpoint (ASCII) and 0x01, followed by the canonical form of the checkpoint without sig. It is verified strictly, as SPEC §7 Signatures requires: a key that fails step 1 or 2 is bad_key, and a signature that fails a later step is bad_signature. A verifier given the keys it holds for the keeper refuses a checkpoint signed by any other key (signer_unknown); how it comes to hold them is its own (a keeper that names its web origin publishes them under jwp-receipts §7). A checkpoint’s digest, which names it (receipt-ledger §5.2, record kind 35), is the digest of the whole checkpoint, sig included.

5.3 Registration

A keeper MAY register its checkpoints with a transparency service as transparency §3 registers periodic checkpoints over a receipt chain: the checkpoint’s canonical form is the registered statement, unchanged, and a checkpoint is the same record registered or not. Registration adds what a signature cannot: that the keeper did not show one head to one party and another head to another. As transparency §6 states, registration is not a precondition of a checkpoint’s validity and is never reported as its verification. A later revision may carry the entries as RFC 6962 leaves, so that two checkpoints have a consistency proof instead of a re-read of the chain; this revision holds them by the chain itself (§7.2).

5.4 A checkpoint closes a chain

A checkpoint closes the chain it is given with when, in this order: its account is the chain’s (audit_account_mismatch); its seq and head are the last entry’s seq and link, or, with no entry, the anchor’s sequence number and link (audit_head_mismatch); and its time is at or after the last entry’s, or the anchor’s (audit_time_reversed).

6. The audit export

An audit export is a text of JSON Lines: UTF-8 (RFC 8259), each line a document’s canonical form followed by one newline (0x0A), at most 2²⁴ bytes before its newline, and nothing else: no empty line, no carriage return, nothing after the last newline. Its first line is its header, a document of exactly these members:

memberformmeaning
kindstring"wai-audit-export"
versioninteger1
accounttokenthe chain’s account
anchorobjectexactly seq, an integer, link, a hash, and time, an instant: the last entry dropped before the first this export holds, as retention dropped it; seq 0, link zeros and time 0 when it holds the chain from entry 1, and seq 0 only then
countintegerthe number of entries it holds

Then the count entries (§3), in order of seq; then one checkpoint (§5) over the last of them, or over the anchor when it holds none. Since every line is its document’s canonical form, an export has one text, and two exports of one chain at one checkpoint are the same bytes.

7. Verification

7.1 An export alone

A verifier is given an export’s text and either the keys it holds for the keeper or none. It runs these steps and stops at the first refusal, which it reports with its reason and where: at, a list holding the index of the line, from 0.

  1. The lines: a text that is not lines of canonical forms as §6 states, at the first line that is not one, or at the line a missing final newline leaves unended (audit_malformed).
  2. The header, at 0: a kind other than "wai-audit-export" is audit_malformed; a version that is an integer other than 1 is audit_unknown_version; a header not of §6’s members and forms is audit_malformed. Then the number of lines: other than count and 2, audit_count_mismatch.
  3. Each entry, in order, at its line: a kind other than "wai-audit-entry" is audit_malformed; a version other than 1, audit_unknown_version; an entry not of §3.1’s members and forms, audit_malformed; then §4’s rules from the header’s anchor.
  4. The checkpoint, at the last line: a kind, a version and the members and forms as for an entry, under §5.1 (audit_malformed, audit_unknown_version); its signature, bad_key, bad_signature, and, when the verifier holds keys, its signer among them, signer_unknown (§5.2); then §5.4 over the chain.

The outcome of an export that passes is accept, with its account, its anchor’s sequence number, the number of entries it holds, the checkpoint’s seq and head, its digest and its signer. A verifier given no keys reports the signer; it has not checked whose it is.

7.2 Append-only between two checkpoints

A verifier is given an earlier checkpoint’s text, a later export’s text and the keys it holds, and runs these steps, stopping at the first refusal:

  1. The earlier checkpoint, at earlier: a text that is not a document is audit_malformed; then §7.1 step 4’s form and signature rules.
  2. The later export, by §7.1, its reason at later and the line.
  3. Across them, at no place (an empty list): the two accounts differ (audit_account_mismatch); the later checkpoint’s seq is below the earlier’s, or its time before the earlier’s (audit_regressed); then the entry the earlier checkpoint names, by its seq: for seq 0, the empty chain, which every chain follows; for the later export’s anchor’s sequence number, the anchor; for an entry the later export holds, that entry; for an earlier one, which the later export’s retention dropped, nothing the verifier holds (audit_checkpoint_not_held); and its link, or the anchor’s, is not the earlier checkpoint’s head (audit_forked).

The outcome of a pair that passes is appended, from the earlier checkpoint’s seq to the later one’s: every entry the earlier checkpoint covered is unchanged in the later export, and the chain grew only after it. audit_checkpoint_not_held is no evidence either way: an account’s holder that wants the rule held across retention keeps an export from before it, or holds its checkpoints registered (§5.3).

8. Reasons

codewhere
audit_malformed§7.1 steps 1 to 4, §7.2 step 1
audit_unknown_version§7.1 steps 2 to 4
audit_count_mismatch§7.1 step 2
audit_account_mismatch§4 rule 1, §5.4, §7.2 step 3
audit_seq_gap§4 rule 2
audit_prev_mismatch§4 rule 3
audit_time_reversed§4 rule 4, §5.4
bad_key§5.2
bad_signature§5.2
signer_unknown§5.2
audit_head_mismatch§5.4
audit_forked§7.2 step 3
audit_regressed§7.2 step 3
audit_checkpoint_not_held§7.2 step 3

9. Conformance

The reference implementation is the audit_chain module of wai-rs (feature audit_chain): verify_export (§7.1), verify_append (§7.2), and Chain, the rules of §4 and §5.4 alone, which data-export holds a bundle’s entries to. The corpus is live-conformance/audit-chain/, written by wai_audit_chain_vectors gen from fixed seeds: exports, each with its text, the keys a verifier holds (or none) and its outcome, accepted ones among them (a chain from its first entry, after retention, with no entry, with every entry dropped, under a next key, with no keys held, a checkpoint at the last entry’s instant, a detail at its bounds), and one refused case for each rule of §3.1, §4, §5 and §6; and pairs of an earlier checkpoint and a later export, appended or refused. tests/audit_chain.rs requires the file to regenerate byte for byte, every case to get its stated outcome, and §8 to list exactly the codes the reference reports. live-conformance/audit_chain_verify.py is the second implementation, written from this text and sharing no code with wai-rs: it reads documents with route_record_verify.py and signatures with the shared _ed25519.py, requires every stated outcome, mutates copies of the corpus and requires each to fail, and drops each of its own checks in turn and requires the corpus to catch it.

10. Security and privacy considerations