# Algorithm registries (/spec/algorithm-registries) Every cryptographic choice in Label 309 is named by a **string identifier** drawn from an extensible registry — `sha2-256`, `chacha20-poly1305-stream64k`, `EdDSA`, and so on. A record never carries a raw algorithm number or an implicit assumption; it states which primitive it used, and a verifier looks that identifier up. This is the mechanism behind the **algorithm-agile** invariant: the registries can grow over time, and a verifier that meets an identifier it does not implement **rejects the record with a stable, typed error** — it never crashes, and it never silently accepts something it cannot check. That single rule is what makes the migration to post-quantum algorithms *additive*. A new identifier is a new row in a table, not a new version of the wire format. Older records keep verifying exactly as before, and older verifiers fail closed against records they were never built to understand. Across every registry, two constraints are absolute. **Implementations MUST NOT invent novel cryptography** — every primitive traces to a named public standard. And **all encryption MUST be authenticated**: only AEAD constructions are permitted, never an unauthenticated cipher with a bolt-on (or absent) integrity check. ## Hash [#hash] The content hash is the primary claim of every record, so the hash registry is the most load-bearing. Both registered functions produce a 32-byte digest, and both are mandatory for a conformant implementation to support. | Identifier | Algorithm | Digest | | ------------- | ------------------------------------------------------------------------ | ------ | | `sha2-256` | SHA-256 ([FIPS 180-4](https://csrc.nist.gov/pubs/fips/180-4/upd1/final)) | 32 B | | `blake2b-256` | BLAKE2b-256 ([RFC 7693](https://www.rfc-editor.org/rfc/rfc7693)) | 32 B | A producer MAY hash the same content under both functions for defense in depth; a single hash is sufficient for a valid record. ## Merkle commitment [#merkle-commitment] For committing to an ordered list of leaves under a single on-chain root, Label 309 registers one Merkle-commitment identifier. It is the IANA-registered string for the SHA-256 binary Merkle tree, sharing the leaf-prefix (`0x00`) and internal-node-prefix (`0x01`) domain separation that prevents leaf/node collisions. | Identifier | Algorithm | Root | | ---------------- | ------------------------------------------------------------------------------ | ---- | | `rfc9162-sha256` | [RFC 9162](https://www.rfc-editor.org/rfc/rfc9162) binary Merkle tree, SHA-256 | 32 B | A single-leaf tree commits to `SHA-256(0x00 ‖ leaf)`, **not** to the bare leaf — so a one-file proof MUST use a plain hash identifier, never a 1-leaf tree. ## AEAD [#aead] The AEAD registry governs which **content format** protects a sealed payload on the wire — the `enc.aead` field. Exactly one identifier is registered under `enc.scheme: 1`, and it is a segmented format rather than a single-shot cipher. | Identifier | Algorithm | Key / Nonce / Per-chunk nonce / Tag | Status | | ----------------------------- | ------------------------------------------ | ----------------------------------- | --------------------------- | | `chacha20-poly1305-stream64k` | ChaCha20-Poly1305, 64 KiB segmented STREAM | 32 B / 24 B / 12 B / 16 B per chunk | Mandatory — the wire format | | `aes-256-gcm` | AES-256-GCM | — | Reserved (future profile) | `chacha20-poly1305-stream64k` is ChaCha20-Poly1305 ([RFC 8439](https://www.rfc-editor.org/rfc/rfc8439)) in the 64 KiB segmented STREAM layout of the [age v1 specification](https://github.com/C2SP/C2SP/blob/main/age.md): the plaintext is split into 65536-byte chunks, and each chunk is sealed under the content key with a 12-byte per-chunk nonce `uint88_be(counter) ‖ final_flag` (counter from 0, `final_flag` `0x01` on the last chunk) and an empty per-chunk AAD, producing a 16-byte tag per chunk. The 24-byte `enc.nonce` is **not** a chunk nonce: it is the envelope-unique salt of the content-key HKDF, which is what keeps the counter nonces safe — the content key is single-use, so no two streams ever share a `(key, nonce)` pair and stateless producers never coordinate nonces across envelopes. The segmented layout lets a verifier authenticate and release a large payload incrementally with bounded memory, and the final flag makes truncation detectable. The on-wire spelling is exactly `chacha20-poly1305-stream64k`; alternative spellings MUST NOT be produced. The full construction is on [Sealed PoE](/spec/sealed-poe). `chacha20-poly1305-stream64k` is the only identifier that may appear as the on-wire content format. A separate construction, `chacha20-poly1305` ([RFC 8439](https://www.rfc-editor.org/rfc/rfc8439), 32-byte key / 12-byte nonce / 16-byte tag), is used **internally** to wrap a per-recipient key inside the sealed construction: a 12-byte all-zero nonce, AAD set to the chosen KEM's `info` label, producing a 48-byte `wrap` (32-byte wrapped key + 16-byte tag). It is a building block, not a wire identifier, and a record that names it in `enc.aead` MUST be rejected. `aes-256-gcm` is named but inactive; it is reserved for a future encryption profile (`enc.scheme: 2`) and a v1 verifier rejects any record that selects it. ## KEM [#kem] The KEM registry covers the key-encapsulation mechanisms used to address a sealed payload to specific recipients. Label 309 registers a classical curve KEM and a post-quantum **hybrid** that ships active from the first release. | Identifier | Algorithm | Public key / Secret | Ciphertext / Shared secret | | ---------------- | ---------------------------------------------------------------- | ------------------- | -------------------------- | | `x25519` | X25519 ECDH ([RFC 7748](https://www.rfc-editor.org/rfc/rfc7748)) | 32 B / 32 B | 32 B / 32 B | | `mlkem768x25519` | X-Wing hybrid (ML-KEM-768 + X25519) | 1216 B / 32 B | 1120 B / 32 B | `mlkem768x25519` is the X-Wing construction from [draft-connolly-cfrg-xwing-kem-10](https://datatracker.ietf.org/doc/draft-connolly-cfrg-xwing-kem/10/): it pairs ML-KEM-768 ([FIPS 203](https://csrc.nist.gov/pubs/fips/203/final)) with X25519 ([RFC 7748](https://www.rfc-editor.org/rfc/rfc7748)) so that an attacker must break **both** to recover the shared secret. The public key is the ML-KEM-768 encapsulation key concatenated with the X25519 public key (1184 B ‖ 32 B = 1216 B); the secret is a 32-byte seed from which the full key is derived. Each recipient's ciphertext is the ML-KEM-768 ciphertext concatenated with an ephemeral X25519 public key (1088 B ‖ 32 B = 1120 B), and the two shared secrets are combined by the X-Wing **SHA3-256** combiner ([FIPS 202](https://csrc.nist.gov/pubs/fips/202/final)) into the final 32-byte secret. Label 309 consumes X-Wing as a **black-box** KEM — encapsulate, decapsulate, the 32-byte shared secret — and relies on no property of the combiner's internal hashing. The identifier is written without internal hyphens to match the established X-Wing spelling. The hybrid is selected per-record by the encryption header, independently of the content AEAD. Because it is already registered, post-quantum confidentiality is a matter of *choosing* the identifier — not waiting for a new wire-format version. A single record names exactly one `enc.kem`, and every slot uses that KEM's shape; a slot of the wrong shape is `ENC_SLOT_INVALID_SHAPE`, a wrong-length `epk` (≠ 32 B) or `kem_ct` (≠ 1120 B) is `KEM_EPK_LENGTH_MISMATCH` / `KEM_CT_LENGTH_MISMATCH`, and an unregistered `enc.kem` is `UNSUPPORTED_KEM_ALG`. The encapsulation material must also be **distinct within one `slots[]`**: all `epk` values (for `x25519`) or all `kem_ct` values (for `mlkem768x25519`) MUST differ. A within-record duplicate is rejected with `ENC_SLOTS_DUPLICATE_KEM_MATERIAL` before any KEM or AEAD primitive runs, because a repeated `epk` or `kem_ct` breaks the per-slot key uniqueness the zero-nonce wrap depends on. A verifier also bounds parser resource use before any primitive: an envelope whose `slots[]` exceeds the reference bound of 1024 slots is `ENC_SLOTS_TOO_MANY`, and a decoded `enc` envelope exceeding 65 536 bytes is `ENC_ENVELOPE_TOO_LARGE`. Both bounds sit far above the \~16 KiB Cardano metadata ceiling that caps any honest record; they are verifier-enforced, deployment-pinned constants — not wire fields — and deployments MAY tighten them. ## KDF [#kdf] The KDF registry names the key-derivation functions. `hkdf-sha256` derives keys inside the sealed construction; `argon2id` stretches a human passphrase against brute force and carries a mandatory parameter floor. | Identifier | Algorithm | Parameters | | ------------- | ----------------------------------------------------------------- | --------------------------------------------------- | | `hkdf-sha256` | HKDF-SHA-256 ([RFC 5869](https://www.rfc-editor.org/rfc/rfc5869)) | `salt` (optional), `info` (optional), output length | | `argon2id` | Argon2id ([RFC 9106](https://www.rfc-editor.org/rfc/rfc9106)) | memory ≥ 65536 KiB, iterations ≥ 3, parallelism ≥ 1 | The Argon2id floor is normative: a passphrase-protected payload MUST use at least 64 MiB of memory, at least three iterations, and at least one lane. A producer MAY choose stronger parameters, and they travel with the record so a verifier can reproduce the derivation. Where the platform supports it, producers SHOULD set the parallelism `p = 4` — the second recommended profile of [RFC 9106 §4](https://www.rfc-editor.org/rfc/rfc9106#section-4) — while a verifier MAY accept any `p ≥ 1`, subject to deployment ceilings. Those ceilings are an implementation SHOULD, not a MAY: a verifier SHOULD enforce upper bounds against a verifier-side denial-of-service from absurd parameters, reporting `ENC_PASSPHRASE_PARAMS_EXCEED_POLICY`. The ceiling is hardware-dependent and non-normative, and MUST NOT be conflated with the floor code `ENC_PASSPHRASE_ARGON2_PARAMS_TOO_LOW`. Only `argon2id` is **wire-selectable**: it is the sole identifier a record may name in `enc.passphrase.alg`. `hkdf-sha256` is an **internal building block** — it is the fixed extract-and-expand step behind the seed-to-key derivation, the per-slot KEK derivation, the slot-set MAC key, the passphrase-commitment MAC key, and the content-key derivation — and it carries no wire identifier. A record that names `hkdf-sha256` in `enc.passphrase.alg` MUST be rejected: HKDF is built for high-entropy inputs, not for stretching a low-entropy passphrase. ### Internal labels are constants, never on the wire [#internal-labels-are-constants-never-on-the-wire] The sealed construction draws its domain separation from a fixed set of label literals — HKDF `info` tags and SHA-256 prefixes (KEK-salt prefixes, transcript prefixes, and the item-hashes prefix). Each is a **constant of `enc.scheme: 1`**, exact ASCII with no terminator or length prefix; none is ever serialised, and none is selectable through any registry. There are eleven: | Label | Role | | -------------------------------------- | ------------------------------------------------------------- | | `cardano-poe-kek-v1` | HKDF `info` for the per-slot KEK on the `x25519` path | | `cardano-poe-kek-mlkem768x25519-v1` | HKDF `info` for the per-slot KEK on the `mlkem768x25519` path | | `cardano-poe-x25519-kek-salt-v1` | SHA-256 prefix for the `x25519` KEK HKDF `salt` | | `cardano-poe-xwing-kek-salt-v1` | SHA-256 prefix for the `mlkem768x25519` KEK HKDF `salt` | | `cardano-poe-item-hashes-v1` | SHA-256 prefix for the item-hashes digest `hashes_hash` | | `cardano-poe-slots-transcript-v1` | SHA-256 prefix for the slots-transcript hash `slots_hash` | | `cardano-poe-slots-mac-v1` | HKDF `info` for the slot-set MAC key | | `cardano-poe-passphrase-transcript-v1` | SHA-256 prefix for the passphrase-transcript hash `pw_hash` | | `cardano-poe-passphrase-mac-v1` | HKDF `info` for the passphrase commitment MAC key | | `cardano-poe-payload-v1` | HKDF `info` for the slots-path content key | | `cardano-poe-payload-passphrase-v1` | HKDF `info` for the passphrase-path content key | Both KEK salts share one labelled-hash shape — `SHA-256(label ‖ enc.nonce ‖ ‖ pub_R)` — under their own per-KEM label. These labels are distinct from the seed-derivation `info` strings on [Keys](/spec/keys) and from the record-signing domain prefix on [Signatures](/spec/signatures): the set is collision-free and prefix-free, so no per-record sealed label equals — or is a byte-prefix of — a long-term-key-derivation label, and identity-key derivation and per-record key wrapping never collide. A verifier MUST use each literal byte-for-byte; a single divergent byte yields a `slots_mac`, a commitment, or an AEAD tag the honest producer cannot reproduce. The byte-level construction that consumes each label is on [Sealed PoE](/spec/sealed-poe). ## Signature [#signature] Label 309 registers one signature algorithm. Authorship signatures are always optional, but when present they are carried as `COSE_Sign1` ([RFC 9052](https://www.rfc-editor.org/rfc/rfc9052)) using Ed25519. | Identifier | COSE alg | Algorithm | Wrapper | | ---------- | -------- | ------------------------------------------------------------ | ----------------------- | | `EdDSA` | `-8` | Ed25519 ([RFC 8032](https://www.rfc-editor.org/rfc/rfc8032)) | `COSE_Sign1` (RFC 9052) | Verification is **strict** per RFC 8032 §5.1.7: implementations MUST reject non-canonical signature encodings and small-order points (no cofactor-clearing extension). This matches the conservative acceptance criteria used across Cardano wallets, so a signature that verifies under one conformant implementation verifies under all of them. Signature support is independent of the content claim. A verifier that does not implement a record's signature algorithm marks that signature slot as *unsupported* and leaves the timestamp and content claims fully valid — an unknown signature algorithm never invalidates the record itself. ## Reserved identifiers [#reserved-identifiers] Several identifiers are **named but not yet active**. They mark the agreed migration path so that future profiles use stable, pre-committed names rather than ad-hoc strings. A conformant producer MUST NOT emit them, and a conformant verifier MUST reject any record that uses one, with the matching typed error. | Identifier | Algorithm | Role | | ------------------- | ------------------------------------------------------------------------------ | ------------ | | `aes-256-gcm` | AES-256-GCM ([NIST SP 800-38D](https://csrc.nist.gov/pubs/sp/800/38/d/final)) | Content AEAD | | `ml-kem-768` | ML-KEM-768 ([FIPS 203](https://csrc.nist.gov/pubs/fips/203/final)), standalone | KEM | | `ml-dsa-65` | ML-DSA ([FIPS 204](https://csrc.nist.gov/pubs/fips/204/final)) | Signature | | `slh-dsa-sha2-128s` | SLH-DSA ([FIPS 205](https://csrc.nist.gov/pubs/fips/205/final)) | Signature | `ml-kem-768` is the *bare* post-quantum KEM, distinct from the registered hybrid `mlkem768x25519`; the hybrid is what Label 309 ships, on the principle that a classical fallback should remain even after the post-quantum half is added. ## Algorithm agility and migration [#algorithm-agility-and-migration] Adding an algorithm is a self-contained, additive operation: cite a public standard for the primitive, add the identifier to the appropriate registry, provide a vetted, well-reviewed implementation of it, and publish a cross-language conformance fixture so independent implementations agree byte-for-byte. The wire-format version does **not** change, because the schema does not change — only the set of recognized strings grows. The consequences follow directly from the registry design: * **Old records stay verifiable.** Their identifiers are still in the registry, so every existing record verifies exactly as it did the day it was published. * **Old verifiers fail closed.** A verifier that predates a new identifier rejects records using it with a stable `UNSUPPORTED_*` error rather than guessing — there is no path to silent acceptance. * **Post-quantum support is additive.** Because `mlkem768x25519` is already registered, and because new KEMs and signatures slot into the same mechanism, the post-quantum transition is a registry growth, not a breaking migration. A wire-format version bump is reserved for genuinely breaking schema changes — a new required field, a removed field, a changed type. Registry growth never qualifies, which is precisely what lets the cryptographic catalogue evolve without ever stranding a published proof. # Content & hashing (/spec/content-and-hashing) The content hash is the claim. Everything a Label 309 record asserts about existence flows from a cryptographic digest of the content bytes, anchored on chain under metadata label 309. This page defines how that digest is carried, exactly what it commits to, and how a single 32-byte root can stand in for an arbitrarily large batch of items. ## The `hashes` map [#the-hashes-map] Each item in a record carries a `hashes` map: a CBOR map from an algorithm identifier to a raw 32-byte digest. ```text title="CBOR" hashes = { "sha2-256": h'…32 bytes…', ; key = algorithm id, value = raw digest } ``` Keys are text-string identifiers drawn from the hash registry; values are raw byte strings, never hex-encoded. The map **must** contain at least one entry, and every registered hash algorithm produces exactly 32 bytes: | Identifier | Algorithm | Reference | Digest | | ------------- | ----------- | -------------------------------------------------------------- | ------ | | `sha2-256` | SHA-256 | [FIPS 180-4](https://csrc.nist.gov/pubs/fips/180-4/upd1/final) | 32 B | | `blake2b-256` | BLAKE2b-256 | [RFC 7693](https://www.rfc-editor.org/rfc/rfc7693) | 32 B | Both identifiers are mandatory for verifiers to implement, so a single-hash record under either one validates everywhere. A verifier that encounters an unrecognized identifier rejects the record with a stable error code rather than silently skipping the entry. The full registry, including reserved post-quantum slots, lives on [Algorithm registries](/spec/algorithm-registries). Using a CBOR map — rather than parallel arrays or a list of `{alg, digest}` sub-objects — has three consequences that are part of the wire contract. Duplicate algorithms are impossible by construction, because CBOR map keys are unique. Canonical ordering is automatic, because canonical CBOR sorts keys by their encoded bytes, so two producers expressing the same hash set emit byte-identical maps and any record-level signature over them is stable. And the shape needs no per-entry validation: a structural validator only checks that each key is registered and each value has the algorithm's digest length. ## What the hash commits to [#what-the-hash-commits-to] The digest commits to the **content bytes** — the exact byte sequence the producer is timestamping. Every entry in a `hashes` map must be the digest of that same byte sequence under its named algorithm; a record whose entries describe different plaintexts is non-conformant. When the content bytes are available to a verifier, it **must** recompute every digest and reject the record if any one fails to match. When a record carries an encryption envelope (`enc`), the hash binds the **plaintext**, never the ciphertext. This is deliberate: a Proof of Existence exists so an author can later reveal plaintext and prove it existed at a given time. Hashing the ciphertext would prove only that some encrypted blob existed, which says nothing about the underlying content. So a sealed record still proves exactly which plaintext was timestamped — the recipient decrypts, recomputes the plaintext digests, and matches them against the on-chain commitment. An `enc`-bearing item therefore must carry at least one content-hash entry; without one there would be no plaintext claim to recompute against. The on-chain digest of a sealed record is the digest of the cleartext. The ciphertext itself lives at a content-addressed `ar://` or `ipfs://` URI, so the bytes a storage gateway returns are tamper-evident against the address without trusting the gateway; a recipient decrypts and recomputes the plaintext hash to close the loop back to the on-chain claim. ## One hash, or several [#one-hash-or-several] A single content hash is fully conformant. For all 256-bit hashes in the registry, the best known second-preimage attacks sit at or near `2^256` classically — a single sound 256-bit hash already covers the realistic threat model over a record's archival lifetime, and structural validators emit no warning for single-entry records. A producer **may** add a second entry from an independent design family as optional defense-in-depth — pairing `sha2-256` (SHA-2: Merkle–Damgård) with `blake2b-256` (BLAKE2: a HAIFA construction over a ChaCha-derived permutation). Because the two families share no structural lineage, a record carrying both is weakened only if both families fall to cryptanalysis at once. The cost is one extra 32-byte digest plus its short identifier per item; the choice is the producer's, and it is never required. ## Merkle batch commitments [#merkle-batch-commitments] A single content hash anchors a single content. To anchor an arbitrarily large collection — a 500-file CI artifact set, a stream of IoT events, an audit-log batch — Label 309 defines a top-level `merkle[]` array. Each entry commits to an ordered list of 32-byte leaves with one 32-byte root published on chain; the ordered leaves themselves live off chain. ```text title="CBOR" merkle = [ { "alg": "rfc9162-sha256", "root": h'…32 bytes…', ; canonical root over the ordered leaves "leaf_count": 4, ; binds the on-chain root to the leaf-list size "uris": [ … ], ; OPTIONAL — where the off-chain leaves list lives }, ] ``` The registered commitment algorithm is `rfc9162-sha256`: the Merkle Tree Hash of [RFC 9162](https://www.rfc-editor.org/rfc/rfc9162) §2.1.1, with SHA-256 as the underlying hash. It is a list-commitment construction, distinct from the content-hash registry — a Merkle root commits to a leaf-list structure, a `sha2-256` digest commits to plaintext bytes — and so it sits in its own array rather than inside `hashes`. The on-chain `leaf_count` binds the root to the size of the off-chain list, foreclosing a substitution that rebuilds a different-sized tree sharing the root for some leaf position. ### Tree construction [#tree-construction] The construction distinguishes leaves from internal nodes with a one-byte domain-separation prefix — `0x00` for leaves, `0x01` for internal nodes — so an attacker cannot craft an internal node that collides with a leaf. For an ordered list `L = (d_0, …, d_{n-1})` of 32-byte values with `n ≥ 1`, the Merkle Tree Hash is defined recursively: ``` MTH(L) = SHA-256(0x00 || d_0) when n == 1 MTH(L) = SHA-256(0x01 || MTH(L[0:k]) || MTH(L[k:n])) when n > 1 where k is the largest power of 2 strictly less than n ``` A crucial consequence: a single leaf hashes as `SHA-256(0x00 || d_0)`, **not** as the bare leaf. The root of a one-leaf tree is therefore never equal to the leaf itself. Producers who want to timestamp a single content **must** use a plain `sha2-256` or `blake2b-256` entry directly, not a one-leaf Merkle tree. An empty tree (`n == 0`) is forbidden. The construction is order-sensitive — permuting the leaves yields a different root — so producers must treat the leaf list as an ordered sequence and preserve that order across publication, archival, and any later proof generation. ### The off-chain leaves list [#the-off-chain-leaves-list] The root is useless without the leaf list, so producers persist the ordered leaves off chain. The canonical artifact is a `cardano-poe-merkle-leaves-v1` document, encoded as canonical CBOR ([RFC 8949](https://www.rfc-editor.org/rfc/rfc8949)): a 32-byte root, the ordered array of 32-byte leaves, and the leaf count. ```text title="CDDL" leaves-list = { "format": "cardano-poe-merkle-leaves-v1", "tree_alg": tstr, ; registered list-commitment algorithm id "root": bytes .size 32, ; raw 32 bytes, not hex "leaves": [ + bytes .size 32 ], ; ordered raw 32-byte leaves "leaf_count": 1..4294967295, ; 1 .. 2^32-1; MUST equal the length of `leaves` ? "leaf_alg": tstr, ; informative; no verification semantics } ``` A verifier resolves the off-chain list, recomputes the root from its `leaves` using the construction above, and matches it against the on-chain `merkle[i].root` byte-for-byte; the `leaf_count` in the file must equal both the on-chain `leaf_count` and `len(leaves)`. This canonical-CBOR container is the **only** normative leaves-list form — there is no JSON projection or alternative serialisation, so two implementations exchanging a leaves-list always exchange byte-comparable documents. ### Inclusion proofs [#inclusion-proofs] The point of batching is selective disclosure: prove that one item was in the committed list without republishing — or even revealing — the rest. An inclusion proof for a leaf is the ordered list of sibling node hashes along the path from that leaf to the root: an `O(log n)` sibling path. A verifier folds the leaf and the siblings back up the tree per RFC 9162 and accepts the proof if, and only if, the reconstructed root equals the published root byte-for-byte. Because RFC 9162 trees are not padded to a power of two, a right-edge leaf in an unbalanced tree can have a shorter path than a leaf on the full side. The authoritative check is therefore algorithmic — does the fold reproduce the root — never a comparison of proof length. One transaction and one 32-byte root can stand in for thousands or millions of leaves. Anyone holding an `O(log n)` proof can later show "this item was in my list," while every undisclosed leaf stays private — the root reveals nothing about the leaves it commits to. # Implementers' guide (/spec/implementers-guide) Label 309 is a wire format and a set of cryptographic constructions, not a product. Any number of independent implementations — in TypeScript, Python, Rust, Go, or a native mobile runtime — can coexist, and a record produced by one **MUST** verify under another. This page is for the team building such an implementation. It describes the architecture that keeps the cryptographic surface auditable, the exact contract that makes two implementations interoperable, and the conformance suite that decides — mechanically — whether you have met it. Two things make Label 309 interoperable across languages. The first is **determinism**: the constructions are pinned to public standards ([RFC 8949](https://www.rfc-editor.org/rfc/rfc8949) canonical CBOR, [RFC 8032](https://www.rfc-editor.org/rfc/rfc8032) Ed25519, [RFC 7748](https://www.rfc-editor.org/rfc/rfc7748) X25519, [RFC 5869](https://www.rfc-editor.org/rfc/rfc5869) HKDF, [RFC 9106](https://www.rfc-editor.org/rfc/rfc9106) Argon2id, [RFC 9052](https://www.rfc-editor.org/rfc/rfc9052) COSE), so the same inputs yield the same bytes everywhere. The second is the **conformance suite**: a set of byte-exact test vectors that an implementation either reproduces or does not. Conformance is a property you can check, not a claim you make. ## The layered architecture [#the-layered-architecture] A conformant implementation **SHOULD** separate cryptographic primitives from application logic into distinct layers, each depending only on the one beneath it. The names below are roles, not package names; choose your own. The boundaries are load-bearing, not cosmetic. Each layer has a single job and a short list of things it is forbidden to know about. ### The cryptographic core [#the-cryptographic-core] The bottom layer holds primitives only: hash functions, KDFs, signature and KEM operations, the AEAD content layer, canonical CBOR, COSE\_Sign1, the sealed-PoE wrap/unwrap construction, Merkle roots and proofs, and the typed error classes they raise. It contains **no** domain logic, **no** HTTP, **no** database access, and **no** UI- or server-framework imports. This layer **MUST** stay dependency-free of anything application- or server-bound, and **MUST** be browser-safe, for three concrete reasons: * **It runs everywhere.** Hashing a file, building an envelope, and — critically — the standalone verifier all run in browsers, in serverless workers, and on the command line as readily as on a server. A server-only dependency (a database driver, a logging framework bound to a runtime, a UI library) would break those targets and bloat every consumer that bundles the core. * **It is the audit surface.** A reviewer can read a primitives-only package end-to-end against the RFCs. The moment application code leaks in, the surface a security reviewer must hold in their head grows without bound. * **It is what third parties embed.** An independent verifier — someone who trusts no service, only the chain — pulls in this layer and nothing above it. Keeping it small and portable is what makes "verify it yourself" practical. Concretely, the core **MUST NOT** import ORM or database drivers, UI frameworks, server-bound logging frameworks, or any application module. Randomness **MUST** come from the platform CSPRNG (Web Crypto `getRandomValues`, or an equivalent re-export), never from a Node-only source, so the same source runs unchanged in a browser. The zero-dependency rule decays the moment a convenient import slips in. An implementation **SHOULD** run a dependency-graph lint that walks every import in the core and the wire-format library and fails the build on any specifier outside a per-layer allow-list. Reviewers forget; the linter does not. ### The wire-format library [#the-wire-format-library] The next layer up owns Label 309 itself: the record schema, the structural validator, and the canonical-CBOR encoder and decoder. It depends on the cryptographic core (for hashing, COSE, and the CBOR codec) and on nothing else application-bound. Its surface is small and pure: * **encode** — produce canonical-CBOR bytes for a validated record. * **decode** — the inverse. * **validate** — run the structural and semantic checks of the standard over a decoded record and return a typed result (see [Verification](/spec/verification)). This layer is where the rules of [The record](/spec/the-record) live as code: the closed key set, the chunk-reassembly discipline, the `items`-or-`merkle` invariant, the canonical-CBOR requirements. Like the core, it stays free of HTTP clients, database drivers, and framework imports. ### The SDK and the application [#the-sdk-and-the-application] The SDK wraps the lower layers into ergonomic helpers — a service client, envelope build/unlock helpers, and the **standalone verifier**, the function that decodes a record, checks its structure, verifies any record signatures against the on-chain key, and produces a verdict using only public data. The standalone verifier **MUST** work with no network access to any implementer-operated service; its only external input is a public blockchain explorer the verifier chooses. The SDK **SHOULD** also remain browser-safe. The application layer — UI, routing, persistence, billing, background jobs — is greenfield and carries no interoperability obligations. Nothing in the standard constrains how you build it, only that it sits above the verified crypto surface rather than reaching into it. ## The byte-identical contract [#the-byte-identical-contract] Interoperability is a property of bytes, not of intentions. Two implementations interoperate if and only if the primitives that have no freedom in their output produce the **same bytes** from the same inputs. This is the parity contract, and it is the heart of conformance. The contract splits cleanly in two. Operations whose output is fully determined by their inputs **MUST** be byte-identical across implementations. Operations that consume randomness cannot be byte-equal call-to-call; for those the contract is **cross-consumability** — a value produced by one implementation **MUST** be consumable by any other (a ciphertext sealed in one language decrypts in another). ### Byte-identical primitives [#byte-identical-primitives] Every operation below is a pure function of its inputs and **MUST** emit byte-identical output in every conformant implementation: | Primitive | Pinned to | Output that must match | | ------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------- | | Seed → Ed25519 / X25519 keypair | HKDF-SHA-256 with the registered info constants | derived public and private keys | | HKDF-SHA-256 | [RFC 5869](https://www.rfc-editor.org/rfc/rfc5869) | output key material for fixed input | | HMAC-SHA-256 slot-set MAC | [RFC 2104](https://www.rfc-editor.org/rfc/rfc2104) | `slots_hash` and `slots_mac` tag bytes for a fixed CEK and slot set | | Argon2id (passphrase KDF) | [RFC 9106](https://www.rfc-editor.org/rfc/rfc9106) | derived key for fixed `(m, t, p, salt, len, password)` | | SHA-256 | [FIPS 180-4](https://csrc.nist.gov/pubs/fips/180-4/upd1/final) | digest | | BLAKE2b-256 | [RFC 7693](https://www.rfc-editor.org/rfc/rfc7693) | digest | | Canonical CBOR encode | [RFC 8949 §4.2.1](https://www.rfc-editor.org/rfc/rfc8949#section-4.2.1) | encoded bytes for fixed input | | COSE\_Sign1 encode | [RFC 9052](https://www.rfc-editor.org/rfc/rfc9052) | structure bytes for fixed header, payload, signature | | Ed25519 sign / verify | [RFC 8032](https://www.rfc-editor.org/rfc/rfc8032) (strict) | signature; verdict | | X25519 ECDH | [RFC 7748](https://www.rfc-editor.org/rfc/rfc7748) | shared secret for fixed scalars | | Sealed-PoE wrap / unwrap | [Sealed PoE](/spec/sealed-poe) | per-slot bytes and MAC when ephemerals and CEK are injected | | Merkle root + inclusion proofs | [RFC 9162 §2.1.1](https://www.rfc-editor.org/rfc/rfc9162#section-2.1.1) | root and per-leaf proofs over an ordered leaf list | Two points deserve emphasis. **Ed25519 is strict**: a conformant verifier **MUST** apply the canonical-`S` and rejected-low-order-point rules of [RFC 8032 §5.1.7](https://www.rfc-editor.org/rfc/rfc8032#section-5.1.7), so two implementations agree not only on signatures they accept but on signatures they reject. **Argon2id crosses ecosystem boundaries**: different languages reach for different Argon2 libraries, but every conformant library implements RFC 9106 and **MUST** produce identical output for identical parameters — the parameter set, not the library, is the contract. ### Randomness-consuming operations [#randomness-consuming-operations] Key generation, sealed-PoE wrapping under fresh per-slot ephemerals, and envelope encryption all draw fresh randomness, so their output differs every call and cannot be byte-pinned. The contract for these is **cross-consumability**: output produced by one implementation **MUST** be consumable by every other. A record sealed in one language **MUST** decrypt in another; a keypair minted in one **MUST** verify and encrypt-to in another. Conformance suites pin these with deterministic test hooks that inject the ephemerals — making the wrap reproducible — and with round-trip fixtures that encrypt in one language and decrypt in the other. ## Building the sealed-PoE construction [#building-the-sealed-poe-construction] Sealed PoE is the densest part of the wire format, and the part where a single wrong byte — a mis-ordered map key, a label off by one character, a non-canonical chunking — produces an envelope that opens in your own implementation but in no other. This section is the build checklist: the exact recipes, the additional authenticated data each AEAD covers, the trial-decrypt loop, and the guards every producer and verifier must enforce. The construction reference on [Sealed PoE](/spec/sealed-poe) is the prose; this is how you wire it up so the parity gate goes green. Pin these external drafts exactly, since their internals fix bytes you must reproduce: * **`chacha20-poly1305-stream64k`** — the content format — is ChaCha20-Poly1305 ([RFC 8439](https://www.rfc-editor.org/rfc/rfc8439)) in the 64 KiB segmented STREAM layout of the [age v1 specification](https://github.com/C2SP/C2SP/blob/main/age.md). Pin the chunk size (65536), the 12-byte per-chunk nonce `uint88_be(counter) ‖ final_flag`, the empty per-chunk AAD, and the final-flag rule exactly — they fix bytes you must reproduce. * **X-Wing** (the `mlkem768x25519` KEM) is [draft-connolly-cfrg-xwing-kem-10](https://datatracker.ietf.org/doc/draft-connolly-cfrg-xwing-kem/10/). Treat it as a **black-box KEM**: the construction binds the recipient public key and the ciphertext into the key-derivation step itself, so it does not rely on any property of the combiner's internal hashing. `XWing.Encapsulate` **MUST** apply the pinned revision's public-key validity check and refuse to encapsulate to a key that fails it; the "never below X25519 classical security" floor is scoped to validly generated keys, and skipping the check forfeits the floor for that recipient. The conformance KEM vectors pin encapsulation against draft-10, so a draft-revision mismatch surfaces immediately. ### One CEK, two key-delivery paths [#one-cek-two-key-delivery-paths] A sealed record encrypts the plaintext **once** under a single content-encryption key (CEK), then delivers that CEK by one of two mutually-exclusive paths, discriminated by field presence — there is no mode tag: * **slots path** — the CEK is wrapped independently to each recipient under a per-slot key-encryption key. `enc` carries `slots` (and `kem`, `slots_mac`). * **passphrase path** — the CEK is derived directly from a normalized passphrase via Argon2id. `enc` carries `passphrase`; it carries no `kem`, `slots`, or `slots_mac`. Both paths share `enc.scheme` (always `1`; reject anything else), `enc.aead` (`chacha20-poly1305-stream64k`), and `enc.nonce` (24 bytes). They differ in where the key commitment lives: on chain in `slots_mac` for the slots path, in a 32-byte header inside the ciphertext blob for the passphrase path. Both bind the item's hash claim into their transcript, and both seal the content in the same segmented STREAM; the difference is the key-delivery and the commitment, not the content layer. ### Per-slot wrap (slots path) [#per-slot-wrap-slots-path] Pick **one** KEM for the whole record — never mix KEMs within a single `slots[]`. For each of N recipients, derive a fresh per-slot key-encryption key and wrap the **same** CEK under it with **ChaCha20-Poly1305 at a 12-byte zero nonce**, AAD set to that KEM's info label literal (never empty AAD), producing exactly 48 bytes (32-byte CEK ciphertext + 16-byte tag). The zero nonce is safe only because the key-encryption key is per-slot; see the uniqueness guard below. **`x25519` (classical).** Fresh ephemeral X25519 keypair per slot: ```text priv_epk : randomBytes(32) ; fresh per slot pub_epk : x25519_publicKey(priv_epk) shared : x25519_sharedSecret(priv_epk, pub_R) ; reject all-zero result kek_salt : SHA-256("cardano-poe-x25519-kek-salt-v1" || enc.nonce || pub_epk || pub_R) ; 32 B KEK : HKDF-SHA-256(ikm = shared, salt = kek_salt, info = "cardano-poe-kek-v1", L = 32) wrap : ChaCha20-Poly1305(key = KEK, nonce = zeros(12), ad = "cardano-poe-kek-v1", plaintext = CEK) ; 48 B slot : { "epk": pub_epk, "wrap": wrap } ``` **`mlkem768x25519` (hybrid; X-Wing).** Fresh X-Wing encapsulation per slot: ```text enc = XWing.Encapsulate(pub_R) ; named fields — MUST NOT consume positional order kem_ct = enc.ct ; 1120 B shared = enc.ss ; 32 B kek_salt : SHA-256("cardano-poe-xwing-kek-salt-v1" || enc.nonce || kem_ct || pub_R) ; 32 B KEK : HKDF-SHA-256(ikm = shared, salt = kek_salt, info = "cardano-poe-kek-mlkem768x25519-v1", L = 32) wrap : ChaCha20-Poly1305(key = KEK, nonce = zeros(12), ad = "cardano-poe-kek-mlkem768x25519-v1", plaintext = CEK) slot : { "kem_ct": kem_ct, "wrap": wrap } ; kem_ct = single 1120-byte byte string ``` Both salts have one shape — `SHA-256(label || enc.nonce || || pub_R)` — carrying the 32-byte ephemeral `pub_epk` on the classical path and the 1120-byte X-Wing ciphertext `kem_ct` on the hybrid path; `||` is byte concatenation, and each salt-prefix literal is exact ASCII with no terminator or length prefix. `pub_R` is the recipient's canonical wire key (32 B for `x25519`, the pinned 1216 B for `mlkem768x25519`). The hybrid slot carries **no** separate `epk` — the X25519 ephemeral is the trailing 32 bytes of `kem_ct` — and `kem_ct` is a **single CBOR byte string of exactly 1120 bytes**: only the whole record body is chunked for transport, never an individual field. The salt binds three values: the slot's KEM material (KEK slot-unique), `pub_R` (defeating confused-deputy relay against a different recipient), and `enc.nonce` (anchoring the KEK to one envelope, so repeated KEM randomness degrades only to cross-envelope linkability). The distinct info labels give cross-KEM domain separation so no KEK derived under one KEM can equal one derived under the other on an identical shared secret. Use each of the eleven internal labels byte-for-byte — `cardano-poe-kek-v1`, `cardano-poe-kek-mlkem768x25519-v1`, `cardano-poe-x25519-kek-salt-v1`, `cardano-poe-xwing-kek-salt-v1`, `cardano-poe-item-hashes-v1`, `cardano-poe-slots-transcript-v1`, `cardano-poe-slots-mac-v1`, `cardano-poe-passphrase-transcript-v1`, `cardano-poe-passphrase-mac-v1`, `cardano-poe-payload-v1`, `cardano-poe-payload-passphrase-v1`. None is ever serialized on the wire; they are fixed constants, not registry-selectable. A single divergent byte yields a `slots_mac`, a commitment, or an AEAD tag the honest producer cannot reproduce. **Shuffle before you MAC.** Input order ("primary recipient first") is privileged metadata; publishing slots in input order leaks it. Shuffle `slots[]` with a CSPRNG using an **unbiased** Fisher-Yates permutation — a plain `u32 % m` index draw skews toward low residues and must be rejection-sampled to a uniform index — **before** computing the slot-set MAC, which binds the shuffled on-wire order. ### Slot-set MAC: hash the transcript, then HMAC under the CEK [#slot-set-mac-hash-the-transcript-then-hmac-under-the-cek] The slot-set MAC binds the whole slot set, plus the header fields that fix how the slots are read, to the CEK. Build it in two steps — hash a closed transcript once, then HMAC that hash: ```text hashes_hash : SHA-256("cardano-poe-item-hashes-v1" || canonicalEncode(item.hashes)) ; 32 B SLOTS_TRANSCRIPT = { ; closed 7-key map; keys are a set, not an order "scheme": 1, "path": "slots", "aead": , ; the content-format identifier "kem": , ; "x25519" | "mlkem768x25519" "nonce": , ; bytes(24) "slots": , ; the shuffled on-wire slot array "hashes_hash": hashes_hash ; bytes(32), over this item's hashes } slots_hash : SHA-256("cardano-poe-slots-transcript-v1" || canonicalEncode(SLOTS_TRANSCRIPT)) HMAC_KEY : HKDF-SHA-256(ikm = CEK, salt = "", info = "cardano-poe-slots-mac-v1", L = 32) slots_mac : HMAC-SHA-256(key = HMAC_KEY, msg = slots_hash) ; 32 B ``` Three things make or break parity here: * **The transcript is a closed map serialized by `canonicalEncode`.** Its key order is the RFC 8949 §4.2.1 sort, never hand-arranged. Pinning `scheme`, `path`, `aead`, `kem`, and `nonce` alongside the slots means a relay that flips any header field — even while leaving slot shapes valid — changes `slots_hash` and breaks the MAC. * **The transcript binds the item's hash claim.** `hashes_hash` 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 hash claim** — an envelope spliced onto an item with a different `hashes` map fails the on-chain match step, before any ciphertext fetch. The `slots` value is the shuffled array of on-wire slot maps directly: each slot field is a single byte string (`epk` 32 B, `kem_ct` 1120 B), so there is no per-field chunking to canonicalise. * **`slots_hash` is computed once and held constant** across the trial-decrypt loop. The per-slot MAC check re-keys HMAC from each candidate CEK but always over the same 32-byte `slots_hash`. Pre-hashing leaves the CEK-keyed commitment intact: it changes the HMAC message from the full transcript to its SHA-256, nothing more. The MAC algorithm, its key derivation, and the transcript schema are all fixed by `enc.scheme = 1` and identical for both KEMs; there is no on-wire MAC identifier. `slots_mac` is exactly 32 bytes and is verified in constant time. ### Content encryption: the segmented STREAM [#content-encryption-the-segmented-stream] Encrypt the plaintext once in the segmented STREAM under a **content key** derived from the CEK. The content key is a separate HKDF leaf of the CEK — salted by `enc.nonce`, under a path-specific `info` — so the wrap layer and the content layer never key the same primitive on the same bytes: ```text content_key : HKDF-SHA-256(ikm = CEK, salt = enc.nonce, info = "cardano-poe-payload-v1", L = 32) ; STREAM (chacha20-poly1305-stream64k): CHUNK_SIZE : 65536 plaintext bytes per non-final chunk chunk nonce : uint88_be(counter) || final_flag ; 12 B; counter from 0, +1 per chunk; ; final_flag = 0x01 on the last chunk, else 0x00 per-chunk AAD : empty ciphertext : seal(chunk_0) || seal(chunk_1) || ... || seal(chunk_final) ; each chunk sealed with ChaCha20-Poly1305 under content_key; sealed = plaintext + 16 B ``` The **per-chunk AAD is empty** — and that is correct, not an omission: the content key derives from the CEK, and the CEK is already committed to the full header (including `hashes_hash`) by `slots_mac`. Flip any header field and the recipient derives a different content key, so the stream fails to open; a per-chunk AAD would re-bind the same context on every chunk without adding security. The counter nonces are safe because the content key is single-use (a fresh CEK salted by the envelope-unique `enc.nonce`), so no two streams share a `(key, nonce)` pair. Build the STREAM so truncation is detectable: every non-final chunk is exactly 65536 plaintext bytes, the final chunk carries `final_flag = 0x01` and 0–65536 bytes (an empty plaintext is one zero-length final chunk — a lone 16-byte tag), and a verifier MUST fail (`TAMPERED_CIPHERTEXT`) on a missing final flag, a final flag on a non-final chunk, data after the final chunk, or a short non-final chunk. Verify each chunk's tag before releasing its plaintext, and treat released bytes as **tentative** until the post-decryption hash recheck passes. The plaintext is the exact original content bytes; the construction prepends, appends, and encrypts no filename, MIME type, size field, or metadata wrapper. The published ciphertext blob is the STREAM chunks (on the passphrase path, preceded by the 32-byte commitment header below). The assembled `enc` map and the resulting URI go on chain; the ciphertext bytes do not — publish them to a content-addressed store and put the `ar://` or `ipfs://` URI in the item's `uris[]`. ### Passphrase path [#passphrase-path] When there are no recipients, derive the CEK from a normalized passphrase with Argon2id. There is no `epk`, no per-slot wrap, no slot-set MAC, and no trial-decrypt loop. The key commitment that `slots_mac` provides on the slots path lives instead in a **32-byte header inside the ciphertext blob**, prepended before the STREAM chunks: ```text passphrase_bytes = utf8(normalize(passphrase)) ; cardano-poe-pw-norm-v1 CEK = argon2id(passphrase_bytes, salt = enc.passphrase.salt, params = enc.passphrase.params, L = 32) hashes_hash = SHA-256("cardano-poe-item-hashes-v1" || canonicalEncode(item.hashes)) PASSPHRASE_TRANSCRIPT = { ; closed 6-key map; keys are a set, not an order "scheme": 1, "path": "passphrase", "aead": , "nonce": , ; bytes(24) "hashes_hash": hashes_hash, ; bytes(32), over this item's hashes "passphrase": { ; closed sub-map "alg": "argon2id", "salt": enc.passphrase.salt, "params": { "m": m, "t": t, "p": p }, "normalization": "cardano-poe-pw-norm-v1" ; scheme-fixed constant, NOT on the wire } } pw_hash = SHA-256("cardano-poe-passphrase-transcript-v1" || canonicalEncode(PASSPHRASE_TRANSCRIPT)) PW_MAC_KEY = HKDF-SHA-256(ikm = CEK, salt = "", info = "cardano-poe-passphrase-mac-v1", L = 32) commitment = HMAC-SHA-256(key = PW_MAC_KEY, msg = pw_hash) ; 32 B content_key = HKDF-SHA-256(ikm = CEK, salt = enc.nonce, info = "cardano-poe-payload-passphrase-v1", L = 32) ciphertext blob = commitment || STREAM chunks ; STREAM under content_key ``` The `PASSPHRASE_TRANSCRIPT` binds the KDF parameters, the header fields, and the item's hash claim into the commitment: tampering with `salt`, any `params` value, `nonce`, `aead`, or splicing the envelope onto a different hash claim yields a different `pw_hash` and the commitment check fails. The `"normalization"` value is a **scheme-fixed constant** fed into the transcript to pin the exact profile the CEK was derived under; it is **never** serialized on the wire (the producer emits only `{ alg, salt, params }`). On the verify side, derive the candidate CEK, read the **leading 32 bytes** of the ciphertext blob, recompute the commitment, and compare **in constant time before opening any STREAM chunk**. A blob shorter than 48 bytes (32-byte commitment + 16-byte minimum STREAM) is malformed (`TAMPERED_CIPHERTEXT`). On mismatch — wrong passphrase, tampered `salt` / `params` / header, or a spliced envelope — surface the same single generic failure and do not begin streaming; a wrong passphrase is indistinguishable from a tampered record. The commitment is deliberately off-chain: an on-chain commitment would be a free offline guessing oracle for every passphrase record, including ones whose ciphertext is withheld. Enforce the parameter floors: `salt` length 16–64 bytes; `m ≥ 65536` KiB (≈ 64 MiB), `t ≥ 3`, `p ≥ 1`. Pin the Argon2 version at `0x13` (19); no other version is admissible under `enc.scheme: 1`, and there is no version field on the wire. Where the platform supports it, producers **SHOULD** emit `p = 4` (the second recommended profile of [RFC 9106 §4](https://www.rfc-editor.org/rfc/rfc9106#section-4)); verifiers **MAY** accept any `p ≥ 1`, subject to deployment ceilings. Argon2id crosses ecosystem boundaries cleanly — the parameter set, not the library, is the contract — so a fixed `(m, t, p, salt, len, password)` must yield byte-identical output in every implementation. The binding between a passphrase and its envelope is the in-ciphertext commitment above; a wrong passphrase and a tampered ciphertext both surface as one generic failure. Bound the raw passphrase **before** normalization and Argon2id: reject any input longer than the reference `MAX_PASSPHRASE_INPUT_BYTES = 4096` UTF-8 bytes, so a pathological passphrase cannot drive a pre-KDF denial of service. Like the slots- path `MAX_SLOTS` and decoded-envelope bounds, this is a deployment-pinned constant you **MAY** tighten, not a wire field. ### The normalization profile is normative [#the-normalization-profile-is-normative] Two implementations **MUST** derive a byte-identical CEK from the same passphrase, and the only way to guarantee that is a pinned normalization. The profile `cardano-poe-pw-norm-v1`, applied in order: 1. **Reject unassigned codepoints** — a passphrase containing any codepoint unassigned in **Unicode 16.0** is rejected (`ENC_PASSPHRASE_UNNORMALIZABLE`) before any normalization runs. Unicode guarantees normalization stability only over assigned codepoints, so this closes a future-drift hole and is invisible to honest users. 2. **NFKC** — Normalization Form KC per [UAX #15](https://www.unicode.org/reports/tr15/) under **Unicode 16.0**. 3. **Whitespace** — define whitespace as every character carrying the Unicode `White_Space` property under **Unicode 16.0**; collapse every maximal run to a single U+0020 SPACE. 4. **Trim** — remove leading and trailing whitespace. 5. **Reject empty** — if the result is the empty string, reject (`ENC_PASSPHRASE_EMPTY`); a whitespace-only passphrase would otherwise key the record to a CEK any party can derive. 6. **Encode** — UTF-8; those bytes are the Argon2id password input. Pin Unicode at **16.0** literally and do not let it float: the `White_Space` property set, the assigned-codepoint set, and the NFKC mapping tables are all version-dependent, so resolving the profile against a different Unicode version can derive a different CEK from the same passphrase and fail to open an honest record. A future revision that adopts a newer Unicode version does so under a **new** profile identifier, never by reinterpreting `cardano-poe-pw-norm-v1`. ### Trial-decrypt: open every slot, fold in the MAC, fail generically [#trial-decrypt-open-every-slot-fold-in-the-mac-fail-generically] A recipient holds one KEM private key and discovers its slot by trying to open each one — recipient public keys are not on the wire. Before invoking any KEM or AEAD primitive, run the resource bounds, then the structural guards. **Bound parser resource use first:** reject an envelope whose decoded size exceeds `65536` bytes (`ENC_ENVELOPE_TOO_LARGE`) or whose `slots[]` exceeds `MAX_SLOTS = 1024` (`ENC_SLOTS_TOO_MANY`). Both reference bounds sit far above the \~16 KiB Cardano transaction-metadata ceiling that constrains any honest record; they are deployment-pinned constants you **MAY** tighten, never wire fields. Then the structural guards: `scheme == 1`; `aead`, `kem` registered; `nonce` 24 bytes; `slots_mac` 32 bytes; `slots` non-empty; recipient secret 32 bytes; each `wrap` 48 bytes; per-KEM, each `epk` exactly 32 bytes with no `kem_ct` (`x25519`) or each `kem_ct` exactly 1120 bytes with no `epk` (`mlkem768x25519`). **Reject within-record duplicate encapsulation here, before any primitive.** All `epk` values must be distinct on the classical path, all `kem_ct` values distinct on the hybrid path; a duplicate raises `ENC_SLOTS_DUPLICATE_KEM_MATERIAL`. This is the verifier-checkable slice of the per-slot-KEK-uniqueness invariant the zero-nonce wrap depends on; cross-record or cross-key reuse is a producer obligation no verifier can detect. This rejection fires only on a repeated `epk` / `kem_ct` — sealing to the same recipient twice with **fresh** per-slot ephemerals is legitimate and does not trip it (see the multiple-matches rule below). `unwrap-negative` carries the duplicate-`epk`-with-KEK-reuse case. Then run the loop, recomputing `slots_hash` once before it and holding it constant: ```text found = false cek_conflict = false selected_CEK = 0^32 for slot in slots: ; iterate ALL slots — no early break ; derive KEK per-KEM, as in the wrap recipe. For x25519 the all-zero shared ; secret is rejected via a secret-independent bit, not an early branch: ; kem_ok = NOT constantTimeEqual(shared, 0^32) ; KEK = ct_select(kem_ok, real_KEK, dummy_KEK) ; dummy_KEK from ikm=0^32, same salt/info ; (XWing.Decapsulate has no all-zero case; kem_ok stays true on the hybrid path.) open_ok, candidate_CEK = ChaCha20-Poly1305_open_or_dummy(KEK, zeros(12), kem_info_label, slot.wrap) HMAC_KEY = HKDF-SHA-256(candidate_CEK, salt = "", info = "cardano-poe-slots-mac-v1", L = 32) mac_ok = constantTimeEqual(HMAC-SHA-256(HMAC_KEY, slots_hash), slots_mac) ok = kem_ok AND open_ok AND mac_ok ; kem_ok folded into acceptance first = ok AND NOT found ; first matching slot cek_conflict = cek_conflict OR (ok AND found AND NOT constantTimeEqual(candidate_CEK, selected_CEK)) selected_CEK = ct_select(first, candidate_CEK, selected_CEK) ; constant-time found = found OR ok if NOT found: reject (single generic failure) if cek_conflict: reject (single generic failure) content_key = HKDF-SHA-256(selected_CEK, salt = enc.nonce, info = "cardano-poe-payload-v1", L = 32) plaintext = STREAM_open(content_key, ciphertext) ; per-chunk authenticated release if STREAM_open fails at any chunk: reject (single generic failure) ; TAMPERED_CIPHERTEXT ``` The non-negotiables in this loop: * **Open atomically; never release unverified plaintext.** Both `*_open_or_dummy` primitives are atomic: on AEAD tag failure they return no plaintext, and the returned candidate (the wrapped CEK, or the content plaintext) is a fixed-or-pseudorandom dummy independent of the failed ciphertext. This is what lets the loop carry a `candidate_CEK` past a failed wrap open without ever exposing unauthenticated bytes. * **Fold the all-zero check into a secret-independent `kem_ok` bit.** Compute `kem_ok = NOT constantTimeEqual(shared, 0^32)` for the `x25519` path, select the KEK in constant time between the real KEK and a dummy KEK derived from `0^32` under the same salt and info, and fold `kem_ok` into acceptance (`ok = kem_ok AND open_ok AND mac_ok`). Do not branch out early on an invalid share — an invalid-ECDH slot can never be accepted, and the loop still does identical work. (`XWing.Decapsulate` has no all-zero case, so `kem_ok` is fixed true on the hybrid path.) * **Fold the `slots_mac` check into the loop.** A malicious sender can craft a slot that opens under the recipient's key with an attacker-chosen CEK (no private-key knowledge needed). Accepting the first AEAD success as "ours" would let that forged slot shadow an honest one. Requiring the candidate CEK to also reproduce `slots_mac` over `slots_hash` defeats slot-substitution, slot-removal, and slot-reorder. Never skip it. * **Permit multiple matches; reject only a CEK conflict.** A recipient key **MAY** legitimately match more than one slot — sealing the same CEK to the same recipient in several slots, each with fresh ephemerals, is valid recipient-count padding and does not trip the duplicate-`epk`/`kem_ct` rejection. Select the **first** match's CEK and do not reject merely because more than one slot matched. The one anomaly to reject is two matching slots that recover **different** CEKs (constant-time compare): track a `cek_conflict` bit and surface the single generic failure if it is set. This is defence-in-depth — under the slot-set commitment a distinct-CEK match is already infeasible — so it fails closed against a broken implementation. * **Iterate all slots within a single private key's pass** — a constant number of slot operations per key, no early break — so a timing observer cannot infer which slot matched. Drive the all-zero rejection through `kem_ok` and dummy work rather than exiting early. A recipient with multiple keys iterates key × slot and **MAY** short-circuit across keys (leaking only the weak "which key matched" signal), but must stay constant-time across the slots of any one key — and must re-derive the `pub_R` half of the salt per key, since both KEMs bind the recipient's own public key into the KEK salt. Bind that salt to the key's **canonical wire encoding** — exactly the 32-byte X25519 public key, or exactly the pinned 1216-byte X-Wing public-key bytes — never a non-canonical re-encoding, or the two sides derive different KEKs. * **Surface one generic failure shape to untrusted callers.** Internally you may track typed outcomes for local diagnostics — `WRONG_RECIPIENT_KEY` (no slot opened), `TAMPERED_HEADER` (a slot opened but no candidate CEK reproduced `slots_mac`), `TAMPERED_CIPHERTEXT` (content AEAD failed after a CEK was recovered and the MAC verified) — but an external observer **MUST NOT** distinguish them by response shape. On timing, the model is deliberately scoped: a verifier **MAY** return at the `if NOT found` check before content decryption, which separates a non-recipient from a recipient whose ciphertext fails to open. That reveals only recipient-vs-non-recipient, never which slot or any key material; uniform timing between those two cases is **not** required and a dummy content open **MUST NOT** be mandated. The constant-time guarantee that holds is the across-slots invariant above. * **Recompute and compare the plaintext hash after decryption.** The on-chain `hashes` map commits to the **plaintext**, not the ciphertext, so the recipient (at the application layer) must recompute the digest and compare: the `sha2-256` entry must match, and `blake2b-256` if present. A mismatch means the record's hash claim does not match the decrypted bytes — refuse to act on the plaintext. The structural validator never decrypts. ### Bound the payload on both sides [#bound-the-payload-on-both-sides] The segmented STREAM imposes **no** cryptographic payload ceiling: the 88-bit per-chunk counter admits `2^88` chunks, and each chunk is sealed under a distinct `(content_key, nonce)` pair well within the RFC 8439 single-invocation limit, so there is no counter-overflow risk to guard against. The maximum a producer or verifier enforces is therefore a **deployment denial-of-service policy**, not a wire constant — enforce it incrementally as the stream is written or read, and abort before buffering an oversized payload. Truncation is caught structurally by the final flag rather than by a size cap. The same posture applies on both the slots path and the passphrase path. ### Sealed-PoE conformance fixtures [#sealed-poe-conformance-fixtures] The sealed-PoE corner of the corpus is where most cross-language bugs surface. Drive your implementation through all of it. The **positive** fixtures pin the deterministic wrap and the trial-decrypt loop for both KEMs — single- and multi-recipient, mixed-N, and the multi-private-key worst case — plus the legitimate case of one recipient matching two slots (fresh ephemerals, same CEK, **MUST** decrypt, so an implementation that rejects multiple matches fails here) and the passphrase path (commitment header plus STREAM chunks in one blob). A dedicated **STREAM-layout** set pins an empty plaintext (one zero-length final chunk), a single-chunk payload, and a multi-chunk payload crossing the 65536-byte boundary. Targeted KATs pin both KEK salts (`SHA-256(label ‖ enc.nonce ‖ ‖ pub_R)`), the `hashes_hash` and its place in both transcripts, X-Wing encapsulation against draft-10, the zero-length-salt HKDF extract (the absent-salt convention of [RFC 5869 §2.2](https://www.rfc-editor.org/rfc/rfc5869#section-2.2), mirroring the `slots_mac` key derivation), the Bech32 recipient/secret encodings, and the checksummed identity-seed encoding. The **negative** fixtures pin the rejection codes: a forged shadow slot before an honest slot (the record **MUST** still decrypt under the honest CEK); a header-flip (`kem`/`aead`/`scheme`) that leaves slot shapes valid; a `hashes`-splice onto an item with a different hash claim; the passphrase-commitment failures (wrong passphrase, tampered `salt`/`params`, tampered header — all failing before any chunk opens); the passphrase-normalization rejections (an unassigned-codepoint input and a whitespace-only input); the all-zero X25519 shared secret; the within-record duplicate slot; and the STREAM-tampering cases (flipped chunk tag, truncated stream, trailing data, short non-final chunk). Two properties have **no** byte vector and are asserted behaviourally instead: the CEK-conflict rejection (constructing one is exactly the multi-key commitment collision the standard assumes infeasible) and the constant-time-across-slots guarantee. Reproduce every pinned byte string and emit the exact code for every negative case. One sealed-PoE property has **no** byte vector: the CEK-conflict rejection — two matching slots that recover **different** CEKs — cannot be constructed as a fixture, because constructing one is exactly the multi-key commitment collision the standard assumes infeasible. Pin it instead with an implementation-level behavioural test that asserts your trial-decrypt loop fails closed on a forced conflict, the same way the constant-time-across-slots property is asserted behaviourally rather than as a byte string. ## Conformance and test vectors [#conformance-and-test-vectors] The normative test vectors **are** the interoperability contract. An implementation is conformant **if and only if** it reproduces every pinned byte string in the conformance suite from the same inputs — and emits the correct typed error code for every negative fixture. There is no partial credit and no appeal: if a comparison fails, the implementation is wrong, never the vector. The vectors live in the standard's conformance suite, organized by primitive class: record fixtures, sealed-PoE wrap/unwrap, COSE\_Sign1 signatures, HKDF, seed derivation, Argon2id, and canonical CBOR. Each pins lowercase-hex inputs and the expected outputs. To use them: feed the inputs into your implementation, compare each named output byte-for-byte, and fix your code on any mismatch. ### Three obligations every implementation must meet [#three-obligations-every-implementation-must-meet] **Reproduce the positive vectors.** For every record fixture, both halves of `encode(record) == expected_cbor` AND the round-trip `encode(decode(expected_cbor)) == expected_cbor` **MUST** hold. The round-trip generalizes beyond the fixtures: for arbitrary well-formed input, `encode(decode(x)) == x`. A decoder that loses or reorders information, or an encoder that is not canonical, breaks this and fails conformance. **Emit the right rejection codes.** The negative fixtures pair a deliberately malformed record with the exact typed error code a structural validator **MUST** raise. Reproducing the bytes of valid records is half the contract; rejecting invalid ones with the **correct** code is the other half. A validator that rejects a bad record for the wrong reason — or accepts it — is non-conformant. The negative fixtures are the single source of truth for cross-language rejection parity: the same malformed input **MUST** raise the same code in every implementation. The full catalogue of codes and their meanings is on [Verification](/spec/verification). **Match the registries.** Algorithm identifiers are named strings drawn from the registries on [Algorithm registries](/spec/algorithm-registries). An unrecognized identifier **MUST** surface the precise unsupported-algorithm code, never a silent acceptance or a panic. The vectors are pinned to upstream RFCs and to the deterministic constructions of this standard. When a comparison fails, the bug is in the implementation under test. Editing a vector to make a suite pass converts a real interoperability failure into a latent one that surfaces only when a record crosses implementations on chain — the worst possible time to discover it. ### Run parity on every change [#run-parity-on-every-change] An implementation that ships more than one language — or wants to prove interoperability with another — **SHOULD** run a single continuous-integration job that builds every package, runs each language's test suite against the shared fixtures, enforces the dependency-graph lint, and checks that the fixture set is **identical** on both sides. A fixture added on one side but not the other fails the gate: the two implementations have silently diverged, and the build catches it before a real record does. The fixtures are the canonical source; each language holds a byte-identical mirror, and the gate asserts the mirror is complete and exact. ## Naming and wire conventions [#naming-and-wire-conventions] A few conventions keep an implementation legible and the wire format stable: * **Wire field names are `snake_case`** — `leaf_count`, `cose_sign1`, `slots_mac`. This holds across languages: even where a language idiomatically uses `camelCase` for its in-memory API, the encoded record uses `snake_case` keys, because the keys are part of the canonical bytes a signature covers. * **Identifiers are registry strings**, not enums baked into code. Hashes, AEADs, KEMs, KDFs, and signatures all reference named identifiers; adding an algorithm (a post-quantum KEM, say) is an additive registry entry, never a wire-format break. * **Cross-language method names mirror semantically.** A function in one language has a same-named counterpart in another (`encode_canonical_cbor` ↔ `encodeCanonicalCbor`), so a reader fluent in either can map one surface onto the other and reason about parity by inspection. * **Bootstrap the crypto layers first.** Stand up the cryptographic core and the wire-format library against the vectors and get the parity gate green before writing a line of application code. The standalone verifier is the smallest application-adjacent surface and the next thing to build; everything else sits on top of a crypto layer you have already proven correct. ## Related pages [#related-pages] * [The record](/spec/the-record) — the wire format the validator and encoder implement. * [Sealed PoE](/spec/sealed-poe) — the construction reference behind the build recipes here. * [Algorithm registries](/spec/algorithm-registries) — the named identifiers an implementation resolves. * [Verification](/spec/verification) — the validation pipeline, the standalone verifier, and the error-code catalogue. # Introduction (/spec) Label 309 is an open standard for **Proof of Existence (PoE)** on the Cardano blockchain. A publisher computes a cryptographic hash of some content and anchors it in a Cardano transaction under metadata **label 309**. From that moment, anyone holding the transaction reference can prove the content existed on or before the block's time — using only the public blockchain, with no need to trust the publisher, a domain, or any server. The standard is deliberately small at its core: the content hash *is* the claim, and everything else is optional metadata about it. On top of that core it defines optional authorship signatures and an optional encrypted ("sealed") payload addressed to specific recipients — none of which ever requires a trusted intermediary. ## The five invariants [#the-five-invariants] Everything in Label 309 follows from five non-negotiable principles: 1. **Content-first.** The content hash is the primary claim; every other field is metadata about it. 2. **Issuer-agnostic.** Any wallet can publish a record. Verifiers never trust the publisher. 3. **Storage-agnostic.** Storage URIs are an optional, plural list (`ar://`, `ipfs://`); a hash-only record is fully valid. 4. **Standalone-verifiable.** A verifier needs only the transaction metadata, optionally the content bytes, and a public blockchain explorer. No issuer server is ever required. 5. **Algorithm-agile.** Hashes, AEADs, KEMs, KDFs, and signatures all reference named identifiers from extensible registries. Migrating to post-quantum algorithms is additive, not a breaking change. ## Conformance profiles [#conformance-profiles] A record participates at one of four layered profiles, from a bare timestamp to an encrypted, recipient-addressed payload. Each profile is a strict superset of the one above it. | Profile | Adds | Answers | | -------------------- | ----------------------------------------------- | ----------------------------------------------- | | **core** | a content hash under label 309 | "this content existed by time T" | | **signed** | one or more COSE\_Sign1 record signatures | "...and this key vouches for it" | | **sealed** | an encrypted payload (passphrase or recipients) | "...kept confidential, but timestamped" | | **recipient-sealed** | per-recipient key slots (X25519 or X-Wing) | "...delivered to specific keys, held privately" | Authorship and encryption are always optional. A verifier that only understands `core` can still validate the timestamp claim of any record. ## Verification, at a glance [#verification-at-a-glance] Label 309 defines three verifier roles, each a strict extension of the previous one: * **Structural validator** — a pure function over the record bytes. No network, no signature checks, no decryption; it only confirms the record is well-formed against the schema and the standard's rules. * **Public verifier** — fetches the transaction from a public explorer, runs structural validation, confirms the record is anchored on-chain, and verifies any authorship signatures. It does not decrypt. * **Recipient verifier** — a public verifier that additionally holds a private key, decrypts a sealed payload addressed to it, and recomputes the plaintext hashes. Because every role works from public data and a verifier-chosen explorer, verification never depends on the publisher's infrastructure. A Label 309 proof is something you can check yourself. Given a transaction reference and — for content claims — the original bytes, any conformant implementation reaches the same verdict from the public chain alone. ## How this specification is organized [#how-this-specification-is-organized] The chapters that follow define the wire format (the record under label 309), the algorithm registries, the cryptographic key model, authorship signatures, sealed (encrypted) delivery, the verification pipeline and its error catalogue, the security model, and the cross-language conformance contract. Each page is self-contained and cites the named constants and rules an implementer needs. # Keys (/spec/keys) Label 309 needs three kinds of asymmetric key: an **Ed25519** key that signs records, an **X25519** key that receives classical sealed payloads, and an **X-Wing** (`mlkem768x25519`) hybrid key that receives post-quantum sealed payloads. The standard does not treat these as three independent secrets to store and shuffle. It defines exactly one secret — a **32-byte seed** — and a deterministic rule that expands it into all three keypairs. This page specifies that derivation: the seed, the three domain-separated HKDF expansions that produce each algorithm's private key, why the domains are kept separate, the per-slot key-encryption keys a sealed PoE derives on top of them, and how the resulting recipient public keys and secrets are encoded for exchange. What an implementation does with the seed beyond this — where it lives, how it is unlocked, whether one human holds several — is out of scope. Label 309 cares only that, given the same 32 bytes, every conformant implementation derives the same keys. ## The seed [#the-seed] A Label 309 key set is rooted in a single value: | Property | Value | | -------- | ------------------------------------------------------------------ | | Length | 32 bytes (256 bits) | | Source | A cryptographically secure RNG, or any 32-byte value the user owns | | Role | Input keying material for the three HKDF expansions below | The seed is a **bare entropy source**, not a key in any one algorithm's sense. It carries no curve, no length tied to a primitive, no encoding ceremony. The keys an implementation actually uses are negotiated per derivation; the seed outlives the algorithm choices made over it. A producer **MAY** generate the seed freshly from the platform CSPRNG or import an existing 32-byte value; either way it **MUST** decode to exactly 32 bytes. No low-entropy pattern is rejected at the derivation layer — an all-zero seed is a valid input, which is what makes the all-zero seed usable as a reproducible conformance fixture. Every public-key fact Label 309 expresses about a party — the key that vouches for a record, the keys that receive a sealed payload — is a deterministic function of these 32 bytes. Reproduce the seed and you reproduce all three keypairs, byte for byte. ## Encoding the seed for backup [#encoding-the-seed-for-backup] Because the 32-byte seed **is** the identity, it is the value a user backs up, exports, and imports — and a bare 32-byte blob is easy to truncate or corrupt silently. Label 309 defines a **checksummed string encoding** for it, accepted alongside raw hex everywhere a seed is taken as input. The string form is Bech32 ([BIP-173](https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki), classic, with the 90-character length cap lifted) under the human-readable prefix `l309-seed-` — the trailing hyphen is part of the HRP, so the Bech32 separator renders the visible prefix `l309-seed-1…`. **Encoding returns the UPPERCASE display form** `L309-SEED-1…`: secrets are loud, and the uppercase rendering is visually distinct from the lowercase `age1…` recipient strings. The all-lowercase form is an equally valid encoding of the same bytes. ```text seed (32 bytes) 0000…0000 -> L309-SEED-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQLFUN82 ``` A parser accepts **two** representations and dispatches on shape: * **The Bech32 string** in a single case (mixed case is rejected per BIP-173), with the checksum verified and the decoded payload exactly 32 bytes. * **Raw hex** — 64 hex digits, case-insensitive, tolerating a `0x` prefix and surrounding or internal whitespace. Each rejected input maps to a distinct construction-API error code, so a caller can tell a typo from a wrong key type: | Input | Error code | | ------------------------------------------------------------------------- | -------------------------- | | A Bech32 string whose checksum fails (a char flip, a truncation) | `SEED_STRING_BAD_CHECKSUM` | | A Bech32 string mixing upper- and lower-case | `SEED_STRING_MIXED_CASE` | | A valid Bech32 string under a different HRP (e.g. an `age1…` recipient) | `SEED_STRING_WRONG_HRP` | | A Bech32 string or hex string decoding to ≠ 32 bytes | `SEED_STRING_WRONG_LENGTH` | | Anything that is neither a recognised Bech32 string nor hex (incl. empty) | `SEED_STRING_UNRECOGNIZED` | These codes describe the seed-string codec, which is a key-handling convenience around the derivation; they are distinct from the wire error-code registry a structural validator emits ([Verification](/spec/verification)). The encoding carries the bare 32 bytes and nothing else — no version, no derivation parameters — because the seed's meaning is fixed by the three `info` strings below, not by how it was transported. ## Deriving the three keypairs [#deriving-the-three-keypairs] Each algorithm's private key is an independent **HKDF-SHA-256** expansion of the same seed, per [RFC 5869](https://www.rfc-editor.org/rfc/rfc5869). The three expansions share their input keying material and their (absent) salt, and differ only in a single parameter — the `info` string that names the algorithm: | Algorithm | `info` string | Output | | ---------------- | ------------------------------- | ------------------------------------- | | Ed25519 | `cardano-poe-ed25519-v1` | 32-byte Ed25519 secret seed | | X25519 | `cardano-poe-x25519-v1` | 32-byte X25519 secret seed | | `mlkem768x25519` | `cardano-poe-mlkem768x25519-v1` | 32-byte X-Wing decapsulation-key seed | The derivation in pseudo-code: ``` ed25519_priv = HKDF-SHA-256(ikm = seed, salt = "", info = "cardano-poe-ed25519-v1", length = 32) x25519_priv = HKDF-SHA-256(ikm = seed, salt = "", info = "cardano-poe-x25519-v1", length = 32) mlkem768x25519_priv = HKDF-SHA-256(ikm = seed, salt = "", info = "cardano-poe-mlkem768x25519-v1", length = 32) ``` Three rules make these outputs interoperable across implementations: 1. **The salt is empty.** The HKDF salt **MUST** be the zero-length byte string. Per [RFC 5869 §2.2](https://www.rfc-editor.org/rfc/rfc5869#section-2.2) an absent salt is treated as `HashLen` zero bytes — 32 zero bytes for SHA-256 — so every conformant library reaches the same extract step. 2. **The output is 32 bytes.** Each expansion requests exactly 32 bytes (a single HKDF block for SHA-256). 3. **The `info` strings are exact ASCII.** Each `info` value **MUST** be encoded as precisely the bytes shown — no surrounding whitespace, no zero terminator, no byte-order mark, no trailing newline. The three strings are 22, 21, and 29 bytes respectively. The 32 output bytes are the algorithm's **secret seed**, not its expanded curve scalar. [RFC 8032 §5.1.5](https://www.rfc-editor.org/rfc/rfc8032#section-5.1.5) draws this distinction for Ed25519: the secret seed is 32 bytes, and the signing library expands it (via SHA-512, then clamping) into the actual scalar and signing prefix internally. The same holds for X25519, where clamping is applied inside the primitive per [RFC 7748 §5](https://www.rfc-editor.org/rfc/rfc7748#section-5). An implementation **MUST** pass the raw 32-byte HKDF output to the primitive and let the library perform expansion and clamping — it does not pre-clamp or pre-expand. For X-Wing the 32-byte output is the [X-Wing](https://datatracker.ietf.org/doc/draft-connolly-cfrg-xwing-kem/10/) decapsulation-key seed, from which the full keypair — including the 1216-byte public key — is regenerated deterministically by X-Wing key generation. In every case the compact 32-byte seed, never an expanded key, is the canonical form to store and transport. ## Why three domains, not one [#why-three-domains-not-one] HKDF's `info` parameter is its **domain-separation tag**: it binds the expanded output to a specific application context, and [RFC 5869 §3.1](https://www.rfc-editor.org/rfc/rfc5869#section-3.1) strongly recommends supplying one when context is available. Label 309 supplies a distinct tag per algorithm rather than reusing one expansion across all three, even though all three private keys happen to be 32 bytes wide. The reason is isolation: * **Failures stay contained.** If two keypairs shared identical bytes, a weakness specific to one algorithm — a nonce-derivation flaw, a side-channel on a scalar multiplication — could expose the key of an unrelated algorithm. Domain separation guarantees the three private keys are independent functions of the seed, so a compromise of one teaches an attacker nothing about the others. * **Migration stays additive.** Each `info` string ends in `-v1`. Adopting a different curve or a different hybrid in a future revision derives a fresh `-v2` key from the same seed under a new tag, with no collision against deployed v1 keys. This mirrors the algorithm-agility the wire format itself relies on. The third tag, `cardano-poe-mlkem768x25519-v1`, gives the post-quantum hybrid its own domain even though its decapsulation-key seed is the same 32-byte width as the classical X25519 secret. A flaw in ML-KEM-768, in X25519, or in the X-Wing combiner therefore cannot cross-contaminate the classical encryption key or the signing key. This identity-key tag, `cardano-poe-mlkem768x25519-v1`, also carries **no** `-kek-` segment: it is distinct from the per-record KEK-derivation label `cardano-poe-kek-mlkem768x25519-v1` below, so the seed → identity-key expansion and the per-slot key wrap of a sealed PoE never share an `info` string. ## Per-slot key-encryption keys [#per-slot-key-encryption-keys] The three seed-derived keypairs above are long-lived identity keys. A sealed PoE adds a second, **per-record** layer of HKDF-SHA-256: for each recipient slot the sender derives a fresh 32-byte key-encryption key (KEK) that wraps the record's content-encryption key. The KEK derivation is part of the key model, so it is specified here; how the wrapped key then rides the envelope is on [Sealed PoE](/spec/sealed-poe). Both KEMs derive the KEK with HKDF-SHA-256 and a KEM-specific `info`, under a **labelled-hash salt** that binds three values: the slot's own KEM material (so the KEK is slot-unique), the recipient public key `pub_R` (so an encapsulation crafted for one recipient cannot be relayed against another), and the envelope-unique `enc.nonce` (so the KEK is anchored to one envelope). The shared secret is the KEM's own ECDH (classical) or X-Wing decapsulation (hybrid) output — the same 32-byte value whether the sender encapsulates or the recipient decapsulates — and `salt` and `info` are identical on both sides: ``` ; x25519 (classical) — salt is a labelled SHA-256 over the ephemeral and recipient keys kek_salt = SHA-256("cardano-poe-x25519-kek-salt-v1" || enc.nonce || pub_epk || pub_R) ; 32 bytes KEK = HKDF-SHA-256(ikm = shared, ; the X25519 ECDH shared secret salt = kek_salt, info = "cardano-poe-kek-v1", L = 32) ; mlkem768x25519 (hybrid) — same labelled-salt shape under the hybrid's own label kek_salt = SHA-256("cardano-poe-xwing-kek-salt-v1" || enc.nonce || kem_ct || pub_R) ; 32 bytes KEK = HKDF-SHA-256(ikm = shared, ; the X-Wing shared secret salt = kek_salt, info = "cardano-poe-kek-mlkem768x25519-v1", L = 32) ``` The two salts have the **same** shape — `SHA-256(label || enc.nonce || || pub_R)` — differing only in the per-KEM label and in which KEM material they carry: the 32-byte ephemeral `pub_epk` on the classical path, the 1120-byte X-Wing ciphertext `kem_ct` on the hybrid path. Both are folded through a fixed-length SHA-256 digest because the hybrid inputs are oversized for a raw salt and one uniform shape keeps the two paths aligned. The binding is computed **outside** the KEM, over the slot's own wire bytes, so it holds X-Wing as a black-box KEM and relies on no property of the combiner's internal hashing. The KEM-distinct `info` label additionally guarantees a KEK derived under one KEM can never equal a KEK derived under the other on an identical 32-byte shared secret. In both salts `pub_R` is the recipient key's **canonical wire encoding** — exactly the 32-byte X25519 public key for `x25519`, exactly the pinned 1216-byte X-Wing public-key byte string for `mlkem768x25519`. Producer and recipient **MUST** use that exact encoding and **MUST NOT** substitute any non-canonical or re-encoded equivalent: the two sides would otherwise feed different salts into HKDF and derive different KEKs, and the slot would never open. Each KEK and its salt-prefix are **internal building blocks** of `enc.scheme: 1`: they carry no wire identifier and are not selectable. The two salt-prefix labels and the two `info` labels here are four of the eleven sealed-construction label literals catalogued on [Algorithm registries](/spec/algorithm-registries); a verifier MUST use each byte-for-byte. ## Recipient public-key encodings [#recipient-public-key-encodings] A sealed-PoE sender needs the recipient's **public** key in a portable string form, and a recipient backs up their **secret** in the matching form. Label 309 reuses the age ecosystem's Bech32 recipient encodings, one human-readable prefix (HRP) per registered key-encapsulation mechanism. In Bech32 the `1` is the separator between the HRP and the data part, so the human-*visible* prefix of a string is its HRP **plus** that `1`. The HRP and the visible prefix are therefore distinct, and the table keeps them in separate columns: | KEM (`enc.kem`) | Public key | Public-key HRP | Public-key visible prefix | Secret HRP | Secret visible prefix | | ---------------- | --------------------------- | -------------- | ------------------------- | -------------------- | ---------------------- | | `x25519` | 32-byte X25519 public key | `age` | `age1…` (62 chars) | `AGE-SECRET-KEY-` | `AGE-SECRET-KEY-1…` | | `mlkem768x25519` | 1216-byte X-Wing public key | `age1pqc` | `age1pqc1…` (1960 chars) | `AGE-SECRET-KEY-PQ-` | `AGE-SECRET-KEY-PQ-1…` | The classical `x25519` recipient string has HRP `age` and the standard age v1 form `age1…`. The hybrid public key concatenates an ML-KEM-768 encapsulation key (1184 bytes) with an X25519 public key (32 bytes); at 1216 bytes its `age1pqc1…` recipient string is 1960 characters. The **secret** an implementation backs up and imports is, on both paths, the 32-byte seed — the X25519 secret seed under `AGE-SECRET-KEY-`, and the X-Wing decapsulation-key seed (the third HKDF output above, `info = "cardano-poe-mlkem768x25519-v1"`) under `AGE-SECRET-KEY-PQ-`. The 1216-byte hybrid public key derives from that seed; the compact seed, never the expanded key, is the canonical secret to store. [BIP-173](https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki) caps a Bech32 string at 90 characters, but that cap exists for human-typed payment addresses and does **not** apply here. An implementation **MUST** encode and decode the `age1pqc1…` string without enforcing the 90-character limit while still applying the Bech32 checksum and charset rules. The distinct HRP `age1pqc` keeps the hybrid recipient from colliding with any classical `age` recipient — and is deliberately **not** `age1pq`, the shorter prefix an upstream native ML-KEM-768 + X25519 encoding already claims for the same primitive, so the two recipient encodings never collide on the wire. The classical encoding stays within ordinary lengths and is handled unchanged. These strings are recipient-discovery conveniences only. A recipient public key **never** appears on a Label 309 record's encryption envelope — an `enc.slots[]` entry carries per-slot key material and a `wrap` value, and the KEM identifier appears once at `enc.kem`. How the envelope and slots are built is covered in [Sealed PoE](/spec/sealed-poe). ## The Ed25519 public key as a signature `kid` [#the-ed25519-public-key-as-a-signature-kid] The Ed25519 public key plays no recipient role; it is the **key identifier** a verifier resolves a signature against. When a producer signs a record, the raw 32-byte Ed25519 public key is the `kid` (label `4`) in the COSE\_Sign1 protected header, per [RFC 9052](https://www.rfc-editor.org/rfc/rfc9052). A verifier reads that 32-byte value straight from the on-chain signature and checks the record body against it — the public key travels with the signature, so no separate lookup is required to verify authorship. The full signing construction, the signed payload, and the verification rules are specified in [Signatures](/spec/signatures). ## Out-of-band key exchange [#out-of-band-key-exchange] Label 309 specifies how recipient public keys are **encoded**, not how they are **discovered**. The standard prescribes no directory, no registry, and no on-chain announcement format for recipient keys. A party who wants to receive a sealed payload publishes their `age1…` or `age1pqc1…` string through whatever channel both sides already trust — a hand-off in person, a record signed under their own Ed25519 key, a record at a stable web or content-addressed location — and the sender is responsible for the provenance of any key they encrypt to. This is a deliberate boundary. The same property that lets a record be verified without trusting a server means key exchange must not smuggle a trusted intermediary back in. A name placed next to a key is an attestation by whoever placed it, never a cryptographic claim: two parties using the same handle still produce keys with different bytes, and a verifier compares the bytes. Mapping human-readable names to keys is something an application built on Label 309 **MAY** offer, but it is an application feature, outside the protocol. ## Related pages [#related-pages] * [Signatures](/spec/signatures) — how the Ed25519 key signs a record and how the `kid` is verified. * [Sealed PoE](/spec/sealed-poe) — how the X25519 and X-Wing public keys address an encrypted payload to specific recipients. * [Algorithm registries](/spec/algorithm-registries) — the named identifiers for signatures, KEMs, AEADs, and KDFs referenced here. # Open questions (/spec/open-questions) Label 309 v1 is **frozen**. The wire format under metadata label 309, the canonical-CBOR encoding, the algorithm registries, the sealed-PoE envelope, and the signature construction are settled: a conformant v1 verifier reads every v1 record, and a conformant v1 producer emits records every v1 verifier accepts. This page is not about changing that. It is the register of what is **confirmed** in the standard today and what remains **forward-looking** — candidate cryptographic constructions reserved for a possible future revision, and the disciplined procedure for changing a cryptographic constant when one of them lands. Nothing here is a product roadmap. Every entry is a protocol-level technical decision: an algorithm identifier, a derivation, a verifier policy, or a versioning rule. A reader implementing the standard needs the **settled** half to build a v1 implementation today, and the **forward-looking** half to build it so that a future post-quantum or RFC-only successor is an additive registry entry, never a rewrite. ## Settled constructions [#settled-constructions] These are decided. They are stated plainly here because they are the load-bearing choices an implementer most often wants confirmed in one place; the linked pages carry the full construction. ### The post-quantum KEM ships in v1 [#the-post-quantum-kem-ships-in-v1] The hybrid KEM **X-Wing** (ML-KEM-768 composed with X25519) is registered under the identifier `mlkem768x25519` in the `enc.kem` registry **from the first release**. It is not a future candidate: it lives in `enc.scheme: 1` alongside the classical `x25519` KEM, and the on-wire `enc.kem` field selects the per-slot KEM, slot shape, and key-encryption-key derivation per record. X-Wing is consumed as a **black box**: the construction uses only its public interface — encapsulate, decapsulate, and the 32-byte shared secret — and makes no assumption about the combiner's internal hashing. Both KEMs derive the per-slot KEK under one labelled-hash salt shape, `SHA-256(label || enc.nonce || || pub_R)`, computed over the slot's own wire bytes: on the hybrid path `SHA-256("cardano-poe-xwing-kek-salt-v1" || enc.nonce || kem_ct || pub_R)`. Hashing to a fixed 32 bytes binds the envelope nonce, the ciphertext, and the recipient public key into the KEK while dissolving the parser ambiguity a variable-length post-quantum ciphertext would create under a bare-concatenation salt. Only the hybrid variant is exposed — pure ML-KEM is deliberately withheld so the X25519 leg preserves classical security if ML-KEM-768 is ever broken. See [Sealed PoE](/spec/sealed-poe) and [Algorithm registries](/spec/algorithm-registries). ### The sealed key commitment binds the header and the hash claim [#the-sealed-key-commitment-binds-the-header-and-the-hash-claim] Both sealed paths commit the content-encryption key to a closed transcript, serialised with the same canonical-CBOR function the rest of the envelope uses, that pins the header fields **and the item's plaintext-hash claim** (`hashes_hash`). On the recipient-addressed (`enc.slots`) path the commitment is on chain: `slots_mac` is a CEK-keyed HMAC over a transcript carrying the scheme, path, AEAD and KEM identifiers, the nonce, the shuffled slot set, and `hashes_hash` — so a relay cannot splice a ciphertext onto a different slot set or a different hash claim without the on-chain MAC check failing, before any ciphertext fetch. The passphrase (`enc.passphrase`) path carries an equivalent commitment in a 32-byte header **inside the ciphertext blob**, over a transcript that adds the Argon2id salt and parameters — so tampering with the KDF inputs makes the commitment check fail. The content itself is then sealed in a segmented STREAM under a content key derived from the CEK; the per-chunk AAD is empty, because every header field is already bound to that key transitively. Full construction on [Sealed PoE](/spec/sealed-poe). ### Confirmation depth is verifier policy [#confirmation-depth-is-verifier-policy] A record is anchored the moment its transaction is included, but a verifier deciding how much settlement to require before reporting a verdict applies a **confirmation-depth threshold**. Label 309 makes this a verifier policy, **not** a normative `MUST`. The standard defines the machine-readable surface — a transaction below the threshold is reported `pending` (the typed code `INSUFFICIENT_CONFIRMATIONS`), never `failed`, and may resolve to `valid` on retry — while the threshold itself (RECOMMENDED ≥ 15 blocks) is a deployment-configured input, not a constant baked into the wire format. A verifier that requires deeper settlement for high-value claims is fully conformant. See [Verification](/spec/verification). ### Ed25519 verification is strict [#ed25519-verification-is-strict] Record-level signatures **MUST** be verified under the strict rules of [RFC 8032 §5.1.7](https://www.rfc-editor.org/rfc/rfc8032#section-5.1.7), not under the more permissive ZIP-215 batch-verification tolerance. Strict verification rejects non-canonical encodings and small-order points that a tolerant verifier would accept, so two conformant verifiers always reach the same verdict on the same signature. Implementers must confirm their Ed25519 backend is in strict mode; some libraries default to the tolerant variant. See [Signatures](/spec/signatures). ### Canonical CBOR is the determinism contract [#canonical-cbor-is-the-determinism-contract] Every record is encoded as canonical CBOR per [RFC 8949 §4.2.1](https://www.rfc-editor.org/rfc/rfc8949#section-4.2.1): preferred integer forms, definite-length arrays and maps, bytewise-sorted map keys, no duplicate keys, no tags. Determinism is what lets a signature computed over the body by one implementation verify under another, and what lets two producers of the same logical record emit byte-identical bytes. This is non-negotiable across every conformant implementation and is the foundation of the cross-language parity contract. Each construction above is part of v1 as shipped. They appear on this page because they are the decisions implementers most often double-check — not because any of them is still open. A v1 implementation builds against these exactly as written. ## Forward-looking candidates [#forward-looking-candidates] The items below are **candidates**, not shipped constructions. None is a v1 defect; none is committed. They are recorded so that an implementation built today leaves room for them as additive registry entries, and so a future reviewer can see which evolutions the algorithm-agile design already anticipates. ### A reserved alternative content-AEAD profile [#a-reserved-alternative-content-aead-profile] The v1 sealed-PoE content layer is `chacha20-poly1305-stream64k` — RFC 8439 ChaCha20-Poly1305 in the segmented STREAM layout — which is itself RFC-backed, so there is no open question about the *current* content cipher's formal status. What remains reserved is a path to a **different** content AEAD if a deployment context ever requires one. The identifier `aes-256-gcm` ([NIST SP 800-38D](https://csrc.nist.gov/pubs/sp/800/38/d/final)) is reserved in the AEAD registry for that purpose and is **not** part of `enc.scheme: 1`: a record naming it follows the unknown-envelope rule today. Introducing it would be a future `enc.scheme: 2` construction that preserves the existing `slots[]` + `slots_mac` and passphrase-commitment models but swaps the content layer, pinning the new chunk size, content-key derivation, per-chunk nonce construction, per-chunk AAD, and final-chunk authentication for that cipher. It is a **fallback profile**, not a replacement: existing `enc.scheme: 1` records remain valid, and the profile must not be implemented before a normative `enc.scheme: 2` definition and its test vectors exist. ### Reserved post-quantum signature algorithms [#reserved-post-quantum-signature-algorithms] The KEM half of the post-quantum trajectory ships in v1 (above). The **signature** half is reserved but not yet implemented. Two families are candidates to slot into the existing signature registry once [IETF COSE](https://datatracker.ietf.org/wg/cose/about/) stabilises the post-quantum algorithm identifiers: | Candidate | Family | Standard | | --------- | -------------------- | ----------------------------------------------------- | | ML-DSA-65 | Lattice (module-LWE) | [FIPS 204](https://csrc.nist.gov/pubs/fips/204/final) | | SLH-DSA | Stateless hash-based | [FIPS 205](https://csrc.nist.gov/pubs/fips/205/final) | Because the signature algorithm is a named identifier in the COSE protected header, registering a successor is purely additive: existing Ed25519 records keep verifying, and a verifier that does not recognise a new signature algorithm reports an unsupported-algorithm signal rather than rejecting the content claim. An unrecognised signature never invalidates the underlying existence proof. See [Signatures](/spec/signatures). ### A potential `v: 2` publication path [#a-potential-v-2-publication-path] Individual additions — a new KEM, a new content AEAD, a new signature algorithm — are registry entries that do not change the top-level record schema. A KEM addition in particular is a registry entry under `enc.scheme: 1`, not a scheme bump; an `enc.scheme` bump is reserved for a cross-KEM construction change (a new `slots_mac` or content AEAD that applies to every KEM at once). If enough **record-schema-changing** candidates accumulate, the cumulative change may warrant a top-level `v: 2` record published alongside v1. Both versions would remain valid metadata schemas under label 309; a verifier selects on the `v` discriminator inside the record. The path-to-standardisation steps repeat for the new revision. This is the largest-scope evolution and is triggered only by accumulation — nothing here is scheduled. ### Optional one-hop supersedence resolution [#optional-one-hop-supersedence-resolution] A record's optional `supersedes` field points at one earlier record by transaction hash. Resolving that pointer is **optional** for a verifier. A verifier that confirms `supersedes` is structurally a 32-byte hash but does not fetch the prior transaction is conformant, but it misses the chance to surface the supersedence chain. Guidance: a verifier **MAY** resolve one hop — re-query the transaction resolver for the referenced hash and report its existence and block time — without recursing further. One hop is sufficient; deeper traversal is the caller's responsibility, and supersedence never revokes the earlier record, which the chain keeps independently verifiable forever. ## Migrating a cryptographic constant [#migrating-a-cryptographic-constant] Every settled construction above depends on named constants: algorithm identifiers, HKDF info strings, KDF salts, AEAD nonce lengths, domain-separation labels. Changing the **meaning** of any one of them is a wire-format break, and the standard prescribes exactly one disciplined path for it. The governing rule: **use the smallest-scope change that resolves the issue, and never silently change the meaning of an existing constant under the same namespace.** The procedure is **additive and backward-compatible** — old records keep verifying under the constants they were published with — and proceeds by scope: 1. **Version the namespace, not the value.** A changed derivation increments the `-vN` suffix on every domain-separation string it touches: HKDF info strings (`…-v1` → `…-v2`), HMAC message prefixes, salts, and labels. A new constant lives under the new suffix; the old constant stays valid under the old suffix. Older verifiers reject newer records cleanly at the discriminator instead of misinterpreting them. 2. **Bump the discriminator that matches the blast radius.** A change confined to one slot family is selected by `enc.kem`; a cross-KEM change to the content layer or slot-set commitment is an `enc.scheme` bump; a change to the record schema itself is a top-level `v` bump. Each discriminator localises the break to the smallest layer that actually changed. 3. **Keep predecessors verifiable.** Older record versions remain readable as frozen schemas. A verifier selects the construction by the record's own version discriminators, so a single implementation can verify v1 and a future successor side by side. No record ever stops verifying because a successor shipped. Post-quantum migration is the canonical case: a new KEM or signature algorithm is a registry entry under a new namespace, selected by an on-wire discriminator, with predecessors untouched. The algorithm-agile design means the migration is more registration than rewrite — which is exactly why the X-Wing hybrid KEM could ship in v1 without disturbing the classical path. ## Related pages [#related-pages] * [Algorithm registries](/spec/algorithm-registries) — the named identifiers whose namespaces version under a migration. * [Sealed PoE](/spec/sealed-poe) — the X-Wing hybrid KEM, the key-commitment transcript binding, and the `enc.scheme` / `enc.kem` discriminators. * [Signatures](/spec/signatures) — strict Ed25519 verification and the signature registry the reserved post-quantum algorithms would join. # Sealed PoE (/spec/sealed-poe) 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](/spec/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](/spec/the-record). This is not [RFC 9180](https://www.rfc-editor.org/rfc/rfc9180) 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](https://age-encryption.org/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](https://github.com/C2SP/C2SP/blob/main/age.md), not against HPKE's analysis. ## The model and its privacy properties [#the-model-and-its-privacy-properties] A sender wants to publish a permanent, timestamped commitment proving that a specific plaintext was sealed for a specific audience at time T — while ensuring only that audience can read it. A hash-only PoE gives the time claim but no audience binding; a PoE over open ciphertext gives no confidentiality at all. Sealed PoE bridges the two: the record commits to the plaintext hash (public, timestamped) and carries the key-delivery material in `enc`, while the ciphertext at the `ar://` or `ipfs://` URI is undecryptable without a matching unlock secret. The construction is deliberately designed so the chain leaks as little as possible about the message and nothing about its audience: * **The plaintext is never on chain.** Only its hash and the wrapped keys are. Anyone who later obtains the plaintext can prove "this exact plaintext was committed at block time T"; nobody else learns what was sealed. * **Recipient public keys are never on chain.** A recipient's public key does not appear anywhere in `enc`. A recipient recognises a message as theirs only by **successfully trial-decrypting** a slot — there is no addressee field to read. An observer with no candidate keys learns only the slot count, the KEM family (`enc.kem`), and the sealed-vs-open distinction. The stronger property — that an adversary *holding* candidate recipient keys still cannot test which (if any) a slot targets — is **key-privacy**, claimed only for the classical `x25519` path; it is not claimed for the hybrid `mlkem768x25519` path (see [Anonymity and the per-KEM split](#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](/spec/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-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](#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](#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 [#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: ```text title="CBOR" content_key = HKDF-SHA-256(ikm = CEK, salt = enc.nonce, info = <"cardano-poe-payload-v1" on the slots path, "cardano-poe-payload-passphrase-v1" on passphrase>, L = 32) ``` The content is then sealed in a **segmented STREAM**, named by the content-format identifier [`chacha20-poly1305-stream64k`](/spec/algorithm-registries). This is the STREAM layout of the [age v1 specification](https://github.com/C2SP/C2SP/blob/main/age.md): ChaCha20-Poly1305 ([RFC 8439](https://www.rfc-editor.org/rfc/rfc8439), 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: ```text title="CBOR" cipher : ChaCha20-Poly1305 (RFC 8439; 12-byte nonce, 16-byte tag) CHUNK_SIZE : 65536 plaintext bytes per non-final chunk chunk nonce : uint88_be(counter) || final_flag ; 12 bytes counter starts at 0, +1 per chunk; final_flag = 0x01 on the final chunk, 0x00 otherwise per-chunk AAD: empty final chunk : 0 to 65536 plaintext bytes; every non-final chunk is exactly 65536 empty input : exactly one final chunk of zero-length plaintext (a lone 16-byte tag) ciphertext = seal(chunk_0) || seal(chunk_1) || ... || seal(chunk_final) ; each sealed chunk = plaintext length + 16 bytes ``` The **final flag** domain-separates the last chunk from the rest, which is what makes truncation detectable: a stream whose last chunk does not carry the `0x01` flag, a `0x01` flag on a chunk that is not last, data following the final chunk, or a non-final chunk shorter than `CHUNK_SIZE` MUST all fail decryption (`TAMPERED_CIPHERTEXT`). Because every sealed chunk is at least its 16-byte tag, the layout also implies a structural floor — a well-formed slots-path ciphertext blob is never shorter than 16 bytes, the lone tag of an empty final chunk. The **per-chunk AAD is empty by design**: all context is bound to the content *transitively*. The content key derives from the CEK, and the CEK is committed to the full header by `slots_mac` on the slots path (whose transcript covers `scheme`, `path`, `aead`, `kem`, `nonce`, the slot set, and the item's hash claim) or by the in-ciphertext commitment on the passphrase path. Flip any header field and the recipient derives or accepts a different key, so decryption fails; a per-chunk AAD would re-bind the same context on every chunk without adding security. The counter-based chunk nonces are safe because the content key is **single-use**: it derives from a fresh CEK salted by the envelope-unique `enc.nonce`, so no two streams ever share a `(key, nonce)` pair and stateless producers — browser tabs, CLI runs, workers, retries — never coordinate nonces across envelopes. The 88-bit counter admits `2^88` chunks, far above any realisable payload, so the format imposes **no cryptographic payload ceiling**; a practical maximum is a deployment denial-of-service policy, not a wire constant. The plaintext input is the exact original content bytes. The construction does **not** prepend, append, or encrypt any filename, MIME type, size field, or manifest — the stream decrypts back to those bytes and only those bytes. 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): ```text title="CBOR" slots path : ciphertext blob = [ STREAM chunks ] passphrase path : ciphertext blob = [ commitment: 32 bytes ] || [ STREAM chunks ] ``` The plaintext hash in `items[].hashes` **always commits to the plaintext**, even when `enc` is present. This is the load-bearing property: a verifier who cannot decrypt can still confirm the record exists, its envelope is well-formed, and the URI is fetchable — but only a holder of a matching recipient key can decrypt the ciphertext and confirm *what* the commitment is to by recomputing the hash. The validator therefore **MUST NOT** decrypt to "verify" hashes; plaintext-hash verification happens at the recipient, after the bytes are recovered. See [Content and hashing](/spec/content-and-hashing) and [Verification](/spec/verification). ## Slots and the slot-set MAC [#slots-and-the-slot-set-mac] On the multi-recipient path, `enc.slots` is a non-empty array of per-recipient slots. Every slot wraps the **same** CEK under a per-recipient key-encryption key (KEK); a recipient who opens any slot recovers the one CEK that decrypts the content. The sender: 1. Selects one KEM for the whole record and generates the CEK (32 random bytes) and `nonce` (24 random bytes). 2. For each recipient, derives a per-slot KEK and wraps the CEK under it (per-KEM details below). 3. **Shuffles** the slot array with a CSPRNG (unbiased Fisher-Yates). 4. Builds the slots transcript over the shuffled array, the cross-KEM header fields, and the item's hash claim, hashes it to `slots_hash`, and computes `slots_mac` as a CEK-keyed HMAC over that hash. 5. Derives the content key from the CEK and `enc.nonce`, and seals the content in the segmented STREAM above. ### The per-slot wrap [#the-per-slot-wrap] Each slot wraps the CEK with **ChaCha20-Poly1305** ([RFC 8439](https://www.rfc-editor.org/rfc/rfc8439), the 12-byte-nonce variant) under the per-slot KEK, producing a 48-byte `wrap` (32-byte CEK ciphertext + 16-byte Poly1305 tag): ```text title="CBOR" wrap = ChaCha20-Poly1305_seal( key = KEK, ; per-slot, 32 bytes nonce = bytes(12, 0x00), ; ZERO nonce ad = , ; 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 [#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. ```text title="CBOR" hashes_hash = SHA-256("cardano-poe-item-hashes-v1" || canonicalEncode(item.hashes)) ; 32 bytes SLOTS_TRANSCRIPT = { ; closed 7-key map; keys are a set, not an order "scheme": 1, ; uint "path": "slots", ; text "aead": , ; text: the content-format identifier "kem": , ; "x25519" | "mlkem768x25519" "nonce": , ; bytes(24) "slots": , ; the shuffled on-wire slot array "hashes_hash": hashes_hash} ; bytes(32), over this item's hashes map slots_hash = SHA-256("cardano-poe-slots-transcript-v1" || canonicalEncode(SLOTS_TRANSCRIPT)) ; 32 bytes HMAC_KEY = HKDF-SHA-256(ikm = CEK, salt = "", info = "cardano-poe-slots-mac-v1", L = 32) slots_mac = HMAC-SHA-256(key = HMAC_KEY, msg = slots_hash) ; 32 bytes ``` `SLOTS_TRANSCRIPT` is a closed map carrying exactly that seven-key set, serialised with `canonicalEncode` so both sides produce byte-identical bytes; its key order is the RFC 8949 §4.2.1 bytewise sort, never hand-arranged. The `slots` value is the shuffled array of closed slot maps exactly as they appear on the wire (`{epk, wrap}` for `x25519`, `{kem_ct, wrap}` for `mlkem768x25519`), so the full per-slot wire content of every slot is inside the transcript. The transcript **additionally** pins `scheme`, `path`, `aead`, `kem`, and `nonce`: a relay that flips any of those header fields while leaving the slot shapes valid produces a different `slots_hash`, so the MAC fails. The `slots_hash` and `hashes_hash` SHA-256 prefixes (`cardano-poe-slots-transcript-v1`, `cardano-poe-item-hashes-v1`) are exact ASCII with no terminator and no length prefix. `hashes_hash` is what binds the envelope to **this item's hash claim**: it is a labelled SHA-256 over the `canonicalEncode` of the item's complete `hashes` map. Because the recipient recomputes `slots_mac` from on-chain bytes alone, a MAC match confirms the envelope was sealed for this exact claim — an envelope spliced onto an item with a different `hashes` map fails the on-chain match step, before any ciphertext fetch. The item's `uris[]` are deliberately **not** bound, so ciphertext may be re-hosted at a new content-addressed URI without invalidating the envelope; a sender for whom the URI list is part of the claim binds it with a record-level signature instead. In the `HMAC_KEY` derivation `salt = ""` is a **zero-length octet string**, the absent-salt convention of [RFC 5869 §2.2](https://www.rfc-editor.org/rfc/rfc5869#section-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](/spec/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-content-layer)). ## The two KEMs [#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`](#hybrid-mlkem768x25519-x-wing) 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](https://www.rfc-editor.org/rfc/rfc9180)) 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](/spec/keys)) reflects that the two hybrid encodings are independent. ### Classical: `x25519` [#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](https://www.rfc-editor.org/rfc/rfc5869)) under a labelled-hash salt: ```text title="CBOR" shared = X25519(priv_epk, pub_R) ; per RFC 7748; reject all-zero output kek_salt = SHA-256("cardano-poe-x25519-kek-salt-v1" || enc.nonce || pub_epk || pub_R) ; 32 bytes KEK = HKDF-SHA-256(ikm = shared, salt = kek_salt, ; binds nonce, ephemeral, recipient info = "cardano-poe-kek-v1", L = 32) slot = { "epk": pub_epk, "wrap": wrap } ; epk = 32 bytes ``` The 32-byte ephemeral public key `epk` is the only key material on the wire; the recipient public key is never published. The salt is a labelled SHA-256 binding three values: `pub_epk` makes every slot's KEK unique, `pub_R` binds it to the specific recipient (defeating any attempt to repurpose an `epk` against a different recipient), and the envelope-unique `enc.nonce` anchors the KEK to one envelope — so a CSPRNG failure that repeated KEM randomness across two envelopes degrades only to cross-envelope linkability, never to a repeated `(KEK, zero-nonce)` wrap pair. X25519 implementations MUST reject the all-zero shared secret per [RFC 7748 §6.1](https://www.rfc-editor.org/rfc/rfc7748#section-6.1); mainstream libraries do this transitively. ### Hybrid: `mlkem768x25519` (X-Wing) [#hybrid-mlkem768x25519-x-wing] The hybrid KEM is the X-Wing construction ([draft-connolly-cfrg-xwing-kem-10](https://datatracker.ietf.org/doc/draft-connolly-cfrg-xwing-kem/10/)), combining ML-KEM-768 ([FIPS 203](https://csrc.nist.gov/pubs/fips/203/final)) 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: ```text title="CBOR" enc = XWing.Encapsulate(pub_R) ; named fields — MUST NOT consume positional order kem_ct = enc.ct ; 1120 bytes shared = enc.ss ; 32 bytes kek_salt = SHA-256("cardano-poe-xwing-kek-salt-v1" || enc.nonce || kem_ct || pub_R) ; 32 bytes KEK = HKDF-SHA-256(ikm = shared, salt = kek_salt, ; binds nonce, kem_ct, recipient info = "cardano-poe-kek-mlkem768x25519-v1", L = 32) wrap = ChaCha20-Poly1305_seal(key = KEK, nonce = zeros(12), ad = "cardano-poe-kek-mlkem768x25519-v1", plaintext = CEK) slot = { "kem_ct": kem_ct, "wrap": wrap } ; kem_ct = single 1120-byte byte string ``` X-Wing key and ciphertext sizes: | 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](/spec/the-record)), never an individual field. ### One KEM per 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` (`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 [#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](/spec/keys)). They do not know in advance which slot, if any, is theirs, so they **trial-decrypt** the array. Two properties shape the loop: the slot-set MAC check is folded **in** (a slot is accepted only when its candidate CEK also reproduces the on-wire `slots_mac`), and the loop runs over **all** slots with no early break, selecting the match in constant time so a timing observer cannot infer which slot index matched. Before any KEM or AEAD primitive is invoked, the verifier MUST run the structural shape checks (the partitioning-oracle defence): `scheme == 1`, `aead`/`kem` registered, `nonce` 24 bytes, `slots_mac` 32 bytes, `slots` non-empty, the recipient secret 32 bytes, every `slot.wrap` exactly 48 bytes, each `x25519` `epk` exactly 32 bytes with no `kem_ct`, each `mlkem768x25519` `kem_ct` exactly 1120 bytes with no `epk`, and the within-`slots` distinctness of all encapsulation material (else `ENC_SLOTS_DUPLICATE_KEM_MATERIAL`). In the same pre-primitive pass the verifier MUST also bound parser resource use: the reference bounds are `MAX_SLOTS = 1024` slots and `65536` bytes for the decoded `enc` envelope. Both sit far above the ≈ 16 KiB Cardano transaction-metadata ceiling that bounds an honest record, so a record exceeding either is malformed and is rejected here — `ENC_SLOTS_TOO_MANY` for too many slots, `ENC_ENVELOPE_TOO_LARGE` for an oversized envelope — before any KEM or AEAD primitive runs. These bounds are verifier-enforced, deployment-pinned constants, not wire fields; a deployment MAY tighten them. ``` ; hashes_hash, SLOTS_TRANSCRIPT and slots_hash are recomputed once, before the loop, and held constant: hashes_hash = SHA-256("cardano-poe-item-hashes-v1" || canonicalEncode(item.hashes)) slots_hash = SHA-256("cardano-poe-slots-transcript-v1" || canonicalEncode(SLOTS_TRANSCRIPT)) if kem == "x25519": pub_R = x25519_publicKey(priv_R) ; recipient public key, 32 B else: pub_R = XWing.publicKey(priv_R) ; recipient X-Wing public key, 1216 B found = false cek_conflict = false selected_CEK = zeros(32) for slot in enc.slots: ; iterate ALL slots — no early break kem_ok = true if kem == "x25519": shared = x25519(priv_R, slot.epk) kem_ok = NOT constant_time_eq(shared, zeros(32)) ; explicit all-zero reject, secret-independent kek_salt = SHA-256("cardano-poe-x25519-kek-salt-v1" || enc.nonce || slot.epk || pub_R) real_KEK = HKDF-SHA-256(shared, salt = kek_salt, info = "cardano-poe-kek-v1", L = 32) dummy_KEK = HKDF-SHA-256(zeros(32), salt = kek_salt, info = "cardano-poe-kek-v1", L = 32) KEK = ct_select(kem_ok, real_KEK, dummy_KEK) ; constant-time, no early exit ad_wrap = "cardano-poe-kek-v1" else: ; mlkem768x25519 shared = XWing.Decapsulate(sk=priv_R, ct=slot.kem_ct) ; pinned API writes Decapsulate(ct, sk) kek_salt = SHA-256("cardano-poe-xwing-kek-salt-v1" || enc.nonce || slot.kem_ct || pub_R) KEK = HKDF-SHA-256(shared, salt = kek_salt, info = "cardano-poe-kek-mlkem768x25519-v1", L = 32) ad_wrap = "cardano-poe-kek-mlkem768x25519-v1" open_ok, candidate_CEK = ChaCha20-Poly1305_open_or_dummy(KEK, zeros(12), ad_wrap, slot.wrap) HMAC_KEY = HKDF-SHA-256(candidate_CEK, salt = "", info = "cardano-poe-slots-mac-v1", L = 32) mac_ok = constant_time_eq(HMAC-SHA-256(HMAC_KEY, slots_hash), enc.slots_mac) ok = kem_ok AND open_ok AND mac_ok ; kem_ok folded into acceptance first = ok AND NOT found ; first matching slot cek_conflict = cek_conflict OR (ok AND found AND NOT constant_time_eq(candidate_CEK, selected_CEK)) selected_CEK = ct_select(first, candidate_CEK, selected_CEK) ; constant-time found = found OR ok if NOT found: reject (single generic failure) ; WRONG_RECIPIENT_KEY / TAMPERED_HEADER if cek_conflict: reject (single generic failure) ; cek_conflict content_key = HKDF-SHA-256(selected_CEK, salt = enc.nonce, info = "cardano-poe-payload-v1", L = 32) plaintext = STREAM_open(content_key, ciphertext) ; per-chunk authenticated release if STREAM_open fails at any chunk: reject (single generic failure) ; TAMPERED_CIPHERTEXT ``` The KEK derivation branches on `enc.kem`: for `x25519` the recipient performs an ECDH against `slot.epk` and re-derives the same labelled salt over `enc.nonce || slot.epk || pub_R`; for `mlkem768x25519` it X-Wing-decapsulates `slot.kem_ct` directly (a single 1120-byte byte string) and recomputes the same labelled salt over `enc.nonce || slot.kem_ct || pub_R`, where `pub_R` is its own 1216-byte X-Wing public key derived from the held seed. The X25519 all-zero shared-secret rejection is **explicit** here rather than relied upon transitively: a slot crafted to drive the shared secret to `zeros(32)` ([RFC 7748 §6.1](https://www.rfc-editor.org/rfc/rfc7748#section-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. 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](#one-kem-per-record) 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](#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. 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 [#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](https://www.rfc-editor.org/rfc/rfc9106)) 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. ```text title="CBOR" passphrase_bytes = utf8(normalize(passphrase)) ; cardano-poe-pw-norm-v1 (see below) CEK = argon2id(passphrase_bytes, salt = enc.passphrase.salt, ; 16–64 bytes, on chain params = enc.passphrase.params, ; { m, t, p }, on chain L = 32) hashes_hash = SHA-256("cardano-poe-item-hashes-v1" || canonicalEncode(item.hashes)) PASSPHRASE_TRANSCRIPT = { ; closed 6-key map; keys are a set, not an order "scheme": 1, ; uint "path": "passphrase", ; text "aead": , ; text: the content-format identifier "nonce": , ; bytes(24) "hashes_hash": hashes_hash, ; bytes(32), over this item's hashes "passphrase": { ; closed sub-map "alg": "argon2id", ; text "salt": enc.passphrase.salt, ; bytes "params": { "m": m, "t": t, "p": p }, ; closed map of uints "normalization": "cardano-poe-pw-norm-v1"}} ; scheme-fixed constant, NOT on the wire pw_hash = SHA-256("cardano-poe-passphrase-transcript-v1" || canonicalEncode(PASSPHRASE_TRANSCRIPT)) PW_MAC_KEY = HKDF-SHA-256(ikm = CEK, salt = "", info = "cardano-poe-passphrase-mac-v1", L = 32) commitment = HMAC-SHA-256(key = PW_MAC_KEY, msg = pw_hash) ; 32 bytes content_key = HKDF-SHA-256(ikm = CEK, salt = enc.nonce, info = "cardano-poe-payload-passphrase-v1", L = 32) ciphertext blob = commitment || STREAM chunks ; STREAM under content_key, as on the slots path ``` The `enc.passphrase` block on the wire is `{ alg, salt, params }` — it names the KDF (`"argon2id"`), the salt, and the parameters. Label 309 fixes a **parameter floor** of `m ≥ 65536` KiB (64 MiB), `t ≥ 3`, `p ≥ 1`; the producer chooses values at or above the floor and the salt is 16–64 bytes inclusive (the 64-byte ceiling is the metadatum byte-string cap). Where the platform supports it, producers SHOULD use `p = 4` (the second recommended profile of [RFC 9106 §4](https://www.rfc-editor.org/rfc/rfc9106#section-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. 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-profile] The normalization applied to the passphrase before Argon2id is the fixed profile `cardano-poe-pw-norm-v1`. It is normative: two implementations MUST derive a byte-identical CEK from the same passphrase, and the only way to guarantee that is a pinned normalization. The profile, applied in order, is: 1. **Reject unassigned codepoints.** A passphrase containing any codepoint **unassigned in Unicode 16.0** is rejected with `ENC_PASSPHRASE_UNNORMALIZABLE` before any normalization step runs. 2. **NFKC.** Apply Normalization Form KC per [UAX #15](https://www.unicode.org/reports/tr15/) under **Unicode 16.0**. 3. **Whitespace.** Define "whitespace" as every character carrying the Unicode `White_Space` property under **Unicode 16.0**, and collapse every maximal run of such characters to a single U+0020 SPACE. 4. **Trim.** Remove leading and trailing whitespace. 5. **Reject empty.** If the result is the empty string, reject with `ENC_PASSPHRASE_EMPTY`: a whitespace-only or otherwise vacuous passphrase normalizes to zero bytes, which Argon2id would silently accept — keying the record to a CEK any party can derive. 6. **Encode.** Encode the result as UTF-8; those bytes are the Argon2id password input. Step 1 is what makes the profile deterministic across implementations and across time. The [Unicode Normalization Stability Policy](https://www.unicode.org/policies/stability_policy.html) 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`. 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 [#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 [#anonymity-and-the-per-kem-split] When a sealed-PoE record carries **no `sigs`**, its on-wire bytes are independent of the sender's identity: each slot carries only per-record, per-slot ephemeral KEM material (the X25519 ephemeral in `slot.epk`, or the X-Wing ciphertext in `slot.kem_ct`), the sender's long-term keys never appear, the slots are CSPRNG-shuffled, no recipient public key is on the wire, and no descriptive field (filename, MIME type, size) is present. An unsigned sealed record therefore binds no sender identity on chain — exactly what whistleblower drops, sealed-bid auctions, and evidence escrow require. For **both** KEMs the honest leakages are identical and unavoidable: the **slot count**, the **sealed-vs-open** distinction, and the **classical-vs-hybrid** KEM family (`enc.kem`) are visible to any observer; nothing more about the recipients is. The **stronger** claim — that an adversary who *holds a set of candidate recipient public keys* cannot test whether a given slot is addressed to one of them (**key-privacy / recipient anonymity**) — is a per-KEM property: * **`x25519` — key-private.** The per-slot encapsulation is a fresh ephemeral public key statistically independent of the recipient key. An adversary holding candidate recipient public keys cannot, from `slot.epk` and `slot.wrap` alone, decide which candidate (if any) the slot targets without the matching private key. The classical path is therefore key-private, which also gives cross-record unlinkability: two sealed PoEs to the same recipient look like unrelated `epk`/`wrap` blobs. * **`mlkem768x25519` — not claimed.** Recipient anonymity against an adversary holding candidate recipient keys is a **separate property not implied by the IND-CCA security** of the hybrid KEM. Label 309 does **not** claim it for the X-Wing path unless and until it is independently justified for X-Wing. A deployment whose threat model requires recipient anonymity against a key-holding adversary MUST NOT rely on the hybrid path for that property. Senders concerned about timing-correlation across records MUST batch publishes off the critical timeline; wire-level cryptography cannot solve metadata-timing attacks. The 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](https://www.rfc-editor.org/rfc/rfc9771) — 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 [#forbidden-patterns] A conformant implementation MUST NOT: * **Reuse a per-slot ephemeral** across slots or records, or otherwise let a KEK repeat — the zero-nonce wrap depends on per-slot KEK uniqueness. * **Reuse a CEK across envelopes** — a fresh CSPRNG CEK per `enc`-bearing item, within a record and across records alike. * **Reuse a passphrase salt** — generate a fresh CSPRNG `enc.passphrase.salt` for every passphrase envelope; the salt is the sole cross-record separator for a reused passphrase. * **Mix KEMs** within one `slots` array (one `enc.kem` per record). * **Publish slots in input order** — the CSPRNG shuffle is required. * **Wrap the CEK with any nonce other than the 12-byte zero nonce, or with empty wrap-AEAD AAD** — the wrap AAD is the KEM's `info` label literal. * **Put a recipient public key on the wire** — the trial-decrypt design is the privacy feature; publishing pubkeys defeats it. * **Skip the `slots_mac` verification** — without it, slot-substitution succeeds. * **Store the plaintext at the `ar://`/`ipfs://` URI** — only the ciphertext is published; the plaintext is delivered out of band or held by the sender. * **Reference ciphertext through any scheme other than `ar://` or `ipfs://`** — the content-addressed schemes bind the URI to the bytes; a host-served URL would require a separate on-chain ciphertext commitment that sealed PoE does not carry. * **Log or persist** the CEK, any KEK, the slot-set HMAC key, the passphrase MAC key, the content key, an ECDH shared secret, an ephemeral private key, or a recipient private key. ## Related pages [#related-pages] * [Keys](/spec/keys) — the seed-derived X25519 and X-Wing keypairs that supply the recipient and sender key material. * [The record](/spec/the-record) — where `enc` sits in the record map and the whole-body transport that carries the record on chain. * [Algorithm registries](/spec/algorithm-registries) — the `enc.aead`, `enc.kem`, and passphrase-KDF identifiers and their backing primitives. * [Content and hashing](/spec/content-and-hashing) — the plaintext-hash commitment that every sealed record carries. * [Verification](/spec/verification) — the validation pipeline, why the validator never decrypts, and the error catalogue. # Security model (/spec/security-model) Label 309's security rests on one idea: a proof is something the relying party checks for itself. A verifier trusts the public Cardano chain and — for content claims — the bytes it is handed. It trusts **nothing else**: not the publisher, not a domain, not a server, not the operator of whatever gateway it happened to read the transaction through. Every guarantee on this page either follows from that stance, marks its boundary, or names a residual risk that lies outside it. This page is the threat model for the standard itself: the trust model a conformant verifier operates under, the privacy properties of an unsigned sealed PoE, the cryptographic obligations every implementation **MUST** uphold, and the limits the wire format does not — and cannot — overcome. It does not describe any particular deployment's operational security; it describes what the format guarantees to anyone who implements it correctly. ## The trust model [#the-trust-model] A relying party that checks a Label 309 proof is asking a narrow question: *did these bytes exist on or before this block's time, and does this verdict depend on anyone's good behaviour besides Cardano's?* The standard is built so the answer to the second half is always "no". There is **no issuer-controlled intermediary** at any step: the chain gateway and the storage gateway a verifier consults are untrusted data sources, verified cryptographically where possible and cross-checked for inclusion and finality. What a verifier trusts: * **The public Cardano chain.** The transaction, its metadata under label 309, and its block time are facts replicated across every Cardano node. A verifier reads them through a gateway of its own choosing and may cross-check several. * **The content bytes, when checking a content claim.** A digest in the record commits to a specific byte sequence. A verifier that holds those bytes recomputes the hash and compares; it does not take anyone's word that the bytes match. What a verifier does **not** trust: * **The publisher.** Label 309 has no `issuer` field. Any wallet can publish any record; the record's validity is independent of who submitted it. A verifier never asks "is this publisher reputable?" — the question has no place in the model. See the issuer-agnostic invariant on [Introduction](/spec/). * **A server or domain.** No step of verification contacts a publisher-operated service. There is no canonical registry to consult, no status endpoint to poll, no identity authority to resolve a key against. * **A gateway operator.** The Cardano, Arweave, and IPFS gateways a verifier reads through are interchangeable and content-checked (below). A gateway is a transport, not a trust root. A Label 309 proof must remain checkable even if every party who created it disappears — the publisher, their server, their company. Given a transaction reference, a public Cardano gateway, and (for content claims) the bytes, any conformant implementation reaches the same verdict from the public chain alone. If a verification path silently needs a specific operator's cooperation, that path is non-conformant. ### Why gateways are not a trust root [#why-gateways-are-not-a-trust-root] A verifier may read the same transaction through any Cardano gateway, and read the same content through any Arweave or IPFS gateway. None of these is trusted to return honest data, because what they return is independently checkable: * A gateway cannot forge a record that carries a valid **signature** — it does not hold the signer's private key, and the verifier recomputes the signed bytes and checks them itself. See [Signatures](/spec/signatures). * A gateway cannot substitute **content** under a content-addressed URI without detection: an `ipfs://` CID is a multihash the verifier recomputes from the fetched bytes, and an `ar://` transaction id commits to the data under Arweave consensus. On a conformant item, the on-chain `hashes` map is a second, independent binding to the plaintext, recomputed at fetch time. * A gateway that **withholds** a transaction or **misreports** how deeply it is buried is a denial-of-service shape, not a forgery shape. A verifier that reads through more than one gateway and aborts on disagreement turns "one dishonest gateway" into "collusion across every gateway it chose" — which it controls. Block depth — how many confirmations a verifier demands before treating a record as settled — is **verifier policy**, not a value Label 309 mandates. The standard fixes no numeric depth; a relying party sets a threshold for its own risk tolerance. See [Verification](/spec/verification). ## Privacy properties of sealed PoE [#privacy-properties-of-sealed-poe] A [sealed PoE](/spec/sealed-poe) keeps a plaintext confidential while still anchoring its timestamp. Beyond confidentiality, the construction is designed so the chain leaks as little as possible about the message and **nothing** about its audience. These are wire-format guarantees: they hold for any correct implementation, because they are properties of the bytes, not of any deployment. ### Sender anonymity for unsigned sealed PoE [#sender-anonymity-for-unsigned-sealed-poe] > An unsigned sealed PoE — a record carrying `enc` and **no** `sigs` — **MUST NOT** > disclose the sender's identity on chain. Its on-wire bytes **MUST** be > independent of the sender's long-term keys. This holds structurally: * **No signer key on the wire.** A sender's identity key reaches the chain only through a `sigs` entry. A record with no `sigs` carries no signer-side byte at all. Authorship is opt-in; anonymity is the default. The sender's long-term X25519 and Ed25519 keys never appear in an unsigned record. * **Fresh per-slot encapsulation.** Each recipient slot carries per-record, per-slot KEM material, freshly generated at seal time — the X25519 ephemeral public key in `slot.epk` on the `x25519` path, or the X-Wing ciphertext in `slot.kem_ct` on the `mlkem768x25519` path. It is unrelated to the sender's long-term key and reveals nothing that links records to a common author. * **Shuffled slots.** The slot array is shuffled with a CSPRNG before publication, so positional ordering ("primary recipient first") carries no signal. A producer who later signs a record discloses their identity key in *that* record only; it appears in no earlier unsigned record, and no operation maps the earlier records' per-slot KEM material back to the author. The identity X25519 and Ed25519 keys derive from the master seed via *different* HKDF context strings, and an Ed25519 public key cannot derive or match an X25519 public key, so signing one record never links the unsigned ones. Opting into authorship is a forward act, not a retroactive deanonymisation. This invariant covers only the label-309 record bytes. The fee-paying Cardano address in the transaction inputs, the producer's network-level identity as seen by a gateway, and any metadata in the off-chain ciphertext blob are **outside** the wire format — Label 309 lives in the metadata, not in the transaction inputs, and cannot defend against fee-payer correlation. A producer who needs fee-payer anonymity must address it at the transaction-construction layer. ### Recipient unlinkability [#recipient-unlinkability] A sealed record never names its recipients. A recipient recognises a message as theirs only by successfully trial-decrypting a slot; there is no addressee field to read. Two distinct guarantees follow, and they must not be conflated. The first holds for **both** KEMs and for any observer: * **Recipients cannot see each other.** Each slot is an independently wrapped key. A recipient who opens their own slot derives nothing about any other slot and cannot tell who else was addressed. * **No recipient public keys on the wire.** Only the per-slot encapsulation (`epk` or `kem_ct`) and the wrapped key appear. An observer with no candidate keys cannot enumerate recipients — it learns only the slot count, the KEM family (`enc.kem`), and the sealed-versus-open distinction. The second — **recipient anonymity against an adversary who already holds a set of candidate recipient public keys** and wants to test whether a slot is addressed to one of them — is a stronger, KEM-specific property, and the standard claims it **only for the classical path**: * **`x25519` is key-private.** The per-slot `epk` is a fresh ephemeral public key statistically independent of the recipient key. From `epk` and `wrap` alone, a key-holding adversary cannot decide which candidate (if any) a slot targets without the matching private key. * **`mlkem768x25519` does not claim it.** Recipient anonymity against a key-holding adversary is a separate property **not implied by** the IND-CCA security of the hybrid KEM, and is not claimed 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. The honest leakages above — slot count, KEM family, sealed-versus-open — are identical for both KEMs; nothing more about the recipients is exposed on either path. ### Plaintext-binding [#plaintext-binding] The on-chain digest commits to the **plaintext**, not to the ciphertext. A recipient who decrypts a sealed PoE recomputes the plaintext hash and compares it to the on-chain `hashes` map. This means a storage layer that serves a tampered ciphertext is caught after decryption, independent of the URI's own integrity model — the timestamp claim is a claim about the plaintext the sender committed to, and nothing the gateway does can satisfy it with different bytes. ### Rebroadcast independence [#rebroadcast-independence] A record's signatures are fixed at the moment its transaction is submitted. The chain has no notion of an "extended signer set" accumulating across transactions. > Rebroadcasting an identical record body in a separate transaction with a > different `sigs` set creates a **separate, independent** record at its own block > time — never an extension of the original. A party who wants to endorse an already-published record publishes their **own** record — referencing the same content, or pointing at the prior transaction via `supersedes` — signed by their key. They do not append a witness to someone else's record. Consequently, an attacker who copies a signed record's bytes into a fresh transaction does not become a co-signer of the original: they produce a later, duplicate claim with whatever `sigs` they chose, and the original's earlier block time remains the canonical record of existence. Verifiers and indexers **MUST NOT** collapse two transactions with identical record bodies into a single "merged signers" view; doing so would falsify the per-record block-time semantics downstream consumers rely on. ## Normative rules for implementations [#normative-rules-for-implementations] The following are security-normative for **any** conformant implementation, regardless of language or platform. [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119) / [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174) keywords are used in the normative sense. | Rule | Obligation | | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **No novel cryptography** | Every primitive is a documented IETF / NIST / W3C standard from an audited library. No hand-rolled ciphers, KDFs, signature schemes, or stanzas. | | **Authenticated encryption only** | All symmetric encryption is AEAD. A tag-verification failure **MUST** raise a typed error; it **MUST NOT** silently return partial plaintext. | | **Constant-time secret compares** | Compare MAC tags, AEAD tags, and any secret-derived bytes in constant time. A data-dependent compare on these surfaces is an authentication oracle. | | **Strict Ed25519 verification** | Verify signatures in canonical (strict) mode. A cofactored / lax verifier accepts edge-case signatures a strict verifier rejects and disagrees with the reference. | | **Never log secrets or plaintext** | Key material, derived keys, and decrypted plaintext **MUST NOT** appear in logs at any level. Byte-typed values are redacted unconditionally. | ### No novel cryptography [#no-novel-cryptography] Label 309 deliberately admits only well-analysed primitives, each named in the [algorithm registries](/spec/algorithm-registries) and implemented by an audited library. An implementation **MUST NOT** introduce a custom symmetric cipher, KDF, signature construction, or key-wrapping stanza. The wire format is a composition of standard parts precisely so its security reduces to the security of those parts. ### Authenticated encryption only [#authenticated-encryption-only] Every symmetric encryption in Label 309 — the content layer and the per-slot key wrap — is AEAD. There is no unauthenticated cipher mode anywhere in the format. A decryptor whose AEAD tag check fails **MUST** surface a structured error with a stable code discriminator and **MUST NOT** fall through to return unauthenticated bytes. The additional authenticated data is part of the tag computation, so a wrong AAD fails identically to a wrong key — there is no partial-success state to probe. ### Constant-time comparison of secrets [#constant-time-comparison-of-secrets] A comparison loop whose running time depends on secret bytes is an oracle. Every equality check over a secret or a MAC/AEAD tag **MUST** be constant-time: * The slot-set MAC and any HMAC tag — a per-byte timing leak on a 32-byte MAC can, under adversarial repetition, recover the tag. * AEAD tag verification — the underlying library's `decrypt` already returns failure without revealing which byte mismatched; implementations **MUST NOT** re-implement this check. * Signature verification — use the library's `verify`, never a hand-rolled equation. A bare `==` / `===` on byte arrays touching any of these surfaces is a defect. Routing every such comparison through a single typed helper makes the property auditable. Constant-time discipline extends to the **shape of the trial-decrypt loop**, not just to individual comparisons. To preserve the hidden-recipient property, a recipient verifier **MUST** process **all** slots within a single private key's pass — a constant number of slot operations per key, with no early break on the first match — so a network-level observer with timing access cannot infer which slot index a key unwrapped, or whether any did. The candidate key is selected in constant time. The validity of the X25519 share is captured by a secret-independent bit, `kem_ok = NOT constantTimeEqual(shared, 0^32)`, computed with a constant-time compare rather than an early branch; the KEK is then a constant-time select between the real KEK and a dummy KEK derived from an all-zero shared secret under the same salt and info, and `kem_ok` is folded into the slot's acceptance (`ok = kem_ok AND open_ok AND mac_ok`) so an invalid-ECDH slot can never be accepted while the loop still performs identical work. The slot array is published in CSPRNG-shuffled order, so wire position already carries no "primary recipient first" signal; combined with the constant-time pass, an observer learns only the slot count. A recipient holding several private keys (for example archived keys across an identity rotation) iterates key × slot and **MAY** short-circuit across keys — that leaks only the weak "which key matched" signal — but **MUST** stay constant-time across the slots of any single key, re-deriving the recipient-key half of the KEK salt per key, since both KEMs commit the salt to that key's **canonical wire encoding** (exactly the 32-byte X25519 public key, or exactly the pinned 1216-byte X-Wing public-key bytes — never a non-canonical re-encoding). ### Strict Ed25519 verification [#strict-ed25519-verification] Label 309 signatures **MUST** be verified in strict (canonical) mode. A lax, cofactored verifier accepts certain malleable or low-order-key signatures that a strict verifier rejects, which would let two conformant implementations disagree on the same record — breaking the single-verdict property the whole standard depends on. The cross-language conformance corpus pins a low-order test vector exactly to catch a verifier that has slipped into lax mode. See [Signatures](/spec/signatures). ### Never log secret material [#never-log-secret-material] Seed bytes, derived private keys, key-encryption keys, content-encryption keys, passphrase-derived material, and decrypted plaintext **MUST NOT** reach logs at any level. Path-based redaction in a logger config is necessary but not sufficient on its own: a value nested deeper than the redaction glob reaches can slip through, so implementations **MUST** also treat every byte-typed value (`Uint8Array`, `bytes`, and the like) as opaque-redacted unconditionally, even for protocol-public values like a nonce. The cheapest secret to protect is the one that never enters a log line. ## Sealed-PoE construction guarantees [#sealed-poe-construction-guarantees] Three further properties are specific to the [sealed PoE](/spec/sealed-poe) construction. They are security-normative for any implementation that produces or opens a sealed record. ### The slot-set MAC is a \~128-bit commitment [#the-slot-set-mac-is-a-128-bit-commitment] The slot-set MAC is what makes a recovered content key a **commitment** to the slot set the recipient matched: a malicious sender cannot construct two distinct slot sets that a single recipient accepts as "theirs". The property required is **restricted key commitment** for the envelope content key, in the sense of [RFC 9771](https://www.rfc-editor.org/rfc/rfc9771) — the recovered key binds to a single slot transcript — **not** a full committing AEAD over arbitrary inputs. The recipient derives a key from a slot, then requires that same key to reproduce `slots_mac` over the slots-transcript hash before treating the slot as its own. Concretely the commitment rests on the multi-key collision resistance of ``` CEK ↦ HMAC-SHA-256( HKDF-SHA-256(CEK, info="cardano-poe-slots-mac-v1"), slots_hash ) ``` for **adversarially chosen** CEKs and transcripts. Because the adversary controls both the CEK and the transcript, the relevant bound is the generic-collision (birthday) bound on the 256-bit HMAC output: finding two `(CEK, transcript)` pairs that yield the same `slots_mac` is a **\~128-bit** search, not the 256-bit second-preimage level. A 128-bit generic-collision margin is the security level this commitment relies on, and is adequate for the threat model. Pre-hashing the transcript to a 32-byte `slots_hash` before the HMAC does not weaken this: a `slots_mac` collision implies either a `slots_hash` collision (an SHA-256 collision) or a collision of the keyed HMAC over equal `slots_hash`, both at the \~128-bit level. Tamper-evidence of the transcript itself inherits SHA-256's `2^128` collision bound: any change to a committed header field or slot byte alters `slots_hash`, and forging an unchanged `slots_hash` over a different transcript is exactly that \~128-bit collision search. A direct consequence is that the **per-slot wrap need not be a committing AEAD**: the commitment is supplied by `slots_mac`, not by the wrap, so a non-committing wrap (the default ChaCha20-Poly1305) is sound here. ### Zero-nonce wrap safety rests on per-slot key uniqueness [#zero-nonce-wrap-safety-rests-on-per-slot-key-uniqueness] Each slot wraps the content key under a per-slot key-encryption key with ChaCha20-Poly1305 at a **zero nonce**. A zero nonce is safe only because the key-encryption key is per-slot and used for exactly one wrap: every slot draws fresh KEM randomness, so the wrap key is unique per slot at negligible collision probability. The safety condition is exactly **per-slot key uniqueness**, and a producer **MUST NOT** introduce anything that could repeat a `(key, nonce)` pair — a CSPRNG failure that repeats KEM randomness, deterministic or colliding encapsulation, reuse of a slot across records, caching a derived key by `(recipient, ephemeral)`, or deduplicating two appearances of the same recipient into one slot. This splits cleanly into a verifier-checkable slice and a producer-only obligation: * **Within-record duplicates are verifier-rejected.** A verifier **MUST** require that the encapsulation material be distinct within a single `slots[]` — all `epk` values distinct on the `x25519` path, all `kem_ct` values distinct on the `mlkem768x25519` path. A duplicate is rejected **before any KEM or AEAD primitive runs**, with the typed code `ENC_SLOTS_DUPLICATE_KEM_MATERIAL`. The conformance corpus exercises this with a record carrying two slots that share an `epk` (or `kem_ct`) and requires that rejection. This rejection fires on a repeated `epk` / `kem_ct` only; it does **not** forbid sealing to the same recipient twice, since honest duplication still draws fresh per-slot KEM randomness for each appearance (see the multiple-matching-slots rule below). * **Cross-record and cross-key reuse is a producer obligation.** No verifier can detect that a producer reused KEM material across two different records or recipients; that lies outside any single record's bytes and remains a producer responsibility. Were a future revision to permit key reuse, the zero nonce would have to be replaced with a fresh nonce in the same change. ### Multiple matching slots: padding is allowed, a CEK conflict is not [#multiple-matching-slots-padding-is-allowed-a-cek-conflict-is-not] A recipient's private key **MAY** legitimately match more than one slot. Sealing the same content key to the same recipient in several slots — each with fresh per-slot ephemerals — is a valid padding and privacy technique, raising the visible slot count without revealing that two of the slots share a recipient. It is distinct from the within-record duplicate-encapsulation rejection above: honest duplication draws fresh KEM randomness, so its `epk` / `kem_ct` differ and it never trips `ENC_SLOTS_DUPLICATE_KEM_MATERIAL`. A verifier therefore **MUST** select the **first** matching slot's content key and **MUST NOT** reject merely because more than one slot matched. The one anomaly a verifier **MUST** reject is two matching slots that recover **different** content keys, compared in constant time. The trial-decrypt loop carries a `cek_conflict` bit across all slots and surfaces the single generic failure if any later match recovers a key that differs from the one already selected. This is defence-in-depth: under the commitment property above a distinct-key match is already infeasible — it is exactly the multi-key collision that property rules out — so the check fails closed against a broken implementation or a future weakening of the assumption. The distinct-key negative is not constructible as a byte vector (constructing one would be the commitment collision itself), so the property is pinned by implementation-level behavioural tests rather than a fixture, alongside a positive fixture for legitimate recipient duplication. ### Parser resource bounds before any primitive [#parser-resource-bounds-before-any-primitive] A verifier **MUST** bound parser resource use before invoking any KEM or AEAD primitive, so an oversized envelope cannot impose work before it is rejected. The reference bounds are `MAX_SLOTS = 1024` slots and `65536` bytes for the decoded `enc` envelope — both far above the \~16 KiB Cardano transaction-metadata ceiling that constrains any honest record, so a record exceeding either bound is malformed. An over-count is rejected with `ENC_SLOTS_TOO_MANY`, an over-size envelope with `ENC_ENVELOPE_TOO_LARGE`. These are verifier-enforced, deployment-pinned constants — not wire fields — and a deployment **MAY** tighten them. The analogous guard on the passphrase path is a maximum raw passphrase input length enforced **before** normalization and Argon2id (reference bound `4096` UTF-8 bytes, `MAX_PASSPHRASE_INPUT_BYTES`), so a pathological passphrase cannot drive a pre-KDF denial of service. ### X-Wing public-key validity bounds the hybrid floor [#x-wing-public-key-validity-bounds-the-hybrid-floor] The hybrid `mlkem768x25519` KEM never drops below X25519's classical security as a floor — but that floor is scoped to **validly generated** recipient keys. `XWing.Encapsulate` **MUST** apply the pinned X-Wing revision's public-key validity check to the recipient key and refuse to encapsulate to a key that fails it, rather than treating a malformed key as if it carried the classical guarantee. A producer that skips the check forfeits the floor for that recipient; the check is the precondition under which the floor holds. ### Bounded payload size [#bounded-payload-size] The content layer is a segmented STREAM: the plaintext is split into 65536-byte chunks, each sealed under the content key with a distinct counter nonce. The 88-bit per-chunk counter admits `2^88` chunks and each chunk stays well within the RFC 8439 single-invocation limit, so — unlike a single-shot AEAD over the whole plaintext — there is **no counter-overflow keystream collision** to guard against and the format imposes no cryptographic payload ceiling. The maximum a deployment enforces is therefore a **denial-of-service policy**, not a cryptographic bound: producers and verifiers **SHOULD** cap payload size to fit their resources and enforce it incrementally as the stream is written or read, aborting before an oversized payload is buffered. Truncation is caught structurally by the per-chunk final flag — a missing final chunk, trailing data, or a short non-final chunk fails decryption — rather than by a size check. The posture is identical on the slots path and the passphrase path. ## Algorithm agility as a security property [#algorithm-agility-as-a-security-property] Every cryptographic choice in a Label 309 record is named by a string from an extensible [registry](/spec/algorithm-registries) — the hash, the AEAD, the KEM, the KDF, the signature scheme. This is not a stylistic choice; it is the migration substrate that lets the format outlive any single algorithm. If a primitive is weakened by cryptanalysis: 1. The relevant registry gains a successor identifier. Nothing in the existing format changes — the new name is **additive**. 2. **Existing records stay valid.** A verifier continues to recognise the old identifier for historical records; their block-time claims do not evaporate because a newer algorithm exists. An implementation **MAY** surface a record that relies only on a since-weakened algorithm as lower-assurance, but **MUST NOT** erase or reject it on that basis alone. 3. **New records publish under the successor.** A producer who wants defence in depth **MAY** commit a content hash under two algorithms from distinct design families, so a single-family break does not collapse the record's evidentiary value — though single-algorithm records remain fully conformant. Because identifiers are strings and the registries are open, migrating to post-quantum hashes, KEMs, or signatures is an additive registry change, never a breaking format revision. The hybrid post-quantum KEM already in the registry is an instance of this property in action, not a special case bolted on. ## Known limits of the standard [#known-limits-of-the-standard] These are not defects to be fixed in a later revision; they are inherent properties of anchoring proofs on a permanent public ledger and of public-key encryption to long-term keys. An honest security model names them. ### On-chain permanence [#on-chain-permanence] Cardano metadata is permanent and globally replicated. A published Label 309 record **cannot be deleted, redacted, or invalidated** — that durability is the entire point of proof of existence. The consequence is a publishing-time responsibility: a content hash, a stake-linked signature, or anything else committed under label 309 is on chain forever. Label 309 deliberately omits free-form descriptive metadata from the record to keep the on-chain footprint minimal, but a producer **MUST** understand that what they publish is irrevocable before they publish it. ### Recipient count is visible [#recipient-count-is-visible] A sealed PoE never names its recipients, but the **number** of recipient slots is the array length, plainly visible on chain — as is the KEM family (`enc.kem`) and the sealed-versus-open distinction. Recipient public keys are off the wire and slots are shuffled, but count-hiding padding is not part of the baseline. A sender for whom the recipient count is itself sensitive must account for this metadata leak operationally — for instance by padding the slot set themselves, or by publishing separate records. ### No per-recipient revocation [#no-per-recipient-revocation] There is no on-chain primitive to revoke a single recipient's access to an already-published sealed PoE. Once a record is encrypted to a public key and anchored, that binding is permanent. This is an inherent property of public-key encryption to a long-term key, not a gap in the format. A key believed compromised is handled going forward — by addressing new records to a fresh key, optionally publishing a superseding record as a caveat-emptor signal — never by reaching back to alter what is already on chain. ### No recipient forward secrecy, by design [#no-recipient-forward-secrecy-by-design] The holder of a recipient private key can decrypt every sealed PoE ever addressed to it, forever. The off-chain ciphertext is permanent and there is no revocation primitive, so a sealed record is *private from the world but not from its recipient*. This is the expected behaviour of encrypting to a long-term public key. A sender who needs to limit a recipient's future reach must encrypt to a short-lived recipient key rather than a long-term identity key — a sender-side choice the format permits but does not mandate. ### Trial-decrypt failures leak no key material [#trial-decrypt-failures-leak-no-key-material] A recipient discovers their slot by trial-decryption. Internally — for a trusted local caller's diagnostics only — the failure paths carry distinct typed codes: "no slot opened under my key" (`WRONG_RECIPIENT_KEY`), "a slot opened but no candidate key reproduced `slots_mac`" (`TAMPERED_HEADER`), and "the content AEAD failed after a key was recovered and the MAC verified" (`TAMPERED_CIPHERTEXT`). These codes carry no oracle value: an adversary without a matching private key opens no slot at all, and every error path **MUST** exclude the underlying secret bytes (shared secret, key-encryption key, content-encryption key) from its message and cause chain. An **untrusted external caller**, however, **MUST** receive exactly **one generic failure shape** regardless of why decryption failed, and the external **response** **MUST NOT** distinguish the three causes by shape, nor reveal which slot matched. The internal codes exist for local debugging; they **MUST NOT** leak to an outside observer through a distinguishable response. The timing model is deliberately scoped. A verifier **MAY** return at the no-match check, before content decryption — so a non-recipient (no slot opened) may be timed apart from a recipient whose slot opened but whose content ciphertext fails to open. That distinction reveals only **recipient-vs-non-recipient**; it never reveals which slot matched, nor any key material. Uniform timing between the non-recipient case and the bad-ciphertext case is **NOT** required, and a dummy content open **MUST NOT** be mandated — forcing content-decryption cost on every non-recipient would buy nothing. The constant-time guarantee that **does** hold is the across-slots invariant (see [Constant-time comparison of secrets](#constant-time-comparison-of-secrets)): within a single key's pass the loop runs over all slots with constant-time compares, so an observer cannot learn which slot, if any, a given key unwrapped — and beyond the recipient-vs-non distinction, learns only the slot count. ## Related pages [#related-pages] * [Introduction](/spec/) — the five invariants, including issuer-agnostic and standalone-verifiable. * [Signatures](/spec/signatures) — strict Ed25519 verification and the fail-soft authorship model. * [Sealed PoE](/spec/sealed-poe) — the encryption envelope and the privacy properties summarised here. * [Algorithm registries](/spec/algorithm-registries) — the named identifiers that make algorithm agility possible. * [Verification](/spec/verification) — the pipeline, confirmation-depth policy, and error catalogue. # Signatures (/spec/signatures) A Label 309 record **MAY** carry one or more authorship signatures in an optional top-level `sigs` array. Each entry is a detached [COSE\_Sign1](https://www.rfc-editor.org/rfc/rfc9052) (RFC 9052) over the record body, attesting that some key vouches for the record. Authorship is **always optional** — the standard never requires a signature, and a record carrying no `sigs` field is a complete, fully verifiable proof of existence. A signature is additive: it answers "and this key vouches for it" on top of the timestamp claim, never instead of it. The content hash is the primary claim; a signature is metadata about who stands behind that claim. Critically, a signature the verifier cannot check — an unsupported algorithm, an unresolvable key — **never** invalidates the content or timestamp claim. Signatures fail softly; existence does not. This page defines what a signature covers, the exact bytes that are signed, the two ways a signer's public key is carried, and the strict verification a public verifier performs. The Ed25519 key itself is defined on [Keys](/spec/keys); the on-wire `sigs` field — where `cose_sign1` and `cose_key` are each a single CBOR byte string — is defined on [The record](/spec/the-record). ## What a signature covers [#what-a-signature-covers] A single `sigs[i]` entry attests to the **entire record body, uniformly**. There is no per-item, per-URI, or per-field signature granularity: one signature commits to every item, every storage URI, every encryption envelope, the `supersedes` pointer if present, and every extension key the record carries. A relay cannot add, drop, or rewrite any of those after the fact without breaking the signature. The signed body is the record map with the `sigs` field **removed** — `remove_keys(record_map, ["sigs"])`, written here as `record_body`. The `sigs` array is excluded from what each entry signs because a signature cannot cover itself, and because each signer commits only to the claim, not to the roster of co-signers. Concretely, every entry signs `{v, items?, merkle?, supersedes?, crit?, }` — the same `record_body` bytes for every entry — but no entry signs the other entries in `sigs`. A signer therefore attests that the body they signed is the body every other entry is bound to; no signer attests to *which* other signers co-signed. A verified signature proves that a key produced a signature over the record body. It does **not** prove that the same key submitted the carrying transaction, paid its fee, or chose its block time. An identical record body **MAY** be republished by any party in a later transaction — that is intentional record portability. Render a verified signature as "signed by \", never as "\ submitted this" or "published by \ at \ ## The signed payload [#the-signed-payload] Each entry carries a detached COSE\_Sign1, so the COSE payload field is empty and the bytes that are actually signed are reconstructed by the verifier from the on-chain record. The signer computes: ``` record_body = remove_keys(record_map, ["sigs"]) record_body_bytes = canonical_cbor(record_body) SIG_DOMAIN_RECORD = utf8("cardano-poe-record-sig-v1") ; 25 bytes to_sign = SIG_DOMAIN_RECORD || record_body_bytes ; concatenation Sig_structure = [ "Signature1", protected, h'', to_sign ] signature = Sign(canonical_cbor(Sig_structure), signer_key) ``` `record_body` is serialised as canonical CBOR per [RFC 8949 §4.2.1](https://www.rfc-editor.org/rfc/rfc8949#section-4.2.1) — the same deterministic encoding the whole record uses. Determinism is what makes a signature interoperable: two implementations that encode the same logical body produce byte-identical `record_body_bytes`, so a signature produced by one verifies under another. ### The domain-separation prefix [#the-domain-separation-prefix] `to_sign` is the 25-byte UTF-8 string `cardano-poe-record-sig-v1` prepended to `record_body_bytes`. The prefix binds the signature to its Label 309 role and prevents cross-protocol replay. A future Cardano metadata schema that happened to share the body's CBOR shape (same keys, same types) could not reuse a Label 309 signature against itself: its `to_sign` would carry a different prefix, or none, so the signed-byte sequence would differ and the signature would fail. Implementations **MUST** embed this literal byte sequence as the leading bytes of `to_sign` exactly; signing only the bare canonical CBOR, with no prefix, is non-conformant. ### Why `external_aad` is empty [#why-external_aad-is-empty] Label 309 places the domain separator **inside** `to_sign`, not in COSE `external_aad`. The `external_aad` slot (`Sig_structure[2]`) is **always** the empty byte string `h''`. This is a deliberate departure from the usual COSE pattern of putting a domain string into `external_aad`, and the reason is wallet interoperability: [CIP-30](https://github.com/cardano-foundation/CIPs/blob/master/CIP-0030/README.md) `signData` — the standard wallet-signing path on Cardano — stipulates that no `external_aad` is used and gives a dApp no way to supply one. A non-empty `external_aad` would make every wallet-produced signature fail. Embedding the prefix in the payload preserves the identical anti-replay property while keeping wallet-produced and verifier-recomputed bytes byte-for-byte equal. ### The Sig\_structure [#the-sig_structure] `Sig_structure` is the 4-element COSE\_Sign1 signing array of [RFC 9052 §4.4](https://www.rfc-editor.org/rfc/rfc9052#section-4.4): | Slot | Value | Notes | | ----- | -------------- | ------------------------------------------------------------------------------------------------------------------------- | | `[0]` | `"Signature1"` | Fixed COSE context identifier, emitted as the full CBOR text string (11 bytes), never the bare UTF-8. | | `[1]` | `protected` | The signer's bstr-wrapped, canonical-CBOR protected-header bytes, used verbatim — never re-canonicalised by the verifier. | | `[2]` | `external_aad` | Always `h''` (zero-length `bstr`). | | `[3]` | `to_sign` | The 25-byte prefix concatenated with `record_body_bytes`. | The published COSE\_Sign1 carries its payload field (`COSE_Sign1[2]`) as CBOR `null` (`0xF6`) — the **detached** form. An attached payload, including a zero-length byte string, is rejected. Detaching the payload is what pins the signed bytes to the record body the verifier independently recomputes; an attached form would let a producer sign borrowed bytes that bear no relation to the on-chain claims. CIP-30 / [CIP-8](https://github.com/cardano-foundation/CIPs/blob/master/CIP-0008/README.md) define an optional unprotected-header `"hashed": true` flag a constrained hardware co-signer may set. When present and true, `Sig_structure[3]` is the 28-byte `Blake2b-224(to_sign)` digest rather than `to_sign` itself; the other three slots are unchanged. A verifier **MUST** inspect the unprotected header and perform this substitution before strict Ed25519 verification. Software and SDK producers **SHOULD NOT** set it — it saves no on-wire bytes and complicates verifier code paths. ## Signing algorithm [#signing-algorithm] The only signature algorithm in v1 is **EdDSA over Ed25519** ([RFC 8032](https://www.rfc-editor.org/rfc/rfc8032)), identified by COSE `alg = -8` ([RFC 9053 §2.2](https://www.rfc-editor.org/rfc/rfc9053#section-2.2)), which lives in the COSE\_Sign1 protected header. A v1 verifier's mandatory baseline is `{-8}`; it **MAY** additionally accept `-19` (Ed25519, fully-specified) and verify both codepoints under the same Ed25519 primitive. The registry is extensible — future revisions add post-quantum signatures additively, never as a breaking change. ## Signer-key resolution [#signer-key-resolution] A public verifier must resolve the signer's public key **without contacting any service**, so every signature carries its key, or an unambiguous in-signature reference to it, on-chain. There are exactly two carry-forms in v1, and they are **mutually exclusive within a single entry** — an entry that uses both is a structural error. ### Path 1 — identity signing (in-signature `kid`) [#path-1--identity-signing-in-signature-kid] The 32-byte raw Ed25519 public key is placed at COSE header label `4` (`kid`, [RFC 9052 §3.1](https://www.rfc-editor.org/rfc/rfc9052#section-3.1)) inside the **protected** header of the COSE\_Sign1. The entry carries no `cose_key` field. By the Label 309 convention, a protected-header `kid` of exactly 32 bytes **is** the public key — not an opaque pointer to one looked up out-of-band. The 32-byte length is an unambiguous discriminator: Ed25519 public keys are always 32 bytes. Placing the key in the protected (not the unprotected) header binds it to the signature; an adversary who rewrote it would break verification. This convention is a deliberate, documented deviation from the opaque-identifier reading of `kid` in RFC 9052; it is what makes the identity path service-independent, with no key directory required. The key model is defined on [Keys](/spec/keys). ### Path 2 — wallet signing (inline `cose_key`) [#path-2--wallet-signing-inline-cose_key] A CIP-30 `signData` signature returns the signer's public key as a separate `cbor` blob, not inside the COSE\_Sign1. A producer chaining such a signature into a record **MUST** place that COSE\_Key into the **same** `sigs[i]` entry under the key `cose_key`, as a single CBOR byte string. The verifier decodes it as a COSE\_Key and reads the Ed25519 public key from label `-2`. The COSE\_Key **MUST** describe only the public half — `kty = OKP (1)`, `crv = Ed25519 (6)`, the 32-byte `x` at label `-2` — and **MUST NOT** carry private-key material (label `-4` and the like); publishing a private scalar on a permanent ledger is an irreversible key leak. ### Mutual exclusion [#mutual-exclusion] The two paths are exclusive at the wire level. An entry carries **either** a 32-byte protected-header `kid` and **no** `cose_key` (path 1), **or** a `cose_key` field and **no** 32-byte protected-header `kid` (path 2) — never both. An entry that carries both is rejected; a verifier never has to disambiguate at verification time. Resolution is therefore a wire-level discrimination, not a ranked precedence: | Path | Condition | Signer key | | ---- | -------------------------------------- | ---------------------------------------- | | 1 | 32-byte protected `kid`, no `cose_key` | The 32-byte `kid` value, used directly. | | 2 | `cose_key` present, no 32-byte `kid` | The Ed25519 key at COSE\_Key label `-2`. | A `kid` carried only in the **unprotected** header is **not** a sanctioned resolution path: it sits outside the signed envelope, so a relay could rewrite it without breaking the signature. A verifier **MUST** ignore unprotected-header `kid` values for resolution. If no permitted path yields a 32-byte Ed25519 key, the entry is reported unresolved and contributes no authorship claim. ## Verification [#verification] A public verifier checks each `sigs[i]` independently, in this order: 1. **Decode.** Parse the `sigs[i].cose_sign1` byte string as a COSE\_Sign1. The payload field **MUST** be `null` (detached); any non-null or non-empty payload is malformed. 2. **Algorithm.** Read the protected-header `alg`. If it is outside the verifier's supported set, the entry is **unsupported** (see below) — not an error on the record. 3. **Resolve the key.** Apply the path-1 / path-2 discrimination above to obtain the 32-byte Ed25519 public key. If no path yields one, the entry is **unresolved**. 4. **Reconstruct and verify.** Rebuild `to_sign` and `Sig_structure = ["Signature1", protected, h'', to_sign]`, canonical-CBOR-encode it, and verify the signature with strict Ed25519. (Substitute `Blake2b-224(to_sign)` for `to_sign` first if the unprotected header carries `"hashed": true`.) 5. **Wallet binding (path 2 only).** Recompute the stake address from the resolved key and compare it byte-for-byte against the protected-header `address`; mismatch fails the binding even though the Ed25519 signature itself verified. This path-2-only check is what lets a UI render a record as wallet-bound; path-1 entries skip it. ### Strict Ed25519 [#strict-ed25519] Verification follows [RFC 8032 §5.1.7](https://www.rfc-editor.org/rfc/rfc8032#section-5.1.7) **strict** rules — there is exactly one acceptable answer for any given key, message, and signature: * Non-canonical encodings of `R` or the signature scalar `S` (notably any `S ≥ ℓ`, the group order) **MUST** be rejected. * Small-order / small-subgroup / torsion-component public keys and `R` values **MUST** be rejected. * The cofactored verification equation (the ZIP-215 / batch-friendly form) **MUST NOT** be substituted for the strict equation. Strictness is what makes the verdict reproducible across implementations: a cofactored verifier would accept signatures a strict one rejects, so two conformant verifiers would disagree. Implementations must select a library — or a library mode — that performs strict, non-cofactored verification. ## Verdict semantics [#verdict-semantics] Signatures are additive, so an unverifiable signature is reported on the entry, not promoted into a record-level failure. Each `sigs[i]` resolves to one of these typed per-entry outcomes; the full error catalogue and the record-level verdict rules live on [Verification](/spec/verification): | Outcome | Meaning | | ------------------------- | ---------------------------------------------------------------------------------------- | | verified | Strict Ed25519 (and, for path 2, the address binding) passed. | | **signature unsupported** | The protected-header `alg` is outside the verifier's set. **Info, never an error.** | | signer key unresolved | No permitted path yields a 32-byte Ed25519 public key. | | signature invalid | Strict Ed25519 returned `false` over the reconstructed `Sig_structure`. | | wallet address mismatch | Path 2: the signature verified, but the recomputed stake address ≠ the claimed one. | An unrecognised or unsupported signature algorithm yields a typed **signature-unsupported** outcome at info severity. The content and timestamp claim — the on-chain `hashes` commitment — is structurally valid regardless of which signature algorithms a verifier implements. A record carrying only future-algorithm signatures still surfaces as a valid proof of existence, with each such entry tagged unsupported. Signatures are additive; existence does not depend on them. ## Related pages [#related-pages] * [Keys](/spec/keys) — the Ed25519 signing key, its derivation, and the 32-byte public key carried in path-1 `kid`. * [The record](/spec/the-record) — the top-level `sigs` field, the closed `sig-entry` map (`cose_sign1` / `cose_key` each a single byte string), and the whole-body transport. * [Verification](/spec/verification) — the per-entry outcome codes, the record-level verdict rules, and the full validation pipeline. # The record (/spec/the-record) A Label 309 record is a single CBOR map carried in Cardano transaction metadata under **label 309**. The map commits one or more content hashes to the chain; the block time of the transaction is the witness that those bytes existed no later than that moment. Everything else the record can carry — storage URIs, an encryption envelope, authorship signatures, a supersedence pointer — is optional metadata about that core claim. This page defines the on-wire shape: where the record sits, how it is encoded, how oversized values are transported, and the closed schema a structural validator checks against. The cryptographic constructions referenced here (hash algorithms, the sealed envelope, signatures) have their own pages; this one is the wire format. ## Where the record lives [#where-the-record-lives] A PoE record **MUST** be placed under transaction metadata label `309`, which is reserved as "Proof of Existence record" in the [CIP-10 metadata-label registry](https://github.com/cardano-foundation/CIPs/blob/master/CIP-0010/registry.json). Transaction metadata is a map from integer label to value, so a transaction **MUST NOT** carry more than one PoE record — exactly one record per transaction. A transaction **MAY** carry additional metadata under other labels (for example a [CIP-20](https://github.com/cardano-foundation/CIPs/blob/master/CIP-0020/README.md) `674` message). A verifier processing PoE **MUST** ignore every label other than `309`. On the Conway-era ledger, transaction metadata is the `metadata` field inside the transaction's `auxiliary_data`. The values admitted under any label are constrained to the ledger's recursive `metadatum` type — integers, byte strings, text strings, arrays, and maps, with **both byte strings and text strings capped at 64 bytes each**: ```text title="CDDL" metadatum = { * metadatum => metadatum } / [ * metadatum ] / int / bstr .size (0..64) / tstr .size (0..64) ``` A transaction carrying any single `bstr` or `tstr` longer than 64 bytes is rejected by Cardano nodes at submission, before any verifier sees it. That cap is the reason Label 309 defines a transport-chunking discipline (below); every field the record carries — base or extension — must reduce to a `metadatum`. ## Transport: the whole-body chunk array [#transport-the-whole-body-chunk-array] A serialised record body routinely exceeds 64 bytes, so it cannot be stored under label 309 as a bare value. The record body is therefore carried as an **opaque whole-body chunk array**: a single CBOR array of ≤ 64-byte byte strings (`bstr .size (1..64)`) whose in-order concatenation **is** the record body. This transport split is the **only** chunking Label 309 performs — the one genuinely ledger-forced step in the format. Because the ledger sees only this transport array and never the fields inside the reassembled body, those fields are ordinary CBOR values with **no per-field chunk wrappers and no field-level 64-byte cap**: a storage URI is a single text string, a `COSE_Sign1` is a single byte string, and an X-Wing `kem_ct` is a single 1120-byte byte string. A field longer than 64 bytes simply rides across the chunk boundaries of the whole-body array like any other stretch of the body. A producer **MUST** serialise the record body once to canonical CBOR, split that byte string into chunks of 1 to 64 bytes, and store the resulting definite-length array of definite-length byte strings as the label-309 value. The array form is **always required**, even for a body of 64 bytes or fewer: such a body is a length-1 array, never a bare map or byte string. Producers **SHOULD** use the minimal split (every chunk except the last exactly 64 bytes) and **SHOULD NOT** emit zero-length chunks; over-chunking wastes transaction bytes with no benefit. A verifier **MUST** byte-concatenate the array elements in order to reassemble the record body **before** structural validation, and **MUST** reject any label-309 value that is not such an array. Chunk boundaries carry no semantic meaning: two transport arrays whose concatenations are byte-identical denote the same record. The carriage-error taxonomy pins the rejection codes — a chunk longer than 64 bytes is `CHUNK_TOO_LARGE`; an array element that is not a byte string, an indefinite-length array or element, or a non-array label-309 value (bare map, bare byte string, integer) is `MALFORMED_CBOR`. A zero-length chunk contributes no bytes and is tolerated, never rejected on its own. Everything below — the record map, the CDDL, the field rules — describes the record body after chunk reassembly. The whole-body chunk array is not part of the schema; it is undone first, then the body is validated. ## The record map [#the-record-map] The reassembled record body is a CBOR map. Integer-valued fields are CBOR major type 0/1; text fields are major type 3 and **MUST** be valid UTF-8; byte fields are major type 2; arrays are major type 4; nested maps are major type 5. An optional field that is present **MUST NOT** carry an empty value. The top-level shape is: | Key | Type | Status | Meaning | | ------------ | ----------------------- | -------- | ------------------------------------------------------------------------------- | | `v` | uint | REQUIRED | Schema version; this document defines `v = 1`. | | `items` | array of item maps | OPTIONAL | Per-content commitments — see [Content and hashing](/spec/content-and-hashing). | | `merkle` | array of commitments | OPTIONAL | List commitments binding off-chain leaf lists to one root. | | `supersedes` | bytes (32) | OPTIONAL | Transaction hash of a prior record this one replaces. | | `sigs` | array of signature maps | OPTIONAL | Record-level authorship signatures — see [Signatures](/spec/signatures). | | `crit` | array of text strings | OPTIONAL | Extension keys that are mandatory to understand. | A conformant record **MUST** commit to at least one of `items` (with ≥ 1 entry) or `merkle` (with ≥ 1 entry). A record carrying neither — or carrying one of them as an empty array — is rejected as an empty record. Apart from this rule, `items` and `merkle` are orthogonal: a record may carry either alone or both together. Label 309 imposes no numeric cap on entry counts. The only ceiling is the live Cardano maximum transaction size, and producers pay per-byte fees that naturally bound record size. A validator **MUST NOT** reject a record purely because it carries many entries, as long as it fits under the ledger's size limit. ### The version field [#the-version-field] `v` is a CBOR unsigned integer, not a semantic-version string. This document defines exactly `v = 1`. A validator **MUST** reject a record whose `v` is outside its supported set with a typed error; it **MUST NOT** panic, abort, or silently treat the record as a different metadata schema. The `v` integer bumps only when a change would cause a v1 parser to misinterpret the record — additive, namespaced extensions do not bump it. ### Items [#items] Each entry in `items` is a CBOR map with one required field and two optional ones: * `hashes` — REQUIRED, a non-empty map from hash-algorithm identifier to raw 32-byte digest. At least one entry; duplicate algorithms are impossible because CBOR map keys are unique. See [Content and hashing](/spec/content-and-hashing). * `uris` — OPTIONAL, a plural list of discovery URIs (rules below). * `enc` — OPTIONAL, the encryption envelope for a sealed item. See [Sealed PoE](/spec/sealed-poe). There is no per-item signature slot. Authorship is expressed only at the record level, by a `sigs[]` entry that covers every item uniformly. ### Merkle commitments [#merkle-commitments] Each entry in `merkle` binds the record to an ordered list of 32-byte leaves via a canonical hash-tree construction, so one 32-byte root on the chain can stand in for an arbitrarily large off-chain leaf list. A commitment is a closed map: | Field | Type | Status | Meaning | | ------------ | ---------- | -------- | ------------------------------------------------------------ | | `alg` | tstr | REQUIRED | Registered list-commitment algorithm identifier. | | `root` | bytes (32) | REQUIRED | Canonical root over the producer's ordered leaf list. | | `leaf_count` | uint | REQUIRED | Number of committed leaves; binds the root to the list size. | | `uris` | URI list | OPTIONAL | Content-addressed URI(s) for the off-chain leaves-list file. | A Merkle root commits to a leaf-list structure, whereas a `hashes` entry commits to plaintext bytes; the two are verified differently (inclusion proof versus plaintext recomputation), which is why list commitments live at the top level rather than inside an item. The list-commitment registry is disjoint from the content-hash registry — see [Algorithm registries](/spec/algorithm-registries). ### Supersedes [#supersedes] `supersedes` is an optional 32-byte Cardano transaction hash pointing at one earlier Label 309 record. It is a service-independent, append-only link: a later record can point at a prior record with no off-chain database or vendor record id. Supersedence does **not** remove, revoke, or invalidate the prior record — the chain is append-only, and verifiers **MUST** continue to treat the earlier record as existent and independently verifiable. The pointer carries no reason or free-text field; any human meaning (correction, replacement, withdrawal) belongs in the new content, not in label 309. A verifier resolving the pointer **MUST** look it up on the same Cardano network as the containing transaction; the field carries no network discriminator because a transaction hash is unique only within its own network. ### Signatures [#signatures] `sigs` is an optional array of record-level signature entries. Each entry carries a detached [COSE\_Sign1](https://www.rfc-editor.org/rfc/rfc9052) structure over the record body — that is, the full record map with `sigs` removed — and optionally the signer's public key for the wallet-signing path. A single signature attests to the entire body: every item, every URI, every envelope, the supersedence pointer if present, and any extension keys. Signatures are always optional, and an unrecognised signature algorithm never invalidates the content claim. The signed payload, the domain-separation prefix, signer-key resolution, and strict verification rules are specified on [Signatures](/spec/signatures). ## URI rules [#uri-rules] When present, `uris` is a non-empty list; each entry is a single CBOR text string carrying exactly one URI. There is no per-URI length cap and no wrapping shape: the whole-body transport already satisfies the ledger's 64-byte string cap, so a long `ipfs:///` URI is one text string like any other. Each URI **MUST** be absolute, **MUST** include a scheme and hierarchical part, and **MUST NOT** contain a fragment identifier — a PoE is a claim about content bytes, not about a sub-component of a document. The v1 scheme set is closed and content-addressed: | Scheme | Notes | | --------- | ------------------------------------------------------------------------ | | `ar://` | Arweave transaction id (43-character base64url). Form `ar://`. | | `ipfs://` | IPFS CID, CIDv1 preferred. Form `ipfs://` or `ipfs:///`. | Producers **MUST NOT** emit any other scheme — `https://`, `http://`, `file://`, `data:`, and the rest are all rejected. The restriction is deliberate, not temporary: a content-addressed URI binds the fetched bytes to the URI itself through the storage layer's integrity model (an IPFS CID is a multihash of the content; an Arweave transaction id commits to the data under Arweave consensus), so a verifier can confirm "the bytes I fetched are the bytes the producer committed to" without trusting DNS, TLS, gateways, or certificate authorities. An out-of-set scheme makes a record structurally invalid; it never validates as `valid`. `uris` is optional throughout. A hash-only record with `uris` omitted is a complete claim — content existence is asserted without committing to a retrieval channel. The exact CID profile (accepted multibase prefixes, codecs, and multihashes) is part of the verification rules; see [Verification](/spec/verification). ## Canonical CBOR [#canonical-cbor] Every Label 309 record **MUST** be encoded as canonical CBOR per [RFC 8949 §4.2.1](https://www.rfc-editor.org/rfc/rfc8949#section-4.2.1) (Core Deterministic Encoding). Concretely: 1. Preferred (shortest-form) serialisation for every integer. 2. Definite-length encoding for all byte strings, text strings, arrays, and maps. 3. No semantic tags (this document requires none — a bignum tag 2/3 **MUST NOT** appear). 4. Map keys sorted in bytewise lexicographic order of their CBOR encoding. 5. UTF-8 text strings with no byte-order mark. 6. No duplicate keys in any map. 7. No floating-point or non-trivial simple values — a record carries only integers, byte strings, text strings, arrays, maps, and (where a schema admits it) `true`/`false`/`null`. Major-type-7 floats (including an integral `1.0`), negative zero, and `undefined` **MUST** be rejected, not coerced. Determinism is what makes the format interoperable: two producers expressing the same logical record emit byte-identical bytes, so a signature computed over the body by one implementation verifies under another. A validator **MUST** reject a non-canonical encoding. Explorers and wallets may surface metadata through a JSON projection, but a conformant verifier **MUST** validate the original transaction CBOR, never a lossy JSON re-encoding of it. ## Forward compatibility [#forward-compatibility] Label 309 v1 reserves a closed set of base keys: `v`, `items`, `merkle`, `supersedes`, `sigs`, `crit`. A record **MAY** additionally carry **extension keys** whose names match one of two reserved namespaces: * `^x-.+` — the vendor / experimental namespace. * `^[a-z]+-.+` — the companion-specification namespace, where the prefix names the registering specification. A validator **MUST** decode and preserve extension keys, **MUST NOT** reject a record solely because they are present, and **MUST** surface them informationally without claiming to have verified their contents. Extension keys are part of the signed body, so a record-level signature covers them — a relay cannot inject an extension key after the signature was produced. Any unknown top-level key that matches **neither** pattern (a typo such as `supersedess`, or a case variant such as `Sigs`) is rejected as an unknown field. The pattern-based tolerance preserves typo detection on the base set while keeping a stable pool open for future additions. A producer that requires a verifier to understand a non-base field **MUST** list that field's name in the top-level `crit` array. A v1 verifier that encounters a `crit` entry it does not implement **MUST NOT** report the record as valid. Each `crit` entry **MUST** match the extension-key pattern (base keys are forbidden in `crit`), **MUST** name a field actually present in the record, and **MUST** be unique — so a critical mark is always traceable to a concrete field whose semantics the verifier is obliged to understand. These rules follow the must-understand / must-ignore precedents in [RFC 9052 §3.1](https://www.rfc-editor.org/rfc/rfc9052#section-3.1) (COSE `crit`) and [RFC 7515 §4.1.11](https://www.rfc-editor.org/rfc/rfc7515#section-4.1.11) (JWS `crit`). ## Byte budget [#byte-budget] The only hard ceiling on record size is the live Cardano `maxTxSize` protocol parameter — 16 384 bytes at protocol major version 10 on mainnet, subject to ledger parameter updates. Label 309 imposes no schema-level cap below that. Records exceeding the limit are rejected by Cardano nodes at submission, so no verifier ever sees one; a validator **MUST NOT** invent a Label 309-specific ceiling beneath `maxTxSize`. In practice a transaction's non-metadata structure (inputs, outputs, witnesses, fee and validity fields) consumes roughly 245 bytes, leaving on the order of 16 KB for the label-309 record. Producers **SHOULD** target a few hundred bytes below the limit to absorb fee variance and **SHOULD** compute the candidate record's size before submission, failing fast if it would not fit. The realistic shapes that fit are generous: well over a hundred single-hash items, dozens of record-level signatures, or many classical recipient slots all sit comfortably within one transaction — and a single Merkle root commits to an unbounded off-chain leaf list at a fixed on-chain cost of 32 bytes. ## CDDL schema [#cddl-schema] The following CDDL is the structural schema for the **reassembled record body** — the canonical-CBOR bytes obtained after concatenating the ≤ 64-byte chunk array stored under label 309. The reassembled body is plain deterministic CBOR: it is not itself a ledger `metadatum`, and its fields are **not** subject to the 64-byte string cap, which the whole-body transport wrapper alone satisfies. The wrapper is not modelled here. The block describes the permissive superset of well-formed shapes; cross-field invariants (the `items`-or-`merkle` rule, the `slots` ⊕ `passphrase` exclusivity of the encryption envelope, registry membership of algorithm identifiers, the per-KEM slot-shape rules) are enforced by a typed validation pass over the decoded structure, not by the CDDL itself. ```text title="CDDL" ; An extension value is any CBOR value the canonical (deterministic) encoding ; profile admits. Floats and semantic tags are excluded by that profile (they ; are rejected as MALFORMED_CBOR on decode), so the exclusion is not repeated ; here; the reassembled body carries no field-level 64-byte cap. extension-value = { * extension-value => extension-value } / [ * extension-value ] / int / bstr / tstr / bool / null ; A conformant record MUST carry at least one of `items` (>= 1 entry) or ; `merkle` (>= 1 entry); a record with both absent (or both empty) is rejected ; as SCHEMA_EMPTY_RECORD by the typed pass, not at the CDDL layer. poe-record = { poe-common, ? "items": [ 1* item-entry ], ? "crit": [ 1* tstr ], * extension-key => extension-value } poe-common = ( "v": 1, ? "merkle": [ 1* merkle-commit ], ? "supersedes": bytes32, ? "sigs": [ 1* sig-entry ], ) extension-key = tstr .regexp "^x-.+" / tstr .regexp "^[a-z]+-.+" item-entry = { "hashes": hash-map, ? "uris": [ 1* uri ], ? "enc": enc, } ; A non-empty CBOR map keyed by a content-hash algorithm identifier with the ; 32-byte digest as value. Map-key uniqueness makes duplicate algorithms ; structurally impossible. hash-map = { + content-hash-alg => bytes32 } ; A list commitment binds the record to an ordered leaf list. `leaf_count` ; binds the on-chain commitment to the off-chain list size. merkle-commit = { "alg": merkle-commit-alg, "root": bytes32, "leaf_count": uint32, ? "uris": [ 1* uri ], } ; `enc` is a choice between the scheme-1 envelope shape and a bounded opaque ; envelope (the degrade-to-opaque rule for an unsupported scheme/kem/aead). The ; typed pass enforces the slots/passphrase exclusivity and the per-KEM ; slot-shape rules over a supported envelope. enc = enc-scheme-1 / enc-opaque ; `scheme: 1` is not a version counter for the `enc` map alone: it names the ; ENTIRE sealed cryptographic suite — the canonicalEncode rules, the slot ; schema, the HKDF and HMAC hashes, the wrap AEAD, the segmented-STREAM content ; format, the transcript schemas, the in-ciphertext passphrase commitment, the ; pinned X-Wing revision, every domain-separation label, and the Argon2id and ; passphrase-normalization profiles. Changing any one of them requires a new ; `scheme` value; see Sealed PoE for the construction it pins. enc-scheme-1 = { "scheme": 1, "aead": aead-alg, "nonce": bstr, ? "kem": kem-alg, ? "slots": [ 1* slot ], ? "slots_mac": bytes32, ? "passphrase": passphrase-block, } ; The opaque reading of an envelope under an unsupported identifier: `scheme` ; is the only structurally required key, and every other entry is any key/value ; pair the canonical profile admits, subject to the generic decode bounds. enc-opaque = { "scheme": uint, * tstr => extension-value } slot = classical-slot / hybrid-slot ; enc.kem = "x25519": the per-slot X25519 ephemeral public key + wrapped CEK. classical-slot = { "epk": bytes32, "wrap": bytes48, } ; enc.kem = "mlkem768x25519": the 1120-byte X-Wing ciphertext plus the wrapped ; CEK. There is NO `epk` — the X25519 ephemeral is the trailing 32 bytes of the ; X-Wing ciphertext inside `kem_ct`. hybrid-slot = { "kem_ct": bstr .size 1120, "wrap": bytes48, } passphrase-block = { "alg": kdf-alg, "salt": bstr .size (16..64), "params": { "m": uint32, "t": uint32, "p": uint32 }, } ; A signature entry is a closed map. `cose_sign1` is REQUIRED and carries the ; CBOR-encoded COSE_Sign1 as a single byte string; `cose_key` is OPTIONAL and ; carries the CBOR-encoded COSE_Key for the wallet-signing path as a single ; byte string. sig-entry = { "cose_sign1": bstr, ? "cose_key": bstr, } ; A uri is one absolute URI in a single text string. The URI shape rules ; (absolute, no fragment, closed scheme set {ar://, ipfs://}) are enforced in ; the typed pass; the rule carries no length cap. uri = tstr bytes32 = bstr .size 32 bytes48 = bstr .size 48 ; uint32 is the pinned range of every numeric field: an unsigned integer ; representable in 4 bytes (0 .. 2^32-1), handled as an exact integer. uint32 = uint .size 4 ; Algorithm-identifier strings are open `tstr`: the registries are ; authoritative for accepted values, and the typed pass emits the precise ; unsupported-algorithm code for any unrecognised identifier. content-hash-alg = tstr ; e.g. "sha2-256", "blake2b-256" merkle-commit-alg = tstr ; e.g. "rfc9162-sha256" aead-alg = tstr kem-alg = tstr kdf-alg = tstr ``` ## Related pages [#related-pages] * [Content and hashing](/spec/content-and-hashing) — the `hashes` map, what a digest commits to, and exact-bytes semantics. * [Algorithm registries](/spec/algorithm-registries) — the named identifiers for hashes, list commitments, AEADs, KEMs, and KDFs. * [Signatures](/spec/signatures) — the record-level `sigs` construction and verification. * [Sealed PoE](/spec/sealed-poe) — the `enc` envelope and recipient key slots. * [Verification](/spec/verification) — the validation pipeline, the CID profile, and the error catalogue. # Verification (/spec/verification) Label 309 is verified, never asserted. A publisher anchors a content hash on Cardano under label 309; from that point on the claim stands on its own bytes, and anyone holding the transaction reference can check it. This page defines *how* that check runs: the three verifier roles, what each one does and does not touch, the verdict states they emit, the confirmation depth below which a verdict stays provisional, and the typed error catalogue that makes two independent implementations agree on the same failure for the same input. The defining property is **service independence**. A conformant verifier reaches its verdict using only the public chain, a verifier-chosen Cardano explorer or gateway, and — for content and sealed claims — content-addressed storage gateways the verifier also chooses. It never contacts the publisher. The standard names no specific provider; the gateway is an input the operator supplies. ## Three roles, each a strict extension [#three-roles-each-a-strict-extension] Verification is layered. Each role does everything the role above it does, then adds one capability. A lower role is a complete, useful verifier on its own — it simply proves less. | Role | Adds | Touches | | ------------------------ | ----------------------------------------------------------- | ------------------------------------- | | **Structural validator** | schema + domain conformance over the record bytes | nothing — a pure function | | **Public verifier** | chain resolution, on-chain inclusion, signature checks | a Cardano explorer + content gateways | | **Recipient verifier** | trial-decrypt of a sealed payload, plaintext-hash recompute | the verifier's own private key | ### Structural validator — a pure function over the bytes [#structural-validator--a-pure-function-over-the-bytes] The structural validator is a single function from a byte string to a result. It performs **no I/O, no cryptographic signature checks, and no decryption**. It never sees a network, a transaction, or a key. Given the same input it returns the same output, every time, anywhere — which is what lets it run pre-submission inside a publisher's tooling, inside a third-party indexer, or inside an archival tool confirming long-term well-formedness, all without a server. Its pipeline is fixed: ``` 1. resource bounds — a local, non-normative guard on input size; never a Label 309 conformance error. 2. canonical decode — decode with a canonical-CBOR decoder (RFC 8949 §4.2.1): definite lengths, sorted map keys, no duplicate keys, valid UTF-8. Any malformed or non-canonical input → a single MALFORMED_CBOR. 3. schema parse — type, length, and the chunk/length bounds; a strict object mode that rejects unknown fields. 4. domain rules — cross-field constraints the schema cannot express: registry membership, the items-or-merkle rule, COSE structural shape, URI reconstruction, envelope shape. 5. result — { valid, record } with optional warnings/info, or a sorted list of typed issues. ``` The validator is profile-agnostic: it parses the full v1 schema regardless of which subset a downstream verifier intends to act on. Errors fail the record; warnings and info entries are surfaced but leave it valid. Crucially, it confirms the *shape* of a COSE\_Sign1 — four-element array, detached (null) payload, a well-formed protected header — but **never verifies the signature**, and it never rejects a record merely because the signature algorithm is one it does not recognise (that is tagged `SIGNATURE_UNSUPPORTED`, severity `info`, and the record stays valid). Verifying the signature is the public verifier's job. See [The record](/spec/the-record) for the schema this pass enforces. ### Public verifier — chain, inclusion, and signatures [#public-verifier--chain-inclusion-and-signatures] The public verifier layers the chain on top of the structural validator. Given a Cardano transaction reference, it: 1. **Resolves a verifier-chosen explorer.** The explorer chain is an input; the verifier tries them in order, fetching the **raw on-chain transaction CBOR** — never the explorer's metadata-JSON projection. The JSON view collapses CBOR major types into a JSON union and discards map-key order, definite-length framing, and the bytes-vs-text discriminator, so a verifier that re-encoded from it could not reproduce the byte-exact signing input and every signature on a conforming record would fail. A single provider's negative answer is **not** chain-authoritative — the transaction may be on chain and merely unknown to that provider — so the verifier consults every remaining provider before emitting `TX_NOT_FOUND`; if every provider is unreachable it emits `PROVIDER_UNAVAILABLE`. 2. **Binds the fetched bytes to the transaction reference.** Before reading *anything* out of a fetched transaction, the verifier recomputes `blake2b-256` over the fetched transaction-body bytes — by ledger definition, the transaction id — and rejects the response on any mismatch with the requested hash. It then recomputes `blake2b-256` over the fetched auxiliary-data bytes and rejects on any mismatch with the now-verified body's `auxiliary_data_hash`. Both digests are computed over the bytes **exactly as fetched**, never a re-encoding. A response that fails either check carries provably wrong bytes and is discarded; if no provider yields a response that survives the binding, the report carries `TX_INTEGRITY_MISMATCH`. After this step every byte of the record and the surrounding transaction is cryptographically committed to the caller-supplied hash — no explorer can substitute, amend, or truncate the record without producing a blake2b-256 second preimage. 3. **Unwraps the auxiliary data.** The Conway-era ledger admits three auxiliary-data encodings, all valid: a bare untagged metadata map, a two-element `[ metadata, scripts ]` array, and a tag-259 map with the metadata under key `0`. The verifier **MUST** accept all three and dispatch **purely on the top-level CBOR type and tag** — a tag-259 value is the keyed-map form, an untagged array is the two-element form, and an untagged map is always the metadata map itself. It **MUST NOT** inspect a map's keys to guess the shape; any other top-level shape, or any tag other than 259, is `MALFORMED_CBOR`. If the bound transaction carries no label-309 metadata, the verifier emits `METADATA_NOT_FOUND` — a **record-attributable** outcome, because the bound transaction itself proves the absence: no provider could have stripped the metadata without failing the binding. 4. **Reassembles the whole-body chunk array.** The label-309 value is the canonical-CBOR record body split into an array of ≤ 64-byte byte strings. The verifier byte-concatenates the elements in order — returning the raw bytes, no re-encode pass — so the canonical-CBOR check can still catch a non-conformant on-chain encoding. 5. **Structurally validates** the reassembled body (the role above). A validator rejection short-circuits the report with verdict `failed`. 6. **Checks confirmation depth** (below). A transaction below the threshold halts here with verdict `pending`. 7. **Verifies every record-level signature** under strict Ed25519. It does not decrypt. Signature resolution, the domain-separated payload, and the strict verification rules are specified on [Signatures](/spec/signatures). 8. **Fetches and hash-checks content**, holding the record to account only for bytes it can attribute to a URI's own content address (below). It does not decrypt. A signature is computed over the byte-exact canonical CBOR of the record body. A JSON projection of metadata is lossy by construction — it cannot round-trip back to those bytes. Re-encoding from JSON breaks every signature on a conforming record. The raw transaction CBOR is the only authoritative input to any cryptographic check; a JSON view is for human display, after verification has already passed. ### Recipient verifier — decrypt and recompute [#recipient-verifier--decrypt-and-recompute] The recipient verifier is a public verifier that additionally holds a private key. For a sealed item addressed to it, it **trial-decrypts** the on-chain key slots with its key, recovers the content key on success, decrypts the ciphertext, and then **recomputes the plaintext hashes** against the on-chain commitment — closing the loop between the encrypted bytes and the content-existence claim. Because every sealed item carries at least one content-hash entry, that recomputation always has something concrete to compare against. The sealed envelope, the key slots, and the unwrap construction are specified on [Sealed PoE](/spec/sealed-poe). Before the recipient touches any KEM or AEAD primitive, it re-applies the same envelope shape and resource checks the structural validator already ran, rejecting a structurally invalid or oversized envelope first. Two of those pre-primitive guards are deployment-pinned resource bounds, not wire fields: the reference bounds are `MAX_SLOTS = 1024` slots and `65536` bytes for the decoded `enc` envelope, both far above the \~16 KiB Cardano transaction-metadata ceiling that bounds any honest record, so a record exceeding either is malformed and is rejected with `ENC_SLOTS_TOO_MANY` or `ENC_ENVELOPE_TOO_LARGE` before a single primitive runs. Deployments **MAY** tighten them. One shape check is load-bearing for security: the encapsulation material **MUST be distinct within one `slots[]`** — all `epk` values for `x25519`, or all `kem_ct` values for `mlkem768x25519`. A within-record duplicate is rejected with `ENC_SLOTS_DUPLICATE_KEM_MATERIAL` before any KEM or AEAD primitive runs, because a repeated `epk`/`kem_ct` would break the per-slot key uniqueness the zero-nonce wrap relies on. Cross-record or cross-key key reuse is a producer obligation and is not verifier-detectable; only the within-record duplicate is. All three checks are structural — the validator enforces them on every record (the duplicate-material, slot-count, and decoded-envelope codes are Part-A codes); the recipient simply runs them again as defence-in-depth. The unwrap itself is two cryptographic steps the recipient reproduces from the envelope, never from any side input. First it recomputes the **slots transcript hash** `slots_hash` once, before the loop, and holds it constant across every slot: `slots_hash = SHA-256("cardano-poe-slots-transcript-v1" || canonicalEncode(SLOTS_TRANSCRIPT))`, where the transcript pins the header fields, the on-wire slot set (each slot field a single byte string — there is no per-field chunking to normalise), and the item's hash claim via `hashes_hash`. Binding `hashes_hash` is what lets the recipient confirm the envelope was sealed for **this exact hash claim** from on-chain bytes alone, before any ciphertext fetch — an envelope spliced onto an item with a different `hashes` map fails the MAC. It iterates **all** slots with no early break; a slot is accepted only when the content key it yields also reproduces the on-wire `slots_mac` over that constant `slots_hash`. Acceptance also folds in a **secret-independent validity bit**: on the classical path a slot crafted to drive the X25519 shared secret to the all-zero value sets that bit false (a constant-time compare against `0^32`), the KEK is constant-time-selected to a dummy derived from `0^32` so the loop performs identical work, and the bit gates the slot's acceptance — an invalid-ECDH slot can never open regardless of its wrap or MAC. Both the wrap open and the later content open are **atomic**: on an AEAD tag failure they return no plaintext, and the candidate they hand back (the wrapped key, or the content plaintext) is a fixed or pseudorandom dummy that is independent of the failed ciphertext — no unverified plaintext is ever released. A recipient key **MAY** legitimately match more than one slot: a producer may seal the same content key to the same recipient in several slots, each with fresh per-slot KEM material, to pad the recipient count — a valid privacy technique, and distinct from the duplicate-encapsulation-material rejection, which fires only on an identical `epk`/`kem_ct`. The verifier selects the **first** match's key and **MUST NOT** reject merely because several slots matched. The one anomaly it **MUST** reject is two matching slots that recover **different** content keys (compared in constant time): the loop carries a `cek_conflict` bit and surfaces the single generic failure if any later match yields a key differing from the selected one. This is defence-in-depth — under the slot-set commitment a distinct-key match is already infeasible, so the check simply fails closed. Then, on the recovered content-encryption key, it derives the content key (an HKDF leaf of that key salted by `enc.nonce`) and opens the **segmented STREAM** ciphertext chunk by chunk, verifying each chunk's tag before releasing that chunk's plaintext. The per-chunk AAD is empty: all header context is already bound to the key transitively through `slots_mac`, so flipping any header field changes what the recipient derives and the stream fails to open. Truncation is caught by the final flag — a missing final chunk, data after it, a final flag on a non-final chunk, or a short non-final chunk all fail as `TAMPERED_CIPHERTEXT`. The segmented format imposes no cryptographic payload ceiling (the 88-bit chunk counter admits `2^88` chunks); a practical maximum is a deployment denial-of-service policy, enforced incrementally as the stream is read. On the passphrase path the recipient first reads the leading 32-byte commitment header and compares it in constant time before opening any chunk. The recipient path is where the error catalogue earns its precision: it distinguishes the case where *no slot accepted this key* (wrong recipient) from the case where a slot accepted the key but the *slot set* or the *ciphertext* was tampered. Those are different security claims and carry different codes (below). An **untrusted caller**, however, receives exactly **one generic failure shape** regardless of cause — no slot opened, the slot set was tampered, or the content tag failed — and the **response** never reveals which case occurred, nor which slot matched; the typed codes are an internal diagnostic for a trusted local caller only. Timing follows one explicit model. The verifier **MAY** return at the no-match check — before content decryption — so a non-recipient and a recipient take measurably different time. That difference reveals only **recipient-versus-non-recipient**, never which slot matched and no key material. Uniform timing between a non-recipient and a recipient whose ciphertext fails to open is **NOT** required, and a dummy content open **MUST NOT** be mandated — it would impose content-decryption cost on every passer-by. The constant-time guarantee that does hold is the **across-slots** one: within a single private key's pass the loop runs over all slots with constant-time compares, so nothing leaks *which* slot, if any, that key unwraps. ## Finality: confirmation depth [#finality-confirmation-depth] Cardano settlement is probabilistic. A transaction one block deep can still be orphaned by a short reorg; a transaction many blocks deep has settled with overwhelming likelihood. A verifier that called a one-block-deep record `valid` would let an attacker re-anchor a contradictory record on a competing fork and collect a "valid" verdict on both — silently breaking the append-only assumption the whole proof rests on. So a verifier reports the record as **`pending`, not `failed`**, while it sits below a confirmation-depth threshold. The **RECOMMENDED** general-purpose threshold is **≥ 15 blocks** (roughly five minutes). The threshold is verifier policy, not a wire constant: deployments handling high-value or evidentiary records **SHOULD** raise it toward hard finality, and a verifier **MUST** surface the threshold it used so consumers can layer a stricter policy on top. A `pending` record is well-formed and on-chain; it simply has not settled deeply enough yet, and may resolve to `valid` on a later retry. Confirmation depth, block height, and block time are **explorer-asserted facts the verifier never fabricates**. The transaction-reference binding makes the transaction *content* trustless, but the chain facts *about* it are not derivable from the bytes — transaction CBOR carries no timestamp or height. Depth is computed as `(resolving explorer's tip height) − (height of the including block) + 1`, so a transaction in the tip block has depth exactly 1; block time is the POSIX timestamp (integer seconds, UTC) of the including block's slot, taken from the explorer's time field, never recomputed by the verifier. Because these facts rest on the explorer's word, a verifier **SHOULD** resolve them from at least two independent explorers and surface any divergence; deployments for which block time is load-bearing — legal notarisation, deadline disputes — **MUST** cross-check it. The report carries the resolved depth alongside the threshold it was compared against, and `block_time` (with `block_slot` when available). ## Verdict states [#verdict-states] A verifier concludes in one of **four machine verdicts**, each paired one-to-one with a process exit code, so a caller — a CI gate, a monitor, a script — can tell a record-attributable failure apart from a transient operational one without parsing the structured report. The governing line is **attribution**: condemning a record requires evidence the record's own bytes — or bytes attributably bound to its references — actually provide. No provider misbehaviour can manufacture a `failed`. | Verdict | Exit | Meaning | | ---------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **valid** | 0 | every check the verifier ran returned `ok`; no `error`-severity issue is present. | | **failed** | 1 | a **record-attributable** failure: the structural validator rejected the bytes, a signature did not verify, an attributable hash mismatched, the bound transaction carries no label-309 metadata (`METADATA_NOT_FOUND`), or a deny-host rule fired. | | **unverifiable** | 2 | no record-attributable error, but a required check could not run or could not be **attributed** — the transaction did not resolve, no provider response survived the binding (`TX_INTEGRITY_MISMATCH`), or committed content/ciphertext could not be obtained or attributed. The same record may verify `valid` on retry or under a different gateway. | | **pending** | 3 | structurally well-formed and on-chain, but below the confirmation-depth threshold (`INSUFFICIENT_CONFIRMATIONS`); may settle. No result from a pending record may be presented as final. | Verifier-host runtime failures that are not record-attributable use exit codes 4 and above and correspond to no verdict. A `valid` verdict **MUST NOT** be reported when any `error`-severity issue is present; a record **MAY** be `valid` with a non-empty `warnings` and/or `info` list. Neither layer may "soften" an error into a warning to make a record pass. Each verdict is reserved for its own case: `pending` is never substituted for `valid` or `failed`, and a provider-attributable failure is `unverifiable`, never `failed`. A **commitment floor** governs availability: when content checking ran but availability failures left **no** content commitment of the record actually verified, the verdict is `unverifiable`, never `valid` — a `valid` verdict means at least one content commitment was checked. Integrity outcomes are untouched by the floor: attributable bytes that fail a commitment produce `failed` regardless of what else was available. ## Content-address binding and attribution [#content-address-binding-and-attribution] Step 8 (and, for the recipient verifier, decryption) fetches bytes from content-addressed storage gateways the verifier chose — and gateways are untrusted. The verdict line above turns on a single question the verifier MUST be able to answer about every fetched byte stream: **can these bytes be attributed to the URI itself?** Both schemes are content-addressed, so they can: * **`ipfs://`** — recompute the CID over the fetched content (the multihash directly for a raw-codec CID; block-by-block digests along the resolved path for a DAG-form CID). * **`ar://`** — validate the signed Arweave transaction and recompute the chunk Merkle tree against its `data_root`; for an ANS-104 data item, recompute the deep-hash, verify the owner signature, and check that the SHA-256 of the signature equals the id in the URI. Bytes that satisfy the record's own digests need no binding check — the record's commitment is at least as strong as the storage layer's. Where the binding *is* applied, attribution decides what a mismatch means: * **Attributable bytes** — binding verified, or supplied out-of-band by the caller — are held against the record when they fail a commitment: `URI_INTEGRITY_MISMATCH` for item content (a **hard** integrity failure regardless of what any sibling URI holds), the `MERKLE_*` family for a leaves-list, `TAMPERED_CIPHERTEXT` for an attributable ciphertext blob. Verdict `failed`. * **Unattributable bytes** — binding not verified, or verified and failed — indict the serving **provider**, never the record: `URI_PROVIDER_INTEGRITY_MISMATCH` (warning). The verifier continues with the remaining URIs and gateways; a claim left with no attributable bytes ends in an availability outcome (`CONTENT_UNAVAILABLE`, `MERKLE_LEAVES_UNAVAILABLE`, `CIPHERTEXT_UNAVAILABLE`), verdict `unverifiable` — exactly as if nothing had been fetched. This is the storage-side counterpart of the transaction-reference binding: a misbehaving gateway can degrade only **availability**, never the verdict. `URI_INTEGRITY_MISMATCH` and `URI_PROVIDER_INTEGRITY_MISMATCH` are therefore distinct codes — the first condemns the record, the second condemns a provider — and a verifier that conflated them would let a hostile gateway forge a `failed`. The post-decryption plaintext-hash recheck needs no attribution qualifier: ciphertext that opens under the authenticated envelope is attributed by the AEAD itself, so a plaintext-hash mismatch there (`URI_INTEGRITY_MISMATCH`) is always record-attributable and forces `failed`. ## The typed error catalogue [#the-typed-error-catalogue] Every failure mode resolves to a code from a single closed catalogue. Codes are `SCREAMING_SNAKE_CASE`, and a conformant implementation **MUST** emit exactly those strings — never a parser's internal lowercase code, never a free-form message. Two implementations in two languages reaching the same input emit the same code; the full normative list is pinned byte-exact by the conformance test suite, and the catalogue is locked (codes are added only by amendment). ### Severity model [#severity-model] Every issue carries one of three severities, and the distinction is load-bearing: * **error** — invalidates the verdict. A `valid` result cannot coexist with any `error`. * **warning** — a non-fatal run-time anomaly (a single gateway failed, a leaves-list was only partially available) that does not block `valid`. * **info** — a deliberate non-check: an aspect the verifier *chose* not to evaluate (a field outside its profile, an unrecognised optional algorithm). An info entry is not a softened error and is never used as one. One code stands apart: `INSUFFICIENT_CONFIRMATIONS` maps to the `pending` verdict rather than to a severity, because the record is well-formed and only awaiting settlement. ### Error families [#error-families] The catalogue groups into families. A representative — not exhaustive — set of codes: | Family | Severity | Representative codes | | ------------------------------ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Malformed / non-canonical CBOR | error | `MALFORMED_CBOR`, `CHUNK_TOO_LARGE` | | Schema | error | `SCHEMA_TYPE_MISMATCH`, `SCHEMA_MISSING_REQUIRED`, `SCHEMA_UNKNOWN_FIELD`, `SCHEMA_EMPTY_RECORD` | | Unsupported algorithm | error | `UNSUPPORTED_HASH_ALG`, `UNSUPPORTED_AEAD_ALG`, `UNSUPPORTED_KEM_ALG`, `UNSUPPORTED_MERKLE_COMMIT_ALG` | | Explorer / metadata | error / pending | `TX_NOT_FOUND`, `PROVIDER_UNAVAILABLE`, `TX_INTEGRITY_MISMATCH` (→ `unverifiable`), `METADATA_NOT_FOUND` (→ `failed`); `INSUFFICIENT_CONFIRMATIONS` (→ `pending`) | | Signature | error / info | `MALFORMED_SIG_COSE_SIGN1`, `SIGNER_KEY_UNRESOLVED`, `SIGNATURE_INVALID`, `WALLET_ADDRESS_MISMATCH`; `SIGNATURE_UNSUPPORTED` (info) | | Encryption / KEM / wrap | error | `KEM_EPK_LENGTH_MISMATCH`, `KEM_CT_LENGTH_MISMATCH`, `WRAP_LENGTH_MISMATCH`, `ENC_SLOTS_DUPLICATE_KEM_MATERIAL`, `ENC_SLOTS_TOO_MANY`, `ENC_ENVELOPE_TOO_LARGE`, `ENC_REQUIRES_CONTENT_HASH` | | Decryption outcome | error | `WRONG_DECRYPTION_INPUT_SHAPE`, `WRONG_RECIPIENT_KEY`, `TAMPERED_HEADER`, `TAMPERED_CIPHERTEXT`, `KDF_DERIVATION_FAILED` | | URI / content | error / warning | `INVALID_URI`, `URI_TARGET_FORBIDDEN`, `URI_INTEGRITY_MISMATCH`, `CONTENT_UNAVAILABLE` (→ `unverifiable`), `CIPHERTEXT_UNAVAILABLE` (→ `unverifiable`); `URI_PROVIDER_INTEGRITY_MISMATCH`, `URI_FETCH_FAILED` (warnings) | | Merkle list-commitment | error / warning / info | `MERKLE_ROOT_MISMATCH`, `SCHEMA_MERKLE_LEAF_COUNT_MISMATCH`; `MERKLE_LEAVES_UNAVAILABLE` (dual); `MERKLE_UNSUPPORTED` (dual) | | Service independence | error | `SERVICE_INDEPENDENCE_VIOLATION` | The network/policy codes (`TX_NOT_FOUND`, `PROVIDER_UNAVAILABLE`, `CONTENT_UNAVAILABLE`, `CIPHERTEXT_UNAVAILABLE`) — and `TX_INTEGRITY_MISMATCH`, whose mismatch is provable against the providers rather than the record — are `error`-severity in the issue list (they block a `valid` verdict) but **not** record-attributable, so they map to `unverifiable`, never `failed`. `URI_PROVIDER_INTEGRITY_MISMATCH` is the per-fetch warning under the same principle. A few codes carry **dual severity** — `ENC_UNSUPPORTED`, `MERKLE_UNSUPPORTED`, `OUT_OF_PROFILE_SKIPPED`, and `MERKLE_LEAVES_UNAVAILABLE` — reading `info`/`warning` by default and promoting to `error` in a strict context (the recipient role, the merkle-only escalation, strict end-to-end mode, or the commitment floor). The recipient path is the most diagnostically precise family. For a **trusted local caller**, a failed decrypt resolves to one of three internal codes: `WRONG_RECIPIENT_KEY` means no slot accepted the supplied key (no content key was ever recovered); `TAMPERED_HEADER` means a key was recovered but the candidate content key did not reproduce `slots_mac` over `slots_hash` (a slot, a header field, or `slots_mac` itself was altered); `TAMPERED_CIPHERTEXT` means the slot set was intact but the content AEAD tag failed after the key was recovered. The three are structurally distinguishable to that local caller, and the boundary between them leaks no key material. To an **untrusted external caller** all three collapse to the single generic failure described above, indistinguishable by response shape. Under the timing model, a permitted early no-match return separates `WRONG_RECIPIENT_KEY` (a non-recipient) from the two recipient-side tamper outcomes, which carry no further distinction from one another — so the diagnostic precision never becomes a slot- or key-selective oracle. ### How codes map to the four verdicts [#how-codes-map-to-the-four-verdicts] A code emitted by the structural validator means the record bytes do not conform. The public verifier short-circuits the report with verdict `failed` — exit 1 — and the validator's issue list, without running any further chain or crypto work. A code emitted only after structural validation passes — a signature that did not verify, an **attributable** hash that did not match, `METADATA_NOT_FOUND` on the bound transaction — is likewise `failed`: each is record-attributable, evidence the record's own bytes provide. A transient or provider-attributable failure is a different verdict: content that could not be fetched or attributed, an explorer that was unreachable, or a provider that served bytes failing the transaction-reference binding all map to `unverifiable` — exit 2 — because none is the record's fault and the same record may verify `valid` on retry. The distinction the exit code preserves is therefore three-way among the failing states: record-attributable `failed` (1), operational or provider-attributable `unverifiable` (2), and below-threshold `pending` (3). A few codes carry context-dependent severity. A verifier reading a record richer than its declared profile (for example a hash-only verifier encountering a sealed item) reports the extra field as `info` in a display context and still validates the hash claim, or as `error` in a strict end-to-end audit context. Likewise an unrecognised *optional* signature algorithm is `info` — the content-existence claim does not depend on it — so a public hash-only proof remains `valid` even when an advisory signature is unverifiable. ## Service independence is not optional [#service-independence-is-not-optional] Verification never reaches back to the publisher. Every outbound call a conformant verifier makes is directed at infrastructure the operator chose — a Cardano explorer for the transaction, and content-addressed storage gateways for any `ar://` or `ipfs://` bytes. This is structurally enforced, not a code-comment promise: * Every network call routes through a single egress wrapper that records `url`, `method`, `status`, byte count, and purpose for **every** call — success, failure, and retry alike — into a mandatory audit trail on the report. A verifier that cannot produce that trail cannot prove its independence. * That wrapper accepts a deploy-supplied deny-host list and hard-fails any call to a matching host with `SERVICE_INDEPENDENCE_VIOLATION`. The list is an operator input — a conformance suite populates it with the implementer's own domains — not a wire constant of Label 309. * A conformance harness runs the verifier against frozen fixture transactions in a network where the operator's own domains resolve to nowhere, and asserts the verifier still returns `valid`. The assertion is made at the OS network layer, not by grepping source — a verifier reaching a forbidden host by hardcoded IP would pass a source scan but fail this test. The verifier needs a reachable Cardano explorer and nothing implementer-specific. Content gateways, a recipient private key, and an off-chain leaves-list are optional inputs that unlock the content, recipient, and Merkle checks respectively. None of them is a service the standard names, and none of them is the publisher. ## Related pages [#related-pages] * [The record](/spec/the-record) — the wire format the structural validator checks against: label 309, the map shape, chunk reassembly, and the CDDL schema. * [Signatures](/spec/signatures) — the record-level COSE\_Sign1 construction, the domain-separated payload, and the strict Ed25519 verification rules. * [Sealed PoE](/spec/sealed-poe) — the encryption envelope, recipient key slots, and the unwrap the recipient verifier performs.