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

@@ -5,6 +5,7 @@ package udprelay
import (
"bytes"
"crypto/rand"
"net"
"net/netip"
"testing"
@@ -14,6 +15,7 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"go4.org/mem"
"golang.org/x/crypto/blake2s"
"tailscale.com/disco"
"tailscale.com/net/packet"
"tailscale.com/types/key"
@@ -352,3 +354,117 @@ func TestServer_getNextVNILocked(t *testing.T) {
_, err = s.getNextVNILocked()
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])
}