From f1130421f063d391d4a94ca7eb819facffdbe7c3 Mon Sep 17 00:00:00 2001 From: Tom DNetto Date: Mon, 28 Nov 2022 16:39:03 -0800 Subject: [PATCH] ipn,types/persist: store disallowed TKA's in prefs, lock local-disable Signed-off-by: Tom DNetto --- client/tailscale/localclient.go | 15 +++++ cmd/tailscale/cli/network-lock.go | 12 ++++ ipn/ipnlocal/network-lock.go | 43 ++++++++++++- ipn/ipnlocal/network-lock_test.go | 100 ++++++++++++++++++++++++++++++ ipn/localapi/localapi.go | 25 ++++++++ tka/tka.go | 5 ++ types/persist/persist.go | 10 ++- types/persist/persist_clone.go | 2 + types/persist/persist_test.go | 7 ++- types/persist/persist_view.go | 5 ++ 10 files changed, 220 insertions(+), 4 deletions(-) diff --git a/client/tailscale/localclient.go b/client/tailscale/localclient.go index e705a9638..3042f7f25 100644 --- a/client/tailscale/localclient.go +++ b/client/tailscale/localclient.go @@ -853,6 +853,21 @@ func (lc *LocalClient) NetworkLockLog(ctx context.Context, maxEntries int) ([]ip return decodeJSON[[]ipnstate.NetworkLockUpdate](body) } +// NetworkLockForceLocalDisable forcibly shuts down network lock on this node. +func (lc *LocalClient) NetworkLockForceLocalDisable(ctx context.Context) error { + // This endpoint expects an empty JSON stanza as the payload. + var b bytes.Buffer + if err := json.NewEncoder(&b).Encode(struct{}{}); err != nil { + return err + } + + if _, err := lc.send(ctx, "POST", "/localapi/v0/tka/force-local-disable", 200, &b); err != nil { + return fmt.Errorf("error: %w", err) + } + return nil +} + + // SetServeConfig sets or replaces the serving settings. // If config is nil, settings are cleared and serving is disabled. func (lc *LocalClient) SetServeConfig(ctx context.Context, config *ipn.ServeConfig) error { diff --git a/cmd/tailscale/cli/network-lock.go b/cmd/tailscale/cli/network-lock.go index 26a5863fe..8cc6944aa 100644 --- a/cmd/tailscale/cli/network-lock.go +++ b/cmd/tailscale/cli/network-lock.go @@ -37,6 +37,7 @@ nlDisableCmd, nlDisablementKDFCmd, nlLogCmd, + nlLocalDisableCmd, }, Exec: runNetworkLockStatus, } @@ -348,6 +349,17 @@ func runNetworkLockDisable(ctx context.Context, args []string) error { return localClient.NetworkLockDisable(ctx, secrets[0]) } +var nlLocalDisableCmd = &ffcli.Command{ + Name: "local-disable", + ShortUsage: "local-disable", + ShortHelp: "Disables the currently-active tailnet lock for this node", + Exec: runNetworkLockLocalDisable, +} + +func runNetworkLockLocalDisable(ctx context.Context, args []string) error { + return localClient.NetworkLockForceLocalDisable(ctx) +} + var nlDisablementKDFCmd = &ffcli.Command{ Name: "disablement-kdf", ShortUsage: "disablement-kdf ", diff --git a/ipn/ipnlocal/network-lock.go b/ipn/ipnlocal/network-lock.go index 8346ed7cf..36b71547c 100644 --- a/ipn/ipnlocal/network-lock.go +++ b/ipn/ipnlocal/network-lock.go @@ -25,6 +25,7 @@ "tailscale.com/tka" "tailscale.com/types/key" "tailscale.com/types/netmap" + "tailscale.com/types/persist" "tailscale.com/types/tkatype" "tailscale.com/util/mak" ) @@ -134,7 +135,7 @@ func (b *LocalBackend) tkaSyncIfNeeded(nm *netmap.NetworkMap, prefs ipn.PrefsVie } if wantEnabled && !isEnabled { - if err := b.tkaBootstrapFromGenesisLocked(bs.GenesisAUM); err != nil { + if err := b.tkaBootstrapFromGenesisLocked(bs.GenesisAUM, prefs.Persist()); err != nil { return fmt.Errorf("bootstrap: %w", err) } isEnabled = true @@ -278,7 +279,7 @@ func (b *LocalBackend) chonkPathLocked() string { // tailnet key authority, based on the given genesis AUM. // // b.mu must be held. -func (b *LocalBackend) tkaBootstrapFromGenesisLocked(g tkatype.MarshaledAUM) error { +func (b *LocalBackend) tkaBootstrapFromGenesisLocked(g tkatype.MarshaledAUM, persist *persist.Persist) error { if err := b.CanSupportNetworkLock(); err != nil { return err } @@ -288,6 +289,19 @@ func (b *LocalBackend) tkaBootstrapFromGenesisLocked(g tkatype.MarshaledAUM) err return fmt.Errorf("reading genesis: %v", err) } + if persist != nil && len(persist.DisallowedTKAStateIDs) > 0 { + if genesis.State == nil { + return errors.New("invalid genesis: missing State") + } + bootstrapStateID := fmt.Sprintf("%d:%d", genesis.State.StateID1, genesis.State.StateID2) + + for _, stateID := range persist.DisallowedTKAStateIDs { + if stateID == bootstrapStateID { + return fmt.Errorf("TKA with stateID of %q is disallowed on this node", stateID) + } + } + } + chonkDir := b.chonkPathLocked() if err := os.Mkdir(filepath.Dir(chonkDir), 0755); err != nil && !os.IsExist(err) { return fmt.Errorf("creating chonk root dir: %v", err) @@ -495,6 +509,31 @@ func (b *LocalBackend) NetworkLockKeyTrustedForTest(keyID tkatype.KeyID) bool { return b.tka.authority.KeyTrusted(keyID) } +// NetworkLockForceLocalDisable shuts down TKA locally, and denylists the current +// TKA from being initialized locally in future. +func (b *LocalBackend) NetworkLockForceLocalDisable() error { + b.mu.Lock() + defer b.mu.Unlock() + if b.tka == nil { + return errNetworkLockNotActive + } + + id1, id2 := b.tka.authority.StateIDs() + stateID := fmt.Sprintf("%d:%d", id1, id2) + + newPrefs := b.pm.CurrentPrefs().AsStruct().Clone() // .Persist should always be initialized here. + newPrefs.Persist.DisallowedTKAStateIDs = append(newPrefs.Persist.DisallowedTKAStateIDs, stateID) + if err := b.pm.SetPrefs(newPrefs.View()); err != nil { + return fmt.Errorf("saving prefs: %w", err) + } + + if err := os.RemoveAll(b.chonkPathLocked()); err != nil { + return fmt.Errorf("deleting TKA state: %w", err) + } + b.tka = nil + return nil +} + // NetworkLockSign signs the given node-key and submits it to the control plane. // rotationPublic, if specified, must be an ed25519 public key. func (b *LocalBackend) NetworkLockSign(nodeKey key.NodePublic, rotationPublic []byte) error { diff --git a/ipn/ipnlocal/network-lock_test.go b/ipn/ipnlocal/network-lock_test.go index 1044e2d6d..d508f8ee7 100644 --- a/ipn/ipnlocal/network-lock_test.go +++ b/ipn/ipnlocal/network-lock_test.go @@ -778,3 +778,103 @@ func TestTKASign(t *testing.T) { t.Errorf("NetworkLockSign() failed: %v", err) } } + +func TestTKAForceDisable(t *testing.T) { + envknob.Setenv("TAILSCALE_USE_WIP_CODE", "1") + defer envknob.Setenv("TAILSCALE_USE_WIP_CODE", "") + 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 := must.Get(newProfileManager(new(mem.Store), t.Logf, "")) + must.Do(pm.SetPrefs((&ipn.Prefs{ + Persist: &persist.Persist{ + PrivateNodeKey: nodePriv, + NetworkLockKey: nlPriv, + }, + }).View())) + + temp := t.TempDir() + tkaPath := filepath.Join(temp, "tka-profile", string(pm.CurrentProfile().ID)) + os.Mkdir(tkaPath, 0755) + chonk, err := tka.ChonkDir(tkaPath) + if err != nil { + t.Fatal(err) + } + authority, genesis, 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) + } + if body.Version != tailcfg.CurrentCapabilityVersion { + t.Errorf("bootstrap 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) + out := tailcfg.TKABootstrapResponse{ + GenesisAUM: genesis.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() + + cc := fakeControlClient(t, client) + b := LocalBackend{ + varRoot: temp, + cc: cc, + ccAuto: cc, + logf: t.Logf, + tka: &tkaState{ + authority: authority, + storage: chonk, + }, + pm: pm, + store: pm.Store(), + } + + 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-initalized") + } +} diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index ec87b25cc..f11217666 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -89,6 +89,7 @@ "tka/sign": (*Handler).serveTKASign, "tka/status": (*Handler).serveTKAStatus, "tka/disable": (*Handler).serveTKADisable, + "tka/force-local-disable": (*Handler).serveTKALocalDisable, "upload-client-metrics": (*Handler).serveUploadClientMetrics, "watch-ipn-bus": (*Handler).serveWatchIPNBus, "whois": (*Handler).serveWhoIs, @@ -1243,6 +1244,30 @@ func (h *Handler) serveTKADisable(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) } +func (h *Handler) serveTKALocalDisable(w http.ResponseWriter, r *http.Request) { + if !h.PermitWrite { + http.Error(w, "network-lock modify access denied", http.StatusForbidden) + return + } + if r.Method != http.MethodPost { + http.Error(w, "use POST", http.StatusMethodNotAllowed) + return + } + + // Require a JSON stanza for the body as an additional CSRF protection. + var req struct{} + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid JSON body", 400) + return + } + + if err := h.b.NetworkLockForceLocalDisable(); err != nil { + http.Error(w, "network-lock local disable failed: "+err.Error(), http.StatusBadRequest) + return + } + w.WriteHeader(200) +} + func (h *Handler) serveTKALog(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "use GET", http.StatusMethodNotAllowed) diff --git a/tka/tka.go b/tka/tka.go index 338912872..0454b42f6 100644 --- a/tka/tka.go +++ b/tka/tka.go @@ -714,3 +714,8 @@ func (a *Authority) Keys() []Key { } return out } + +// StateIDs returns the stateIDs for this tailnet key authority. +func (a *Authority) StateIDs() (uint64, uint64) { + return a.state.StateID1, a.state.StateID2 +} diff --git a/types/persist/persist.go b/types/persist/persist.go index b128c5f70..38f18baff 100644 --- a/types/persist/persist.go +++ b/types/persist/persist.go @@ -7,6 +7,7 @@ import ( "fmt" + "reflect" "tailscale.com/tailcfg" "tailscale.com/types/key" @@ -39,6 +40,12 @@ type Persist struct { UserProfile tailcfg.UserProfile NetworkLockKey key.NLPrivate NodeID tailcfg.StableNodeID + + // DisallowedTKAStateIDs stores the tka.State.StateID values which + // this node will not operate network lock on. This is used to + // prevent bootstrapping TKA onto a key authority which was forcibly + // disabled. + DisallowedTKAStateIDs []string } // PublicNodeKey returns the public key for the node key. @@ -70,7 +77,8 @@ func (p *Persist) Equals(p2 *Persist) bool { p.LoginName == p2.LoginName && p.UserProfile == p2.UserProfile && p.NetworkLockKey.Equal(p2.NetworkLockKey) && - p.NodeID == p2.NodeID + p.NodeID == p2.NodeID && + reflect.DeepEqual(p.DisallowedTKAStateIDs, p2.DisallowedTKAStateIDs) } func (p *Persist) Pretty() string { diff --git a/types/persist/persist_clone.go b/types/persist/persist_clone.go index aeb40afe5..82db9c52b 100644 --- a/types/persist/persist_clone.go +++ b/types/persist/persist_clone.go @@ -20,6 +20,7 @@ func (src *Persist) Clone() *Persist { } dst := new(Persist) *dst = *src + dst.DisallowedTKAStateIDs = append(src.DisallowedTKAStateIDs[:0:0], src.DisallowedTKAStateIDs...) return dst } @@ -34,4 +35,5 @@ func (src *Persist) Clone() *Persist { UserProfile tailcfg.UserProfile NetworkLockKey key.NLPrivate NodeID tailcfg.StableNodeID + DisallowedTKAStateIDs []string }{}) diff --git a/types/persist/persist_test.go b/types/persist/persist_test.go index 7651fe02a..97d22daf1 100644 --- a/types/persist/persist_test.go +++ b/types/persist/persist_test.go @@ -22,7 +22,7 @@ func fieldsOf(t reflect.Type) (fields []string) { } func TestPersistEqual(t *testing.T) { - persistHandles := []string{"LegacyFrontendPrivateMachineKey", "PrivateNodeKey", "OldPrivateNodeKey", "Provider", "LoginName", "UserProfile", "NetworkLockKey", "NodeID"} + persistHandles := []string{"LegacyFrontendPrivateMachineKey", "PrivateNodeKey", "OldPrivateNodeKey", "Provider", "LoginName", "UserProfile", "NetworkLockKey", "NodeID", "DisallowedTKAStateIDs"} if have := fieldsOf(reflect.TypeOf(Persist{})); !reflect.DeepEqual(have, persistHandles) { t.Errorf("Persist.Equal check might be out of sync\nfields: %q\nhandled: %q\n", have, persistHandles) @@ -133,6 +133,11 @@ func TestPersistEqual(t *testing.T) { &Persist{NodeID: "abc"}, false, }, + { + &Persist{DisallowedTKAStateIDs: nil}, + &Persist{DisallowedTKAStateIDs: []string{"0:0"}}, + false, + }, } for i, test := range tests { if got := test.a.Equals(test.b); got != test.want { diff --git a/types/persist/persist_view.go b/types/persist/persist_view.go index b961c07c9..15355abf4 100644 --- a/types/persist/persist_view.go +++ b/types/persist/persist_view.go @@ -13,6 +13,7 @@ "tailscale.com/tailcfg" "tailscale.com/types/key" "tailscale.com/types/structs" + "tailscale.com/types/views" ) //go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=Persist @@ -72,6 +73,9 @@ func (v PersistView) LoginName() string { return v.ж.LoginName func (v PersistView) UserProfile() tailcfg.UserProfile { return v.ж.UserProfile } func (v PersistView) NetworkLockKey() key.NLPrivate { return v.ж.NetworkLockKey } func (v PersistView) NodeID() tailcfg.StableNodeID { return v.ж.NodeID } +func (v PersistView) DisallowedTKAStateIDs() views.Slice[string] { + return views.SliceOf(v.ж.DisallowedTKAStateIDs) +} // A compilation failure here means this code must be regenerated, with the command at the top of this file. var _PersistViewNeedsRegeneration = Persist(struct { @@ -84,4 +88,5 @@ func (v PersistView) NodeID() tailcfg.StableNodeID { return v.ж.NodeID } UserProfile tailcfg.UserProfile NetworkLockKey key.NLPrivate NodeID tailcfg.StableNodeID + DisallowedTKAStateIDs []string }{})