mirror of
https://github.com/slackhq/nebula.git
synced 2026-11-15 13:06:52 +01:00
Document version 3 certificates and hybrid handshakes
This commit is contained in:
1 parent
1e6a95df15
commit
4ce04760ae
3 files changed
+188
-43
No files matched your search
@@ -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 <ip:port>` panicked on a nil RemoteList and, over the ssh debug server, took nebula
|
||||
|
||||
+160
-41
@@ -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.
|
||||
+13
-2
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user