Files
netbird/client/internal/pqkem/message.go
2026-08-27 12:18:07 +02:00

137 lines
4.7 KiB
Go

package pqkem
import (
"crypto/mlkem"
"fmt"
)
// Wire framing for the PQ-KEM exchange. Messages are self-contained, versioned,
// transport-agnostic byte blobs: the same bytes ride the Signal offer/answer
// (initial, pre-tunnel) or a WireGuard-tunnel packet (rekey). They are NOT a gRPC
// service — the network layer only sees opaque []byte.
//
// Layout (all messages): [type:1][version:1][exchangeID:16][payload...]
const (
// ProtocolVersion is bumped on any wire-incompatible change; a peer rejects
// messages it does not understand rather than misparsing them.
ProtocolVersion uint8 = 1
// ExchangeIDSize identifies one offer/answer/confirm round so stale answers
// (e.g. an answer to a pre-restart offer) are dropped instead of applied.
ExchangeIDSize = 16
headerSize = 1 + 1 + ExchangeIDSize
)
// MsgType tags the three message kinds of the exchange.
type MsgType uint8
const (
MsgOffer MsgType = iota + 1
MsgAnswer
MsgConfirm
)
// ExchangeID is the per-round correlator echoed by the answer and the confirm.
type ExchangeID [ExchangeIDSize]byte
// OfferMsg carries the initiator's public material (X25519 pub ‖ ML-KEM encap key).
type OfferMsg struct {
ExchangeID ExchangeID
// KEMOffer is the raw Initiator.Offer() blob (OfferSize bytes).
KEMOffer []byte
}
// AnswerMsg carries the responder's reply (ML-KEM ciphertext ‖ X25519 pub) for the
// round identified by ExchangeID.
type AnswerMsg struct {
ExchangeID ExchangeID
// KEMAnswer is the raw Respond() answer blob (AnswerSize bytes).
KEMAnswer []byte
}
// ConfirmMsg is sent ONE WAY, initiator -> responder, over the tunnel under the
// NEW PSK, for the round identified by ExchangeID. It carries no key material.
//
// It is one-way because of the knowledge asymmetry of a KEM: the initiator learns
// the responder holds the key simply by receiving the answer (the responder had to
// derive it to encapsulate), so the initiator needs no confirmation. Only the
// responder is left unsure whether the initiator received the answer and committed
// the key — this message resolves that. As a bonus, being sent under the new PSK it
// triggers the WireGuard handshake with the new key: the responder converges on
// receiving it, and the initiator converges by observing that handshake succeed
// (which fails on a PSK mismatch, so success proves the responder also committed).
type ConfirmMsg struct {
ExchangeID ExchangeID
}
// Encode serialises the offer with its framed header.
func (m *OfferMsg) Encode() ([]byte, error) {
if len(m.KEMOffer) != OfferSize {
return nil, fmt.Errorf("offer payload: got %d, want %d", len(m.KEMOffer), OfferSize)
}
return frame(MsgOffer, m.ExchangeID, m.KEMOffer), nil
}
// Encode serialises the answer with its framed header.
func (m *AnswerMsg) Encode() ([]byte, error) {
if len(m.KEMAnswer) != AnswerSize {
return nil, fmt.Errorf("answer payload: got %d, want %d", len(m.KEMAnswer), AnswerSize)
}
return frame(MsgAnswer, m.ExchangeID, m.KEMAnswer), nil
}
// Encode serialises the confirm with its framed header. It returns an error only
// for signature uniformity with the other messages; it never actually fails.
func (m *ConfirmMsg) Encode() ([]byte, error) {
return frame(MsgConfirm, m.ExchangeID, nil), nil
}
// Decode parses a framed message into one of *OfferMsg / *AnswerMsg / *ConfirmMsg.
func Decode(buf []byte) (MsgType, any, error) {
if len(buf) < headerSize {
return 0, nil, fmt.Errorf("message too short: %d bytes", len(buf))
}
typ := MsgType(buf[0])
if ver := buf[1]; ver != ProtocolVersion {
return typ, nil, fmt.Errorf("unsupported protocol version %d (want %d)", ver, ProtocolVersion)
}
var id ExchangeID
copy(id[:], buf[2:headerSize])
payload := buf[headerSize:]
switch typ {
case MsgOffer:
if len(payload) != OfferSize {
return typ, nil, fmt.Errorf("offer payload: got %d, want %d", len(payload), OfferSize)
}
return typ, &OfferMsg{ExchangeID: id, KEMOffer: payload}, nil
case MsgAnswer:
if len(payload) != AnswerSize {
return typ, nil, fmt.Errorf("answer payload: got %d, want %d", len(payload), AnswerSize)
}
return typ, &AnswerMsg{ExchangeID: id, KEMAnswer: payload}, nil
case MsgConfirm:
if len(payload) != 0 {
return typ, nil, fmt.Errorf("confirm payload must be empty, got %d bytes", len(payload))
}
return typ, &ConfirmMsg{ExchangeID: id}, nil
default:
return typ, nil, fmt.Errorf("unknown message type %d", typ)
}
}
func frame(typ MsgType, id ExchangeID, payload []byte) []byte {
buf := make([]byte, headerSize+len(payload))
buf[0] = byte(typ)
buf[1] = ProtocolVersion
copy(buf[2:], id[:])
copy(buf[headerSize:], payload)
return buf
}
// compile-time assurance the KEM blob sizes referenced here stay in sync with kem.go.
var _ = [1]struct{}{}[OfferSize-(32+mlkem.EncapsulationKeySize768)]