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 content AEAD pass, 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 chunked transport of oversized
slot fields, 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 identities 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. A sealed PoE is, in effect, held until claimed: it sits on the chain indistinguishable to everyone except the holders of the matching private keys. - 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 AEAD identifier. v1 defines "xchacha20-poly1305". |
nonce | REQUIRED | 24 random bytes — the content AEAD nonce. |
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 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 the AAD fed to the content AEAD: nonce || slots_mac on the slots path, the empty byte string on the passphrase path. A
producer or verifier MUST select the path by inspecting which of slots /
passphrase is present before invoking the AEAD.
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 AEAD — 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.
The content layer
Both paths converge on one symmetric pass over the plaintext. A single 32-byte
content-encryption key (CEK) encrypts the entire plaintext under
XChaCha20-Poly1305 (the
draft-irtf-cfrg-xchacha-03 extended-nonce variant of
RFC 8439), with the 24-byte
enc.nonce:
ciphertext = XChaCha20-Poly1305_seal(
key = CEK, ; 32 bytes
nonce = enc.nonce, ; 24 random bytes
ad = AAD, ; nonce || slots_mac (slots path)
; h'' (passphrase path)
plaintext = file_bytes)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 ciphertext decrypts back to those bytes and only those bytes. The
24-byte random nonce is what makes XChaCha20-Poly1305 the v1 choice: a producer
can draw a fresh nonce from a CSPRNG without coordinating counters across tabs,
CLI runs, workers, or retries, which is the right property for stateless
producers. The trade-off is that the XChaCha draft expired without becoming an
RFC; the construction is pinned byte-for-byte by test vectors instead, and the
AEAD identifier is explicit on the wire so a future enc.scheme: 2 can introduce
an RFC-backed content layer without disturbing v1.
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.
- Computes
slots_macover the shuffled array. - Encrypts the content under the CEK with AAD
nonce || slots_mac.
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 to the CEK, defeating slot-substitution,
slot-removal, and slot-reorder tampering. It is a 32-byte HMAC-SHA-256 over the
canonical CBOR of the slots array, keyed by a CEK-derived key:
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 = canonicalCBOR(slots))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. The MAC covers each slot's full wire content — {epk, wrap} for the
classical KEM, {kem_ct, wrap} for the hybrid KEM — so the whole chunked
kem_ct array is inside the MAC. Because the content AEAD's AAD is nonce || slots_mac, the content pass and the slot set are cryptographically welded: any
edit to the slots breaks slots_mac, which in turn breaks the content
authentication.
The slot-set MAC is chunking-invariant
For the hybrid KEM, kem_ct is carried as a chunked array (each chunk ≤ 64 bytes, per the
transport rules on The record). Before computing or verifying slots_mac, an
implementation MUST canonicalise each kem_ct: reassemble it to the full 1120 bytes, then
re-split into the canonical ≤ 64-byte sequence. The MAC then depends on the kem_ct bytes, not on
the wire chunk boundaries — a record re-chunked in transit still verifies, while any byte flip
still fails. Classical slots have no kem_ct and are unchanged.
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: [≤64-byte chunks], 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 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.
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):
shared = X25519(priv_epk, pub_R) ; per RFC 7748; reject all-zero output
KEK = HKDF-SHA-256(ikm = shared,
salt = pub_epk || pub_R, ; 64 bytes, ephemeral first
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. Putting both pub_epk and pub_R in the
HKDF salt (ephemeral first, recipient second) keeps every slot's KEK unique and
binds it to the specific recipient, defeating any attempt to repurpose an epk
against a different recipient. 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 (the CFRG X-Wing draft), 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:
(kem_ct, shared) = XWing.Encapsulate(pub_R) ; kem_ct = 1120 bytes, shared = 32 bytes
KEK = HKDF-SHA-256(ikm = shared,
salt = "", ; empty
info = "cardano-poe-kek-mlkem768x25519-v1",
L = 32)
slot = { "kem_ct": chunk64(kem_ct), "wrap": wrap }X-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 last 32
bytes of the 1120-byte kem_ct. The KEK derivation uses an empty HKDF salt:
the X-Wing shared secret is already a transcript hash over both ciphertexts and
the recipient's X25519 public key (the combiner is SHA3-256 over those values
plus the fixed X-Wing label), so re-feeding them into the salt would be redundant.
The KEM-distinct info label is what guarantees 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 chunked byte-string array per the
transport rules on The record.
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.
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. The slot-set MAC check is folded into the loop —
a slot is accepted only when the CEK it yields also reproduces the on-wire
slots_mac:
canonicalise each hybrid slot's kem_ct ; reassemble → re-split, for the MAC
slots_cbor = canonicalCBOR(slots) ; constant across the loop
for slot in enc.slots:
derive KEK from this slot (per enc.kem)
try:
candidate_CEK = ChaCha20-Poly1305_open(
key = KEK, nonce = zeros(12), ad = <KEM info>, ct = slot.wrap)
except AEAD failure:
continue ; not our slot — keep scanning
HMAC_KEY = HKDF-SHA-256(ikm = candidate_CEK, salt = "",
info = "cardano-poe-slots-mac-v1", L = 32)
if constant_time_eq(HMAC-SHA-256(HMAC_KEY, slots_cbor), enc.slots_mac):
CEK = candidate_CEK
break ; this slot is genuinely ours
if no CEK recovered: ; WRONG_RECIPIENT_KEY / TAMPERED_HEADER
reject
plaintext = XChaCha20-Poly1305_open(
key = CEK, nonce = enc.nonce, ad = enc.nonce || enc.slots_mac, ct = ciphertext)The KEK derivation branches on enc.kem: for x25519 the recipient performs an
ECDH against slot.epk and re-derives the same pub_epk || pub_R salt; for
mlkem768x25519 it reassembles slot.kem_ct to 1120 bytes and X-Wing-decapsulates
it. Everything after the wrap opens — the slot-set MAC check and the content
decrypt — is KEM-independent.
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 the honest
one. Folding the slots_mac check into the loop means a slot is accepted only when its CEK
reproduces the MAC over the whole slot array — so a forged slot is skipped and scanning continues
to the honest one. The slot.wrap length MUST be checked to be 48 bytes before any AEAD call,
a partitioning-oracle defence age v1 also applies.
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, and the content AEAD uses empty AAD.
passphrase_bytes = utf8(normalize(passphrase)) ; NFKC → collapse ws → trim → encode
CEK = argon2id(passphrase_bytes,
salt = enc.passphrase.salt, ; 16–64 bytes, on chain
params = enc.passphrase.params, ; { m, t, p }, on chain
L = 32)
ciphertext = XChaCha20-Poly1305_seal(
key = CEK, nonce = enc.nonce, ad = h'', plaintext = file_bytes)The enc.passphrase block 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).
The passphrase is run through a deterministic normalisation
pipeline (Unicode NFKC, whitespace collapse, trim, UTF-8 encode) so that the same
human input always produces the same KDF input across implementations.
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 unlinkability
The slots construction uses ephemeral-static ECDH (or fresh X-Wing encapsulation) with a fresh ephemeral per slot, which buys three 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.
- Unlinkability across records. Two sealed PoEs to the same recipient use
independent ephemerals, so an on-chain observer sees unrelated
epk/kem_ctblobs and cannot link the records to one recipient without already holding that recipient's private key (which trial-decrypt requires).
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.
- Mix KEMs within one
slotsarray (oneenc.kemper record). - Publish slots in input order — the CSPRNG shuffle is required.
- 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, 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 how oversized slot fields are chunked. - 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.
Signatures
The optional record-level `sigs` array — a detached COSE_Sign1 over the whole record body, its domain-separated signed payload, the two signer-key paths, and strict Ed25519 verification.
Verification
The three Label 309 verifier roles, the verdict states, finality depth, and the typed error catalogue — how anyone reaches the same answer from public infrastructure alone.