mirror of
https://github.com/slackhq/nebula.git
synced 2026-11-15 12:56:52 +01:00
Reassemble msg2 in a slot per responder FragID, so two hosts answering one msg1 do not wipe each other out
This commit is contained in:
1 parent
6885bac85d
commit
84e28c62ea
5 files changed
+326
-19
No files matched your search
@@ -319,6 +319,12 @@ Implemented in `handshake/fragment` (`Sender`, `Assembly`, and the capped `Table
|
||||
FragID) with a total memory cap, per-source quotas (/32 for IPv4, /64 for IPv6), a
|
||||
sliding TTL of about 2 s refreshed by progress, and random eviction under pressure.
|
||||
msg2 and msg3 reassembly attaches to the existing pending handshake found by RemoteIndex.
|
||||
The initiator keeps two msg2 slots keyed by the responder's FragID, each capped at 16 KiB.
|
||||
When a stale lighthouse address for the peer belongs to another host now, both hosts answer
|
||||
the same msg1, and their interleaved bursts would wipe each other out of a single slot that
|
||||
emptied on every FragID change; with a slot each, whichever completes first is processed, and
|
||||
a wrong host's answer takes the usual block-and-retry path. The responder's msg3 slot needs no
|
||||
such care: it checks FragID against the initiator's index it learned from msg2.
|
||||
|
||||
### Retransmission and amplification
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -501,3 +502,84 @@ func TestHybridRaceWithClassical(t *testing.T) {
|
||||
myControl.Stop()
|
||||
theirControl.Stop()
|
||||
}
|
||||
|
||||
// TestHybridWrongResponder gives the initiator a stale address for its peer
|
||||
// that belongs to another v3 host now, so both answer its msg1. Their msg2
|
||||
// bursts interleave at the initiator and each must fill its own slot: the
|
||||
// tunnel to the right host comes up within two attempts, one if its answer
|
||||
// completes first and two if the other host's does, which the initiator
|
||||
// blocks and retries past as it would for a classical handshake. With require
|
||||
// there is no classical turn to fall back on, so a single slot that emptied
|
||||
// on every FragID change would run to the handshake timeout.
|
||||
func TestHybridWrongResponder(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
cfg m
|
||||
pqOnly bool
|
||||
}{
|
||||
{"taking turns", pqConfig(true, true, false), false},
|
||||
{"pq only", pqRequire(), true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ca := newHybridCA(t)
|
||||
myControl, myVpn, myUdp, _ := newSimpleServer(cert.Version3, ca.ca, ca.key, "me", "10.128.0.1/24", tc.cfg)
|
||||
theirControl, theirVpn, theirUdp, _ := newSimpleServer(cert.Version3, ca.ca, ca.key, "them", "10.128.0.2/24", tc.cfg)
|
||||
otherControl, otherVpn, otherUdp, _ := newSimpleServer(cert.Version3, ca.ca, ca.key, "other", "10.128.0.3/24", tc.cfg)
|
||||
myControl.InjectLightHouseAddr(theirVpn[0].Addr(), otherUdp)
|
||||
myControl.InjectLightHouseAddr(theirVpn[0].Addr(), theirUdp)
|
||||
|
||||
r := router.NewR(t, myControl, theirControl, otherControl)
|
||||
defer r.RenderFlow()
|
||||
myControl.Start()
|
||||
theirControl.Start()
|
||||
otherControl.Start()
|
||||
|
||||
var tally hybridTally
|
||||
msg1ToThem := 0
|
||||
msg2ByFragID := map[uint32]int{}
|
||||
var classical []string
|
||||
myControl.InjectTunPacket(BuildTunUDPPacket(theirVpn[0].Addr(), 80, myVpn[0].Addr(), 80, []byte("Hi from me")))
|
||||
require.True(t, routeUntilData(t, r, theirControl, 5*time.Second, func(p *udp.Packet, receiver *nebula.Control) router.ExitType {
|
||||
tally.observe(p)
|
||||
if h, fh := handshakeOf(p); h != nil {
|
||||
switch {
|
||||
case fh == nil:
|
||||
classical = append(classical, fmt.Sprintf("counter=%d to=%s", h.MessageCounter, p.To))
|
||||
case h.MessageCounter == 1 && p.To == theirUdp:
|
||||
msg1ToThem++
|
||||
case h.MessageCounter == 2 && receiver == myControl:
|
||||
msg2ByFragID[fh.FragID]++
|
||||
}
|
||||
}
|
||||
return router.KeepRouting
|
||||
}), "tunnel never came up")
|
||||
t.Logf("msg1 fragments to them: %d, msg2 fragments by FragID: %v, classical: %v", msg1ToThem, msg2ByFragID, classical)
|
||||
|
||||
assertUdpPacket(t, []byte("Hi from me"), theirControl.GetFromTun(true), myVpn[0].Addr(), theirVpn[0].Addr(), 80, 80)
|
||||
assert.GreaterOrEqual(t, len(msg2ByFragID), 2, "both hosts answered")
|
||||
// A responder only resends msg2 when the initiator retries, so every
|
||||
// FragID staying at one burst means each interleaved answer completed in
|
||||
// its own slot. (The msg1 count is not a clean measure: a restart after
|
||||
// the wrong host answers leaves the old handshake's timer entry in place,
|
||||
// so the new one ticks twice, a few milliseconds apart.)
|
||||
for fragID, n := range msg2ByFragID {
|
||||
assert.LessOrEqual(t, n, 9+3, "responder %d had to resend msg2", fragID)
|
||||
}
|
||||
if tc.pqOnly {
|
||||
assert.Empty(t, classical, "require never offers a classical attempt")
|
||||
}
|
||||
|
||||
assertHostInfoPair(t, myUdp, theirUdp, myVpn, theirVpn, myControl, theirControl)
|
||||
assertTunnel(t, myVpn[0].Addr(), theirVpn[0].Addr(), myControl, theirControl, r)
|
||||
assert.Nil(t, myControl.GetHostInfoByVpnAddr(otherVpn[0].Addr(), false), "no tunnel to the other host")
|
||||
assert.Empty(t, myControl.ListHostmapIndexes(true), "no pending index may outlive the handshake")
|
||||
assert.Equal(t, cert.Version3, myControl.GetHostInfoByVpnAddr(theirVpn[0].Addr(), false).Cert.Version(), "the hybrid handshake won")
|
||||
|
||||
myControl.Stop()
|
||||
theirControl.Stop()
|
||||
otherControl.Stop()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,21 @@ func (e *Encoder) Symbol(dst []byte, index int) ([]byte, error) {
|
||||
// Decoder collects symbols for one message until it can reconstruct it.
|
||||
// It stores at most K symbols, so its memory is bounded by the layout no matter
|
||||
// how many symbols arrive.
|
||||
//
|
||||
// TODO(in-place decode): for receivers that already hold state for the message,
|
||||
// the initiator's msg2 slots and a pending responder's msg3, store into one
|
||||
// buffer of K*SymbolSize (plus room for the Nebula header) allocated from the
|
||||
// layout: data symbol i at offset i*SymbolSize, repair symbols parked in the
|
||||
// slots of data symbols not seen yet (moved to another empty slot if that data
|
||||
// symbol arrives; one always exists while fewer than K are held). Decode then
|
||||
// folds the known columns into each parked repair symbol in place and runs
|
||||
// Gauss-Jordan across those rows in place; a Cauchy submatrix needs no
|
||||
// pivoting, so each solution lands in the slot of its own column and the
|
||||
// buffer is the packet. That removes the per-symbol copies, the output buffer
|
||||
// and reassembled()'s copy, about 20KB and a dozen allocations per msg2, at the
|
||||
// cost of holding K*SymbolSize from the first fragment and destroying the
|
||||
// input on a failed decode. It is not for the responder's msg1 table, which
|
||||
// must never allocate from a claimed size.
|
||||
type Decoder struct {
|
||||
layout Layout
|
||||
have [MaxSymbols]bool
|
||||
|
||||
+92
-19
@@ -28,13 +28,19 @@ const (
|
||||
pqAmplification = 3
|
||||
)
|
||||
|
||||
// pqMaxMsgLen bounds the fragmented handshake messages a node reassembles:
|
||||
// msg1 in the responder's table and msg2 in an initiator's slots. The largest
|
||||
// is msg2 at about 9.6KB, which leaves room for certificates carrying many
|
||||
// names and routes.
|
||||
const pqMaxMsgLen = 16384
|
||||
|
||||
// pqTableConfig bounds the responder's reassembly of msg1 fragments from senders
|
||||
// that have not authenticated yet.
|
||||
var pqTableConfig = fragment.TableConfig{
|
||||
MaxBytes: 4 << 20,
|
||||
MaxEntries: 1024,
|
||||
MaxPerSource: 4,
|
||||
MaxMsgLen: 16384,
|
||||
MaxMsgLen: pqMaxMsgLen,
|
||||
TTL: 3 * time.Second,
|
||||
}
|
||||
|
||||
@@ -84,6 +90,24 @@ func (hm *HandshakeManager) reloadPQ(c *config.C) {
|
||||
)
|
||||
}
|
||||
|
||||
// pqMsg2Slots is how many responders' msg2s one attempt reassembles at once.
|
||||
// Two hosts answer the same msg1 when a stale lighthouse address for the peer
|
||||
// belongs to another node now. Both answers name this attempt in RemoteIndex
|
||||
// and differ only in FragID, the responder's own index. A single assembly that
|
||||
// emptied on a FragID change let the two interleaved bursts wipe each other
|
||||
// out, and since hearing any msg2 fragment stops the classical turns, the
|
||||
// retries only repeated it (up to three extra attempts in e2e). One slot per
|
||||
// FragID lets whichever answer completes first be processed; a wrong host's
|
||||
// answer then takes the existing block-and-retry path.
|
||||
const pqMsg2Slots = 2
|
||||
|
||||
// msg2Slot reassembles one responder's msg2.
|
||||
type msg2Slot struct {
|
||||
fragID uint32
|
||||
msgLen uint16
|
||||
a *fragment.Assembly
|
||||
}
|
||||
|
||||
// pqAttempt is the initiator's post-quantum attempt for a pending handshake. It
|
||||
// lives alongside the classical attempt in HandshakeHostInfo and has its own
|
||||
// local index, so whichever answer arrives first wins.
|
||||
@@ -92,9 +116,62 @@ type pqAttempt struct {
|
||||
subtype header.MessageSubType
|
||||
index uint32
|
||||
sender *fragment.Sender
|
||||
msg2 *fragment.Assembly
|
||||
cookieResendAt time.Time // last resend prompted by a cookie reply
|
||||
heard bool // a msg2 fragment or cookie reply arrived, so the peer speaks PQ
|
||||
msg2 [pqMsg2Slots]msg2Slot // an array, not a map: one slot is used nearly always
|
||||
cookieResendAt time.Time // last resend prompted by a cookie reply
|
||||
heard bool // a msg2 fragment or cookie reply arrived, so the peer speaks PQ
|
||||
}
|
||||
|
||||
// slotFor returns the assembly collecting msg2 fragments with fh's FragID,
|
||||
// starting one if there is none. A responder never changes its FragID or
|
||||
// MsgLen, so a fragment whose MsgLen disagrees with its slot is a stray or a
|
||||
// forgery and starts that slot over. When every slot is taken the one holding
|
||||
// the least is replaced: a third responder is vanishingly rare, and a forged
|
||||
// FragID could already spoil an attempt. It returns nil if no assembly can be
|
||||
// made for fh.
|
||||
func (pq *pqAttempt) slotFor(fh fragment.Header) *fragment.Assembly {
|
||||
victim := -1
|
||||
for i := range pq.msg2 {
|
||||
s := &pq.msg2[i]
|
||||
if s.a != nil && s.fragID == fh.FragID {
|
||||
if s.msgLen == fh.MsgLen {
|
||||
return s.a
|
||||
}
|
||||
victim = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if victim < 0 {
|
||||
for i := range pq.msg2 {
|
||||
if pq.msg2[i].a == nil {
|
||||
victim = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if victim < 0 {
|
||||
victim = 0
|
||||
for i := 1; i < len(pq.msg2); i++ {
|
||||
if pq.msg2[i].a.Stored() < pq.msg2[victim].a.Stored() {
|
||||
victim = i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a, err := fragment.NewAssembly(fh)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
pq.msg2[victim] = msg2Slot{fragID: fh.FragID, msgLen: fh.MsgLen, a: a}
|
||||
return a
|
||||
}
|
||||
|
||||
// dropSlot releases the slot holding a.
|
||||
func (pq *pqAttempt) dropSlot(a *fragment.Assembly) {
|
||||
for i := range pq.msg2 {
|
||||
if pq.msg2[i].a == a {
|
||||
pq.msg2[i] = msg2Slot{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pqFragKey identifies one msg1 being reassembled.
|
||||
@@ -392,8 +469,13 @@ func (hm *HandshakeManager) handleFragment(via ViaSender, packet []byte, h *head
|
||||
}
|
||||
}
|
||||
|
||||
// continuePQ collects msg2 fragments for the initiator's PQ attempt.
|
||||
// continuePQ collects msg2 fragments for the initiator's PQ attempt, one slot
|
||||
// per responder FragID, and processes the first msg2 to complete.
|
||||
func (hm *HandshakeManager) continuePQ(via ViaSender, hh *HandshakeHostInfo, h *header.H, fh fragment.Header, sym []byte) {
|
||||
if int(fh.MsgLen) > pqMaxMsgLen {
|
||||
return
|
||||
}
|
||||
|
||||
hh.Lock()
|
||||
defer hh.Unlock()
|
||||
|
||||
@@ -403,26 +485,17 @@ func (hm *HandshakeManager) continuePQ(via ViaSender, hh *HandshakeHostInfo, h *
|
||||
}
|
||||
pq.heard = true
|
||||
|
||||
if pq.msg2 == nil {
|
||||
a, err := fragment.NewAssembly(fh)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
pq.msg2 = a
|
||||
}
|
||||
ready, err := pq.msg2.Add(fh, sym)
|
||||
if errors.Is(err, fragment.ErrMismatch) {
|
||||
// The responder's FragID and MsgLen never change, so this is a stray or a
|
||||
// forgery. Start over from this fragment rather than wedge.
|
||||
pq.msg2 = nil
|
||||
a := pq.slotFor(fh)
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
ready, err := a.Add(fh, sym)
|
||||
if err != nil || !ready {
|
||||
return
|
||||
}
|
||||
|
||||
msg, err := pq.msg2.Decode()
|
||||
pq.msg2 = nil
|
||||
msg, err := a.Decode()
|
||||
pq.dropSlot(a)
|
||||
if err != nil {
|
||||
hm.l.Debug("failed to decode handshake fragments", "from", via, "error", err)
|
||||
return
|
||||
|
||||
@@ -397,3 +397,134 @@ func TestUsePQ(t *testing.T) {
|
||||
hm.notePQPeer(header.HandshakeIXPSK2MLKEM1024, []netip.Addr{peer})
|
||||
assert.Equal(t, []bool{true, true, true}, attempts(false, false, false), "then only PQ")
|
||||
}
|
||||
|
||||
// msg2Fragments returns one attempt's packets carrying msg as a responder's msg2
|
||||
// under fragID, data symbols first and then repair.
|
||||
func msg2Fragments(t *testing.T, msg []byte, fragID uint32) [][]byte {
|
||||
t.Helper()
|
||||
s, err := fragment.NewSender(msg, header.HandshakeIXHybridMLKEM1024, 7, 2, fragID)
|
||||
require.NoError(t, err)
|
||||
now, later, err := s.Attempt(fragment.DefaultRepair(s.Layout().K))
|
||||
require.NoError(t, err)
|
||||
return append(now, later...)
|
||||
}
|
||||
|
||||
// feedMsg2 puts one packet through an attempt's slots.
|
||||
func feedMsg2(t *testing.T, pq *pqAttempt, p []byte) (*fragment.Assembly, fragment.Header, bool) {
|
||||
t.Helper()
|
||||
fh, sym, err := fragment.Parse(p[header.Len:])
|
||||
require.NoError(t, err)
|
||||
a := pq.slotFor(fh)
|
||||
require.NotNil(t, a)
|
||||
ready, err := a.Add(fh, sym)
|
||||
require.NoError(t, err)
|
||||
return a, fh, ready
|
||||
}
|
||||
|
||||
func slotsUsed(pq *pqAttempt) (n int, ids []uint32) {
|
||||
for _, s := range pq.msg2 {
|
||||
if s.a != nil {
|
||||
n++
|
||||
ids = append(ids, s.fragID)
|
||||
}
|
||||
}
|
||||
return n, ids
|
||||
}
|
||||
|
||||
// TestPQMsg2Slots interleaves two responders' msg2 bursts, the shape two hosts
|
||||
// answering the same msg1 produce on a LAN, and checks each fills its own slot.
|
||||
func TestPQMsg2Slots(t *testing.T) {
|
||||
msgB := make([]byte, 9625) // the hybrid msg2: k = 9
|
||||
msgC := make([]byte, 9625)
|
||||
for i := range msgB {
|
||||
msgB[i], msgC[i] = 'b', 'c'
|
||||
}
|
||||
pktsB := msg2Fragments(t, msgB, 1001)
|
||||
pktsC := msg2Fragments(t, msgC, 2002)
|
||||
require.Len(t, pktsB, 12)
|
||||
|
||||
// B0 B1 C0 B2 B3 B4 C1 C2 B5 B6 B7 B8 C3: no run of one FragID reaches nine,
|
||||
// so a single slot emptied on every change would never complete.
|
||||
order := []struct {
|
||||
fromC bool
|
||||
i int
|
||||
}{{false, 0}, {false, 1}, {true, 0}, {false, 2}, {false, 3}, {false, 4}, {true, 1}, {true, 2}, {false, 5}, {false, 6}, {false, 7}, {false, 8}, {true, 3}}
|
||||
|
||||
pq := &pqAttempt{}
|
||||
var got []byte
|
||||
for n, o := range order {
|
||||
p := pktsB[o.i]
|
||||
if o.fromC {
|
||||
p = pktsC[o.i]
|
||||
}
|
||||
a, _, ready := feedMsg2(t, pq, p)
|
||||
if !ready {
|
||||
continue
|
||||
}
|
||||
require.Equal(t, 11, n, "B completes on its ninth symbol")
|
||||
msg, err := a.Decode()
|
||||
require.NoError(t, err)
|
||||
got = msg
|
||||
pq.dropSlot(a)
|
||||
}
|
||||
require.Equal(t, msgB, got)
|
||||
|
||||
n, ids := slotsUsed(pq)
|
||||
assert.Equal(t, 1, n)
|
||||
assert.Equal(t, []uint32{2002}, ids, "B's slot was released, C's kept")
|
||||
|
||||
// C's four symbols survived B's completion, so five more finish it.
|
||||
var c *fragment.Assembly
|
||||
var ready bool
|
||||
for i := 4; i < 9; i++ {
|
||||
c, _, ready = feedMsg2(t, pq, pktsC[i])
|
||||
assert.Equal(t, i == 8, ready, "symbol %d", i)
|
||||
}
|
||||
msg, err := c.Decode()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, msgC, msg)
|
||||
|
||||
t.Run("a third responder takes the slot holding the least", func(t *testing.T) {
|
||||
pq := &pqAttempt{}
|
||||
feedMsg2(t, pq, pktsB[0])
|
||||
feedMsg2(t, pq, pktsB[1])
|
||||
feedMsg2(t, pq, pktsC[0])
|
||||
pktsD := msg2Fragments(t, msgC, 3003)
|
||||
d, fh, _ := feedMsg2(t, pq, pktsD[0])
|
||||
_, ids := slotsUsed(pq)
|
||||
assert.ElementsMatch(t, []uint32{1001, 3003}, ids)
|
||||
|
||||
l, err := fh.Layout()
|
||||
require.NoError(t, err)
|
||||
b, _, _ := feedMsg2(t, pq, pktsB[2])
|
||||
assert.Equal(t, 3*l.SymbolSize, b.Stored(), "B kept its symbols")
|
||||
assert.Equal(t, l.SymbolSize, d.Stored())
|
||||
})
|
||||
|
||||
t.Run("a different MsgLen under the same FragID starts the slot over", func(t *testing.T) {
|
||||
pq := &pqAttempt{}
|
||||
feedMsg2(t, pq, pktsB[0])
|
||||
feedMsg2(t, pq, pktsB[1])
|
||||
shorter := msg2Fragments(t, msgB[:9000], 1001)
|
||||
a, fh, _ := feedMsg2(t, pq, shorter[0])
|
||||
l, err := fh.Layout()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, l.SymbolSize, a.Stored())
|
||||
n, ids := slotsUsed(pq)
|
||||
assert.Equal(t, 1, n)
|
||||
assert.Equal(t, []uint32{1001}, ids)
|
||||
})
|
||||
|
||||
t.Run("an oversize claim is dropped before it takes a slot", func(t *testing.T) {
|
||||
hm, _ := newPQTestManager(t)
|
||||
hh := &HandshakeHostInfo{hostinfo: &HostInfo{}, pq: &pqAttempt{index: 42}}
|
||||
hm.indexes[42] = hh
|
||||
fh := fragment.Header{FragID: 1001, MsgLen: pqMaxMsgLen + 1}
|
||||
l, err := fh.Layout()
|
||||
require.NoError(t, err)
|
||||
hm.continuePQ(ViaSender{}, hh, &header.H{RemoteIndex: 42, MessageCounter: 2}, fh, make([]byte, l.SymbolSize))
|
||||
n, _ := slotsUsed(hh.pq)
|
||||
assert.Zero(t, n)
|
||||
assert.False(t, hh.pq.heard)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user