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
The DSSE Envelope Profile — zddc-dsse-v1
Status: Draft

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), and signatures (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] payload MUST 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] signatures MUST be a non-empty array of objects each carrying exactly keyid (string) and sig (string) — plus, since the S4 addendum (2026-08-17), an OPTIONAL webauthn member whose shape and permitted placement are [M-E25]'s. sig is standard base64 as in [M-E2]. DSSE's optional cert member 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] payloadType MUST be exactly application/vnd.zddc.<type>+json; v=1 where <type> is one of the nine v1 registry entries — the seven link statement types (statements.md §1) plus the local record-revision (statements.md §11, added 2026-08-16) and the local content-attestation (statements.md §12, added 2026-08-17):

    <type> payloadType
    transmittal application/vnd.zddc.transmittal+json; v=1
    receipt application/vnd.zddc.receipt+json; v=1
    response application/vnd.zddc.response+json; v=1
    withdrawal application/vnd.zddc.withdrawal+json; v=1
    anchor-update application/vnd.zddc.anchor-update+json; v=1
    consent-withdrawal application/vnd.zddc.consent-withdrawal+json; v=1
    succession application/vnd.zddc.succession+json; v=1
    record-revision application/vnd.zddc.record-revision+json; v=1
    content-attestation application/vnd.zddc.content-attestation+json; v=1

    The string is compared byte-exactly: lowercase, one ASCII space after the semicolon, v=1 lowercase, no other parameters, no trailing space. Any other string — including a case variant, a re-ordered parameter, or a v=2 — MUST be rejected (unsupported_type). No media-type parsing is performed and none is permitted: a parser that treats ;v=1 as 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 receipt payload can never be presented as a transmittal (memo §6.2 / §10.10.3). A verifier MUST derive the statement type from payloadType alone 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, or bad_pae when a runner can attribute the failure).
  • [M-E7] LEN counts 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 webauthn entry ([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's alg field 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 as unsupported_type rather than guessing ([M-K7] is unchanged). Ed25519 signatures are 64 bytes; on an entry without a webauthn member 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:

    1. The signature MUST be 64 bytes ([M-E9]). Split it as R = sig[0..32], S = sig[32..64], both little-endian.
    2. S MUST be canonical: interpreted as a little-endian integer, S < L where L = 2^252 + 27742317777372353535851937790883648493. S ≥ L MUST be rejected — including S values congruent to a valid scalar mod L, which is the malleability case.
    3. A (the anchored public key) MUST decode as a point; a decode failure MUST be rejected. R is never decoded — it is a 32-byte string that rule 5 compares, and nothing else.
    4. k = SHA-512(R ‖ A ‖ M) mod L, where R and A are the encoded bytes as received, not re-encodings of the decoded point.
    5. The verifier MUST use the cofactorless equation [S]B = R + [k]A, evaluated by recomputing R' = [S]B − [k]A and comparing the 32-byte encoding of R' against the received R bytes. The comparison is over encodings, never over point values.
    6. The verifier MUST NOT add a small-order-A rejection, a small-order-R rejection, or a separate canonicality check on R. Rule 5 already makes every non-canonical encoding of R fail, since a recomputed R' 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 .html tools) and Go's crypto/ed25519 (in zddc-server) — across 169 cases: the 151-case Project Wycheproof corpus, the 12 ed25519-speccheck cases from the "many EdDSAs" paper (both vendored at corpora/ and re-run against those exact committed bytes), and the six ZDDC envelope transforms of envelope/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.md D-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 A is 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 as y = p − 1 with the x-sign bit set (ecff…ff), which §5.1.3 says MUST fail to decode because the only point with that y has x = 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-A and 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.md names 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/ed25519 is a library the server chooses. Web Crypto is not — it exposes importKey and verify and no point arithmetic at all: no scalar multiplication, no point decoding, no access to R'. A browser verifier can therefore implement only rules 1 and 2 — the 64-byte length and the S < L scalar 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 received R bytes, and the absence of a small-order-A rejection are BoringSSL's behaviour reached through one crypto.subtle.verify call. 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.md D-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-speccheck cases, 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 in package.json is precisely the event that should re-run them.

  • [M-E11] keyid MUST be the lowercase-hex SHA-256 of the raw public key bytes — exactly peer.Fingerprint as 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] keyid is a lookup hint, never authority. A verifier MUST resolve the key through its anchor state (keys.md) and MUST reject an envelope whose keyid matches no key anchored for the statement's from party (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's signatures[0] (§6 step 7). That signature is verified against the key the payload's own new_root names — 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, signatures MUST 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 succession statement MUST carry exactly one or exactly two entries, and the count MUST agree with its payload's linked boolean ([M-S38]):

    linked signatures meaning
    true exactly 2 linked succession — cryptographic continuity
    false exactly 1 break succession — key loss, no continuity

    Any other combination MUST be rejected (malformed). A linked: true statement with one signature claims continuity the envelope does not carry, which is how key loss would be laundered into key theft; a linked: false statement 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's new_root ([M-S37]) — i.e. signatures[0].keyid MUST equal the SHA-256 fingerprint of the raw new_root bytes. 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 for from.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] keyid values 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-succession type 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.

  1. Parse the envelope. Reject non-JSON, a non-object, a missing or wrong-typed member, an extra member, or a cert member — malformed ([M-E1], [M-E3]).

  2. Check payloadType byte-exactly against the §2 registry. Unknown type or unknown version → unsupported_type ([M-E4]).

  3. Decode payload as strict standard base64. Reject on any decode failure → malformed ([M-E2]). Reject decoded length > 16 MiB → too_large ([M-M13]).

  4. 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]).

  5. 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.

  6. 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, for succession, its agreement with the payload's linked; distinct keyids ([M-E18]); each keyid well-formed lowercase hex ([M-E11]); each sig strict 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].keyid MUST be the fingerprint of new_root ([M-E15]), and a reversed array is rejected here rather than repaired ([M-E17]).
    • Identity → unknown_key. For a linked succession, 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.
  7. Resolve every keyid through the anchor state for the payload's from party (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) → invalid with reason unknown_key; a key whose status cannot be determined because the verifier's anchor state predates the statement → the tri-state unknown-because-stale, not acceptance (keys.md §6).

    Exception — succession, index 0 (DECIDED 2026-08-13). For a succession statement, signatures[0] is verified against the key named in the payload's own new_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: false statement 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.

  8. 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.

  9. 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:

    1. The per-type body rules run BEFORE the re/thread linking rules. A payload whose body is not its declared type's body is malformed, full stop — the type comes from payloadType alone ([M-E5], [M-S1]), so a well-formed receipt body presented under the transmittal type is a broken transmittal, not a mis-linked receipt. Its re/thread fields would also violate [M-S13], and reporting bad_link there would name the symptom while hiding the type confusion, which is the thing worth knowing.

    2. Inside a manifest entry, the structural and encoding rules run BEFORE the path-form rules — is it an object, is size an integer, is root 64 lowercase hex ([M-M4]), and does [M-M6]'s empty-file coupling hold (size == 0 iff root is the empty-tree hash) — then path against [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: 0 entry with a bad path — one answering malformed, the other bad_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.

    3. 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 from payloadType alone ([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:

      1. from and to — presence, shape and party form ([M-S2], [M-S3]);
      2. link binding — membership and distinctness ([M-S9]);
      3. the anchor's scope against the container's location ([M-K14]);
      4. ts ([M-S4]);
      5. prev ([M-S5]);
      6. the chain tip ([M-S6]);
      7. re and thread ([M-S7], [M-S8]).

      Where this was DERIVED and where it was CHOSEN. Rule 1 and the position of re/thread last are derived — statements/invalid/37 pins the first and rule 1 already stated the second. That from/to precede everything else is derived too: [M-S9] and [M-K14] both read from.party, so they cannot be judged before the field they read has been. The rest — scope before ts, ts before prev, prev before 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.

    4. 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-typed manifest[].enc is malformed before the same entry's illegal path is bad_path; a wrong-typed from.name is bad_link before to'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.

  10. 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. A keyid matching no entry, an entry whose declared kind disagrees with the payload's key_kind, or an empty/absent set: all unknown_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 cert member ([M-E3]) — raw keys only (memo §11.4).
  • No revocation material inline. Revocation rides anchor-update on 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 enc set 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 prev chain 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 webauthn signature-entry extension. A signature entry MAY carry a webauthn member: 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's sig):

    {
      "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-revision or content-attestation (the §12 addendum widened this from record-revision alone) — whose payload's key_kind is personal-webauthn, and the coupling holds in BOTH directions (statements.md [M-S65]): a webauthn member on any link payloadType or under any other key_kind is malformed, and a personal-webauthn statement whose signature entry carries no webauthn member is malformed — 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:

    1. Decode and parse client_data_json. It MUST parse as a JSON object carrying string members type and challenge (other members — origin, crossOrigin, and anything a future client adds — are carried, signature-covered, and do not fail parsing). type MUST be exactly "webauthn.get" — anything else, including "webauthn.create", is malformed (a registration ceremony is not a signature).
    2. The challenge IS the payload binding. challenge MUST equal the base64url-unpadded SHA-256 of PAE(payloadType, payload_bytes) — the same PAE every Ed25519 entry signs directly. A mismatch is bad_pae: the assertion was made over a different payload, which is exactly the type/content confusion the PAE exists to prevent.
    3. Decode authenticator_data. Fewer than 37 bytes is malformed. Split: rpIdHash = bytes[0..32], flags = bytes[32], signCount = bytes[33..37] (carried, never evaluated — see the honest limits below).
    4. The RP binding. rpIdHash MUST equal the SHA-256 of the matched record-signer entry's registered rp_id ([M-K33]). A mismatch is unknown_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]).
    5. User presence. Bit 0 of flags (UP) MUST be set: the physical touch is the deliberate act D4 demands. Clear is malformed. Bit 2 (UV — PIN/biometric) is recorded and reported, never required by this profile (webauthn memo F6): the verification result carries it as user_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.
    6. The signature equation ([M-E27]) over authenticator_data || SHA-256(client_data_json) with the matched entry's public key. Failure is bad_signature.

    The signed material — clientDataJSON with its challenge and whatever origin the client asserted, authenticatorData with 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 asserted origin when client_data_json carries 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.

  • [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). sig is the DER-encoded SEQUENCE { 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) with r, s in [1, n-1]; violations are malformed, 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.

Rendered from “The DSSE Envelope Profile — zddc-dsse-v1” ZDDC