Status: APPROVED 2026-08-12. Transcribes
federation-and-custody.md §6.1–§6.4, §6.11,
§10.2, §10.10.3. Requirement ids [M-E*]; the
canonical payload rules [M-C*] live in manifest.md §1 and are
referenced, not repeated. MUST/SHOULD/MAY per RFC 2119.
DSSE (in-toto's Dead Simple Signing Envelope) was chosen
over COSE and JWS because its PAE is ~10 lines of browser JS,
needs neither ASN.1 nor CBOR, and has none of JWS's
alg-confusion history (memo §6, §10.2). This
document profiles it: what a ZDDC envelope may contain, how
the pre-authentication encoding is computed byte-for-byte, how
a signing key is named, and the exact verification procedure
with every MUST-reject enumerated.
1. The envelope object
An envelope is a JSON object with exactly three members:
{
"payloadType": "application/vnd.zddc.transmittal+json; v=1",
"payload": "eyJmcm9tIjogey4uLg==",
"signatures": [
{"keyid": "3f2a…64hex", "sig": "MEUCIQ…base64"}
]
}- [M-E1] An envelope MUST be a JSON object
carrying exactly the members
payloadType(string),payload(string), andsignatures(array). Additional top-level members MUST be rejected (malformed). Unlike payload fields ([M-C7]), envelope members are closed — the envelope is the security perimeter, and an unrecognized member there is a signal that the producer meant something this verifier does not implement. - [M-E2]
payloadMUST be the canonical payload bytes ([M-C1]) encoded with standard base64 (RFC 4648 §4, with padding, no line breaks). A verifier MUST reject base64url, unpadded base64, embedded whitespace, or any input that does not round-trip (malformed). - [M-E3]
signaturesMUST be a non-empty array of objects each carrying exactlykeyid(string) andsig(string) — plus, since the S4 addendum (2026-08-17), an OPTIONALwebauthnmember whose shape and permitted placement are [M-E25]'s.sigis standard base64 as in [M-E2]. DSSE's optionalcertmember is not part of this profile and MUST be rejected (malformed) — there are no certificates in the raw-key world (memo §11.4). Any other member on a signature entry is rejected (malformed), exactly as [M-E1] closes the envelope itself. - Envelope files on disk are the pretty-or-compact JSON the
producer chose: the envelope itself is not
canonicalized, because nothing signs it. Only
payload's decoded bytes are canonical, and only those bytes are signed. A verifier MUST NOT re-serialize the envelope as part of verification.
2.
payloadType — the type registry and domain
separation
[M-E4]
payloadTypeMUST be exactlyapplication/vnd.zddc.<type>+json; v=1where<type>is one of the nine v1 registry entries — the seven link statement types (statements.md§1) plus the localrecord-revision(statements.md§11, added 2026-08-16) and the localcontent-attestation(statements.md§12, added 2026-08-17):<type>payloadType transmittalapplication/vnd.zddc.transmittal+json; v=1receiptapplication/vnd.zddc.receipt+json; v=1responseapplication/vnd.zddc.response+json; v=1withdrawalapplication/vnd.zddc.withdrawal+json; v=1anchor-updateapplication/vnd.zddc.anchor-update+json; v=1consent-withdrawalapplication/vnd.zddc.consent-withdrawal+json; v=1successionapplication/vnd.zddc.succession+json; v=1record-revisionapplication/vnd.zddc.record-revision+json; v=1content-attestationapplication/vnd.zddc.content-attestation+json; v=1The string is compared byte-exactly: lowercase, one ASCII space after the semicolon,
v=1lowercase, no other parameters, no trailing space. Any other string — including a case variant, a re-ordered parameter, or av=2— MUST be rejected (unsupported_type). No media-type parsing is performed and none is permitted: a parser that treats;v=1as equivalent would accept two byte-strings for one type, and the type string is inside the PAE.The type string carries the version, so changed semantics can never verify under old rules (memo §6.11). A v2 of any type is a new string that every v1 verifier refuses; there is no negotiation and no downgrade.
[M-E5] Domain separation is the point of the registry: because the type string is a PAE field, a signature over a
receiptpayload can never be presented as atransmittal(memo §6.2 / §10.10.3). A verifier MUST derive the statement type frompayloadTypealone and MUST NOT infer it from payload contents.
3. PAE — the pre-authentication encoding
DSSE PAE, verbatim from the DSSE spec, no ZDDC changes:
PAE(type, body) = "DSSEv1" ‖ SP ‖ LEN(type) ‖ SP ‖ type ‖ SP ‖ LEN(body) ‖ SP ‖ body
where SP is one ASCII space (0x20),
LEN(x) is the byte length of
x rendered in ASCII decimal with no leading zeros
and no sign, type is the UTF-8 bytes of
payloadType, and body is the
decoded canonical payload bytes. There is no
trailing separator and no NUL.
- [M-E6] The signed message is exactly
PAE(payloadType, payload_bytes). A signature computed over the payload alone, over the base64 text, over a differently-typed PAE, or over any other framing MUST fail verification (bad_signature, orbad_paewhen a runner can attribute the failure). - [M-E7]
LENcounts bytes, not characters. A payload containing non-ASCII text has a byte length larger than its character count; getting this wrong is the classic cross-implementation PAE bug, so a conforming implementation MUST pass the non-ASCII PAE vector.
Worked example (the
envelope/valid/01-transmittal-minimal vector
carries the byte-exact expected PAE in hex; this is the
shape):
44 53 53 45 76 31 20 34 34 20 61 70 70 6c …
D S S E v 1 SP 4 4 SP a p p l …
4. Algorithm and key identification
[M-E8] The signature algorithm is not carried in the envelope. It is bound to the key: the verifier learns it from the anchor entry that declares the key (
keys.md§2). This is memo §6.4's "algorithm bound to the anchor, never chosen by the artifact" — it removes the downgrade surface entirely. A verifier MUST NOT accept an algorithm hint from any part of the envelope or payload.[M-E9] v1 implements Ed25519 (RFC 8032) for every signature entry except one: a
webauthnentry ([M-E25]) verifies under the ES256 assertion profile ([M-E27]) — the S4 addendum, and the ONLY place ECDSA-P-256 is implemented. ECDSA-P-256 remains reserved by the party anchor'salgfield for reach (memo §6.4): no v1 producer emits it on a link statement and a v1 verifier MUST still reject a party anchor declaring it asunsupported_typerather than guessing ([M-K7] is unchanged). Ed25519 signatures are 64 bytes; on an entry without awebauthnmember any other length MUST be rejected (malformed).[M-E10] Ed25519 verification semantics are pinned to remove the "Taming the many EdDSAs" divergence (memo §6.4), because the browser and the server must agree bit for bit or the same artifact verifies in one and not the other. A conforming verifier MUST implement exactly this rule, and MUST NOT add checks to it or remove checks from it:
- The signature MUST be 64 bytes ([M-E9]). Split it as
R = sig[0..32],S = sig[32..64], both little-endian. SMUST be canonical: interpreted as a little-endian integer,S < LwhereL = 2^252 + 27742317777372353535851937790883648493.S ≥ LMUST be rejected — includingSvalues congruent to a valid scalar modL, which is the malleability case.A(the anchored public key) MUST decode as a point; a decode failure MUST be rejected.Ris never decoded — it is a 32-byte string that rule 5 compares, and nothing else.k = SHA-512(R ‖ A ‖ M) mod L, whereRandAare the encoded bytes as received, not re-encodings of the decoded point.- The verifier MUST use the cofactorless
equation
[S]B = R + [k]A, evaluated by recomputingR' = [S]B − [k]Aand comparing the 32-byte encoding ofR'against the receivedRbytes. The comparison is over encodings, never over point values. - The verifier MUST NOT add a small-order-
Arejection, a small-order-Rrejection, or a separate canonicality check onR. Rule 5 already makes every non-canonical encoding ofRfail, since a recomputedR'is always canonical; an extra check changes no outcome, and a cofactored equation ([8S]B = [8]R + [8k]A) changes several — it accepts signatures rule 5 rejects, which is the single largest source of browser/server disagreement in the wild.
This is measured behaviour, not a reading of RFC 8032. The rule is the observed, identical behaviour of the two reference implementations — Web Crypto's
Ed25519(BoringSSL, in the.htmltools) and Go'scrypto/ed25519(inzddc-server) — across 169 cases: the 151-case Project Wycheproof corpus, the 12ed25519-speccheckcases from the "many EdDSAs" paper (both vendored atcorpora/and re-run against those exact committed bytes), and the six ZDDC envelope transforms ofenvelope/valid/01. The two implementations agreed on all 169; there is no case in that corpus where one accepts and the other rejects, so the contract guarantees the full behaviour rather than an intersection, and ZDDC refuses nothing that either implementation would accept (decisions.mdD-19 records the run).Two consequences worth stating outright, because both are places where a well-intentioned implementation would drift:
- A small-order or otherwise degenerate
Ais NOT rejected at verification time. Both implementations verify signatures under a small-order public key, and rule 6 requires conforming verifiers to do the same. This is safe here only because [M-E11] and [M-E12] make a key usable solely by resolving it through anchor state — a key nobody anchored can never reach rule 1. Key acceptability is the anchor layer's job (keys.md), not the signature layer's. - Neither implementation is RFC 8032 §5.1.3-strict
about decoding
A. Both accept a public key encoded asy = p − 1with the x-sign bit set (ecff…ff), which §5.1.3 says MUST fail to decode because the only point with thatyhasx = 0. They deviate identically, so the contract holds; but a v1 implementation MUST NOT "fix" this, because fixing it unilaterally is precisely how the browser and the server stop agreeing.
Rules 2, 5 and 6 are exercised by
envelope/invalid/32–35. The small-order-Aand cofactored/cofactorless axes are not expressible as ZDDC envelope vectors — they are pinned by the vendored corpora that [M-R12] requires a runner to execute (corpora/README.mdnames the exact deciding cases;vectors/README.md§5 records why no vector can stand in for them).[M-E10] in the browser is a statement about the PLATFORM
The two halves of this contract are not symmetric, and pretending otherwise would misdescribe what a browser verifier can promise.
Go's
crypto/ed25519is a library the server chooses. Web Crypto is not — it exposesimportKeyandverifyand no point arithmetic at all: no scalar multiplication, no point decoding, no access toR'. A browser verifier can therefore implement only rules 1 and 2 — the 64-byte length and theS < Lscalar bound, both of which are cheap integer work on the signature bytes, and both of which it does implement so the contract does not silently move if an engine's own range check ever loosens.Rules 3, 4, 5 and 6 are INHERITED FROM THE PLATFORM, not implemented. Cofactorless verification, the comparison of
R''s encoding against the receivedRbytes, and the absence of a small-order-Arejection are BoringSSL's behaviour reached through onecrypto.subtle.verifycall. The browser half of ZDDC does not choose them and cannot choose them.The consequence, stated because it is load-bearing rather than tidy: if a future Chromium changed its verification equation, a conforming ZDDC implementation could not compensate — it could only DETECT the change. There is no code to write that would restore the old behaviour; the only available responses are to notice, to record the disagreement, and to renegotiate [M-E10] down to the new intersection (
conformance.md§2,decisions.mdD-19).Which makes [M-R12]'s corpora obligation the only real check on [M-E10] in the browser, and therefore not optional housekeeping. The four ZDDC vectors exercise rules 2 and 5 on one signature; the vendored corpora are what would actually catch a platform whose equation moved — 151 Wycheproof cases plus the 12
ed25519-speccheckcases, including the only cofactored/cofactorless discriminator either corpus contains. A browser implementation that skips them has verified nothing about the property this requirement exists to guarantee, and a Chromium bump inpackage.jsonis precisely the event that should re-run them.- The signature MUST be 64 bytes ([M-E9]). Split it as
[M-E11]
keyidMUST be the lowercase-hex SHA-256 of the raw public key bytes — exactlypeer.Fingerprintas shipped in Stage 1 (zddc/internal/peer/peer.go), so the transport layer and the custody layer name keys identically and one keyring serves both. For Ed25519 the "raw public key bytes" are the 32-byte value; for a WebAuthn ES256 credential they are the 65-byte uncompressed SEC1 point ([M-E27]) — in both cases not SPKI/DER and not a JWK. 64 hex characters; uppercase, wrong length, or non-hex MUST be rejected (malformed).[M-E12]
keyidis a lookup hint, never authority. A verifier MUST resolve the key through its anchor state (keys.md) and MUST reject an envelope whosekeyidmatches no key anchored for the statement'sfromparty (unknown_key). A verifier MUST NOT verify against a key supplied by the artifact itself.There is exactly one exception, and it is deliberate:
succession'ssignatures[0](§6 step 7). That signature is verified against the key the payload's ownnew_rootnames — a key the verifier has by definition not anchored yet, because anchoring it is what the statement exists to ask for. Everything that follows from making that exception is spelled out at step 7; read it before implementing this requirement, because a reader of [M-E12] alone would conclude the case cannot arise.
5. Multi-signature (countersigning) layout
A countersignature in ZDDC is a new statement, not
a second signature. A receipt is its own
receipt envelope referencing the transmittal by
id (memo §6.2, §10.10.3); nothing ever appends a signature to
someone else's envelope, because that would let a receipt
replay as an issuance.
succession is the single exception,
and it is not a countersignature of another party's act — it
is one party's own statement carrying proof of key
continuity. (RESOLVED 2026-08-12, decisions.md D-10. The
memo §6.15 describes this as a payload field
old_sig; a payload-embedded signature cannot be
produced without circularity, since the old key would have to
sign a payload containing its own output. The second DSSE
signature is the literal reading of "the old root's signature
over the same payload", keeps one canonical form per payload,
and is what DSSE's signature array exists for. No
old_sig payload field exists — statements.md
§7.3.)
5.1 Signature cardinality
[M-E13] For every statement type except
succession,signaturesMUST contain exactly one entry. Zero or two-or-more MUST be rejected (malformed) — including when every present signature verifies. A second valid signature on a transmittal is not a stronger transmittal; it is a shape this profile does not define.[M-E14] A
successionstatement MUST carry exactly one or exactly two entries, and the count MUST agree with its payload'slinkedboolean ([M-S38]):linkedsignatures meaning trueexactly 2 linked succession — cryptographic continuity falseexactly 1 break succession — key loss, no continuity Any other combination MUST be rejected (
malformed). Alinked: truestatement with one signature claims continuity the envelope does not carry, which is how key loss would be laundered into key theft; alinked: falsestatement with two signatures asserts a break it did not take.
5.2
Signature order and identity — succession
The array is ordered and positional. A verifier MUST NOT search the array for a signature that fits; it MUST check each index against its required signer, in order:
- [M-E15]
signatures[0]MUST be by the key named in the payload'snew_root([M-S37]) — i.e.signatures[0].keyidMUST equal the SHA-256 fingerprint of the rawnew_rootbytes. This is the signer of record: the statement is the new root's act. Mismatch →malformed. That key is not required to be anchored, which is the one exception to [M-E12] and is spelled out with its consequences at §6 step 7. - [M-E16] When
linked: true,signatures[1]MUST be by the party's currently anchored root forfrom.party(keys.md[M-K9]) — the key being succeeded. A signature by any other key MUST be rejected (unknown_key), even when it verifies and even when that key is anchored for some other party. The old root's signature is what makes the succession self-authorising; a stranger's signature authorises nothing. - [M-E17] Order is normative and MUST NOT
be repaired. An envelope presenting the old root at index 0
and the new root at index 1 MUST be rejected
(
malformed), not silently reordered. Accepting either order would mean the same two signatures could assert succession in both directions, so a leaked historical envelope could be replayed to roll a party's root backwards. - [M-E18]
keyidvalues within one envelope MUST be distinct (malformed), and each MUST resolve under the rules of §4.
5.3 No thresholds
- [M-E19] When more than one signature is
present, every signature MUST verify against
its required signer. There is no threshold, no quorum, and no
best-effort acceptance: one valid plus one invalid is a
rejected envelope (
bad_signature). A verifier that ignores an unverifiable extra signature has silently accepted an unauthenticated claim. - [M-E20] A verifier MUST NOT treat a
second signature on any non-
successiontype as additive assurance, a co-issuance, an approval, or a countersignature. Those acts are separate statements with their own payloadTypes ([M-E5]); there is exactly one place in v1 where two signatures mean anything, and this section is it.
6. The verification procedure
A verifier is given: envelope bytes, the link
context (which bilateral link this arrived on, from
keys.md §5's anchor state
for both parties), and its anchor state as-of
timestamp. It returns a tri-state crypto verdict (memo §10.5:
valid / unknown-because-stale /
invalid) and — for content statements — a
separate currency verdict (statements.md §9).
Steps run in order; the first failure stops with that reason
class.
Parse the envelope. Reject non-JSON, a non-object, a missing or wrong-typed member, an extra member, or a
certmember —malformed([M-E1], [M-E3]).Check
payloadTypebyte-exactly against the §2 registry. Unknown type or unknown version →unsupported_type([M-E4]).Decode
payloadas strict standard base64. Reject on any decode failure →malformed([M-E2]). Reject decoded length > 16 MiB →too_large([M-M13]).Parse the payload as JSON and reject a non-object →
malformed([M-C4]). Reject duplicate object keys →malformed. Reject any non-integer number, or an integer outside ±(2^53 − 1) →not_canonical([M-C3]).Re-canonicalize and byte-compare. Serialize the parsed payload per RFC 8785 and compare with the decoded bytes; any difference →
not_canonical([M-C2]). This step is what makes the statement id a function of content rather than of one serializer's habits, and it must run before signature verification so a verifier never reports "valid signature" over bytes it would not itself have produced.Check the signature array. Two groups, and they do not share a reason class — the summary matters, because the class is part of the conformance contract:
- Shape →
malformed. Non-empty; the count per [M-E13]/[M-E14] and, forsuccession, its agreement with the payload'slinked; distinctkeyids ([M-E18]); eachkeyidwell-formed lowercase hex ([M-E11]); eachsigstrict base64 decoding to 64 bytes ([M-E9]). Also in this group, because it is a self-consistency check on the artifact and needs no anchor state:signatures[0].keyidMUST be the fingerprint ofnew_root([M-E15]), and a reversed array is rejected here rather than repaired ([M-E17]). - Identity →
unknown_key. For alinkedsuccession,signatures[1]MUST name this party's currently anchored root ([M-E16]). This one asks the anchor state a question — "is this key the one I pinned for this party?" — so a signature by a well-formed but wrong key (a counterparty's root, or one of this party's own operational keys) is an unresolvable signer, not a malformed envelope.
- Shape →
Resolve every
keyidthrough the anchor state for the payload'sfromparty (keys.md§5). Unresolvable →unknown_key([M-E12]). A key resolved but not valid at the statement's evaluation instant (expired, revoked before it, or certified outside its validity window) →invalidwith reasonunknown_key; a key whose status cannot be determined because the verifier's anchor state predates the statement → the tri-stateunknown-because-stale, not acceptance (keys.md§6).Exception —
succession, index 0 (DECIDED 2026-08-13). For asuccessionstatement,signatures[0]is verified against the key named in the payload's ownnew_root([M-E15], [M-S37]) and is deliberately NOT required to be anchored. It cannot be: the new root is precisely the key the counterparty does not hold yet, and resolving index 0 through the anchor state would make every succession unverifiable. This is the single place in v1 where a verifier uses a key the artifact supplies, and it is a real exception to [M-E12], not an oversight in it. Index 1, when present, is resolved normally and is what carries the authority ([M-E16]).What follows, stated plainly because it is a sharp edge and not a footnote: an UNLINKED succession carries no cryptographic authority at all. A
linked: falsestatement is one signature, by a key of the signer's own choosing, over a payload naming that same key. Anyone can mint one, for any party, at any time — nothing in it is checkable against anything the verifier already trusts, and a conforming verifier that accepts it has verified only that the bytes are self-consistent. Its entire assurance is the out-of-band ceremony [M-S40] already requires: the counterparty re-verifies the new root by contract, phone or in person before re-anchoring, exactly as it did for the original TOFU ([M-K28]). An implementation that presents a format-valid break succession as an authenticated act, or that re-anchors on one without that ceremony, is non-conforming — it converts key loss into key theft. What a break succession is for is honest bookkeeping: it records in the ledger that continuity was lost, so the gap is visible rather than spliced over.The linked case is different in kind, not in degree:
signatures[1]by the party's currently anchored root is what makes the statement self-authorising, and the counterparty needs no ceremony for it.Compute
PAE(payloadType, payload_bytes)per §3 and verify every signature against the signer required at its index ([M-E6], [M-E19]). Every signature must verify; there is no threshold. Any failure →bad_signature.Check the common header —
from,to,ts,prev,re,thread— and the per-type body rules (statements.md§2 and the per-type sections). Link-binding violations →bad_link; body violations → their own class.[M-E23] The order of the rule families inside this step is normative, because it decides which class a statement is refused with (and a reason class is part of the conformance contract,
vectors/README.md§1). Only the order of independent checks inside one family is free:The per-type body rules run BEFORE the
re/threadlinking rules. A payload whose body is not its declared type's body ismalformed, full stop — the type comes frompayloadTypealone ([M-E5], [M-S1]), so a well-formedreceiptbody presented under thetransmittaltype is a broken transmittal, not a mis-linked receipt. Itsre/threadfields would also violate [M-S13], and reportingbad_linkthere would name the symptom while hiding the type confusion, which is the thing worth knowing.Inside a manifest entry, the structural and encoding rules run BEFORE the path-form rules — is it an object, is
sizean integer, isroot64 lowercase hex ([M-M4]), and does [M-M6]'s empty-file coupling hold (size == 0iffrootis the empty-tree hash) — thenpathagainst [M-M9]/[M-M10]. Shape before content: "this is not a manifest entry" (malformed) is a stronger and earlier statement than "this entry's path is not a legal path" (bad_path), and an entry that is not an entry has no path to judge.The empty-file coupling belongs on the structural side of that line even though it reads like a consistency check, and it is named here because leaving it unplaced is how the two reference implementations came to disagree about a
size: 0entry with a bad path — one answeringmalformed, the otherbad_path, with no vector between them. It is a claim about the entry's own two fields and needs no path at all, so it is decided in the same breath as the fields it reads.The per-type body rules run before the WHOLE common header, not only before
re/thread. Rule 1 is the special case that had a vector; this is the general statement, and the reasoning is rule 1's verbatim: the type comes frompayloadTypealone ([M-E5], [M-S1]), so "is this a transmittal at all?" is answered before "does this transmittal link correctly?". A payload that is broken in both ways is refused for the body, because the body is what says which rules the header is even being read under.The header families themselves then run in this order, so that a payload violating two of them cannot answer differently in two implementations:
fromandto— presence, shape and party form ([M-S2], [M-S3]);- link binding — membership and distinctness ([M-S9]);
- the anchor's
scopeagainst the container's location ([M-K14]); ts([M-S4]);prev([M-S5]);- the chain tip ([M-S6]);
reandthread([M-S7], [M-S8]).
Where this was DERIVED and where it was CHOSEN. Rule 1 and the position of
re/threadlast are derived —statements/invalid/37pins the first and rule 1 already stated the second. Thatfrom/toprecede everything else is derived too: [M-S9] and [M-K14] both readfrom.party, so they cannot be judged before the field they read has been. The rest —scopebeforets,tsbeforeprev,prevbefore the chain tip — was chosen, from the outside in: the link, then the statement's place on it, then its place in a thread. No vector implied an order, and two implementations had invented two.Inside each of the two phases, the [M-C11] type checks run as a block, before any of that phase's content rules, in the order the fields are listed by the spec — the body's declared fields in the order of its own §3–§7 section, the header's in the order of
statements.md§2. So a wrong-typedmanifest[].encismalformedbefore the same entry's illegalpathisbad_path; a wrong-typedfrom.nameisbad_linkbeforeto's cardinality is, and after a broken body either way. This is rule 2 generalized from the manifest entry to the whole payload, and it is what keeps [M-C11] from needing an ordering rule of its own: typing is not a third phase, it is the structural half of the phase the field lives in.
Compute the statement id = lowercase-hex SHA-256 of the canonical payload bytes ([M-C5]). The id is only meaningful now — after the envelope verified — and is what other statements reference.
[M-E24] The local statement in this
procedure. For record-revision (statements.md §11) the
steps run as numbered with three substitutions, each forced by
the same fact — a local statement has no from
party and no link:
- Step 6/7 signer resolution is against the
verifier's record-signer set ([M-K31], the
context's
record_signers) instead of any party's anchor state. Akeyidmatching no entry, an entry whose declaredkinddisagrees with the payload'skey_kind, or an empty/absent set: allunknown_key([M-S61]) — no set is never a pass, exactly as no anchor state is never a pass. [M-E13]'s exactly-one-signature rule applies as written. - The staleness branch of step 7 is not
taken: a record-signer set carries no revocation
machinery in v1, so there is nothing to be stale against and
the tri-state collapses to
valid/invalid([M-S61], [M-K31]). - Step 9 applies §11's own rules ([M-S53]–[M-S62]) in place of the common header; the [M-E23] discipline (typing as a block before content, in the spec's field order) carries over unchanged.
Nothing else moves: the envelope shape, the PAE, the canonical-form gates and [M-E10] verification are byte-for-byte the same procedure, which is the point — S2's producer signs with the machinery the link layer already pinned rather than inventing a parallel envelope.
[M-E21] A verifier MUST NOT report any
positive result before step 8 completes, and MUST NOT treat
"no anchor state at all" as a pass. An unanchored key is
unknown_key, never a TOFU acceptance: anchoring
is a deliberate out-of-band act (memo §6.18's inbound door —
no auto-anchor, never queued, never provisionally
accepted).
[M-E22] Failure MUST NOT be an oracle. A
verifier exposing results over a network surface (the server's
inbound door) answers a single refusal without the reason
class; the class is for logs, local UI, and this conformance
suite. (Not vector-testable — it is a property of a
deployment's response surface, not of a statement. Pinned
instead by the Stage 4 handler tests, per conformance.md
§5.)
7. What an envelope deliberately does not carry
Each of these is a deferral with its hook named (conformance.md
§4):
- No timestamp token. RFC 3161 is not baseline-verifiable in Web Crypto (memo §6.5, §10.2). The receipt is the bilateral timestamp. Hook: an optional payload field in a future version, ignorable by [M-C7].
- No certificate chain, no
certmember ([M-E3]) — raw keys only (memo §11.4). - No revocation material inline. Revocation
rides
anchor-updateon the link (statements.md§5); an envelope never carries a CRL/OCSP analogue. - No encryption. ZDDC does no
application-level encryption (memo §5b); an encrypted payload
file is an opaque file with
encset in the manifest ([M-M8]). The envelope is always cleartext JSON — it is metadata about a custody act, and hiding it would break offline verification. - No transparency-log inclusion proof. The
completeness head is deferred (memo §6.9); the
prevchain gives middle-completeness for free (statements.md§2).
8. The WebAuthn assertion profile (S4 addendum, 2026-08-17)
Added per docs/design/webauthn.md
(DECIDED 2026-08-17). This is the second signature
encoding the record-signing memo's S4 named — the ONLY browser
path to a hardware key. Everything about the payload
is unchanged: a personal-webauthn statement is a
record-revision (statements.md §11)
byte-for-byte in the same canonical form, and the assertion
transitively signs the same PAE every other entry signs. What
changes is where the signature bytes come from: a WebAuthn
authenticator signs
authenticatorData || SHA-256(clientDataJSON),
never our PAE directly — so the envelope carries the assertion
verbatim and the binding runs through the challenge.
[M-E25] The
webauthnsignature-entry extension. A signature entry MAY carry awebauthnmember: a JSON object with exactly three members, each a base64url-unpadded string ([M-K2]'s vocabulary — WebAuthn's own JSON serialization of these values, deliberately not [M-E2]'s standard base64, which continues to govern the entry'ssig):{ "keyid": "<64-hex SHA-256 of the 65-byte uncompressed public key point>", "sig": "<standard base64: the DER ECDSA signature EXACTLY as the authenticator returned it>", "webauthn": { "authenticator_data": "<base64url, >= 37 bytes>", "client_data_json": "<base64url: the UTF-8 clientDataJSON bytes>", "credential_id": "<base64url: the authenticator's credential handle>" } }A missing member, an extra member, a non-string member, or a value that is not strict base64url:
malformed. The extension is permitted ONLY on a local envelope —record-revisionorcontent-attestation(the §12 addendum widened this from record-revision alone) — whose payload'skey_kindispersonal-webauthn, and the coupling holds in BOTH directions (statements.md[M-S65]): awebauthnmember on any linkpayloadTypeor under any otherkey_kindismalformed, and apersonal-webauthnstatement whose signature entry carries nowebauthnmember ismalformed— there is no Ed25519 spelling of a hardware assertion and no assertion spelling of a software signature.[M-E26] Assertion verification replaces step 8 for the entry, run in this order, first failure wins:
- Decode and parse
client_data_json. It MUST parse as a JSON object carrying string memberstypeandchallenge(other members —origin,crossOrigin, and anything a future client adds — are carried, signature-covered, and do not fail parsing).typeMUST be exactly"webauthn.get"— anything else, including"webauthn.create", ismalformed(a registration ceremony is not a signature). - The challenge IS the payload binding.
challengeMUST equal the base64url-unpadded SHA-256 ofPAE(payloadType, payload_bytes)— the same PAE every Ed25519 entry signs directly. A mismatch isbad_pae: the assertion was made over a different payload, which is exactly the type/content confusion the PAE exists to prevent. - Decode
authenticator_data. Fewer than 37 bytes ismalformed. Split:rpIdHash = bytes[0..32],flags = bytes[32],signCount = bytes[33..37](carried, never evaluated — see the honest limits below). - The RP binding.
rpIdHashMUST equal the SHA-256 of the matched record-signer entry's registeredrp_id([M-K33]). A mismatch isunknown_key: the credential as registered does not cover the scope this assertion was minted in — the same class as every other registration-binding failure ([M-S64]). - User presence. Bit 0 of
flags(UP) MUST be set: the physical touch is the deliberate act D4 demands. Clear ismalformed. Bit 2 (UV — PIN/biometric) is recorded and reported, never required by this profile (webauthn memo F6): the verification result carries it asuser_verified, no UI may claim the stronger term when the flag says the weaker, and a deployment that wants UV-always enforces it as a verification-context policy, not a format rule. - The signature equation ([M-E27]) over
authenticator_data || SHA-256(client_data_json)with the matched entry's public key. Failure isbad_signature.
The signed material —
clientDataJSONwith itschallengeand whateveroriginthe client asserted,authenticatorDatawith its flags — is covered by the ECDSA signature, so everything checked above is tamper-evident once step 6 passes; the order exists so a failure is attributed to the most specific rule. A verifier's result SHOULD surface the assertedoriginwhenclient_data_jsoncarries one; checking it against an expected-origins list is verification-context policy (an offline verifier years later may not know the origins), and the rpIdHash check in step 4 is the load-bearing scope bind.- Decode and parse
[M-E27] The ES256 equation. ECDSA over NIST P-256 with SHA-256 (COSE alg -7), public key encoded as the 65-byte uncompressed SEC1 point (
0x04 || X || Y; a point not on the curve fails resolution as a broken entry).sigis the DER-encodedSEQUENCE { r INTEGER, s INTEGER }exactly as the authenticator produced it — carried verbatim, never transcoded ([M-E2]'s standard base64 wraps it). DER MUST be minimal (no leading zero octets beyond sign-padding, definite short-form lengths) withr, sin[1, n-1]; violations aremalformed, judged before the equation. Low-S is deliberately NOT required: authenticators emit either form, a verifier accepts both, and signature malleability buys an attacker nothing here (the statement id is the payload hash, not the envelope hash). Both reference implementations MUST agree on this equation; the conformance vectors carry known-answer assertions (generated once and pinned, since ECDSA signing is randomized — the corpora posture,vectors/README.md).
Honest limits, stated so implementations do not
over-claim: an assertion proves possession of the
credential plus user presence (and verification, when flagged)
at the signing instant, within one RP scope. The
signCount is carried but never evaluated — many
authenticators (all passkeys) return zero, and an offline
verifier could not maintain a counter anyway. The credential
is origin-bound where the software key file is portable:
re-registration per serving domain is the cost of
hardware-backed signing, stated at registration, not
discovered later.