Keys

The Label 309 key model — one 32-byte seed, three algorithm keypairs derived from it by domain-separated HKDF-SHA-256, the per-slot key-encryption keys a sealed PoE derives on top, and how recipient public keys and secrets are encoded.

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

A Label 309 key set is rooted in a single value:

PropertyValue
Length32 bytes (256 bits)
SourceA cryptographically secure RNG, or any 32-byte value the user owns
RoleInput 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.

The seed is the whole identity

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

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

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:

InputError code
A Bech32 string whose checksum fails (a char flip, a truncation)SEED_STRING_BAD_CHECKSUM
A Bech32 string mixing upper- and lower-caseSEED_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 bytesSEED_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). 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

Each algorithm's private key is an independent HKDF-SHA-256 expansion of the same seed, per RFC 5869. 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:

Algorithminfo stringOutput
Ed25519cardano-poe-ed25519-v132-byte Ed25519 secret seed
X25519cardano-poe-x25519-v132-byte X25519 secret seed
mlkem768x25519cardano-poe-mlkem768x25519-v132-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 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 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. 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 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

HKDF's info parameter is its domain-separation tag: it binds the expanded output to a specific application context, and RFC 5869 §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

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.

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 || <slot KEM material> || 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; a verifier MUST use each byte-for-byte.

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 keyPublic-key HRPPublic-key visible prefixSecret HRPSecret visible prefix
x2551932-byte X25519 public keyageage1… (62 chars)AGE-SECRET-KEY-AGE-SECRET-KEY-1…
mlkem768x255191216-byte X-Wing public keyage1pqcage1pqc1… (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 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.

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

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.

  • Signatures — how the Ed25519 key signs a record and how the kid is verified.
  • Sealed PoE — how the X25519 and X-Wing public keys address an encrypted payload to specific recipients.
  • Algorithm registries — the named identifiers for signatures, KEMs, AEADs, and KDFs referenced here.