diff --git a/cmd/derper/depaware.txt b/cmd/derper/depaware.txt index e86596f73..a390ddb82 100644 --- a/cmd/derper/depaware.txt +++ b/cmd/derper/depaware.txt @@ -51,7 +51,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa tailscale.com/safesocket from tailscale.com/client/tailscale tailscale.com/syncs from tailscale.com/cmd/derper+ tailscale.com/tailcfg from tailscale.com/client/tailscale+ - tailscale.com/tka from tailscale.com/client/tailscale + tailscale.com/tka from tailscale.com/client/tailscale+ W tailscale.com/tsconst from tailscale.com/net/interfaces 💣 tailscale.com/tstime/mono from tailscale.com/tstime/rate tailscale.com/tstime/rate from tailscale.com/wgengine/filter diff --git a/control/controlclient/direct.go b/control/controlclient/direct.go index 7243cf384..19054d60c 100644 --- a/control/controlclient/direct.go +++ b/control/controlclient/direct.go @@ -106,6 +106,7 @@ type Options struct { KeepAlive bool Logf logger.Logf HTTPTestClient *http.Client // optional HTTP client to use (for tests only) + NoiseTestClient *http.Client // optional HTTP client to use for noise RPCs (tests only) DebugFlags []string // debug settings to send to control LinkMonitor *monitor.Mon // optional link monitor PopBrowserURL func(url string) // optional func to open browser @@ -226,6 +227,12 @@ func NewDirect(opts Options) (*Direct, error) { c.SetNetInfo(ni) } } + if opts.NoiseTestClient != nil { + c.noiseClient = &noiseClient{ + Client: opts.NoiseTestClient, + } + c.serverNoiseKey = key.NewMachine().Public() // prevent early error before hitting test client + } return c, nil } diff --git a/control/controlclient/map.go b/control/controlclient/map.go index 73a309e50..5e5ea8766 100644 --- a/control/controlclient/map.go +++ b/control/controlclient/map.go @@ -48,6 +48,7 @@ type mapSession struct { lastHealth []string lastPopBrowserURL string stickyDebug tailcfg.Debug // accumulated opt.Bool values + lastTKAInfo *tailcfg.TKAInfo // netMapBuilding is non-nil during a netmapForResponse call, // containing the value to be returned, once fully populated. @@ -115,6 +116,9 @@ func (ms *mapSession) netmapForResponse(resp *tailcfg.MapResponse) *netmap.Netwo if resp.Health != nil { ms.lastHealth = resp.Health } + if resp.TKAInfo != nil { + ms.lastTKAInfo = resp.TKAInfo + } debug := resp.Debug if debug != nil { @@ -152,9 +156,17 @@ func (ms *mapSession) netmapForResponse(resp *tailcfg.MapResponse) *netmap.Netwo DERPMap: ms.lastDERPMap, Debug: debug, ControlHealth: ms.lastHealth, + TKAEnabled: ms.lastTKAInfo != nil && !ms.lastTKAInfo.Disabled, } ms.netMapBuilding = nm + if ms.lastTKAInfo != nil && ms.lastTKAInfo.Head != "" { + if err := nm.TKAHead.UnmarshalText([]byte(ms.lastTKAInfo.Head)); err != nil { + ms.logf("error unmarshalling TKAHead: %v", err) + nm.TKAEnabled = false + } + } + if resp.Node != nil { ms.lastNode = resp.Node } diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 1cd3cb0be..c1bf61aed 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -684,6 +684,9 @@ func (b *LocalBackend) setClientStatus(st controlclient.Status) { } } if st.NetMap != nil { + if err := b.tkaSyncIfNeededLocked(st.NetMap); err != nil { + b.logf("[v1] TKA sync error: %v", err) + } if b.findExitNodeIDLocked(st.NetMap) { prefsChanged = true } diff --git a/ipn/ipnlocal/network-lock.go b/ipn/ipnlocal/network-lock.go index 042998f7b..416b02ad2 100644 --- a/ipn/ipnlocal/network-lock.go +++ b/ipn/ipnlocal/network-lock.go @@ -1,4 +1,4 @@ -// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved. +// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. @@ -12,6 +12,8 @@ import ( "fmt" "io" "net/http" + "os" + "path/filepath" "time" "tailscale.com/envknob" @@ -31,6 +33,118 @@ type tkaState struct { storage *tka.FS } +// tkaSyncIfNeededLocked examines TKA info reported from the control plane, +// performing the steps necessary to synchronize local tka state. +// +// There are 4 scenarios handled here: +// - Enablement: nm.TKAEnabled but b.tka == nil +// ∴ reach out to /machine/tka/boostrap to get the genesis AUM, then +// initialize TKA. +// - Disablement: !nm.TKAEnabled but b.tka != nil +// ∴ reach out to /machine/tka/boostrap to read the disablement secret, +// then verify and clear tka local state. +// - Sync needed: b.tka.Head != nm.TKAHead +// ∴ complete multi-step synchronization flow. +// - Everything up to date: All other cases. +// ∴ no action necessary. +// +// b.mu must be held. b.mu will be stepped out of (and back in) during network +// RPCs. +func (b *LocalBackend) tkaSyncIfNeededLocked(nm *netmap.NetworkMap) error { + if !networkLockAvailable() { + // If the feature flag is not enabled, pretend we don't exist. + return nil + } + if nm.SelfNode == nil { + return errors.New("SelfNode missing") + } + + isEnabled := b.tka != nil + wantEnabled := nm.TKAEnabled + if isEnabled != wantEnabled { + var ourHead tka.AUMHash + if b.tka != nil { + ourHead = b.tka.authority.Head() + } + + // Regardless of whether we are moving to disabled or enabled, we + // need information from the tka bootstrap endpoint. + b.mu.Unlock() + bs, err := b.tkaFetchBootstrap(nm.SelfNode.ID, ourHead) + b.mu.Lock() + if err != nil { + return fmt.Errorf("fetching bootstrap: %v", err) + } + + if wantEnabled && !isEnabled { + if err := b.tkaBootstrapFromGenesisLocked(bs.GenesisAUM); err != nil { + return fmt.Errorf("bootstrap: %v", err) + } + isEnabled = true + } else if !wantEnabled && isEnabled { + if b.tka.authority.ValidDisablement(bs.DisablementSecret) { + b.tka = nil + isEnabled = false + + if err := os.RemoveAll(b.chonkPath()); err != nil { + return fmt.Errorf("os.RemoveAll: %v", err) + } + } else { + b.logf("Disablement secret did not verify, leaving TKA enabled.") + } + } else { + return fmt.Errorf("[bug] unreachable invariant of wantEnabled /w isEnabled") + } + } + + if isEnabled && b.tka.authority.Head() != nm.TKAHead { + // TODO(tom): Implement sync + } + + return nil +} + +// chonkPath returns the absolute path to the directory in which TKA +// state (the 'tailchonk') is stored. +func (b *LocalBackend) chonkPath() string { + return filepath.Join(b.TailscaleVarRoot(), "tka") +} + +// tkaBootstrapFromGenesisLocked initializes the local (on-disk) state of the +// tailnet key authority, based on the given genesis AUM. +// +// b.mu must be held. +func (b *LocalBackend) tkaBootstrapFromGenesisLocked(g tkatype.MarshaledAUM) error { + if !b.CanSupportNetworkLock() { + return errors.New("network lock not supported in this configuration") + } + + var genesis tka.AUM + if err := genesis.Unserialize(g); err != nil { + return fmt.Errorf("reading genesis: %v", err) + } + + chonkDir := b.chonkPath() + if err := os.Mkdir(chonkDir, 0755); err != nil && !os.IsExist(err) { + return fmt.Errorf("mkdir: %v", err) + } + + chonk, err := tka.ChonkDir(chonkDir) + if err != nil { + return fmt.Errorf("chonk: %v", err) + } + authority, err := tka.Bootstrap(chonk, genesis) + if err != nil { + return fmt.Errorf("tka bootstrap: %v", err) + } + + b.tka = &tkaState{ + authority: authority, + storage: chonk, + } + return nil +} + // CanSupportNetworkLock returns true if tailscaled is able to operate // a local tailnet key authority (and hence enforce network lock). func (b *LocalBackend) CanSupportNetworkLock() bool { @@ -237,3 +351,50 @@ func (b *LocalBackend) tkaInitFinish(nm *netmap.NetworkMap, nks map[tailcfg.Node return a, nil } } + +// tkaFetchBootstrap sends a /machine/tka/bootstrap RPC to the control plane +// over noise. This is used to get values necessary to enable or disable TKA. +func (b *LocalBackend) tkaFetchBootstrap(nodeID tailcfg.NodeID, head tka.AUMHash) (*tailcfg.TKABootstrapResponse, error) { + bootstrapReq := tailcfg.TKABootstrapRequest{ + NodeID: nodeID, + } + if !head.IsZero() { + head, err := head.MarshalText() + if err != nil { + return nil, fmt.Errorf("head.MarshalText failed: %v", err) + } + bootstrapReq.Head = string(head) + } + + var req bytes.Buffer + if err := json.NewEncoder(&req).Encode(bootstrapReq); err != nil { + return nil, fmt.Errorf("encoding request: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("ctx: %w", err) + } + req2, err := http.NewRequestWithContext(ctx, "GET", "https://unused/machine/tka/bootstrap", &req) + if err != nil { + return nil, fmt.Errorf("req: %w", err) + } + res, err := b.DoNoiseRequest(req2) + if err != nil { + return nil, fmt.Errorf("resp: %w", err) + } + if res.StatusCode != 200 { + body, _ := io.ReadAll(res.Body) + res.Body.Close() + return nil, fmt.Errorf("request returned (%d): %s", res.StatusCode, string(body)) + } + a := new(tailcfg.TKABootstrapResponse) + err = json.NewDecoder(res.Body).Decode(a) + res.Body.Close() + if err != nil { + return nil, fmt.Errorf("decoding JSON: %w", err) + } + + return a, nil +} diff --git a/ipn/ipnlocal/network-lock_test.go b/ipn/ipnlocal/network-lock_test.go new file mode 100644 index 000000000..5d5c5c80c --- /dev/null +++ b/ipn/ipnlocal/network-lock_test.go @@ -0,0 +1,243 @@ +// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package ipnlocal + +import ( + "bytes" + "context" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "tailscale.com/control/controlclient" + "tailscale.com/hostinfo" + "tailscale.com/tailcfg" + "tailscale.com/tka" + "tailscale.com/types/key" + "tailscale.com/types/netmap" +) + +func fakeControlClient(t *testing.T, c *http.Client) *controlclient.Auto { + hi := hostinfo.New() + ni := tailcfg.NetInfo{LinkType: "wired"} + hi.NetInfo = &ni + + k := key.NewMachine() + opts := controlclient.Options{ + ServerURL: "https://example.com", + Hostinfo: hi, + GetMachinePrivateKey: func() (key.MachinePrivate, error) { + return k, nil + }, + HTTPTestClient: c, + NoiseTestClient: c, + Status: func(controlclient.Status) {}, + } + + cc, err := controlclient.NewNoStart(opts) + if err != nil { + t.Fatal(err) + } + return cc +} + +// NOTE: URLs must have a https scheme and example.com domain to work with the underlying +// httptest plumbing, despite the domain being unused in the actual noise request transport. +func fakeNoiseServer(t *testing.T, handler http.HandlerFunc) (*httptest.Server, *http.Client) { + 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 +} + +func TestTKAEnablementFlow(t *testing.T) { + networkLockAvailable = func() bool { return true } // Enable the feature flag + + // 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} + a1, genesisAUM, err := tka.Create(&tka.Mem{}, tka.State{ + Keys: []tka.Key{key}, + DisablementSecrets: [][]byte{bytes.Repeat([]byte{0xa5}, 32)}, + }, 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": + body := new(tailcfg.TKABootstrapRequest) + if err := json.NewDecoder(r.Body).Decode(body); err != nil { + t.Fatal(err) + } + if body.NodeID != 420 { + t.Errorf("bootstrap nodeID=%v, want 420", body.NodeID) + } + if body.Head != "" { + t.Errorf("bootstrap head=%s, want empty hash", body.Head) + } + + w.WriteHeader(200) + out := tailcfg.TKABootstrapResponse{ + GenesisAUM: genesisAUM.Serialize(), + } + if err := json.NewEncoder(w).Encode(out); err != nil { + t.Fatal(err) + } + + default: + t.Errorf("unhandled endpoint path: %v", r.URL.Path) + w.WriteHeader(404) + } + })) + defer ts.Close() + temp := t.TempDir() + + cc := fakeControlClient(t, client) + b := LocalBackend{ + varRoot: temp, + cc: cc, + ccAuto: cc, + logf: t.Logf, + } + + b.mu.Lock() + err = b.tkaSyncIfNeededLocked(&netmap.NetworkMap{ + SelfNode: &tailcfg.Node{ID: 420}, + TKAEnabled: true, + TKAHead: tka.AUMHash{}, + }) + b.mu.Unlock() + 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) { + networkLockAvailable = func() bool { return true } // Enable the feature flag + temp := t.TempDir() + os.Mkdir(filepath.Join(temp, "tka"), 0755) + + // 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} + chonk, err := tka.ChonkDir(filepath.Join(temp, "tka")) + if err != nil { + t.Fatal(err) + } + authority, _, err := tka.Create(chonk, tka.State{ + Keys: []tka.Key{key}, + DisablementSecrets: [][]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/bootstrap": + body := new(tailcfg.TKABootstrapRequest) + if err := json.NewDecoder(r.Body).Decode(body); err != nil { + t.Fatal(err) + } + var disablement []byte + switch body.NodeID { + case 42: + disablement = bytes.Repeat([]byte{0x42}, 32) // wrong secret + case 420: + disablement = disablementSecret + default: + t.Errorf("bootstrap nodeID=%v, wanted 42 or 420", body.NodeID) + } + 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) + out := tailcfg.TKABootstrapResponse{ + DisablementSecret: disablement, + } + if err := json.NewEncoder(w).Encode(out); err != nil { + t.Fatal(err) + } + + default: + t.Errorf("unhandled endpoint path: %v", r.URL.Path) + w.WriteHeader(404) + } + })) + defer ts.Close() + + cc := fakeControlClient(t, client) + b := LocalBackend{ + varRoot: temp, + cc: cc, + ccAuto: cc, + logf: t.Logf, + tka: &tkaState{ + authority: authority, + storage: chonk, + }, + } + + // Test that the wrong disablement secret does not shut down the authority. + // NodeID == 42 indicates this scenario to our mock server. + b.mu.Lock() + err = b.tkaSyncIfNeededLocked(&netmap.NetworkMap{ + SelfNode: &tailcfg.Node{ID: 42}, + TKAEnabled: false, + TKAHead: authority.Head(), + }) + b.mu.Unlock() + 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. + // NodeID == 420 indicates this scenario to our mock server. + b.mu.Lock() + err = b.tkaSyncIfNeededLocked(&netmap.NetworkMap{ + SelfNode: &tailcfg.Node{ID: 420}, + TKAEnabled: false, + TKAHead: authority.Head(), + }) + b.mu.Unlock() + 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.chonkPath()); err == nil || !os.IsNotExist(err) { + t.Errorf("os.Stat(chonkDir) = %v, want ErrNotExist", err) + } +} diff --git a/ipn/ipnserver/server.go b/ipn/ipnserver/server.go index d5685dcf1..16c2b4ab4 100644 --- a/ipn/ipnserver/server.go +++ b/ipn/ipnserver/server.go @@ -772,7 +772,7 @@ func New(logf logger.Logf, logid string, store ipn.StateStore, eng wgengine.Engi }) if root := b.TailscaleVarRoot(); root != "" { - chonkDir := filepath.Join(root, "chonk") + chonkDir := filepath.Join(root, "tka") if _, err := os.Stat(chonkDir); err == nil { // The directory exists, which means network-lock has been initialized. storage, err := tka.ChonkDir(chonkDir) diff --git a/tailcfg/tka.go b/tailcfg/tka.go index fc59b8f68..39d46ec2c 100644 --- a/tailcfg/tka.go +++ b/tailcfg/tka.go @@ -101,6 +101,8 @@ type TKAInfo struct { // TKABootstrapRequest is sent by a node to get information necessary for // enabling or disabling the tailnet key authority. type TKABootstrapRequest struct { + // NodeID is the node ID of the initiating client. + NodeID NodeID // Head represents the node's head AUMHash (tka.Authority.Head), if // network lock is enabled. Head string @@ -120,6 +122,8 @@ type TKABootstrapResponse struct { // state (TKA). Values of type tka.AUMHash are encoded as strings in their // MarshalText form. type TKASyncOfferRequest struct { + // NodeID is the node ID of the initiating client. + NodeID NodeID // Head represents the node's head AUMHash (tka.Authority.Head). This // corresponds to tka.SyncOffer.Head. Head string @@ -147,6 +151,8 @@ type TKASyncOfferResponse struct { // TKASyncSendRequest encodes AUMs that a node believes the control plane // is missing. type TKASyncSendRequest struct { + // NodeID is the node ID of the initiating client. + NodeID NodeID // MissingAUMs encodes AUMs that the node believes the control plane // is missing. MissingAUMs []tkatype.MarshaledAUM diff --git a/tka/aum.go b/tka/aum.go index 9c2daac6d..5d515b670 100644 --- a/tka/aum.go +++ b/tka/aum.go @@ -45,6 +45,12 @@ func (h AUMHash) MarshalText() ([]byte, error) { return b, nil } +// IsZero returns true if the hash is the empty value. +func (h AUMHash) IsZero() bool { + return h == (AUMHash{}) +} + + // AUMKind describes valid AUM types. type AUMKind uint8 diff --git a/tka/builder_test.go b/tka/builder_test.go index 10ea71d19..74399d347 100644 --- a/tka/builder_test.go +++ b/tka/builder_test.go @@ -31,7 +31,7 @@ func TestAuthorityBuilderAddKey(t *testing.T) { storage := &Mem{} a, _, err := Create(storage, State{ Keys: []Key{key}, - DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})}, + DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})}, }, signer25519(priv)) if err != nil { t.Fatalf("Create() failed: %v", err) @@ -68,7 +68,7 @@ func TestAuthorityBuilderRemoveKey(t *testing.T) { storage := &Mem{} a, _, err := Create(storage, State{ Keys: []Key{key, key2}, - DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})}, + DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})}, }, signer25519(priv)) if err != nil { t.Fatalf("Create() failed: %v", err) @@ -100,7 +100,7 @@ func TestAuthorityBuilderSetKeyVote(t *testing.T) { storage := &Mem{} a, _, err := Create(storage, State{ Keys: []Key{key}, - DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})}, + DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})}, }, signer25519(priv)) if err != nil { t.Fatalf("Create() failed: %v", err) @@ -136,7 +136,7 @@ func TestAuthorityBuilderSetKeyMeta(t *testing.T) { storage := &Mem{} a, _, err := Create(storage, State{ Keys: []Key{key}, - DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})}, + DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})}, }, signer25519(priv)) if err != nil { t.Fatalf("Create() failed: %v", err) @@ -172,7 +172,7 @@ func TestAuthorityBuilderMultiple(t *testing.T) { storage := &Mem{} a, _, err := Create(storage, State{ Keys: []Key{key}, - DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})}, + DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})}, }, signer25519(priv)) if err != nil { t.Fatalf("Create() failed: %v", err) diff --git a/tka/scenario_test.go b/tka/scenario_test.go index 7aa7a960c..e80a697c4 100644 --- a/tka/scenario_test.go +++ b/tka/scenario_test.go @@ -169,7 +169,7 @@ func testScenario(t *testing.T, sharedChain string, sharedOptions ...testchainOp sharedOptions = append(sharedOptions, optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{ Keys: []Key{key}, - DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})}, + DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})}, }}), optKey("key", key, priv), optSignAllUsing("key")) diff --git a/tka/sig_test.go b/tka/sig_test.go index c5958e322..a8536611c 100644 --- a/tka/sig_test.go +++ b/tka/sig_test.go @@ -226,7 +226,7 @@ func TestSigCredential(t *testing.T) { a, _ := Open(newTestchain(t, "G1\nG1.template = genesis", optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{ Keys: []Key{k}, - DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})}, + DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})}, }})).Chonk()) if err := a.NodeKeyAuthorized(node.Public(), nestedSig.Serialize()); err == nil { t.Error("NodeKeyAuthorized(SigCredential, node) did not fail") diff --git a/tka/state.go b/tka/state.go index 6bf55f2fb..353b68a85 100644 --- a/tka/state.go +++ b/tka/state.go @@ -93,7 +93,13 @@ const disablementLength = 32 var disablementSalt = []byte("tailscale network-lock disablement salt") -func disablementKDF(secret []byte) []byte { +// DisablementKDF computes a public value which can be stored in a +// key authority, but cannot be reversed to find the input secret. +// +// When the output of this function is stored in tka state (i.e. in +// tka.State.DisablementSecrets) a call to Authority.ValidDisablement() +// with the input of this function as the argument will return true. +func DisablementKDF(secret []byte) []byte { // time = 4 (3 recommended, booped to 4 to compensate for less memory) // memory = 16 (32 recommended) // threads = 4 @@ -103,7 +109,7 @@ func disablementKDF(secret []byte) []byte { // checkDisablement returns true for a valid disablement secret. func (s State) checkDisablement(secret []byte) bool { - derived := disablementKDF(secret) + derived := DisablementKDF(secret) for _, candidate := range s.DisablementSecrets { if bytes.Equal(derived, candidate) { return true diff --git a/tka/sync_test.go b/tka/sync_test.go index 47e0d7a05..2e5e31de5 100644 --- a/tka/sync_test.go +++ b/tka/sync_test.go @@ -342,7 +342,7 @@ func TestSyncSimpleE2E(t *testing.T) { `, optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{ Keys: []Key{key}, - DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})}, + DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})}, }}), optKey("key", key, priv), optSignAllUsing("key")) diff --git a/tka/tka_test.go b/tka/tka_test.go index f975a6b7b..de7d777a7 100644 --- a/tka/tka_test.go +++ b/tka/tka_test.go @@ -305,7 +305,7 @@ func TestAuthorityValidDisablement(t *testing.T) { `, optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{ Keys: []Key{key}, - DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})}, + DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})}, }}), ) @@ -321,7 +321,7 @@ func TestCreateBootstrapAuthority(t *testing.T) { a1, genesisAUM, err := Create(&Mem{}, State{ Keys: []Key{key}, - DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})}, + DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})}, }, signer25519(priv)) if err != nil { t.Fatalf("Create() failed: %v", err) @@ -361,7 +361,7 @@ func TestAuthorityInformNonLinear(t *testing.T) { `, optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{ Keys: []Key{key}, - DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})}, + DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})}, }}), optKey("key", key, priv), optSignAllUsing("key")) @@ -406,7 +406,7 @@ func TestAuthorityInformLinear(t *testing.T) { `, optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{ Keys: []Key{key}, - DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})}, + DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})}, }}), optKey("key", key, priv), optSignAllUsing("key")) diff --git a/types/netmap/netmap.go b/types/netmap/netmap.go index 27992eb8a..a2270870a 100644 --- a/types/netmap/netmap.go +++ b/types/netmap/netmap.go @@ -14,6 +14,7 @@ import ( "time" "tailscale.com/tailcfg" + "tailscale.com/tka" "tailscale.com/types/key" "tailscale.com/wgengine/filter" ) @@ -61,6 +62,13 @@ type NetworkMap struct { // check problems. ControlHealth []string + // TKAEnabled indicates whether the tailnet key authority should be + // enabled, from the perspective of the control plane. + TKAEnabled bool + // TKAHead indicates the control plane's understanding of 'head' (the + // hash of the latest update message to tick through TKA). + TKAHead tka.AUMHash + // ACLs User tailcfg.UserID