Procházet zdrojové kódy

Document post-quantum handshakes as hybrid only, and the crossing window

JackDoan před 6 dny
rodič
revize
1d0281cba5
2 změnil soubory, kde provedl 79 přidání a 91 odebrání
  1. 73 83
      docs/pq-handshake.md
  2. 6 8
      examples/config.yml

+ 73 - 83
docs/pq-handshake.md

@@ -1,16 +1,17 @@
 # Post-quantum handshakes
 
-Status: draft. Decisions below were made on 2026-10-13 (phase 1) and 2026-10-14 (phase 2); open
-items are listed at the end. Both phases are implemented (see
-[phase 1](#implementation-phase-1) and [phase 2](#implementation-phase-2)).
+Status: draft. Decisions below were made on 2026-10-13 and 2026-10-14; open items are listed at
+the end. Post-quantum handshakes ship hybrid only, on version 3 certificates (decided
+2026-10-14); see [Implementation](#implementation-certificates-and-the-hybrid-handshake).
 
 ## Goals
 
 1. **Confidentiality against harvest-now-decrypt-later.** Traffic recorded today must stay
    secret after a cryptographically relevant quantum computer exists. This needs a
    post-quantum key exchange now.
-2. **Post-quantum authentication, later.** Needs certificates whose CA signature and host
-   keys are post-quantum. This requires reissuing certificates, so it ships second.
+2. **Post-quantum authentication.** Needs certificates whose CA signature and host keys are
+   post-quantum, so certificates have to be reissued or extended. Post-quantum key exchange
+   ships only together with it: there is no post-quantum handshake on version 2 certificates.
 3. **Never rely on IP fragmentation.** ML-KEM and ML-DSA do not fit in one packet, so the
    handshake must carry messages larger than the path MTU itself.
 
@@ -27,43 +28,30 @@ The only signature in the system stays the CA signature inside the certificate.
 | Today's IX msg1 / msg2 (v2 cert, 151 B) | 252 B / 287 B |
 
 `crypto/mlkem` is available in Go 1.26; `crypto/mldsa` arrives in Go 1.27, which `go.mod`
-requires since phase 2.
-
-## Phase 1: ephemeral ML-KEM on today's certificates
-
-A new handshake subtype, `ix_psk2_mlkem1024` (2), IX-shaped (2 messages; the responder
-completes on msg1 as today), using the flynn/noise `psk2` modifier. The Noise protocol name
-becomes `Noise_IXpsk2_...`, which separates it from the classical subtype.
-
-- **msg1**: the initiator generates a fresh ML-KEM-1024 key pair and places the
-  encapsulation key in `NebulaHandshakeDetails.KEMKey` (field 9). msg1's payload is mixed
-  into the Noise transcript, so it cannot be stripped or replaced without msg2 failing.
-  With the psk modifier Noise also mixes `e` into the cipher key, so msg1's static key and
-  payload are encrypted, though only under a key derived from the public ephemeral.
-- **Responder**: after `ReadMessage(msg1)` it encapsulates to that key, obtaining
-  `(ct, ss)`, and calls `HandshakeState.SetPresharedKey(psk)` before writing msg2, with
-  `psk = HKDF-Extract(SHA-256, salt = "nebula ix_psk2_mlkem1024 v1", ikm = ss || ct)`.
-  The ciphertext is bound in because, unlike the encapsulation key, it is not part of the
-  Noise transcript.
-- **msg2** on the wire is `ct || noise_msg2`. The ciphertext travels outside the Noise
-  message because the initiator needs `ss` before processing msg2's `psk` token. Any change
-  to `ct` yields a different `ss` (ML-KEM implicit rejection) and the handshake fails.
-
-Transport keys depend on both the X25519 DH outputs and the ML-KEM shared secret.
-Authentication is unchanged and classical. With a minimal v2 certificate msg1 and msg2 are
-about 1.84 KB each, so each travels as 2 data and 2 repair fragments of about 936 bytes.
-A KEM key on the classical subtype, or a missing or malformed one on this subtype, is a fatal
-protocol error; a forged ciphertext only fails that packet.
-
-The `psk` token is the last in msg2, so the responder's static key `s` is encrypted under `ee` and
-`se` only, and a future quantum attacker can read it from a recording; the certificate and indexes
-follow the psk and stay protected. Identity hiding is a non-goal, and phase 2's KEM-first ordering
-closes this anyway.
-
-## Phase 2: hybrid IX with certificate v3
-
-Phase 2 adds post-quantum authentication. Decisions were made on 2026-10-14; it is implemented on
-the `pq-identity` branch (see [Implementation](#implementation-phase-2)).
+requires.
+
+## An ephemeral-KEM handshake, built and dropped
+
+The first design was `ix_psk2_mlkem1024`: classical IX on version 2 certificates with an ephemeral
+ML-KEM-1024 exchange mixed in through the `psk2` slot, so recorded traffic was protected without
+reissuing certificates. It was built, field tested and then dropped on 2026-10-14, before any
+release. Post-quantum handshakes ship hybrid only, for three reasons:
+
+- It left authentication classical, which a deployment wanting quantum safety needs anyway, and
+  a second post-quantum handshake would be twice the code to review and keep.
+- It completes in two messages, so the responder could not tell which of two crossing attempts
+  the initiator kept (see [Crossing attempts](#three-messages-in-the-manager)); on real hosts 5 of
+  100 first pings failed at 50 ms round trip for that reason.
+- Its `psk` token came last in msg2, so the responder's static key travelled under classical keys
+  only.
+
+Subtype 2 and payload field 9 (its encapsulation key) stay reserved. The code is on the
+`pq-handshake` branch for reference.
+
+## Hybrid IX with certificate v3
+
+Decided on 2026-10-14 and implemented on the `pq-identity` branch (see
+[Implementation](#implementation-certificates-and-the-hybrid-handshake)).
 
 ### Certificate v3
 
@@ -95,7 +83,7 @@ CertificateV3 ::= SEQUENCE {
   form that replaces the old one, since one v2 CA has one v3 extension.
 
 - **The v2 form.** Fields 0 to 3 are a byte exact v2 certificate, signed by the v2 form of the CA.
-  A v3 holder presents it in classical and phase 1 handshakes, so peers that only speak v2 accept
+  A v3 holder presents it in classical handshakes, so peers that only speak v2 accept
   it as they always have, and old parsers never see v3 bytes. A node therefore needs one
   certificate, not a v2 and v3 pair.
 - **Signatures.** Both must verify. The ML-DSA-87 signature covers the classical signature too, so
@@ -197,7 +185,7 @@ flynn/noise) before this can merge.
 
 - **Responder.** After msg2 the responder keeps the handshake pending under its local index, the
   multi-message responder the manager lacked before. A repeated msg1 maps to it through the
-  answered-msg1 cache and gets msg2 resent with fresh symbols, as in phase 1. msg3's fragments name
+  answered-msg1 cache and gets msg2 resent with fresh symbols. msg3's fragments name
   the responder's index, so they reach the pending entry and never the pre-authentication table;
   its reassembly is capped at 4 KiB. Until msg3 verifies, data for that index is dropped quietly:
   a RecvError would tear down the initiator's new tunnel. On completing, the responder sends an
@@ -211,22 +199,33 @@ flynn/noise) before this can merge.
 - **Lost confirmation.** A msg3 fragment for a v3 tunnel the responder already completed means the
   initiator is still waiting, so the responder confirms again, at most once per retry interval and
   only to the tunnel's own remote, never to the fragment's source.
-- **Crossing attempts.** The initiator's classical attempt can reach the responder, which
-  completes it at once, while the hybrid handshake still waits for msg3. If the initiator got the
-  hybrid msg2 first it has committed to hybrid and sends msg3, so the responder takes the hybrid
-  tunnel even though the classical one is newer, rather than rejecting it as too old.
+- **Crossing attempts.** The initiator alternates, and its first classical attempt follows the
+  hybrid msg1 anywhere from right away to three retry intervals later (the retry timer lands two
+  slots ahead on the timer wheel, which moves on whole ticks), so at real round-trip times it
+  often goes out before msg2 is back. Answered, it would complete at once on the responder while
+  the hybrid handshake waits for msg3. So for four retry intervals after answering a peer's new
+  hybrid msg1, the responder leaves that peer's classical msg1s unanswered: the crossing one
+  arrives within the window whatever the round trip, because both take the same path. The
+  window is short so an initiator whose hybrid attempt fails on its own side (one that does not
+  yet trust our v3 CA, during a rollout) still gets a classical answer to a later attempt; a
+  block lasting as long as the hybrid state would refuse it until its handshake timed out, and
+  its next handshake would start with a hybrid msg1 again. A classical msg1 that still crosses,
+  past the window on a very slow path, is answered, but msg3 settles it: the initiator only
+  sends msg3 after committing to hybrid, so the responder takes the hybrid tunnel even though
+  the classical one is newer, rather than rejecting it as too old.
 - **A failed attempt.** A post-quantum attempt can fail fatally where the classical one would not,
   for example a v3 certificate from a CA the initiator trusts only in its v2 form. The failed
   attempt is dropped and the handshake continues classical. With `require` the handshake ends.
 
 ### Rollout with v3
 
-- A node with a v3 certificate initiates hybrid handshakes in place of phase 1 ones, alternating
-  with classical as before. It never initiates phase 1, but still answers phase 1 handshakes with
-  its v2 form, so a peer with only a v2 certificate gets a classical tunnel from it.
-- A responder without a v3 certificate drops hybrid msg1s before reassembly.
-- `handshakes.pq.require` with a v3 certificate means hybrid only, in both directions: phase 1
-  msg1s are dropped too, so every peer needs a v3 certificate.
+- `handshakes.pq` only does anything on a node with a v3 certificate. Without one, `initiate`,
+  `accept` and `sticky` log a warning and are ignored, and `require` is refused at startup and
+  on reload, since such a node could reach no one.
+- A responder without a v3 certificate, or whose suite uses another KEM, drops hybrid msg1s
+  before reassembly, and the initiator falls back to classical.
+- `handshakes.pq.require` means hybrid only, in both directions, so every peer needs a v3
+  certificate.
 - Post-quantum authentication is required through the CA pool, by not listing the CA's v2 form.
   A sticky or `require` initiator resists active downgrade of the key exchange; only the CA pool
   resists downgrade of authentication, since a quantum attacker could forge a v2 form.
@@ -354,7 +353,7 @@ Implemented in `handshake/fragment` (`Sender`, `Assembly`, and the capped `Table
   running Noise or certificate verification.
 - Before an address is validated, a responder sends at most 3x the bytes it received from
   it (QUIC's rule). Normal handshakes sit near 1.1-1.2x.
-- In phase 2 the initiator retransmits msg3 until any authenticated packet arrives from
+- The initiator retransmits msg3 until any authenticated packet arrives from
   the responder; the responder sends a small confirmation on completion and drops tunnel
   traffic until msg3 verifies.
 
@@ -419,7 +418,7 @@ does not downgrade.
   `handshakes.pq.initiate`, `handshakes.pq.accept`. `handshakes.pq.repair_delay` (default
   10ms) sets how long repair fragments trail the data fragments.
 - `handshakes.pq.require` makes every handshake PQ: it implies `initiate` and `accept`,
-  never offers a classical attempt, and drops classical msg1s.
+  never offers a classical attempt, and drops classical msg1s. It needs a v3 certificate.
 - Old nodes silently drop unknown subtypes, so discovery is speculative: an initiator with
   `initiate` enabled alternates PQ and classical attempts on successive timer retries,
   keeping both pending, and the first to complete wins. A path nothing has been sent over
@@ -444,14 +443,14 @@ mean burst length in packets. First-attempt success for the whole handshake with
 
 | Handshake | Loss | Burst 1 | Burst 2 | Burst 4 | Burst 8 |
 |---|---|---|---|---|---|
-| phase 1 (2, 2) no FEC | 5% | 81.4% | 85.6% | 87.9% | 89.0% |
-| phase 1 (2, 2) FEC | 5% | 99.9% | 99.4% | 99.3% | 98.6% |
-| phase 1 (2, 2) FEC | 10% | 99.3% | 97.5% | 97.4% | 96.3% |
-| phase 2 (7, 9, 2) no FEC | 5% | 39.8% | 57.5% | 70.4% | 77.7% |
-| phase 2 (7, 9, 2) FEC | 5% | 99.6% | 94.2% | 90.4% | 88.8% |
-| phase 2 (7, 9, 2) FEC | 10% | 95.9% | 84.6% | 79.0% | 77.3% |
-
-With fresh repair symbols, phase 2 completes within two attempts at least 99.1% of the time
+| two messages (2, 2) no FEC | 5% | 81.4% | 85.6% | 87.9% | 89.0% |
+| two messages (2, 2) FEC | 5% | 99.9% | 99.4% | 99.3% | 98.6% |
+| two messages (2, 2) FEC | 10% | 99.3% | 97.5% | 97.4% | 96.3% |
+| hybrid (7, 9, 2) no FEC | 5% | 39.8% | 57.5% | 70.4% | 77.7% |
+| hybrid (7, 9, 2) FEC | 5% | 99.6% | 94.2% | 90.4% | 88.8% |
+| hybrid (7, 9, 2) FEC | 10% | 95.9% | 84.6% | 79.0% | 77.3% |
+
+With fresh repair symbols, the hybrid handshake completes within two attempts at least 99.1% of the time
 in every scenario above, and within three at least 99.99%.
 
 ## Prior art
@@ -471,10 +470,8 @@ in every scenario above, and within three at least 99.99%.
 - **TFTP, NFS duplicate request cache, memcached UDP, IP reassembly bugs**: retransmit
   storms, ID-wrap misassembly, reflection amplification, and allocation from claimed sizes.
 
-## Implementation (phase 1)
+## Implementation: fragment transport and manager
 
-- `handshake`: the `ix_psk2_mlkem1024` subtype (`patterns.go`, `kem.go`), the `KEMKey`
-  payload field, and the psk2 plumbing in `Machine`.
 - `handshake/fec`, `handshake/fragment`: the code and the fragment transport.
 - `handshake_manager_pq.go` and the manager:
   - **Initiator.** Attempts alternate PQ and classical, starting with PQ (sticky peers get
@@ -492,16 +489,16 @@ in every scenario above, and within three at least 99.99%.
 - The pre-authentication table is capped at 4 MiB, 1024 entries, 4 per source (IPv4 /32,
   IPv6 /64, or relay channel), 16 KiB messages and a 3 s idle timeout. Cookies are
   demanded from half full.
-- Tests: Noise-level KEM tests, codec and fragment tests and fuzzers, manager unit tests
+- Tests: codec and fragment tests and fuzzers, manager unit tests
   for the response budget and rate limit, and e2e tests for a clean handshake, loss of
   data fragments in both directions, fallback to a peer that does not accept PQ, a lost
   msg2 recovered by fresh symbols, sticky mode under an attacker dropping PQ packets,
   relays, index cleanup on completion, fallback and timeout, and a responder flooded from
   spoofed sources demanding and accepting a cookie. Unit tests cover cookie binding and
-  rotation, the never-amplify rule, the initiator's rate limited resend, and reload. ML-KEM and HKDF also
-  pass under `GOEXPERIMENT=boringcrypto` and `GODEBUG=fips140=on`.
+  rotation, the never-amplify rule, the initiator's rate limited resend, and reload. ML-KEM also
+  passes under `GOEXPERIMENT=boringcrypto` and `GODEBUG=fips140=on`.
 
-## Implementation (phase 2)
+## Implementation: certificates and the hybrid handshake
 
 - `cert`: `cert_v3.go` (format, `V2Form`, `ExtendToV3`, `SignPQ`), `pq_suite.go` (`PQSuite` and
   the suite table), `SignHybridWith` and the composite `Sign` path, the CA pool's v3 index, suite
@@ -512,8 +509,9 @@ in every scenario above, and within three at least 99.99%.
   `Machine.PeerCert` for a responder that has to act before completion.
 - `pki.go` loads a v3 certificate with its composite key and derives the v2 form; messages after
   the handshake treat v3 tunnels as v2 (`protocolVersion`).
-- `handshake_manager_hybrid.go`: pending responders, confirmation and reconfirmation, and the
-  initiator's msg3 resends and teardown; `outside.go` keeps quiet about pending responder indexes.
+- `handshake_manager_hybrid.go`: pending responders, confirmation and reconfirmation, the
+  initiator's msg3 resends and teardown, and the crossing window; `outside.go` keeps quiet about
+  pending responder indexes.
 - `nebula-cert`: `ca`, `keygen` and `sign` take `-version 3`, `keygen -in-key` extends an existing
   host key, `sign -version 2` with a v3 CA signs as its v2 form, and `pq-extend` upgrades v2
   certificates in place, keeping an encrypted CA key encrypted. `ca`, `keygen` and `pq-extend` of a
@@ -538,21 +536,13 @@ in every scenario above, and within three at least 99.99%.
   it times out, and the Machine reports it as recoverable. Blocks publishing the module; the fix is
   a deferred rollback that also restores `k`, `n` and `hasK`. Smaller pre-publish items are in
   [DefinedNet/noise#3](https://github.com/DefinedNet/noise/issues/3).
-- The initiator's first classical retry can fire 0-100 ms after the post-quantum attempt, so at
-  real round-trip times it often crosses it. For hybrid, msg3 settles which tunnel the responder
-  keeps, but the crossing classical tunnel lingers on the responder until it is found dead. The
-  ephemeral-KEM handshake has no msg3: the responder keeps the newer classical tunnel while the
-  initiator keeps the KEM one, and replies go to a released index (field test: 5 of 100 first
-  pings failed at 50 ms round trip, no loss). Options: no classical turn until one retry interval
-  after the post-quantum attempt, and/or complete a losing attempt's late msg2 only to send it a
-  CloseTunnel.
-
 Field tested on real hosts on 2026-10-14 (`pq-lab/2026-10-14-hybrid/`, untracked): LAN median 3.2 ms
 against 1.4 ms classical, +2.0 ms at 50 ms round trip, no fallbacks with `require` under loss.
 
 The cryptographic review of 2026-10-14 found no break in the composition or the certificate
 chain. Its outcomes are folded in above: deterministic ML-DSA signing, the fingerprint guidance,
-the `skem` oracle note, the psk2 note, the pending-responder exposure and the `sticky` default.
+the `skem` oracle note, the psk2 note (moot now that handshake is dropped), the pending-responder
+exposure and the `sticky` default.
 
 Accepted as is: a tunnel that came up classical between two v3 holders (say, the hybrid attempt
 was dropped) stays classical until it rehandshakes for another reason. `require`, `sticky` and a

+ 6 - 8
examples/config.yml

@@ -425,12 +425,11 @@ logging:
   # after receiving the response for lighthouse queries
   #trigger_buffer: 64
 
-  # pq enables handshakes that mix an ephemeral ML-KEM-1024 key exchange into the session keys, protecting
-  # recorded traffic against a future quantum computer. These handshakes are too large for one packet and travel
-  # as fragments with forward error correction.
-  # With a version 3 certificate in pki.cert they are hybrid handshakes, also authenticated post-quantum with the
-  # certificates' ML-KEM-1024 keys, and need a version 3 certificate on both ends. Without one, authentication is
-  # unchanged.
+  # pq enables hybrid handshakes: ML-KEM-1024 alongside the classical key exchange, so recorded traffic stays
+  # secret against a future quantum computer, and authentication by the version 3 certificates' ML-KEM-1024 keys.
+  # Both ends need a version 3 certificate in pki.cert; without one these settings are ignored with a warning, and
+  # require is refused. These handshakes are too large for one packet and travel as fragments with forward error
+  # correction.
   #pq:
     # initiate starts post-quantum handshakes. Attempts alternate between post-quantum and classical until one is
     # answered, so peers that do not support them still connect.
@@ -438,8 +437,7 @@ logging:
     # accept answers post-quantum handshakes from peers.
     #accept: false
     # require makes every handshake post-quantum: it implies initiate and accept, never offers a classical handshake
-    # and refuses classical handshakes from peers. Only enable it once every host can speak post-quantum handshakes.
-    # With a version 3 certificate only hybrid handshakes are made or answered, so every peer needs one too.
+    # and refuses classical handshakes from peers. Only enable it once every host has a version 3 certificate.
     #require: false
     # sticky never falls back to a classical handshake with a peer that has completed a post-quantum one since
     # nebula started, which defeats an attacker who drops post-quantum handshakes to force a downgrade.