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.
This commit is contained in:
riccardom
2026-08-06 14:42:07 +02:00
parent 7699697231
commit b5a72eca65
9 changed files with 103 additions and 15 deletions
+7 -1
View File
@@ -9,7 +9,13 @@ type CallbackHandler interface {
// and must be programmed into the consumer's secure channel. It is invoked at
// the commit point of each side: the initiator on receiving the answer, the
// responder on receiving the confirm.
OnNewPSKReady(remoteID RemoteID, psk PSK) error
//
// bootstrap is true when the PSK came from a signalling exchange (initial setup or
// a wake re-negotiation) rather than a data-path rotation. On bootstrap the consumer
// must ensure the live channel actually adopts the PSK now (e.g. force a WireGuard
// re-handshake) — the channel may already be up on a pre-PQ key. A rotation
// (bootstrap=false) is adopted at the channel's next natural rekey.
OnNewPSKReady(remoteID RemoteID, psk PSK, bootstrap bool) error
// OnRekeyFailed fires when an exchange fails to converge within the allotted
// time. The host should tear the peer connection down so it re-establishes, and
+4 -4
View File
@@ -67,7 +67,7 @@ func (m *Manager) startExchange(remoteID RemoteID, viaSignal bool, ackID Exchang
// (that offer riding the data path under the freshly adopted key proves it worked),
// then derives the PSK for the new offer, commits it optimistically, and returns the
// framed answer. A duplicate offer returns the cached answer without re-deriving.
func (m *Manager) processOffer(remoteID RemoteID, o *OfferMsg) ([]byte, error) {
func (m *Manager) processOffer(remoteID RemoteID, o *OfferMsg, viaSignal bool) ([]byte, error) {
m.trace("pqkem: offer received", "peer", remoteID, "exchange", idHex(o.ExchangeID), "acks", idHex(o.AckID))
if o.AckID != (ExchangeID{}) {
@@ -114,7 +114,7 @@ func (m *Manager) processOffer(remoteID RemoteID, o *OfferMsg) ([]byte, error) {
m.trace("pqkem: new PSK derived", "peer", remoteID, "exchange", idHex(o.ExchangeID), "role", "responder", "psk_fp", pskFingerprint(psk))
// Commit optimistically so our data path can rekey to the new PSK.
if err := m.cbHandler.OnNewPSKReady(remoteID, psk); err != nil {
if err := m.cbHandler.OnNewPSKReady(remoteID, psk, viaSignal); err != nil {
return nil, err
}
m.trace("pqkem: answer sent", "peer", remoteID, "exchange", idHex(o.ExchangeID))
@@ -125,7 +125,7 @@ func (m *Manager) processOffer(remoteID RemoteID, o *OfferMsg) ([]byte, error) {
// stateAwaitingRekey; the next offer (chained from OnDataPathRekeyed) will acknowledge
// this exchange. Only valid in stateAwaitingAnswer; advancing the state under the
// lock makes a concurrent/duplicate answer bail.
func (m *Manager) processAnswer(remoteID RemoteID, a *AnswerMsg) error {
func (m *Manager) processAnswer(remoteID RemoteID, a *AnswerMsg, viaSignal bool) error {
m.mu.Lock()
ex := m.exchanges[remoteID]
if ex == nil || ex.id != a.ExchangeID || ex.state != stateAwaitingAnswer {
@@ -159,7 +159,7 @@ func (m *Manager) processAnswer(remoteID RemoteID, a *AnswerMsg) error {
m.trace("pqkem: new PSK derived", "peer", remoteID, "exchange", idHex(a.ExchangeID), "role", "initiator", "psk_fp", pskFingerprint(psk))
return m.cbHandler.OnNewPSKReady(remoteID, psk)
return m.cbHandler.OnNewPSKReady(remoteID, psk, viaSignal)
}
// ackConverged (responder) records convergence of the exchange named by ackID: a
+4 -4
View File
@@ -305,7 +305,7 @@ func (m *Manager) SignalOnOffer(remoteID RemoteID, offer []byte) ([]byte, error)
if typ != MsgOffer {
return nil, fmt.Errorf("expected offer from %s, got type %d", remoteID, typ)
}
return m.processOffer(remoteID, msg.(*OfferMsg))
return m.processOffer(remoteID, msg.(*OfferMsg), true)
}
// SignalOnAnswer processes a KEM answer the host extracted from an incoming answer.
@@ -318,7 +318,7 @@ func (m *Manager) SignalOnAnswer(remoteID RemoteID, answer []byte) error {
if typ != MsgAnswer {
return fmt.Errorf("expected answer from %s, got type %d", remoteID, typ)
}
return m.processAnswer(remoteID, msg.(*AnswerMsg))
return m.processAnswer(remoteID, msg.(*AnswerMsg), true)
}
// ---- Data path ----
@@ -346,7 +346,7 @@ func (m *Manager) OnDataPathMessage(remoteID RemoteID, raw []byte) error {
}
switch typ {
case MsgOffer:
answer, err := m.processOffer(remoteID, msg.(*OfferMsg))
answer, err := m.processOffer(remoteID, msg.(*OfferMsg), false)
if err != nil {
return err
}
@@ -355,7 +355,7 @@ func (m *Manager) OnDataPathMessage(remoteID RemoteID, raw []byte) error {
}
return m.pushDataPath(remoteID, answer)
case MsgAnswer:
return m.processAnswer(remoteID, msg.(*AnswerMsg))
return m.processAnswer(remoteID, msg.(*AnswerMsg), false)
default:
return fmt.Errorf("unhandled data-path message type %d from %s", typ, remoteID)
}
+1 -1
View File
@@ -65,7 +65,7 @@ type fakeWG struct {
func newFakeWG() *fakeWG { return &fakeWG{psks: map[RemoteID]PSK{}} }
func (f *fakeWG) OnNewPSKReady(remoteID RemoteID, psk PSK) error {
func (f *fakeWG) OnNewPSKReady(remoteID RemoteID, psk PSK, bootstrap bool) error {
f.mu.Lock()
defer f.mu.Unlock()
f.psks[remoteID] = psk