summaryrefslogtreecommitdiffhomepage
path: root/ipn
diff options
context:
space:
mode:
authorTom DNetto <tom@tailscale.com>2023-03-01 12:47:29 -0800
committerTom <twitchyliquid64@users.noreply.github.com>2023-03-03 10:09:05 -1000
commite2d652ec4dc53dce6c828e0128126ac7db2d6b09 (patch)
tree9730760bef0b07ef999b931a3f398d9cccb0a49c /ipn
parent3f8e8b04fd4e32f63b2b8c4bc0f9ad5695d2bb20 (diff)
downloadtailscale-e2d652ec4dc53dce6c828e0128126ac7db2d6b09.tar.xz
tailscale-e2d652ec4dc53dce6c828e0128126ac7db2d6b09.zip
ipn,cmd/tailscale: implement resigning nodes on tka key removal
Signed-off-by: Tom DNetto <tom@tailscale.com>
Diffstat (limited to 'ipn')
-rw-r--r--ipn/ipnlocal/network-lock.go94
-rw-r--r--ipn/ipnlocal/network-lock_test.go133
-rw-r--r--ipn/localapi/localapi.go27
3 files changed, 254 insertions, 0 deletions
diff --git a/ipn/ipnlocal/network-lock.go b/ipn/ipnlocal/network-lock.go
index c8b1da764..955ad3a76 100644
--- a/ipn/ipnlocal/network-lock.go
+++ b/ipn/ipnlocal/network-lock.go
@@ -784,6 +784,64 @@ func (b *LocalBackend) NetworkLockLog(maxEntries int) ([]ipnstate.NetworkLockUpd
return out, nil
}
+// NetworkLockAffectedSigs returns the signatures which would be invalidated
+// by removing trust in the specified KeyID.
+func (b *LocalBackend) NetworkLockAffectedSigs(keyID tkatype.KeyID) ([]tkatype.MarshaledSignature, error) {
+ var (
+ ourNodeKey key.NodePublic
+ err error
+ )
+ b.mu.Lock()
+ if p := b.pm.CurrentPrefs(); p.Valid() && p.Persist().Valid() && !p.Persist().PrivateNodeKey().IsZero() {
+ ourNodeKey = p.Persist().PublicNodeKey()
+ }
+ if b.tka == nil {
+ err = errNetworkLockNotActive
+ }
+ b.mu.Unlock()
+ if err != nil {
+ return nil, err
+ }
+
+ resp, err := b.tkaReadAffectedSigs(ourNodeKey, keyID)
+ if err != nil {
+ return nil, err
+ }
+
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ if b.tka == nil {
+ return nil, errNetworkLockNotActive
+ }
+
+ // Confirm for ourselves tha the signatures would actually be invalidated
+ // by removal of trusted in the specified key.
+ for i, sigBytes := range resp.Signatures {
+ var sig tka.NodeKeySignature
+ if err := sig.Unserialize(sigBytes); err != nil {
+ return nil, fmt.Errorf("failed decoding signature %d: %w", i, err)
+ }
+
+ sigKeyID, err := sig.UnverifiedAuthorizingKeyID()
+ if err != nil {
+ return nil, fmt.Errorf("extracting SigID from signature %d: %w", i, err)
+ }
+ if !bytes.Equal(keyID, sigKeyID) {
+ return nil, fmt.Errorf("got signature with keyID %X from request for %X", sigKeyID, keyID)
+ }
+
+ var nodeKey key.NodePublic
+ if err := nodeKey.UnmarshalBinary(sig.Pubkey); err != nil {
+ return nil, fmt.Errorf("failed decoding pubkey for signature %d: %w", i, err)
+ }
+ if err := b.tka.authority.NodeKeyAuthorized(nodeKey, sigBytes); err != nil {
+ return nil, fmt.Errorf("signature %d is not valid: %w", i, err)
+ }
+ }
+
+ return resp.Signatures, nil
+}
+
func signNodeKey(nodeInfo tailcfg.TKASignInfo, signer key.NLPrivate) (*tka.NodeKeySignature, error) {
p, err := nodeInfo.NodePublic.MarshalBinary()
if err != nil {
@@ -1110,3 +1168,39 @@ func (b *LocalBackend) tkaSubmitSignature(ourNodeKey key.NodePublic, sig tkatype
return a, nil
}
+
+func (b *LocalBackend) tkaReadAffectedSigs(ourNodeKey key.NodePublic, key tkatype.KeyID) (*tailcfg.TKASignaturesUsingKeyResponse, error) {
+ var encodedReq bytes.Buffer
+ if err := json.NewEncoder(&encodedReq).Encode(tailcfg.TKASignaturesUsingKeyRequest{
+ Version: tailcfg.CurrentCapabilityVersion,
+ NodeKey: ourNodeKey,
+ KeyID: key,
+ }); err != nil {
+ return nil, fmt.Errorf("encoding request: %v", err)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(ctx, "GET", "https://unused/machine/tka/affected-sigs", &encodedReq)
+ if err != nil {
+ return nil, fmt.Errorf("req: %w", err)
+ }
+ resp, err := b.DoNoiseRequest(req)
+ if err != nil {
+ return nil, fmt.Errorf("resp: %w", err)
+ }
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ return nil, fmt.Errorf("request returned (%d): %s", resp.StatusCode, string(body))
+ }
+ a := new(tailcfg.TKASignaturesUsingKeyResponse)
+ err = json.NewDecoder(&io.LimitedReader{R: resp.Body, N: 1024 * 1024}).Decode(a)
+ resp.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 daf1d0d06..31ff5adf0 100644
--- a/ipn/ipnlocal/network-lock_test.go
+++ b/ipn/ipnlocal/network-lock_test.go
@@ -7,6 +7,7 @@ import (
"bytes"
"context"
"encoding/json"
+ "fmt"
"net"
"net/http"
"net/http/httptest"
@@ -877,3 +878,135 @@ func TestTKAForceDisable(t *testing.T) {
t.Fatal("tka was re-initalized")
}
}
+
+func TestTKAAffectedSigs(t *testing.T) {
+ nodePriv := key.NewNode()
+ // toSign := key.NewNode()
+ nlPriv := key.NewNLPrivate()
+
+ pm := must.Get(newProfileManager(new(mem.Store), t.Logf))
+ must.Do(pm.SetPrefs((&ipn.Prefs{
+ Persist: &persist.Persist{
+ PrivateNodeKey: nodePriv,
+ NetworkLockKey: nlPriv,
+ },
+ }).View()))
+
+ // Make a fake TKA authority, to seed local state.
+ disablementSecret := bytes.Repeat([]byte{0xa5}, 32)
+ tkaKey := tka.Key{Kind: tka.Key25519, Public: nlPriv.Public().Verifier(), Votes: 2}
+
+ 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, _, err := tka.Create(chonk, tka.State{
+ Keys: []tka.Key{tkaKey},
+ DisablementSecrets: [][]byte{tka.DisablementKDF(disablementSecret)},
+ }, nlPriv)
+ if err != nil {
+ t.Fatalf("tka.Create() failed: %v", err)
+ }
+
+ untrustedKey := key.NewNLPrivate()
+ tcs := []struct {
+ name string
+ makeSig func() *tka.NodeKeySignature
+ wantErr string
+ }{
+ {
+ "no error",
+ func() *tka.NodeKeySignature {
+ sig, _ := signNodeKey(tailcfg.TKASignInfo{NodePublic: nodePriv.Public()}, nlPriv)
+ return sig
+ },
+ "",
+ },
+ {
+ "signature for different keyID",
+ func() *tka.NodeKeySignature {
+ sig, _ := signNodeKey(tailcfg.TKASignInfo{NodePublic: nodePriv.Public()}, untrustedKey)
+ return sig
+ },
+ fmt.Sprintf("got signature with keyID %X from request for %X", untrustedKey.KeyID(), nlPriv.KeyID()),
+ },
+ {
+ "invalid signature",
+ func() *tka.NodeKeySignature {
+ sig, _ := signNodeKey(tailcfg.TKASignInfo{NodePublic: nodePriv.Public()}, nlPriv)
+ copy(sig.Signature, []byte{1, 2, 3, 4, 5, 6}) // overwrite with trash to invalid signature
+ return sig
+ },
+ "signature 0 is not valid: invalid signature",
+ },
+ }
+
+ for _, tc := range tcs {
+ t.Run(tc.name, func(t *testing.T) {
+ s := tc.makeSig()
+ ts, client := fakeNoiseServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ defer r.Body.Close()
+ switch r.URL.Path {
+ case "/machine/tka/affected-sigs":
+ body := new(tailcfg.TKASignaturesUsingKeyRequest)
+ 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())
+ }
+
+ w.WriteHeader(200)
+ if err := json.NewEncoder(w).Encode(tailcfg.TKASignaturesUsingKeyResponse{
+ Signatures: []tkatype.MarshaledSignature{s.Serialize()},
+ }); 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(),
+ }
+
+ sigs, err := b.NetworkLockAffectedSigs(nlPriv.KeyID())
+ switch {
+ case tc.wantErr == "" && err != nil:
+ t.Errorf("NetworkLockAffectedSigs() failed: %v", err)
+ case tc.wantErr != "" && err == nil:
+ t.Errorf("NetworkLockAffectedSigs().err = nil, want %q", tc.wantErr)
+ case tc.wantErr != "" && err.Error() != tc.wantErr:
+ t.Errorf("NetworkLockAffectedSigs().err = %q, want %q", err.Error(), tc.wantErr)
+ }
+
+ if tc.wantErr == "" {
+ if len(sigs) != 1 {
+ t.Fatalf("len(sigs) = %d, want 1", len(sigs))
+ }
+ if !bytes.Equal(s.Serialize(), sigs[0]) {
+ t.Errorf("unexpected signature: got %v, want %v", sigs[0], s.Serialize())
+ }
+ }
+ })
+ }
+}
diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go
index 64828adab..7294d88ed 100644
--- a/ipn/localapi/localapi.go
+++ b/ipn/localapi/localapi.go
@@ -99,6 +99,7 @@ var handler = map[string]localAPIHandler{
"tka/status": (*Handler).serveTKAStatus,
"tka/disable": (*Handler).serveTKADisable,
"tka/force-local-disable": (*Handler).serveTKALocalDisable,
+ "tka/affected-sigs": (*Handler).serveTKAAffectedSigs,
"upload-client-metrics": (*Handler).serveUploadClientMetrics,
"watch-ipn-bus": (*Handler).serveWatchIPNBus,
"whois": (*Handler).serveWhoIs,
@@ -1601,6 +1602,32 @@ func (h *Handler) serveTKALog(w http.ResponseWriter, r *http.Request) {
w.Write(j)
}
+func (h *Handler) serveTKAAffectedSigs(w http.ResponseWriter, r *http.Request) {
+ if r.Method != httpm.POST {
+ http.Error(w, "use POST", http.StatusMethodNotAllowed)
+ return
+ }
+ keyID, err := ioutil.ReadAll(http.MaxBytesReader(w, r.Body, 2048))
+ if err != nil {
+ http.Error(w, "reading body", http.StatusBadRequest)
+ return
+ }
+
+ sigs, err := h.b.NetworkLockAffectedSigs(keyID)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ j, err := json.MarshalIndent(sigs, "", "\t")
+ if err != nil {
+ http.Error(w, "JSON encoding error", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Write(j)
+}
+
// serveProfiles serves profile switching-related endpoints. Supported methods
// and paths are:
// - GET /profiles/: list all profiles (JSON-encoded array of ipn.LoginProfiles)