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:
| 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.
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-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQLFUN82A 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
0xprefix 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). 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:
| 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:
- 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
HashLenzero bytes — 32 zero bytes for SHA-256 — so every conformant library reaches the same extract step. - The output is 32 bytes. Each expansion requests exactly 32 bytes (a single HKDF block for SHA-256).
- The
infostrings are exact ASCII. Eachinfovalue 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
infostring ends in-v1. Adopting a different curve or a different hybrid in a future revision derives a fresh-v2key 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 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 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.
Related pages
- Signatures — how the Ed25519 key signs a record and how the
kidis 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.