From ce994743179f345e68da43e914003733b7990d76 Mon Sep 17 00:00:00 2001 From: Tom DNetto Date: Thu, 2 Mar 2023 13:24:26 -0800 Subject: [PATCH] all: implement preauth-key support with tailnet lock Signed-off-by: Tom DNetto --- client/tailscale/localclient.go | 24 +++++++++++ cmd/tailscale/cli/network-lock.go | 59 ++++++++++++++++++++++++++++ cmd/tailscale/cli/web_test.go | 3 +- control/controlclient/direct.go | 57 ++++++++++++++++++++++++++- control/controlclient/direct_test.go | 40 +++++++++++++++++++ ipn/ipnlocal/network-lock.go | 36 +++++++++++++++++ ipn/localapi/localapi.go | 35 +++++++++++++++++ 7 files changed, 251 insertions(+), 3 deletions(-) diff --git a/client/tailscale/localclient.go b/client/tailscale/localclient.go index 90b4e9688..e35846e61 100644 --- a/client/tailscale/localclient.go +++ b/client/tailscale/localclient.go @@ -850,6 +850,30 @@ func (lc *LocalClient) NetworkLockInit(ctx context.Context, keys []tka.Key, disa return decodeJSON[*ipnstate.NetworkLockStatus](body) } +// NetworkLockWrapPreauthKey wraps a pre-auth key with information to +// enable unattended bringup in the locked tailnet. +func (lc *LocalClient) NetworkLockWrapPreauthKey(ctx context.Context, preauthKey string, tkaKey key.NLPrivate) (string, error) { + encodedPrivate, err := tkaKey.MarshalText() + if err != nil { + return "", err + } + + var b bytes.Buffer + type wrapRequest struct { + TSKey string + TKAKey string // key.NLPrivate.MarshalText + } + if err := json.NewEncoder(&b).Encode(wrapRequest{TSKey: preauthKey, TKAKey: string(encodedPrivate)}); err != nil { + return "", err + } + + body, err := lc.send(ctx, "POST", "/localapi/v0/tka/wrap-preauth-key", 200, &b) + if err != nil { + return "", fmt.Errorf("error: %w", err) + } + return string(body), nil +} + // NetworkLockModify adds and/or removes key(s) to the tailnet key authority. func (lc *LocalClient) NetworkLockModify(ctx context.Context, addKeys, removeKeys []tka.Key) error { var b bytes.Buffer diff --git a/cmd/tailscale/cli/network-lock.go b/cmd/tailscale/cli/network-lock.go index 943929eec..47ba6a005 100644 --- a/cmd/tailscale/cli/network-lock.go +++ b/cmd/tailscale/cli/network-lock.go @@ -15,6 +15,7 @@ import ( "os" "strconv" "strings" + "time" "github.com/mattn/go-colorable" "github.com/mattn/go-isatty" @@ -39,6 +40,7 @@ var netlockCmd = &ffcli.Command{ nlDisablementKDFCmd, nlLogCmd, nlLocalDisableCmd, + nlTskeyWrapCmd, }, Exec: runNetworkLockStatus, } @@ -622,3 +624,60 @@ func runNetworkLockLog(ctx context.Context, args []string) error { } return nil } + +var nlTskeyWrapCmd = &ffcli.Command{ + Name: "tskey-wrap", + ShortUsage: "tskey-wrap ", + ShortHelp: "Modifies a pre-auth key from the admin panel to work with tailnet lock", + LongHelp: "Modifies a pre-auth key from the admin panel to work with tailnet lock", + Exec: runTskeyWrapCmd, +} + +func runTskeyWrapCmd(ctx context.Context, args []string) error { + if len(args) != 1 { + return errors.New("usage: lock tskey-wrap ") + } + if strings.Contains(args[0], "--TL") { + return errors.New("Error: provided key was already wrapped") + } + + st, err := localClient.StatusWithoutPeers(ctx) + if err != nil { + return fixTailscaledConnectError(err) + } + + // Generate a separate tailnet-lock key just for the credential signature. + // We use the free-form meta strings to mark a little bit of metadata about this + // key. + priv := key.NewNLPrivate() + m := map[string]string{ + "purpose": "pre-auth key", + "wrapper_stableid": string(st.Self.ID), + "wrapper_createtime": fmt.Sprint(time.Now().Unix()), + } + if strings.HasPrefix(args[0], "tskey-auth-") && strings.Index(args[0][len("tskey-auth-"):], "-") > 0 { + // We don't want to accidentally embed the nonce part of the authkey in + // the event the format changes. As such, we make sure its in the format we + // expect (tskey-auth--nonce) before we parse + // out and embed the stableID. + s := strings.TrimPrefix(args[0], "tskey-auth-") + m["authkey_stableid"] = s[:strings.Index(s, "-")] + } + k := tka.Key{ + Kind: tka.Key25519, + Public: priv.Public().Verifier(), + Votes: 1, + Meta: m, + } + + wrapped, err := localClient.NetworkLockWrapPreauthKey(ctx, args[0], priv) + if err != nil { + return fmt.Errorf("wrapping failed: %w", err) + } + if err := localClient.NetworkLockModify(ctx, []tka.Key{k}, nil); err != nil { + return fmt.Errorf("add key failed: %w", err) + } + + fmt.Println(wrapped) + return nil +} diff --git a/cmd/tailscale/cli/web_test.go b/cmd/tailscale/cli/web_test.go index 138580833..8cf19daf2 100644 --- a/cmd/tailscale/cli/web_test.go +++ b/cmd/tailscale/cli/web_test.go @@ -86,10 +86,9 @@ func TestQnapAuthnURL(t *testing.T) { }, { name: "err != nil", - in: "http://192.168.0.%31/", + in: "http://192.168.0.%31/", want: "http://localhost/cgi-bin/authLogin.cgi?qtoken=token", }, - } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/control/controlclient/direct.go b/control/controlclient/direct.go index aa0e84293..d05145c1f 100644 --- a/control/controlclient/direct.go +++ b/control/controlclient/direct.go @@ -7,6 +7,8 @@ import ( "bufio" "bytes" "context" + "crypto/ed25519" + "encoding/base64" "encoding/binary" "encoding/json" "errors" @@ -424,7 +426,7 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new tryingNewKey := c.tryingNewKey serverKey := c.serverKey serverNoiseKey := c.serverNoiseKey - authKey := c.authKey + authKey, isWrapped, wrappedSig, wrappedKey := decodeWrappedAuthkey(c.authKey, c.logf) hi := c.hostInfoLocked() backendLogID := hi.BackendLogID expired := c.expiry != nil && !c.expiry.IsZero() && c.expiry.Before(c.timeNow()) @@ -510,6 +512,22 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new if nodeKeySignature, err = resignNKS(persist.NetworkLockKey, tryingNewKey.Public(), opt.OldNodeKeySignature); err != nil { c.logf("Failed re-signing node-key signature: %v", err) } + } else if isWrapped { + // We were given a wrapped pre-auth key, which means that in addition + // to being a regular pre-auth key there was a suffix with information to + // generate a tailnet-lock signature. + nk, err := tryingNewKey.Public().MarshalBinary() + if err != nil { + return false, "", nil, fmt.Errorf("marshalling node-key: %w", err) + } + sig := &tka.NodeKeySignature{ + SigKind: tka.SigRotation, + Pubkey: nk, + Nested: wrappedSig, + } + sigHash := sig.SigHash() + sig.Signature = ed25519.Sign(wrappedKey, sigHash[:]) + nodeKeySignature = sig.Serialize() } if backendLogID == "" { @@ -1713,6 +1731,43 @@ func (c *Direct) ReportHealthChange(sys health.Subsystem, sysErr error) { res.Body.Close() } +// decodeWrappedAuthkey separates wrapping information from an authkey, if any. +// In all cases the authkey is returned, sans wrapping information if any. +// +// If the authkey is wrapped, isWrapped returns true, along with the wrapping signature +// and private key. +func decodeWrappedAuthkey(key string, logf logger.Logf) (authKey string, isWrapped bool, sig *tka.NodeKeySignature, priv ed25519.PrivateKey) { + authKey, suffix, found := strings.Cut(key, "--TL") + if !found { + return key, false, nil, nil + } + sigBytes, privBytes, found := strings.Cut(suffix, "-") + if !found { + logf("decoding wrapped auth-key: did not find delimiter") + return key, false, nil, nil + } + + rawSig, err := base64.RawStdEncoding.DecodeString(sigBytes) + if err != nil { + logf("decoding wrapped auth-key: signature decode: %v", err) + return key, false, nil, nil + } + rawPriv, err := base64.RawStdEncoding.DecodeString(privBytes) + if err != nil { + logf("decoding wrapped auth-key: priv decode: %v", err) + return key, false, nil, nil + } + + sig = new(tka.NodeKeySignature) + if err := sig.Unserialize([]byte(rawSig)); err != nil { + logf("decoding wrapped auth-key: signature: %v", err) + return key, false, nil, nil + } + priv = ed25519.PrivateKey(rawPriv) + + return authKey, true, sig, priv +} + var ( metricMapRequestsActive = clientmetric.NewGauge("controlclient_map_requests_active") diff --git a/control/controlclient/direct_test.go b/control/controlclient/direct_test.go index 4ee33eb71..eee079a49 100644 --- a/control/controlclient/direct_test.go +++ b/control/controlclient/direct_test.go @@ -4,6 +4,7 @@ package controlclient import ( + "crypto/ed25519" "encoding/json" "net/http" "net/http/httptest" @@ -142,3 +143,42 @@ func TestTsmpPing(t *testing.T) { t.Fatal(err) } } + +func TestDecodeWrappedAuthkey(t *testing.T) { + k, isWrapped, sig, priv := decodeWrappedAuthkey("tskey-32mjsdkdsffds9o87dsfkjlh", nil) + if want := "tskey-32mjsdkdsffds9o87dsfkjlh"; k != want { + t.Errorf("decodeWrappedAuthkey().key = %q, want %q", k, want) + } + if isWrapped { + t.Error("decodeWrappedAuthkey().isWrapped = true, want false") + } + if sig != nil { + t.Errorf("decodeWrappedAuthkey().sig = %v, want nil", sig) + } + if priv != nil { + t.Errorf("decodeWrappedAuthkey().priv = %v, want nil", priv) + } + + k, isWrapped, sig, priv = decodeWrappedAuthkey("tskey-auth-k7UagY1CNTRL-ZZZZZ--TLpAEDA1ggnXuw4/fWnNWUwcoOjLemhOvml1juMl5lhLmY5sBUsj8EWEAfL2gdeD9g8VDw5tgcxCiHGlEb67BgU2DlFzZApi4LheLJraA+pYjTGChVhpZz1iyiBPD+U2qxDQAbM3+WFY0EBlggxmVqG53Hu0Rg+KmHJFMlUhfgzo+AQP6+Kk9GzvJJOs4-k36RdoSFqaoARfQo0UncHAV0t3YTqrkD5r/z2jTrE43GZWobnce7RGD4qYckUyVSF+DOj4BA/r4qT0bO8kk6zg", nil) + if want := "tskey-auth-k7UagY1CNTRL-ZZZZZ"; k != want { + t.Errorf("decodeWrappedAuthkey().key = %q, want %q", k, want) + } + if !isWrapped { + t.Error("decodeWrappedAuthkey().isWrapped = false, want true") + } + + if sig == nil { + t.Fatal("decodeWrappedAuthkey().sig = nil, want non-nil signature") + } + sigHash := sig.SigHash() + if !ed25519.Verify(sig.KeyID, sigHash[:], sig.Signature) { + t.Error("signature failed to verify") + } + + // Make sure the private is correct by using it. + someSig := ed25519.Sign(priv, []byte{1, 2, 3, 4}) + if !ed25519.Verify(sig.WrappingPubkey, []byte{1, 2, 3, 4}, someSig) { + t.Error("failed to use priv") + } + +} diff --git a/ipn/ipnlocal/network-lock.go b/ipn/ipnlocal/network-lock.go index 2690fff97..bdf8da286 100644 --- a/ipn/ipnlocal/network-lock.go +++ b/ipn/ipnlocal/network-lock.go @@ -6,7 +6,9 @@ package ipnlocal import ( "bytes" "context" + "crypto/ed25519" "crypto/rand" + "encoding/base64" "encoding/binary" "encoding/json" "errors" @@ -847,6 +849,40 @@ func (b *LocalBackend) NetworkLockAffectedSigs(keyID tkatype.KeyID) ([]tkatype.M return resp.Signatures, nil } +var tkaSuffixEncoder = base64.RawStdEncoding + +// NetworkLockWrapPreauthKey wraps a pre-auth key with information to +// enable unattended bringup in the locked tailnet. +// +// The provided trusted tailnet-lock key is used to sign +// a SigCredential structure, which is encoded along with the +// private key and appended to the pre-auth key. +func (b *LocalBackend) NetworkLockWrapPreauthKey(preauthKey string, tkaKey key.NLPrivate) (string, error) { + b.mu.Lock() + defer b.mu.Unlock() + if b.tka == nil { + return "", errNetworkLockNotActive + } + + pub, priv, err := ed25519.GenerateKey(nil) // nil == crypto/rand + if err != nil { + return "", err + } + + sig := tka.NodeKeySignature{ + SigKind: tka.SigCredential, + KeyID: tkaKey.KeyID(), + WrappingPubkey: pub, + } + sig.Signature, err = tkaKey.SignNKS(sig.SigHash()) + if err != nil { + return "", fmt.Errorf("signing failed: %w", err) + } + + b.logf("Generated network-lock credential signature using %s", tkaKey.Public().CLIString()) + return fmt.Sprintf("%s--TL%s-%s", preauthKey, tkaSuffixEncoder.EncodeToString(sig.Serialize()), tkaSuffixEncoder.EncodeToString(priv)), nil +} + func signNodeKey(nodeInfo tailcfg.TKASignInfo, signer key.NLPrivate) (*tka.NodeKeySignature, error) { p, err := nodeInfo.NodePublic.MarshalBinary() if err != nil { diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index 747bb5aef..5ba6f11ff 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -101,6 +101,7 @@ var handler = map[string]localAPIHandler{ "tka/disable": (*Handler).serveTKADisable, "tka/force-local-disable": (*Handler).serveTKALocalDisable, "tka/affected-sigs": (*Handler).serveTKAAffectedSigs, + "tka/wrap-preauth-key": (*Handler).serveTKAWrapPreauthKey, "upload-client-metrics": (*Handler).serveUploadClientMetrics, "watch-ipn-bus": (*Handler).serveWatchIPNBus, "whois": (*Handler).serveWhoIs, @@ -1570,6 +1571,40 @@ func (h *Handler) serveTKAModify(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) } +func (h *Handler) serveTKAWrapPreauthKey(w http.ResponseWriter, r *http.Request) { + if !h.PermitWrite { + http.Error(w, "network-lock modify access denied", http.StatusForbidden) + return + } + if r.Method != httpm.POST { + http.Error(w, "use POST", http.StatusMethodNotAllowed) + return + } + + type wrapRequest struct { + TSKey string + TKAKey string // key.NLPrivate.MarshalText + } + var req wrapRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 12*1024)).Decode(&req); err != nil { + http.Error(w, "invalid JSON body", http.StatusBadRequest) + return + } + var priv key.NLPrivate + if err := priv.UnmarshalText([]byte(req.TKAKey)); err != nil { + http.Error(w, "invalid JSON body", http.StatusBadRequest) + return + } + + wrappedKey, err := h.b.NetworkLockWrapPreauthKey(req.TSKey, priv) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(200) + w.Write([]byte(wrappedKey)) +} + func (h *Handler) serveTKADisable(w http.ResponseWriter, r *http.Request) { if !h.PermitWrite { http.Error(w, "network-lock modify access denied", http.StatusForbidden)