diff --git a/client/tailscale/localclient.go b/client/tailscale/localclient.go index 703c2b9c2..1a9a1567c 100644 --- a/client/tailscale/localclient.go +++ b/client/tailscale/localclient.go @@ -36,6 +36,7 @@ import ( "tailscale.com/safesocket" "tailscale.com/tailcfg" "tailscale.com/tka" + "tailscale.com/types/key" ) // defaultLocalClient is the default LocalClient when using the legacy @@ -827,6 +828,25 @@ func (lc *LocalClient) NetworkLockModify(ctx context.Context, addKeys, removeKey return pr, nil } +// NetworkLockSign signs the specified node-key and transmits that signature to the control plane. +// rotationPublic, if specified, must be an ed25519 public key. +func (lc *LocalClient) NetworkLockSign(ctx context.Context, nodeKey key.NodePublic, rotationPublic []byte) error { + var b bytes.Buffer + type signRequest struct { + NodeKey key.NodePublic + RotationPublic []byte + } + + if err := json.NewEncoder(&b).Encode(signRequest{NodeKey: nodeKey, RotationPublic: rotationPublic}); err != nil { + return err + } + + if _, err := lc.send(ctx, "POST", "/localapi/v0/tka/sign", 200, &b); err != nil { + return fmt.Errorf("error: %w", err) + } + return nil +} + // tailscaledConnectHint gives a little thing about why tailscaled (or // platform equivalent) is not answering localapi connections. // diff --git a/cmd/tailscale/cli/network-lock.go b/cmd/tailscale/cli/network-lock.go index 224d67b88..cb84dc33d 100644 --- a/cmd/tailscale/cli/network-lock.go +++ b/cmd/tailscale/cli/network-lock.go @@ -26,6 +26,7 @@ var netlockCmd = &ffcli.Command{ nlStatusCmd, nlAddCmd, nlRemoveCmd, + nlSignCmd, }, Exec: runNetworkLockStatus, } @@ -163,3 +164,27 @@ func runNetworkLockModify(ctx context.Context, addArgs, removeArgs []string) err fmt.Printf("Status: %+v\n\n", status) return nil } + +var nlSignCmd = &ffcli.Command{ + Name: "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) + } + + return localClient.NetworkLockSign(ctx, nodeKey, nil) + default: + return errors.New("expected a single node-key as only argument") + } +} diff --git a/ipn/ipnlocal/network-lock.go b/ipn/ipnlocal/network-lock.go index f5854d18f..a4a53b6eb 100644 --- a/ipn/ipnlocal/network-lock.go +++ b/ipn/ipnlocal/network-lock.go @@ -430,6 +430,47 @@ func (b *LocalBackend) NetworkLockKeyTrustedForTest(keyID tkatype.KeyID) bool { return b.tka.authority.KeyTrusted(keyID) } +// 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 { + ourNodeKey, sig, err := func(nodeKey key.NodePublic, rotationPublic []byte) (key.NodePublic, tka.NodeKeySignature, error) { + b.mu.Lock() + defer b.mu.Unlock() + + if b.tka == nil { + return key.NodePublic{}, tka.NodeKeySignature{}, errNetworkLockNotActive + } + if !b.tka.authority.KeyTrusted(b.nlPrivKey.KeyID()) { + return key.NodePublic{}, tka.NodeKeySignature{}, errors.New("this node is not trusted by network lock") + } + + p, err := nodeKey.MarshalBinary() + if err != nil { + return key.NodePublic{}, tka.NodeKeySignature{}, err + } + sig := tka.NodeKeySignature{ + SigKind: tka.SigDirect, + KeyID: b.nlPrivKey.KeyID(), + Pubkey: p, + WrappingPubkey: rotationPublic, + } + sig.Signature, err = b.nlPrivKey.SignNKS(sig.SigHash()) + if err != nil { + return key.NodePublic{}, tka.NodeKeySignature{}, fmt.Errorf("signature failed: %w", err) + } + return b.prefs.Persist().PublicNodeKey(), sig, nil + }(nodeKey, rotationPublic) + if err != nil { + return err + } + + b.logf("Generated network-lock signature for %v, submitting to control plane", nodeKey) + if _, err := b.tkaSubmitSignature(ourNodeKey, sig.Serialize()); err != nil { + return err + } + return nil +} + // NetworkLockModify adds and/or removes keys in the tailnet's key authority. func (b *LocalBackend) NetworkLockModify(addKeys, removeKeys []tka.Key) (err error) { defer func() { @@ -817,3 +858,39 @@ func (b *LocalBackend) tkaDoDisablement(ourNodeKey key.NodePublic, head tka.AUMH return a, nil } + +func (b *LocalBackend) tkaSubmitSignature(ourNodeKey key.NodePublic, sig tkatype.MarshaledSignature) (*tailcfg.TKASubmitSignatureResponse, error) { + var req bytes.Buffer + if err := json.NewEncoder(&req).Encode(tailcfg.TKASubmitSignatureRequest{ + Version: tailcfg.CurrentCapabilityVersion, + NodeKey: ourNodeKey, + Signature: sig, + }); err != nil { + return nil, fmt.Errorf("encoding request: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + req2, err := http.NewRequestWithContext(ctx, "GET", "https://unused/machine/tka/sign", &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.TKASubmitSignatureResponse) + err = json.NewDecoder(&io.LimitedReader{R: res.Body, N: 1024 * 1024}).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 index 0efe3a312..8f027aa46 100644 --- a/ipn/ipnlocal/network-lock_test.go +++ b/ipn/ipnlocal/network-lock_test.go @@ -629,3 +629,83 @@ func TestTKADisable(t *testing.T) { t.Errorf("NetworkLockDisable() failed: %v", err) } } + +func TestTKASign(t *testing.T) { + envknob.Setenv("TAILSCALE_USE_WIP_CODE", "1") + temp := t.TempDir() + os.Mkdir(filepath.Join(temp, "tka"), 0755) + nodePriv := key.NewNode() + toSign := 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} + 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/sign": + body := new(tailcfg.TKASubmitSignatureRequest) + 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()) + } + + var sig tka.NodeKeySignature + if err := sig.Unserialize(body.Signature); err != nil { + t.Fatalf("malformed signature: %v", err) + } + + if err := authority.NodeKeyAuthorized(toSign.Public(), body.Signature); err != nil { + t.Errorf("signature does not verify: %v", err) + } + + w.WriteHeader(200) + if err := json.NewEncoder(w).Encode(tailcfg.TKASubmitSignatureResponse{}); 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, + }, + prefs: (&ipn.Prefs{ + Persist: &persist.Persist{PrivateNodeKey: nodePriv}, + }).View(), + nlPrivKey: nlPriv, + } + + if err := b.NetworkLockSign(toSign.Public(), nil); err != nil { + t.Errorf("NetworkLockSign() failed: %v", err) + } +} diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index 57ca7048c..8895770c8 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -34,6 +34,7 @@ import ( "tailscale.com/net/netutil" "tailscale.com/tailcfg" "tailscale.com/tka" + "tailscale.com/types/key" "tailscale.com/types/logger" "tailscale.com/util/clientmetric" "tailscale.com/util/mak" @@ -75,6 +76,7 @@ var handler = map[string]localAPIHandler{ "status": (*Handler).serveStatus, "tka/init": (*Handler).serveTKAInit, "tka/modify": (*Handler).serveTKAModify, + "tka/sign": (*Handler).serveTKASign, "tka/status": (*Handler).serveTKAStatus, "upload-client-metrics": (*Handler).serveUploadClientMetrics, "whois": (*Handler).serveWhoIs, @@ -921,6 +923,34 @@ func (h *Handler) serveTKAStatus(w http.ResponseWriter, r *http.Request) { w.Write(j) } +func (h *Handler) serveTKASign(w http.ResponseWriter, r *http.Request) { + if !h.PermitRead { + http.Error(w, "lock status access denied", http.StatusForbidden) + return + } + if r.Method != http.MethodPost { + http.Error(w, "use POST", http.StatusMethodNotAllowed) + return + } + + type signRequest struct { + NodeKey key.NodePublic + RotationPublic []byte + } + var req signRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid JSON body", http.StatusBadRequest) + return + } + + if err := h.b.NetworkLockSign(req.NodeKey, req.RotationPublic); err != nil { + http.Error(w, "signing failed: "+err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + func (h *Handler) serveTKAInit(w http.ResponseWriter, r *http.Request) { if !h.PermitWrite { http.Error(w, "lock init access denied", http.StatusForbidden) diff --git a/tailcfg/tka.go b/tailcfg/tka.go index c24020a29..c1cec6a0f 100644 --- a/tailcfg/tka.go +++ b/tailcfg/tka.go @@ -214,3 +214,23 @@ type TKADisableRequest struct { type TKADisableResponse struct { // Nothing. (yet?) } + +// TKASubmitSignatureRequest transmits a node-key signature to the control plane. +// +// This is the request schema for a /tka/sign noise RPC. +type TKASubmitSignatureRequest struct { + // Version is the client's capabilities. + Version CapabilityVersion + + // NodeKey is the client's current node key. The node-key which + // is being signed is embedded in Signature. + NodeKey key.NodePublic + + // Signature encodes the node-key signature being submitted. + Signature tkatype.MarshaledSignature +} + +// TKASubmitSignatureResponse is the JSON response from a /tka/sign RPC. +type TKASubmitSignatureResponse struct { + // Nothing. (yet?) +}