net/udprelay: use blake2s-256 MAC for handshake challenge

This commit replaces crypto/rand challenge generation with a blake2s-256
MAC. This enables the peer relay server to respond to multiple forward
disco.BindUDPRelayEndpoint messages per handshake generation without
sacrificing the proof of IP ownership properties of the handshake.

Responding to multiple forward disco.BindUDPRelayEndpoint messages per
handshake generation improves client address/path selection where
lowest client->server path/addr one-way delay does not necessarily
equate to lowest client<->server round trip delay.

It also improves situations where outbound traffic is filtered
independent of input, and the first reply
disco.BindUDPRelayEndpointChallenge message is dropped on the reply
path, but a later reply using a different source would make it through.

Reduction in serverEndpoint state saves 112 bytes per instance, trading
for slightly more expensive crypto ops: 277ns/op vs 321ns/op on an M1
Macbook Pro.

Updates tailscale/corp#34414

Signed-off-by: Jordan Whited <jordan@tailscale.com>
This commit is contained in:
Jordan Whited 2025-11-20 19:33:18 -08:00 committed by Jordan Whited
parent 6637003cc8
commit 755309c04e
2 changed files with 228 additions and 58 deletions

View File

@ -10,6 +10,7 @@ import (
"bytes" "bytes"
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/binary"
"errors" "errors"
"fmt" "fmt"
"net" "net"
@ -20,6 +21,7 @@ import (
"time" "time"
"go4.org/mem" "go4.org/mem"
"golang.org/x/crypto/blake2s"
"golang.org/x/net/ipv6" "golang.org/x/net/ipv6"
"tailscale.com/disco" "tailscale.com/disco"
"tailscale.com/net/batching" "tailscale.com/net/batching"
@ -74,6 +76,8 @@ type Server struct {
netChecker *netcheck.Client netChecker *netcheck.Client
mu sync.Mutex // guards the following fields mu sync.Mutex // guards the following fields
macSecrets [][blake2s.Size]byte // [0] is most recent, max 2 elements
macSecretRotatedAt time.Time
derpMap *tailcfg.DERPMap derpMap *tailcfg.DERPMap
onlyStaticAddrPorts bool // no dynamic addr port discovery when set onlyStaticAddrPorts bool // no dynamic addr port discovery when set
staticAddrPorts views.Slice[netip.AddrPort] // static ip:port pairs set with [Server.SetStaticAddrPorts] staticAddrPorts views.Slice[netip.AddrPort] // static ip:port pairs set with [Server.SetStaticAddrPorts]
@ -85,6 +89,8 @@ type Server struct {
byDisco map[key.SortedPairOfDiscoPublic]*serverEndpoint byDisco map[key.SortedPairOfDiscoPublic]*serverEndpoint
} }
const macSecretRotationInterval = time.Minute * 2
const ( const (
minVNI = uint32(1) minVNI = uint32(1)
maxVNI = uint32(1<<24 - 1) maxVNI = uint32(1<<24 - 1)
@ -100,11 +106,9 @@ type serverEndpoint struct {
// Disco protocol messages for transmission towards discoPubKeys[0]. // Disco protocol messages for transmission towards discoPubKeys[0].
discoPubKeys key.SortedPairOfDiscoPublic discoPubKeys key.SortedPairOfDiscoPublic
discoSharedSecrets [2]key.DiscoShared discoSharedSecrets [2]key.DiscoShared
handshakeGeneration [2]uint32 // or zero if a handshake has never started for that relay leg inProgressGeneration [2]uint32 // or zero if a handshake has never started, or has just completed
handshakeAddrPorts [2]netip.AddrPort // or zero value if a handshake has never started for that relay leg
boundAddrPorts [2]netip.AddrPort // or zero value if a handshake has never completed for that relay leg boundAddrPorts [2]netip.AddrPort // or zero value if a handshake has never completed for that relay leg
lastSeen [2]time.Time // TODO(jwhited): consider using mono.Time lastSeen [2]time.Time // TODO(jwhited): consider using mono.Time
challenge [2][disco.BindUDPRelayChallengeLen]byte
packetsRx [2]uint64 // num packets received from/sent by each client after they are bound packetsRx [2]uint64 // num packets received from/sent by each client after they are bound
bytesRx [2]uint64 // num bytes received from/sent by each client after they are bound bytesRx [2]uint64 // num bytes received from/sent by each client after they are bound
@ -113,7 +117,29 @@ type serverEndpoint struct {
allocatedAt time.Time allocatedAt time.Time
} }
func (e *serverEndpoint) handleDiscoControlMsg(from netip.AddrPort, senderIndex int, discoMsg disco.Message, serverDisco key.DiscoPublic) (write []byte, to netip.AddrPort) { func blakeMACFromBindMsg(blakeKey [blake2s.Size]byte, src netip.AddrPort, msg disco.BindUDPRelayEndpointCommon) ([blake2s.Size]byte, error) {
input := make([]byte, 8, 4+4+32+18) // vni + generation + invited party disco key + addr:port
binary.BigEndian.PutUint32(input[0:4], msg.VNI)
binary.BigEndian.PutUint32(input[4:8], msg.Generation)
input = msg.RemoteKey.AppendTo(input)
input, err := src.AppendBinary(input)
if err != nil {
return [blake2s.Size]byte{}, err
}
h, err := blake2s.New256(blakeKey[:])
if err != nil {
return [blake2s.Size]byte{}, err
}
_, err = h.Write(input)
if err != nil {
return [blake2s.Size]byte{}, err
}
var out [blake2s.Size]byte
h.Sum(out[:0])
return out, nil
}
func (e *serverEndpoint) handleDiscoControlMsg(from netip.AddrPort, senderIndex int, discoMsg disco.Message, serverDisco key.DiscoPublic, macSecrets [][blake2s.Size]byte) (write []byte, to netip.AddrPort) {
if senderIndex != 0 && senderIndex != 1 { if senderIndex != 0 && senderIndex != 1 {
return nil, netip.AddrPort{} return nil, netip.AddrPort{}
} }
@ -144,18 +170,11 @@ func (e *serverEndpoint) handleDiscoControlMsg(from netip.AddrPort, senderIndex
// Generation must be nonzero, silently drop // Generation must be nonzero, silently drop
return nil, netip.AddrPort{} return nil, netip.AddrPort{}
} }
if e.handshakeGeneration[senderIndex] == discoMsg.Generation { e.inProgressGeneration[senderIndex] = discoMsg.Generation
// we've seen this generation before, silently drop
return nil, netip.AddrPort{}
}
e.handshakeGeneration[senderIndex] = discoMsg.Generation
e.handshakeAddrPorts[senderIndex] = from
m := new(disco.BindUDPRelayEndpointChallenge) m := new(disco.BindUDPRelayEndpointChallenge)
m.VNI = e.vni m.VNI = e.vni
m.Generation = discoMsg.Generation m.Generation = discoMsg.Generation
m.RemoteKey = e.discoPubKeys.Get()[otherSender] m.RemoteKey = e.discoPubKeys.Get()[otherSender]
rand.Read(e.challenge[senderIndex][:])
copy(m.Challenge[:], e.challenge[senderIndex][:])
reply := make([]byte, packet.GeneveFixedHeaderLength, 512) reply := make([]byte, packet.GeneveFixedHeaderLength, 512)
gh := packet.GeneveHeader{Control: true, Protocol: packet.GeneveProtocolDisco} gh := packet.GeneveHeader{Control: true, Protocol: packet.GeneveProtocolDisco}
gh.VNI.Set(e.vni) gh.VNI.Set(e.vni)
@ -165,6 +184,11 @@ func (e *serverEndpoint) handleDiscoControlMsg(from netip.AddrPort, senderIndex
} }
reply = append(reply, disco.Magic...) reply = append(reply, disco.Magic...)
reply = serverDisco.AppendTo(reply) reply = serverDisco.AppendTo(reply)
mac, err := blakeMACFromBindMsg(macSecrets[0], from, m.BindUDPRelayEndpointCommon)
if err != nil {
return nil, netip.AddrPort{}
}
m.Challenge = mac
box := e.discoSharedSecrets[senderIndex].Seal(m.AppendMarshal(nil)) box := e.discoSharedSecrets[senderIndex].Seal(m.AppendMarshal(nil))
reply = append(reply, box...) reply = append(reply, box...)
return reply, from return reply, from
@ -174,17 +198,29 @@ func (e *serverEndpoint) handleDiscoControlMsg(from netip.AddrPort, senderIndex
// silently drop // silently drop
return nil, netip.AddrPort{} return nil, netip.AddrPort{}
} }
generation := e.handshakeGeneration[senderIndex] generation := e.inProgressGeneration[senderIndex]
if generation == 0 || // we have no active handshake if generation == 0 || // we have no in-progress handshake
generation != discoMsg.Generation || // mismatching generation for the active handshake generation != discoMsg.Generation { // mismatching generation for the in-progress handshake
e.handshakeAddrPorts[senderIndex] != from || // mismatching source for the active handshake
!bytes.Equal(e.challenge[senderIndex][:], discoMsg.Challenge[:]) { // mismatching answer for the active handshake
// silently drop // silently drop
return nil, netip.AddrPort{} return nil, netip.AddrPort{}
} }
for _, macSecret := range macSecrets {
mac, err := blakeMACFromBindMsg(macSecret, from, discoMsg.BindUDPRelayEndpointCommon)
if err != nil {
// silently drop
return nil, netip.AddrPort{}
}
// Speed is favored over constant-time comparison here. The sender is
// already authenticated via disco.
if bytes.Equal(mac[:], discoMsg.Challenge[:]) {
// Handshake complete. Update the binding for this sender. // Handshake complete. Update the binding for this sender.
e.boundAddrPorts[senderIndex] = from e.boundAddrPorts[senderIndex] = from
e.lastSeen[senderIndex] = time.Now() // record last seen as bound time e.lastSeen[senderIndex] = time.Now() // record last seen as bound time
e.inProgressGeneration[senderIndex] = 0 // reset to zero, which indicates there is no in-progress handshake
return nil, netip.AddrPort{}
}
}
// MAC does not match, silently drop
return nil, netip.AddrPort{} return nil, netip.AddrPort{}
default: default:
// unexpected message types, silently drop // unexpected message types, silently drop
@ -192,7 +228,7 @@ func (e *serverEndpoint) handleDiscoControlMsg(from netip.AddrPort, senderIndex
} }
} }
func (e *serverEndpoint) handleSealedDiscoControlMsg(from netip.AddrPort, b []byte, serverDisco key.DiscoPublic) (write []byte, to netip.AddrPort) { func (e *serverEndpoint) handleSealedDiscoControlMsg(from netip.AddrPort, b []byte, serverDisco key.DiscoPublic, macSecrets [][blake2s.Size]byte) (write []byte, to netip.AddrPort) {
senderRaw, isDiscoMsg := disco.Source(b) senderRaw, isDiscoMsg := disco.Source(b)
if !isDiscoMsg { if !isDiscoMsg {
// Not a Disco message // Not a Disco message
@ -223,23 +259,22 @@ func (e *serverEndpoint) handleSealedDiscoControlMsg(from netip.AddrPort, b []by
return nil, netip.AddrPort{} return nil, netip.AddrPort{}
} }
return e.handleDiscoControlMsg(from, senderIndex, discoMsg, serverDisco) return e.handleDiscoControlMsg(from, senderIndex, discoMsg, serverDisco, macSecrets)
} }
func (e *serverEndpoint) handlePacket(from netip.AddrPort, gh packet.GeneveHeader, b []byte, serverDisco key.DiscoPublic) (write []byte, to netip.AddrPort) { func (e *serverEndpoint) handleDataPacket(from netip.AddrPort, b []byte, now time.Time) (write []byte, to netip.AddrPort) {
if !gh.Control {
if !e.isBound() { if !e.isBound() {
// not a control packet, but serverEndpoint isn't bound // not a control packet, but serverEndpoint isn't bound
return nil, netip.AddrPort{} return nil, netip.AddrPort{}
} }
switch { switch {
case from == e.boundAddrPorts[0]: case from == e.boundAddrPorts[0]:
e.lastSeen[0] = time.Now() e.lastSeen[0] = now
e.packetsRx[0]++ e.packetsRx[0]++
e.bytesRx[0] += uint64(len(b)) e.bytesRx[0] += uint64(len(b))
return b, e.boundAddrPorts[1] return b, e.boundAddrPorts[1]
case from == e.boundAddrPorts[1]: case from == e.boundAddrPorts[1]:
e.lastSeen[1] = time.Now() e.lastSeen[1] = now
e.packetsRx[1]++ e.packetsRx[1]++
e.bytesRx[1] += uint64(len(b)) e.bytesRx[1] += uint64(len(b))
return b, e.boundAddrPorts[0] return b, e.boundAddrPorts[0]
@ -249,15 +284,6 @@ func (e *serverEndpoint) handlePacket(from netip.AddrPort, gh packet.GeneveHeade
} }
} }
if gh.Protocol != packet.GeneveProtocolDisco {
// control packet, but not Disco
return nil, netip.AddrPort{}
}
msg := b[packet.GeneveFixedHeaderLength:]
return e.handleSealedDiscoControlMsg(from, msg, serverDisco)
}
func (e *serverEndpoint) isExpired(now time.Time, bindLifetime, steadyStateLifetime time.Duration) bool { func (e *serverEndpoint) isExpired(now time.Time, bindLifetime, steadyStateLifetime time.Duration) bool {
if !e.isBound() { if !e.isBound() {
if now.Sub(e.allocatedAt) > bindLifetime { if now.Sub(e.allocatedAt) > bindLifetime {
@ -621,7 +647,35 @@ func (s *Server) handlePacket(from netip.AddrPort, b []byte) (write []byte, to n
return nil, netip.AddrPort{} return nil, netip.AddrPort{}
} }
return e.handlePacket(from, gh, b, s.discoPublic) now := time.Now()
if gh.Control {
if gh.Protocol != packet.GeneveProtocolDisco {
// control packet, but not Disco
return nil, netip.AddrPort{}
}
msg := b[packet.GeneveFixedHeaderLength:]
s.maybeRotateMACSecretLocked(now)
return e.handleSealedDiscoControlMsg(from, msg, s.discoPublic, s.macSecrets)
}
return e.handleDataPacket(from, b, now)
}
func (s *Server) maybeRotateMACSecretLocked(now time.Time) {
if !s.macSecretRotatedAt.IsZero() && now.Sub(s.macSecretRotatedAt) < macSecretRotationInterval {
return
}
switch len(s.macSecrets) {
case 0:
s.macSecrets = make([][blake2s.Size]byte, 1, 2)
case 1:
s.macSecrets = append(s.macSecrets, [blake2s.Size]byte{})
fallthrough
case 2:
s.macSecrets[1] = s.macSecrets[0]
}
rand.Read(s.macSecrets[0][:])
s.macSecretRotatedAt = now
return
} }
func (s *Server) packetReadLoop(readFromSocket, otherSocket batching.Conn, readFromSocketIsIPv4 bool) { func (s *Server) packetReadLoop(readFromSocket, otherSocket batching.Conn, readFromSocketIsIPv4 bool) {

View File

@ -5,6 +5,7 @@ package udprelay
import ( import (
"bytes" "bytes"
"crypto/rand"
"net" "net"
"net/netip" "net/netip"
"testing" "testing"
@ -14,6 +15,7 @@ import (
"github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts" "github.com/google/go-cmp/cmp/cmpopts"
"go4.org/mem" "go4.org/mem"
"golang.org/x/crypto/blake2s"
"tailscale.com/disco" "tailscale.com/disco"
"tailscale.com/net/packet" "tailscale.com/net/packet"
"tailscale.com/types/key" "tailscale.com/types/key"
@ -352,3 +354,117 @@ func TestServer_getNextVNILocked(t *testing.T) {
_, err = s.getNextVNILocked() _, err = s.getNextVNILocked()
c.Assert(err, qt.IsNil) c.Assert(err, qt.IsNil)
} }
func Test_blakeMACFromBindMsg(t *testing.T) {
var macSecret [blake2s.Size]byte
rand.Read(macSecret[:])
src := netip.MustParseAddrPort("[2001:db8::1]:7")
msgA := disco.BindUDPRelayEndpointCommon{
VNI: 1,
Generation: 1,
RemoteKey: key.NewDisco().Public(),
Challenge: [32]byte{},
}
macA, err := blakeMACFromBindMsg(macSecret, src, msgA)
if err != nil {
t.Fatal(err)
}
msgB := msgA
msgB.VNI++
macB, err := blakeMACFromBindMsg(macSecret, src, msgB)
if err != nil {
t.Fatal(err)
}
if macA == macB {
t.Fatalf("varying VNI input produced identical mac: %v", macA)
}
msgC := msgA
msgC.Generation++
macC, err := blakeMACFromBindMsg(macSecret, src, msgC)
if err != nil {
t.Fatal(err)
}
if macA == macC {
t.Fatalf("varying Generation input produced identical mac: %v", macA)
}
msgD := msgA
msgD.RemoteKey = key.NewDisco().Public()
macD, err := blakeMACFromBindMsg(macSecret, src, msgD)
if err != nil {
t.Fatal(err)
}
if macA == macD {
t.Fatalf("varying RemoteKey input produced identical mac: %v", macA)
}
msgE := msgA
msgE.Challenge = [32]byte{0x01} // challenge is not part of the MAC and should be ignored
macE, err := blakeMACFromBindMsg(macSecret, src, msgE)
if err != nil {
t.Fatal(err)
}
if macA != macE {
t.Fatalf("varying Challenge input produced varying mac: %v", macA)
}
macSecretB := macSecret
macSecretB[0] ^= 0xFF
macF, err := blakeMACFromBindMsg(macSecretB, src, msgA)
if err != nil {
t.Fatal(err)
}
if macA == macF {
t.Fatalf("varying macSecret input produced identical mac: %v", macA)
}
srcB := netip.AddrPortFrom(src.Addr(), src.Port()+1)
macG, err := blakeMACFromBindMsg(macSecret, srcB, msgA)
if err != nil {
t.Fatal(err)
}
if macA == macG {
t.Fatalf("varying src input produced identical mac: %v", macA)
}
}
func Benchmark_blakeMACFromBindMsg(b *testing.B) {
var macSecret [blake2s.Size]byte
rand.Read(macSecret[:])
src := netip.MustParseAddrPort("[2001:db8::1]:7")
msg := disco.BindUDPRelayEndpointCommon{
VNI: 1,
Generation: 1,
RemoteKey: key.NewDisco().Public(),
Challenge: [32]byte{},
}
b.ReportAllocs()
for b.Loop() {
_, err := blakeMACFromBindMsg(macSecret, src, msg)
if err != nil {
b.Fatal(err)
}
}
}
func TestServer_maybeRotateMACSecretLocked(t *testing.T) {
s := &Server{}
start := time.Now()
s.maybeRotateMACSecretLocked(start)
qt.Assert(t, len(s.macSecrets), qt.Equals, 1)
macSecret := s.macSecrets[0]
s.maybeRotateMACSecretLocked(start.Add(macSecretRotationInterval - time.Nanosecond))
qt.Assert(t, len(s.macSecrets), qt.Equals, 1)
qt.Assert(t, s.macSecrets[0], qt.Equals, macSecret)
s.maybeRotateMACSecretLocked(start.Add(macSecretRotationInterval))
qt.Assert(t, len(s.macSecrets), qt.Equals, 2)
qt.Assert(t, s.macSecrets[1], qt.Equals, macSecret)
qt.Assert(t, s.macSecrets[0], qt.Not(qt.Equals), s.macSecrets[1])
s.maybeRotateMACSecretLocked(s.macSecretRotatedAt.Add(macSecretRotationInterval))
qt.Assert(t, macSecret, qt.Not(qt.Equals), s.macSecrets[0])
qt.Assert(t, macSecret, qt.Not(qt.Equals), s.macSecrets[1])
qt.Assert(t, s.macSecrets[0], qt.Not(qt.Equals), s.macSecrets[1])
}