multiport: derive lane sessions on demand, install once authenticated

How many lanes a tunnel has is partly the peer's call: it advertises how many it
sends on and we have to be able to receive all of them, up to the header's 256.
Deriving them all when the handshake completes meant a peer advertising a large
count cost us a replay window and two cipher states per lane, per tunnel, for
lanes it may never send on.

Derive each session on the first packet that needs it instead. The TX side asks
through laneSet.session, which installs on the spot — we only ask for lanes we
chose to send on. The RX side can't do that: anyone who can spoof a tunnel's
local index can name any lane, and installing on sight would hand them the same
allocation for free. So laneSession hands back a session without publishing it
and reports that it did; outside.go installs it only once the packet has
decrypted, which is the first moment the lane is known to be real. A spoofer
gets an HKDF per packet and nothing retained.

Only the session table is sized by the peer's advert now. txAddr, demand and
probe are sized by the lanes we will actually send on, so the peer can no longer
size our per-lane tx state either.

Also refuse relayed lane packets before the session lookup rather than after,
so a junk relay packet can't reach the derivation path at all.
This commit is contained in:
Wade Simmons
2026-09-02 14:48:17 -04:00
parent fb20de39b2
commit 8c5c740571
6 changed files with 288 additions and 103 deletions
+10 -10
View File
@@ -77,8 +77,8 @@ func newConnectionStateFromResult(r *handshake.Result) (*ConnectionState, error)
return ci, nil return ci, nil
} }
// newLaneConnectionState derives multiport lane s's session from the completed // newLaneConnectionState derives multiport lane s's session from the base
// base handshake. Each key is an HKDF expansion of the base tunnel's matching // tunnel's material. Each key is an HKDF expansion of the base tunnel's matching
// key, labelled with the lane index, so the pair stays matched with no extra // key, labelled with the lane index, so the pair stays matched with no extra
// negotiation: Noise leaves our send key equal to the peer's receive key, and // negotiation: Noise leaves our send key equal to the peer's receive key, and
// expanding both with the same label preserves that. // expanding both with the same label preserves that.
@@ -86,26 +86,26 @@ func newConnectionStateFromResult(r *handshake.Result) (*ConnectionState, error)
// The lane gets its own counter and replay window starting from zero. No // The lane gets its own counter and replay window starting from zero. No
// handshake messages were spent on it, so unlike the base session there is // handshake messages were spent on it, so unlike the base session there is
// nothing to seed. // nothing to seed.
func newLaneConnectionState(r *handshake.Result, lane uint8) (*ConnectionState, error) { func newLaneConnectionState(m *laneMaterial, lane uint8) (*ConnectionState, error) {
if lane == 0 { if lane == 0 {
return nil, fmt.Errorf("lane 0 is the base session") return nil, fmt.Errorf("lane 0 is the base session")
} }
eKey, err := deriveLaneKey(r.EKey.UnsafeKey(), lane) eKey, err := deriveLaneKey(m.eKey, lane)
if err != nil { if err != nil {
return nil, err return nil, err
} }
dKey, err := deriveLaneKey(r.DKey.UnsafeKey(), lane) dKey, err := deriveLaneKey(m.dKey, lane)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &ConnectionState{ return &ConnectionState{
myCert: r.MyCert, myCert: m.myCert,
initiator: r.Initiator, initiator: m.initiator,
peerCert: r.RemoteCert, peerCert: m.peerCert,
eKey: noiseutil.NewCipherStateFromKey(eKey, r.Cipher), eKey: noiseutil.NewCipherStateFromKey(eKey, m.cipher),
dKey: noiseutil.NewCipherStateFromKey(dKey, r.Cipher), dKey: noiseutil.NewCipherStateFromKey(dKey, m.cipher),
window: NewBits(ReplayWindow), window: NewBits(ReplayWindow),
epoch: sessionEpoch.Add(1), epoch: sessionEpoch.Add(1),
}, nil }, nil
+1 -1
View File
@@ -434,7 +434,7 @@ func copyLanes(h *HostInfo) []ControlLane {
l.Up = true l.Up = true
l.Remote = *addr l.Remote = *addr
} }
if cs := ls.sessions[s]; cs != nil { if cs := ls.sessions[s].Load(); cs != nil {
l.MessageCounter = cs.messageCounter.Load() l.MessageCounter = cs.messageCounter.Load()
} }
lanes = append(lanes, l) lanes = append(lanes, l)
+5 -12
View File
@@ -721,12 +721,10 @@ func (hm *HandshakeManager) laneAdvert() *handshake.LaneDetails {
} }
} }
// maybeAllocLanes derives the multiport lane sessions for a just-completed // maybeAllocLanes sets up the multiport lanes for a just-completed tunnel. Must
// tunnel. Must run before the hostinfo becomes visible in the hostmap: the data // run before the hostinfo becomes visible in the hostmap: the data plane reads
// plane reads hostinfo.lanes with no synchronization at all. // hostinfo.lanes without synchronizing on it. The sessions themselves are derived
// // later, on the first packet that needs each one.
// A failure here costs the tunnel its lanes, not the tunnel: the base session is
// already usable and every lane the data plane wants falls back to it.
func (hm *HandshakeManager) maybeAllocLanes(hostinfo *HostInfo, result *handshake.Result) { func (hm *HandshakeManager) maybeAllocLanes(hostinfo *HostInfo, result *handshake.Result) {
if hm.config.laneCount == 0 || result.PeerPortCount == 0 { if hm.config.laneCount == 0 || result.PeerPortCount == 0 {
return return
@@ -735,12 +733,7 @@ func (hm *HandshakeManager) maybeAllocLanes(hostinfo *HostInfo, result *handshak
return return
} }
ls, err := newLaneSet(result, hm.config.laneCount, hm.f.myVpnAddrs[0], hostinfo.vpnAddrs[0]) hostinfo.lanes = newLaneSet(result, hm.config.laneCount, hm.f.myVpnAddrs[0], hostinfo.vpnAddrs[0])
if err != nil {
hostinfo.logger(hm.l).Error("Failed to derive multiport lanes", "error", err)
return
}
hostinfo.lanes = ls
} }
// beginHandshake handles an incoming handshake packet that doesn't match any // beginHandshake handles an incoming handshake packet that doesn't match any
+135 -42
View File
@@ -9,6 +9,8 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/flynn/noise"
"github.com/slackhq/nebula/cert"
"github.com/slackhq/nebula/handshake" "github.com/slackhq/nebula/handshake"
"github.com/slackhq/nebula/header" "github.com/slackhq/nebula/header"
"github.com/slackhq/nebula/noiseutil" "github.com/slackhq/nebula/noiseutil"
@@ -28,11 +30,18 @@ import (
// Lane 0 is the base tunnel itself: HostInfo.ConnectionState, socket 0, and the // Lane 0 is the base tunnel itself: HostInfo.ConnectionState, socket 0, and the
// peer's real remote address. Lane s > 0 egresses writers[s] (bound to // peer's real remote address. Lane s > 0 egresses writers[s] (bound to
// listen.port+s) toward the peer's advertised port range. Receiving on a lane // listen.port+s) toward the peer's advertised port range. Receiving on a lane
// needs no permission — the session exists as soon as the base handshake // needs no permission — the keys are derivable the moment the base handshake
// completes — but sending on one needs proof the new 5-tuple actually works, // completes — but sending on one needs proof the new 5-tuple actually works,
// since nothing else would notice a middlebox quietly dropping it. So a lane // since nothing else would notice a middlebox quietly dropping it. So a lane
// stays down until a probe on it is acked, and falls back to the base tunnel the // stays down until a probe on it is acked, and falls back to the base tunnel the
// moment it stops being acked. // moment it stops being acked.
//
// Both directions are pay-per-use. A lane session is derived on the first packet
// that needs it, because how many lanes exist is partly the peer's call: it
// advertises how many it sends on, and we have to be able to receive all of
// them. Deriving them all up front would let a peer advertising the maximum cost
// us a replay window and two cipher states per lane, per tunnel, for lanes it
// may never send on.
const ( const (
// laneKeyInfo is the HKDF label prefix for lane key expansion. Changing it // laneKeyInfo is the HKDF label prefix for lane key expansion. Changing it
@@ -65,28 +74,35 @@ const (
// laneSet holds a peer's lane sessions and the state deciding which lanes may // laneSet holds a peer's lane sessions and the state deciding which lanes may
// carry traffic. It is built when the base handshake completes and never // carry traffic. It is built when the base handshake completes and never
// resized, so the slices and their lengths are immutable; mu guards the fields // resized, so the slices and their lengths are immutable; mu guards the fields
// under it, and txAddr/demand are atomics read by the data plane without it. // under it, and sessions/txAddr/demand are atomics read by the data plane
// without it.
type laneSet struct { type laneSet struct {
// sessions[s] is lane s's session. sessions[0] is nil: lane 0 is the base // sessions[s] holds lane s's session once something has needed it, and nil
// tunnel's own ConnectionState. Immutable, so the RX path reads it with no // until then. sessions[0] is never populated: lane 0 is the base tunnel's own
// synchronization at all. // ConnectionState. The length is immutable, so the data plane bounds-checks
sessions []*ConnectionState // and loads with no locking.
sessions []atomic.Pointer[ConnectionState]
// material is what a lane session is derived from, kept because sessions are
// derived lazily and the handshake result is long gone by then.
material laneMaterial
// txAddr[s] holds lane s's remote address while the lane is proven usable // txAddr[s] holds lane s's remote address while the lane is proven usable
// and nil otherwise. This single atomic is both the TX gate and the // and nil otherwise. This single atomic is both the TX gate and the
// destination, so a routine that loads non-nil has everything it needs. // destination, so a routine that loads non-nil has everything it needs.
// Sized txLanes: lanes above that never send.
txAddr []atomic.Pointer[netip.AddrPort] txAddr []atomic.Pointer[netip.AddrPort]
// demand[s] is raised by the TX path when a routine riding lane s has // demand[s] is raised by the TX path when a routine riding lane s has
// traffic for this peer and the lane is down. Probing is demand-driven: a // traffic for this peer and the lane is down. Probing is demand-driven: a
// peer we exchange a trickle with never costs more than its base tunnel, no // peer we exchange a trickle with never costs more than its base tunnel, no
// matter how many lanes are configured. // matter how many lanes are configured. Sized txLanes.
demand []atomic.Bool demand []atomic.Bool
// txLanes is how many lanes we may send on — our lane count clamped to the // txLanes is how many lanes we may send on — our lane count clamped to the
// ports the peer bound. Lanes from txLanes up exist only to receive, which // ports the peer bound. Lanes from txLanes up can only receive, which is how
// is how a peer with more routines than us still spreads its own traffic. // a peer with more routines than us still spreads its own traffic.
// Immutable. // Immutable, and the length of every TX-side slice here.
txLanes int txLanes int
mu sync.Mutex mu sync.Mutex
@@ -103,10 +119,22 @@ type laneSet struct {
// a roam invalidates every lane rather than moving it. // a roam invalidates every lane rather than moving it.
peerAddr netip.Addr peerAddr netip.Addr
// probe[s] is lane s's probe and backoff state. // probe[s] is lane s's probe and backoff state. Sized txLanes.
probe []laneProbeState probe []laneProbeState
} }
// laneMaterial is everything a lane session is derived from. The two base keys
// are the same secret the base tunnel's own cipher states already hold — the
// noiseutil.CipherState interface just doesn't hand them back, so a lane set
// keeps its own copy rather than a reference to the session.
type laneMaterial struct {
eKey, dKey [32]byte
cipher noise.CipherFunc
myCert cert.Certificate
peerCert *cert.CachedCertificate
initiator bool
}
type laneProbeState struct { type laneProbeState struct {
// gen is the generation of the last probe sent, echoed in the ack so a late // gen is the generation of the last probe sent, echoed in the ack so a late
// ack cannot promote a lane on the strength of a superseded probe. // ack cannot promote a lane on the strength of a superseded probe.
@@ -128,46 +156,48 @@ type laneProbeState struct {
retryAt time.Time retryAt time.Time
} }
// newLaneSet derives the lane sessions for a freshly completed base handshake. // newLaneSet sets up the lanes for a freshly completed base handshake. It
// It returns nil when the pair has no lane beyond the base tunnel, which is the // returns nil when the pair has no lane beyond the base tunnel, which is the
// normal answer for a peer running without multiport. // normal answer for a peer running without multiport. No session is derived
func newLaneSet(r *handshake.Result, myLanes int, myAddr, peerAddr netip.Addr) (*laneSet, error) { // here; each is derived on the first packet that needs it.
func newLaneSet(r *handshake.Result, myLanes int, myAddr, peerAddr netip.Addr) *laneSet {
// PeerPortCount and PeerBasePort are already bounded to uint16 by the // PeerPortCount and PeerBasePort are already bounded to uint16 by the
// handshake payload parser. A zero port count is a peer that did not // handshake payload parser. A zero port count is a peer that did not
// advertise multiport at all, so there is no lane to be had in either // advertise multiport at all, so there is no lane to be had in either
// direction. // direction.
peerPorts := uint16(r.PeerPortCount) peerPorts := uint16(r.PeerPortCount)
if peerPorts == 0 { if peerPorts == 0 {
return nil, nil return nil
} }
// Sessions have to cover both directions: we send on our lanes and receive // Sessions have to cover both directions: we send on our lanes and receive
// on the peer's, and one derived session serves both ends of a lane index. // on the peer's, and one derived session serves both ends of a lane index.
// Only the session table is sized by the peer's advertised count; the TX-side
// state is sized by what we will actually send on.
n := min(max(myLanes, int(r.PeerTxLanes)), header.MaxLane+1) n := min(max(myLanes, int(r.PeerTxLanes)), header.MaxLane+1)
if n < 2 { if n < 2 {
return nil, nil return nil
} }
txLanes := min(myLanes, int(peerPorts), n)
ls := &laneSet{ return &laneSet{
sessions: make([]*ConnectionState, n), sessions: make([]atomic.Pointer[ConnectionState], n),
txAddr: make([]atomic.Pointer[netip.AddrPort], n), material: laneMaterial{
demand: make([]atomic.Bool, n), eKey: r.EKey.UnsafeKey(),
probe: make([]laneProbeState, n), dKey: r.DKey.UnsafeKey(),
txLanes: min(myLanes, int(peerPorts), n), cipher: r.Cipher,
myCert: r.MyCert,
peerCert: r.RemoteCert,
initiator: r.Initiator,
},
txAddr: make([]atomic.Pointer[netip.AddrPort], txLanes),
demand: make([]atomic.Bool, txLanes),
probe: make([]laneProbeState, txLanes),
txLanes: txLanes,
peerPortCount: peerPorts, peerPortCount: peerPorts,
peerBasePort: uint16(r.PeerBasePort), peerBasePort: uint16(r.PeerBasePort),
portOffset: lanePortOffset(myAddr, peerAddr, peerPorts), portOffset: lanePortOffset(myAddr, peerAddr, peerPorts),
} }
for s := 1; s < n; s++ {
cs, err := newLaneConnectionState(r, uint8(s))
if err != nil {
return nil, err
}
ls.sessions[s] = cs
}
return ls, nil
} }
// lanePortOffset returns the rotation applied to this pair's lane target ports, // lanePortOffset returns the rotation applied to this pair's lane target ports,
@@ -202,13 +232,63 @@ func lanePortOffset(myAddr, peerAddr netip.Addr, peerPortCount uint16) uint16 {
return o return o
} }
// laneSession returns lane s's session, or nil if this peer has no lane s. // laneSession returns the session to decrypt a lane s packet with, deriving one
func (i *HostInfo) laneSession(s uint8) *ConnectionState { // if this is the first packet to claim that lane. A nil session with no error
// means this tunnel has no lane s at all.
//
// cached reports whether the session was already in the table. A fresh one is
// deliberately left out of it: anyone who can spoof this tunnel's local index
// can name any lane, and installing on sight would let them make us hold a
// replay window and two cipher states per lane without authenticating anything.
// The caller installs with installSession once the packet decrypts, which is the
// first moment the lane is known to be real.
func (i *HostInfo) laneSession(s uint8) (ci *ConnectionState, cached bool, err error) {
ls := i.lanes ls := i.lanes
if ls == nil || int(s) >= len(ls.sessions) { if ls == nil || s == 0 || int(s) >= len(ls.sessions) {
return nil return nil, false, nil
} }
return ls.sessions[s]
if cs := ls.sessions[s].Load(); cs != nil {
return cs, true, nil
}
cs, err := newLaneConnectionState(&ls.material, s)
if err != nil {
return nil, false, err
}
return cs, false, nil
}
// installSession publishes a session derived by laneSession, so the next packet
// on the lane doesn't have to derive it again. cs must have already decrypted a
// packet from the peer.
//
// A loser of the race keeps the session it decrypted with and drops it after,
// which loses that one packet's replay-window entry. The alternative is holding
// a lock across a decrypt to close a window that is one packet wide and only
// open on the first packet of a lane.
func (ls *laneSet) installSession(s uint8, cs *ConnectionState) {
ls.sessions[s].CompareAndSwap(nil, cs)
}
// session returns lane s's session for our own use, deriving and installing it
// if it doesn't exist yet. Unlike the RX path this needs no proof the lane is
// real: we only ask for lanes we chose to send on. s must be a lane this set
// covers.
func (ls *laneSet) session(s int) (*ConnectionState, error) {
if cs := ls.sessions[s].Load(); cs != nil {
return cs, nil
}
cs, err := newLaneConnectionState(&ls.material, uint8(s))
if err != nil {
return nil, err
}
if !ls.sessions[s].CompareAndSwap(nil, cs) {
return ls.sessions[s].Load(), nil
}
return cs, nil
} }
// maxMessageCounter returns the highest counter across the base session and // maxMessageCounter returns the highest counter across the base session and
@@ -222,8 +302,10 @@ func (i *HostInfo) maxMessageCounter() uint64 {
} }
c := i.ConnectionState.messageCounter.Load() c := i.ConnectionState.messageCounter.Load()
if ls := i.lanes; ls != nil { if ls := i.lanes; ls != nil {
for _, cs := range ls.sessions { for s := range ls.sessions {
cs := ls.sessions[s].Load()
if cs == nil { if cs == nil {
// Never derived, so it has never sent anything either.
continue continue
} }
if lc := cs.messageCounter.Load(); lc > c { if lc := cs.messageCounter.Load(); lc > c {
@@ -244,7 +326,13 @@ func (ls *laneSet) txLane(s int) (*ConnectionState, netip.AddrPort) {
} }
if addr := ls.txAddr[s].Load(); addr != nil { if addr := ls.txAddr[s].Load(); addr != nil {
return ls.sessions[s], *addr // The lane is only up because a probe was acked on it, and that probe
// derived the session, so this load cannot miss. Fall back rather than
// derive here anyway: this is the hot path and a nil is not worth an HKDF.
if cs := ls.sessions[s].Load(); cs != nil {
return cs, *addr
}
return nil, netip.AddrPort{}
} }
// Load-guarded so the common case of a lane that will not come up is a // Load-guarded so the common case of a lane that will not come up is a
@@ -395,7 +483,12 @@ func (ls *laneSet) resetLocked() {
// keys we derived for this lane match the ones it derived. Reports whether the // keys we derived for this lane match the ones it derived. Reports whether the
// probe made it onto the wire. // probe made it onto the wire.
func (f *Interface) sendLaneProbe(hostinfo *HostInfo, s int, gen uint8, addr netip.AddrPort, nb, out []byte) bool { func (f *Interface) sendLaneProbe(hostinfo *HostInfo, s int, gen uint8, addr netip.AddrPort, nb, out []byte) bool {
ci := hostinfo.lanes.sessions[s] // The first probe on a lane is what derives its session.
ci, err := hostinfo.lanes.session(s)
if err != nil {
hostinfo.logger(f.l).Error("Failed to derive multiport lane session", "error", err, "lane", s)
return false
}
if ci == nil || ci.eKey == nil { if ci == nil || ci.eKey == nil {
return false return false
} }
@@ -413,7 +506,7 @@ func (f *Interface) sendLaneProbe(hostinfo *HostInfo, s int, gen uint8, addr net
} }
b := header.EncodeLane(out[:0], header.Version, header.Test, header.LaneProbe, hostinfo.remoteIndexId, c, uint8(s)) b := header.EncodeLane(out[:0], header.Version, header.Test, header.LaneProbe, hostinfo.remoteIndexId, c, uint8(s))
b, err := ci.eKey.EncryptDanger(b, b, []byte{uint8(s), gen}, c, nb) b, err = ci.eKey.EncryptDanger(b, b, []byte{uint8(s), gen}, c, nb)
if noiseutil.EncryptLockNeeded { if noiseutil.EncryptLockNeeded {
ci.writeLock.Unlock() ci.writeLock.Unlock()
} }
+111 -27
View File
@@ -24,16 +24,24 @@ var (
testPeerAddr = netip.MustParseAddr("10.0.0.2") testPeerAddr = netip.MustParseAddr("10.0.0.2")
) )
// newTestLaneSet derives a lane set from a real handshake result so the sessions // newTestLaneSet builds a lane set from a real handshake result so the sessions
// hold usable keys. // it derives hold usable keys.
func newTestLaneSet(t *testing.T, r *handshake.Result, myLanes int, peerPorts, peerBase, peerTxLanes uint32) *laneSet { func newTestLaneSet(t *testing.T, r *handshake.Result, myLanes int, peerPorts, peerBase, peerTxLanes uint32) *laneSet {
t.Helper() t.Helper()
r.PeerPortCount = peerPorts r.PeerPortCount = peerPorts
r.PeerBasePort = peerBase r.PeerBasePort = peerBase
r.PeerTxLanes = peerTxLanes r.PeerTxLanes = peerTxLanes
ls, err := newLaneSet(r, myLanes, testMyAddr, testPeerAddr) return newLaneSet(r, myLanes, testMyAddr, testPeerAddr)
}
// laneSessionFor derives lane s's session and installs it, standing in for the
// data-plane call that would normally be the first to need it.
func laneSessionFor(t *testing.T, ls *laneSet, s int) *ConnectionState {
t.Helper()
cs, err := ls.session(s)
require.NoError(t, err) require.NoError(t, err)
return ls require.NotNil(t, cs)
return cs
} }
func newTestLaneHostInfo(t *testing.T, r *handshake.Result, ls *laneSet) *HostInfo { func newTestLaneHostInfo(t *testing.T, r *handshake.Result, ls *laneSet) *HostInfo {
@@ -118,15 +126,15 @@ func TestLaneKeyDerivationSymmetry(t *testing.T) {
initLS := newTestLaneSet(t, initR, 4, 4, 4242, 4) initLS := newTestLaneSet(t, initR, 4, 4, 4242, 4)
respLS := newTestLaneSet(t, respR, 4, 4, 4242, 4) respLS := newTestLaneSet(t, respR, 4, 4, 4242, 4)
require.Len(t, initLS.sessions, 4) require.Len(t, initLS.sessions, 4)
assert.Nil(t, initLS.sessions[0], "lane 0 is the base session, not a derived one") assert.Nil(t, initLS.sessions[0].Load(), "lane 0 is the base session, not a derived one")
nb := make([]byte, 12) nb := make([]byte, 12)
for s := 1; s < 4; s++ { for s := 1; s < 4; s++ {
out := header.EncodeLane(make([]byte, 0, mtu), header.Version, header.Message, 0, 200, 1, uint8(s)) out := header.EncodeLane(make([]byte, 0, mtu), header.Version, header.Message, 0, 200, 1, uint8(s))
ct, err := initLS.sessions[s].eKey.EncryptDanger(out, out, []byte("lane payload"), 1, nb) ct, err := laneSessionFor(t, initLS, s).eKey.EncryptDanger(out, out, []byte("lane payload"), 1, nb)
require.NoError(t, err) require.NoError(t, err)
pt, err := respLS.sessions[s].Decrypt(test.NewLogger(), 1, ct, nb) pt, err := laneSessionFor(t, respLS, s).Decrypt(test.NewLogger(), 1, ct, nb)
require.NoError(t, err, "lane %d keys did not match", s) require.NoError(t, err, "lane %d keys did not match", s)
assert.Equal(t, []byte("lane payload"), pt) assert.Equal(t, []byte("lane payload"), pt)
} }
@@ -134,9 +142,9 @@ func TestLaneKeyDerivationSymmetry(t *testing.T) {
// Distinct lanes get distinct keys: lane 2's session must not open lane 1's // Distinct lanes get distinct keys: lane 2's session must not open lane 1's
// ciphertext, or the header's lane index would be forgeable in effect. // ciphertext, or the header's lane index would be forgeable in effect.
out := header.EncodeLane(make([]byte, 0, mtu), header.Version, header.Message, 0, 200, 7, 1) out := header.EncodeLane(make([]byte, 0, mtu), header.Version, header.Message, 0, 200, 7, 1)
ct, err := initLS.sessions[1].eKey.EncryptDanger(out, out, []byte("lane payload"), 7, nb) ct, err := laneSessionFor(t, initLS, 1).eKey.EncryptDanger(out, out, []byte("lane payload"), 7, nb)
require.NoError(t, err) require.NoError(t, err)
_, err = respLS.sessions[2].Decrypt(test.NewLogger(), 7, ct, nb) _, err = laneSessionFor(t, respLS, 2).Decrypt(test.NewLogger(), 7, ct, nb)
assert.Error(t, err) assert.Error(t, err)
} }
@@ -144,21 +152,29 @@ func TestNewLaneSetSizing(t *testing.T) {
initR, _ := runTestHandshake(t) initR, _ := runTestHandshake(t)
// A peer with no multiport advert gets no lanes at all. // A peer with no multiport advert gets no lanes at all.
ls, err := newLaneSet(&handshake.Result{}, 4, testMyAddr, testPeerAddr) assert.Nil(t, newLaneSet(&handshake.Result{}, 4, testMyAddr, testPeerAddr))
require.NoError(t, err)
assert.Nil(t, ls)
// One lane means only the base tunnel, which is not a lane set. // One lane means only the base tunnel, which is not a lane set.
ls, err = newLaneSet(&handshake.Result{PeerPortCount: 4, PeerTxLanes: 1}, 1, testMyAddr, testPeerAddr) assert.Nil(t, newLaneSet(&handshake.Result{PeerPortCount: 4, PeerTxLanes: 1}, 1, testMyAddr, testPeerAddr))
require.NoError(t, err)
assert.Nil(t, ls)
// Sessions cover both directions: enough for everything the peer may send, // Sessions cover both directions: enough for everything the peer may send,
// even though we may only send on a few. // even though we may only send on a few.
ls = newTestLaneSet(t, initR, 2, 8, 4242, 6) ls := newTestLaneSet(t, initR, 2, 8, 4242, 6)
assert.Len(t, ls.sessions, 6, "sessions must cover the peer's tx lanes") assert.Len(t, ls.sessions, 6, "sessions must cover the peer's tx lanes")
assert.Equal(t, 2, ls.txLanes, "we may only send on our own lanes") assert.Equal(t, 2, ls.txLanes, "we may only send on our own lanes")
// The peer's advert sizes the session table but nothing else. Its lane count
// is its own choice, so it must not be able to make us allocate per-lane tx
// state we will never use.
assert.Len(t, ls.txAddr, 2)
assert.Len(t, ls.demand, 2)
assert.Len(t, ls.probe, 2)
// Nothing is derived up front, for the same reason.
for s := range ls.sessions {
assert.Nil(t, ls.sessions[s].Load(), "lane %d derived before anything needed it", s)
}
// Our tx lanes are clamped to the ports the peer actually bound: a lane // Our tx lanes are clamped to the ports the peer actually bound: a lane
// aimed past the peer's range would land on some unrelated socket. // aimed past the peer's range would land on some unrelated socket.
ls = newTestLaneSet(t, initR, 8, 3, 4242, 8) ls = newTestLaneSet(t, initR, 8, 3, 4242, 8)
@@ -166,6 +182,48 @@ func TestNewLaneSetSizing(t *testing.T) {
assert.Len(t, ls.sessions, 8) assert.Len(t, ls.sessions, 8)
} }
// The RX path must not cache a session for a lane until a packet on it has
// actually decrypted, or a spoofer naming lanes at random could make us hold a
// replay window and two cipher states per lane without authenticating anything.
func TestLaneSessionRxDerivation(t *testing.T) {
initR, respR := runTestHandshake(t)
respLS := newTestLaneSet(t, respR, 4, 4, 4242, 4)
hi := newTestLaneHostInfo(t, respR, respLS)
ci, cached, err := hi.laneSession(2)
require.NoError(t, err)
require.NotNil(t, ci)
assert.False(t, cached, "the first packet on a lane derives, it does not hit")
assert.Nil(t, respLS.sessions[2].Load(), "an unauthenticated packet must not install a session")
// The lane the peer really is using decrypts, and that is what installs it.
nb := make([]byte, 12)
out := header.EncodeLane(make([]byte, 0, mtu), header.Version, header.Message, 0, 200, 1, 2)
ct, err := laneSessionFor(t, newTestLaneSet(t, initR, 4, 4, 4242, 4), 2).
eKey.EncryptDanger(out, out, []byte("real lane traffic"), 1, nb)
require.NoError(t, err)
pt, err := ci.Decrypt(test.NewLogger(), 1, ct, nb)
require.NoError(t, err)
assert.Equal(t, []byte("real lane traffic"), pt)
respLS.installSession(2, ci)
assert.Same(t, ci, respLS.sessions[2].Load())
// Now it is a hit, and the replay window the decrypt above advanced is the
// one the next packet sees.
got, cached, err := hi.laneSession(2)
require.NoError(t, err)
assert.True(t, cached)
assert.Same(t, ci, got)
// A racing install loses rather than swapping the session out, which would
// throw away the replay window the live one has been accumulating.
other, err := newLaneConnectionState(&respLS.material, 2)
require.NoError(t, err)
respLS.installSession(2, other)
assert.Same(t, ci, respLS.sessions[2].Load())
}
func TestLaneTxGate(t *testing.T) { func TestLaneTxGate(t *testing.T) {
initR, _ := runTestHandshake(t) initR, _ := runTestHandshake(t)
ls := newTestLaneSet(t, initR, 4, 4, 4242, 4) ls := newTestLaneSet(t, initR, 4, 4, 4242, 4)
@@ -178,12 +236,19 @@ func TestLaneTxGate(t *testing.T) {
assert.True(t, ls.demand[1].Load()) assert.True(t, ls.demand[1].Load())
assert.False(t, ls.demand[2].Load(), "demand raised on an untouched lane") assert.False(t, ls.demand[2].Load(), "demand raised on an untouched lane")
// Promotion publishes the session and destination together. // An up lane with no session derived yet cannot happen — the probe that
// promoted it derived one — but it must fall back rather than send in the
// clear if it ever does.
want := netip.MustParseAddrPort("192.0.2.1:4243") want := netip.MustParseAddrPort("192.0.2.1:4243")
ls.txAddr[1].Store(&want) ls.txAddr[1].Store(&want)
ci, _ = ls.txLane(1)
assert.Nil(t, ci)
// Promotion publishes the session and destination together.
sess := laneSessionFor(t, ls, 1)
ls.demand[1].Store(false) ls.demand[1].Store(false)
ci, addr = ls.txLane(1) ci, addr = ls.txLane(1)
assert.Equal(t, ls.sessions[1], ci) assert.Same(t, sess, ci)
assert.Equal(t, want, addr) assert.Equal(t, want, addr)
assert.False(t, ls.demand[1].Load(), "a hit must not raise demand") assert.False(t, ls.demand[1].Load(), "a hit must not raise demand")
@@ -199,14 +264,30 @@ func TestLaneSessionLookup(t *testing.T) {
ls := newTestLaneSet(t, initR, 3, 4, 4242, 3) ls := newTestLaneSet(t, initR, 3, 4, 4242, 3)
hi := newTestLaneHostInfo(t, initR, ls) hi := newTestLaneHostInfo(t, initR, ls)
assert.Nil(t, hi.laneSession(0), "lane 0 is the base session") assertNoLane := func(s uint8, msg string) {
assert.Equal(t, ls.sessions[2], hi.laneSession(2)) t.Helper()
assert.Nil(t, hi.laneSession(3), "a lane we never derived") ci, cached, err := hi.laneSession(s)
assert.Nil(t, hi.laneSession(255)) require.NoError(t, err)
assert.Nil(t, ci, msg)
assert.False(t, cached, msg)
}
assertNoLane(0, "lane 0 is the base session")
assertNoLane(3, "a lane beyond what this tunnel covers")
assertNoLane(255, "a lane beyond what this tunnel covers")
// An already-derived lane is returned as a hit.
sess := laneSessionFor(t, ls, 2)
ci, cached, err := hi.laneSession(2)
require.NoError(t, err)
assert.Same(t, sess, ci)
assert.True(t, cached)
// A peer without lanes answers nil for every lane rather than panicking. // A peer without lanes answers nil for every lane rather than panicking.
bare := &HostInfo{} bare := &HostInfo{}
assert.Nil(t, bare.laneSession(1)) ci, _, err = bare.laneSession(1)
require.NoError(t, err)
assert.Nil(t, ci)
} }
func TestMaxMessageCounter(t *testing.T) { func TestMaxMessageCounter(t *testing.T) {
@@ -219,7 +300,7 @@ func TestMaxMessageCounter(t *testing.T) {
// A lane past the base is what the rehandshake threshold has to notice: the // A lane past the base is what the rehandshake threshold has to notice: the
// base counter would sit still while the lane burns through its nonces. // base counter would sit still while the lane burns through its nonces.
ls.sessions[2].messageCounter.Store(9000) laneSessionFor(t, ls, 2).messageCounter.Store(9000)
assert.Equal(t, uint64(9000), hi.maxMessageCounter()) assert.Equal(t, uint64(9000), hi.maxMessageCounter())
assert.Equal(t, uint64(0), (&HostInfo{}).maxMessageCounter()) assert.Equal(t, uint64(0), (&HostInfo{}).maxMessageCounter())
@@ -418,7 +499,7 @@ func TestHandleLaneProbe(t *testing.T) {
assert.Equal(t, header.LaneProbeAck, h.Subtype) assert.Equal(t, header.LaneProbeAck, h.Subtype)
assert.Equal(t, uint8(0), h.Lane(), "the ack is base-tunnel traffic") assert.Equal(t, uint8(0), h.Lane(), "the ack is base-tunnel traffic")
pt, err := newTestLaneSet(t, initR, 4, 4, 4242, 4).sessions[1].dKey.DecryptDanger( pt, err := laneSessionFor(t, newTestLaneSet(t, initR, 4, 4, 4242, 4), 1).dKey.DecryptDanger(
nil, sent.bufs[0][:header.Len], sent.bufs[0][header.Len:], h.MessageCounter, make([]byte, 12)) nil, sent.bufs[0][:header.Len], sent.bufs[0][header.Len:], h.MessageCounter, make([]byte, 12))
_ = pt _ = pt
assert.Error(t, err, "the ack must not be readable with a lane key") assert.Error(t, err, "the ack must not be readable with a lane key")
@@ -522,8 +603,11 @@ func TestSendInsideMessageLaneSwap(t *testing.T) {
assert.Equal(t, uint8(0), h.Lane()) assert.Equal(t, uint8(0), h.Lane())
// Once the lane is up, slot-1 traffic rides the lane session, the lane // Once the lane is up, slot-1 traffic rides the lane session, the lane
// socket and the lane's destination, tagged with the lane index. // socket and the lane's destination, tagged with the lane index. Promotion
// normally happens on the ack of a probe, which is also what derived the
// session, so stand both up here.
laneRemote := netip.MustParseAddrPort("192.0.2.1:5354") laneRemote := netip.MustParseAddrPort("192.0.2.1:5354")
laneSessionFor(t, ls, 1)
ls.txAddr[1].Store(&laneRemote) ls.txAddr[1].Store(&laneRemote)
ls.demand[1].Store(false) ls.demand[1].Store(false)
ifce.sendInsideMessage(hi, pkt, nb, tx1) ifce.sendInsideMessage(hi, pkt, nb, tx1)
@@ -537,7 +621,7 @@ func TestSendInsideMessageLaneSwap(t *testing.T) {
assert.Equal(t, hi.remoteIndexId, h.RemoteIndex) assert.Equal(t, hi.remoteIndexId, h.RemoteIndex)
// And the peer's derived lane-1 session is what opens it. // And the peer's derived lane-1 session is what opens it.
pt, err := peerLS.sessions[1].Decrypt(test.NewLogger(), h.MessageCounter, laneWriter.bufs[0], nb) pt, err := laneSessionFor(t, peerLS, 1).Decrypt(test.NewLogger(), h.MessageCounter, laneWriter.bufs[0], nb)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, pkt.Bytes, pt) assert.Equal(t, pkt.Bytes, pt)
+26 -11
View File
@@ -111,10 +111,28 @@ func (f *Interface) readOutsidePackets(via ViaSender, packet []byte, rxc *rxCont
// is the base tunnel; a higher lane is one of the sessions derived from it. // is the base tunnel; a higher lane is one of the sessions derived from it.
ci := hostinfo.ConnectionState ci := hostinfo.ConnectionState
lane := h.Lane() lane := h.Lane()
laneCached := true
if lane != 0 { if lane != 0 {
ci = hostinfo.laneSession(lane) if isMessageRelay {
// A relay carrier is always the base tunnel, so lane ciphertext can
// never legitimately arrive wrapped in one. Checked before the lookup
// below so a junk relay packet can't make us derive a session.
f.messageMetrics.RxInvalid(1)
if f.l.Enabled(context.Background(), slog.LevelDebug) {
hostinfo.logger(f.l).Debug("Refusing relayed multiport lane packet", "from", via, "header", h)
}
return
}
var err error
ci, laneCached, err = hostinfo.laneSession(lane)
if err != nil {
f.messageMetrics.RxInvalid(1)
hostinfo.logger(f.l).Error("Failed to derive multiport lane session", "error", err, "lane", lane)
return
}
if ci == nil { if ci == nil {
// A lane we have no session for: a stale lane from a tunnel that has // A lane this tunnel doesn't have: a stale lane from a tunnel that has
// since rolled, or a peer sending above what it advertised. Dropping // since rolled, or a peer sending above what it advertised. Dropping
// silently is right for both — a recv_error would tear down a // silently is right for both — a recv_error would tear down a
// perfectly good base tunnel on the strength of one odd packet. // perfectly good base tunnel on the strength of one odd packet.
@@ -124,15 +142,6 @@ func (f *Interface) readOutsidePackets(via ViaSender, packet []byte, rxc *rxCont
} }
return return
} }
if isMessageRelay {
// A relay carrier is always the base tunnel, so lane ciphertext can
// never legitimately arrive wrapped in one.
f.messageMetrics.RxInvalid(1)
if f.l.Enabled(context.Background(), slog.LevelDebug) {
hostinfo.logger(f.l).Debug("Refusing relayed multiport lane packet", "from", via, "header", h)
}
return
}
} }
if len(packet) < header.Len+ci.dKey.Overhead() { if len(packet) < header.Len+ci.dKey.Overhead() {
@@ -165,6 +174,12 @@ func (f *Interface) readOutsidePackets(via ViaSender, packet []byte, rxc *rxCont
return return
} }
if !laneCached {
// The packet decrypted, so the peer really is using this lane and the
// session we derived for it is worth keeping.
hostinfo.lanes.installSession(lane, ci)
}
// Roam before we respond, but only on the base tunnel: a lane's source // Roam before we respond, but only on the base tunnel: a lane's source
// address is a per-lane 4-tuple, not the tunnel's remote, and letting it // address is a per-lane 4-tuple, not the tunnel's remote, and letting it
// roam the hostinfo would point every non-lane packet at a lane port. // roam the hostinfo would point every non-lane packet at a lane port.