Implementers' guide
How to build a conformant Label 309 implementation — the recommended layered architecture, the cross-language byte-identical contract, and the conformance test vectors that define interoperability.
Label 309 is a wire format and a set of cryptographic constructions, not a product. Any number of independent implementations — in TypeScript, Python, Rust, Go, or a native mobile runtime — can coexist, and a record produced by one MUST verify under another. This page is for the team building such an implementation. It describes the architecture that keeps the cryptographic surface auditable, the exact contract that makes two implementations interoperable, and the conformance suite that decides — mechanically — whether you have met it.
Two things make Label 309 interoperable across languages. The first is determinism: the constructions are pinned to public standards (RFC 8949 canonical CBOR, RFC 8032 Ed25519, RFC 7748 X25519, RFC 5869 HKDF, RFC 9106 Argon2id, RFC 9052 COSE), so the same inputs yield the same bytes everywhere. The second is the conformance suite: a set of byte-exact test vectors that an implementation either reproduces or does not. Conformance is a property you can check, not a claim you make.
The layered architecture
A conformant implementation SHOULD separate cryptographic primitives from application logic into distinct layers, each depending only on the one beneath it. The names below are roles, not package names; choose your own.
The boundaries are load-bearing, not cosmetic. Each layer has a single job and a short list of things it is forbidden to know about.
The cryptographic core
The bottom layer holds primitives only: hash functions, KDFs, signature and KEM operations, the AEAD content layer, canonical CBOR, COSE_Sign1, the sealed-PoE wrap/unwrap construction, Merkle roots and proofs, and the typed error classes they raise. It contains no domain logic, no HTTP, no database access, and no UI- or server-framework imports.
This layer MUST stay dependency-free of anything application- or server-bound, and MUST be browser-safe, for three concrete reasons:
- It runs everywhere. Hashing a file, building an envelope, and — critically — the standalone verifier all run in browsers, in serverless workers, and on the command line as readily as on a server. A server-only dependency (a database driver, a logging framework bound to a runtime, a UI library) would break those targets and bloat every consumer that bundles the core.
- It is the audit surface. A reviewer can read a primitives-only package end-to-end against the RFCs. The moment application code leaks in, the surface a security reviewer must hold in their head grows without bound.
- It is what third parties embed. An independent verifier — someone who trusts no service, only the chain — pulls in this layer and nothing above it. Keeping it small and portable is what makes "verify it yourself" practical.
Concretely, the core MUST NOT import ORM or database drivers, UI frameworks,
server-bound logging frameworks, or any application module. Randomness MUST
come from the platform CSPRNG (Web Crypto getRandomValues, or an equivalent
re-export), never from a Node-only source, so the same source runs unchanged in a
browser.
Enforce the boundary in CI, not in code review
The zero-dependency rule decays the moment a convenient import slips in. An implementation SHOULD run a dependency-graph lint that walks every import in the core and the wire-format library and fails the build on any specifier outside a per-layer allow-list. Reviewers forget; the linter does not.
The wire-format library
The next layer up owns Label 309 itself: the record schema, the structural validator, and the canonical-CBOR encoder and decoder. It depends on the cryptographic core (for hashing, COSE, and the CBOR codec) and on nothing else application-bound. Its surface is small and pure:
- encode — produce canonical-CBOR bytes for a validated record.
- decode — the inverse.
- validate — run the structural and semantic checks of the standard over a decoded record and return a typed result (see Verification).
This layer is where the rules of The record live as code: the
closed key set, the chunk-reassembly discipline, the items-or-merkle invariant,
the canonical-CBOR requirements. Like the core, it stays free of HTTP clients,
database drivers, and framework imports.
The SDK and the application
The SDK wraps the lower layers into ergonomic helpers — a service client, envelope build/unlock helpers, and the standalone verifier, the function that decodes a record, checks its structure, verifies any record signatures against the on-chain key, and produces a verdict using only public data. The standalone verifier MUST work with no network access to any implementer-operated service; its only external input is a public blockchain explorer the verifier chooses. The SDK SHOULD also remain browser-safe.
The application layer — UI, routing, persistence, billing, background jobs — is greenfield and carries no interoperability obligations. Nothing in the standard constrains how you build it, only that it sits above the verified crypto surface rather than reaching into it.
The byte-identical contract
Interoperability is a property of bytes, not of intentions. Two implementations interoperate if and only if the primitives that have no freedom in their output produce the same bytes from the same inputs. This is the parity contract, and it is the heart of conformance.
The contract splits cleanly in two. Operations whose output is fully determined by their inputs MUST be byte-identical across implementations. Operations that consume randomness cannot be byte-equal call-to-call; for those the contract is cross-consumability — a value produced by one implementation MUST be consumable by any other (a ciphertext sealed in one language decrypts in another).
Byte-identical primitives
Every operation below is a pure function of its inputs and MUST emit byte-identical output in every conformant implementation:
| Primitive | Pinned to | Output that must match |
|---|---|---|
| Seed → Ed25519 / X25519 keypair | HKDF-SHA-256 with the registered info constants | derived public and private keys |
| HKDF-SHA-256 | RFC 5869 | output key material for fixed input |
| HMAC-SHA-256 slot-set MAC | RFC 2104 | slots_hash and slots_mac tag bytes for a fixed CEK and slot set |
| Argon2id (passphrase KDF) | RFC 9106 | derived key for fixed (m, t, p, salt, len, password) |
| SHA-256 | FIPS 180-4 | digest |
| BLAKE2b-256 | RFC 7693 | digest |
| Canonical CBOR encode | RFC 8949 §4.2.1 | encoded bytes for fixed input |
| COSE_Sign1 encode | RFC 9052 | structure bytes for fixed header, payload, signature |
| Ed25519 sign / verify | RFC 8032 (strict) | signature; verdict |
| X25519 ECDH | RFC 7748 | shared secret for fixed scalars |
| Sealed-PoE wrap / unwrap | Sealed PoE | per-slot bytes and MAC when ephemerals and CEK are injected |
| Merkle root + inclusion proofs | RFC 9162 §2.1.1 | root and per-leaf proofs over an ordered leaf list |
Two points deserve emphasis. Ed25519 is strict: a conformant verifier
MUST apply the canonical-S and rejected-low-order-point rules of
RFC 8032 §5.1.7, so two
implementations agree not only on signatures they accept but on signatures they
reject. Argon2id crosses ecosystem boundaries: different languages reach for
different Argon2 libraries, but every conformant library implements RFC 9106 and
MUST produce identical output for identical parameters — the parameter set, not
the library, is the contract.
Randomness-consuming operations
Key generation, sealed-PoE wrapping under fresh per-slot ephemerals, and envelope encryption all draw fresh randomness, so their output differs every call and cannot be byte-pinned. The contract for these is cross-consumability: output produced by one implementation MUST be consumable by every other. A record sealed in one language MUST decrypt in another; a keypair minted in one MUST verify and encrypt-to in another. Conformance suites pin these with deterministic test hooks that inject the ephemerals — making the wrap reproducible — and with round-trip fixtures that encrypt in one language and decrypt in the other.
Building the sealed-PoE construction
Sealed PoE is the densest part of the wire format, and the part where a single wrong byte — a mis-ordered map key, a label off by one character, a non-canonical chunking — produces an envelope that opens in your own implementation but in no other. This section is the build checklist: the exact recipes, the additional authenticated data each AEAD covers, the trial-decrypt loop, and the guards every producer and verifier must enforce. The construction reference on Sealed PoE is the prose; this is how you wire it up so the parity gate goes green. Pin these external drafts exactly, since their internals fix bytes you must reproduce:
chacha20-poly1305-stream64k— the content format — is ChaCha20-Poly1305 (RFC 8439) in the 64 KiB segmented STREAM layout of the age v1 specification. Pin the chunk size (65536), the 12-byte per-chunk nonceuint88_be(counter) ‖ final_flag, the empty per-chunk AAD, and the final-flag rule exactly — they fix bytes you must reproduce.- X-Wing (the
mlkem768x25519KEM) is draft-connolly-cfrg-xwing-kem-10. Treat it as a black-box KEM: the construction binds the recipient public key and the ciphertext into the key-derivation step itself, so it does not rely on any property of the combiner's internal hashing.XWing.EncapsulateMUST apply the pinned revision's public-key validity check and refuse to encapsulate to a key that fails it; the "never below X25519 classical security" floor is scoped to validly generated keys, and skipping the check forfeits the floor for that recipient. The conformance KEM vectors pin encapsulation against draft-10, so a draft-revision mismatch surfaces immediately.
One CEK, two key-delivery paths
A sealed record encrypts the plaintext once under a single content-encryption key (CEK), then delivers that CEK by one of two mutually-exclusive paths, discriminated by field presence — there is no mode tag:
- slots path — the CEK is wrapped independently to each recipient under a
per-slot key-encryption key.
enccarriesslots(andkem,slots_mac). - passphrase path — the CEK is derived directly from a normalized passphrase
via Argon2id.
enccarriespassphrase; it carries nokem,slots, orslots_mac.
Both paths share enc.scheme (always 1; reject anything else), enc.aead
(chacha20-poly1305-stream64k), and enc.nonce (24 bytes). They differ in where
the key commitment lives: on chain in slots_mac for the slots path, in a 32-byte
header inside the ciphertext blob for the passphrase path. Both bind the item's hash
claim into their transcript, and both seal the content in the same segmented STREAM;
the difference is the key-delivery and the commitment, not the content layer.
Per-slot wrap (slots path)
Pick one KEM for the whole record — never mix KEMs within a single slots[].
For each of N recipients, derive a fresh per-slot key-encryption key and wrap the
same CEK under it with ChaCha20-Poly1305 at a 12-byte zero nonce, AAD set
to that KEM's info label literal (never empty AAD), producing exactly 48 bytes
(32-byte CEK ciphertext + 16-byte tag). The zero nonce is safe only because the
key-encryption key is per-slot; see the uniqueness guard below.
x25519 (classical). Fresh ephemeral X25519 keypair per slot:
priv_epk : randomBytes(32) ; fresh per slot
pub_epk : x25519_publicKey(priv_epk)
shared : x25519_sharedSecret(priv_epk, pub_R) ; reject all-zero result
kek_salt : SHA-256("cardano-poe-x25519-kek-salt-v1" || enc.nonce || pub_epk || pub_R) ; 32 B
KEK : HKDF-SHA-256(ikm = shared, salt = kek_salt,
info = "cardano-poe-kek-v1", L = 32)
wrap : ChaCha20-Poly1305(key = KEK, nonce = zeros(12),
ad = "cardano-poe-kek-v1", plaintext = CEK) ; 48 B
slot : { "epk": pub_epk, "wrap": wrap }mlkem768x25519 (hybrid; X-Wing). Fresh X-Wing encapsulation per slot:
enc = XWing.Encapsulate(pub_R) ; named fields — MUST NOT consume positional order
kem_ct = enc.ct ; 1120 B
shared = enc.ss ; 32 B
kek_salt : SHA-256("cardano-poe-xwing-kek-salt-v1" || enc.nonce || kem_ct || pub_R) ; 32 B
KEK : HKDF-SHA-256(ikm = shared, salt = kek_salt,
info = "cardano-poe-kek-mlkem768x25519-v1", L = 32)
wrap : ChaCha20-Poly1305(key = KEK, nonce = zeros(12),
ad = "cardano-poe-kek-mlkem768x25519-v1", plaintext = CEK)
slot : { "kem_ct": kem_ct, "wrap": wrap } ; kem_ct = single 1120-byte byte stringBoth salts have one shape — SHA-256(label || enc.nonce || <slot KEM material> || pub_R) — carrying the 32-byte ephemeral pub_epk on the classical path and the
1120-byte X-Wing ciphertext kem_ct on the hybrid path; || is byte concatenation,
and each salt-prefix literal is exact ASCII with no terminator or length prefix.
pub_R is the recipient's canonical wire key (32 B for x25519, the pinned 1216 B
for mlkem768x25519). The hybrid slot carries no separate epk — the X25519
ephemeral is the trailing 32 bytes of kem_ct — and kem_ct is a single CBOR
byte string of exactly 1120 bytes: only the whole record body is chunked for
transport, never an individual field.
The salt binds three values: the slot's KEM material (KEK slot-unique), pub_R
(defeating confused-deputy relay against a different recipient), and enc.nonce
(anchoring the KEK to one envelope, so repeated KEM randomness degrades only to
cross-envelope linkability). The distinct info labels give cross-KEM domain
separation so no KEK derived under one KEM can equal one derived under the other on
an identical shared secret. Use each of the eleven internal labels byte-for-byte —
cardano-poe-kek-v1, cardano-poe-kek-mlkem768x25519-v1,
cardano-poe-x25519-kek-salt-v1, cardano-poe-xwing-kek-salt-v1,
cardano-poe-item-hashes-v1, cardano-poe-slots-transcript-v1,
cardano-poe-slots-mac-v1, cardano-poe-passphrase-transcript-v1,
cardano-poe-passphrase-mac-v1, cardano-poe-payload-v1,
cardano-poe-payload-passphrase-v1. None is ever serialized on the wire; they are
fixed constants, not registry-selectable. A single divergent byte yields a
slots_mac, a commitment, or an AEAD tag the honest producer cannot reproduce.
Shuffle before you MAC. Input order ("primary recipient first") is privileged
metadata; publishing slots in input order leaks it. Shuffle slots[] with a
CSPRNG using an unbiased Fisher-Yates permutation — a plain u32 % m index
draw skews toward low residues and must be rejection-sampled to a uniform index —
before computing the slot-set MAC, which binds the shuffled on-wire order.
Slot-set MAC: hash the transcript, then HMAC under the CEK
The slot-set MAC binds the whole slot set, plus the header fields that fix how the slots are read, to the CEK. Build it in two steps — hash a closed transcript once, then HMAC that hash:
hashes_hash : SHA-256("cardano-poe-item-hashes-v1" || canonicalEncode(item.hashes)) ; 32 B
SLOTS_TRANSCRIPT = { ; closed 7-key map; keys are a set, not an order
"scheme": 1,
"path": "slots",
"aead": <enc.aead>, ; the content-format identifier
"kem": <enc.kem>, ; "x25519" | "mlkem768x25519"
"nonce": <enc.nonce>, ; bytes(24)
"slots": <slots>, ; the shuffled on-wire slot array
"hashes_hash": hashes_hash ; bytes(32), over this item's hashes
}
slots_hash : SHA-256("cardano-poe-slots-transcript-v1" || canonicalEncode(SLOTS_TRANSCRIPT))
HMAC_KEY : HKDF-SHA-256(ikm = CEK, salt = "", info = "cardano-poe-slots-mac-v1", L = 32)
slots_mac : HMAC-SHA-256(key = HMAC_KEY, msg = slots_hash) ; 32 BThree things make or break parity here:
- The transcript is a closed map serialized by
canonicalEncode. Its key order is the RFC 8949 §4.2.1 sort, never hand-arranged. Pinningscheme,path,aead,kem, andnoncealongside the slots means a relay that flips any header field — even while leaving slot shapes valid — changesslots_hashand breaks the MAC. - The transcript binds the item's hash claim.
hashes_hashis a labelled SHA-256 over thecanonicalEncodeof the item's completehashesmap. Because the recipient recomputesslots_macfrom on-chain bytes alone, a MAC match confirms the envelope was sealed for this exact hash claim — an envelope spliced onto an item with a differenthashesmap fails the on-chain match step, before any ciphertext fetch. Theslotsvalue is the shuffled array of on-wire slot maps directly: each slot field is a single byte string (epk32 B,kem_ct1120 B), so there is no per-field chunking to canonicalise. slots_hashis computed once and held constant across the trial-decrypt loop. The per-slot MAC check re-keys HMAC from each candidate CEK but always over the same 32-byteslots_hash. Pre-hashing leaves the CEK-keyed commitment intact: it changes the HMAC message from the full transcript to its SHA-256, nothing more.
The MAC algorithm, its key derivation, and the transcript schema are all fixed by
enc.scheme = 1 and identical for both KEMs; there is no on-wire MAC identifier.
slots_mac is exactly 32 bytes and is verified in constant time.
Content encryption: the segmented STREAM
Encrypt the plaintext once in the segmented STREAM under a content key derived
from the CEK. The content key is a separate HKDF leaf of the CEK — salted by
enc.nonce, under a path-specific info — so the wrap layer and the content layer
never key the same primitive on the same bytes:
content_key : HKDF-SHA-256(ikm = CEK, salt = enc.nonce, info = "cardano-poe-payload-v1", L = 32)
; STREAM (chacha20-poly1305-stream64k):
CHUNK_SIZE : 65536 plaintext bytes per non-final chunk
chunk nonce : uint88_be(counter) || final_flag ; 12 B; counter from 0, +1 per chunk;
; final_flag = 0x01 on the last chunk, else 0x00
per-chunk AAD : empty
ciphertext : seal(chunk_0) || seal(chunk_1) || ... || seal(chunk_final)
; each chunk sealed with ChaCha20-Poly1305 under content_key; sealed = plaintext + 16 BThe per-chunk AAD is empty — and that is correct, not an omission: the content
key derives from the CEK, and the CEK is already committed to the full header
(including hashes_hash) by slots_mac. Flip any header field and the recipient
derives a different content key, so the stream fails to open; a per-chunk AAD would
re-bind the same context on every chunk without adding security. The counter nonces
are safe because the content key is single-use (a fresh CEK salted by the
envelope-unique enc.nonce), so no two streams share a (key, nonce) pair.
Build the STREAM so truncation is detectable: every non-final chunk is exactly
65536 plaintext bytes, the final chunk carries final_flag = 0x01 and 0–65536
bytes (an empty plaintext is one zero-length final chunk — a lone 16-byte tag), and
a verifier MUST fail (TAMPERED_CIPHERTEXT) on a missing final flag, a final flag
on a non-final chunk, data after the final chunk, or a short non-final chunk. Verify
each chunk's tag before releasing its plaintext, and treat released bytes as
tentative until the post-decryption hash recheck passes.
The plaintext is the exact original content bytes; the construction prepends,
appends, and encrypts no filename, MIME type, size field, or metadata wrapper. The
published ciphertext blob is the STREAM chunks (on the passphrase path, preceded by
the 32-byte commitment header below). The assembled enc map and the resulting URI
go on chain; the ciphertext bytes do not — publish them to a content-addressed store
and put the ar:// or ipfs:// URI in the item's uris[].
Passphrase path
When there are no recipients, derive the CEK from a normalized passphrase with
Argon2id. There is no epk, no per-slot wrap, no slot-set MAC, and no trial-decrypt
loop. The key commitment that slots_mac provides on the slots path lives instead
in a 32-byte header inside the ciphertext blob, prepended before the STREAM
chunks:
passphrase_bytes = utf8(normalize(passphrase)) ; cardano-poe-pw-norm-v1
CEK = argon2id(passphrase_bytes, salt = enc.passphrase.salt,
params = enc.passphrase.params, L = 32)
hashes_hash = SHA-256("cardano-poe-item-hashes-v1" || canonicalEncode(item.hashes))
PASSPHRASE_TRANSCRIPT = { ; closed 6-key map; keys are a set, not an order
"scheme": 1,
"path": "passphrase",
"aead": <enc.aead>,
"nonce": <enc.nonce>, ; bytes(24)
"hashes_hash": hashes_hash, ; bytes(32), over this item's hashes
"passphrase": { ; closed sub-map
"alg": "argon2id",
"salt": enc.passphrase.salt,
"params": { "m": m, "t": t, "p": p },
"normalization": "cardano-poe-pw-norm-v1" ; scheme-fixed constant, NOT on the wire
}
}
pw_hash = SHA-256("cardano-poe-passphrase-transcript-v1" || canonicalEncode(PASSPHRASE_TRANSCRIPT))
PW_MAC_KEY = HKDF-SHA-256(ikm = CEK, salt = "", info = "cardano-poe-passphrase-mac-v1", L = 32)
commitment = HMAC-SHA-256(key = PW_MAC_KEY, msg = pw_hash) ; 32 B
content_key = HKDF-SHA-256(ikm = CEK, salt = enc.nonce, info = "cardano-poe-payload-passphrase-v1", L = 32)
ciphertext blob = commitment || STREAM chunks ; STREAM under content_keyThe PASSPHRASE_TRANSCRIPT binds the KDF parameters, the header fields, and the
item's hash claim into the commitment: tampering with salt, any params value,
nonce, aead, or splicing the envelope onto a different hash claim yields a
different pw_hash and the commitment check fails. The "normalization" value is a
scheme-fixed constant fed into the transcript to pin the exact profile the CEK
was derived under; it is never serialized on the wire (the producer emits only
{ alg, salt, params }).
On the verify side, derive the candidate CEK, read the leading 32 bytes of the
ciphertext blob, recompute the commitment, and compare in constant time before
opening any STREAM chunk. A blob shorter than 48 bytes (32-byte commitment +
16-byte minimum STREAM) is malformed (TAMPERED_CIPHERTEXT). On mismatch — wrong
passphrase, tampered salt / params / header, or a spliced envelope — surface the
same single generic failure and do not begin streaming; a wrong passphrase is
indistinguishable from a tampered record. The commitment is deliberately off-chain:
an on-chain commitment would be a free offline guessing oracle for every passphrase
record, including ones whose ciphertext is withheld.
Enforce the parameter floors: salt length 16–64 bytes; m ≥ 65536 KiB
(≈ 64 MiB), t ≥ 3, p ≥ 1. Pin the Argon2 version at 0x13 (19); no other
version is admissible under enc.scheme: 1, and there is no version field on the
wire. Where the platform supports it, producers SHOULD emit p = 4 (the second
recommended profile of RFC 9106 §4);
verifiers MAY accept any p ≥ 1, subject to deployment ceilings. Argon2id
crosses ecosystem boundaries cleanly — the parameter set, not the library, is the
contract — so a fixed (m, t, p, salt, len, password) must yield byte-identical
output in every implementation. The binding between a passphrase and its envelope is
the in-ciphertext commitment above; a wrong passphrase and a tampered ciphertext
both surface as one generic failure.
Bound the raw passphrase before normalization and Argon2id: reject any input
longer than the reference MAX_PASSPHRASE_INPUT_BYTES = 4096 UTF-8 bytes, so a
pathological passphrase cannot drive a pre-KDF denial of service. Like the slots-
path MAX_SLOTS and decoded-envelope bounds, this is a deployment-pinned constant
you MAY tighten, not a wire field.
The normalization profile is normative
Two implementations MUST derive a byte-identical CEK from the same passphrase,
and the only way to guarantee that is a pinned normalization. The profile
cardano-poe-pw-norm-v1, applied in order:
- Reject unassigned codepoints — a passphrase containing any codepoint
unassigned in Unicode 16.0 is rejected (
ENC_PASSPHRASE_UNNORMALIZABLE) before any normalization runs. Unicode guarantees normalization stability only over assigned codepoints, so this closes a future-drift hole and is invisible to honest users. - NFKC — Normalization Form KC per UAX #15 under Unicode 16.0.
- Whitespace — define whitespace as every character carrying the Unicode
White_Spaceproperty under Unicode 16.0; collapse every maximal run to a single U+0020 SPACE. - Trim — remove leading and trailing whitespace.
- Reject empty — if the result is the empty string, reject
(
ENC_PASSPHRASE_EMPTY); a whitespace-only passphrase would otherwise key the record to a CEK any party can derive. - Encode — UTF-8; those bytes are the Argon2id password input.
Pin Unicode at 16.0 literally and do not let it float: the White_Space
property set, the assigned-codepoint set, and the NFKC mapping tables are all
version-dependent, so resolving the profile against a different Unicode version can
derive a different CEK from the same passphrase and fail to open an honest record. A
future revision that adopts a newer Unicode version does so under a new profile
identifier, never by reinterpreting cardano-poe-pw-norm-v1.
Trial-decrypt: open every slot, fold in the MAC, fail generically
A recipient holds one KEM private key and discovers its slot by trying to open each
one — recipient public keys are not on the wire. Before invoking any KEM or AEAD
primitive, run the resource bounds, then the structural guards. Bound parser
resource use first: reject an envelope whose decoded size exceeds
65536 bytes (ENC_ENVELOPE_TOO_LARGE) or whose slots[] exceeds
MAX_SLOTS = 1024 (ENC_SLOTS_TOO_MANY). Both reference bounds sit far above the
~16 KiB Cardano transaction-metadata ceiling that constrains any honest record;
they are deployment-pinned constants you MAY tighten, never wire fields. Then
the structural guards: scheme == 1; aead, kem registered; nonce 24 bytes;
slots_mac 32 bytes; slots non-empty; recipient secret 32 bytes; each wrap 48
bytes; per-KEM, each epk exactly 32 bytes with no kem_ct (x25519) or each
kem_ct exactly 1120 bytes with no epk (mlkem768x25519).
Reject within-record duplicate encapsulation here, before any primitive. All
epk values must be distinct on the classical path, all kem_ct values distinct on
the hybrid path; a duplicate raises
ENC_SLOTS_DUPLICATE_KEM_MATERIAL. This is the verifier-checkable slice of the
per-slot-KEK-uniqueness invariant the zero-nonce wrap depends on; cross-record or
cross-key reuse is a producer obligation no verifier can detect. This rejection
fires only on a repeated epk / kem_ct — sealing to the same recipient twice
with fresh per-slot ephemerals is legitimate and does not trip it (see the
multiple-matches rule below). unwrap-negative carries the
duplicate-epk-with-KEK-reuse case.
Then run the loop, recomputing slots_hash once before it and holding it constant:
found = false
cek_conflict = false
selected_CEK = 0^32
for slot in slots: ; iterate ALL slots — no early break
; derive KEK per-KEM, as in the wrap recipe. For x25519 the all-zero shared
; secret is rejected via a secret-independent bit, not an early branch:
; kem_ok = NOT constantTimeEqual(shared, 0^32)
; KEK = ct_select(kem_ok, real_KEK, dummy_KEK) ; dummy_KEK from ikm=0^32, same salt/info
; (XWing.Decapsulate has no all-zero case; kem_ok stays true on the hybrid path.)
open_ok, candidate_CEK = ChaCha20-Poly1305_open_or_dummy(KEK, zeros(12), kem_info_label, slot.wrap)
HMAC_KEY = HKDF-SHA-256(candidate_CEK, salt = "", info = "cardano-poe-slots-mac-v1", L = 32)
mac_ok = constantTimeEqual(HMAC-SHA-256(HMAC_KEY, slots_hash), slots_mac)
ok = kem_ok AND open_ok AND mac_ok ; kem_ok folded into acceptance
first = ok AND NOT found ; first matching slot
cek_conflict = cek_conflict OR (ok AND found AND NOT constantTimeEqual(candidate_CEK, selected_CEK))
selected_CEK = ct_select(first, candidate_CEK, selected_CEK) ; constant-time
found = found OR ok
if NOT found: reject (single generic failure)
if cek_conflict: reject (single generic failure)
content_key = HKDF-SHA-256(selected_CEK, salt = enc.nonce, info = "cardano-poe-payload-v1", L = 32)
plaintext = STREAM_open(content_key, ciphertext) ; per-chunk authenticated release
if STREAM_open fails at any chunk: reject (single generic failure) ; TAMPERED_CIPHERTEXTThe non-negotiables in this loop:
- Open atomically; never release unverified plaintext. Both
*_open_or_dummyprimitives are atomic: on AEAD tag failure they return no plaintext, and the returned candidate (the wrapped CEK, or the content plaintext) is a fixed-or-pseudorandom dummy independent of the failed ciphertext. This is what lets the loop carry acandidate_CEKpast a failed wrap open without ever exposing unauthenticated bytes. - Fold the all-zero check into a secret-independent
kem_okbit. Computekem_ok = NOT constantTimeEqual(shared, 0^32)for thex25519path, select the KEK in constant time between the real KEK and a dummy KEK derived from0^32under the same salt and info, and foldkem_okinto acceptance (ok = kem_ok AND open_ok AND mac_ok). Do not branch out early on an invalid share — an invalid-ECDH slot can never be accepted, and the loop still does identical work. (XWing.Decapsulatehas no all-zero case, sokem_okis fixed true on the hybrid path.) - Fold the
slots_maccheck into the loop. A malicious sender can craft a slot that opens under the recipient's key with an attacker-chosen CEK (no private-key knowledge needed). Accepting the first AEAD success as "ours" would let that forged slot shadow an honest one. Requiring the candidate CEK to also reproduceslots_macoverslots_hashdefeats slot-substitution, slot-removal, and slot-reorder. Never skip it. - Permit multiple matches; reject only a CEK conflict. A recipient key MAY
legitimately match more than one slot — sealing the same CEK to the same recipient
in several slots, each with fresh ephemerals, is valid recipient-count padding and
does not trip the duplicate-
epk/kem_ctrejection. Select the first match's CEK and do not reject merely because more than one slot matched. The one anomaly to reject is two matching slots that recover different CEKs (constant-time compare): track acek_conflictbit and surface the single generic failure if it is set. This is defence-in-depth — under the slot-set commitment a distinct-CEK match is already infeasible — so it fails closed against a broken implementation. - Iterate all slots within a single private key's pass — a constant number of
slot operations per key, no early break — so a timing observer cannot infer which
slot matched. Drive the all-zero rejection through
kem_okand dummy work rather than exiting early. A recipient with multiple keys iterates key × slot and MAY short-circuit across keys (leaking only the weak "which key matched" signal), but must stay constant-time across the slots of any one key — and must re-derive thepub_Rhalf of the salt per key, since both KEMs bind the recipient's own public key into the KEK salt. Bind that salt to the key's canonical wire encoding — exactly the 32-byte X25519 public key, or exactly the pinned 1216-byte X-Wing public-key bytes — never a non-canonical re-encoding, or the two sides derive different KEKs. - Surface one generic failure shape to untrusted callers. Internally you may
track typed outcomes for local diagnostics —
WRONG_RECIPIENT_KEY(no slot opened),TAMPERED_HEADER(a slot opened but no candidate CEK reproducedslots_mac),TAMPERED_CIPHERTEXT(content AEAD failed after a CEK was recovered and the MAC verified) — but an external observer MUST NOT distinguish them by response shape. On timing, the model is deliberately scoped: a verifier MAY return at theif NOT foundcheck before content decryption, which separates a non-recipient from a recipient whose ciphertext fails to open. That reveals only recipient-vs-non-recipient, never which slot or any key material; uniform timing between those two cases is not required and a dummy content open MUST NOT be mandated. The constant-time guarantee that holds is the across-slots invariant above. - Recompute and compare the plaintext hash after decryption. The on-chain
hashesmap commits to the plaintext, not the ciphertext, so the recipient (at the application layer) must recompute the digest and compare: thesha2-256entry must match, andblake2b-256if present. A mismatch means the record's hash claim does not match the decrypted bytes — refuse to act on the plaintext. The structural validator never decrypts.
Bound the payload on both sides
The segmented STREAM imposes no cryptographic payload ceiling: the 88-bit
per-chunk counter admits 2^88 chunks, and each chunk is sealed under a distinct
(content_key, nonce) pair well within the RFC 8439 single-invocation limit, so
there is no counter-overflow risk to guard against. The maximum a producer or
verifier enforces is therefore a deployment denial-of-service policy, not a wire
constant — enforce it incrementally as the stream is written or read, and abort
before buffering an oversized payload. Truncation is caught structurally by the
final flag rather than by a size cap. The same posture applies on both the slots
path and the passphrase path.
Sealed-PoE conformance fixtures
The sealed-PoE corner of the corpus is where most cross-language bugs surface.
Drive your implementation through all of it. The positive fixtures pin the
deterministic wrap and the trial-decrypt loop for both KEMs — single- and
multi-recipient, mixed-N, and the multi-private-key worst case — plus the legitimate
case of one recipient matching two slots (fresh ephemerals, same CEK, MUST
decrypt, so an implementation that rejects multiple matches fails here) and the
passphrase path (commitment header plus STREAM chunks in one blob). A dedicated
STREAM-layout set pins an empty plaintext (one zero-length final chunk), a
single-chunk payload, and a multi-chunk payload crossing the 65536-byte boundary.
Targeted KATs pin both KEK salts (SHA-256(label ‖ enc.nonce ‖ <KEM material> ‖ pub_R)), the hashes_hash and its place in both transcripts, X-Wing encapsulation
against draft-10, the zero-length-salt HKDF extract (the absent-salt convention of
RFC 5869 §2.2, mirroring the
slots_mac key derivation), the Bech32 recipient/secret encodings, and the
checksummed identity-seed encoding.
The negative fixtures pin the rejection codes: a forged shadow slot before an
honest slot (the record MUST still decrypt under the honest CEK); a header-flip
(kem/aead/scheme) that leaves slot shapes valid; a hashes-splice onto an
item with a different hash claim; the passphrase-commitment failures (wrong
passphrase, tampered salt/params, tampered header — all failing before any chunk
opens); the passphrase-normalization rejections (an unassigned-codepoint input and a
whitespace-only input); the all-zero X25519 shared secret; the within-record
duplicate slot; and the STREAM-tampering cases (flipped chunk tag, truncated stream,
trailing data, short non-final chunk). Two properties have no byte vector and
are asserted behaviourally instead: the CEK-conflict rejection (constructing one is
exactly the multi-key commitment collision the standard assumes infeasible) and the
constant-time-across-slots guarantee. Reproduce every pinned byte string and emit
the exact code for every negative case.
One sealed-PoE property has no byte vector: the CEK-conflict rejection — two matching slots that recover different CEKs — cannot be constructed as a fixture, because constructing one is exactly the multi-key commitment collision the standard assumes infeasible. Pin it instead with an implementation-level behavioural test that asserts your trial-decrypt loop fails closed on a forced conflict, the same way the constant-time-across-slots property is asserted behaviourally rather than as a byte string.
Conformance and test vectors
The normative test vectors are the interoperability contract. An implementation is conformant if and only if it reproduces every pinned byte string in the conformance suite from the same inputs — and emits the correct typed error code for every negative fixture. There is no partial credit and no appeal: if a comparison fails, the implementation is wrong, never the vector.
The vectors live in the standard's conformance suite, organized by primitive class: record fixtures, sealed-PoE wrap/unwrap, COSE_Sign1 signatures, HKDF, seed derivation, Argon2id, and canonical CBOR. Each pins lowercase-hex inputs and the expected outputs. To use them: feed the inputs into your implementation, compare each named output byte-for-byte, and fix your code on any mismatch.
Three obligations every implementation must meet
Reproduce the positive vectors. For every record fixture, both halves of
encode(record) == expected_cbor AND the round-trip
encode(decode(expected_cbor)) == expected_cbor MUST hold. The round-trip
generalizes beyond the fixtures: for arbitrary well-formed input,
encode(decode(x)) == x. A decoder that loses or reorders information, or an
encoder that is not canonical, breaks this and fails conformance.
Emit the right rejection codes. The negative fixtures pair a deliberately malformed record with the exact typed error code a structural validator MUST raise. Reproducing the bytes of valid records is half the contract; rejecting invalid ones with the correct code is the other half. A validator that rejects a bad record for the wrong reason — or accepts it — is non-conformant. The negative fixtures are the single source of truth for cross-language rejection parity: the same malformed input MUST raise the same code in every implementation. The full catalogue of codes and their meanings is on Verification.
Match the registries. Algorithm identifiers are named strings drawn from the registries on Algorithm registries. An unrecognized identifier MUST surface the precise unsupported-algorithm code, never a silent acceptance or a panic.
Fix the implementation, never the vector
The vectors are pinned to upstream RFCs and to the deterministic constructions of this standard. When a comparison fails, the bug is in the implementation under test. Editing a vector to make a suite pass converts a real interoperability failure into a latent one that surfaces only when a record crosses implementations on chain — the worst possible time to discover it.
Run parity on every change
An implementation that ships more than one language — or wants to prove interoperability with another — SHOULD run a single continuous-integration job that builds every package, runs each language's test suite against the shared fixtures, enforces the dependency-graph lint, and checks that the fixture set is identical on both sides. A fixture added on one side but not the other fails the gate: the two implementations have silently diverged, and the build catches it before a real record does. The fixtures are the canonical source; each language holds a byte-identical mirror, and the gate asserts the mirror is complete and exact.
Naming and wire conventions
A few conventions keep an implementation legible and the wire format stable:
- Wire field names are
snake_case—leaf_count,cose_sign1,slots_mac. This holds across languages: even where a language idiomatically usescamelCasefor its in-memory API, the encoded record usessnake_casekeys, because the keys are part of the canonical bytes a signature covers. - Identifiers are registry strings, not enums baked into code. Hashes, AEADs, KEMs, KDFs, and signatures all reference named identifiers; adding an algorithm (a post-quantum KEM, say) is an additive registry entry, never a wire-format break.
- Cross-language method names mirror semantically. A function in one language
has a same-named counterpart in another (
encode_canonical_cbor↔encodeCanonicalCbor), so a reader fluent in either can map one surface onto the other and reason about parity by inspection. - Bootstrap the crypto layers first. Stand up the cryptographic core and the wire-format library against the vectors and get the parity gate green before writing a line of application code. The standalone verifier is the smallest application-adjacent surface and the next thing to build; everything else sits on top of a crypto layer you have already proven correct.
Related pages
- The record — the wire format the validator and encoder implement.
- Sealed PoE — the construction reference behind the build recipes here.
- Algorithm registries — the named identifiers an implementation resolves.
- Verification — the validation pipeline, the standalone verifier, and the error-code catalogue.