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.

FieldStatusMeaning
schemeREQUIREDConstruction-family version. v1 defines scheme = 1.
aeadREQUIREDContent AEAD identifier. v1 defines "xchacha20-poly1305".
nonceREQUIRED24 random bytes — the content AEAD nonce.
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 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 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:

CBOR
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:

  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.
  4. Computes slots_mac over the shuffled array.
  5. 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):

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

CBOR
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.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: [≤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):

CBOR
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 bytes

The 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:

CBOR
(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:

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

CBOR
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_ct blobs 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 slots array (one enc.kem per 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_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, 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 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.