Sealed PoE

The Label 309 encryption envelope — how a sender seals content to one or more recipient keys while the chain carries only the plaintext hash and the wrapped key slots, never the plaintext and never the recipients.

A sealed PoE anchors a timestamped commitment to a plaintext while keeping that plaintext readable only by a chosen audience. The on-chain record carries the plaintext hash — the proof of timing, exactly as for any other record — plus an encryption envelope (enc) holding the material needed to recover the content-encryption key. The ciphertext itself never touches the chain; it lives at a content-addressed URI (ar:// or ipfs://). Nothing on the chain reveals the plaintext, and nothing reveals who the recipients are.

This page specifies the enc envelope: its two mutually-exclusive key-delivery paths, the per-recipient key slots, the slot-set MAC, the segmented content STREAM, and the trial-decryption a recipient performs to discover and open a message addressed to it. The recipient keys themselves — the seed-derived X25519 and X-Wing keypairs — are defined on Keys; this page consumes them. The enc map's place in the record map, and the whole-body transport that carries it on chain, are defined on The record.

Not HPKE

This is not RFC 9180 HPKE. It is an age-style multi-recipient KEM-then-wrap design — per-recipient encapsulation, an HKDF-derived key-encryption key, and an AEAD-wrapped content-encryption key — with the age v1 stanza pattern transposed to canonical CBOR. It has no suite_id and no LabeledExtract/LabeledExpand cascade; evaluate it against the ECIES literature and the age v1 specification, not against HPKE's analysis.

The model and its privacy properties

A sender wants to publish a permanent, timestamped commitment proving that a specific plaintext was sealed for a specific audience at time T — while ensuring only that audience can read it. A hash-only PoE gives the time claim but no audience binding; a PoE over open ciphertext gives no confidentiality at all. Sealed PoE bridges the two: the record commits to the plaintext hash (public, timestamped) and carries the key-delivery material in enc, while the ciphertext at the ar:// or ipfs:// URI is undecryptable without a matching unlock secret.

The construction is deliberately designed so the chain leaks as little as possible about the message and nothing about its audience:

  • The plaintext is never on chain. Only its hash and the wrapped keys are. Anyone who later obtains the plaintext can prove "this exact plaintext was committed at block time T"; nobody else learns what was sealed.
  • Recipient public keys are never on chain. A recipient's public key does not appear anywhere in enc. A recipient recognises a message as theirs only by successfully trial-decrypting a slot — there is no addressee field to read. An observer with no candidate keys learns only the slot count, the KEM family (enc.kem), and the sealed-vs-open distinction. The stronger property — that an adversary holding candidate recipient keys still cannot test which (if any) a slot targets — is key-privacy, claimed only for the classical x25519 path; it is not claimed for the hybrid mlkem768x25519 path (see Anonymity and the per-KEM split).
  • Recipients learn nothing about each other. Each per-recipient slot is an opaque wrapped key. A recipient who opens their own slot cannot derive any other recipient's key, and cannot tell who else was addressed.
  • Slot ordering leaks nothing. The order in which a sender lists recipients (e.g. "primary first") is privileged metadata. The slot array is shuffled with a CSPRNG before publication, so even the positional ordering carries no signal.
  • Unsigned sealed PoE preserves sender anonymity. Authorship signatures are optional (see Signatures). A sealed record with no sigs[] binds no sender identity on chain — exactly what whistleblower drops, sealed-bid auctions, and evidence escrow require.

What the chain does reveal is narrow: that a record is a sealed PoE (enc is present), the plaintext hash, the block timestamp, and the count of slots (the array length). The count is the only recipient-adjacent fact exposed, and it reveals only "how many", never "who". Timing-correlation across records is a metadata concern that wire-level cryptography cannot solve; senders who need to defeat it must batch publishes off the sensitive timeline.

Recipient public keys are exchanged out of band. Label 309 prescribes no discovery mechanism: a recipient may publish their key on their own website, a DNS record, a social profile, a QR code, or an on-chain self-attestation. A verifier takes the recipient key bytes as input and makes no claim about whose key they are — provenance is the sender's trust decision, exactly as when emailing a PGP key.

The envelope and its two paths

The enc map carries common fields plus exactly one of two mutually exclusive key-delivery paths. A structural validator enforces the exclusivity; a record carrying both, or neither, is rejected.

FieldStatusMeaning
schemeREQUIREDConstruction-family version. v1 defines scheme = 1.
aeadREQUIREDContent-format identifier. v1 defines "chacha20-poly1305-stream64k".
nonceREQUIRED24 random bytes — the envelope-unique salt of the content key and every slot KEK.
kemslots path onlyPer-slot KEM selector ("x25519" or "mlkem768x25519").
slotsone pathArray of per-recipient key slots (multi-recipient).
slots_macslots path only32-byte HMAC binding the slot set and the item's hash claim to the content key.
passphrasethe other pathPassphrase-KDF block (passphrase-derived key).
  • enc.slots — multi-recipient. The envelope carries N independently-wrapped key slots, one per recipient. The ciphertext is undecryptable without a private key matching one of the slots. Specified in Slots and the slot-set MAC below.
  • enc.passphrase — passphrase-derived. The envelope carries no slots; the content key is derived directly from a normalised passphrase. Specified in Passphrase path below.

Both paths share scheme, aead, and nonce. They differ in which key is present and, consequently, in where the key commitment lives. On the slots path the commitment is on chain: slots_mac is a CEK-keyed HMAC over a transcript that pins the header fields, the slot set, and the item's hash claim, so a recipient confirms the right key before fetching anything. On the passphrase path there are no slots to bind, so the commitment is a 32-byte header carried inside the ciphertext blob — testing a passphrase guess requires the blob itself, never just the public chain. Each path serialises its transcript with the same canonicalEncode function, and a producer or verifier selects the path by inspecting which of slots / passphrase is present. The two paths are exhaustive and mutually exclusive.

enc.scheme names the construction family, independent of the record's v field. A verifier MUST require enc.scheme === 1 and reject any other value. The field is reserved for a future cross-cutting change — a different slot-set MAC schedule or content format — not for adding a KEM: the per-slot KEM is selected by enc.kem, and both KEMs below live under scheme = 1 from the first release. More broadly, enc.scheme: 1 identifies the entire cryptographic suite, not merely the MAC and the content format: the canonicalEncode rules, the slot schema, the HKDF hash, the HMAC hash, the per-slot wrap AEAD, the segmented-STREAM content format, the slots and passphrase transcript schemas (including the hashes_hash item binding), the in-ciphertext passphrase commitment, the pinned X-Wing revision, the domain-separation labels, the Argon2id version and profile, and the passphrase normalization profile are all fixed by it, so changing any of them requires a new enc.scheme value.

The content layer

Both paths converge on one symmetric pass over the plaintext, keyed by a value derived from a single 32-byte content-encryption key (CEK). The CEK is what the slots deliver (each slot wraps it) or what the passphrase KDF produces; the content is not encrypted under the CEK directly. Instead each path derives a separate 32-byte content key as an HKDF leaf of the CEK — salted by the envelope-unique enc.nonce, under a path-specific info — so the key-delivery layer and the content layer never key the same primitive on the same bytes:

CBOR
content_key = HKDF-SHA-256(ikm = CEK, salt = enc.nonce,
                           info = <"cardano-poe-payload-v1" on the slots path,
                                   "cardano-poe-payload-passphrase-v1" on passphrase>,
                           L = 32)

The content is then sealed in a segmented STREAM, named by the content-format identifier chacha20-poly1305-stream64k. This is the STREAM layout of the age v1 specification: ChaCha20-Poly1305 (RFC 8439, the 12-byte-nonce variant) over the plaintext split into fixed-size chunks, each sealed under the content key with a per-chunk counter nonce:

CBOR
cipher       : ChaCha20-Poly1305 (RFC 8439; 12-byte nonce, 16-byte tag)
CHUNK_SIZE   : 65536 plaintext bytes per non-final chunk
chunk nonce  : uint88_be(counter) || final_flag      ; 12 bytes
               counter starts at 0, +1 per chunk;
               final_flag = 0x01 on the final chunk, 0x00 otherwise
per-chunk AAD: empty
final chunk  : 0 to 65536 plaintext bytes; every non-final chunk is exactly 65536
empty input  : exactly one final chunk of zero-length plaintext (a lone 16-byte tag)

ciphertext = seal(chunk_0) || seal(chunk_1) || ... || seal(chunk_final)
                                             ; each sealed chunk = plaintext length + 16 bytes

The final flag domain-separates the last chunk from the rest, which is what makes truncation detectable: a stream whose last chunk does not carry the 0x01 flag, a 0x01 flag on a chunk that is not last, data following the final chunk, or a non-final chunk shorter than CHUNK_SIZE MUST all fail decryption (TAMPERED_CIPHERTEXT). Because every sealed chunk is at least its 16-byte tag, the layout also implies a structural floor — a well-formed slots-path ciphertext blob is never shorter than 16 bytes, the lone tag of an empty final chunk.

The per-chunk AAD is empty by design: all context is bound to the content transitively. The content key derives from the CEK, and the CEK is committed to the full header by slots_mac on the slots path (whose transcript covers scheme, path, aead, kem, nonce, the slot set, and the item's hash claim) or by the in-ciphertext commitment on the passphrase path. Flip any header field and the recipient derives or accepts a different key, so decryption fails; a per-chunk AAD would re-bind the same context on every chunk without adding security.

The counter-based chunk nonces are safe because the content key is single-use: it derives from a fresh CEK salted by the envelope-unique enc.nonce, so no two streams ever share a (key, nonce) pair and stateless producers — browser tabs, CLI runs, workers, retries — never coordinate nonces across envelopes. The 88-bit counter admits 2^88 chunks, far above any realisable payload, so the format imposes no cryptographic payload ceiling; a practical maximum is a deployment denial-of-service policy, not a wire constant.

The plaintext input is the exact original content bytes. The construction does not prepend, append, or encrypt any filename, MIME type, size field, or manifest — the stream decrypts back to those bytes and only those bytes.

Released chunks are tentative until the hash recheck

The segmented format exists so a verifier can authenticate and release a multi-GiB payload incrementally with bounded memory. Each chunk's tag is verified before that chunk's plaintext is released, and truncation is caught by the final flag — but the plaintext-hash recheck runs over the whole plaintext, after the last chunk. A streaming consumer MUST therefore treat released bytes as tentative — no side effects, no acknowledgement, no "received" status — until that final check passes.

The published ciphertext is a single object. On the slots path it is exactly the STREAM chunks; on the passphrase path a 32-byte key-commitment header is prepended inside the same blob (same object, same URI, same fetch — never a second stored object):

CBOR
slots path      : ciphertext blob = [ STREAM chunks ]
passphrase path : ciphertext blob = [ commitment: 32 bytes ] || [ STREAM chunks ]

The plaintext hash in items[].hashes always commits to the plaintext, even when enc is present. This is the load-bearing property: a verifier who cannot decrypt can still confirm the record exists, its envelope is well-formed, and the URI is fetchable — but only a holder of a matching recipient key can decrypt the ciphertext and confirm what the commitment is to by recomputing the hash. The validator therefore MUST NOT decrypt to "verify" hashes; plaintext-hash verification happens at the recipient, after the bytes are recovered. See Content and hashing and Verification.

Slots and the slot-set MAC

On the multi-recipient path, enc.slots is a non-empty array of per-recipient slots. Every slot wraps the same CEK under a per-recipient key-encryption key (KEK); a recipient who opens any slot recovers the one CEK that decrypts the content. The sender:

  1. Selects one KEM for the whole record and generates the CEK (32 random bytes) and nonce (24 random bytes).
  2. For each recipient, derives a per-slot KEK and wraps the CEK under it (per-KEM details below).
  3. Shuffles the slot array with a CSPRNG (unbiased Fisher-Yates).
  4. Builds the slots transcript over the shuffled array, the cross-KEM header fields, and the item's hash claim, hashes it to slots_hash, and computes slots_mac as a CEK-keyed HMAC over that hash.
  5. Derives the content key from the CEK and enc.nonce, and seals the content in the segmented STREAM above.

The per-slot wrap

Each slot wraps the CEK with ChaCha20-Poly1305 (RFC 8439, the 12-byte-nonce variant) under the per-slot KEK, producing a 48-byte wrap (32-byte CEK ciphertext + 16-byte Poly1305 tag):

CBOR
wrap = ChaCha20-Poly1305_seal(
  key       = KEK,                    ; per-slot, 32 bytes
  nonce     = bytes(12, 0x00),        ; ZERO nonce
  ad        = <KEM info literal>,     ; the KEK info string for the chosen KEM
  plaintext = CEK)

The 12-byte all-zero nonce is safe precisely because each slot's KEK is unique per record: a KEK is therefore used for exactly one wrap, so the nonce can never collide under any one key. This is a hard invariant — if any revision ever allowed a KEK to be reused (caching, deterministic ephemerals, recipient deduplication that reuses a slot), the zero nonce would have to be replaced with a random one in the same change.

The slot-set MAC

slots_mac binds the entire slot set — together with the cross-KEM header fields that fix how the slots are interpreted, and the item's plaintext-hash claim — to the CEK, defeating slot-substitution, slot-removal, slot-reorder, and envelope-splice tampering. The binding is a two-step construction: a slots transcript is hashed once to a 32-byte slots_hash, and that hash is the message of a CEK-keyed HMAC.

CBOR
hashes_hash = SHA-256("cardano-poe-item-hashes-v1" || canonicalEncode(item.hashes))  ; 32 bytes

SLOTS_TRANSCRIPT = {                  ; closed 7-key map; keys are a set, not an order
  "scheme":      1,                   ; uint
  "path":        "slots",             ; text
  "aead":        <enc.aead>,          ; text: 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 map

slots_hash = SHA-256("cardano-poe-slots-transcript-v1" || canonicalEncode(SLOTS_TRANSCRIPT))  ; 32 bytes
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 bytes

SLOTS_TRANSCRIPT is a closed map carrying exactly that seven-key set, serialised with canonicalEncode so both sides produce byte-identical bytes; its key order is the RFC 8949 §4.2.1 bytewise sort, never hand-arranged. The slots value is the shuffled array of closed slot maps exactly as they appear on the wire ({epk, wrap} for x25519, {kem_ct, wrap} for mlkem768x25519), so the full per-slot wire content of every slot is inside the transcript. The transcript additionally pins scheme, path, aead, kem, and nonce: a relay that flips any of those header fields while leaving the slot shapes valid produces a different slots_hash, so the MAC fails. The slots_hash and hashes_hash SHA-256 prefixes (cardano-poe-slots-transcript-v1, cardano-poe-item-hashes-v1) are exact ASCII with no terminator and no length prefix.

hashes_hash is what binds the envelope to this item's hash claim: it is a labelled SHA-256 over the canonicalEncode of the item's complete hashes map. Because the recipient recomputes slots_mac from on-chain bytes alone, a MAC match confirms the envelope was sealed for this exact claim — an envelope spliced onto an item with a different hashes map fails the on-chain match step, before any ciphertext fetch. The item's uris[] are deliberately not bound, so ciphertext may be re-hosted at a new content-addressed URI without invalidating the envelope; a sender for whom the URI list is part of the claim binds it with a record-level signature instead.

In the HMAC_KEY derivation salt = "" is a zero-length octet string, the absent-salt convention of RFC 5869 §2.2 (HKDF-Extract substitutes HashLen zero bytes — 32 for SHA-256). It is pinned by a byte-exact conformance vector rather than left to a library default, so an implementation that mishandles the absent salt fails the vector instead of silently deriving a different key.

slots_hash is computed once per record and is constant across the recipient trial-decrypt loop — the per-slot MAC check re-keys HMAC from each candidate CEK but always over the same 32-byte slots_hash. The commitment property is preserved because the HMAC key is still HKDF-SHA-256(CEK, …): pre-hashing the transcript only changes the HMAC message from the full transcript to its SHA-256, leaving the CEK-keyed binding intact.

The slot-set MAC is fixed by enc.scheme: there is no on-wire identifier for it, exactly one construction exists per scheme value, and it is identical for both KEMs. slots_mac MUST be exactly 32 bytes (ENC_SLOTS_MAC_INVALID_LENGTH on a wrong length) and MUST be verified in constant time.

The transcript depends on each slot's wire bytes directly. Both slot fields are single CBOR byte strings — epk is 32 bytes, kem_ct is 1120 bytes — so there is no per-field chunking to normalise and no chunk-boundary ambiguity: the only chunking Label 309 performs is the whole-body transport split on The record, undone before any of this runs. A byte flip anywhere in a slot changes slots_hash and fails the MAC.

The content layer needs no separate per-pass binding to the slot set: the content key is an HKDF leaf of the CEK, and the CEK is already committed to the full header — including hashes_hash — by slots_mac. Editing any slot or header field changes what the recipient derives, so the content stream simply fails to open. The per-chunk AAD is therefore empty (see The content layer).

The two KEMs

The KEM, selected per record by enc.kem, fixes the slot shape and the KEK derivation. Both are registered under enc.scheme = 1 from the first release.

enc.kemKEMRecipient public keySlot shapeKEK info string
"x25519"X25519 (classical)32 bytes{ epk: bstr(32), wrap: bstr(48) }"cardano-poe-kek-v1"
"mlkem768x25519"X-Wing = X25519 + ML-KEM-7681216 bytes{ kem_ct: bstr(1120), wrap: bstr(48) }"cardano-poe-kek-mlkem768x25519-v1"

Producers SHOULD default to mlkem768x25519. The hybrid KEM is secure against both classical and harvest-now-decrypt-later quantum adversaries while keeping X25519's classical security as a floor — the X-Wing combiner binds both shared secrets. That "never below X25519 classical security" floor is scoped to validly generated recipient keys: it presumes the public key passes the pinned X-Wing revision's key-validity check (applied at encapsulation, see Hybrid: mlkem768x25519 below). The classical x25519 KEM stays available for recipients whose published key is X25519-only. The identifier mlkem768x25519 is deliberately written without hyphens, matching the X-Wing/age ecosystem spelling.

Both KEMs use the same age stanza pattern — per-recipient KEM material plus a symmetric wrap of the file key — and the same header binding (the slot-set MAC), so one uniform construction covers both with no HPKE dependency. The classical x25519 path closely mirrors age's native X25519 recipient. The hybrid mlkem768x25519 path deliberately diverges from age's own post-quantum choice: age v1.3.0 ships native post-quantum recipients (visible prefix age1pq…) that wrap the file key via HPKE SealBase (RFC 9180) over an ML-KEM-768 + X25519 KEM, not the stanza pattern. Keeping the stanza wrap for the hybrid path is what lets one uniform wrap and one uniform header-binding cover both KEMs. The hybrid wrap therefore does not inherit age's HPKE construction, and no age-inheritance claim is made for it; the distinct age1pqc recipient encoding (see Keys) reflects that the two hybrid encodings are independent.

Classical: x25519

For each recipient the sender generates a fresh ephemeral X25519 keypair, performs an ECDH against the recipient public key, and derives the KEK with HKDF (RFC 5869) under a labelled-hash salt:

CBOR
shared   = X25519(priv_epk, pub_R)               ; per RFC 7748; reject all-zero output
kek_salt = SHA-256("cardano-poe-x25519-kek-salt-v1" || enc.nonce || pub_epk || pub_R)  ; 32 bytes
KEK      = HKDF-SHA-256(ikm = shared,
                        salt = kek_salt,          ; binds nonce, ephemeral, recipient
                        info = "cardano-poe-kek-v1",
                        L = 32)
slot     = { "epk": pub_epk, "wrap": wrap }       ; epk = 32 bytes

The 32-byte ephemeral public key epk is the only key material on the wire; the recipient public key is never published. The salt is a labelled SHA-256 binding three values: pub_epk makes every slot's KEK unique, pub_R binds it to the specific recipient (defeating any attempt to repurpose an epk against a different recipient), and the envelope-unique enc.nonce anchors the KEK to one envelope — so a CSPRNG failure that repeated KEM randomness across two envelopes degrades only to cross-envelope linkability, never to a repeated (KEK, zero-nonce) wrap pair. X25519 implementations MUST reject the all-zero shared secret per RFC 7748 §6.1; mainstream libraries do this transitively.

Hybrid: mlkem768x25519 (X-Wing)

The hybrid KEM is the X-Wing construction (draft-connolly-cfrg-xwing-kem-10), combining ML-KEM-768 (FIPS 203) with X25519. Each encapsulation draws fresh ML-KEM randomness and a fresh X25519 ephemeral and yields a 1120-byte ciphertext and a 32-byte combined shared secret. The KEK derivation binds the recipient via an external salt computed over the slot's own wire bytes:

CBOR
enc      = XWing.Encapsulate(pub_R)    ; named fields — MUST NOT consume positional order
kem_ct   = enc.ct                      ; 1120 bytes
shared   = enc.ss                      ; 32 bytes
kek_salt = SHA-256("cardano-poe-xwing-kek-salt-v1" || enc.nonce || kem_ct || pub_R)  ; 32 bytes
KEK      = HKDF-SHA-256(ikm = shared,
                        salt = kek_salt,          ; binds nonce, kem_ct, recipient
                        info = "cardano-poe-kek-mlkem768x25519-v1",
                        L = 32)
wrap     = ChaCha20-Poly1305_seal(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 string

X-Wing key and ciphertext sizes:

ComponentSizeComposition
Public key1216 bytesML-KEM-768 ek (1184) ‖ X25519 pk (32)
Ciphertext1120 bytesML-KEM-768 ct (1088) ‖ X25519 ephemeral (32)
Shared secret32 bytesX-Wing combiner output
Decapsulation key32 bytesa seed; the public key is derived from it

A hybrid slot carries no epk field — the X25519 ephemeral is the trailing 32 bytes of the 1120-byte kem_ct. XWing.Encapsulate MUST apply the pinned X-Wing revision's public-key validity check to pub_R and reject an invalid key rather than encapsulating to it; this is the precondition under which the hybrid floor never drops below X25519 classical security. The construction consumes X-Wing through an adapter with named fields only: Encapsulate(pk) yields .ct (1120 B) and .ss (32 B); Decapsulate(sk, ct) yields the 32-byte shared secret. Implementations MUST map to the pinned revision's API by name and MUST NOT consume positional return values — the pinned revision returns (ss, ct) from encapsulation and writes decapsulation as Decapsulate(ct, sk), the reverse of a naive left-to-right reading. The KEK derivation binds the recipient through a fixed-length labelled salt, SHA-256("cardano-poe-xwing-kek-salt-v1" || enc.nonce || kem_ct || pub_R), where kem_ct is the 1120-byte ciphertext exactly as carried in the slot and pub_R is the 1216-byte X-Wing recipient public key. This is the same three-value shape the classical salt uses under its own label — kem_ct anchors the KEK to a slot-unique value, pub_R binds it to the specific recipient, and enc.nonce anchors it to one envelope — expressed through a SHA-256 digest because the hybrid inputs are oversized for a raw salt. In both salts the pub_R term is the recipient key's canonical wire encoding: exactly the 32-byte x25519_publicKey(priv_R) for x25519, exactly the pinned 1216-byte X-Wing public-key byte string for mlkem768x25519. Producer and verifier MUST use that exact encoding and MUST NOT substitute any non-canonical or re-encoded equivalent, or the two sides derive different KEKs and an honest record fails to open. Crucially the binding is computed outside the KEM, over the slot's own wire bytes, so the construction holds X-Wing as a black-box KEM: it consumes only the public KEM interface (encapsulate, decapsulate, the 32-byte shared secret) and makes no assumption about the combiner's internal hashing. The KEM-distinct info label cardano-poe-kek-mlkem768x25519-v1 additionally guarantees that a KEK derived for one KEM can never equal a KEK derived for the other, even on an identical 32-byte shared secret. The 1120-byte ciphertext is carried as a single CBOR byte string in slot.kem_ct — only the whole record body is chunked for transport (see The record), never an individual field.

One KEM per record

A single sealed-PoE item carries exactly one enc.kem; every slot uses that KEM's shape and KEK derivation. A file is all-classical or all-hybrid — slots of different KEMs MUST NOT appear in the same slots array, and a verifier MUST reject a record whose slot shapes are inconsistent with the declared enc.kem (ENC_SLOT_INVALID_SHAPE).

The encapsulation material MUST also be distinct within one slots array: for x25519 all epk values MUST differ, for mlkem768x25519 all kem_ct values MUST differ. A duplicate is rejected — before any KEM or AEAD primitive runs — with ENC_SLOTS_DUPLICATE_KEM_MATERIAL. This is the verifiable slice of the per-slot-KEK-uniqueness invariant the zero-nonce wrap depends on: cross-record or cross-key KEK reuse is a producer obligation a verifier cannot detect, but a within-record duplicate is structurally visible and MUST fail.

Recipient trial decryption

A recipient holds a private key (a 32-byte X25519 scalar for x25519, or a 32-byte X-Wing decapsulation seed for mlkem768x25519 — both seed-derived; see Keys). They do not know in advance which slot, if any, is theirs, so they trial-decrypt the array. Two properties shape the loop: the slot-set MAC check is folded in (a slot is accepted only when its candidate CEK also reproduces the on-wire slots_mac), and the loop runs over all slots with no early break, selecting the match in constant time so a timing observer cannot infer which slot index matched.

Before any KEM or AEAD primitive is invoked, the verifier MUST run the structural shape checks (the partitioning-oracle defence): scheme == 1, aead/kem registered, nonce 24 bytes, slots_mac 32 bytes, slots non-empty, the recipient secret 32 bytes, every slot.wrap exactly 48 bytes, each x25519 epk exactly 32 bytes with no kem_ct, each mlkem768x25519 kem_ct exactly 1120 bytes with no epk, and the within-slots distinctness of all encapsulation material (else ENC_SLOTS_DUPLICATE_KEM_MATERIAL).

In the same pre-primitive pass the verifier MUST also bound parser resource use: the reference bounds are MAX_SLOTS = 1024 slots and 65536 bytes for the decoded enc envelope. Both sit far above the ≈ 16 KiB Cardano transaction-metadata ceiling that bounds an honest record, so a record exceeding either is malformed and is rejected here — ENC_SLOTS_TOO_MANY for too many slots, ENC_ENVELOPE_TOO_LARGE for an oversized envelope — before any KEM or AEAD primitive runs. These bounds are verifier-enforced, deployment-pinned constants, not wire fields; a deployment MAY tighten them.

; hashes_hash, SLOTS_TRANSCRIPT and slots_hash are recomputed once, before the loop, and held constant:
hashes_hash = SHA-256("cardano-poe-item-hashes-v1" || canonicalEncode(item.hashes))
slots_hash  = SHA-256("cardano-poe-slots-transcript-v1" || canonicalEncode(SLOTS_TRANSCRIPT))
if kem == "x25519": pub_R = x25519_publicKey(priv_R)   ; recipient public key, 32 B
else:               pub_R = XWing.publicKey(priv_R)     ; recipient X-Wing public key, 1216 B

found        = false
cek_conflict = false
selected_CEK = zeros(32)
for slot in enc.slots:                  ; iterate ALL slots — no early break
    kem_ok = true
    if kem == "x25519":
        shared    = x25519(priv_R, slot.epk)
        kem_ok    = NOT constant_time_eq(shared, zeros(32))     ; explicit all-zero reject, secret-independent
        kek_salt  = SHA-256("cardano-poe-x25519-kek-salt-v1" || enc.nonce || slot.epk || pub_R)
        real_KEK  = HKDF-SHA-256(shared,    salt = kek_salt, info = "cardano-poe-kek-v1", L = 32)
        dummy_KEK = HKDF-SHA-256(zeros(32), salt = kek_salt, info = "cardano-poe-kek-v1", L = 32)
        KEK       = ct_select(kem_ok, real_KEK, dummy_KEK)      ; constant-time, no early exit
        ad_wrap   = "cardano-poe-kek-v1"
    else:                               ; mlkem768x25519
        shared   = XWing.Decapsulate(sk=priv_R, ct=slot.kem_ct)   ; pinned API writes Decapsulate(ct, sk)
        kek_salt = SHA-256("cardano-poe-xwing-kek-salt-v1" || enc.nonce || slot.kem_ct || pub_R)
        KEK      = HKDF-SHA-256(shared, salt = kek_salt,
                               info = "cardano-poe-kek-mlkem768x25519-v1", L = 32)
        ad_wrap  = "cardano-poe-kek-mlkem768x25519-v1"

    open_ok, candidate_CEK = ChaCha20-Poly1305_open_or_dummy(KEK, zeros(12), ad_wrap, slot.wrap)
    HMAC_KEY = HKDF-SHA-256(candidate_CEK, salt = "", info = "cardano-poe-slots-mac-v1", L = 32)
    mac_ok   = constant_time_eq(HMAC-SHA-256(HMAC_KEY, slots_hash), enc.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 constant_time_eq(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)   ; WRONG_RECIPIENT_KEY / TAMPERED_HEADER
if cek_conflict: reject (single generic failure)   ; cek_conflict

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_CIPHERTEXT

The KEK derivation branches on enc.kem: for x25519 the recipient performs an ECDH against slot.epk and re-derives the same labelled salt over enc.nonce || slot.epk || pub_R; for mlkem768x25519 it X-Wing-decapsulates slot.kem_ct directly (a single 1120-byte byte string) and recomputes the same labelled salt over enc.nonce || slot.kem_ct || pub_R, where pub_R is its own 1216-byte X-Wing public key derived from the held seed. The X25519 all-zero shared-secret rejection is explicit here rather than relied upon transitively: a slot crafted to drive the shared secret to zeros(32) (RFC 7748 §6.1) sets the secret-independent validity bit kem_ok to false, the KEK is constant-time-selected to a dummy_KEK derived from zeros(32) under the same salt and info so the loop performs identical work, and kem_ok is folded into ok — so an invalid-ECDH slot can never be accepted regardless of the wrap or MAC outcome, and the record surfaces the single generic failure if nothing else matches. Everything after the wrap opens — the slot-set MAC check, the content-key derivation, and the content decrypt — is KEM-independent.

Both *_open_or_dummy AEAD primitives are atomic: on a tag-verification failure they return no plaintext, and the returned candidate (candidate_CEK for the wrap open, the plaintext for the content open) is a fixed or pseudorandom dummy that is independent of the failed ciphertext. No unverified plaintext is ever released to the caller, so a failed open cannot become a decryption oracle.

Why the MAC check lives inside the loop

A malicious sender can craft a slot that opens under a recipient's key but yields an attacker-chosen CEK (encapsulating to the recipient's public key needs no private key). If a recipient accepted the first AEAD success as "theirs", that forged slot would shadow an honest one later in the array. Folding the slots_mac check into the loop means a slot is accepted only when its candidate CEK reproduces the MAC over slots_hash — so a forged slot is skipped and scanning continues. The slot.wrap length MUST be checked to be 48 bytes before any AEAD call, a partitioning-oracle defence age v1 also applies.

Multiple matching slots: duplication is permitted, a CEK conflict is not. A recipient's private key MAY legitimately match more than one slot. A producer may seal the same CEK to the same recipient across several slots — each with its own fresh per-slot ephemeral — to pad the apparent recipient count, a valid privacy technique. The verifier selects the first match's CEK and MUST NOT reject merely because more than one slot matched. This is distinct from the within-record duplicate-encapsulation-material rejection (ENC_SLOTS_DUPLICATE_KEM_MATERIAL), which fires on a repeated epk or kem_ct: honest duplication draws fresh per-slot KEM randomness for each appearance, so its epk / kem_ct differ and it never collides with that check. The one anomaly the verifier MUST reject is two matching slots that recover different CEKs (compared in constant time): the loop carries a cek_conflict bit across all slots and, if any later match recovers a CEK that differs from the selected one, surfaces the single generic failure. This is defence-in-depth — under the commitment property the recovered CEK supplies (the slot-set MAC binds the CEK to a single slot transcript; see Anonymity and the per-KEM split), a distinct-CEK match is already infeasible, being exactly the multi-key collision that commitment rules out, so the check fails closed against a broken implementation or a future weakening of that assumption.

One generic failure shape, constant time across slots

An untrusted caller MUST receive exactly one generic failure shape regardless of why decryption failed — no slot opened, the slot set was tampered, or the content AEAD failed — and the response MUST NOT distinguish these, nor reveal which slot matched. An implementation MAY surface internal typed codes — WRONG_RECIPIENT_KEY (no slot opens), TAMPERED_HEADER (a slot opens but no candidate CEK reproduces the slots_mac over slots_hash), TAMPERED_CIPHERTEXT (the content AEAD fails after a CEK is recovered) — to a trusted local caller for diagnostics, but those codes MUST NOT leak to an external observer through a distinguishable response.

On timing, the verifier MAY return at the no-match check (if NOT found) before content decryption. That early return reveals only recipient vs non-recipient — never which slot matched and no key material — because the across-slots loop above has already run to completion by the time the check is reached. Uniform timing between the non-recipient case and a recipient whose ciphertext fails to open is NOT required, and a dummy content open MUST NOT be mandated: forcing every non-recipient to pay the full content-decryption cost buys no privacy the loop does not already provide. The constant-time guarantee that does hold is the across-slots invariant — the loop processes a constant number of slot operations per private key with no early break, so a network-level observer learns only the slot count, never which slot (if any) the key unwraps. A recipient holding several keys (e.g. archived keys across an identity rotation) iterates private-key × slot, re-deriving the pub_R salt half from the current key; it 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.

After recovering the plaintext, the recipient — in the application layer, not the decrypt function — recomputes the plaintext hash and checks it against items[].hashes. A mismatch means the record's on-chain commitment does not match the decrypted bytes, and the recipient MUST refuse to act on the plaintext. This is the step that closes the loop: the chain witnessed a commitment at time T, and the recipient confirms it is a commitment to exactly these bytes.

Passphrase path

The alternative key-delivery path replaces the recipient slots with a passphrase. There is no slots array, no slots_mac, no per-slot ephemeral, and no trial-decrypt loop: the CEK is derived directly from a normalised passphrase via Argon2id (RFC 9106) over an on-chain salt and parameters. 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 — same object, same URI, same fetch.

CBOR
passphrase_bytes = utf8(normalize(passphrase))   ; cardano-poe-pw-norm-v1 (see below)
CEK              = argon2id(passphrase_bytes,
                           salt   = enc.passphrase.salt,    ; 16–64 bytes, on chain
                           params = enc.passphrase.params,  ; { m, t, p }, on chain
                           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,                    ; uint
  "path":        "passphrase",         ; text
  "aead":        <enc.aead>,           ; text: the content-format identifier
  "nonce":       <enc.nonce>,          ; bytes(24)
  "hashes_hash": hashes_hash,          ; bytes(32), over this item's hashes
  "passphrase": {                      ; closed sub-map
    "alg":           "argon2id",       ; text
    "salt":          enc.passphrase.salt,         ; bytes
    "params":        { "m": m, "t": t, "p": p },  ; closed map of uints
    "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 bytes

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_key, as on the slots path

The enc.passphrase block on the wire is { alg, salt, params } — it names the KDF ("argon2id"), the salt, and the parameters. Label 309 fixes a parameter floor of m ≥ 65536 KiB (64 MiB), t ≥ 3, p ≥ 1; the producer chooses values at or above the floor and the salt is 16–64 bytes inclusive (the 64-byte ceiling is the metadatum byte-string cap). Where the platform supports it, producers SHOULD use p = 4 (the second recommended profile of RFC 9106 §4); verifiers MAY accept any p ≥ 1, subject to the deployment ceilings below.

The PASSPHRASE_TRANSCRIPT binds the KDF parameters, the header fields, and the item's hash claim into the commitment: the verifier recomputes the transcript from the received enc map and the item's hashes, so 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 content is then sealed in the same segmented STREAM as the slots path, under the passphrase-path content key. 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 serialised on the wire.

Verification order. The verifier derives the candidate CEK from the entered passphrase, reads the leading 32 bytes of the ciphertext blob, recomputes the commitment, and compares it in constant time — before opening any STREAM chunk. A passphrase-path blob shorter than 48 bytes — the 32-byte commitment header plus the 16-byte minimum STREAM — cannot be well-formed and is malformed ciphertext (TAMPERED_CIPHERTEXT). On mismatch — wrong passphrase, tampered salt / params, tampered header, or a spliced envelope — the verifier surfaces the same single generic failure as any other decryption failure and MUST NOT begin streaming. A wrong passphrase is therefore indistinguishable from a tampered record.

Before normalization and Argon2id, an implementation MUST bound the raw passphrase input length so an oversized passphrase cannot drive a pre-KDF denial-of-service: the reference bound is 4096 UTF-8 bytes of raw input, rejected before any normalization or hashing work. Like the MAX_SLOTS and decoded-enc- envelope bounds the slots path enforces, this is a verifier-enforced, deployment-pinned constant — not a wire field — and a deployment MAY tighten it. Beyond the parameter floor, implementations SHOULD also enforce upper bounds on m, t, and p against verifier-side DoS; those ceilings are non-normative (hardware-dependent) and MUST NOT be conflated with the floor.

Why the commitment is off-chain

An on-chain passphrase commitment would hand every observer a free offline test oracle — derive a candidate CEK from a guessed passphrase, check it against the chain — for every passphrase record, forever, including records whose ciphertext is withheld. Carrying the commitment inside the ciphertext blob means testing a guess requires the blob itself: a withheld-ciphertext record exposes no passphrase-guessable material on the permanent ledger, and a legitimate recipient who already holds the blob pays nothing to read a 32-byte header first.

The normalization profile

The normalization applied to the passphrase before Argon2id is the fixed profile cardano-poe-pw-norm-v1. It 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, applied in order, is:

  1. Reject unassigned codepoints. A passphrase containing any codepoint unassigned in Unicode 16.0 is rejected with ENC_PASSPHRASE_UNNORMALIZABLE before any normalization step runs.
  2. NFKC. Apply Normalization Form KC per UAX #15 under Unicode 16.0.
  3. Whitespace. Define "whitespace" as every character carrying the Unicode White_Space property under Unicode 16.0, and collapse every maximal run of such characters to a single U+0020 SPACE.
  4. Trim. Remove leading and trailing whitespace.
  5. Reject empty. If the result is the empty string, reject with ENC_PASSPHRASE_EMPTY: a whitespace-only or otherwise vacuous passphrase normalizes to zero bytes, which Argon2id would silently accept — keying the record to a CEK any party can derive.
  6. Encode. Encode the result as UTF-8; those bytes are the Argon2id password input.

Step 1 is what makes the profile deterministic across implementations and across time. The Unicode Normalization Stability Policy guarantees that a string's normalization is stable across future Unicode versions only when every codepoint in it is assigned in the version where it was normalized; an unassigned codepoint may acquire a decomposition later and silently change the derived CEK. Rejecting unassigned codepoints closes that hole entirely, and is invisible to honest users — every character in actual written use is assigned.

The Unicode version is pinned at Unicode 16.0 literally and MUST NOT float: the White_Space property set, the assigned-codepoint set, and the NFKC mapping tables are all version-dependent, and a verifier resolving the profile against a different Unicode version could derive a different CEK from the same passphrase and fail to decrypt an honest record. A future revision that adopts a newer Unicode version does so under a new profile identifier, not by re-interpreting cardano-poe-pw-norm-v1.

Passphrase entropy is the only barrier

The salt and Argon2id parameters are public on the chain forever, so an attacker has unlimited offline time to brute-force the passphrase against them. Passphrase entropy is the sole security margin on this path. Producers SHOULD use a CSPRNG-generated diceware passphrase rather than a human-chosen one, and SHOULD surface a visible warning when accepting typed passphrases that the on-chain ciphertext will be permanently subject to offline attack.

Forward secrecy and per-slot independence

The slots construction uses ephemeral-static ECDH (or fresh X-Wing encapsulation) with a fresh ephemeral per slot, which buys two properties that a static-static or shared-ephemeral design would lose:

  • Forward secrecy against sender compromise. The sender holds no long-term key in the construction; the ephemeral is zeroized after sealing. Compromising sender state later cannot decrypt records published before the compromise.
  • Per-slot independence. Different recipients get different ephemerals, hence different shared secrets and KEKs. One recipient leaking their wrapped CEK reveals the CEK (unavoidable — it is the file key) but never another recipient's KEK.

Sealed PoE has no recipient forward secrecy by design: once a record is sealed to a long-term recipient key, the holder of the matching private key can decrypt it forever. That is a property of public-key encryption to a long-term key, not a defect.

Anonymity and the per-KEM split

When a sealed-PoE record carries no sigs, its on-wire bytes are independent of the sender's identity: each slot carries only per-record, per-slot ephemeral KEM material (the X25519 ephemeral in slot.epk, or the X-Wing ciphertext in slot.kem_ct), the sender's long-term keys never appear, the slots are CSPRNG-shuffled, no recipient public key is on the wire, and no descriptive field (filename, MIME type, size) is present. An unsigned sealed record therefore binds no sender identity on chain — exactly what whistleblower drops, sealed-bid auctions, and evidence escrow require.

For both KEMs the honest leakages are identical and unavoidable: the slot count, the sealed-vs-open distinction, and the classical-vs-hybrid KEM family (enc.kem) are visible to any observer; nothing more about the recipients is.

The stronger claim — that an adversary who holds a set of candidate recipient public keys cannot test whether a given slot is addressed to one of them (key-privacy / recipient anonymity) — is a per-KEM property:

  • x25519 — key-private. The per-slot encapsulation is a fresh ephemeral public key statistically independent of the recipient key. An adversary holding candidate recipient public keys cannot, from slot.epk and slot.wrap alone, decide which candidate (if any) the slot targets without the matching private key. The classical path is therefore key-private, which also gives cross-record unlinkability: two sealed PoEs to the same recipient look like unrelated epk/wrap blobs.
  • mlkem768x25519 — not claimed. Recipient anonymity against an adversary holding candidate recipient keys is a separate property not implied by the IND-CCA security of the hybrid KEM. Label 309 does not claim it for the X-Wing path unless and until it is independently justified for X-Wing. A deployment whose threat model requires recipient anonymity against a key-holding adversary MUST NOT rely on the hybrid path for that property.

Senders concerned about timing-correlation across records MUST batch publishes off the critical timeline; wire-level cryptography cannot solve metadata-timing attacks.

The slot-set MAC is the commitment; the wrap need not be

The recovered CEK is a commitment to the slot set the recipient matched: a malicious sender cannot construct two distinct slot sets a single recipient accepts as theirs. The property required here is restricted key commitment for the envelope CEK in the sense of RFC 9771 — the recovered CEK binds to a single slot transcript — not a full committing AEAD over arbitrary inputs. It rests on the multi-key collision resistance of CEK ↦ HMAC-SHA-256(HKDF-SHA-256(CEK, "cardano-poe-slots-mac-v1"), slots_hash) for adversarially chosen CEKs and transcripts — a ~128-bit generic-collision margin (the birthday bound on a 256-bit output), adequate for the threat model. Tamper-evidence of the transcript itself inherits SHA-256's ~2^128 collision bound: any change to the committed header fields or slot bytes alters slots_hash, and forging an unchanged slots_hash over a different transcript is exactly that ~2^128 collision search. Because the commitment is supplied by slots_mac, the per-slot wrap AEAD need not be a committing AEAD; the default non-committing ChaCha20-Poly1305 is sound here.

Forbidden patterns

A conformant implementation MUST NOT:

  • Reuse a per-slot ephemeral across slots or records, or otherwise let a KEK repeat — the zero-nonce wrap depends on per-slot KEK uniqueness.
  • Reuse a CEK across envelopes — a fresh CSPRNG CEK per enc-bearing item, within a record and across records alike.
  • Reuse a passphrase salt — generate a fresh CSPRNG enc.passphrase.salt for every passphrase envelope; the salt is the sole cross-record separator for a reused passphrase.
  • Mix KEMs within one slots array (one enc.kem per record).
  • Publish slots in input order — the CSPRNG shuffle is required.
  • Wrap the CEK with any nonce other than the 12-byte zero nonce, or with empty wrap-AEAD AAD — the wrap AAD is the KEM's info label literal.
  • Put a recipient public key on the wire — the trial-decrypt design is the privacy feature; publishing pubkeys defeats it.
  • Skip the slots_mac verification — without it, slot-substitution succeeds.
  • Store the plaintext at the ar:///ipfs:// URI — only the ciphertext is published; the plaintext is delivered out of band or held by the sender.
  • Reference ciphertext through any scheme other than ar:// or ipfs:// — the content-addressed schemes bind the URI to the bytes; a host-served URL would require a separate on-chain ciphertext commitment that sealed PoE does not carry.
  • Log or persist the CEK, any KEK, the slot-set HMAC key, the passphrase MAC key, the content key, an ECDH shared secret, an ephemeral private key, or a recipient private key.
  • Keys — the seed-derived X25519 and X-Wing keypairs that supply the recipient and sender key material.
  • The record — where enc sits in the record map and the whole-body transport that carries the record on chain.
  • Algorithm registries — the enc.aead, enc.kem, and passphrase-KDF identifiers and their backing primitives.
  • Content and hashing — the plaintext-hash commitment that every sealed record carries.
  • Verification — the validation pipeline, why the validator never decrypts, and the error catalogue.