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.algper §2.createdper [M-C6].- [M-K2] For Ed25519,
privateis the 32-byte seed andpublicis 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.mdD-11. Raw bytes are whatkeyidalready hashes ([M-E11]), what the peers registry already stores, and what Web Crypto imports asraw/pkcs8with 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
privatewith"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).iterMUST 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 inhandler.ReservedSidecarandcache.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 bysuccession.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 equalSHA-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-updatestatements on the link, never anchored individually. Consequence, stated honestly in the memo and repeated here so no implementation over-claims: at org scale theemailsallowlist is enforced by the org's issuance discipline plus the counterparty's ownemailsfilter (§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.mdD-13).emailson a peers row means something different: which forwardedX-ZDDC-Useridentities 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:
scopeis evaluated against the CONTAINER'S LOCATION, supplied by the verifier's context (§5.0'scontainer) — 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 andscopeare 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, ananchor-updateand atransmittalread 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'sscope([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 againstroot, 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'sencfield. 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.emailsis 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-updatestatement signed by the anchoredroot_key(statements.md§5). The verifier MUST check, in order:- the
anchor-updateenvelope verifies under the pinned root ([M-E*]); - the op naming this key is
addorrotate, and no later verified oprevokes it with acompromise_dateat or before the statement's evaluation instant; - the statement's evaluation instant falls within the op's
[not_before, not_after]window. Both endpoints are INCLUSIVE: the window isnot_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; - the op's
emailmatches the statement'sfrom.person, and that address matches the party row'semails([M-K13]).
Any failure →
unknown_key.- the
[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-updatewith arotateop, 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-updateop, not a type (memo §6.11.3):{"op": "revoke", "key": …, "compromise_date": "YYYY-MM-DD"}. Signatures made aftercompromise_dateMUST be treated asinvalideven 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
successionstatement (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_afterin their certifying op; "short-lived" is the memo's posture, and this spec sets no mandatory maximum lifetime (decisions.mdD-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_shais 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 registrationThe 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'sprincipalagainst. sign_algfollows [M-K7] — absent or unimplemented fails the row closed (unsupported_type), never defaults;fingerprintfollows [M-K10] — a mismatch withsign_keyfails the row closed.- Self-service for your own row; authority for
others' (the token-store posture): the door accepts a
write to
users/<email>.yamlfrom 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 registrationThe rules, each an existing rule applied one field group over:
- The group is fixed-kind by construction
(
personal-webauthn); the row'skey_kindfield keeps describingsign_keyalone. A person holds both kinds at once — the file key forfile://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] bindsprincipalagainstemailexactly as for the software kind; [M-E26] step 4 binds the assertion'srpIdHashagainstrp_id; the envelope'scredential_idMUST equal the entry's (unknown_keyotherwise). An entry missingrp_idoremailcan verify nothing —unknown_key, the no-identity posture of [M-S64]. webauthn_algfollows [M-K7]'s refuse-don't-guess:es256is the one implemented value ([M-E27], scoped to this profile only — a party anchor declaringecdsa-p256still 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.