PostQuantum.Hybrid — Specification

This document is the normative wire-format and algorithm specification for PostQuantum.Hybrid v1. It is intended to be sufficient to write a compatible implementation in another language. The library itself is the reference implementation.

Versioning

  • Library version follows SemVer.
  • Wire-format version is bound to a single-byte algorithm identifier prefixed to every serialized artifact. Any future change that affects the on-the-wire bytes must use a new algorithm identifier so v1 blobs continue to parse and verify.

Algorithm identifiers

Family Value Meaning
Hybrid KEM 0x01 X25519MlKem768 — X25519 (RFC 7748) + ML-KEM-768 (FIPS 203), HKDF-SHA256 combiner
Hybrid KEM 0x02 X25519MlKem768XWing (preview) — same components and byte layout as 0x01, X-Wing SHA3-256 combiner (see "Combiner")
Hybrid KEM 0x03 XWing (preview) — strict IETF X-Wing (draft-connolly-cfrg-xwing-kem-10): PQ-first byte order, 32-byte seed private key (see "IETF X-Wing")
Hybrid signatures 0x01 Ed25519MlDsa65 — Ed25519 (RFC 8032) + ML-DSA-65 (FIPS 204)

Each family numbers its identifiers independently. The library refuses to parse blobs whose first byte is not in the supported set.

Hybrid KEM (X25519MlKem768)

Component sizes

Quantity Bytes
X25519 public key 32
X25519 private key (clamped scalar) 32
X25519 shared secret 32
ML-KEM-768 encapsulation key 1184
ML-KEM-768 decapsulation key (FIPS 203 standard encoding) 2400
ML-KEM-768 ciphertext 1088
ML-KEM-768 shared secret 32

Wire formats

All multi-byte integers are little-endian. There are none in v1 — every layout is a fixed-size concatenation.

HybridKemPublicKey   (1217 bytes) := algId(1) || X25519_pub(32)  || MLKEM768_pub(1184)
HybridKemPrivateKey  (2433 bytes) := algId(1) || X25519_priv(32) || MLKEM768_priv(2400)
HybridKemCiphertext  (1121 bytes) := algId(1) || X25519_eph_pub(32) || MLKEM768_ct(1088)
HybridKemSharedSecret(  32 bytes) := raw HKDF output (see "Combiner" below)

X25519_eph_pub is the ephemeral public key the sender generated for this encapsulation; in DH-KEM terms it is the "ciphertext".

Encapsulation

inputs:  HybridKemPublicKey { X25519_pub, MLKEM768_pub }
outputs: HybridKemCiphertext { X25519_eph_pub, MLKEM768_ct }
         sharedSecret (32 bytes)
  1. Generate an ephemeral X25519 key pair (X25519_eph_priv, X25519_eph_pub) using a cryptographically secure RNG.
  2. Compute ss_X = X25519(X25519_eph_priv, X25519_pub) — 32 bytes.
  3. Run MLKEM768.Encaps(MLKEM768_pub) -> (MLKEM768_ct, ss_M) — 1088 + 32 bytes.
  4. sharedSecret = Combine(ss_X, ss_M, X25519_eph_pub, MLKEM768_ct).
  5. Return the assembled HybridKemCiphertext and sharedSecret.

Decapsulation

inputs:  HybridKemPrivateKey, HybridKemCiphertext
outputs: sharedSecret (32 bytes)
  1. Compute ss_X = X25519(X25519_priv, X25519_eph_pub).
  2. Run MLKEM768.Decaps(MLKEM768_priv, MLKEM768_ct) -> ss_M. Per FIPS 203, this never throws on a malformed ciphertext; it returns an indistinguishable pseudorandom value (implicit rejection).
  3. sharedSecret = Combine(ss_X, ss_M, X25519_eph_pub, MLKEM768_ct).
  4. Return sharedSecret. If the sender's encapsulation was honest and intact, this equals the sender's sharedSecret; otherwise it diverges pseudorandomly and downstream symmetric decryption authentically fails.

Combiner

sharedSecret = HKDF-SHA256(
    ikm  = ss_X || ss_M,
    salt = empty,
    info = label || X25519_eph_pub || MLKEM768_ct,
    L    = 32 )

label = ASCII "PostQuantum.Hybrid v1 KEM X25519-MLKEM768"   (41 bytes)

The info parameter binds the entire transcript so any tampering with either ciphertext component yields a different shared secret. This pattern is analogous to (but distinct from) the X-Wing construction; see docs/adr/0003-kem-combiner.md for rationale.

Combiner at algorithm-id 0x02 (X-Wing, preview)

Algorithm-id 0x02 uses the same key/ciphertext byte layouts as 0x01 (classical-first, sizes above) but derives the shared secret with the X-Wing combiner from draft-connolly-cfrg-xwing-kem:

sharedSecret = SHA3-256( ss_M || ss_X || X25519_eph_pub || X25519_pub || XWingLabel )

XWingLabel = 0x5c 0x2e 0x2f 0x2f 0x5e 0x5c   (the 6-byte X-Wing label "\.//^\", hashed last)

History: releases up to and including v1.0.1 hashed the label first (SHA3-256(label || ss_M || ...)). The current X-Wing draft moved the label to the end (draft-03 changelog: "Move label at the end"; unchanged through draft-10), so the 0x02 preview combiner was corrected to label-last after v1.0.1. Secrets derived at 0x02 by v1.0.1 differ from those derived by later versions — mixed-version peers fail closed at the AEAD layer. Algorithm-id 0x01 is unaffected.

X25519_pub is the recipient's static X25519 public key. Note that MLKEM768_ct is not hashed directly — per the X-Wing analysis, ss_M already depends on it (ML-KEM is implicitly-rejecting), while the X25519 transcript must be bound explicitly.

This is not IETF X-Wing wire interop. The IETF construction orders components post-quantum-first and has its own single-blob encodings; 0x02 applies only the X-Wing combiner formula to the v1 byte layout. For strict IETF X-Wing interop use algorithm-id 0x03 (next section). See docs/adr/0013-xwing-combiner-preview.md.

IETF X-Wing (XWing, algorithm-id 0x03, preview)

Algorithm-id 0x03 is byte-for-byte IETF X-Wing per draft-connolly-cfrg-xwing-kem-10 behind the 1-byte algorithm-id prefix. Stripping the prefix yields genuine X-Wing bytes consumable by other implementations (CIRCL, libcrux, …); prepending 0x03 to foreign X-Wing material makes it importable. See docs/adr/0015-ietf-xwing-algorithm-id-3.md.

Wire formats

HybridKemPublicKey  (1217 bytes) := algId(1) || MLKEM768_pub(1184) || X25519_pub(32)
HybridKemPrivateKey (  33 bytes) := algId(1) || seed(32)
HybridKemCiphertext (1121 bytes) := algId(1) || MLKEM768_ct(1088)  || X25519_eph_pub(32)

Note the post-quantum-first component order (the draft's pk = pk_M || pk_X, ct = ct_M || ct_X) — the reverse of 0x01/0x02. Public-key and ciphertext total lengths coincide with 0x01/0x02; the algorithm-id byte is the discriminator, so parsers must dispatch on it before assuming a component order.

Key derivation

The entire decapsulation key is the 32-byte seed:

expanded = SHAKE-256(seed, 96)
(MLKEM768_pub, MLKEM768_priv) = MLKEM768.KeyGen_internal(expanded[0:64])   # d || z
X25519_priv = expanded[64:96]
X25519_pub  = X25519(X25519_priv, basepoint)

Key generation draws seed from a cryptographically secure RNG; decapsulation re-expands it per the formulas above.

Encapsulation / decapsulation

Identical flow to 0x01 (ephemeral X25519 + ML-KEM-768 encapsulation, implicit rejection preserved), with the shared secret derived by the X-Wing combiner defined for 0x02 above:

sharedSecret = SHA3-256( ss_M || ss_X || X25519_eph_pub || X25519_pub || XWingLabel )

This matches the draft exactly; the implementation passes the draft's official test vectors (see "Test vectors").

ASN.1 encodings (real OID)

Unlike the placeholder OIDs used by 0x01/0x02 (ADR 0014), 0x03 uses the draft's allocated OID id-XWing = 1.3.6.1.4.1.62253.25722 with no inner ASN.1 wrapping:

  • SubjectPublicKeyInfo: the BIT STRING is the raw 1216-byte MLKEM768_pub || X25519_pub (no algorithm-id prefix).
  • PKCS#8 PrivateKeyInfo: the OCTET STRING is the raw 32-byte seed.

These envelopes interoperate directly with other X-Wing stacks; the test suite re-encodes Cloudflare CIRCL's published fixtures byte-for-byte.

Hybrid signatures (Ed25519MlDsa65)

Component sizes

Quantity Bytes
Ed25519 public key 32
Ed25519 private key (seed) 32
Ed25519 signature 64
ML-DSA-65 public key 1952
ML-DSA-65 private key (FIPS 204 standard encoding) 4032
ML-DSA-65 signature 3309

Wire formats

HybridSignaturePublicKey  (1985 bytes) := algId(1) || Ed25519_pub(32)  || MLDSA65_pub(1952)
HybridSignaturePrivateKey (4065 bytes) := algId(1) || Ed25519_priv(32) || MLDSA65_priv(4032)
HybridSignature           (3374 bytes) := algId(1) || Ed25519_sig(64)  || MLDSA65_sig(3309)

Signing

inputs:  HybridSignaturePrivateKey, message
outputs: HybridSignature
  1. Compute sig_E = Ed25519.Sign(Ed25519_priv, message).
  2. Compute sig_M = MLDSA65.Sign(MLDSA65_priv, message, ctx = empty). This is "pure" FIPS-204 ML-DSA with an empty context; ML-DSA signing is randomized by default, so two signatures over the same message under the same key WILL differ.
  3. Return algId || sig_E || sig_M.

Verification

inputs:  HybridSignaturePublicKey, message, HybridSignature
output:  bool
  1. If |signature| != 3374, return false.
  2. If signature[0] != publicKey.algorithmId, return false.
  3. Compute ok_E = Ed25519.Verify(Ed25519_pub, message, sig_E).
  4. Compute ok_M = MLDSA65.Verify(MLDSA65_pub, message, sig_M, ctx = empty).
  5. Return ok_E AND ok_M. Both must verify.

PEM encoding

PEM (RFC 7468) is supported with library-specific labels. The body is the raw byte format above, base64-encoded with 64-character lines.

Label Wraps
PQH HYBRID KEM PUBLIC KEY HybridKemPublicKey
PQH HYBRID KEM PRIVATE KEY HybridKemPrivateKey
PQH HYBRID SIG PUBLIC KEY HybridSignaturePublicKey
PQH HYBRID SIG PRIVATE KEY HybridSignaturePrivateKey

Ciphertexts and signatures have raw binary forms only; PEM-wrapping them is not idiomatic.

Test vectors (informative)

Algorithm-id 0x03 (IETF X-Wing) has deterministic key derivation and decapsulation, and the test suite validates both against the draft's three official KAT vectors plus the CIRCL x509 fixtures (vendored under tests/PostQuantum.Hybrid.Tests/fixtures/xwing/).

For 0x01/0x02, all relevant flows are randomized, so test vectors cannot be made deterministic without exposing internal RNG state. The test suite verifies:

  • Sender and receiver agree on the 32-byte shared secret after KEM encapsulation/decapsulation.
  • Hybrid signatures round-trip via sign/verify.
  • Tampering with any byte of any wire artifact causes verification to fail (or, for KEM, causes the derived secret to diverge).
  • All wire blobs have the exact sizes stated above.

Implementers porting to another language should validate their port by cross-verifying signatures and KEM transcripts produced by this reference implementation.