cmd/derper: fix mesh auth for DERP servers (#16061)

To authenticate mesh keys, the DERP servers used a simple == comparison,
which is susceptible to a side channel timing attack.

By extracting the mesh key for a DERP server, an attacker could DoS it
by forcing disconnects using derp.Client.ClosePeer. They could also
enumerate the public Wireguard keys, IP addresses and ports for nodes
connected to that DERP server.

DERP servers configured without mesh keys deny all such requests.

This patch also extracts the mesh key logic into key.DERPMesh, to
prevent this from happening again.

Security bulletin: https://tailscale.com/security-bulletins#ts-2025-003

Fixes tailscale/corp#28720

Signed-off-by: Simon Law <sfllaw@tailscale.com>
This commit is contained in:
Simon Law
2025-05-22 12:14:16 -07:00
committed by GitHub
parent aa8bc23c49
commit 3ee4c60ff0
9 changed files with 338 additions and 71 deletions

68
types/key/derp.go Normal file
View File

@@ -0,0 +1,68 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package key
import (
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"strings"
"go4.org/mem"
"tailscale.com/types/structs"
)
var ErrInvalidMeshKey = errors.New("invalid mesh key")
// DERPMesh is a mesh key, used for inter-DERP-node communication and for
// privileged DERP clients.
type DERPMesh struct {
_ structs.Incomparable // == isn't constant-time
k [32]byte // 64-digit hexadecimal numbers fit in 32 bytes
}
// DERPMeshFromRaw32 parses a 32-byte raw value as a DERP mesh key.
func DERPMeshFromRaw32(raw mem.RO) DERPMesh {
if raw.Len() != 32 {
panic("input has wrong size")
}
var ret DERPMesh
raw.Copy(ret.k[:])
return ret
}
// ParseDERPMesh parses a DERP mesh key from a string.
// This function trims whitespace around the string.
// If the key is not a 64-digit hexadecimal number, ErrInvalidMeshKey is returned.
func ParseDERPMesh(key string) (DERPMesh, error) {
key = strings.TrimSpace(key)
if len(key) != 64 {
return DERPMesh{}, fmt.Errorf("%w: must be 64-digit hexadecimal number", ErrInvalidMeshKey)
}
decoded, err := hex.DecodeString(key)
if err != nil {
return DERPMesh{}, fmt.Errorf("%w: %v", ErrInvalidMeshKey, err)
}
return DERPMeshFromRaw32(mem.B(decoded)), nil
}
// IsZero reports whether k is the zero value.
func (k DERPMesh) IsZero() bool {
return k.Equal(DERPMesh{})
}
// Equal reports whether k and other are the same key.
func (k DERPMesh) Equal(other DERPMesh) bool {
// Compare mesh keys in constant time to prevent timing attacks.
// Since mesh keys are a fixed length, we dont need to be concerned
// about timing attacks on client mesh keys that are the wrong length.
// See https://github.com/tailscale/corp/issues/28720
return subtle.ConstantTimeCompare(k.k[:], other.k[:]) == 1
}
// String returns k as a hex-encoded 64-digit number.
func (k DERPMesh) String() string {
return hex.EncodeToString(k.k[:])
}

133
types/key/derp_test.go Normal file
View File

@@ -0,0 +1,133 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package key
import (
"errors"
"testing"
"go4.org/mem"
)
func TestDERPMeshIsValid(t *testing.T) {
for name, tt := range map[string]struct {
input string
want string
wantErr error
}{
"good": {
input: "0123456789012345678901234567890123456789012345678901234567890123",
want: "0123456789012345678901234567890123456789012345678901234567890123",
wantErr: nil,
},
"hex": {
input: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
want: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
wantErr: nil,
},
"uppercase": {
input: "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF",
want: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
wantErr: nil,
},
"whitespace": {
input: " 0123456789012345678901234567890123456789012345678901234567890123 ",
want: "0123456789012345678901234567890123456789012345678901234567890123",
wantErr: nil,
},
"short": {
input: "0123456789abcdef",
wantErr: ErrInvalidMeshKey,
},
"long": {
input: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0",
wantErr: ErrInvalidMeshKey,
},
} {
t.Run(name, func(t *testing.T) {
k, err := ParseDERPMesh(tt.input)
if !errors.Is(err, tt.wantErr) {
t.Errorf("err %v, want %v", err, tt.wantErr)
}
got := k.String()
if got != tt.want && tt.wantErr == nil {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}
func TestDERPMesh(t *testing.T) {
t.Parallel()
for name, tt := range map[string]struct {
str string
hex []byte
equal bool // are str and hex equal?
}{
"zero": {
str: "0000000000000000000000000000000000000000000000000000000000000000",
hex: []byte{
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
},
equal: true,
},
"equal": {
str: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
hex: []byte{
0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef,
0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef,
0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef,
0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef,
},
equal: true,
},
"unequal": {
str: "0badc0de00000000000000000000000000000000000000000000000000000000",
hex: []byte{
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
},
equal: false,
},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
k, err := ParseDERPMesh(tt.str)
if err != nil {
t.Fatal(err)
}
// string representation should round-trip
s := k.String()
if s != tt.str {
t.Fatalf("string %s, want %s", s, tt.str)
}
// if tt.equal, then tt.hex is intended to be equal
if k.k != [32]byte(tt.hex) && tt.equal {
t.Fatalf("decoded %x, want %x", k.k, tt.hex)
}
h := DERPMeshFromRaw32(mem.B(tt.hex))
if k.Equal(h) != tt.equal {
if tt.equal {
t.Fatalf("%v != %v", k, h)
} else {
t.Fatalf("%v == %v", k, h)
}
}
})
}
}