diff --git a/client/tailscale/localclient.go b/client/tailscale/localclient.go index e70f6b15a..a9e6edc28 100644 --- a/client/tailscale/localclient.go +++ b/client/tailscale/localclient.go @@ -827,6 +827,14 @@ func (lc *LocalClient) SetServeConfig(ctx context.Context, config *ipn.ServeConf return nil } +// NetworkLockDisable shuts down network-lock across the tailnet. +func (lc *LocalClient) NetworkLockDisable(ctx context.Context, secret []byte) error { + if _, err := lc.send(ctx, "POST", "/localapi/v0/tka/disable", 200, bytes.NewReader(secret)); err != nil { + return fmt.Errorf("error: %w", err) + } + return nil +} + // GetServeConfig return the current serve config. // // If the serve config is empty, it returns (nil, nil). diff --git a/cmd/tailscale/cli/cli_test.go b/cmd/tailscale/cli/cli_test.go index 57cd9303a..c5eab17b9 100644 --- a/cmd/tailscale/cli/cli_test.go +++ b/cmd/tailscale/cli/cli_test.go @@ -19,6 +19,7 @@ import ( "tailscale.com/health/healthmsg" "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" + "tailscale.com/tka" "tailscale.com/tstest" "tailscale.com/types/persist" "tailscale.com/types/preftype" @@ -1156,3 +1157,69 @@ func TestUpWorthWarning(t *testing.T) { t.Errorf("want false for other misc errors") } } + +func TestParseNLArgs(t *testing.T) { + tcs := []struct { + name string + input []string + parseKeys bool + parseDisablements bool + + wantErr error + wantKeys []tka.Key + wantDisablements [][]byte + }{ + { + name: "empty", + input: nil, + parseKeys: true, + parseDisablements: true, + }, + { + name: "key no votes", + input: []string{"nlpub:" + strings.Repeat("00", 32)}, + parseKeys: true, + wantKeys: []tka.Key{{Kind: tka.Key25519, Votes: 1, Public: bytes.Repeat([]byte{0}, 32)}}, + }, + { + name: "key with votes", + input: []string{"nlpub:" + strings.Repeat("01", 32) + "?5"}, + parseKeys: true, + wantKeys: []tka.Key{{Kind: tka.Key25519, Votes: 5, Public: bytes.Repeat([]byte{1}, 32)}}, + }, + { + name: "disablements", + input: []string{"disablement:" + strings.Repeat("02", 32), "disablement-secret:" + strings.Repeat("03", 32)}, + parseDisablements: true, + wantDisablements: [][]byte{bytes.Repeat([]byte{2}, 32), bytes.Repeat([]byte{3}, 32)}, + }, + { + name: "disablements not allowed", + input: []string{"disablement:" + strings.Repeat("02", 32)}, + parseKeys: true, + wantErr: fmt.Errorf("parsing key 1: key hex string doesn't have expected type prefix nlpub:"), + }, + { + name: "keys not allowed", + input: []string{"nlpub:" + strings.Repeat("02", 32)}, + parseDisablements: true, + wantErr: fmt.Errorf("parsing argument 1: expected value with \"disablement:\" or \"disablement-secret:\" prefix, got %q", "nlpub:0202020202020202020202020202020202020202020202020202020202020202"), + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + keys, disablements, err := parseNLArgs(tc.input, tc.parseKeys, tc.parseDisablements) + if !reflect.DeepEqual(err, tc.wantErr) { + t.Fatalf("parseNLArgs(%v).err = %v, want %v", tc.input, err, tc.wantErr) + } + + if !reflect.DeepEqual(keys, tc.wantKeys) { + t.Errorf("keys = %v, want %v", keys, tc.wantKeys) + } + if !reflect.DeepEqual(disablements, tc.wantDisablements) { + t.Errorf("disablements = %v, want %v", disablements, tc.wantDisablements) + } + }) + } +} diff --git a/cmd/tailscale/cli/network-lock.go b/cmd/tailscale/cli/network-lock.go index fc1c66499..e70491477 100644 --- a/cmd/tailscale/cli/network-lock.go +++ b/cmd/tailscale/cli/network-lock.go @@ -5,8 +5,8 @@ package cli import ( - "bytes" "context" + "encoding/hex" "errors" "fmt" "strconv" @@ -27,6 +27,8 @@ var netlockCmd = &ffcli.Command{ nlAddCmd, nlRemoveCmd, nlSignCmd, + nlDisableCmd, + nlDisablementKDFCmd, }, Exec: runNetworkLockStatus, } @@ -47,15 +49,12 @@ func runNetworkLockInit(ctx context.Context, args []string) error { return errors.New("network-lock is already enabled") } - // Parse the set of initially-trusted keys. - keys, err := parseNLKeyArgs(args) + // Parse initially-trusted keys & disablement values. + keys, disablementValues, err := parseNLArgs(args, true, true) if err != nil { return err } - // TODO(tom): Implement specification of disablement values from the command line. - disablementValues := [][]byte{bytes.Repeat([]byte{0xa5}, 32)} - status, err := localClient.NetworkLockInit(ctx, keys, disablementValues) if err != nil { return err @@ -143,17 +142,34 @@ var nlRemoveCmd = &ffcli.Command{ }, } -// parseNLKeyArgs converts a slice of strings into a slice of tka.Key. The keys -// should be specified using their key.NLPublic.MarshalText representation with -// an optional '?' suffix. If any of the keys encounters an error, a nil -// slice is returned along with an appropriate error. -func parseNLKeyArgs(args []string) ([]tka.Key, error) { - var keys []tka.Key +// parseNLArgs parses a slice of strings into slices of tka.Key & disablement +// values/secrets. +// The keys encoded in args should be specified using their key.NLPublic.MarshalText +// representation with an optional '?' suffix. +// Disablement values or secrets must be encoded in hex with a prefix of 'disablement:' or +// 'disablement-secret:'. +// +// If any element could not be parsed, +// a nil slice is returned along with an appropriate error. +func parseNLArgs(args []string, parseKeys, parseDisablements bool) (keys []tka.Key, disablements [][]byte, err error) { for i, a := range args { + if parseDisablements && (strings.HasPrefix(a, "disablement:") || strings.HasPrefix(a, "disablement-secret:")) { + b, err := hex.DecodeString(a[strings.Index(a, ":")+1:]) + if err != nil { + return nil, nil, fmt.Errorf("parsing disablement %d: %v", i+1, err) + } + disablements = append(disablements, b) + continue + } + + if !parseKeys { + return nil, nil, fmt.Errorf("parsing argument %d: expected value with \"disablement:\" or \"disablement-secret:\" prefix, got %q", i+1, a) + } + var nlpk key.NLPublic spl := strings.SplitN(a, "?", 2) if err := nlpk.UnmarshalText([]byte(spl[0])); err != nil { - return nil, fmt.Errorf("parsing key %d: %v", i+1, err) + return nil, nil, fmt.Errorf("parsing key %d: %v", i+1, err) } k := tka.Key{ @@ -164,13 +180,13 @@ func parseNLKeyArgs(args []string) ([]tka.Key, error) { if len(spl) > 1 { votes, err := strconv.Atoi(spl[1]) if err != nil { - return nil, fmt.Errorf("parsing key %d votes: %v", i+1, err) + return nil, nil, fmt.Errorf("parsing key %d votes: %v", i+1, err) } k.Votes = uint(votes) } keys = append(keys, k) } - return keys, nil + return keys, disablements, nil } func runNetworkLockModify(ctx context.Context, addArgs, removeArgs []string) error { @@ -182,11 +198,11 @@ func runNetworkLockModify(ctx context.Context, addArgs, removeArgs []string) err return errors.New("network-lock is not enabled") } - addKeys, err := parseNLKeyArgs(addArgs) + addKeys, _, err := parseNLArgs(addArgs, true, false) if err != nil { return err } - removeKeys, err := parseNLKeyArgs(removeArgs) + removeKeys, _, err := parseNLArgs(removeArgs, true, false) if err != nil { return err } @@ -202,24 +218,65 @@ func runNetworkLockModify(ctx context.Context, addArgs, removeArgs []string) err var nlSignCmd = &ffcli.Command{ Name: "sign", - ShortUsage: "sign ", + ShortUsage: "sign []", ShortHelp: "Signs a node-key and transmits that signature to the control plane", Exec: runNetworkLockSign, } -// TODO(tom): Implement specifying the rotation key for the signature. func runNetworkLockSign(ctx context.Context, args []string) error { - switch len(args) { - case 0: - return errors.New("expected node-key as second argument") - case 1: - var nodeKey key.NodePublic - if err := nodeKey.UnmarshalText([]byte(args[0])); err != nil { - return fmt.Errorf("decoding node-key: %w", err) + var ( + nodeKey key.NodePublic + rotationKey key.NLPublic + ) + + if len(args) == 0 || len(args) > 2 { + return errors.New("usage: lock sign []") + } + if err := nodeKey.UnmarshalText([]byte(args[0])); err != nil { + return fmt.Errorf("decoding node-key: %w", err) + } + if len(args) > 1 { + if err := rotationKey.UnmarshalText([]byte(args[1])); err != nil { + return fmt.Errorf("decoding rotation-key: %w", err) } + } - return localClient.NetworkLockSign(ctx, nodeKey, nil) - default: - return errors.New("expected a single node-key as only argument") + return localClient.NetworkLockSign(ctx, nodeKey, []byte(rotationKey.Verifier())) +} + +var nlDisableCmd = &ffcli.Command{ + Name: "disable", + ShortUsage: "disable ", + ShortHelp: "Consumes a disablement secret to shut down network-lock across the tailnet", + Exec: runNetworkLockDisable, +} + +func runNetworkLockDisable(ctx context.Context, args []string) error { + _, secrets, err := parseNLArgs(args, false, true) + if err != nil { + return err + } + if len(secrets) != 1 { + return errors.New("usage: lock disable ") } + return localClient.NetworkLockDisable(ctx, secrets[0]) +} + +var nlDisablementKDFCmd = &ffcli.Command{ + Name: "disablement-kdf", + ShortUsage: "disablement-kdf ", + ShortHelp: "Computes a disablement value from a disablement secret", + Exec: runNetworkLockDisablementKDF, +} + +func runNetworkLockDisablementKDF(ctx context.Context, args []string) error { + if len(args) != 1 { + return errors.New("usage: lock disablement-kdf ") + } + secret, err := hex.DecodeString(args[0]) + if err != nil { + return err + } + fmt.Printf("disablement:%x\n", tka.DisablementKDF(secret)) + return nil } diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index 28fef37da..a4a695c63 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -13,6 +13,7 @@ import ( "errors" "fmt" "io" + "io/ioutil" "net" "net/http" "net/http/httputil" @@ -80,6 +81,7 @@ var handler = map[string]localAPIHandler{ "tka/modify": (*Handler).serveTKAModify, "tka/sign": (*Handler).serveTKASign, "tka/status": (*Handler).serveTKAStatus, + "tka/disable": (*Handler).serveTKADisable, "upload-client-metrics": (*Handler).serveUploadClientMetrics, "whois": (*Handler).serveWhoIs, } @@ -1073,6 +1075,30 @@ func (h *Handler) serveTKAModify(w http.ResponseWriter, r *http.Request) { w.Write(j) } +func (h *Handler) serveTKADisable(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 + } + + body := io.LimitReader(r.Body, 1024*1024) + secret, err := ioutil.ReadAll(body) + if err != nil { + http.Error(w, "reading secret", 400) + return + } + + if err := h.b.NetworkLockDisable(secret); err != nil { + http.Error(w, "network-lock disable failed: "+err.Error(), http.StatusBadRequest) + return + } + w.WriteHeader(200) +} + func defBool(a string, def bool) bool { if a == "" { return def