Files
tailscale/ipn/ipnlocal/network-lock_test.go
T
Alex Chan d6ffc0d986 tka,ipn: reduce boilerplate in Tailnet Lock tests
The `CreateStateForTest` helper reduces boilerplate in cases where the test
only cares about the trusted keys and not the disablement values (and makes
it more obvious where the disablement values are meaningful).

The `setupChonkStorage` helper reduces the boilerplate when creating on-disk
TKA storage in tests.

The `fakeLocalBackend` helper reduces the boilerplate when setting up a
`LocalBackend` instance in the IPN tests.

Updates #cleanup

Change-Id: Iacfba1be5f7fab208eec11e4369d63c7d7519da5
Signed-off-by: Alex Chan <alexc@tailscale.com>
2026-05-07 21:49:27 +01:00

1177 lines
36 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_tailnetlock
package ipnlocal
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"testing"
"time"
go4mem "go4.org/mem"
"github.com/google/go-cmp/cmp"
"tailscale.com/control/controlclient"
"tailscale.com/health"
"tailscale.com/hostinfo"
"tailscale.com/ipn"
"tailscale.com/ipn/store/mem"
"tailscale.com/net/netmon"
"tailscale.com/net/tsdial"
"tailscale.com/tailcfg"
"tailscale.com/tka"
"tailscale.com/tsd"
"tailscale.com/tstest"
"tailscale.com/tstest/tkatest"
"tailscale.com/types/key"
"tailscale.com/types/netmap"
"tailscale.com/types/persist"
"tailscale.com/types/tkatype"
"tailscale.com/util/eventbus/eventbustest"
"tailscale.com/util/must"
"tailscale.com/util/set"
)
func fakeControlClient(t *testing.T, c *http.Client) *controlclient.Auto {
hi := hostinfo.New()
ni := tailcfg.NetInfo{LinkType: "wired"}
hi.NetInfo = &ni
bus := eventbustest.NewBus(t)
k := key.NewMachine()
dialer := tsdial.NewDialer(netmon.NewStatic())
opts := controlclient.Options{
ServerURL: "https://example.com",
Hostinfo: hi,
GetMachinePrivateKey: func() (key.MachinePrivate, error) {
return k, nil
},
HTTPTestClient: c,
NoiseTestClient: c,
Dialer: dialer,
Bus: bus,
SkipStartForTests: true,
}
cc, err := controlclient.New(opts)
if err != nil {
t.Fatal(err)
}
return cc
}
func fakeNoiseServer(t *testing.T, handler http.HandlerFunc) (*httptest.Server, *http.Client) {
t.Helper()
ts := httptest.NewUnstartedServer(handler)
ts.StartTLS()
client := ts.Client()
client.Transport.(*http.Transport).TLSClientConfig.InsecureSkipVerify = true
client.Transport.(*http.Transport).DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, ts.Listener.Addr().String())
}
return ts, client
}
// newLocalBackendForTKA creates a new instance of [LocalBackend] for testing
// Tailnet Lock, in particular setting the tka field.
func newLocalBackendForTKA(t *testing.T, varRoot string, client *http.Client, pm *profileManager, authority *tka.Authority, chonk tka.CompactableChonk) LocalBackend {
t.Helper()
cc := fakeControlClient(t, client)
return LocalBackend{
varRoot: varRoot,
cc: cc,
ccAuto: cc,
logf: t.Logf,
health: health.NewTracker(eventbustest.NewBus(t)),
tka: &tkaState{
profile: pm.CurrentProfile().ID(),
authority: authority,
storage: chonk,
},
pm: pm,
store: pm.Store(),
}
}
func setupProfileManager(t *testing.T, nodePriv key.NodePrivate, nlPriv key.NLPrivate) *profileManager {
t.Helper()
pm := must.Get(newProfileManager(new(mem.Store), t.Logf, health.NewTracker(eventbustest.NewBus(t))))
must.Do(pm.SetPrefs((&ipn.Prefs{
Persist: &persist.Persist{
PrivateNodeKey: nodePriv,
NetworkLockKey: nlPriv,
},
}).View(), ipn.NetworkProfile{}))
return pm
}
// setupChonkStorage creates a new [tka.FS] in a temporary folder.
func setupChonkStorage(t *testing.T, pm *profileManager) (varRoot string, chonk *tka.FS) {
varRoot = t.TempDir()
tkaPath := filepath.Join(varRoot, "tka-profile", string(pm.CurrentProfile().ID()))
os.Mkdir(tkaPath, 0755)
chonk, err := tka.ChonkDir(tkaPath)
if err != nil {
t.Fatal(err)
}
return varRoot, chonk
}
func TestTKAEnablementFlow(t *testing.T) {
nodePriv := key.NewNode()
// Make a fake TKA authority, getting a usable genesis AUM which
// our mock server can communicate.
nlPriv := key.NewNLPrivate()
key := tka.Key{Kind: tka.Key25519, Public: nlPriv.Public().Verifier(), Votes: 2}
state := tka.CreateStateForTest(key)
chonk := tka.ChonkMem()
a1, genesisAUM, err := tka.Create(chonk, state, nlPriv)
if err != nil {
t.Fatalf("tka.Create() failed: %v", err)
}
ts, client := fakeNoiseServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
switch r.URL.Path {
case "/machine/tka/bootstrap":
resp := tailcfg.TKABootstrapResponse{
GenesisAUM: genesisAUM.Serialize(),
}
req, err := tkatest.HandleTKABootstrap(w, r, resp)
if err != nil {
t.Errorf("HandleTKABootstrap: %v", err)
}
if req.NodeKey != nodePriv.Public() {
t.Errorf("bootstrap nodeKey=%v, want %v", req.NodeKey, nodePriv.Public())
}
if req.Head != "" {
t.Errorf("bootstrap head=%s, want empty hash", req.Head)
}
// Sync offer/send endpoints are hit even though the node is up-to-date,
// so we implement enough of a fake that the client doesn't explode.
case "/machine/tka/sync/offer":
err := tkatest.HandleTKASyncOffer(w, r, a1, chonk)
if err != nil {
t.Errorf("HandleTKASyncOffer: %v", err)
}
case "/machine/tka/sync/send":
err := tkatest.HandleTKASyncSend(w, r, a1, chonk)
if err != nil {
t.Errorf("HandleTKASyncOffer: %v", err)
}
default:
t.Errorf("unhandled endpoint path: %v", r.URL.Path)
w.WriteHeader(404)
}
}))
defer ts.Close()
temp := t.TempDir()
cc := fakeControlClient(t, client)
pm := setupProfileManager(t, nodePriv, nlPriv)
b := LocalBackend{
capTailnetLock: true,
varRoot: temp,
cc: cc,
ccAuto: cc,
logf: t.Logf,
health: health.NewTracker(eventbustest.NewBus(t)),
pm: pm,
store: pm.Store(),
}
err = b.tkaSyncIfNeeded(&netmap.NetworkMap{
TKAEnabled: true,
TKAHead: a1.Head(),
}, pm.CurrentPrefs())
if err != nil {
t.Errorf("tkaSyncIfNeededLocked() failed: %v", err)
}
if b.tka == nil {
t.Fatal("tka was not initialized")
}
if b.tka.authority.Head() != a1.Head() {
t.Errorf("authority.Head() = %x, want %x", b.tka.authority.Head(), a1.Head())
}
}
func TestTKADisablementFlow(t *testing.T) {
nodePriv := key.NewNode()
// Make a fake TKA authority, to seed local state.
disablementSecret := bytes.Repeat([]byte{0xa5}, 32)
nlPriv := key.NewNLPrivate()
key := tka.Key{Kind: tka.Key25519, Public: nlPriv.Public().Verifier(), Votes: 2}
pm := setupProfileManager(t, nodePriv, nlPriv)
varRoot, chonk := setupChonkStorage(t, pm)
authority, _, err := tka.Create(chonk, tka.State{
Keys: []tka.Key{key},
DisablementValues: [][]byte{tka.DisablementKDF(disablementSecret)},
}, nlPriv)
if err != nil {
t.Fatalf("tka.Create() failed: %v", err)
}
returnWrongSecret := false
ts, client := fakeNoiseServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
switch r.URL.Path {
case "/machine/tka/bootstrap":
var disablement []byte
if returnWrongSecret {
disablement = bytes.Repeat([]byte{0x42}, 32) // wrong secret
} else {
disablement = disablementSecret
}
resp := tailcfg.TKABootstrapResponse{
DisablementSecret: disablement,
}
req, err := tkatest.HandleTKABootstrap(w, r, resp)
if err != nil {
t.Errorf("HandleTKABootstrap: %v", err)
}
if req.NodeKey != nodePriv.Public() {
t.Errorf("nodeKey=%v, want %v", req.NodeKey, nodePriv.Public())
}
var head tka.AUMHash
if err := head.UnmarshalText([]byte(req.Head)); err != nil {
t.Fatalf("failed unmarshal of body.Head: %v", err)
}
if head != authority.Head() {
t.Errorf("reported head = %x, want %x", head, authority.Head())
}
default:
t.Errorf("unhandled endpoint path: %v", r.URL.Path)
w.WriteHeader(404)
}
}))
defer ts.Close()
b := newLocalBackendForTKA(t, varRoot, client, pm, authority, chonk)
// Test that the wrong disablement secret does not shut down the authority.
returnWrongSecret = true
err = b.tkaSyncIfNeeded(&netmap.NetworkMap{
TKAEnabled: false,
TKAHead: authority.Head(),
}, pm.CurrentPrefs())
if err != nil {
t.Errorf("tkaSyncIfNeededLocked() failed: %v", err)
}
if b.tka == nil {
t.Error("TKA was disabled despite incorrect disablement secret")
}
// Test the correct disablement secret shuts down the authority.
returnWrongSecret = false
err = b.tkaSyncIfNeeded(&netmap.NetworkMap{
TKAEnabled: false,
TKAHead: authority.Head(),
}, pm.CurrentPrefs())
if err != nil {
t.Errorf("tkaSyncIfNeededLocked() failed: %v", err)
}
if b.tka != nil {
t.Fatal("tka was not shut down")
}
if _, err := os.Stat(b.chonkPathLocked()); err == nil || !os.IsNotExist(err) {
t.Errorf("os.Stat(chonkDir) = %v, want ErrNotExist", err)
}
}
func TestTKASync(t *testing.T) {
someKeyPriv := key.NewNLPrivate()
someKey := tka.Key{Kind: tka.Key25519, Public: someKeyPriv.Public().Verifier(), Votes: 1}
type tkaSyncScenario struct {
name string
// controlAUMs is called (if non-nil) to get any AUMs which the tka state
// on control should be seeded with.
controlAUMs func(*testing.T, *tka.Authority, tka.Chonk, tka.Signer) []tka.AUM
// controlAUMs is called (if non-nil) to get any AUMs which the tka state
// on the node should be seeded with.
nodeAUMs func(*testing.T, *tka.Authority, tka.Chonk, tka.Signer) []tka.AUM
}
tcs := []tkaSyncScenario{
{name: "up-to-date"},
{
name: "control-has-an-update",
controlAUMs: func(t *testing.T, a *tka.Authority, storage tka.Chonk, signer tka.Signer) []tka.AUM {
b := a.NewUpdater(signer)
if err := b.RemoveKey(someKey.MustID()); err != nil {
t.Fatal(err)
}
aums, err := b.Finalize(storage)
if err != nil {
t.Fatal(err)
}
return aums
},
},
{
// AKA 'control data loss' scenario
name: "node-has-an-update",
nodeAUMs: func(t *testing.T, a *tka.Authority, storage tka.Chonk, signer tka.Signer) []tka.AUM {
b := a.NewUpdater(signer)
if err := b.RemoveKey(someKey.MustID()); err != nil {
t.Fatal(err)
}
aums, err := b.Finalize(storage)
if err != nil {
t.Fatal(err)
}
return aums
},
},
{
// AKA 'control data loss + update in the meantime' scenario
name: "node-and-control-diverge",
controlAUMs: func(t *testing.T, a *tka.Authority, storage tka.Chonk, signer tka.Signer) []tka.AUM {
b := a.NewUpdater(signer)
if err := b.SetKeyMeta(someKey.MustID(), map[string]string{"ye": "swiggity"}); err != nil {
t.Fatal(err)
}
aums, err := b.Finalize(storage)
if err != nil {
t.Fatal(err)
}
return aums
},
nodeAUMs: func(t *testing.T, a *tka.Authority, storage tka.Chonk, signer tka.Signer) []tka.AUM {
b := a.NewUpdater(signer)
if err := b.SetKeyMeta(someKey.MustID(), map[string]string{"ye": "swooty"}); err != nil {
t.Fatal(err)
}
aums, err := b.Finalize(storage)
if err != nil {
t.Fatal(err)
}
return aums
},
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
nodePriv := key.NewNode()
nlPriv := key.NewNLPrivate()
pm := setupProfileManager(t, nodePriv, nlPriv)
// Setup the tka authority on the control plane.
key := tka.Key{Kind: tka.Key25519, Public: nlPriv.Public().Verifier(), Votes: 2}
controlStorage := tka.ChonkMem()
controlState := tka.CreateStateForTest(key, someKey)
controlAuthority, bootstrap, err := tka.Create(controlStorage, controlState, nlPriv)
if err != nil {
t.Fatalf("tka.Create() failed: %v", err)
}
if tc.controlAUMs != nil {
if err := controlAuthority.Inform(controlStorage, tc.controlAUMs(t, controlAuthority, controlStorage, nlPriv)); err != nil {
t.Fatalf("controlAuthority.Inform() failed: %v", err)
}
}
// Setup the TKA authority on the node.
varRoot, nodeStorage := setupChonkStorage(t, pm)
nodeAuthority, err := tka.Bootstrap(nodeStorage, bootstrap)
if err != nil {
t.Fatalf("tka.Bootstrap() failed: %v", err)
}
if tc.nodeAUMs != nil {
if err := nodeAuthority.Inform(nodeStorage, tc.nodeAUMs(t, nodeAuthority, nodeStorage, nlPriv)); err != nil {
t.Fatalf("nodeAuthority.Inform() failed: %v", err)
}
}
// Make a mock control server.
ts, client := fakeNoiseServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
switch r.URL.Path {
case "/machine/tka/sync/offer":
err := tkatest.HandleTKASyncOffer(w, r, controlAuthority, controlStorage)
if err != nil {
t.Errorf("HandleTKASyncOffer: %v", err)
}
case "/machine/tka/sync/send":
err := tkatest.HandleTKASyncSend(w, r, controlAuthority, controlStorage)
if err != nil {
t.Errorf("HandleTKASyncSend: %v", err)
}
default:
t.Errorf("unhandled endpoint path: %v", r.URL.Path)
w.WriteHeader(404)
}
}))
defer ts.Close()
// Setup the client.
b := newLocalBackendForTKA(t, varRoot, client, pm, nodeAuthority, nodeStorage)
// Finally, let's trigger a sync.
err = b.tkaSyncIfNeeded(&netmap.NetworkMap{
TKAEnabled: true,
TKAHead: controlAuthority.Head(),
}, pm.CurrentPrefs())
if err != nil {
t.Errorf("tkaSyncIfNeededLocked() failed: %v", err)
}
// Check that at the end of this ordeal, the node and the control
// plane are in sync.
if nodeHead, controlHead := b.tka.authority.Head(), controlAuthority.Head(); nodeHead != controlHead {
t.Errorf("node head = %v, want %v", nodeHead, controlHead)
}
})
}
}
// Whenever we run a TKA sync and get new state from control, we compact the
// local state.
func TestTKASyncTriggersCompact(t *testing.T) {
someKeyPriv := key.NewNLPrivate()
someKey := tka.Key{Kind: tka.Key25519, Public: someKeyPriv.Public().Verifier(), Votes: 1}
nodePriv := key.NewNode()
nlPriv := key.NewNLPrivate()
pm := setupProfileManager(t, nodePriv, nlPriv)
// Create a clock, and roll it back by 30 days.
//
// Our compaction algorithm preserves AUMs received in the last 14 days, so
// we need to backdate the commit times to make the AUMs eligible for compaction.
clock := tstest.NewClock(tstest.ClockOpts{})
clock.Advance(-30 * 24 * time.Hour)
// Set up the TKA authority on the control plane.
key := tka.Key{Kind: tka.Key25519, Public: nlPriv.Public().Verifier(), Votes: 2}
controlStorage := tka.ChonkMem()
controlStorage.SetClock(clock)
controlState := tka.CreateStateForTest(key, someKey)
controlAuthority, bootstrap, err := tka.Create(controlStorage, controlState, nlPriv)
if err != nil {
t.Fatalf("tka.Create() failed: %v", err)
}
// Fill the control plane TKA authority with a lot of AUMs, enough so that:
//
// 1. the chain of AUMs includes some checkpoints
// 2. the chain is long enough it would be trimmed if we ran the compaction
// algorithm with the defaults
for range 100 {
upd := controlAuthority.NewUpdater(nlPriv)
if err := upd.RemoveKey(someKey.MustID()); err != nil {
t.Fatalf("RemoveKey: %v", err)
}
if err := upd.AddKey(someKey); err != nil {
t.Fatalf("AddKey: %v", err)
}
aums, err := upd.Finalize(controlStorage)
if err != nil {
t.Fatalf("Finalize: %v", err)
}
if err := controlAuthority.Inform(controlStorage, aums); err != nil {
t.Fatalf("controlAuthority.Inform() failed: %v", err)
}
}
// Set up the TKA authority on the node.
nodeStorage := tka.ChonkMem()
nodeStorage.SetClock(clock)
nodeAuthority, err := tka.Bootstrap(nodeStorage, bootstrap)
if err != nil {
t.Fatalf("tka.Bootstrap() failed: %v", err)
}
// Make a mock control server.
ts, client := fakeNoiseServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
switch r.URL.Path {
case "/machine/tka/sync/offer":
err := tkatest.HandleTKASyncOffer(w, r, controlAuthority, controlStorage)
if err != nil {
t.Errorf("HandleTKASyncOffer: %v", err)
}
case "/machine/tka/sync/send":
err := tkatest.HandleTKASyncSend(w, r, controlAuthority, controlStorage)
if err != nil {
t.Errorf("HandleTKASyncSend: %v", err)
}
default:
t.Errorf("unhandled endpoint path: %v", r.URL.Path)
w.WriteHeader(404)
}
}))
defer ts.Close()
// Setup the client.
varRoot := ""
b := newLocalBackendForTKA(t, varRoot, client, pm, nodeAuthority, nodeStorage)
// Trigger a sync.
err = b.tkaSyncIfNeeded(&netmap.NetworkMap{
TKAEnabled: true,
TKAHead: controlAuthority.Head(),
}, pm.CurrentPrefs())
if err != nil {
t.Errorf("tkaSyncIfNeeded() failed: %v", err)
}
// Add a new AUM in control.
upd := controlAuthority.NewUpdater(nlPriv)
if err := upd.RemoveKey(someKey.MustID()); err != nil {
t.Fatalf("RemoveKey: %v", err)
}
aums, err := upd.Finalize(controlStorage)
if err != nil {
t.Fatalf("Finalize: %v", err)
}
if err := controlAuthority.Inform(controlStorage, aums); err != nil {
t.Fatalf("controlAuthority.Inform() failed: %v", err)
}
// Run a second sync, which should trigger a compaction.
err = b.tkaSyncIfNeeded(&netmap.NetworkMap{
TKAEnabled: true,
TKAHead: controlAuthority.Head(),
}, pm.CurrentPrefs())
if err != nil {
t.Errorf("tkaSyncIfNeeded() failed: %v", err)
}
// Check that the node and control plane are in sync.
if nodeHead, controlHead := b.tka.authority.Head(), controlAuthority.Head(); nodeHead != controlHead {
t.Errorf("node head = %v, want %v", nodeHead, controlHead)
}
// Check the node has compacted away some of its AUMs; that it has purged some AUMs which
// are still kept in the control plane.
nodeAUMs, err := b.tka.storage.AllAUMs()
if err != nil {
t.Errorf("AllAUMs() for node failed: %v", err)
}
controlAUMS, err := controlStorage.AllAUMs()
if err != nil {
t.Errorf("AllAUMs() for control failed: %v", err)
}
if len(nodeAUMs) == len(controlAUMS) {
t.Errorf("node has not compacted; it has the same number of AUMs as control (node = control = %d)", len(nodeAUMs))
}
}
func TestTKAFilterNetmap(t *testing.T) {
nlPriv := key.NewNLPrivate()
nlKey := tka.Key{Kind: tka.Key25519, Public: nlPriv.Public().Verifier(), Votes: 2}
state := tka.CreateStateForTest(nlKey)
storage := tka.ChonkMem()
authority, _, err := tka.Create(storage, state, nlPriv)
if err != nil {
t.Fatalf("tka.Create() failed: %v", err)
}
b := &LocalBackend{
logf: t.Logf,
health: health.NewTracker(eventbustest.NewBus(t)),
tka: &tkaState{authority: authority},
}
n1, n2, n3, n4, n5 := key.NewNode(), key.NewNode(), key.NewNode(), key.NewNode(), key.NewNode()
n1GoodSig, err := signNodeKey(tailcfg.TKASignInfo{NodePublic: n1.Public()}, nlPriv)
if err != nil {
t.Fatal(err)
}
n4Sig, err := signNodeKey(tailcfg.TKASignInfo{NodePublic: n4.Public()}, nlPriv)
if err != nil {
t.Fatal(err)
}
n4Sig.Signature[3] = 42 // mess up the signature
n4Sig.Signature[4] = 42 // mess up the signature
n5nl := key.NewNLPrivate()
n5InitialSig, err := signNodeKey(tailcfg.TKASignInfo{NodePublic: n5.Public(), RotationPubkey: n5nl.Public().Verifier()}, nlPriv)
if err != nil {
t.Fatal(err)
}
resign := func(nl key.NLPrivate, currentSig tkatype.MarshaledSignature) (key.NodePrivate, tkatype.MarshaledSignature) {
nk := key.NewNode()
sig, err := tka.ResignNKS(nl, nk.Public(), currentSig)
if err != nil {
t.Fatal(err)
}
return nk, sig
}
n5Rotated, n5RotatedSig := resign(n5nl, n5InitialSig.Serialize())
nodeFromAuthKey := func(authKey string) (key.NodePrivate, tkatype.MarshaledSignature) {
_, isWrapped, sig, priv := tka.DecodeWrappedAuthkey(authKey, t.Logf)
if !isWrapped {
t.Errorf("expected wrapped key")
}
node := key.NewNode()
nodeSig, err := tka.SignByCredential(priv, sig, node.Public())
if err != nil {
t.Error(err)
}
return node, nodeSig
}
preauth, err := b.NetworkLockWrapPreauthKey("tskey-auth-k7UagY1CNTRL-ZZZZZ", nlPriv)
if err != nil {
t.Fatal(err)
}
// Two nodes created using the same auth key, both should be valid.
n60, n60Sig := nodeFromAuthKey(preauth)
n61, n61Sig := nodeFromAuthKey(preauth)
nm := &netmap.NetworkMap{
Peers: nodeViews([]*tailcfg.Node{
{ID: 1, Key: n1.Public(), KeySignature: n1GoodSig.Serialize()},
{ID: 2, Key: n2.Public(), KeySignature: nil}, // missing sig
{ID: 3, Key: n3.Public(), KeySignature: n1GoodSig.Serialize()}, // someone elses sig
{ID: 4, Key: n4.Public(), KeySignature: n4Sig.Serialize()}, // messed-up signature
{ID: 50, Key: n5.Public(), KeySignature: n5InitialSig.Serialize()}, // rotated
{ID: 51, Key: n5Rotated.Public(), KeySignature: n5RotatedSig},
{ID: 60, Key: n60.Public(), KeySignature: n60Sig},
{ID: 61, Key: n61.Public(), KeySignature: n61Sig},
}),
}
b.tkaFilterNetmapLocked(nm)
want := nodeViews([]*tailcfg.Node{
{ID: 1, Key: n1.Public(), KeySignature: n1GoodSig.Serialize()},
{ID: 51, Key: n5Rotated.Public(), KeySignature: n5RotatedSig},
{ID: 60, Key: n60.Public(), KeySignature: n60Sig},
{ID: 61, Key: n61.Public(), KeySignature: n61Sig},
})
nodePubComparer := cmp.Comparer(func(x, y key.NodePublic) bool {
return x.Raw32() == y.Raw32()
})
if diff := cmp.Diff(want, nm.Peers, nodePubComparer); diff != "" {
t.Errorf("filtered netmap differs (-want, +got):\n%s", diff)
}
// Create two more node signatures using the same wrapping key as n5.
// Since they have the same rotation chain, both will be filtered out.
n7, n7Sig := resign(n5nl, n5RotatedSig)
n8, n8Sig := resign(n5nl, n5RotatedSig)
nm = &netmap.NetworkMap{
Peers: nodeViews([]*tailcfg.Node{
{ID: 1, Key: n1.Public(), KeySignature: n1GoodSig.Serialize()},
{ID: 2, Key: n2.Public(), KeySignature: nil}, // missing sig
{ID: 3, Key: n3.Public(), KeySignature: n1GoodSig.Serialize()}, // someone elses sig
{ID: 4, Key: n4.Public(), KeySignature: n4Sig.Serialize()}, // messed-up signature
{ID: 50, Key: n5.Public(), KeySignature: n5InitialSig.Serialize()}, // rotated
{ID: 51, Key: n5Rotated.Public(), KeySignature: n5RotatedSig}, // rotated
{ID: 7, Key: n7.Public(), KeySignature: n7Sig}, // same rotation chain as n8
{ID: 8, Key: n8.Public(), KeySignature: n8Sig}, // same rotation chain as n7
}),
}
b.tkaFilterNetmapLocked(nm)
want = nodeViews([]*tailcfg.Node{
{ID: 1, Key: n1.Public(), KeySignature: n1GoodSig.Serialize()},
})
if diff := cmp.Diff(want, nm.Peers, nodePubComparer); diff != "" {
t.Errorf("filtered netmap differs (-want, +got):\n%s", diff)
}
// Confirm that repeated rotation works correctly.
for range 100 {
n5Rotated, n5RotatedSig = resign(n5nl, n5RotatedSig)
}
n51, n51Sig := resign(n5nl, n5RotatedSig)
nm = &netmap.NetworkMap{
Peers: nodeViews([]*tailcfg.Node{
{ID: 1, Key: n1.Public(), KeySignature: n1GoodSig.Serialize()},
{ID: 5, Key: n5Rotated.Public(), KeySignature: n5RotatedSig}, // rotated
{ID: 51, Key: n51.Public(), KeySignature: n51Sig},
}),
}
b.tkaFilterNetmapLocked(nm)
want = nodeViews([]*tailcfg.Node{
{ID: 1, Key: n1.Public(), KeySignature: n1GoodSig.Serialize()},
{ID: 51, Key: n51.Public(), KeySignature: n51Sig},
})
if diff := cmp.Diff(want, nm.Peers, nodePubComparer); diff != "" {
t.Errorf("filtered netmap differs (-want, +got):\n%s", diff)
}
}
func TestTKADisable(t *testing.T) {
nodePriv := key.NewNode()
// Make a fake TKA authority, to seed local state.
disablementSecret := bytes.Repeat([]byte{0xa5}, 32)
nlPriv := key.NewNLPrivate()
key := tka.Key{Kind: tka.Key25519, Public: nlPriv.Public().Verifier(), Votes: 2}
pm := setupProfileManager(t, nodePriv, nlPriv)
temp, chonk := setupChonkStorage(t, pm)
authority, _, err := tka.Create(chonk, tka.State{
Keys: []tka.Key{key},
DisablementValues: [][]byte{tka.DisablementKDF(disablementSecret)},
}, nlPriv)
if err != nil {
t.Fatalf("tka.Create() failed: %v", err)
}
ts, client := fakeNoiseServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
switch r.URL.Path {
case "/machine/tka/disable":
body := new(tailcfg.TKADisableRequest)
if err := json.NewDecoder(r.Body).Decode(body); err != nil {
t.Fatal(err)
}
if body.Version != tailcfg.CurrentCapabilityVersion {
t.Errorf("disable CapVer = %v, want %v", body.Version, tailcfg.CurrentCapabilityVersion)
}
if body.NodeKey != nodePriv.Public() {
t.Errorf("nodeKey = %v, want %v", body.NodeKey, nodePriv.Public())
}
if !bytes.Equal(body.DisablementSecret, disablementSecret) {
t.Errorf("disablement secret = %x, want %x", body.DisablementSecret, disablementSecret)
}
var head tka.AUMHash
if err := head.UnmarshalText([]byte(body.Head)); err != nil {
t.Fatalf("failed unmarshal of body.Head: %v", err)
}
if head != authority.Head() {
t.Errorf("reported head = %x, want %x", head, authority.Head())
}
w.WriteHeader(200)
if err := json.NewEncoder(w).Encode(tailcfg.TKADisableResponse{}); err != nil {
t.Fatal(err)
}
default:
t.Errorf("unhandled endpoint path: %v", r.URL.Path)
w.WriteHeader(404)
}
}))
defer ts.Close()
b := newLocalBackendForTKA(t, temp, client, pm, authority, chonk)
// Test that we get an error for an incorrect disablement secret.
if err := b.NetworkLockDisable([]byte{1, 2, 3, 4}); err == nil || err.Error() != "incorrect disablement secret" {
t.Errorf("NetworkLockDisable(<bad secret>).err = %v, want 'incorrect disablement secret'", err)
}
if err := b.NetworkLockDisable(disablementSecret); err != nil {
t.Errorf("NetworkLockDisable() failed: %v", err)
}
}
func TestTKASign(t *testing.T) {
nodePriv := key.NewNode()
toSign := key.NewNode()
nlPriv := key.NewNLPrivate()
pm := setupProfileManager(t, nodePriv, nlPriv)
// Make a fake TKA authority, to seed local state.
key := tka.Key{Kind: tka.Key25519, Public: nlPriv.Public().Verifier(), Votes: 2}
state := tka.CreateStateForTest(key)
varRoot, chonk := setupChonkStorage(t, pm)
authority, _, err := tka.Create(chonk, state, nlPriv)
if err != nil {
t.Fatalf("tka.Create() failed: %v", err)
}
ts, client := fakeNoiseServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
switch r.URL.Path {
case "/machine/tka/sign":
_, _, err := tkatest.HandleTKASign(w, r, authority)
if err != nil {
t.Errorf("HandleTKASign: %v", err)
}
default:
t.Errorf("unhandled endpoint path: %v", r.URL.Path)
w.WriteHeader(404)
}
}))
defer ts.Close()
b := newLocalBackendForTKA(t, varRoot, client, pm, authority, chonk)
if err := b.NetworkLockSign(toSign.Public(), nil); err != nil {
t.Errorf("NetworkLockSign() failed: %v", err)
}
}
func TestTKAForceDisable(t *testing.T) {
nodePriv := key.NewNode()
// Make a fake TKA authority, to seed local state.
nlPriv := key.NewNLPrivate()
key := tka.Key{Kind: tka.Key25519, Public: nlPriv.Public().Verifier(), Votes: 2}
state := tka.CreateStateForTest(key)
pm := setupProfileManager(t, nodePriv, nlPriv)
temp, chonk := setupChonkStorage(t, pm)
authority, genesis, err := tka.Create(chonk, state, nlPriv)
if err != nil {
t.Fatalf("tka.Create() failed: %v", err)
}
ts, client := fakeNoiseServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
switch r.URL.Path {
case "/machine/tka/bootstrap":
resp := tailcfg.TKABootstrapResponse{
GenesisAUM: genesis.Serialize(),
}
req, err := tkatest.HandleTKABootstrap(w, r, resp)
if err != nil {
t.Errorf("HandleTKABootstrap: %v", err)
}
if req.NodeKey != nodePriv.Public() {
t.Errorf("nodeKey=%v, want %v", req.NodeKey, nodePriv.Public())
}
default:
t.Errorf("unhandled endpoint path: %v", r.URL.Path)
w.WriteHeader(404)
}
}))
defer ts.Close()
cc := fakeControlClient(t, client)
sys := tsd.NewSystem()
sys.Set(pm.Store())
b := newTestLocalBackendWithSys(t, sys)
b.SetVarRoot(temp)
b.SetControlClientGetterForTesting(func(controlclient.Options) (controlclient.Client, error) {
return cc, nil
})
b.mu.Lock()
b.tka = &tkaState{
authority: authority,
storage: chonk,
}
b.pm = pm
b.mu.Unlock()
if err := b.NetworkLockForceLocalDisable(); err != nil {
t.Fatalf("NetworkLockForceLocalDisable() failed: %v", err)
}
if b.tka != nil {
t.Fatal("tka was not shut down")
}
if _, err := os.Stat(b.chonkPathLocked()); err == nil || !os.IsNotExist(err) {
t.Errorf("os.Stat(chonkDir) = %v, want ErrNotExist", err)
}
err = b.tkaSyncIfNeeded(&netmap.NetworkMap{
TKAEnabled: true,
TKAHead: authority.Head(),
}, pm.CurrentPrefs())
if err != nil && err.Error() != "bootstrap: TKA with stateID of \"0:0\" is disallowed on this node" {
t.Errorf("tkaSyncIfNeededLocked() failed: %v", err)
}
if b.tka != nil {
t.Fatal("tka was re-initialized")
}
}
func TestTKAAffectedSigs(t *testing.T) {
nodePriv := key.NewNode()
// toSign := key.NewNode()
nlPriv := key.NewNLPrivate()
pm := setupProfileManager(t, nodePriv, nlPriv)
// Make a fake TKA authority, to seed local state.
tkaKey := tka.Key{Kind: tka.Key25519, Public: nlPriv.Public().Verifier(), Votes: 2}
state := tka.CreateStateForTest(tkaKey)
varRoot, chonk := setupChonkStorage(t, pm)
authority, _, err := tka.Create(chonk, state, nlPriv)
if err != nil {
t.Fatalf("tka.Create() failed: %v", err)
}
untrustedKey := key.NewNLPrivate()
tcs := []struct {
name string
makeSig func() *tka.NodeKeySignature
wantErr string
}{
{
"no-error",
func() *tka.NodeKeySignature {
sig, _ := signNodeKey(tailcfg.TKASignInfo{NodePublic: nodePriv.Public()}, nlPriv)
return sig
},
"",
},
{
"signature-for-different-keyID",
func() *tka.NodeKeySignature {
sig, _ := signNodeKey(tailcfg.TKASignInfo{NodePublic: nodePriv.Public()}, untrustedKey)
return sig
},
fmt.Sprintf("got signature with keyID %X from request for %X", untrustedKey.KeyID(), nlPriv.KeyID()),
},
{
"invalid-signature",
func() *tka.NodeKeySignature {
sig, _ := signNodeKey(tailcfg.TKASignInfo{NodePublic: nodePriv.Public()}, nlPriv)
copy(sig.Signature, []byte{1, 2, 3, 4, 5, 6}) // overwrite with trash to invalid signature
return sig
},
"signature 0 is not valid: invalid signature",
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
s := tc.makeSig()
ts, client := fakeNoiseServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
switch r.URL.Path {
case "/machine/tka/affected-sigs":
body := new(tailcfg.TKASignaturesUsingKeyRequest)
if err := json.NewDecoder(r.Body).Decode(body); err != nil {
t.Fatal(err)
}
if body.Version != tailcfg.CurrentCapabilityVersion {
t.Errorf("sign CapVer = %v, want %v", body.Version, tailcfg.CurrentCapabilityVersion)
}
if body.NodeKey != nodePriv.Public() {
t.Errorf("nodeKey = %v, want %v", body.NodeKey, nodePriv.Public())
}
w.WriteHeader(200)
if err := json.NewEncoder(w).Encode(tailcfg.TKASignaturesUsingKeyResponse{
Signatures: []tkatype.MarshaledSignature{s.Serialize()},
}); err != nil {
t.Fatal(err)
}
default:
t.Errorf("unhandled endpoint path: %v", r.URL.Path)
w.WriteHeader(404)
}
}))
defer ts.Close()
b := newLocalBackendForTKA(t, varRoot, client, pm, authority, chonk)
sigs, err := b.NetworkLockAffectedSigs(nlPriv.KeyID())
switch {
case tc.wantErr == "" && err != nil:
t.Errorf("NetworkLockAffectedSigs() failed: %v", err)
case tc.wantErr != "" && err == nil:
t.Errorf("NetworkLockAffectedSigs().err = nil, want %q", tc.wantErr)
case tc.wantErr != "" && err.Error() != tc.wantErr:
t.Errorf("NetworkLockAffectedSigs().err = %q, want %q", err.Error(), tc.wantErr)
}
if tc.wantErr == "" {
if len(sigs) != 1 {
t.Fatalf("len(sigs) = %d, want 1", len(sigs))
}
if !bytes.Equal(s.Serialize(), sigs[0]) {
t.Errorf("unexpected signature: got %v, want %v", sigs[0], s.Serialize())
}
}
})
}
}
func TestTKARecoverCompromisedKeyFlow(t *testing.T) {
nodePriv := key.NewNode()
nlPriv := key.NewNLPrivate()
cosignPriv := key.NewNLPrivate()
compromisedPriv := key.NewNLPrivate()
pm := setupProfileManager(t, nodePriv, nlPriv)
// Make a fake TKA authority, to seed local state.
key := tka.Key{Kind: tka.Key25519, Public: nlPriv.Public().Verifier(), Votes: 2}
cosignKey := tka.Key{Kind: tka.Key25519, Public: cosignPriv.Public().Verifier(), Votes: 2}
compromisedKey := tka.Key{Kind: tka.Key25519, Public: compromisedPriv.Public().Verifier(), Votes: 1}
state := tka.CreateStateForTest(key, compromisedKey, cosignKey)
varRoot, chonk := setupChonkStorage(t, pm)
authority, _, err := tka.Create(chonk, state, nlPriv)
if err != nil {
t.Fatalf("tka.Create() failed: %v", err)
}
ts, client := fakeNoiseServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
switch r.URL.Path {
case "/machine/tka/sync/send":
err := tkatest.HandleTKASyncSend(w, r, authority, chonk)
if err != nil {
t.Errorf("HandleTKASyncSend: %v", err)
}
// Make sure the key we removed isn't trusted.
if authority.KeyTrusted(compromisedPriv.KeyID()) {
t.Error("compromised key was not removed from tka")
}
default:
t.Errorf("unhandled endpoint path: %v", r.URL.Path)
w.WriteHeader(404)
}
}))
defer ts.Close()
b := newLocalBackendForTKA(t, varRoot, client, pm, authority, chonk)
aum, err := b.NetworkLockGenerateRecoveryAUM([]tkatype.KeyID{compromisedPriv.KeyID()}, tka.AUMHash{})
if err != nil {
t.Fatalf("NetworkLockGenerateRecoveryAUM() failed: %v", err)
}
// Cosign using the cosigning key.
{
pm := setupProfileManager(t, nodePriv, cosignPriv)
b := newLocalBackendForTKA(t, varRoot, client, pm, authority, chonk)
if aum, err = b.NetworkLockCosignRecoveryAUM(aum); err != nil {
t.Fatalf("NetworkLockCosignRecoveryAUM() failed: %v", err)
}
}
// Finally, submit the recovery AUM. Validation is done
// in the fake control handler.
if err := b.NetworkLockSubmitRecoveryAUM(aum); err != nil {
t.Errorf("NetworkLockSubmitRecoveryAUM() failed: %v", err)
}
}
func TestRotationTracker(t *testing.T) {
newNK := func(idx byte) key.NodePublic {
// single-byte public key to make it human-readable in tests.
raw32 := [32]byte{idx}
return key.NodePublicFromRaw32(go4mem.B(raw32[:]))
}
rd := func(initialKind tka.SigKind, wrappingKey []byte, prevKeys ...key.NodePublic) *tka.RotationDetails {
return &tka.RotationDetails{
InitialSig: &tka.NodeKeySignature{SigKind: initialKind, WrappingPubkey: wrappingKey},
PrevNodeKeys: prevKeys,
}
}
n1, n2, n3, n4, n5 := newNK(1), newNK(2), newNK(3), newNK(4), newNK(5)
pk1, pk2, pk3 := []byte{1}, []byte{2}, []byte{3}
type addDetails struct {
np key.NodePublic
details *tka.RotationDetails
}
tests := []struct {
name string
addDetails []addDetails
want set.Set[key.NodePublic]
}{
{
name: "empty",
want: nil,
},
{
name: "single_prev_key",
addDetails: []addDetails{
{np: n1, details: rd(tka.SigDirect, pk1, n2)},
},
want: set.SetOf([]key.NodePublic{n2}),
},
{
name: "several_prev_keys",
addDetails: []addDetails{
{np: n1, details: rd(tka.SigDirect, pk1, n2)},
{np: n3, details: rd(tka.SigDirect, pk2, n4)},
{np: n2, details: rd(tka.SigDirect, pk1, n3, n4)},
},
want: set.SetOf([]key.NodePublic{n2, n3, n4}),
},
{
name: "several_per_pubkey_latest_wins",
addDetails: []addDetails{
{np: n2, details: rd(tka.SigDirect, pk3, n1)},
{np: n3, details: rd(tka.SigDirect, pk3, n1, n2)},
{np: n4, details: rd(tka.SigDirect, pk3, n1, n2, n3)},
{np: n5, details: rd(tka.SigDirect, pk3, n4)},
},
want: set.SetOf([]key.NodePublic{n1, n2, n3, n4}),
},
{
name: "several_per_pubkey_same_chain_length_all_rejected",
addDetails: []addDetails{
{np: n2, details: rd(tka.SigDirect, pk3, n1)},
{np: n3, details: rd(tka.SigDirect, pk3, n1, n2)},
{np: n4, details: rd(tka.SigDirect, pk3, n1, n2)},
{np: n5, details: rd(tka.SigDirect, pk3, n1, n2)},
},
want: set.SetOf([]key.NodePublic{n1, n2, n3, n4, n5}),
},
{
name: "several_per_pubkey_longest_wins",
addDetails: []addDetails{
{np: n2, details: rd(tka.SigDirect, pk3, n1)},
{np: n3, details: rd(tka.SigDirect, pk3, n1, n2)},
{np: n4, details: rd(tka.SigDirect, pk3, n1, n2)},
{np: n5, details: rd(tka.SigDirect, pk3, n1, n2, n3)},
},
want: set.SetOf([]key.NodePublic{n1, n2, n3, n4}),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &rotationTracker{logf: t.Logf}
for _, ad := range tt.addDetails {
r.addRotationDetails(ad.np, ad.details)
}
if got := r.obsoleteKeys(); !reflect.DeepEqual(got, tt.want) {
t.Errorf("rotationTracker.obsoleteKeys() = %v, want %v", got, tt.want)
}
})
}
}