From 905b7f79141e2ad90b756d6e2bca9eae19eac779 Mon Sep 17 00:00:00 2001 From: riccardom Date: Sun, 26 Jul 2026 13:15:51 +0200 Subject: [PATCH] pqkem: carry KEM offer/answer over the signalling exchange --- client/internal/engine.go | 3 +++ client/internal/peer/conn.go | 18 ++++++++++++++++ client/internal/peer/handshaker.go | 18 ++++++++++++++-- client/internal/pqkem_adapter.go | 34 ++++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 2 deletions(-) diff --git a/client/internal/engine.go b/client/internal/engine.go index f6976b63b..cdaa5057c 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -1918,6 +1918,9 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV }, ICEConfig: e.createICEConfig(), } + if e.pqkemManager != nil { + config.PQ = pqHandshaker{mgr: e.pqkemManager} + } serviceDependencies := peer.ServiceDependencies{ StatusRecorder: e.statusRecorder, diff --git a/client/internal/peer/conn.go b/client/internal/peer/conn.go index 09a4e8b02..872029cec 100644 --- a/client/internal/peer/conn.go +++ b/client/internal/peer/conn.go @@ -74,6 +74,21 @@ type RosenpassConfig struct { PermissiveMode bool } +// PQHandshaker attaches post-quantum ML-KEM material to signalling offers/answers and +// feeds received material back. It is implemented by the engine over the pqkem +// manager and is nil when the PQ exchange is disabled. remoteKey is the peer's +// WireGuard public key. +type PQHandshaker interface { + // OfferPayload returns the KEM offer to embed in an outgoing offer (nil if this + // peer is not the KEM initiator) and the local PQ data-path port to announce. + OfferPayload(remoteKey string) (payload []byte, port int) + // AnswerPayload processes a received KEM offer (nil if absent) and returns the KEM + // answer to embed in the outgoing answer (nil if none) and the local PQ port. + AnswerPayload(remoteKey string, recvOffer []byte) (payload []byte, port int) + // OnAnswer feeds a received KEM answer (nil if absent). + OnAnswer(remoteKey string, recvAnswer []byte) +} + // ConnConfig is a peer Connection configuration type ConnConfig struct { // Key is a public key of a remote peer @@ -91,6 +106,9 @@ type ConnConfig struct { RosenpassConfig RosenpassConfig + // PQ carries post-quantum ML-KEM material on offers/answers; nil when disabled. + PQ PQHandshaker + // ICEConfig ICE protocol configuration ICEConfig icemaker.Config } diff --git a/client/internal/peer/handshaker.go b/client/internal/peer/handshaker.go index 881219794..23f744909 100644 --- a/client/internal/peer/handshaker.go +++ b/client/internal/peer/handshaker.go @@ -138,7 +138,7 @@ func (h *Handshaker) Listen(ctx context.Context) { h.iceListener(&remoteOfferAnswer) } - if err := h.sendAnswer(); err != nil { + if err := h.sendAnswer(&remoteOfferAnswer); err != nil { h.log.Errorf("failed to send remote offer confirmation: %s", err) continue } @@ -159,6 +159,10 @@ func (h *Handshaker) Listen(ctx context.Context) { if h.iceListener != nil && h.RemoteICESupported() { h.iceListener(&remoteOfferAnswer) } + + if h.config.PQ != nil { + h.config.PQ.OnAnswer(h.config.Key, remoteOfferAnswer.MlkemPayload) + } case <-ctx.Done(): h.log.Infof("stop listening for remote offers and answers") return @@ -205,13 +209,23 @@ func (h *Handshaker) sendOffer() error { } offer := h.buildOfferAnswer() + if h.config.PQ != nil { + offer.MlkemPayload, offer.MlkemPort = h.config.PQ.OfferPayload(h.config.Key) + } h.log.Debugf("sending offer with serial: %s", offer.SessionIDString()) return h.signaler.SignalOffer(offer, h.config.Key) } -func (h *Handshaker) sendAnswer() error { +func (h *Handshaker) sendAnswer(remoteOffer *OfferAnswer) error { answer := h.buildOfferAnswer() + if h.config.PQ != nil { + var recvOffer []byte + if remoteOffer != nil { + recvOffer = remoteOffer.MlkemPayload + } + answer.MlkemPayload, answer.MlkemPort = h.config.PQ.AnswerPayload(h.config.Key, recvOffer) + } h.log.Debugf("sending answer with serial: %s", answer.SessionIDString()) return h.signaler.SignalAnswer(answer, h.config.Key) diff --git a/client/internal/pqkem_adapter.go b/client/internal/pqkem_adapter.go index 26c0165bf..4373520d7 100644 --- a/client/internal/pqkem_adapter.go +++ b/client/internal/pqkem_adapter.go @@ -31,3 +31,37 @@ func (h pqCallbackHandler) OnRekeyFailed(remoteID pqkem.RemoteID) error { log.Warnf("pqkem: post-quantum rekey failed for peer %s", remoteID) return nil } + +// pqHandshaker adapts the pqkem manager to peer.PQHandshaker (string peer keys), +// wiring the host's signalling offers/answers to the KEM exchange. +type pqHandshaker struct { + mgr *pqkem.Manager +} + +func (p pqHandshaker) OfferPayload(remoteKey string) ([]byte, int) { + payload, err := p.mgr.SignalOffer(pqkem.RemoteID(remoteKey)) + if err != nil { + log.Warnf("pqkem: build offer for %s: %v", remoteKey, err) + } + return payload, p.mgr.LocalPort() +} + +func (p pqHandshaker) AnswerPayload(remoteKey string, recvOffer []byte) ([]byte, int) { + if len(recvOffer) == 0 { + return nil, p.mgr.LocalPort() + } + payload, err := p.mgr.SignalOnOffer(pqkem.RemoteID(remoteKey), recvOffer) + if err != nil { + log.Warnf("pqkem: build answer for %s: %v", remoteKey, err) + } + return payload, p.mgr.LocalPort() +} + +func (p pqHandshaker) OnAnswer(remoteKey string, recvAnswer []byte) { + if len(recvAnswer) == 0 { + return + } + if err := p.mgr.SignalOnAnswer(pqkem.RemoteID(remoteKey), recvAnswer); err != nil { + log.Warnf("pqkem: process answer from %s: %v", remoteKey, err) + } +}