Beta Pre-1.0 — the file-naming convention may still change. v1.0 will freeze it. ZDDC home · All specifications

ZDDC - Custody Extension ~1.0
Keys, Anchors & Trust State
Status: Draft

Status: APPROVED 2026-08-12. Transcribes federation-and-custody.md §2e, §3, §6.4, §6.7, §6.16, §10.2, §10.3, §10.5, §10.10.1–2, §10.11, plus kernel-and-bundle.md ("the registry register IS the trust-anchor store") and the shipped federation-transport-stage1.md S2 key layer. Requirement ids [M-K*].

Two things live here and must never be conflated:

  • a key file — the private half, on the holder's own disk, never served, never packaged;
  • an anchor — the public half plus its scope and lifetime, recorded in a registry row on the counterparty's side, which is what makes a signature mean anything.

1. Key files (private halves)

[M-K1] A key is one file: <root>/.zddc.d/keys/<name>.key.json (memo §6.16), a JSON object:

{
  "kind": "sign",
  "alg": "ed25519",
  "created": "2026-01-15T09:00:00Z",
  "public": "<base64url-unpadded raw public key bytes>",
  "private": "<base64url-unpadded raw private key bytes>"
}
  • kind ∈ sign | kem. alg per §2. created per [M-C6].
  • [M-K2] For Ed25519, private is the 32-byte seed and public is the 32-byte public key — raw bytes, base64url unpadded, matching the shipped transport key file's encoding (peer/key.go). (The memo §6.16 says "JWK"; this profile says raw bytes for both halves — decisions.md D-11. Raw bytes are what keyid already hashes ([M-E11]), what the peers registry already stores, and what Web Crypto imports as raw/pkcs8 with a 16-byte fixed prefix. A JWK would introduce a second encoding of the same key inside one system.)
  • [M-K3] The private half MAY be passphrase-wrapped, replacing private with "wrapped": {"kdf": "PBKDF2-SHA256", "iter": <int>, "salt": "<b64url>", "iv": "<b64url>", "ct": "<b64url>"} — AES-256-GCM over the raw private bytes, key derived by PBKDF2-SHA256 (no Argon2 in Web Crypto — memo §10.3). iter MUST be ≥ 600 000. Wrapping is OFF by default at rung 1 and stated honestly: the folder is the security boundary, and a forgotten passphrase destroys the identity of exactly the user least able to escrow one (memo §6.16). Orgs and servers opt in.
  • [M-K4] Key files live under the reserved enclave .zddc.d/, which is never served and never packaged on any surface — ?zip=1, transmittal zips, evidentiary exports, mirror walks (memo §2e; enforced today in handler.ReservedSidecar and cache.cachePathFor). This is an authorization property of the holding server, not a courtesy of the fetching side. An implementation that writes a key file anywhere else is non-conforming.
  • [M-K5] A key file MUST NOT be persisted to browser origin storage (localStorage, IndexedDB, the Cache API). At rung 1 the file is written into the user's own data folder via the File System Access API, so backup of the folder is backup of the identity (memo §10.11.1). A cache clear must not be able to destroy an identity.
  • [M-K6] An existing-but-unreadable key file is an error, never a trigger to mint a new one — silently re-keying strands every counterparty that anchored the old public half. (Shipped behaviour; peer/key.go.)

2. Algorithms

[M-K7] alg values in v1:

alg kind status
ed25519 sign implemented (RFC 8032; Web Crypto + Go)
ecdsa-p256 sign reserved — declared shape only; a v1 verifier rejects unsupported_type
x25519 kem reserved — the encryption hook (§4)
ecdh-p256 kem reserved — the encryption hook (§4)

An unknown alg MUST fail closed at verify (unsupported_type), never default to Ed25519. The algorithm is a property of the key, never of the artifact ([M-E8]).

An ABSENT alg is an unknown alg — on a party row's root_alg, on an operational entry's alg, and on an add/rotate op's alg ([M-S30]) alike. So is an empty string: nothing names an algorithm here except one of the four tokens above, and a verifier MUST NOT reach for a default in either case (unsupported_type). "Never default to Ed25519" is the whole of this rule, and absence is where a default is most tempting: ed25519 is the only algorithm v1 implements, so guessing it costs nothing today and silently becomes a downgrade surface the moment a second one exists. A key whose algorithm nobody wrote down is a key nobody declared, which is exactly what an anchor is for.

One implementation defaulted an empty value to ed25519 in both places (decisions.md, D-S2) — an accept where the other refused, and the only accept/reject split the anchor layer had.

Where the two classes divide. For a payload field, absence is malformed (the statement is not of its declared shape) and a present-but- unimplemented value is unsupported_type — that is [M-S52]'s split for succession.alg, and [M-S30]'s for an op. For derived anchor state, which is not a payload and has no malformed reading, both are unsupported_type: the verifier holds a row it cannot evaluate.

3. The anchor — what a counterparty records

An anchor is a registry row, not a new construct: "the registry register IS the trust-anchor store" (kernel-and-bundle.md), so anchors are ordinary records — versioned, schema-validated, form-rendered, edited through the same doors as any other row (memo §2e/§3).

3.1 Two registries, two roles

The shipped system has two registry surfaces, and v1 keeps them distinct:

Registry Row Key it holds Role
<ZDDC_ROOT>/peers/<name>.yaml one bilateral transport link sign_key — the instance link key, minted lazily, always online ZDDC-Sig request signing (Stage 1, SHIPPED)
the bundle's party registry (<project>/ssr/<party>.yaml in the GC bundle; contacts/<row>.yaml in the personal bundle) one party identity root_key — the party's custody root, ideally offline signing custody statements (this spec)

[M-K8] The custody anchor is the party row's root_key, not the peers row's sign_key. A verifier resolving a statement's signer MUST use the party registry, and MUST NOT accept a transport link key as a custody signer merely because the request arrived over that link.

3.1.1 "Lives once" is an OPERATOR guarantee, not a one-key claim

RESOLVED 2026-08-12 (decisions.md D-12; memo §2e carries a dated correction note). Memo §2e said "key material lives ONCE, in the party registry row" and "the same anchored key later verifies the party's signed transmittals", while §3/§10.3 make the custody root an offline key that countersigns short-lived operational keys — and Stage 1 shipped an always-online transport key in a second registry. An offline root cannot be an always-online request signer. The resolution keeps the property the sentence was protecting and drops the claim it accidentally made:

  • [M-K30] Two keys, two lifecycles, and they MUST NOT be interchanged in either direction. root_key (party registry) is the offline custody anchor, rotated by succession. sign_key (peers row) is the online transport identity, minted lazily per instance and rotated by pasting a new public half. Beyond [M-K8]'s rule that a transport key is never a custody signer, the converse also holds: an implementation MUST NOT present a custody root as a ZDDC-Sig signer. Neither key is a fallback for the other, and a deployment that has only one of them has not configured the other.
  • "Lives once" holds at the OPERATOR level, and that is what it was always for: one relationship is one row, one page, one place to rotate or sever. There is no second key-list construct, no separate trust store, and nowhere that the same key material is duplicated. What varies inside that one row is which field a given key occupies.
  • The security reason this is the right reading, not merely the compatible one: fusing the two would force every server to hold its party root online to sign requests, deleting exactly the compromise containment §10.3 chose the operational-key tier for. The offline root's whole value is that a compromised server cannot mint identities.

3.2 The party row's federation field group

[M-K9] A party row MAY carry the anchor group; a row without it is a party you have not keyed (grants nothing custody-wise, which is the default). The group is ONE nested object, custody:, on the row (AMENDED 2026-09-21; until then the keys sat flat on the row, and there are no aliases for the flat spellings — a flat root_key is not a pin; the ssr-custody-group migration that moved one ran clean on every project and retired 2026-09-22):

name: acme
custody:
  root_key: "<base64url-unpadded raw public key>"   # the pinned custody root
  root_alg: ed25519
  kem_key: "<base64url-unpadded raw public key>"    # OPTIONAL (§4), reserved — v1 reads neither kem key
  kem_alg: x25519
  emails:                                           # who may sign AS this party
    - alice@example.com
  scope: /100004/                                   # OPTIONAL path bound (§3.4)
  anchored: 2026-01-15                              # date of the out-of-band TOFU
  fingerprint: "<64 hex>"                           # OPTIONAL, redundant: SHA-256(root_key raw)

Why a group, and why on this registry only. The ssr row is a procurement package — cost, schedule, deliverables, a scope-of-work statement — with the counterparty's key material as a minority of its fields, and flat those five keys read as unrelated columns ("Root alg" next to "Estimator"). Nesting them is the shape every register already uses for a related set (value:, milestones:, udf:; columns address the leaves as JSON pointers), so it adds no mechanic. It also dissolves a name collision the flat shape forced: the ssr row's own scope is the package's scope-of-work TEXT, while this group's scope is a path bound — one name, two rules, which the Go reader could only avoid by refusing to read the row's scope at all. Under the group they are scope and custody/scope, and [M-K14] is read from the row. The users/ and peers/ registries keep their key fields flat on purpose: those rows ARE key records (a person's signing key, a link's transport key), so a group there would be the whole row wearing a second name.

  • [M-K10] fingerprint, when present, MUST equal SHA-256(raw root_key bytes) in lowercase hex ([M-E11]); a mismatch MUST fail the row closed (unknown_key) rather than preferring either value. It exists so an operator can compare an out-of-band fingerprint against the row by eye — the one number exchanged over the phone (memo §6.16).
  • [M-K11] Counterparties pin the ROOT (memo §10.10.1, DECIDED). Operational keys are learned from root-signed anchor-update statements on the link, never anchored individually. Consequence, stated honestly in the memo and repeated here so no implementation over-claims: at org scale the emails allowlist is enforced by the org's issuance discipline plus the counterparty's own emails filter (§3.3) — not by the counterparty cryptographically constraining what the root may mint.
  • [M-K12] Public halves are not secrets and MUST travel — a mirror cannot enforce locally without them (memo §2e). The party registry is an ordinary ACL-governed directory. Only the enclave is hidden.

3.3 The emails allowlist — and the term collision

[M-K13] The party row's emails is the set of acting persons who may sign as this party: a statement whose signing key is certified to alice@example.com is accepted only if the verifier's row for that party lists a matching entry. Match forms, case-insensitive, exactly as the shipped transport allowlist (peer.AllowForwarded): alice@example.com (exact), @example.com / *@example.com (whole domain). An empty or absent emails means no acting person is accepted — a length-2 (org) chain fails closed; a length-1 (self-certified individual) chain does not consult it at all (§5.1).

Term collision, flagged (decisions.md D-13). emails on a peers row means something different: which forwarded X-ZDDC-User identities a link may assert (S6's confused-deputy guard). Same key name, same match syntax, different question — "who may act over this channel" vs "who may sign as this party". They are deliberately not merged (a site instance forwards people who never sign anything), but one of them should probably be renamed before Stage 4.

3.4 Scope

[M-K14] custody/scope, when present, is a path prefix bounding where this party's statements are accepted — the same narrowing shape as the shipped token scope (S1). It NARROWS only: an anchor grants nothing by itself (memo §3 — "identity is per-PARTY; authorization is per-project"), so a scope can only further restrict what a grant already opened.

Which frame it is evaluated in (DECIDED 2026-08-13 — as written, the rule was unimplementable). The old wording said a verifier MUST reject a statement whose manifest paths fall outside the scope. It cannot: scope is a path in the verifier's own tree (/100004/), while [M-M9] guarantees manifest paths are relative to the transmittal container (drawings/PLT-100_B_IFR.pdf). The two are in different frames, so a literal prefix test can never match and the requirement is dead on arrival — which is exactly how both reference implementations left it: written, inert, matching nothing. The frame is now pinned:

  • scope is evaluated against the CONTAINER'S LOCATION, supplied by the verifier's context (§5.0's container) — never against manifest paths. The verifier knows where the container it is reading lives (archive/<party>/issued/<mailbox>/<transmittal>/ or wherever it mounted the zip); that address and scope are in the same frame, and comparing them is the question the requirement was always asking.
  • The bound is on the party's statements, not on one statement type. A receipt, an anchor-update and a transmittal read out of a container outside the scope are all refused: the requirement says where this party's statements are accepted, and a rule that fenced only the types carrying a manifest would leave the link-level acts — the ones that change keys — unfenced, which is backwards.
  • This is not a workaround, it is the correct test. Manifest paths are container-relative by construction, so every one of them is inside the container's location: if the container is within scope, so is everything it contains, and if it is not, no manifest path can rescue it. Testing the container is both necessary and sufficient — testing the paths was a category error.
  • A consent-withdrawal's scope ([M-S34]) is compared directly, since both are prefixes; a leading / on either is insignificant and MUST be stripped before comparison. Violation of either test: bad_link.
  • A verifier that has not been told where the container lives MUST NOT evaluate this rule, and MUST NOT report it as satisfied — an offline reader handed a loose zip genuinely does not know, and silently passing is the one answer that is never honest. Not evaluating a narrowing rule is safe; the grant the scope narrows is enforced elsewhere ([M-K14]'s whole point is that an anchor grants nothing on its own).

Both halves are vector-covered as of 2026-08-13: envelope/valid/12 supplies a container inside the scope and envelope/invalid/37 one outside it, on otherwise identical bytes. Until a context carried a container the rule was written, inert, and matching nothing in either implementation — which is a requirement that exists only in prose, and the reason the context key was added rather than the rule left as a known gap.

4. The KEM key — present, unused, deferred behind the hook

[M-K15] kem_key/kem_alg are OPTIONAL (memo §5b DECIDED: "the anchor is signing key + optional encryption key", not a mandatory two-key anchor). v1 defines the fields and nothing else:

  • A v1 producer MUST NOT encrypt anything; a v1 verifier MUST NOT attempt decryption and MUST NOT reject a manifest entry carrying enc ([M-M8]) — the ciphertext still verifies against root, which is exactly the memo's "a mirror verifies the blob without being able to read it".
  • The KEM key is party-level, separate from the signing hierarchy (memo §10.10.2), so operational-signing-key turnover never strands ciphertext. Its rotation/escrow discipline is deferred with the encryption envelope itself.
  • Hook name for conformance.md §4: kem_key + the manifest's enc field. Nothing else in v1 mentions encryption.

5. Anchor state and the verification chain

[M-K16] "Anchor state" is the verifier's local, explicitly-dated view of one party's keys: the party row plus every verified anchor-update statement received on that link, in prev-chain order, up to the verifier's last successful link exchange. It is derived state — recomputable by rescanning the link's _ledger/, never stored as a separate truth (the server/browser split rule; memo §6.12).

5.0 The shape of that derived state

Because it is derived, nothing about it is a wire format and an implementation may hold it however it likes — but a verifier is given it, and the conformance vectors have to hand one over, so the keys it must carry are specified here. (vectors/contexts.json is exactly this shape; before this section existed, an implementer had to reverse-engineer it from that fixture.)

Per link, two dated instants and the two parties:

key meaning
as_of the verifier's last successful trust-anchor update from this link ([M-K21]). Every result MUST be presented beside it.
now the verifier's own clock, which is the evaluation instant when no receipt is held ([M-K19]). Supplied, never read — a verifier that reads a clock during verification cannot be tested ([M-R4]).
link.parties the two ends, as party fingerprints. A set, not a pair — see [M-S9].
container OPTIONAL. Where the container this statement was read from lives, in the verifier's own tree frame — 100004/archive/acme/issued/default/TR-0007/, or wherever a .zip was mounted. The only frame in which an anchor's scope is meaningful (§3.4). Absent is a real answer, not a default: an offline reader handed a loose zip genuinely does not know, and a verifier that has not been told MUST NOT evaluate the scope rule and MUST NOT report it as satisfied.
anchors one entry per party, below.
record_signers OPTIONAL (added 2026-08-16). The keys trusted to sign this server's local record-revision statements (§8, [M-K31]; statements.md [M-S61]) — one entry per key: key (base64url-unpadded raw public — 32 Ed25519 bytes, or the 65-byte uncompressed P-256 point for a personal-webauthn entry), kind (the [M-S59] token it may vouch as), optional name, and — for the personal kinds — email, the identity the key is registered to ([M-S64]; §9/§10). A personal-webauthn entry additionally carries rp_id and credential_id (§10, [M-K33]). Consulted ONLY by the local type; the seven link types never resolve against it, and a record signer is not thereby a party anchor in either direction ([M-K30]'s never-interchanged rule, extended). Empty or absent refuses every record-revision (unknown_key) — never a pass.

Per party, the [M-K9] row fields — party, name, root_key, root_alg, kem_key, kem_alg, emails, scope, anchored, fingerprint — plus one derived member:

key meaning
operational the party's operational keys as the verifier has learned them, folded from every verified root-signed anchor-update in prev-chain order ([M-S33]: a later op supersedes an earlier one for the same key). This member is never in the party row on disk — a counterparty pins the root and learns the rest ([M-K11]), so it exists only in derived state.

Each operational entry carries the certifying op's own fields — key, alg, kind, email, not_before, not_after ([M-S30]) — plus the outcome of any revoke op applied to it ([M-S31]):

key meaning
revoked a revoke op has been applied to this key.
compromise_date the revoking op's calendar date, when it carried one. Signatures at or after it are invalid ([M-K24]).
revoked_at the instant from which a revocation WITHOUT a compromise date takes effect; signatures before it stay valid ([M-S31]). Derived-state only, never on the wire. A revoked entry with neither of these two fails closed.

An entry that fails any of its own consistency rules ([M-K10]'s fingerprint agreement, a root_alg this version does not implement) fails the row closed. A single undecodable operational entry does not: it authenticates nobody, so it is skipped, and a broken entry must not be able to mask a valid one.

"Undecodable" includes "not an entry at all". An operational member that is null, a string, a number or an array is skipped exactly as one whose key will not decode is — and a verifier MUST NOT fail, throw, or abort the walk on it. This is robustness rather than protocol: derived state is a local file, so a corrupt one is a local accident, and the honest behaviour is a refusal that names the key as unanchored, not a broken page. (One reference implementation threw a TypeError here and took the whole verification down with it — decisions.md, D-S12. A verifier that cannot survive its own state file cannot report on anything else in it either.)

5.1 The chain is length 1 or 2

[M-K17] One verification algorithm covers both (memo §3):

  • Length 1 — self-certified (rung 1, an individual). The signing key IS the anchored root_key. emails is not consulted; the key is the identity. There is no compromise containment and the spec says so.

  • Length 2 — org. The signing key is an operational key learned from an anchor-update statement signed by the anchored root_key (statements.md §5). The verifier MUST check, in order:

    1. the anchor-update envelope verifies under the pinned root ([M-E*]);
    2. the op naming this key is add or rotate, and no later verified op revokes it with a compromise_date at or before the statement's evaluation instant;
    3. the statement's evaluation instant falls within the op's [not_before, not_after] window. Both endpoints are INCLUSIVE: the window is not_before ≤ t ≤ not_after. The square brackets already said so, but leaving an endpoint to be inferred is how two implementations end up disagreeing on exactly one second and nobody notices for a year — and neither endpoint is a boundary any vector lands on, so the suite would not catch it either;
    4. the op's email matches the statement's from.person, and that address matches the party row's emails ([M-K13]).

    Any failure → unknown_key.

[M-K18] There is no length-3 chain. An operational key MUST NOT certify another key; only an anchored root may sign anchor-update. A verifier MUST reject an anchor-update signed by a non-root key (unknown_key).

5.2 The evaluation instant

[M-K19] The instant a statement's key validity is judged at is, in priority order: (1) the receipt timestamp if the verifier holds a verified receipt for this statement — the bilateral timestamp is the only trusted time (memo §6.5); else (2) the verifier's own clock at verification time. The statement's own ts is informational and MUST NOT be used — a signer controls it, and an attacker holding a stolen key controls it too (memo §10.5's reason for the compromise-date rule).

5.3 The tri-state result

[M-K20] Verification returns exactly one of (memo §10.5):

Result Meaning
valid signed by a key anchored and un-revoked as of the verifier's anchor state, whose state covers the evaluation instant
unknown-because-stale signature arithmetic passes, but the verifier's anchor state is older than the evaluation instant — it cannot know whether a revocation exists
invalid anything else

[M-K21] Every surface presenting a result MUST present the anchor state's as-of date alongside it. The honest sentence a ZDDC verifier makes is: "signed by a key that was root-certified and un-revoked as of <as-of>, the verifier's last trust-anchor update from this link." Presenting valid without the as-of date over-claims, which is the exact failure the memo's §10.5 exists to prevent.

[M-K22] Honest limit, stated in the spec because implementations must not paper over it: a thief-signed artifact predating the revocation notice verifies forever offline. Compromise dates invalidate after the date; they cannot reach into a package a verifier already holds and never re-checks.

6. Rotation, revocation, succession, expiry

v1 invents nothing here — it records what the memo decided:

  • [M-K23] Rotation of an operational key = an anchor-update with a rotate op, signed by the root, on each link. No counterparty edit is needed (that is the whole reason the root is pinned — [M-K11]).
  • [M-K24] Revocation is an anchor-update op, not a type (memo §6.11.3): {"op": "revoke", "key": …, "compromise_date": "YYYY-MM-DD"}. Signatures made after compromise_date MUST be treated as invalid even if otherwise well-formed and even if timestamped — an attacker holding the key can also obtain timestamps (memo §10.5).
  • [M-K25] Root succession is the succession statement (statements.md §7), signed by the new root and, when the old key survives, counter-signed by it in the same envelope ([M-E14]). History is never rewritten: the registry row appends the succession event (old key, new key, date, old→new name) and thread-joining across the event is done by the row, not by editing statements (memo §6.15).
  • [M-K26] Expiry. Root keys carry no expiry in v1 — they are pinned out-of-band and replaced by succession. Operational keys carry not_before/not_after in their certifying op; "short-lived" is the memo's posture, and this spec sets no mandatory maximum lifetime (decisions.md D-14 — a mandated ceiling would strand offline rung-1 producers who cannot re-certify without a network).
  • [M-K27] Key-lifecycle acts ride the link and are receipted like any statement (memo §3, §10.11): there is no CRL, no OCSP, no key server, and no fetch during verification. A verifier that reaches the network to decide a signature is non-conforming — offline verification is the constraint the whole trust model was chosen for.

7. Bootstrapping (TOFU) — what is specified and what is not

[M-K28] Establishing an anchor is an out-of-band act — contract, phone, in person — comparing the fingerprint ([M-K10]). No statement type establishes a link (memo §6.15: "establishment needs NO statement type"); the link exists when both sides hold each other's row. An inbound statement from an unanchored key is refused at the door: never queued, never provisionally accepted, never auto-anchored (memo §6.18, [M-E21]).

[M-K29] An email address inside an anchor is an unverifiable self-claim for an individual; the out-of-band channel, not the address, is the binding (memo §3). Implementations MUST NOT present an anchored email as a verified attribute of a person.

8. The server record-signing key (added 2026-08-16)

The producer half of the local record-revision statement (statements.md §11; docs/design/record-signing.md S2). One new requirement id; everything else is the existing key-file law applied to one more file.

[M-K31] The server operational key for record signing is ONE key file: <root>/.zddc.d/keys/server.key.json, in exactly the [M-K1] shape (kind: "sign", alg: "ed25519", raw halves per [M-K2]) — the same file law the transport link key already lives under, deliberately, so there is one key-file format in the system. The rules around it:

  • Absent means the server does not sign. The revision hash chain still stamps (previous_sha is not gated on a key); no envelope is produced, and that is a legitimate standing state, not an error. Enabling signing is an operator door — a deliberate act, never automatic: a key minted silently on first write is a signature nobody decided to make, which is the exact posture §6.16's "a signature the user didn't knowingly make is worse than none" forbids.
  • Existing but unreadable is an error, never a re-mint — [M-K6] applies verbatim, and for a producer it means the write path fails closed rather than quietly continuing unsigned: a server that silently stops signing has downgraded its own evidence without anyone deciding that either.
  • The verification context is the key's public half — a verifier is told which keys may sign a server's record revisions (§5.0 record_signers), out of band, exactly as a counterparty is told a party root ([M-K28]). [M-K8]/[M-K30] extend naturally: a record signer is not a custody party anchor, not a transport key, and neither of those is a record signer — three keys, three questions, never interchanged.
  • v1 carries no rotation or revocation machinery for record signers — stated as the honest limit it is ([M-S61]'s no-staleness-axis consequence). Replacing the key is an operator act; envelopes signed by the old key stay verifiable exactly while the verifier keeps the old public half in its set, which is also the escape hatch: the set is a set.
  • The private half never leaves the enclave ([M-K4]); passphrase wrapping ([M-K3]) is not supported for this key in v1 — a wrapped key cannot sign unattended at the write chokepoint, so supporting the field would advertise a shape the producer must refuse anyway.

9. The personal signing key and the users registry (added 2026-08-17)

The S3 addendum (docs/design/personal-signing.md, DECIDED 2026-08-17): the person-held key behind key_kind: "personal-software" statements and the registry row that makes its signatures resolvable.

[M-K32] A person's signing key is registered as a site-level registry row — <root>/users/<email>.yaml, the filename being the identity — an ordinary record (schema-validated, audit-stamped, revision-chained, and therefore itself server-signed wherever S2 signing is on):

sign_key: "<base64url-unpadded raw Ed25519 public key>"
sign_alg: ed25519
key_kind: personal-software     # what this key may vouch as ([M-S59])
fingerprint: "<64 hex>"         # OPTIONAL, redundant: SHA-256(sign_key raw)
registered: 2026-08-20          # date of the deliberate registration

The rules, each an existing rule applied one registry over:

  • Identity, never authorization. Nothing reads users/ to decide access; grants keep naming emails, and a registered key changes what a person can prove, never what they can do. (Federation §3's line, [M-K8]/[M-K30]'s never-interchanged discipline: a users row is not a party anchor, not a transport key, not a record-signer grant.)
  • The row feeds the verifier's record-signer set as an entry {key: sign_key, kind: key_kind, email: <the filename>} — which is what [M-S64] binds a personal statement's principal against.
  • sign_alg follows [M-K7] — absent or unimplemented fails the row closed (unsupported_type), never defaults; fingerprint follows [M-K10] — a mismatch with sign_key fails the row closed.
  • Self-service for your own row; authority for others' (the token-store posture): the door accepts a write to users/<email>.yaml from the authenticated <email> themselves, and requires site config-edit for anyone else's row. The registration is thereby an authenticated self-claim — [M-K29] applies verbatim, and an implementation MUST NOT present a registered key as a verified attribute of the person beyond "their authenticated session registered it".
  • Rotation is replacement; there is no revocation instant in v1 — the same honest limit as [M-K31]: replacing the row stops future acceptance under the old key, existing envelopes verify while a verifier keeps the old public half, and the row's own revision chain (plus S2's envelopes over it) is the key history.
  • The private half is the person's file — [M-K1] shape, [M-K2] raw bytes, [M-K5] never browser origin storage, [M-K3] wrapping optional and off by default. It lives in a folder the person chooses; the server never holds it, which is the whole point: a personal envelope is producible only where the person is.

10. The WebAuthn credential and the users registry (added 2026-08-17)

The S4 addendum (docs/design/webauthn.md, DECIDED 2026-08-17): the hardware-authenticator credential behind key_kind: "personal-webauthn" statements (envelope.md §8) and the registry group that makes its assertions resolvable. There is deliberately no key FILE here at all — the private half lives inside the authenticator and never exists as software, which is [M-K4] taken to its limit.

[M-K33] A person's WebAuthn credential is registered as an OPTIONAL flat group on the same users-registry row [M-K32] defines — one row stays "everything this identity can prove", one place to rotate:

# users/alice@example.com.yaml — the [M-K32] fields, plus:
webauthn_key: "<base64url-unpadded 65-byte uncompressed SEC1 P-256 point>"
webauthn_alg: es256                    # what the authenticator minted
webauthn_credential_id: "<base64url>"  # the authenticator's handle
webauthn_rp_id: "zddc.example"         # the origin family it is bound to
webauthn_registered: 2026-08-20        # date of the deliberate registration

The rules, each an existing rule applied one field group over:

  • The group is fixed-kind by construction (personal-webauthn); the row's key_kind field keeps describing sign_key alone. A person holds both kinds at once — the file key for file:// and offline, the credential for online — and each resolves independently.
  • The row feeds the verifier's record-signer set as an entry {key: webauthn_key, kind: personal-webauthn, email: <the filename>, rp_id: webauthn_rp_id, credential_id: webauthn_credential_id}. [M-S64] binds principal against email exactly as for the software kind; [M-E26] step 4 binds the assertion's rpIdHash against rp_id; the envelope's credential_id MUST equal the entry's (unknown_key otherwise). An entry missing rp_id or email can verify nothing — unknown_key, the no-identity posture of [M-S64].
  • webauthn_alg follows [M-K7]'s refuse-don't-guess: es256 is the one implemented value ([M-E27], scoped to this profile only — a party anchor declaring ecdsa-p256 still refuses); absent or anything else fails the group closed (unsupported_type). Fingerprint vocabulary is [M-K10]'s, over the 65 raw point bytes.
  • ONE credential per row (v1, stated limit): rows are records — form-rendered, CSV round-tripped — and a credential list would tax every such surface for a need rotation-by-replacement already covers. Losing a signing credential is not lockout: re-register from your authenticated session ([M-K32]'s self-service door).
  • Registration is a self-claim, attestation none. The registering page reads the credential's public key client-side and writes the row through the [M-K32] door; no attestation chain is requested or verified — provenance-of-hardware is vendor-CA machinery this profile deliberately omits, and [M-K29] applies verbatim: an implementation MUST NOT present the registration as more than "their authenticated session registered it". What the ASSERTION later proves — possession plus presence within the registered RP scope — is [M-E26]'s business, not registration's.
  • Rotation is replacement; no revocation instant; the row's chain is the history — [M-K32]'s lifecycle verbatim. The honest limit unique to this kind: the credential is origin-bound (rp_id), so moving servers means re-registering where a key file just travels — stated in the registration dialog, recorded in the row.
Rendered from “Keys, Anchors & Trust State” ZDDC