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 classicalx25519path; it is not claimed for the hybridmlkem768x25519path (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.
| Field | Status | Meaning |
|---|---|---|
scheme | REQUIRED | Construction-family version. v1 defines scheme = 1. |
aead | REQUIRED | Content-format identifier. v1 defines "chacha20-poly1305-stream64k". |
nonce | REQUIRED | 24 random bytes — the envelope-unique salt of the content key and every slot KEK. |
kem | slots path only | Per-slot KEM selector ("x25519" or "mlkem768x25519"). |
slots | one path | Array of per-recipient key slots (multi-recipient). |
slots_mac | slots path only | 32-byte HMAC binding the slot set and the item's hash claim to the content key. |
passphrase | the other path | Passphrase-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:
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:
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 bytesThe 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):
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:
- Selects one KEM for the whole record and generates the CEK (32 random bytes)
and
nonce(24 random bytes). - For each recipient, derives a per-slot KEK and wraps the CEK under it (per-KEM details below).
- Shuffles the slot array with a CSPRNG (unbiased Fisher-Yates).
- 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 computesslots_macas a CEK-keyed HMAC over that hash. - 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):
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.
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 bytesSLOTS_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.kem | KEM | Recipient public key | Slot shape | KEK info string |
|---|---|---|---|---|
"x25519" | X25519 (classical) | 32 bytes | { epk: bstr(32), wrap: bstr(48) } | "cardano-poe-kek-v1" |
"mlkem768x25519" | X-Wing = X25519 + ML-KEM-768 | 1216 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:
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 bytesThe 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:
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 stringX-Wing key and ciphertext sizes:
| Component | Size | Composition |
|---|---|---|
| Public key | 1216 bytes | ML-KEM-768 ek (1184) ‖ X25519 pk (32) |
| Ciphertext | 1120 bytes | ML-KEM-768 ct (1088) ‖ X25519 ephemeral (32) |
| Shared secret | 32 bytes | X-Wing combiner output |
| Decapsulation key | 32 bytes | a 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_CIPHERTEXTThe 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.
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 pathThe 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:
- Reject unassigned codepoints. A passphrase containing any codepoint
unassigned in Unicode 16.0 is rejected with
ENC_PASSPHRASE_UNNORMALIZABLEbefore any normalization step runs. - NFKC. Apply 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, and collapse every maximal run of such characters to a single U+0020 SPACE. - Trim. Remove leading and trailing whitespace.
- 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. - 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, fromslot.epkandslot.wrapalone, 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 unrelatedepk/wrapblobs.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.saltfor every passphrase envelope; the salt is the sole cross-record separator for a reused passphrase. - Mix KEMs within one
slotsarray (oneenc.kemper 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
infolabel literal. - Put a recipient public key on the wire — the trial-decrypt design is the privacy feature; publishing pubkeys defeats it.
- Skip the
slots_macverification — 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://oripfs://— 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.
Related pages
- Keys — the seed-derived X25519 and X-Wing keypairs that supply the recipient and sender key material.
- The record — where
encsits 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.