Appendix: Specification
This appendix holds the byte-level rules that 4 How EventChain Works relies on.
Entry Specification
Lines and rejection. Each line ends in 0x0a, and a file that contains 0x0d anywhere counts as malformed. A verifier that finds a malformed line under any rule in this appendix rejects the whole AOF and reports the line.
Field encodings. Every member of the entry object is a JSON string. Binary members use base64url without padding (Josefsson 2006, sec. 5):
payload_hash,prev_hash: the 32-byte SHA-256 digest.public_key: the 33-byte SEC1 compressed point (Standards for Efficient Cryptography Group 2009, sec. 2.3.3).signature: the 64-byte concatenationr ‖ s, each a 32-byte big-endian unsigned integer.
entry_id and payload_ref are opaque strings with no encoding requirement.
Event ID. The Event ID (Append-Only File) excludes the row’s terminating 0x0a. After the append, the Hub returns the new position and head hash and the number of lines written (receipt); for a one-line submission the head hash is that line’s Event ID. The Hub may record Event IDs in a separate lookup index. An application keeps evidence(row key, Event ID, salt) beside its existing data. The Hub answers lookups, and a holder checks each answer against the Event ID and its own copy of the chain.
entry.data(). The signed message entry.data() is the JSON Canonicalization Scheme (Rundgren et al. 2020) serialisation of the entry object without its signature member. The removal applies to exactly the member named signature; the signature covers every other member the declared revision permits. Because RFC 8785 orders members by name, producers may write members in any order on the line. An unknown member or an unsupported revision makes the entry malformed.
Chain bytes. prev_hash is the SHA-256 digest of the previous line exactly as written: every byte after the preceding line terminator (or the start of the file), up to and excluding the previous line’s own 0x0a. A verifier applies no canonicalisation, so reformatting a line into a form a JSON parser would read as equivalent changes its hash and breaks the next entry’s predecessor check. The final line has no successor; a retained head or a heartbeat at or beyond it binds its exact bytes.
Figure 1 separates the two byte paths; the rules above define them.
signature, serialised with JCS. These inputs are not interchangeable.
Genesis. The first entry carries a prev_hash of 32 zero bytes, encoded like any other hash, and a verifier rejects an AOF whose first entry carries any other value.
Duplicate members. An entry in which any member name appears more than once counts as malformed. Parsers resolve duplicate names differently, so relying on a parser’s default would let one line verify two ways.
Unicode. An escape that denotes an unpaired UTF-16 surrogate in a member name or value counts as malformed; a verifier never substitutes a replacement character.
Revisions and Mint entries. The string member v declares the revision. Without it, an entry has the six base members; "1" also admits the Mint members in Table 1, which record a person’s confirmation of an earlier entry by passkey (What a Signature Proves). A verifier compares revision labels as strings, never as numbers. A revision-1 entry without Mint members is an ordinary producer-signed entry.
| Mint member | Value or encoding |
|---|---|
kind |
"mint" |
target_hash |
Target entry’s Event ID: unpadded base64url of its 32-byte line hash |
attester_key |
Unpadded base64url of the 33-byte SEC1 compressed credential public key |
assertion_sig |
Unpadded base64url of the 64-byte WebAuthn signature, raw r ‖ s |
authenticator_data |
Exact authenticator-data bytes, unpadded base64url |
client_data_json |
Exact client-data JSON bytes, unpadded base64url |
A Mint entry declares v = "1" and carries all six Mint members beside the base members. Partial or misplaced Mint members, or an unknown kind, make the entry malformed. The target must be an earlier verified entry in the same chain.
WebAuthn Assertion Checks
For a Mint entry, the verifier checks three things, following the WebAuthn assertion format (Hodges et al. 2021). client_data_json must be valid UTF-8 JSON of type webauthn.get whose challenge equals the encoded target_hash. authenticator_data must hold at least 37 bytes, with the user-presence flag set and consistent backup-state flags. assertion_sig must verify under attester_key over authenticator_data ‖ SHA-256(client_data_json). These checks leave the relying party’s origin and RP ID policy, user verification and signature counter to the application’s accepted authentication context.
Heartbeat Encoding and Retrieval
Signed format. The Hub takes position and head_hash in Table 2 from the same chain state.
| Member | Meaning |
|---|---|
v, kind |
Literal values "1" and "heartbeat" |
topic_id |
Exact topic identifier from the agreed settings |
position |
Number of entries in the committed prefix, at least one |
head_hash |
SHA-256 of the exact final row in that prefix, excluding its terminating 0x0a |
issued_at |
Hub-asserted issuance time, Unix seconds with an optional fractional part |
signature |
Hub signature over the statement fields above |
The encoding is a UTF-8 JSON object with string values. The Hub signs the JCS serialisation of the other members and then adds signature, using ECDSA-P256 and the binary encodings of the entry specification. A verifier rejects duplicate or unknown members, unknown revisions, unpaired surrogates and malformed values. position uses ASCII decimal digits without leading zeros. issued_at uses the same form, optionally followed by a decimal point and fractional digits without trailing zeros. The verifier takes the accepted Hub key and interval from the agreed publication settings.
Retrieval. The Hub serves these endpoints for hosts to pull, and each host serves them for holders. Relative to the server’s base URL for the topic, GET heartbeat/latest returns the latest heartbeat as one JSON object and GET heartbeat returns the full list as an array. GET heartbeat?from={timestamp}&until={timestamp} returns the heartbeats with from <= issued_at < until, and an omitted bound leaves that end open. If conflicting signed heartbeats arise, the host keeps both.
Submission, Receipt and Refusal
Submission. A submission is one or more lines, each the exact bytes of an entry, joined by 0x0a with no terminator after the last. The first line’s prev_hash is the expected head, and no other field carries it; each later line’s prev_hash names the hash of the line before it in the submission. A line is a JSON object and cannot contain a raw newline, so the separator is unambiguous; a body that ends in 0x0a has an empty last line and counts as malformed. The Hub appends the body and one 0x0a, whole or not at all. The submission hash is the SHA-256 digest of the body, so for a one-line submission it is the line’s Event ID.
Receipt and refusal. A receipt or refusal has the shape of a heartbeat, so a verifier that checks one checks all three: a flat JSON object of string members, signed under the Hub key over the JCS serialisation of the other members, with the encodings of the entry specification and the rejection rules of the heartbeat. Table 3 lists the members.
| Member | Receipt | Refusal |
|---|---|---|
v, kind |
"1", "receipt" |
"1", "refusal" |
topic_id |
Exact topic identifier | Exact topic identifier |
submission_hash |
Hash of the submitted body | Hash of the submitted body |
issued_at |
Hub-asserted time, as in a heartbeat | Hub-asserted time, as in a heartbeat |
position, head_hash |
Chain state after the append; head_hash is the last appended line’s Event ID and binds every line before it |
Present on head_mismatch: the current state, so the submitter knows what extension to fetch |
count |
Number of lines appended | Absent |
reason |
Absent | One value from Table 4 |
line |
Absent | On a verifier fault, the ordinal of the refused line within the body |
signature |
Hub signature over the members above | Hub signature over the members above |
Table 4 lists the reasons a refusal can carry. The first five are the verifier’s own fault names, so a refusal and a verifier verdict on the same line agree; the last is the Hub’s.
| Reason | Meaning |
|---|---|
malformed |
The line is not an entry under the entry specification |
chain_mismatch |
The line’s prev_hash is not the hash of the line before it in the submission |
key_off_curve |
The public_key is 33 well-formed bytes that name no point on P-256 |
signature_invalid |
The signature is not this key’s over the line’s signed message |
attestation_fault |
A Mint envelope failed one of the WebAuthn checks |
head_mismatch |
The first line’s prev_hash is not the current head; another submission arrived first |
A receipt is the Hub’s signed commitment that the submission is in the history at that position, so a history that later omits it contradicts the Hub’s own signature, and the producer who retained the receipt holds the proof. The Hub never retries a refused submission and never reuses a signature over an old head: the submitter fetches the extension, verifies it and signs again.
CBC and Correction References
Offer and acceptance. The offer is an ordinary entry whose payload names the subject of the agreement, the parties, the conditions and the time of the offer. The acceptance carries subject_hash, the offer’s Event ID, together with its acknowledgement of the terms. The reference binds the acceptance to that exact offer wherever the acceptance sits in the chain. When the parties require the acceptance to follow the offer immediately, its prev_hash also equals the offer’s Event ID. Whether subject_hash becomes an entry member or a payload field remains open, and it belongs to neither the base nor the Mint members above. Either way, the signature must cover it, directly or through the payload hash.
Corrections
A statement that addresses an earlier entry refers to it through subject_hash, exactly as an acceptance does, while prev_hash still names the immediately preceding line. The payload says which kind of statement the entry is and carries its substance. When the accepting party disputes an offer, it withholds acceptance and records a signed objection that refers to the offer, so the CBC stays pending and the objection says why; a resolution is a further signed entry that refers to the objection. When an author has lost its key and enrolled a new one (What a Signature Proves), application policy decides whether a correction signed with the new key counts as that author’s.
Key Registries
A registry (What a Signature Proves) is a topic whose entries are binding events and whose keeper accepts each binding. Each is an ordinary entry under the entry specification with no additional member. Four kinds of binding event exist, and the payload says which kind an entry is:
- Register. The subject key signs the offer, so the entry’s
public_keyis the key the binding covers. A person’s passkey cannot sign an entry, so for a person the producing system signs the offer and the person confirms it in a Mint entry (WebAuthn checks), and the binding coversattester_key. The payload carries the subject type (person, device, service or registry root), an identity reference, a role and the hash of the enrolment evidence. The keeper signs the acceptance withsubject_hashnaming the offer (CBC references). The binding stands from the acceptance’s position; before that it is pending, and a refusal is a signed objection (corrections). - Rotate. The new key signs the offer, whose payload names the binding it replaces by Event ID, and the keeper accepts. A registry’s root key rotates the same way: the new root signs the offer and the current root accepts, and holders follow the new root from that position.
- Retire. The keeper signs one entry that names the binding by Event ID. Signatures the key made before that position stand.
- Compromise. The keeper signs one entry that names the binding and the time from which the key is suspect, which may precede the entry. The verifier reports each entry the key signed at or after that time and before the claim’s position as inside a claimed compromise window and leaves the decision to the application.
Under the payload rule, the binding payload travels with its salt, so a holder resolves keys offline; the enrolment evidence, including any personal details, stays with the keeper under its access policy. Each topic declares the registries it accepts, by registry topic and root key, directly or through a binding in a registry it already accepts, and a change to that declaration is a CBC that the topic’s other parties countersign. When two accepted registries bind one key differently, the verifier reports conflicting, keeps both bindings and leaves the decision to the application.
Three conventions remain open, each with the constraint an answer must meet:
- Declaration encoding. How the first entry carries the accepted registries and the scope of each, and how a holder authenticates which topic a history belongs to through that first entry. Constraint: every holder can read it without the payload access policy.
- Entry-to-binding reference. Whether a verifier resolves a key by lookup in the accepted registries at verification time, as this appendix assumes, or an entry carries a reference to its binding. Constraint: a carried reference needs a new entry revision, because an unknown member makes an entry malformed.
- Attribution outcomes. The states the verifier reports for a key, modelled on the heartbeat outcomes: bound, pending, unbound, conflicting, retired before signing and inside a claimed compromise window are the candidates. Constraint: signature validity, binding evidence, compromise status and authorisation stay separate.
The foundation stewards these conventions (Protocol Stewardship). The questions it settles first:
- Who may keep a registry that others accept, and what enrolment evidence each must demand.
- How a keeper recovers when it loses its root key or a thief rotates the root first: a new first entry, and each accepting topic’s countersigned change to its declaration.
- Whether any rule beyond reporting decides between two accepted registries that disagree.
- How far back a compromise claim may reach, and who must countersign one.
- How long a keeper retains enrolment evidence, and how it deletes it.
Payload Access and Export
The application attaches the access policy to the payload, never to payload_ref. An authorised holder can export public payloads with their salts, keeping the exact bytes the hash covers and linking each to its payload_ref or Event ID, so others can verify them offline. Salt length and encoding, including the boundary between salt and payload, remain open in the specification. A public key or a payload hash can still be linkable to a person, so neither is automatically anonymous under GDPR.
Verifier Outcomes for Heartbeat Evidence
The verifier reports heartbeat evidence as missing when no authenticated heartbeat for the topic is retrievable, stale when the latest one falls outside the application’s freshness window, or conflicting when authenticated heartbeats or the supplied history disagree at a covered position. For that topic, the application pauses the actions that need verified shared evidence, keeps capturing and queuing new events locally, and escalates. A conflict keeps both heartbeats for reconciliation.