diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f27bc82..927d022f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Version 3 certificates and post-quantum hybrid handshakes (`ix_hybrid_mlkem1024`). A version 3 certificate is a + version 2 certificate extended with an ML-KEM-1024 key (hosts) or an ML-DSA-87 key (CAs) and an ML-DSA-87 CA + signature, and a node holding one still presents the version 2 certificate it extends to peers that need it. + `nebula-cert pq-extend` upgrades existing CAs and hosts in place. See `docs/pq-handshake.md`. + +### Changed + +- A version 3 certificate has two fingerprints: its own, and that of the version 2 certificate it extends. Each + identifies it in logs and to `pki.blocklist`. Blocking the v2 fingerprint blocks both forms, but blocking only the + v3 fingerprint leaves the v2 form usable in classical handshakes. **Operators must list and block both + fingerprints**; `nebula-cert print` shows them as `fingerprint` and `v2Fingerprint`. +- Firewall `ca_sha` rules match a version 3 peer by either fingerprint of its CA. + ### Fixed - `create-tunnel -address ` panicked on a nil RemoteList and, over the ssh debug server, took nebula diff --git a/docs/pq-handshake.md b/docs/pq-handshake.md index 3f4fa4db..a6be4d7f 100644 --- a/docs/pq-handshake.md +++ b/docs/pq-handshake.md @@ -1,7 +1,8 @@ # Post-quantum handshakes -Status: draft. Decisions below were made on 2026-10-13; open items are listed at the end. -Phase 1 is implemented (see [Implementation](#implementation-phase-1)). +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)). ## Goals @@ -25,8 +26,8 @@ The only signature in the system stays the CA signature inside the certificate. | ML-DSA-87 public key / signature | 2592 B / 4627 B | | Today's IX msg1 / msg2 (v2 cert, 151 B) | 252 B / 287 B | -`crypto/mlkem` is available in Go 1.26 (the current `go.mod`); `crypto/mldsa` arrives in -Go 1.27 and is only needed for phase 2. +`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 @@ -54,49 +55,139 @@ about 1.84 KB each, so each travels as 2 data and 2 repair fragments of about 93 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. -## Phase 2: pqIX hybrid with certificate v3 +## Phase 2: hybrid IX with certificate v3 -Certificate v3 is hybrid: the classical static key and CA signature plus a static -ML-KEM-1024 key and an ML-DSA-87 CA signature. Host authentication is by KEM rather than by -signature, following PQNoise's pqIX, while keeping the classical DH tokens: +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)). -- **msg1** (I to R): `e, s`, ephemeral ML-KEM key, initiator cert. -- **msg2** (R to I): `e, s`, ciphertext to I's ephemeral KEM key, ciphertext to I's static - KEM key, responder cert. -- **msg3** (I to R): ciphertext to R's static KEM key, plus first data. R authenticates I - when msg3 decrypts; I authenticates R implicitly when R's first reply decrypts. +### Certificate v3 -msg3 is about 1.6 KB, 2 data and 2 repair fragments, and adds no new message types: +A version 3 certificate is a version 2 certificate extended with post-quantum keys. It is its own +format, with its own PEM banner (`NEBULA CERTIFICATE V3`) and a strict parser, but its first +fields are exactly the fields of a v2 certificate: -- **Responder.** After sending msg2 the responder keeps a pending Machine, found by its - local index like the initiator's today; this is the multi-message responder support the - manager lacks now. A repeat msg1 maps to it through the answered-msg1 cache and gets msg2 - resent with fresh symbols, exactly as in phase 1. Until msg3 verifies, data for that - index is dropped silently: it must not draw a RecvError, which would tear down the - initiator's new tunnel. On completing it sends an empty `Test`/`TestReply` under the - new keys, so the initiator hears back even when there is no traffic. -- **Initiator.** Writing msg3 yields the transport keys, so the hostinfo moves to the main - hostmap and data flows at once, readable only by the real responder. The handshake entry - stays behind in a confirming state: each retry tick resends msg3 with fresh symbols until - any packet from the responder decrypts on the new tunnel, and if that never happens - within the handshake timeout the tunnel is torn down. Losing msg3 costs the data sent - behind it, which the overlay's own transport retransmits. -- msg3's fragments use the responder's index as RemoteIndex and land on the responder's - pending entry, so they never enter the pre-authentication table. +``` +CertificateV3 ::= SEQUENCE { + details [0] Details, -- v2 details, unchanged + curve [1] Curve DEFAULT curve25519, + publicKey [2] OCTET STRING, -- classical: X25519 / P256 for hosts, Ed25519 / ECDSA P256 for CAs + signature [3] OCTET STRING, -- classical CA signature over details || curve || publicKey, as in v2 + pqPublicKey [4] OCTET STRING, -- ML-KEM-1024 encapsulation key for hosts, ML-DSA-87 public key for CAs + pqSignature [5] OCTET STRING -- ML-DSA-87 by the issuing CA, context "nebula certificate v3", + -- over the DER of fields 0 to 4, curve always present +} +``` -The initiator never caches the responder's certificate, so every handshake has three -messages and carries both full certificates. With ML-DSA-87: +- **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 + 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 + neither signature can be swapped out (which also makes P256 low/high-s alternates moot for v3). + If ML-DSA fell classically and someone swapped `pqPublicKey`, the hybrid handshake still needs + the classical static key, which only the classical signature covers. +- **Issuer and fingerprints.** The details are v2-shaped, so the issuer is the fingerprint of the + CA's v2 form. A v3 certificate's own fingerprint is the SHA-256 of its whole encoding and differs + from its v2 form's. Both identify it: the blocklist checks both, and operators must list both, + since blocking only the v3 fingerprint leaves the v2 form usable in classical handshakes. Firewall + `ca_sha` rules match a v3 peer by either fingerprint of its CA. +- **CA pool.** A v3 CA verifies only v3 certificates. To accept v2 certificates as well the pool + must also list the CA's v2 form. Dropping the v2 form is how a node requires post-quantum + authentication; there is no separate setting. Two different v3 extensions of one v2 CA are + rejected as ambiguous. +- **Keys are composite**, classical followed by post-quantum, wherever a v3 certificate deals in + them: `PublicKey()` returns `classical || pq`, and private keys are `classical || seed` (64 byte + ML-KEM seed for hosts, 32 byte ML-DSA seed for CAs), in one PEM block in `pki.key` with banners + such as `NEBULA X25519 MLKEM1024 PRIVATE KEY` and `NEBULA ED25519 MLDSA87 PRIVATE KEY`. +- **Both curves.** Curve25519 and P256; the post-quantum half does not depend on the curve. +- **In-place upgrade.** `nebula-cert pq-extend` turns an existing v2 CA into a v3 CA whose v2 form + is the old CA certificate, byte for byte, and extends existing host certificates the same way. + Fingerprints, blocklists and trust in the v2 forms carry over unchanged. Hosts get their ML-KEM + key with `nebula-cert keygen -in-key`, which keeps the classical key. -| Message | Size | Data fragments (k) | +| Certificate | Full | In a handshake | v2 form | +|---|---|---|---| +| CA, Curve25519 | 7362 B | n/a | 134 B | +| Host, Curve25519 | 6375 B | 4769 B | 171 B | + +`crypto/mldsa` is new in Go 1.27, so `go.mod` moves to Go 1.27. + +### Handshake + +A new subtype, `ix_hybrid_mlkem1024` (3), runs `HandshakeHybridIX` from +[github.com/DefinedNet/noise](https://github.com/DefinedNet/noise) (its `PQ_RESEARCH.md` gives the +design rationale and security argument), with ML-KEM-1024 supplied by nebula: + +``` +-> e, s +<- ekem, skem, e, ee, se, s, es +-> skem +``` + +- `e` and `s` carry composite keys, X25519 (or P256) followed by ML-KEM-1024. DH tokens use the + classical halves, `ekem` and `skem` encapsulate to the ML-KEM halves, and every secret goes into + the one chaining key. The static ML-KEM key is the post-quantum half of the certificate's public + key, so it travels in `s` and the handshake form of the certificate leaves out both keys. +- **KEM tokens first.** msg2 starts with `ekem, skem`, so the responder's static key and + certificate are encrypted under keys that already include both KEM secrets: the responder's + identity is protected against harvest-now-decrypt-later, as TLS 1.3 with `X25519MLKEM768` + protects the server certificate. (OpenSSH offers no comparison: its hybrid key exchange sends the + host key in the clear.) The initiator's certificate travels in msg1 in the clear, as in IX today. +- **Authentication.** The responder authenticates the initiator when msg3 decrypts (it depends on + msg2's `skem` to the initiator's static key); the initiator authenticates the responder when the + first packet under the new keys decrypts (it depends on msg3's `skem`). Neither side needs a + signature. Knowing only a peer's classical private key gets an impersonator nowhere: as an + initiator it cannot read msg2, as a responder it cannot read msg3. +- v3 certificates go with this subtype and nothing else: a v3 certificate on any other subtype, or + a v2 one on this one, is a protocol error. + +| Message | Size (Curve25519) | Data fragments (k) | |---|---|---| -| msg1 | ~8.2 KB | 8 | -| msg2 | ~9.8 KB | 9 | -| msg3 | ~1.6 KB | 2 | +| msg1 | 8006 B | 7 | +| msg2 | 11193 B | 10 | +| msg3 | 1616 B | 2 | -flynn/noise cannot mix multiple KEM secrets into the chaining key, so phase 2 needs a -KEM-aware Noise symmetric state written in this repository. Both subtypes coexist. +**TODO:** the noise library's responder `e` also carries an ephemeral ML-KEM key that no token +encapsulates to: 1568 bytes of msg2 (which would otherwise be 9 fragments) and a key generation +for nothing. Removing it is a change to DefinedNet/noise. -Cryptographic review of the hybrid composition will be done by in-house cryptographers. +**Dependency.** DefinedNet/noise is a private repository today. `go.mod` points at a local clone +with a `replace` directive; the module has to be published (or the hybrid patterns upstreamed to +flynn/noise) before this can merge. + +### Three messages in the manager + +- **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 + 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 + empty `Test`/`TestReply` under the new keys so the initiator hears back without traffic. A + pending responder expires after the handshake timeout. +- **Initiator.** Writing msg3 yields the transport keys, so the tunnel goes live at once, readable + only by the real responder; msg3 goes out before any packets cached during the handshake. Until + anything from the responder decrypts on the new tunnel, msg3 is resent with fresh symbols on a + linear backoff, and if nothing has decrypted by the handshake timeout the tunnel is closed. + Losing msg3 costs the data sent right behind it, which the overlay retransmits. +- **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. +- **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. +- 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. ## Fragment transport @@ -207,7 +298,7 @@ Implemented in `handshake/fragment` (`Sender`, `Assembly`, and the capped `Table - 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 responder; the responder sends a small confirmation on completion and holds tunnel + the responder; the responder sends a small confirmation on completion and drops tunnel traffic until msg3 verifies. ### Denial of service @@ -288,6 +379,9 @@ mean burst length in packets. First-attempt success for the whole handshake with | 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% | +The phase 2 rows assume (7, 9, 2) data fragments; as built it is (7, 10, 2), the extra msg2 +fragment being the unused ephemeral ML-KEM key noted above. + With fresh repair symbols, phase 2 completes within two attempts at least 99.1% of the time in every scenario above, and within three at least 99.99%. @@ -338,7 +432,32 @@ in every scenario above, and within three at least 99.99%. 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`. +## Implementation (phase 2) + +- `cert`: `cert_v3.go` (format, `V2Form`, `ExtendToV3`, `SignPQ`), `SignHybridWith` and the + composite `Sign` path, the CA pool's v3 index and v2-form blocklisting, and the composite key + PEM banners. +- `handshake`: the `ix_hybrid_mlkem1024` subtype on `noise.HandshakeHybridIX`, `KEMMLKEM1024`, + composite credentials, cipher states ordered by role for three message patterns, and + `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. +- `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. +- Tests: certificate format, strictness, tampering, pool and blocklist rules and in-place + extension on both curves; Machine-level hybrid handshakes including corrupted msg3 and + impersonation with only the classical key; e2e tests for a clean hybrid handshake, a lost + confirmation, a never confirmed handshake (no RecvErrors, teardown, expiry), a v2-only peer, an + initiator that trusts only the v2 form, `require` both ways, and relays; pki loading; firewall + `ca_sha`; and the `nebula-cert` flows. + ## Open items -None for phase 1. Phase 2 needs certificate v3, the KEM-aware Noise symmetric state, and -the msg3 design above. +- Publish DefinedNet/noise, or upstream the hybrid patterns, and drop the `replace` in `go.mod`. +- The unused responder ephemeral ML-KEM key in msg2 (see the TODO above). +- Cryptographic review of the hybrid composition and of the v3 certificate signatures. +- A tunnel that came up classical between two v3 holders (say, the hybrid attempt was dropped) + stays classical until it rehandshakes for another reason. diff --git a/examples/config.yml b/examples/config.yml index 3f354826..850e4d35 100644 --- a/examples/config.yml +++ b/examples/config.yml @@ -4,10 +4,17 @@ # PKI defines the location of credentials for this node. Each of these can also be inlined by using the yaml ": |" syntax. pki: # The CAs that are accepted by this node. Must contain one or more certificates created by 'nebula-cert ca' + # A version 3 CA only accepts version 3 certificates, which only post-quantum hybrid handshakes use. To also + # accept version 2 certificates from it, list its v2 form as well (nebula-cert print shows its v2Fingerprint). + # Leaving the v2 form out is how a node requires post-quantum authentication. ca: /etc/nebula/ca.crt + # cert may hold a version 3 certificate, which also serves as its v2 form for classical handshakes. key then + # holds the version 3 key from 'nebula-cert keygen -version 3' or 'nebula-cert sign'. cert: /etc/nebula/host.crt key: /etc/nebula/host.key # blocklist is a list of certificate fingerprints that we will refuse to talk to + # A version 3 certificate has two fingerprints, its own and its v2 form's. Blocking the v2 one blocks both, + # blocking the v3 one leaves the v2 form usable in classical handshakes, so list both. #blocklist: # - c99d4e650533b92061b09918e838a5a0a6aaee21eed1d12fd937682865936c72 # disconnect_invalid is a toggle to force a client to be disconnected if the certificate is expired or invalid. @@ -419,8 +426,11 @@ logging: #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. Authentication is unchanged. These handshakes are too - # large for one packet and travel as fragments with forward error correction. + # 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: # 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. @@ -429,6 +439,7 @@ logging: #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. #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.