ADR 0015: Strict IETF X-Wing interop at algorithm-id 0x03
Status: Accepted (preview — tracks draft-connolly-cfrg-xwing-kem-10)
Context
Algorithm-id 0x02 (X25519MlKem768XWing) uses the X-Wing combiner
(SHA3-256 over ss_M || ss_X || ct_X || pk_X || label) but keeps the
PostQuantum.Hybrid v1 conventions everywhere else: classical-first
component order on the wire, an independently generated X25519 key and
ML-KEM key, and a 2432-byte expanded private key. A 0x02 blob is
therefore not interoperable with other X-Wing implementations
(CIRCL, libcrux, rustls), even though the combiner now matches the
draft exactly (see ADR 0013 / the 0x02 combiner fix).
The IETF draft defines X-Wing as a complete KEM, not just a combiner:
- The decapsulation key is a single 32-byte seed.
expanded = SHAKE-256(seed, 96);(pk_M, sk_M) = ML-KEM-768.KeyGen_internal(expanded[0:64]);sk_X = expanded[64:96];pk_X = X25519(sk_X, basepoint). - Encapsulation key:
pk_M || pk_X(1216 bytes, ML-KEM first). - Ciphertext:
ct_M || ct_X(1120 bytes, ML-KEM first). - Shared secret:
SHA3-256(ss_M || ss_X || ct_X || pk_X || "\x5c\x2e\x2f\x2f\x5e\x5c"). - ASN.1:
id-XWing = 1.3.6.1.4.1.62253.25722, with no inner ASN.1 wrapping — the SPKI BIT STRING is the raw 1216-byte key and the PKCS#8 OCTET STRING is the raw 32-byte seed.
The draft ships official test vectors (spec/test-vectors.json) and
CIRCL-generated x509 fixtures, so conformance is mechanically checkable.
Decision
Add a third KEM algorithm, HybridKemAlgorithm.XWing = 3, that is
byte-for-byte IETF X-Wing behind the 1-byte algorithm-id prefix:
| Object | Layout | Size |
|---|---|---|
| Public key | 0x03 \|\| pk_M \|\| pk_X |
1217 B |
| Private key | 0x03 \|\| seed |
33 B |
| Ciphertext | 0x03 \|\| ct_M \|\| ct_X |
1121 B |
Stripping the prefix yields genuine IETF X-Wing bytes; prepending
0x03 to foreign X-Wing material makes it importable. The PQ-first
component order and the seed-only private key are what distinguish
0x03 from 0x02 on the wire (public key and ciphertext total sizes
coincide at 1217/1121 — the algorithm-id byte is the discriminator,
which is why imports must dispatch on algorithm-id before length).
- Key generation: draw 32 random bytes, expand per the draft.
ML-KEM keygen-from-seed uses the native
MLKem.ImportPrivateSeedon .NET 10 and BouncyCastleMLKemPrivateKeyParameters.FromSeed(64-byted || z) on .NET 8 / fallback. SHAKE-256 comes from BouncyCastleShakeDigest(256)on both TFMs (the BCL's SHAKE is not available across the whole support matrix). - Encapsulation reuses the existing algorithm-agnostic flow; the
combiner registry maps
3to the existingXWingKemCombiner. - Decapsulation re-expands the seed each call (the draft's reference semantics). ML-KEM implicit rejection is preserved by the backends.
- SPKI/PKCS#8 use the real
id-XWingOID with the raw IETF inner bytes (1216-byte key / 32-byte seed, no0x03prefix), so exported envelopes are directly consumable by other X-Wing stacks. The placeholder-PEN OIDs of ADR 0014 remain for algorithm-ids 1 and 2. - Conformance tests vendor the draft's official vectors
(seed/eseed/pk/ct/ss KATs via public import + decapsulate paths) and
the CIRCL x509 fixtures (
xwing.pub/xwing.priv).
0x02 stays supported and unchanged; its docs steer new interop work
to 0x03.
Rationale
- Wire-format stability is sacred. IETF X-Wing semantics cannot be
retrofitted onto
0x02(different component order, different private key) — a new algorithm-id is exactly what the versioning rule in CLAUDE.md / ADR 0007 prescribes. - Seed-only private keys are the draft's deliberate design (fully deterministic expansion, nothing-up-my-sleeve, tiny at-rest secrets). Diverging would forfeit interop with every other implementation.
- No hand-rolled crypto. SHAKE-256, X25519, and ML-KEM-from-seed all come from BouncyCastle or the BCL; the only new code is glue and byte layout.
- A real OID is finally legitimate here. Unlike the LAMPS composite
drafts (still unallocated),
id-XWingis concretely assigned in the X-Wing draft's ASN.1 module and already used in the wild (CIRCL).
Consequences
- New public surface:
HybridKemAlgorithm.XWing = 3(PublicAPI.Unshipped.txt entry; XML docs flag preview status until the draft becomes an RFC — if a later draft changes the scheme, a new algorithm-id will be allocated rather than mutating0x03). MlKemBackendgains seed-based operations (public-key derivation and decapsulation from a 64-byted || z).- Private-key material handling: the 32-byte seed is the secret;
expanded intermediates (
expanded[96],sk_X, ML-KEM handles) are zeroed/disposed after each use like all other key material. - Import dispatch order changes from length-first to
algorithm-id-first, since
0x03private keys are 33 bytes, not 2433. Existing v1/v2 blobs parse exactly as before. - SPEC.md gains a
0x03section; README install/interop matrix and CHANGELOG updated in the same PR.