Commit Graph
37 Commits
Author SHA1 Message Date
riccardom 2ba7c6520d Introduces a forced WG handshake on initial MLKEM bootstrap.
To ensure two peers agree on a key, we need asymmetry. one peer is
the controller ("initiator") the other is the "responder".

Otherwise imagine two offers in parallel driving two answers at the same time

   A                   B
   | <----B-OFFER----- |
   | -----A-OFFER----> |
   |                   |
   |                   |
   ---------------------------------
  |****** ICE + WG Handshake ****** |
   ---------------------------------
   |                   |
   | <----B-ANSWER---- |
   | -----A-ANSWER---> |

PSK is derived on receive of offer, so A and B derive different PSKs.
When WG handshake takes place it picks misaligned PSKs.

So we impair the two nodes and only the offer of one of the two (the controller/initiator)
carries the KEM material.

This means that if the responder OFFER/ANSWER comes first, when the controller/initiator's one
completes (and the genuine PSK is shared between A and B, we need to force a new WG handshake with
the proper keys.
2026-08-07 10:24:01 +02:00
riccardom 726ea030ab Anticipates PSK before WG does handshake so it finds it to set it 2026-08-07 10:24:01 +02:00
riccardom 2df8e69f59 Skip default port send in signal proto 2026-08-07 10:24:01 +02:00
riccardom 4caabdacc2 Allow non strict mode 2026-08-07 10:24:01 +02:00
riccardom cb662e307b Remove obvious comments; leave only the why of things 2026-08-07 10:24:01 +02:00
riccardom 8407bea1cd Adds test to validate compromised keys are not accepted 2026-08-07 10:24:01 +02:00
riccardom 011294bc74 pqkem: concurrency tests (recovery + race)
- RecoversViaResignalAfterDataPathBreak: a data-path rotation that can no longer
  converge raises OnRekeyFailed, and re-bootstrapping over signalling resyncs both
  peers on a fresh PSK even while the data path stays broken.
- ConcurrentRekeysNoRace: hammers the single-lock state machine with concurrent
  rotation clocks from many goroutines (run with -race) and asserts no split-brain
  via a final deterministic bootstrap.
2026-08-07 10:24:01 +02:00
riccardom 1d510bad8e Discriminate initial from rekey failure 2026-08-07 10:24:01 +02:00
riccardom 8a203e7e4e pqkem: strict (fail-closed) mode + wire status Quantum resistance
Strict mode (NB_PQ_MLKEM_STRICT, default off) closes the initial PQ-vulnerable
window (NET-1408): when enabled, conn.presharedKey programs a per-conn random
sentinel PSK until the ML-KEM exchange derives the real one, so no session can form
on a non-PQ key (the real PSK is pushed via SetPresharedKey once it converges).
Default stays opportunistic.

Also surface PQ status: the peer 'Quantum resistance' flag (RosenpassEnabled) is now
true when an ML-KEM PSK has been derived for the peer, not only for Rosenpass.
2026-08-07 10:24:01 +02:00
riccardom 27ac3ca9f6 pqkem: derive PSK with HKDF-SHA256
Replace the raw SHA-256 concat combiner with HKDF-SHA256 (crypto/hkdf, Go 1.24):
IKM = ML-KEM_ss || X25519_ss (draft-ietf-tls-ecdhe-mlkem order), salt = the
domain-separation label, info = full transcript (offer || answer) || canonicalised
peer identities. Keeps the transcript + identity binding while using a proper KDF.
2026-08-07 10:24:01 +02:00
riccardom 3ad2989556 Don't rotate PQ keys if data path is idle for ~90s (less than a WG handhshake time 2026-08-07 10:24:01 +02:00
riccardom c9a66a7fbc Adds log tracepoints
- Add a trace slog level (NB_PQ_MLKEM_LOG_LEVEL=trace) and move the verbose
  per-exchange lifecycle logs (offer/answer/PSK/ack/rotation) to it, so debug
  stays quiet and troubleshooting is opt-in.
- Stop logging the raw preshared key; drop the temporary pqkem-dbg OnRemoteOffer/
  OnRemoteAnswer probes.
- Demote the per-handshake conn log to trace.
2026-08-07 10:24:01 +02:00
riccardom 8b7c105b5e pqkem: apply derived PSK at WG peer-config time (pull) + keep push for rekey 2026-08-07 10:24:01 +02:00
riccardom e6cc446877 pqkem: dedicated slog logger via NB_PQ_MLKEM_LOG_LEVEL 2026-08-07 10:24:01 +02:00
riccardom 09664e84aa Homogeneous logs prefix 2026-08-07 10:24:01 +02:00
riccardom 4d2037ccc9 Bit of renaming
peer -> peerAddrs
have types for remoteID and localID
t.Close log error
Manager SetTransport -> Start
2026-08-07 10:24:01 +02:00
riccardom f0eb275575 Typo 2026-08-07 10:24:01 +02:00
riccardom 341ed699d9 Race fix 2026-08-07 10:24:01 +02:00
riccardom 11d0ad13bb Makes Transport just a UDP socket.
Manager owns maps for remoteID <-> remote UDP addr
Engine talks to manager only
2026-08-07 10:24:01 +02:00
riccardom 8fa2a41888 Added enabled env var 2026-08-07 10:24:01 +02:00
riccardom 3059e7d141 Invert order of keys as per draft 2026-08-07 10:24:01 +02:00
riccardom 551145def6 Removes confirm. Uses next offer to deliver confirmation/ack of previous round
We clock the next Offer initiation to the OnDataPathRekeyed, so we have 2 minutes
ahead of us to do our attempts and stuff before to give up.
On failure, we will know because we will not receive a new answer.. but more importantly
the wg handshake will fail :D
2026-08-07 10:24:01 +02:00
riccardom 40016ae082 Leave signal offer/answer as a pull/push operation not as an actual transport 2026-08-07 10:24:01 +02:00
riccardom 9dbc7401c9 Assume two transports: initial "signal" (control plane) one (no data path established yet) + data path one
Define OnDataPathRekeyed event to transition from control plane path to data plane path over the WG tunnel.

Keep confirm ALWAYS on NEW established WG tunnel (posthandshake with rekeying). We keep an active method
irrelevant of the WG handshake (we might decide that the indirect wg handshake is sufficient in the future).

Optimistic commit on responder(when sending answer), while on initiator we set it on getting the answer
2026-08-07 10:24:01 +02:00
riccardom abe28c41ee Epurate wg refs 2026-08-07 10:24:01 +02:00
riccardom 56a8681b76 Collapse Driver and Manager in one.
- Have just one manager => one lock
 - Session state is needed in driver to => we have it available now.
 - Isomorphically align to rosenpass components and functionality

File	Role	                                  rosenpass equivalent
kem.go	primitive pure X25519MLKEM768	          crypto.go/handshake
message.go	Offer/Answer/Confirm + Encode/Decode  messages.go
manager.go	Manager stateful, single lock	      server logic
callbacks.go	WGCallbackHandler (seam output)	  Handler
Transport (interfaccia)	seam trasporto pluggable  Conn
2026-08-07 10:24:01 +02:00
riccardom 9c6b00125b [squash] isInitial and answered can be inferred without state variables 2026-08-07 10:24:01 +02:00
riccardom 090f97d3c4 Manages convergence 2026-08-07 10:24:01 +02:00
riccardom b0f5731699 Models reattempts 2026-08-07 10:24:01 +02:00
riccardom e69b9ccc18 Reuse answer, don't calculate again 2026-08-07 10:24:01 +02:00
riccardom cb3285a79b Adds driver to glue together manager and outside world 2026-08-07 10:24:01 +02:00
riccardom 14df9d75a3 Defines event callbacks 2026-08-07 10:24:01 +02:00
riccardom b23b757380 Admits possible errors on Encode 2026-08-07 10:24:01 +02:00
riccardom 744508f742 Bench key material boilerplate time/allocs
CGO_ENABLED=1 go test ./client/internal/pqkem/ -run '^$' -bench . -benchmem 2>&1 | grep -E "Benchmark|ns/op|PASS|ok" | head -20

BenchmarkX25519Keygen-14    	   33795	     34966 ns/op	     224 B/op	       5 allocs/op
BenchmarkX25519ECDH-14      	   33855	     33973 ns/op	      32 B/op	       1 allocs/op
BenchmarkMLKEMKeygen-14     	   21817	     67778 ns/op	    8200 B/op	       2 allocs/op
BenchmarkMLKEMEncaps-14     	   29918	     43235 ns/op	    1216 B/op	       2 allocs/op
BenchmarkMLKEMDecaps-14     	   26048	     56291 ns/op	      64 B/op	       2 allocs/op
PASS
ok  	github.com/netbirdio/netbird/client/internal/pqkem	9.751s
Shell cwd was reset to /home/riccardo/Desktop/Personal/netbirdio/netbird
2026-08-07 10:24:01 +02:00
riccardom d808ecf8dd Pure mechanics of manager 2026-08-07 10:24:01 +02:00
riccardom f5b350a812 Messages definition 2026-08-07 10:24:01 +02:00
riccardom acb8970346 ML-KEM encapsulate/decapsulate module 2026-08-07 10:24:01 +02:00