فهرست منبع

cmd,ipn/ipnlocal,tailcfg: implement TKA disablement

 * Plumb disablement values through some of the internals of TKA enablement.
 * Transmit the node's TKA hash at the end of sync so the control plane understands each node's head.
 * Implement /machine/tka/disable RPC to actuate disablement on the control plane.

There is a partner PR for the control server I'll send shortly.

Signed-off-by: Tom DNetto <[email protected]>
Tom DNetto 3 سال پیش
والد
کامیت
d98305c537

+ 6 - 3
client/tailscale/localclient.go

@@ -778,13 +778,16 @@ func (lc *LocalClient) NetworkLockStatus(ctx context.Context) (*ipnstate.Network
 }
 
 // NetworkLockInit initializes the tailnet key authority.
-func (lc *LocalClient) NetworkLockInit(ctx context.Context, keys []tka.Key) (*ipnstate.NetworkLockStatus, error) {
+//
+// TODO(tom): Plumb through disablement secrets.
+func (lc *LocalClient) NetworkLockInit(ctx context.Context, keys []tka.Key, disablementValues [][]byte) (*ipnstate.NetworkLockStatus, error) {
 	var b bytes.Buffer
 	type initRequest struct {
-		Keys []tka.Key
+		Keys              []tka.Key
+		DisablementValues [][]byte
 	}
 
-	if err := json.NewEncoder(&b).Encode(initRequest{Keys: keys}); err != nil {
+	if err := json.NewEncoder(&b).Encode(initRequest{Keys: keys, DisablementValues: disablementValues}); err != nil {
 		return nil, err
 	}
 

+ 5 - 1
cmd/tailscale/cli/network-lock.go

@@ -5,6 +5,7 @@
 package cli
 
 import (
+	"bytes"
 	"context"
 	"errors"
 	"fmt"
@@ -51,7 +52,10 @@ func runNetworkLockInit(ctx context.Context, args []string) error {
 		return err
 	}
 
-	status, err := localClient.NetworkLockInit(ctx, keys)
+	// 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
 	}

+ 5 - 0
control/controlclient/auto.go

@@ -561,6 +561,11 @@ func (c *Auto) SetNetInfo(ni *tailcfg.NetInfo) {
 	c.sendNewMapRequest()
 }
 
+// SetTKAHead updates the TKA head hash that map-request infrastructure sends.
+func (c *Auto) SetTKAHead(headHash string) {
+	c.direct.SetTKAHead(headHash)
+}
+
 func (c *Auto) sendStatus(who string, err error, url string, nm *netmap.NetworkMap) {
 	c.mu.Lock()
 	if c.closed {

+ 3 - 0
control/controlclient/client.go

@@ -65,6 +65,9 @@ type Client interface {
 	// in a separate http request. It has nothing to do with the rest of
 	// the state machine.
 	SetNetInfo(*tailcfg.NetInfo)
+	// SetTKAHead changes the TKA head hash value that will be sent in
+	// subsequent netmap requests.
+	SetTKAHead(headHash string)
 	// UpdateEndpoints changes the Endpoint structure that will be sent
 	// in subsequent node registration requests.
 	// TODO: a server-side change would let us simply upload this

+ 17 - 0
control/controlclient/direct.go

@@ -94,6 +94,7 @@ type Direct struct {
 	hostinfo      *tailcfg.Hostinfo // always non-nil
 	netinfo       *tailcfg.NetInfo
 	endpoints     []tailcfg.Endpoint
+	tkaHead       string
 	everEndpoints bool   // whether we've ever had non-empty endpoints
 	lastPingURL   string // last PingRequest.URL received, for dup suppression
 }
@@ -317,6 +318,21 @@ func (c *Direct) SetNetInfo(ni *tailcfg.NetInfo) bool {
 	return true
 }
 
+// SetNetInfo stores a new TKA head value for next update.
+// It reports whether the TKA head changed.
+func (c *Direct) SetTKAHead(tkaHead string) bool {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+
+	if tkaHead == c.tkaHead {
+		return false
+	}
+
+	c.tkaHead = tkaHead
+	c.logf("tkaHead: %v", tkaHead)
+	return true
+}
+
 func (c *Direct) GetPersist() persist.Persist {
 	c.mu.Lock()
 	defer c.mu.Unlock()
@@ -829,6 +845,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool
 		Hostinfo:      hi,
 		DebugFlags:    c.debugFlags,
 		OmitPeers:     cb == nil,
+		TKAHead:       c.tkaHead,
 
 		// On initial startup before we know our endpoints, set the ReadOnly flag
 		// to tell the control server not to distribute out our (empty) endpoints to peers.

+ 20 - 0
ipn/ipnlocal/local.go

@@ -831,6 +831,16 @@ func (b *LocalBackend) setClientStatus(st controlclient.Status) {
 			b.logf("[v1] TKA sync error: %v", err)
 		}
 		b.mu.Lock()
+		if b.tka != nil {
+			head, err := b.tka.authority.Head().MarshalText()
+			if err != nil {
+				b.logf("[v1] error marshalling tka head: %v", err)
+			} else {
+				b.cc.SetTKAHead(string(head))
+			}
+		} else {
+			b.cc.SetTKAHead("")
+		}
 
 		if !envknob.TKASkipSignatureCheck() {
 			b.tkaFilterNetmapLocked(st.NetMap)
@@ -1226,11 +1236,21 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
 	b.cc = cc
 	b.ccAuto, _ = cc.(*controlclient.Auto)
 	endpoints := b.endpoints
+	var tkaHead string
+	if b.tka != nil {
+		head, err := b.tka.authority.Head().MarshalText()
+		if err != nil {
+			b.mu.Unlock()
+			return fmt.Errorf("marshalling tka head: %w", err)
+		}
+		tkaHead = string(head)
+	}
 	b.mu.Unlock()
 
 	if endpoints != nil {
 		cc.UpdateEndpoints(endpoints)
 	}
+	cc.SetTKAHead(tkaHead)
 
 	b.e.SetNetInfoCallback(b.setNetInfo)
 

+ 125 - 23
ipn/ipnlocal/network-lock.go

@@ -95,6 +95,8 @@ func (b *LocalBackend) tkaSyncIfNeeded(nm *netmap.NetworkMap) error {
 		return nil
 	}
 
+	b.logf("tkaSyncIfNeeded: enabled=%v, head=%v", nm.TKAEnabled, nm.TKAHead)
+
 	b.tkaSyncLock.Lock() // take tkaSyncLock to make this function an exclusive section.
 	defer b.tkaSyncLock.Unlock()
 	b.mu.Lock() // take mu to protect access to synchronized fields.
@@ -125,15 +127,13 @@ func (b *LocalBackend) tkaSyncIfNeeded(nm *netmap.NetworkMap) error {
 			}
 			isEnabled = true
 		} else if !wantEnabled && isEnabled {
-			if b.tka.authority.ValidDisablement(bs.DisablementSecret) {
-				b.tka = nil
-				isEnabled = false
-
-				if err := os.RemoveAll(b.chonkPath()); err != nil {
-					return fmt.Errorf("os.RemoveAll: %v", err)
-				}
+			if err := b.tkaApplyDisablementLocked(bs.DisablementSecret); err != nil {
+				// We log here instead of returning an error (which itself would be
+				// logged), so that sync will continue even if control gives us an
+				// incorrect disablement secret.
+				b.logf("Disablement failed, leaving TKA enabled. Error: %v", err)
 			} else {
-				b.logf("Disablement secret did not verify, leaving TKA enabled.")
+				isEnabled = false
 			}
 		} else {
 			return fmt.Errorf("[bug] unreachable invariant of wantEnabled /w isEnabled")
@@ -216,12 +216,11 @@ func (b *LocalBackend) tkaSyncLocked(ourNodeKey key.NodePublic) error {
 		}
 	}
 
-	// NOTE(tom): We could short-circuit here if our HEAD equals the
-	// control-plane's head, but we don't just so control always has a
-	// copy of all forks that clients had.
-
+	// NOTE(tom): We always send this RPC so control knows what TKA
+	// head we landed at.
+	head := b.tka.authority.Head()
 	b.mu.Unlock()
-	sendResp, err := b.tkaDoSyncSend(ourNodeKey, toSendAUMs, false)
+	sendResp, err := b.tkaDoSyncSend(ourNodeKey, head, toSendAUMs, false)
 	b.mu.Lock()
 	if err != nil {
 		return fmt.Errorf("send RPC: %v", err)
@@ -238,6 +237,21 @@ func (b *LocalBackend) tkaSyncLocked(ourNodeKey key.NodePublic) error {
 	return nil
 }
 
+// tkaApplyDisablementLocked checks a disablement secret and locally disables
+// TKA (if correct). An error is returned if disablement failed.
+//
+// b.mu must be held & TKA must be initialized.
+func (b *LocalBackend) tkaApplyDisablementLocked(secret []byte) error {
+	if b.tka.authority.ValidDisablement(secret) {
+		if err := os.RemoveAll(b.chonkPath()); err != nil {
+			return err
+		}
+		b.tka = nil
+		return nil
+	}
+	return errors.New("incorrect disablement secret")
+}
+
 // chonkPath returns the absolute path to the directory in which TKA
 // state (the 'tailchonk') is stored.
 func (b *LocalBackend) chonkPath() string {
@@ -334,7 +348,7 @@ func (b *LocalBackend) NetworkLockStatus() *ipnstate.NetworkLockStatus {
 // needing signatures is returned as a response.
 // The Finish RPC submits signatures for all these nodes, at which point
 // Control has everything it needs to atomically enable network lock.
-func (b *LocalBackend) NetworkLockInit(keys []tka.Key) error {
+func (b *LocalBackend) NetworkLockInit(keys []tka.Key, disablementValues [][]byte) error {
 	if err := b.CanSupportNetworkLock(); err != nil {
 		return err
 	}
@@ -355,8 +369,11 @@ func (b *LocalBackend) NetworkLockInit(keys []tka.Key) error {
 	// just in case something goes wrong.
 	_, genesisAUM, err := tka.Create(&tka.Mem{}, tka.State{
 		Keys: keys,
-		// TODO(tom): Actually plumb a real disablement value.
-		DisablementSecrets: [][]byte{bytes.Repeat([]byte{1}, 32)},
+		// TODO(tom): s/tka.State.DisablementSecrets/tka.State.DisablementValues
+		//   This will center on consistent nomenclature:
+		//    - DisablementSecret: value needed to disable.
+		//    - DisablementValue: the KDF of the disablement secret, a public value.
+		DisablementSecrets: disablementValues,
 	}, b.nlPrivKey)
 	if err != nil {
 		return fmt.Errorf("tka.Create: %v", err)
@@ -454,8 +471,9 @@ func (b *LocalBackend) NetworkLockModify(addKeys, removeKeys []tka.Key) (err err
 	}
 
 	ourNodeKey := b.prefs.Persist().PublicNodeKey()
+	head := b.tka.authority.Head()
 	b.mu.Unlock()
-	resp, err := b.tkaDoSyncSend(ourNodeKey, aums, true)
+	resp, err := b.tkaDoSyncSend(ourNodeKey, head, aums, true)
 	b.mu.Lock()
 	if err != nil {
 		return err
@@ -474,6 +492,42 @@ func (b *LocalBackend) NetworkLockModify(addKeys, removeKeys []tka.Key) (err err
 	return nil
 }
 
+// NetworkLockDisable disables network-lock using the provided disablement secret.
+func (b *LocalBackend) NetworkLockDisable(secret []byte) error {
+	if err := b.CanSupportNetworkLock(); err != nil {
+		return err
+	}
+
+	var (
+		ourNodeKey key.NodePublic
+		head       tka.AUMHash
+		err        error
+	)
+
+	b.mu.Lock()
+	if b.prefs.Valid() {
+		ourNodeKey = b.prefs.Persist().PublicNodeKey()
+	}
+	if b.tka == nil {
+		err = errNetworkLockNotActive
+	} else {
+		head = b.tka.authority.Head()
+		if !b.tka.authority.ValidDisablement(secret) {
+			err = errors.New("incorrect disablement secret")
+		}
+	}
+	b.mu.Unlock()
+	if err != nil {
+		return err
+	}
+
+	if ourNodeKey.IsZero() {
+		return errors.New("no node-key: is tailscale logged in?")
+	}
+	_, err = b.tkaDoDisablement(ourNodeKey, head, secret)
+	return err
+}
+
 func signNodeKey(nodeInfo tailcfg.TKASignInfo, signer key.NLPrivate) (*tka.NodeKeySignature, error) {
 	p, err := nodeInfo.NodePublic.MarshalBinary()
 	if err != nil {
@@ -519,7 +573,7 @@ func (b *LocalBackend) tkaInitBegin(ourNodeKey key.NodePublic, aum tka.AUM) (*ta
 		return nil, fmt.Errorf("request returned (%d): %s", res.StatusCode, string(body))
 	}
 	a := new(tailcfg.TKAInitBeginResponse)
-	err = json.NewDecoder(res.Body).Decode(a)
+	err = json.NewDecoder(&io.LimitedReader{R: res.Body, N: 10 * 1024 * 1024}).Decode(a)
 	res.Body.Close()
 	if err != nil {
 		return nil, fmt.Errorf("decoding JSON: %w", err)
@@ -555,7 +609,7 @@ func (b *LocalBackend) tkaInitFinish(ourNodeKey key.NodePublic, nks map[tailcfg.
 		return nil, fmt.Errorf("request returned (%d): %s", res.StatusCode, string(body))
 	}
 	a := new(tailcfg.TKAInitFinishResponse)
-	err = json.NewDecoder(res.Body).Decode(a)
+	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)
@@ -603,7 +657,7 @@ func (b *LocalBackend) tkaFetchBootstrap(ourNodeKey key.NodePublic, head tka.AUM
 		return nil, fmt.Errorf("request returned (%d): %s", res.StatusCode, string(body))
 	}
 	a := new(tailcfg.TKABootstrapResponse)
-	err = json.NewDecoder(res.Body).Decode(a)
+	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)
@@ -664,7 +718,7 @@ func (b *LocalBackend) tkaDoSyncOffer(ourNodeKey key.NodePublic, offer tka.SyncO
 		return nil, fmt.Errorf("request returned (%d): %s", res.StatusCode, string(body))
 	}
 	a := new(tailcfg.TKASyncOfferResponse)
-	err = json.NewDecoder(res.Body).Decode(a)
+	err = json.NewDecoder(&io.LimitedReader{R: res.Body, N: 10 * 1024 * 1024}).Decode(a)
 	res.Body.Close()
 	if err != nil {
 		return nil, fmt.Errorf("decoding JSON: %w", err)
@@ -675,10 +729,16 @@ func (b *LocalBackend) tkaDoSyncOffer(ourNodeKey key.NodePublic, offer tka.SyncO
 
 // tkaDoSyncSend sends a /machine/tka/sync/send RPC to the control plane
 // over noise. This is the second of two RPCs implementing tka synchronization.
-func (b *LocalBackend) tkaDoSyncSend(ourNodeKey key.NodePublic, aums []tka.AUM, interactive bool) (*tailcfg.TKASyncSendResponse, error) {
+func (b *LocalBackend) tkaDoSyncSend(ourNodeKey key.NodePublic, head tka.AUMHash, aums []tka.AUM, interactive bool) (*tailcfg.TKASyncSendResponse, error) {
+	headBytes, err := head.MarshalText()
+	if err != nil {
+		return nil, fmt.Errorf("head.MarshalText: %w", err)
+	}
+
 	sendReq := tailcfg.TKASyncSendRequest{
 		Version:     tailcfg.CurrentCapabilityVersion,
 		NodeKey:     ourNodeKey,
+		Head:        string(headBytes),
 		MissingAUMs: make([]tkatype.MarshaledAUM, len(aums)),
 		Interactive: interactive,
 	}
@@ -707,7 +767,49 @@ func (b *LocalBackend) tkaDoSyncSend(ourNodeKey key.NodePublic, aums []tka.AUM,
 		return nil, fmt.Errorf("request returned (%d): %s", res.StatusCode, string(body))
 	}
 	a := new(tailcfg.TKASyncSendResponse)
-	err = json.NewDecoder(res.Body).Decode(a)
+	err = json.NewDecoder(&io.LimitedReader{R: res.Body, N: 10 * 1024 * 1024}).Decode(a)
+	res.Body.Close()
+	if err != nil {
+		return nil, fmt.Errorf("decoding JSON: %w", err)
+	}
+
+	return a, nil
+}
+
+func (b *LocalBackend) tkaDoDisablement(ourNodeKey key.NodePublic, head tka.AUMHash, secret []byte) (*tailcfg.TKADisableResponse, error) {
+	headBytes, err := head.MarshalText()
+	if err != nil {
+		return nil, fmt.Errorf("head.MarshalText: %w", err)
+	}
+
+	var req bytes.Buffer
+	if err := json.NewEncoder(&req).Encode(tailcfg.TKADisableRequest{
+		Version:           tailcfg.CurrentCapabilityVersion,
+		NodeKey:           ourNodeKey,
+		Head:              string(headBytes),
+		DisablementSecret: secret,
+	}); 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/disable", &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.TKADisableResponse)
+	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)

+ 95 - 2
ipn/ipnlocal/network-lock_test.go

@@ -261,6 +261,8 @@ func TestTKASync(t *testing.T) {
 	someKeyPriv := key.NewNLPrivate()
 	someKey := tka.Key{Kind: tka.Key25519, Public: someKeyPriv.Public().Verifier(), Votes: 1}
 
+	disablementSecret := bytes.Repeat([]byte{0xa5}, 32)
+
 	type tkaSyncScenario struct {
 		name string
 		// controlAUMs is called (if non-nil) to get any AUMs which the tka state
@@ -342,7 +344,7 @@ func TestTKASync(t *testing.T) {
 			controlStorage := &tka.Mem{}
 			controlAuthority, bootstrap, err := tka.Create(controlStorage, tka.State{
 				Keys:               []tka.Key{key, someKey},
-				DisablementSecrets: [][]byte{bytes.Repeat([]byte{0xa5}, 32)},
+				DisablementSecrets: [][]byte{tka.DisablementKDF(disablementSecret)},
 			}, nlPriv)
 			if err != nil {
 				t.Fatalf("tka.Create() failed: %v", err)
@@ -416,6 +418,11 @@ func TestTKASync(t *testing.T) {
 						t.Fatal(err)
 					}
 					t.Logf("got sync send:\n%+v", body)
+
+					var remoteHead tka.AUMHash
+					if err := remoteHead.UnmarshalText([]byte(body.Head)); err != nil {
+						t.Fatalf("head unmarshal: %v", err)
+					}
 					toApply := make([]tka.AUM, len(body.MissingAUMs))
 					for i, a := range body.MissingAUMs {
 						if err := toApply[i].Unserialize(a); err != nil {
@@ -434,7 +441,9 @@ func TestTKASync(t *testing.T) {
 					}
 
 					w.WriteHeader(200)
-					if err := json.NewEncoder(w).Encode(tailcfg.TKASyncSendResponse{Head: string(head)}); err != nil {
+					if err := json.NewEncoder(w).Encode(tailcfg.TKASyncSendResponse{
+						Head: string(head),
+					}); err != nil {
 						t.Fatal(err)
 					}
 
@@ -536,3 +545,87 @@ func TestTKAFilterNetmap(t *testing.T) {
 		t.Errorf("filtered netmap differs (-want, +got):\n%s", diff)
 	}
 }
+
+func TestTKADisable(t *testing.T) {
+	envknob.Setenv("TAILSCALE_USE_WIP_CODE", "1")
+	temp := t.TempDir()
+	os.Mkdir(filepath.Join(temp, "tka"), 0755)
+	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}
+	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/disable":
+			body := new(tailcfg.TKADisableRequest)
+			if err := json.NewDecoder(r.Body).Decode(body); err != nil {
+				t.Fatal(err)
+			}
+			if body.Version != tailcfg.CurrentCapabilityVersion {
+				t.Errorf("disable CapVer = %v, want %v", body.Version, tailcfg.CurrentCapabilityVersion)
+			}
+			if body.NodeKey != nodePriv.Public() {
+				t.Errorf("nodeKey = %v, want %v", body.NodeKey, nodePriv.Public())
+			}
+			if !bytes.Equal(body.DisablementSecret, disablementSecret) {
+				t.Errorf("disablement secret = %x, want %x", body.DisablementSecret, disablementSecret)
+			}
+
+			var head tka.AUMHash
+			if err := head.UnmarshalText([]byte(body.Head)); err != nil {
+				t.Fatalf("failed unmarshal of body.Head: %v", err)
+			}
+			if head != authority.Head() {
+				t.Errorf("reported head = %x, want %x", head, authority.Head())
+			}
+
+			w.WriteHeader(200)
+			if err := json.NewEncoder(w).Encode(tailcfg.TKADisableResponse{}); 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(),
+	}
+
+	// Test that we get an error for an incorrect disablement secret.
+	if err := b.NetworkLockDisable([]byte{1, 2, 3, 4}); err == nil || err.Error() != "incorrect disablement secret" {
+		t.Errorf("NetworkLockDisable(<bad secret>).err = %v, want 'incorrect disablement secret'", err)
+	}
+	if err := b.NetworkLockDisable(disablementSecret); err != nil {
+		t.Errorf("NetworkLockDisable() failed: %v", err)
+	}
+}

+ 4 - 0
ipn/ipnlocal/state_test.go

@@ -248,6 +248,10 @@ func (cc *mockControl) SetNetInfo(ni *tailcfg.NetInfo) {
 	cc.called("SetNetInfo")
 }
 
+func (cc *mockControl) SetTKAHead(head string) {
+	cc.logf("SetTKAHead: %s", head)
+}
+
 func (cc *mockControl) UpdateEndpoints(endpoints []tailcfg.Endpoint) {
 	// validate endpoint information here?
 	cc.logf("UpdateEndpoints:  ep=%v", endpoints)

+ 3 - 2
ipn/localapi/localapi.go

@@ -932,7 +932,8 @@ func (h *Handler) serveTKAInit(w http.ResponseWriter, r *http.Request) {
 	}
 
 	type initRequest struct {
-		Keys []tka.Key
+		Keys              []tka.Key
+		DisablementValues [][]byte
 	}
 	var req initRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -940,7 +941,7 @@ func (h *Handler) serveTKAInit(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	if err := h.b.NetworkLockInit(req.Keys); err != nil {
+	if err := h.b.NetworkLockInit(req.Keys, req.DisablementValues); err != nil {
 		http.Error(w, "initialization failed: "+err.Error(), http.StatusInternalServerError)
 		return
 	}

+ 5 - 0
tailcfg/tailcfg.go

@@ -947,6 +947,11 @@ type MapRequest struct {
 	// EndpointTypes are the types of the corresponding endpoints in Endpoints.
 	EndpointTypes []EndpointType `json:",omitempty"`
 
+	// TKAHead describes the hash of the latest AUM applied to the local
+	// tailnet key authority, if one is operating.
+	// It is encoded as tka.AUMHash.MarshalText.
+	TKAHead string `json:",omitempty"`
+
 	// ReadOnly is whether the client just wants to fetch the
 	// MapResponse, without updating their Endpoints. The
 	// Endpoints field will be ignored and LastSeen will not be

+ 34 - 7
tailcfg/tka.go

@@ -86,9 +86,6 @@ type TKAInfo struct {
 	//
 	// If the Head state differs to that known locally, the node should perform
 	// synchronization via a separate RPC.
-	//
-	// TODO(tom): Implement AUM synchronization as noise endpoints
-	// /machine/tka/sync/offer & /machine/tka/sync/send.
 	Head string `json:",omitempty"`
 
 	// Disabled indicates the control plane believes TKA should be disabled,
@@ -97,9 +94,6 @@ type TKAInfo struct {
 	// disable TKA locally.
 	// This field exists to disambiguate a nil TKAInfo in a delta mapresponse
 	// from a nil TKAInfo indicating TKA should be disabled.
-	//
-	// TODO(tom): Implement /machine/tka/bootstrap as a noise endpoint, to
-	// communicate the genesis AUM & any disablement secrets.
 	Disabled bool `json:",omitempty"`
 }
 
@@ -162,7 +156,8 @@ type TKASyncOfferResponse struct {
 }
 
 // TKASyncSendRequest encodes AUMs that a node believes the control plane
-// is missing.
+// is missing, and notifies control of its local TKA state (specifically
+// the head hash).
 type TKASyncSendRequest struct {
 	// Version is the client's capabilities.
 	Version CapabilityVersion
@@ -170,9 +165,15 @@ type TKASyncSendRequest struct {
 	// NodeKey is the client's current node key.
 	NodeKey key.NodePublic
 
+	// Head represents the node's head AUMHash (tka.Authority.Head) after
+	// applying any AUMs from the sync-offer response.
+	// It is encoded as tka.AUMHash.MarshalText.
+	Head string
+
 	// MissingAUMs encodes AUMs that the node believes the control plane
 	// is missing.
 	MissingAUMs []tkatype.MarshaledAUM
+
 	// Interactive is true if additional error checking should be performed as
 	// the request is on behalf of an interactive operation (e.g., an
 	// administrator publishing new changes) as opposed to an automatic
@@ -187,3 +188,29 @@ type TKASyncSendResponse struct {
 	// after applying the missing AUMs.
 	Head string
 }
+
+// TKADisableRequest disables network-lock across the tailnet using the
+// provided disablement secret.
+//
+// This is the request schema for a /tka/disable noise RPC.
+type TKADisableRequest struct {
+	// Version is the client's capabilities.
+	Version CapabilityVersion
+
+	// NodeKey is the client's current node key.
+	NodeKey key.NodePublic
+
+	// Head represents the node's head AUMHash (tka.Authority.Head).
+	// It is encoded as tka.AUMHash.MarshalText.
+	Head string
+
+	// DisablementSecret encodes the secret necessary to disable TKA.
+	DisablementSecret []byte
+}
+
+// TKADisableResponse is the JSON response from a /tka/disable RPC.
+// This schema describes the successful disablement of the tailnet's
+// key authority.
+type TKADisableResponse struct {
+	// Nothing. (yet?)
+}