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 theaudit_chainmodule ofwai-rs(featureaudit_chain); the corpus islive-conformance/audit-chain/, and its second implementation islive-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
- Document, canonical form, digest, token, key and signature: as route-record §2. A document’s numbers are integers from 0 to 2⁵³ − 1, so every document of this text is in the subset RFC 8785 canonicalises without a choice.
- Hash: 64 lowercase hexadecimal digits, 32 bytes. Zeros: the hash of 64
0digits. - Instant: a count of milliseconds since 1970-01-01T00:00:00Z, leap seconds not counted, an integer.
- Account: a token naming whose chain it is: the party whose governance the entries record, an organisation, a tenant or a workspace as the operator divides its service. Keeper: the service that keeps the chain and signs its checkpoints.
- Word: 1 to 64 bytes, a lowercase ASCII letter, then lowercase letters, digits and
_. - Action: an area and a verb, each a lowercase ASCII letter and then lowercase letters and
digits, joined by
.(member.add,key.revoke,limit.set). - Party: an object of exactly
kind, a word, andid, a token: what kind of party it is (a member, a key, a provisioner, the operator, the service itself, a workspace) and its identifier. A party’s kind and id are opaque to every verifier: WAI names no kind and says nothing about how an operator identifies a person, a key or anything else.
3. The entry
3.1 Members
An entry is a document of exactly these members:
| member | form | meaning |
|---|---|---|
kind | string | "wai-audit-entry" |
version | integer | 1 |
account | token | the chain’s account |
seq | integer, at least 1 | its place in the chain: 1 for the account’s first entry, one more for each after |
prev | hash | the link (§3.2) of the entry before it; zeros for entry 1 |
time | instant | when the action was taken |
actor | party | who took it |
action | action | what it was |
target | party or null | what it was taken on; null for an action on nothing but the account |
request | token or null | the request it came in, as the operator names its requests; null for none |
detail | object | 0 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.
3.2 Link
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:
- its
accountis the chain’s (audit_account_mismatch); - its
seqis one more than the entry before it, or the anchor’s (audit_seq_gap); - its
previs the link of the entry before it, or the anchor’s link (audit_prev_mismatch); - its
timeis 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:
| member | form | meaning |
|---|---|---|
kind | string | "wai-audit-checkpoint" |
version | integer | 1 |
account | token | the chain’s account |
seq | integer | the last entry’s seq; 0 for a chain with no entry yet |
head | hash | the last entry’s link; zeros exactly when seq is 0 |
time | instant | when it was signed |
signer | key | the keeper’s key |
sig | signature | §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:
| member | form | meaning |
|---|---|---|
kind | string | "wai-audit-export" |
version | integer | 1 |
account | token | the chain’s account |
anchor | object | exactly 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 |
count | integer | the 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.
- 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). - The header, at 0: a
kindother than"wai-audit-export"isaudit_malformed; aversionthat is an integer other than1isaudit_unknown_version; a header not of §6’s members and forms isaudit_malformed. Then the number of lines: other thancountand 2,audit_count_mismatch. - Each entry, in order, at its line: a
kindother than"wai-audit-entry"isaudit_malformed; aversionother than1,audit_unknown_version; an entry not of §3.1’s members and forms,audit_malformed; then §4’s rules from the header’s anchor. - The checkpoint, at the last line: a
kind, aversionand 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:
- The earlier checkpoint, at
earlier: a text that is not a document isaudit_malformed; then §7.1 step 4’s form and signature rules. - The later export, by §7.1, its reason at
laterand the line. - Across them, at no place (an empty list): the two accounts differ
(
audit_account_mismatch); the later checkpoint’sseqis below the earlier’s, or itstimebefore the earlier’s (audit_regressed); then the entry the earlier checkpoint names, by itsseq: forseq0, 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’shead(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
| code | where |
|---|---|
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
- What a chain proves. Given the keeper’s key, that no entry of an export was changed, removed, reordered or brought from another chain since its checkpoint was signed, and, given an earlier checkpoint, that nothing the earlier one covered changed. Not that the entries are true: an entry is the keeper’s statement, and a keeper that never wrote an entry for an action leaves no trace of it here. Not that the keeper showed every party the same head: registration (§5.3) does that.
- A rewritten chain. A keeper holds its own key, so it can rewrite a chain and sign a new
head. Every checkpoint the account’s holder already holds then fails §7.2 against the new export
(
audit_forked), which is what an earlier checkpoint is for: the holder SHOULD keep the checkpoint of every export it takes. - What an entry carries. An entry carries what the operator records, and an operator that names people names them in it. WAI sets no rule on what that is (§3.1), and none on an operator’s other duties toward it; §3.3’s rule on secrets is the one rule here. A deletion of an account’s records, its audit chain among them, is stated by data-deletion.
- Instants. An entry’s
timeis the keeper’s clock. The chain holds instants not to go back, not to be right.