Skip to content

Add mlkem768x25519-sha256 post-quantum hybrid KEX - #235

Open
Wellz26 wants to merge 1 commit into
apple:mainfrom
Wellz26:feat/mlkem768x25519-sha256-kex
Open

Add mlkem768x25519-sha256 post-quantum hybrid KEX#235
Wellz26 wants to merge 1 commit into
apple:mainfrom
Wellz26:feat/mlkem768x25519-sha256-kex

Conversation

@Wellz26

@Wellz26 Wellz26 commented May 25, 2026

Copy link
Copy Markdown

Motivation

OpenSSH 9.9+ ships mlkem768x25519-sha256 as its default key exchange,
giving SSH transport sessions resistance to harvest-now-decrypt-later
attacks against a future cryptographically-relevant quantum computer.
swift-nio-ssh currently only supports classical ECDH primitives
(curve25519-sha256, ecdh-sha2-nistp256/384/521), so any iOS or macOS
client built on it (e.g. Termius-style apps using Citadel, or anything
embedding NIOSSH directly) is left behind whenever it talks to a modern
sshd.

Apple shipped the MLKEM768 primitive in
swift-crypto 4.5.0
(public MLKEM768.PublicKey / MLKEM768.PrivateKey with
encapsulate() / decapsulate()), so the underlying KEM is now
available without any FFI, BoringSSL detour, or third-party dependency
— only the SSH wire-format integration was missing. This PR adds that
integration.

What this PR does

  1. New file Sources/NIOSSH/Key Exchange/MLKEM768X25519KeyExchange.swift
    implementing EllipticCurveKeyExchangeProtocol for the hybrid:
    • Client init payload (single SSH string):
      `MLKEM768 encapsulation key (1184 B) || X25519 public key (32 B)`
    • Server reply payload (single SSH string):
      `MLKEM768 ciphertext (1088 B) || X25519 public key (32 B)`
    • Shared secret: `K = SHA-256(MLKEM_SS || X25519_SS)`,
      hashed in as an `mpint` per RFC 4253 §8
    • Exchange-hash function: SHA-256
    • RFC 4253 §7.2 key derivation reimplemented for SHA-256
  2. Registered at the top of `SSHKeyExchangeStateMachine.supportedKeyExchangeImplementations`,
    gated on `#available(macOS 26.0, iOS 26.0, ...)` (the deployment-target
    floor for the backing CryptoKit primitive). Classical peers
    transparently fall through to `curve25519-sha256`.
  3. `Package.swift` swift-crypto floor bumped from `1.0.0` → `4.5.0`
    for the public `MLKEM768` API. This is a breaking change for
    downstream consumers still on older swift-crypto — flagging it
    explicitly so maintainers can decide whether to phase it in
    differently.
  4. Tests: round-trip, previous-session-identifier reuse, truncated
    client-init rejection, algorithm-name spelling.

Spec references

Open design questions for maintainers

(1) Mutating + throwing initiateKeyExchangeClientSide

`MLKEM768.PrivateKey.init()` is `throws`, but
`EllipticCurveKeyExchangeProtocol.init` is neither throwing nor
mutating. To avoid changing the protocol surface, this PR:

  • bumps `initiateKeyExchangeClientSide(allocator:)` to
    `mutating func` on the protocol (a backwards-compatible widening —
    existing non-mutating ECDH conformers still work)
  • lazy-generates the ML-KEM keypair inside that method and
    `preconditionFailure`s on the (theoretically unreachable)
    generation-failure path

A cleaner long-term shape would be a `throws` variant or moving
keypair generation into a separately-throwing init. Happy to refactor
in whichever direction you prefer.

(2) swift-crypto floor bump

`MLKEM768` is only available on swift-crypto >= 4.5.0, and the actual
primitive routes through CryptoKit on the macOS 26 / iOS 26 family
(hence the `#available` gate). Older deployment targets will compile,
link, and simply not register the hybrid algorithm — `mlkem768x25519-sha256`
silently drops out of the negotiation list. Is that the policy you'd
want, or should the gate fail loudly?

(3) ML-KEM-1024 follow-up

swift-crypto 4.5.0 also exposes `MLKEM1024`. OpenSSH hasn't
standardized an `mlkem1024x25519-sha384` algorithm name, but if you'd
like a 1024-variant for higher security margin I can extend the same
pattern in a follow-up.

Status

Opening as DRAFT — the implementation builds clean and the new
test target passes (`swift test --filter MLKEM768X25519KeyExchangeTests`),
but I want maintainer feedback on the design questions above before
I sand the rough edges (interop tests against an OpenSSH 9.9 server,
fuzz coverage, additional KAT vectors, etc.). Happy to iterate.

Verification

  • `swift build` — green
  • `swift test --filter MLKEM768X25519KeyExchangeTests` — 4/4 green
  • `swift test --filter KeyExchangeTests` (existing ECDH suite) — 14/14 green; no regressions

— Wellington Mukahiwa (@Wellz26)

Implements the OpenSSH 9.9 / IETF draft-kampanakis-curdle-ssh-pq-ke
algorithm, combining ML-KEM-768 (FIPS 203) with X25519. K is derived
as SHA-256(MLKEM_SS || X25519_SS), giving session keys that remain
secure if either primitive is compromised.

Built on swift-crypto 4.5.0's public MLKEM768 API (gated to
macOS 26 / iOS 26 / watchOS 26 / tvOS 26 / visionOS 26 / macCatalyst 26
where the backing CryptoKit implementation is available). On older
deployment targets the algorithm list silently falls back to the
existing ECDH set.

The new struct slots into supportedKeyExchangeImplementations at the
top of the preference order so OpenSSH 9.9+ peers negotiate the
hybrid by default, with classical-only peers transparently falling
through to curve25519-sha256.

To accommodate ML-KEM-768 keygen (which is throwing on swift-crypto)
without breaking the existing protocol surface, the keypair is
generated lazily inside initiateKeyExchangeClientSide. That method
is now `mutating func` on EllipticCurveKeyExchangeProtocol; existing
ECDH conformers are unaffected. Open question for maintainers: keep
this shape, or grow a throwing init on the protocol?

Includes a round-trip test, previous-session-identifier test,
truncated-payload rejection test, and algorithm-name spelling test
(all green; full existing KEX suite stays green).
@Wellz26
Wellz26 marked this pull request as ready for review May 25, 2026 23:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant