Commit Graph
55 Commits
Author SHA1 Message Date
riccardom 0747fa0398 [client] pqkem: signal a responder failure distinctly from "no KEM"
Capability is signalled in-band by an empty answer: a peer that does not run the
KEM answers with no payload. But a responder that runs the KEM and simply fails
to build the answer (crypto error, failed PSK program, a future protocol version
it cannot parse) also produced an empty payload, so the initiator read it as
"peer has no KEM" and marked it non-capable — permanently, since only peer
removal clears the flag; in strict mode the peer stayed blocked.

Add a payload-less MsgError marker. On a signalling-path failure the responder
returns the marker instead of an empty answer, and the initiator treats it as
"capable but failed this round": it leaves the exchange to time out and
re-bootstrap rather than marking the peer non-capable. A genuinely non-KEM peer
still sends no payload at all, so the empty-answer capability signal is unchanged.

Found in cubic review on #7098 (client/internal/pqkem_adapter.go:72).
2026-10-07 13:38:42 +02:00
riccardom ff634c5330 [client] pqkem: don't let a superseded rotation offer revert a fresh round
OnDataPathRekeyed builds a chained offer, releases the lock, then sends it. A
signal re-bootstrap can supersede that exchange in the gap, after which the stale
offer still went out and the responder would reserve and commit an abandoned
exchange, splitting the keys.

Two guards, no revalidation of message contents:
- Sender side: before putting the chain offer on the wire, re-check it is still
  the current exchange; drop it if a re-bootstrap already replaced it.
- Responder side: a legitimate rotation offer acknowledges the exchange we are
  awaiting-ack on (ackConverged clears it on a match). Drop a chain offer whose
  ack did not match our current exchange — it is a rotation a newer signal round
  has superseded. A bootstrap (zero AckID) is authoritative and always wins.

A supersede landing in the infinitesimal window after the sender check still
leaks one offer, but the responder guard rejects it, so the new round stands.
Neither guard retries, so there is no loop.

Found in cubic review on #7098 (client/internal/pqkem/manager.go:402).
2026-10-07 13:34:41 +02:00
riccardom 1182239faa [client] pqkem: carry a generation on PSK callbacks to drop stale applies
OnNewPSKReady could be applied out of order: two exchanges for a peer can derive
concurrently (one over signal, one over the data path), and the callbacks run
outside the manager lock, so an older exchange's apply could land after a newer
one and restore a stale WireGuard PSK, splitting the tunnel.

Give each exchange a per-peer monotonic generation, assigned under the lock at
creation so a later exchange always carries a higher one, and pass it to
OnNewPSKReady. The host adapter records the newest generation applied per peer
and drops any callback that is not newer, with the check-and-record atomic so the
slow SetPresharedKey call stays off that lock.

Found in cubic review on #7098 (client/internal/pqkem/callbacks.go:12).
2026-10-07 13:30:52 +02:00
riccardom d0f7e2ac7a [client] pqkem: route a failed PSK application through convergence recovery
Both sides recorded the exchange as converged and then called OnNewPSKReady,
returning its error but leaving the "converged" state in place. A host failure to
program the PSK therefore left the peers split between the old data-path key and
the new stored value, with no failure raised and no re-bootstrap.

Treat applying the PSK as part of the commit. On the initiator, a failed
OnNewPSKReady now drops the exchange and raises the failure so recovery
re-bootstraps. On the responder, it drops the exchange and withholds the answer,
so the initiator times out and re-bootstraps rather than converging on a key the
responder could not apply.

Found in cubic review on #7098 (client/internal/pqkem/convergence.go:228).
2026-10-07 13:26:57 +02:00
riccardom 5341ab0a7b [client] pqkem: commit the initiator PSK only after Finish succeeds
processAnswer advanced the exchange to stateAwaitingRekey before init.Finish
derived the PSK. A rekey firing in that window chained the next offer off an
exchange whose key was not yet committed, letting the responder commit PSK N+1
before this side committed PSK N; a lost next answer then left the two peers on
different PSKs.

Move to an intermediate stateFinishing while deriving (OnDataPathRekeyed chains
only on stateAwaitingRekey, so it will not chain mid-derive), and advance to
stateAwaitingRekey only after Finish succeeds and the exchange is still current.

Found in cubic review on #7098 (client/internal/pqkem/convergence.go:194).
2026-10-07 13:25:03 +02:00
riccardom 9041e9a3ee [client] pqkem: document rekey-failure recovery as host-defined
The OnRekeyFailed contract told the host to tear the peer connection down, but
the shipped adapter re-bootstraps over signalling and keeps the previous PSK.
State that recovery is host-defined so another implementation does not perform an
unnecessary teardown.

Found in cubic review on #7098 (client/internal/pqkem/callbacks.go:15).
2026-10-07 13:23:34 +02:00
riccardom cc2d43ee09 [client] pqkem: document the real responder callback timing
OnNewPSKReady's doc claimed the responder fires it "on receiving the confirm",
but there is no confirm round: the responder fires it right after deriving the
PSK from the offer and before sending the answer. Say so, so a host does not
assume the peer has already confirmed the key.

Found in cubic review on #7098 (client/internal/pqkem/callbacks.go:11).
2026-10-07 13:23:22 +02:00
riccardom 9af27883df [client] pqkem: correct the convergence-deadline comment
The loop declares failure on the tick after the last retransmit, so the deadline
is (MaxRetries+1) * RetryInterval, not MaxRetries * RetryInterval.

Found in cubic review on #7098 (client/internal/pqkem/manager.go:19).
2026-10-07 13:22:59 +02:00
riccardom 89ec00eb20 [client] pqkem: address review findings on convergence, strict parsing and status
Bot review (CodeRabbit, cubic) on PR #7098 surfaced several real defects:

- Strict() parsed the raw env value instead of the normalized one, so a
  mixed-case NB_PQ_MLKEM_STRICT such as "tRuE" silently disabled fail-closed
  mode. Parse the lower-cased value, matching Enabled().
- OnDataPathMessage labeled a data-path answer as "signal" in logs, mislabeling
  every rotation answer. Use the data-path label.
- A failed sendAnswer aborted the whole connection setup, skipping the relay/ICE
  listeners; a transient signalling failure now still brings the local transport
  up (the peer retries the answer).
- processOffer left the reserved exchange slot in place when Respond failed, so
  every retransmission of that offer was dropped forever. Clear the reservation
  on a pre-commit error so a retry can derive again.
- Dropped a no-op time.Since that implied a convergence-latency metric that was
  never recorded, and the now-unused startedAt field.
- The strict-mode status line asserted active blocking even for a peer that is
  simply offline; reword it to state that no PSK is established yet.
- conn_test used t.Fatalf for conditions under test; use assert.
2026-10-05 16:02:33 +02:00
riccardom e0523efe5a [client] pqkem: make exchange logs discriminate offer/answer, role, transport, kind
The per-exchange lifecycle logs were all at LevelTrace (below the default field
level) and did not distinguish a signalling re-bootstrap from a data-path rekey,
which made diagnosing exchange activity in the field guesswork (e.g. telling an
ICE-retry-driven re-bootstrap storm apart from normal KEM rotation).

Promote the two pivotal events — an offer going out and a PSK converging — to
LevelDebug and tag every lifecycle log with four dimensions:
  - msg:  offer vs answer (implicit in the message)
  - role: initiator vs responder for this peer
  - via:  signal vs data-path (threaded into processOffer/processAnswer)
  - kind: bootstrap (a new connection / reconnect) vs rotation (a rekey)

kind is derived from the exchange's AckID (zero = bootstrap) on the responder
side and from viaSignal on the initiator side. No behavioural change.
2026-09-11 15:03:47 +02:00
riccardom aca68853d8 [client] pqkem: enforce exchange roles and re-bootstrap stale signal offers
Two convergence bugs surfaced by the security review:

- Role guard (finding B): processOffer accepted an offer even when we are the
  KEM initiator for the peer, and processAnswer accepted an answer when we are
  the responder. The KEM is unidirectional (initiator offers, responder
  answers), so a role-violating message is anomalous — a desync, a duplicate,
  or an injected/spoofed data-path packet. Processing it derived and committed
  a fresh PSK, overwriting a live one and silently dropping any in-flight
  exchange (whose retry loop then exited without raising a failure or
  re-bootstrapping). Reject offers when we are the initiator and answers when
  we are not; this drops only anomalous traffic and leaves the normal flow
  untouched.

- Re-bootstrap on signal re-negotiation (finding A): SignalOffer was idempotent
  in stateAwaitingRekey too, replaying the frozen bootstrap offer. After the
  responder restarted and lost its state it derived a different PSK from fresh
  material, which our awaitingRekey side then rejected — a permanent desync with
  no recovery (in strict mode the peer stays blocked). Make the idempotency
  apply only while a bootstrap is still in flight (awaitingAnswer); once a PSK
  is derived, a fresh signal offer starts a new exchange so both sides converge.
  The controller-double-offer case the idempotency guarded is already covered by
  ShouldSendBootstrapOffer. Reusing the cached offer also reused the same
  ephemeral keys across exchanges, reducing forward secrecy.

Both paths have a failing-without-the-fix regression test.
2026-09-11 15:03:47 +02:00
riccardom 1ee012160b Add comment to explain working boundaries 2026-09-11 14:48:54 +02:00
riccardom ecddbf2e55 Addresses CI fixes 2026-09-11 14:48:54 +02:00
riccardom 11555c3139 Addresses CI fixes 2026-09-11 14:48:54 +02:00
riccardom 1dc48bcc06 Addresses CI fixes 2026-09-11 14:48:54 +02:00
riccardom 3b61222b07 [client] pqkem: gate controller re-offer to kick the KEM exactly once
When the controller receives the responder's (KEM-less) offer it replies with
its own KEM offer instead of answering, so the only transaction that brings the
tunnel up is the one that also carries the PSK. Guard that reply with
ShouldSendBootstrapOffer so it fires only when no exchange is in flight: without
it, every responder offer triggered another offer (an offer-per-offer runaway).
The whole behaviour is isolated to the KEM path (config.PQ != nil); non-PQ
connections answer as before.
2026-09-11 14:48:54 +02:00
riccardom 1870b104ba [client] pqkem: make signalling bootstrap idempotent through awaitingRekey
The controller sends its KEM offer both on its own guard event and in reply
to the responder's offer. SignalOffer was idempotent only while awaiting the
answer; once the answer arrived (awaitingRekey) a repeat call started a fresh
exchange with a different PSK, desyncing the two peers (one on the old PSK,
one on the new) so WireGuard derived misaligned transport keys and dropped all
data. Treat awaitingRekey as in-flight too and return the same offer.
2026-09-11 14:48:54 +02:00
riccardom 3ed4b4fb43 Revert "Introduces a forced WG handshake on initial MLKEM bootstrap."
This reverts commit b5a72eca65.
2026-09-11 14:48:54 +02:00
riccardom a78d587e7a 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-09-11 14:48:54 +02:00
riccardom 2b27040fb0 Anticipates PSK before WG does handshake so it finds it to set it 2026-09-11 14:48:54 +02:00
riccardom b93e8d6676 Skip default port send in signal proto 2026-09-11 14:48:54 +02:00
riccardom cfaf4f1bab Allow non strict mode 2026-09-11 14:48:54 +02:00
riccardom cb5ee8efa8 Remove obvious comments; leave only the why of things 2026-09-11 14:48:54 +02:00
riccardom bfa36521da Adds test to validate compromised keys are not accepted 2026-09-11 14:48:54 +02:00
riccardom 63d29966fd 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-09-11 14:48:54 +02:00
riccardom e1d5551ba5 Discriminate initial from rekey failure 2026-09-11 14:48:54 +02:00
riccardom f322be1944 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-09-11 14:48:54 +02:00
riccardom 8c8abfb2b1 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-09-11 14:48:54 +02:00
riccardom b1c0a2bffe Don't rotate PQ keys if data path is idle for ~90s (less than a WG handhshake time 2026-09-11 14:48:54 +02:00
riccardom 562c02fa42 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-09-11 14:48:54 +02:00
riccardom 7c276ce727 pqkem: apply derived PSK at WG peer-config time (pull) + keep push for rekey 2026-09-11 14:48:54 +02:00
riccardom 56b315d954 pqkem: dedicated slog logger via NB_PQ_MLKEM_LOG_LEVEL 2026-09-11 14:48:54 +02:00
riccardom bac6af349b Homogeneous logs prefix 2026-09-11 14:48:54 +02:00
riccardom 3270fc5e18 Bit of renaming
peer -> peerAddrs
have types for remoteID and localID
t.Close log error
Manager SetTransport -> Start
2026-09-11 14:48:54 +02:00
riccardom 539a1f44c8 Typo 2026-09-11 14:48:54 +02:00
riccardom ef29e996ce Race fix 2026-09-11 14:48:54 +02:00
riccardom cda3922478 Makes Transport just a UDP socket.
Manager owns maps for remoteID <-> remote UDP addr
Engine talks to manager only
2026-09-11 14:48:54 +02:00
riccardom 41298ac24d Added enabled env var 2026-09-11 14:48:54 +02:00
riccardom 902daf61e6 Invert order of keys as per draft 2026-09-11 14:48:54 +02:00
riccardom 0aeed6ae5b 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-09-11 14:48:54 +02:00
riccardom cec4bd49b4 Leave signal offer/answer as a pull/push operation not as an actual transport 2026-09-11 14:48:54 +02:00
riccardom 2ed0d3bf03 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-09-11 14:48:54 +02:00
riccardom f8bb816dea Epurate wg refs 2026-09-11 14:48:54 +02:00
riccardom 661926ce3a 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-09-11 14:48:54 +02:00
riccardom 0a3dc2b43f [squash] isInitial and answered can be inferred without state variables 2026-09-11 14:48:54 +02:00
riccardom fa3c3ad214 Manages convergence 2026-09-11 14:48:54 +02:00
riccardom 75dcd571ca Models reattempts 2026-09-11 14:48:54 +02:00
riccardom d8fa34e60b Reuse answer, don't calculate again 2026-09-11 14:48:54 +02:00
riccardom e4bed8de65 Adds driver to glue together manager and outside world 2026-09-11 14:48:54 +02:00
riccardom 311c5f8a8c Defines event callbacks 2026-09-11 14:48:54 +02:00