Просмотр исходного кода

all: add ts_omit_tailnetlock as a start of making it build-time modular

Updates #17115

Change-Id: I6b083c0db4c4d359e49eb129d626b7f128f0a9d2
Signed-off-by: Brad Fitzpatrick <[email protected]>
Brad Fitzpatrick 5 месяцев назад
Родитель
Сommit
3a49b7464c

+ 0 - 187
client/local/local.go

@@ -38,10 +38,8 @@ import (
 	"tailscale.com/paths"
 	"tailscale.com/safesocket"
 	"tailscale.com/tailcfg"
-	"tailscale.com/tka"
 	"tailscale.com/types/dnstype"
 	"tailscale.com/types/key"
-	"tailscale.com/types/tkatype"
 	"tailscale.com/util/eventbus"
 )
 
@@ -1219,183 +1217,6 @@ func (lc *Client) Ping(ctx context.Context, ip netip.Addr, pingtype tailcfg.Ping
 	return lc.PingWithOpts(ctx, ip, pingtype, PingOpts{})
 }
 
-// NetworkLockStatus fetches information about the tailnet key authority, if one is configured.
-func (lc *Client) NetworkLockStatus(ctx context.Context) (*ipnstate.NetworkLockStatus, error) {
-	body, err := lc.send(ctx, "GET", "/localapi/v0/tka/status", 200, nil)
-	if err != nil {
-		return nil, fmt.Errorf("error: %w", err)
-	}
-	return decodeJSON[*ipnstate.NetworkLockStatus](body)
-}
-
-// NetworkLockInit initializes the tailnet key authority.
-//
-// TODO(tom): Plumb through disablement secrets.
-func (lc *Client) NetworkLockInit(ctx context.Context, keys []tka.Key, disablementValues [][]byte, supportDisablement []byte) (*ipnstate.NetworkLockStatus, error) {
-	var b bytes.Buffer
-	type initRequest struct {
-		Keys               []tka.Key
-		DisablementValues  [][]byte
-		SupportDisablement []byte
-	}
-
-	if err := json.NewEncoder(&b).Encode(initRequest{Keys: keys, DisablementValues: disablementValues, SupportDisablement: supportDisablement}); err != nil {
-		return nil, err
-	}
-
-	body, err := lc.send(ctx, "POST", "/localapi/v0/tka/init", 200, &b)
-	if err != nil {
-		return nil, fmt.Errorf("error: %w", err)
-	}
-	return decodeJSON[*ipnstate.NetworkLockStatus](body)
-}
-
-// NetworkLockWrapPreauthKey wraps a pre-auth key with information to
-// enable unattended bringup in the locked tailnet.
-func (lc *Client) 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 *Client) NetworkLockModify(ctx context.Context, addKeys, removeKeys []tka.Key) error {
-	var b bytes.Buffer
-	type modifyRequest struct {
-		AddKeys    []tka.Key
-		RemoveKeys []tka.Key
-	}
-
-	if err := json.NewEncoder(&b).Encode(modifyRequest{AddKeys: addKeys, RemoveKeys: removeKeys}); err != nil {
-		return err
-	}
-
-	if _, err := lc.send(ctx, "POST", "/localapi/v0/tka/modify", 204, &b); err != nil {
-		return fmt.Errorf("error: %w", err)
-	}
-	return 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 *Client) 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
-}
-
-// NetworkLockAffectedSigs returns all signatures signed by the specified keyID.
-func (lc *Client) NetworkLockAffectedSigs(ctx context.Context, keyID tkatype.KeyID) ([]tkatype.MarshaledSignature, error) {
-	body, err := lc.send(ctx, "POST", "/localapi/v0/tka/affected-sigs", 200, bytes.NewReader(keyID))
-	if err != nil {
-		return nil, fmt.Errorf("error: %w", err)
-	}
-	return decodeJSON[[]tkatype.MarshaledSignature](body)
-}
-
-// NetworkLockLog returns up to maxEntries number of changes to network-lock state.
-func (lc *Client) NetworkLockLog(ctx context.Context, maxEntries int) ([]ipnstate.NetworkLockUpdate, error) {
-	v := url.Values{}
-	v.Set("limit", fmt.Sprint(maxEntries))
-	body, err := lc.send(ctx, "GET", "/localapi/v0/tka/log?"+v.Encode(), 200, nil)
-	if err != nil {
-		return nil, fmt.Errorf("error %w: %s", err, body)
-	}
-	return decodeJSON[[]ipnstate.NetworkLockUpdate](body)
-}
-
-// NetworkLockForceLocalDisable forcibly shuts down network lock on this node.
-func (lc *Client) NetworkLockForceLocalDisable(ctx context.Context) error {
-	// This endpoint expects an empty JSON stanza as the payload.
-	var b bytes.Buffer
-	if err := json.NewEncoder(&b).Encode(struct{}{}); err != nil {
-		return err
-	}
-
-	if _, err := lc.send(ctx, "POST", "/localapi/v0/tka/force-local-disable", 200, &b); err != nil {
-		return fmt.Errorf("error: %w", err)
-	}
-	return nil
-}
-
-// NetworkLockVerifySigningDeeplink verifies the network lock deeplink contained
-// in url and returns information extracted from it.
-func (lc *Client) NetworkLockVerifySigningDeeplink(ctx context.Context, url string) (*tka.DeeplinkValidationResult, error) {
-	vr := struct {
-		URL string
-	}{url}
-
-	body, err := lc.send(ctx, "POST", "/localapi/v0/tka/verify-deeplink", 200, jsonBody(vr))
-	if err != nil {
-		return nil, fmt.Errorf("sending verify-deeplink: %w", err)
-	}
-
-	return decodeJSON[*tka.DeeplinkValidationResult](body)
-}
-
-// NetworkLockGenRecoveryAUM generates an AUM for recovering from a tailnet-lock key compromise.
-func (lc *Client) NetworkLockGenRecoveryAUM(ctx context.Context, removeKeys []tkatype.KeyID, forkFrom tka.AUMHash) ([]byte, error) {
-	vr := struct {
-		Keys     []tkatype.KeyID
-		ForkFrom string
-	}{removeKeys, forkFrom.String()}
-
-	body, err := lc.send(ctx, "POST", "/localapi/v0/tka/generate-recovery-aum", 200, jsonBody(vr))
-	if err != nil {
-		return nil, fmt.Errorf("sending generate-recovery-aum: %w", err)
-	}
-
-	return body, nil
-}
-
-// NetworkLockCosignRecoveryAUM co-signs a recovery AUM using the node's tailnet lock key.
-func (lc *Client) NetworkLockCosignRecoveryAUM(ctx context.Context, aum tka.AUM) ([]byte, error) {
-	r := bytes.NewReader(aum.Serialize())
-	body, err := lc.send(ctx, "POST", "/localapi/v0/tka/cosign-recovery-aum", 200, r)
-	if err != nil {
-		return nil, fmt.Errorf("sending cosign-recovery-aum: %w", err)
-	}
-
-	return body, nil
-}
-
-// NetworkLockSubmitRecoveryAUM submits a recovery AUM to the control plane.
-func (lc *Client) NetworkLockSubmitRecoveryAUM(ctx context.Context, aum tka.AUM) error {
-	r := bytes.NewReader(aum.Serialize())
-	_, err := lc.send(ctx, "POST", "/localapi/v0/tka/submit-recovery-aum", 200, r)
-	if err != nil {
-		return fmt.Errorf("sending cosign-recovery-aum: %w", err)
-	}
-	return nil
-}
-
 // SetServeConfig sets or replaces the serving settings.
 // If config is nil, settings are cleared and serving is disabled.
 func (lc *Client) SetServeConfig(ctx context.Context, config *ipn.ServeConfig) error {
@@ -1421,14 +1242,6 @@ func (lc *Client) DisconnectControl(ctx context.Context) error {
 	return nil
 }
 
-// NetworkLockDisable shuts down network-lock across the tailnet.
-func (lc *Client) 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).

+ 204 - 0
client/local/tailnetlock.go

@@ -0,0 +1,204 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build !ts_omit_tailnetlock
+
+package local
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/url"
+
+	"tailscale.com/ipn/ipnstate"
+	"tailscale.com/tka"
+	"tailscale.com/types/key"
+	"tailscale.com/types/tkatype"
+)
+
+// NetworkLockStatus fetches information about the tailnet key authority, if one is configured.
+func (lc *Client) NetworkLockStatus(ctx context.Context) (*ipnstate.NetworkLockStatus, error) {
+	body, err := lc.send(ctx, "GET", "/localapi/v0/tka/status", 200, nil)
+	if err != nil {
+		return nil, fmt.Errorf("error: %w", err)
+	}
+	return decodeJSON[*ipnstate.NetworkLockStatus](body)
+}
+
+// NetworkLockInit initializes the tailnet key authority.
+//
+// TODO(tom): Plumb through disablement secrets.
+func (lc *Client) NetworkLockInit(ctx context.Context, keys []tka.Key, disablementValues [][]byte, supportDisablement []byte) (*ipnstate.NetworkLockStatus, error) {
+	var b bytes.Buffer
+	type initRequest struct {
+		Keys               []tka.Key
+		DisablementValues  [][]byte
+		SupportDisablement []byte
+	}
+
+	if err := json.NewEncoder(&b).Encode(initRequest{Keys: keys, DisablementValues: disablementValues, SupportDisablement: supportDisablement}); err != nil {
+		return nil, err
+	}
+
+	body, err := lc.send(ctx, "POST", "/localapi/v0/tka/init", 200, &b)
+	if err != nil {
+		return nil, fmt.Errorf("error: %w", err)
+	}
+	return decodeJSON[*ipnstate.NetworkLockStatus](body)
+}
+
+// NetworkLockWrapPreauthKey wraps a pre-auth key with information to
+// enable unattended bringup in the locked tailnet.
+func (lc *Client) 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 *Client) NetworkLockModify(ctx context.Context, addKeys, removeKeys []tka.Key) error {
+	var b bytes.Buffer
+	type modifyRequest struct {
+		AddKeys    []tka.Key
+		RemoveKeys []tka.Key
+	}
+
+	if err := json.NewEncoder(&b).Encode(modifyRequest{AddKeys: addKeys, RemoveKeys: removeKeys}); err != nil {
+		return err
+	}
+
+	if _, err := lc.send(ctx, "POST", "/localapi/v0/tka/modify", 204, &b); err != nil {
+		return fmt.Errorf("error: %w", err)
+	}
+	return 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 *Client) 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
+}
+
+// NetworkLockAffectedSigs returns all signatures signed by the specified keyID.
+func (lc *Client) NetworkLockAffectedSigs(ctx context.Context, keyID tkatype.KeyID) ([]tkatype.MarshaledSignature, error) {
+	body, err := lc.send(ctx, "POST", "/localapi/v0/tka/affected-sigs", 200, bytes.NewReader(keyID))
+	if err != nil {
+		return nil, fmt.Errorf("error: %w", err)
+	}
+	return decodeJSON[[]tkatype.MarshaledSignature](body)
+}
+
+// NetworkLockLog returns up to maxEntries number of changes to network-lock state.
+func (lc *Client) NetworkLockLog(ctx context.Context, maxEntries int) ([]ipnstate.NetworkLockUpdate, error) {
+	v := url.Values{}
+	v.Set("limit", fmt.Sprint(maxEntries))
+	body, err := lc.send(ctx, "GET", "/localapi/v0/tka/log?"+v.Encode(), 200, nil)
+	if err != nil {
+		return nil, fmt.Errorf("error %w: %s", err, body)
+	}
+	return decodeJSON[[]ipnstate.NetworkLockUpdate](body)
+}
+
+// NetworkLockForceLocalDisable forcibly shuts down network lock on this node.
+func (lc *Client) NetworkLockForceLocalDisable(ctx context.Context) error {
+	// This endpoint expects an empty JSON stanza as the payload.
+	var b bytes.Buffer
+	if err := json.NewEncoder(&b).Encode(struct{}{}); err != nil {
+		return err
+	}
+
+	if _, err := lc.send(ctx, "POST", "/localapi/v0/tka/force-local-disable", 200, &b); err != nil {
+		return fmt.Errorf("error: %w", err)
+	}
+	return nil
+}
+
+// NetworkLockVerifySigningDeeplink verifies the network lock deeplink contained
+// in url and returns information extracted from it.
+func (lc *Client) NetworkLockVerifySigningDeeplink(ctx context.Context, url string) (*tka.DeeplinkValidationResult, error) {
+	vr := struct {
+		URL string
+	}{url}
+
+	body, err := lc.send(ctx, "POST", "/localapi/v0/tka/verify-deeplink", 200, jsonBody(vr))
+	if err != nil {
+		return nil, fmt.Errorf("sending verify-deeplink: %w", err)
+	}
+
+	return decodeJSON[*tka.DeeplinkValidationResult](body)
+}
+
+// NetworkLockGenRecoveryAUM generates an AUM for recovering from a tailnet-lock key compromise.
+func (lc *Client) NetworkLockGenRecoveryAUM(ctx context.Context, removeKeys []tkatype.KeyID, forkFrom tka.AUMHash) ([]byte, error) {
+	vr := struct {
+		Keys     []tkatype.KeyID
+		ForkFrom string
+	}{removeKeys, forkFrom.String()}
+
+	body, err := lc.send(ctx, "POST", "/localapi/v0/tka/generate-recovery-aum", 200, jsonBody(vr))
+	if err != nil {
+		return nil, fmt.Errorf("sending generate-recovery-aum: %w", err)
+	}
+
+	return body, nil
+}
+
+// NetworkLockCosignRecoveryAUM co-signs a recovery AUM using the node's tailnet lock key.
+func (lc *Client) NetworkLockCosignRecoveryAUM(ctx context.Context, aum tka.AUM) ([]byte, error) {
+	r := bytes.NewReader(aum.Serialize())
+	body, err := lc.send(ctx, "POST", "/localapi/v0/tka/cosign-recovery-aum", 200, r)
+	if err != nil {
+		return nil, fmt.Errorf("sending cosign-recovery-aum: %w", err)
+	}
+
+	return body, nil
+}
+
+// NetworkLockSubmitRecoveryAUM submits a recovery AUM to the control plane.
+func (lc *Client) NetworkLockSubmitRecoveryAUM(ctx context.Context, aum tka.AUM) error {
+	r := bytes.NewReader(aum.Serialize())
+	_, err := lc.send(ctx, "POST", "/localapi/v0/tka/submit-recovery-aum", 200, r)
+	if err != nil {
+		return fmt.Errorf("sending cosign-recovery-aum: %w", err)
+	}
+	return nil
+}
+
+// NetworkLockDisable shuts down network-lock across the tailnet.
+func (lc *Client) 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
+}

+ 9 - 5
cmd/tailscale/cli/cli.go

@@ -207,10 +207,14 @@ func noDupFlagify(c *ffcli.Command) {
 	}
 }
 
-var fileCmd func() *ffcli.Command
-var sysPolicyCmd func() *ffcli.Command
-var maybeWebCmd func() *ffcli.Command
-var maybeDriveCmd func() *ffcli.Command
+var (
+	fileCmd,
+	sysPolicyCmd,
+	maybeWebCmd,
+	maybeDriveCmd,
+	maybeNetlockCmd,
+	_ func() *ffcli.Command
+)
 
 func newRootCmd() *ffcli.Command {
 	rootfs := newFlagSet("tailscale")
@@ -257,7 +261,7 @@ change in the future.
 			nilOrCall(fileCmd),
 			bugReportCmd,
 			certCmd,
-			netlockCmd,
+			nilOrCall(maybeNetlockCmd),
 			licensesCmd,
 			exitNodeCmd(),
 			updateCmd,

+ 6 - 0
cmd/tailscale/cli/network-lock.go

@@ -1,6 +1,8 @@
 // Copyright (c) Tailscale Inc & AUTHORS
 // SPDX-License-Identifier: BSD-3-Clause
 
+//go:build !ts_omit_tailnetlock
+
 package cli
 
 import (
@@ -27,6 +29,10 @@ import (
 	"tailscale.com/util/prompt"
 )
 
+func init() {
+	maybeNetlockCmd = func() *ffcli.Command { return netlockCmd }
+}
+
 var netlockCmd = &ffcli.Command{
 	Name:       "lock",
 	ShortUsage: "tailscale lock <subcommand> [arguments...]",

+ 13 - 0
cmd/tailscaled/deps_test.go

@@ -77,3 +77,16 @@ func TestOmitDrive(t *testing.T) {
 		},
 	}.Check(t)
 }
+
+func TestOmitTailnetLock(t *testing.T) {
+	deptest.DepChecker{
+		GOOS:   "linux",
+		GOARCH: "amd64",
+		Tags:   "ts_omit_tailnetlock,ts_include_cli",
+		OnDep: func(dep string) {
+			if strings.Contains(dep, "cbor") {
+				t.Errorf("unexpected dep with ts_omit_tailnetlock: %q", dep)
+			}
+		},
+	}.Check(t)
+}

+ 1 - 0
feature/featuretags/featuretags.go

@@ -19,6 +19,7 @@ var Features = map[string]string{
 	"syspolicy":        "System policy configuration (MDM) support",
 	"systray":          "Linux system tray",
 	"taildrop":         "Taildrop (file sending) support",
+	"tailnetlock":      "Tailnet Lock support",
 	"tap":              "Experimental Layer 2 (ethernet) support",
 	"tka":              "Tailnet Lock (TKA) support",
 	"tpm":              "TPM support",

+ 0 - 48
ipn/ipnlocal/local.go

@@ -82,7 +82,6 @@ import (
 	"tailscale.com/posture"
 	"tailscale.com/syncs"
 	"tailscale.com/tailcfg"
-	"tailscale.com/tka"
 	"tailscale.com/tsd"
 	"tailscale.com/tstime"
 	"tailscale.com/types/appctype"
@@ -7179,53 +7178,6 @@ func (b *LocalBackend) SwitchProfile(profile ipn.ProfileID) error {
 	return b.resetForProfileChangeLockedOnEntry(unlock)
 }
 
-func (b *LocalBackend) initTKALocked() error {
-	cp := b.pm.CurrentProfile()
-	if cp.ID() == "" {
-		b.tka = nil
-		return nil
-	}
-	if b.tka != nil {
-		if b.tka.profile == cp.ID() {
-			// Already initialized.
-			return nil
-		}
-		// As we're switching profiles, we need to reset the TKA to nil.
-		b.tka = nil
-	}
-	root := b.TailscaleVarRoot()
-	if root == "" {
-		b.tka = nil
-		b.logf("network-lock unavailable; no state directory")
-		return nil
-	}
-
-	chonkDir := b.chonkPathLocked()
-	if _, err := os.Stat(chonkDir); err == nil {
-		// The directory exists, which means network-lock has been initialized.
-		storage, err := tka.ChonkDir(chonkDir)
-		if err != nil {
-			return fmt.Errorf("opening tailchonk: %v", err)
-		}
-		authority, err := tka.Open(storage)
-		if err != nil {
-			return fmt.Errorf("initializing tka: %v", err)
-		}
-		if err := authority.Compact(storage, tkaCompactionDefaults); err != nil {
-			b.logf("tka compaction failed: %v", err)
-		}
-
-		b.tka = &tkaState{
-			profile:   cp.ID(),
-			authority: authority,
-			storage:   storage,
-		}
-		b.logf("tka initialized at head %x", authority.Head())
-	}
-
-	return nil
-}
-
 // resetDialPlan resets the dialPlan for this LocalBackend. It will log if
 // anything is reset.
 //

+ 49 - 0
ipn/ipnlocal/network-lock.go

@@ -1,6 +1,8 @@
 // Copyright (c) Tailscale Inc & AUTHORS
 // SPDX-License-Identifier: BSD-3-Clause
 
+//go:build !ts_omit_tailnetlock
+
 package ipnlocal
 
 import (
@@ -56,6 +58,53 @@ type tkaState struct {
 	filtered  []ipnstate.TKAPeer
 }
 
+func (b *LocalBackend) initTKALocked() error {
+	cp := b.pm.CurrentProfile()
+	if cp.ID() == "" {
+		b.tka = nil
+		return nil
+	}
+	if b.tka != nil {
+		if b.tka.profile == cp.ID() {
+			// Already initialized.
+			return nil
+		}
+		// As we're switching profiles, we need to reset the TKA to nil.
+		b.tka = nil
+	}
+	root := b.TailscaleVarRoot()
+	if root == "" {
+		b.tka = nil
+		b.logf("network-lock unavailable; no state directory")
+		return nil
+	}
+
+	chonkDir := b.chonkPathLocked()
+	if _, err := os.Stat(chonkDir); err == nil {
+		// The directory exists, which means network-lock has been initialized.
+		storage, err := tka.ChonkDir(chonkDir)
+		if err != nil {
+			return fmt.Errorf("opening tailchonk: %v", err)
+		}
+		authority, err := tka.Open(storage)
+		if err != nil {
+			return fmt.Errorf("initializing tka: %v", err)
+		}
+		if err := authority.Compact(storage, tkaCompactionDefaults); err != nil {
+			b.logf("tka compaction failed: %v", err)
+		}
+
+		b.tka = &tkaState{
+			profile:   cp.ID(),
+			authority: authority,
+			storage:   storage,
+		}
+		b.logf("tka initialized at head %x", authority.Head())
+	}
+
+	return nil
+}
+
 // tkaFilterNetmapLocked checks the signatures on each node key, dropping
 // nodes from the netmap whose signature does not verify.
 //

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

@@ -1,6 +1,8 @@
 // Copyright (c) Tailscale Inc & AUTHORS
 // SPDX-License-Identifier: BSD-3-Clause
 
+//go:build !ts_omit_tailnetlock
+
 package ipnlocal
 
 import (

+ 31 - 0
ipn/ipnlocal/tailnetlock_disabled.go

@@ -0,0 +1,31 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build ts_omit_tailnetlock
+
+package ipnlocal
+
+import (
+	"tailscale.com/ipn"
+	"tailscale.com/ipn/ipnstate"
+	"tailscale.com/tka"
+	"tailscale.com/types/netmap"
+)
+
+type tkaState struct {
+	authority *tka.Authority
+}
+
+func (b *LocalBackend) initTKALocked() error {
+	return nil
+}
+
+func (b *LocalBackend) tkaSyncIfNeeded(nm *netmap.NetworkMap, prefs ipn.PrefsView) error {
+	return nil
+}
+
+func (b *LocalBackend) tkaFilterNetmapLocked(nm *netmap.NetworkMap) {}
+
+func (b *LocalBackend) NetworkLockStatus() *ipnstate.NetworkLockStatus {
+	return &ipnstate.NetworkLockStatus{Enabled: false}
+}

+ 0 - 394
ipn/localapi/localapi.go

@@ -41,14 +41,12 @@ import (
 	"tailscale.com/net/netutil"
 	"tailscale.com/net/portmapper"
 	"tailscale.com/tailcfg"
-	"tailscale.com/tka"
 	"tailscale.com/tstime"
 	"tailscale.com/types/dnstype"
 	"tailscale.com/types/key"
 	"tailscale.com/types/logger"
 	"tailscale.com/types/logid"
 	"tailscale.com/types/ptr"
-	"tailscale.com/types/tkatype"
 	"tailscale.com/util/clientmetric"
 	"tailscale.com/util/eventbus"
 	"tailscale.com/util/httpm"
@@ -124,19 +122,6 @@ var handler = map[string]LocalAPIHandler{
 	"start":                        (*Handler).serveStart,
 	"status":                       (*Handler).serveStatus,
 	"suggest-exit-node":            (*Handler).serveSuggestExitNode,
-	"tka/affected-sigs":            (*Handler).serveTKAAffectedSigs,
-	"tka/cosign-recovery-aum":      (*Handler).serveTKACosignRecoveryAUM,
-	"tka/disable":                  (*Handler).serveTKADisable,
-	"tka/force-local-disable":      (*Handler).serveTKALocalDisable,
-	"tka/generate-recovery-aum":    (*Handler).serveTKAGenerateRecoveryAUM,
-	"tka/init":                     (*Handler).serveTKAInit,
-	"tka/log":                      (*Handler).serveTKALog,
-	"tka/modify":                   (*Handler).serveTKAModify,
-	"tka/sign":                     (*Handler).serveTKASign,
-	"tka/status":                   (*Handler).serveTKAStatus,
-	"tka/submit-recovery-aum":      (*Handler).serveTKASubmitRecoveryAUM,
-	"tka/verify-deeplink":          (*Handler).serveTKAVerifySigningDeeplink,
-	"tka/wrap-preauth-key":         (*Handler).serveTKAWrapPreauthKey,
 	"update/check":                 (*Handler).serveUpdateCheck,
 	"update/install":               (*Handler).serveUpdateInstall,
 	"update/progress":              (*Handler).serveUpdateProgress,
@@ -1892,25 +1877,6 @@ func (h *Handler) serveUploadClientMetrics(w http.ResponseWriter, r *http.Reques
 	json.NewEncoder(w).Encode(struct{}{})
 }
 
-func (h *Handler) serveTKAStatus(w http.ResponseWriter, r *http.Request) {
-	if !h.PermitRead {
-		http.Error(w, "lock status access denied", http.StatusForbidden)
-		return
-	}
-	if r.Method != httpm.GET {
-		http.Error(w, "use GET", http.StatusMethodNotAllowed)
-		return
-	}
-
-	j, err := json.MarshalIndent(h.b.NetworkLockStatus(), "", "\t")
-	if err != nil {
-		http.Error(w, "JSON encoding error", http.StatusInternalServerError)
-		return
-	}
-	w.Header().Set("Content-Type", "application/json")
-	w.Write(j)
-}
-
 func (h *Handler) serveSetGUIVisible(w http.ResponseWriter, r *http.Request) {
 	if r.Method != httpm.POST {
 		http.Error(w, "use POST", http.StatusMethodNotAllowed)
@@ -1958,366 +1924,6 @@ func (h *Handler) serveSetUseExitNodeEnabled(w http.ResponseWriter, r *http.Requ
 	e.Encode(prefs)
 }
 
-func (h *Handler) serveTKASign(w http.ResponseWriter, r *http.Request) {
-	if !h.PermitWrite {
-		http.Error(w, "lock sign access denied", http.StatusForbidden)
-		return
-	}
-	if r.Method != httpm.POST {
-		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)
-		return
-	}
-	if r.Method != httpm.POST {
-		http.Error(w, "use POST", http.StatusMethodNotAllowed)
-		return
-	}
-
-	type initRequest struct {
-		Keys               []tka.Key
-		DisablementValues  [][]byte
-		SupportDisablement []byte
-	}
-	var req initRequest
-	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-		http.Error(w, "invalid JSON body", http.StatusBadRequest)
-		return
-	}
-
-	if !h.b.NetworkLockAllowed() {
-		http.Error(w, "Tailnet Lock is not supported on your pricing plan", http.StatusForbidden)
-		return
-	}
-
-	if err := h.b.NetworkLockInit(req.Keys, req.DisablementValues, req.SupportDisablement); err != nil {
-		http.Error(w, "initialization failed: "+err.Error(), http.StatusInternalServerError)
-		return
-	}
-
-	j, err := json.MarshalIndent(h.b.NetworkLockStatus(), "", "\t")
-	if err != nil {
-		http.Error(w, "JSON encoding error", http.StatusInternalServerError)
-		return
-	}
-	w.Header().Set("Content-Type", "application/json")
-	w.Write(j)
-}
-
-func (h *Handler) serveTKAModify(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 modifyRequest struct {
-		AddKeys    []tka.Key
-		RemoveKeys []tka.Key
-	}
-	var req modifyRequest
-	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-		http.Error(w, "invalid JSON body", http.StatusBadRequest)
-		return
-	}
-
-	if err := h.b.NetworkLockModify(req.AddKeys, req.RemoveKeys); err != nil {
-		http.Error(w, "network-lock modify failed: "+err.Error(), http.StatusInternalServerError)
-		return
-	}
-	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(http.StatusOK)
-	w.Write([]byte(wrappedKey))
-}
-
-func (h *Handler) serveTKAVerifySigningDeeplink(w http.ResponseWriter, r *http.Request) {
-	if !h.PermitRead {
-		http.Error(w, "signing deeplink verification access denied", http.StatusForbidden)
-		return
-	}
-	if r.Method != httpm.POST {
-		http.Error(w, "use POST", http.StatusMethodNotAllowed)
-		return
-	}
-
-	type verifyRequest struct {
-		URL string
-	}
-	var req verifyRequest
-	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-		http.Error(w, "invalid JSON for verifyRequest body", http.StatusBadRequest)
-		return
-	}
-
-	res := h.b.NetworkLockVerifySigningDeeplink(req.URL)
-	j, err := json.MarshalIndent(res, "", "\t")
-	if err != nil {
-		http.Error(w, "JSON encoding error", http.StatusInternalServerError)
-		return
-	}
-	w.Header().Set("Content-Type", "application/json")
-	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 != httpm.POST {
-		http.Error(w, "use POST", http.StatusMethodNotAllowed)
-		return
-	}
-
-	body := io.LimitReader(r.Body, 1024*1024)
-	secret, err := io.ReadAll(body)
-	if err != nil {
-		http.Error(w, "reading secret", http.StatusBadRequest)
-		return
-	}
-
-	if err := h.b.NetworkLockDisable(secret); err != nil {
-		http.Error(w, "network-lock disable failed: "+err.Error(), http.StatusBadRequest)
-		return
-	}
-	w.WriteHeader(http.StatusOK)
-}
-
-func (h *Handler) serveTKALocalDisable(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
-	}
-
-	// Require a JSON stanza for the body as an additional CSRF protection.
-	var req struct{}
-	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-		http.Error(w, "invalid JSON body", http.StatusBadRequest)
-		return
-	}
-
-	if err := h.b.NetworkLockForceLocalDisable(); err != nil {
-		http.Error(w, "network-lock local disable failed: "+err.Error(), http.StatusBadRequest)
-		return
-	}
-	w.WriteHeader(http.StatusOK)
-}
-
-func (h *Handler) serveTKALog(w http.ResponseWriter, r *http.Request) {
-	if r.Method != httpm.GET {
-		http.Error(w, "use GET", http.StatusMethodNotAllowed)
-		return
-	}
-
-	limit := 50
-	if limitStr := r.FormValue("limit"); limitStr != "" {
-		l, err := strconv.Atoi(limitStr)
-		if err != nil {
-			http.Error(w, "parsing 'limit' parameter: "+err.Error(), http.StatusBadRequest)
-			return
-		}
-		limit = int(l)
-	}
-
-	updates, err := h.b.NetworkLockLog(limit)
-	if err != nil {
-		http.Error(w, "reading log failed: "+err.Error(), http.StatusInternalServerError)
-		return
-	}
-
-	j, err := json.MarshalIndent(updates, "", "\t")
-	if err != nil {
-		http.Error(w, "JSON encoding error", http.StatusInternalServerError)
-		return
-	}
-	w.Header().Set("Content-Type", "application/json")
-	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 := io.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)
-}
-
-func (h *Handler) serveTKAGenerateRecoveryAUM(w http.ResponseWriter, r *http.Request) {
-	if !h.PermitWrite {
-		http.Error(w, "access denied", http.StatusForbidden)
-		return
-	}
-	if r.Method != httpm.POST {
-		http.Error(w, "use POST", http.StatusMethodNotAllowed)
-		return
-	}
-
-	type verifyRequest struct {
-		Keys     []tkatype.KeyID
-		ForkFrom string
-	}
-	var req verifyRequest
-	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-		http.Error(w, "invalid JSON for verifyRequest body", http.StatusBadRequest)
-		return
-	}
-
-	var forkFrom tka.AUMHash
-	if req.ForkFrom != "" {
-		if err := forkFrom.UnmarshalText([]byte(req.ForkFrom)); err != nil {
-			http.Error(w, "decoding fork-from: "+err.Error(), http.StatusBadRequest)
-			return
-		}
-	}
-
-	res, err := h.b.NetworkLockGenerateRecoveryAUM(req.Keys, forkFrom)
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-	w.Header().Set("Content-Type", "application/octet-stream")
-	w.Write(res.Serialize())
-}
-
-func (h *Handler) serveTKACosignRecoveryAUM(w http.ResponseWriter, r *http.Request) {
-	if !h.PermitWrite {
-		http.Error(w, "access denied", http.StatusForbidden)
-		return
-	}
-	if r.Method != httpm.POST {
-		http.Error(w, "use POST", http.StatusMethodNotAllowed)
-		return
-	}
-
-	body := io.LimitReader(r.Body, 1024*1024)
-	aumBytes, err := io.ReadAll(body)
-	if err != nil {
-		http.Error(w, "reading AUM", http.StatusBadRequest)
-		return
-	}
-	var aum tka.AUM
-	if err := aum.Unserialize(aumBytes); err != nil {
-		http.Error(w, "decoding AUM", http.StatusBadRequest)
-		return
-	}
-
-	res, err := h.b.NetworkLockCosignRecoveryAUM(&aum)
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-	w.Header().Set("Content-Type", "application/octet-stream")
-	w.Write(res.Serialize())
-}
-
-func (h *Handler) serveTKASubmitRecoveryAUM(w http.ResponseWriter, r *http.Request) {
-	if !h.PermitWrite {
-		http.Error(w, "access denied", http.StatusForbidden)
-		return
-	}
-	if r.Method != httpm.POST {
-		http.Error(w, "use POST", http.StatusMethodNotAllowed)
-		return
-	}
-
-	body := io.LimitReader(r.Body, 1024*1024)
-	aumBytes, err := io.ReadAll(body)
-	if err != nil {
-		http.Error(w, "reading AUM", http.StatusBadRequest)
-		return
-	}
-	var aum tka.AUM
-	if err := aum.Unserialize(aumBytes); err != nil {
-		http.Error(w, "decoding AUM", http.StatusBadRequest)
-		return
-	}
-
-	if err := h.b.NetworkLockSubmitRecoveryAUM(&aum); err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-	w.WriteHeader(http.StatusOK)
-}
-
 // serveProfiles serves profile switching-related endpoints. Supported methods
 // and paths are:
 //   - GET /profiles/: list all profiles (JSON-encoded array of ipn.LoginProfiles)

+ 413 - 0
ipn/localapi/tailnetlock.go

@@ -0,0 +1,413 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build !ts_omit_tailnetlock
+
+package localapi
+
+import (
+	"encoding/json"
+	"io"
+	"net/http"
+	"strconv"
+
+	"tailscale.com/tka"
+	"tailscale.com/types/key"
+	"tailscale.com/types/tkatype"
+	"tailscale.com/util/httpm"
+)
+
+func init() {
+	handler["tka/affected-sigs"] = (*Handler).serveTKAAffectedSigs
+	handler["tka/cosign-recovery-aum"] = (*Handler).serveTKACosignRecoveryAUM
+	handler["tka/disable"] = (*Handler).serveTKADisable
+	handler["tka/force-local-disable"] = (*Handler).serveTKALocalDisable
+	handler["tka/generate-recovery-aum"] = (*Handler).serveTKAGenerateRecoveryAUM
+	handler["tka/init"] = (*Handler).serveTKAInit
+	handler["tka/log"] = (*Handler).serveTKALog
+	handler["tka/modify"] = (*Handler).serveTKAModify
+	handler["tka/sign"] = (*Handler).serveTKASign
+	handler["tka/status"] = (*Handler).serveTKAStatus
+	handler["tka/submit-recovery-aum"] = (*Handler).serveTKASubmitRecoveryAUM
+	handler["tka/verify-deeplink"] = (*Handler).serveTKAVerifySigningDeeplink
+	handler["tka/wrap-preauth-key"] = (*Handler).serveTKAWrapPreauthKey
+}
+
+func (h *Handler) serveTKAStatus(w http.ResponseWriter, r *http.Request) {
+	if !h.PermitRead {
+		http.Error(w, "lock status access denied", http.StatusForbidden)
+		return
+	}
+	if r.Method != httpm.GET {
+		http.Error(w, "use GET", http.StatusMethodNotAllowed)
+		return
+	}
+
+	j, err := json.MarshalIndent(h.b.NetworkLockStatus(), "", "\t")
+	if err != nil {
+		http.Error(w, "JSON encoding error", http.StatusInternalServerError)
+		return
+	}
+	w.Header().Set("Content-Type", "application/json")
+	w.Write(j)
+}
+
+func (h *Handler) serveTKASign(w http.ResponseWriter, r *http.Request) {
+	if !h.PermitWrite {
+		http.Error(w, "lock sign access denied", http.StatusForbidden)
+		return
+	}
+	if r.Method != httpm.POST {
+		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)
+		return
+	}
+	if r.Method != httpm.POST {
+		http.Error(w, "use POST", http.StatusMethodNotAllowed)
+		return
+	}
+
+	type initRequest struct {
+		Keys               []tka.Key
+		DisablementValues  [][]byte
+		SupportDisablement []byte
+	}
+	var req initRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, "invalid JSON body", http.StatusBadRequest)
+		return
+	}
+
+	if !h.b.NetworkLockAllowed() {
+		http.Error(w, "Tailnet Lock is not supported on your pricing plan", http.StatusForbidden)
+		return
+	}
+
+	if err := h.b.NetworkLockInit(req.Keys, req.DisablementValues, req.SupportDisablement); err != nil {
+		http.Error(w, "initialization failed: "+err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	j, err := json.MarshalIndent(h.b.NetworkLockStatus(), "", "\t")
+	if err != nil {
+		http.Error(w, "JSON encoding error", http.StatusInternalServerError)
+		return
+	}
+	w.Header().Set("Content-Type", "application/json")
+	w.Write(j)
+}
+
+func (h *Handler) serveTKAModify(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 modifyRequest struct {
+		AddKeys    []tka.Key
+		RemoveKeys []tka.Key
+	}
+	var req modifyRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, "invalid JSON body", http.StatusBadRequest)
+		return
+	}
+
+	if err := h.b.NetworkLockModify(req.AddKeys, req.RemoveKeys); err != nil {
+		http.Error(w, "network-lock modify failed: "+err.Error(), http.StatusInternalServerError)
+		return
+	}
+	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(http.StatusOK)
+	w.Write([]byte(wrappedKey))
+}
+
+func (h *Handler) serveTKAVerifySigningDeeplink(w http.ResponseWriter, r *http.Request) {
+	if !h.PermitRead {
+		http.Error(w, "signing deeplink verification access denied", http.StatusForbidden)
+		return
+	}
+	if r.Method != httpm.POST {
+		http.Error(w, "use POST", http.StatusMethodNotAllowed)
+		return
+	}
+
+	type verifyRequest struct {
+		URL string
+	}
+	var req verifyRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, "invalid JSON for verifyRequest body", http.StatusBadRequest)
+		return
+	}
+
+	res := h.b.NetworkLockVerifySigningDeeplink(req.URL)
+	j, err := json.MarshalIndent(res, "", "\t")
+	if err != nil {
+		http.Error(w, "JSON encoding error", http.StatusInternalServerError)
+		return
+	}
+	w.Header().Set("Content-Type", "application/json")
+	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 != httpm.POST {
+		http.Error(w, "use POST", http.StatusMethodNotAllowed)
+		return
+	}
+
+	body := io.LimitReader(r.Body, 1024*1024)
+	secret, err := io.ReadAll(body)
+	if err != nil {
+		http.Error(w, "reading secret", http.StatusBadRequest)
+		return
+	}
+
+	if err := h.b.NetworkLockDisable(secret); err != nil {
+		http.Error(w, "network-lock disable failed: "+err.Error(), http.StatusBadRequest)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+}
+
+func (h *Handler) serveTKALocalDisable(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
+	}
+
+	// Require a JSON stanza for the body as an additional CSRF protection.
+	var req struct{}
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, "invalid JSON body", http.StatusBadRequest)
+		return
+	}
+
+	if err := h.b.NetworkLockForceLocalDisable(); err != nil {
+		http.Error(w, "network-lock local disable failed: "+err.Error(), http.StatusBadRequest)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+}
+
+func (h *Handler) serveTKALog(w http.ResponseWriter, r *http.Request) {
+	if r.Method != httpm.GET {
+		http.Error(w, "use GET", http.StatusMethodNotAllowed)
+		return
+	}
+
+	limit := 50
+	if limitStr := r.FormValue("limit"); limitStr != "" {
+		l, err := strconv.Atoi(limitStr)
+		if err != nil {
+			http.Error(w, "parsing 'limit' parameter: "+err.Error(), http.StatusBadRequest)
+			return
+		}
+		limit = int(l)
+	}
+
+	updates, err := h.b.NetworkLockLog(limit)
+	if err != nil {
+		http.Error(w, "reading log failed: "+err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	j, err := json.MarshalIndent(updates, "", "\t")
+	if err != nil {
+		http.Error(w, "JSON encoding error", http.StatusInternalServerError)
+		return
+	}
+	w.Header().Set("Content-Type", "application/json")
+	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 := io.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)
+}
+
+func (h *Handler) serveTKAGenerateRecoveryAUM(w http.ResponseWriter, r *http.Request) {
+	if !h.PermitWrite {
+		http.Error(w, "access denied", http.StatusForbidden)
+		return
+	}
+	if r.Method != httpm.POST {
+		http.Error(w, "use POST", http.StatusMethodNotAllowed)
+		return
+	}
+
+	type verifyRequest struct {
+		Keys     []tkatype.KeyID
+		ForkFrom string
+	}
+	var req verifyRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, "invalid JSON for verifyRequest body", http.StatusBadRequest)
+		return
+	}
+
+	var forkFrom tka.AUMHash
+	if req.ForkFrom != "" {
+		if err := forkFrom.UnmarshalText([]byte(req.ForkFrom)); err != nil {
+			http.Error(w, "decoding fork-from: "+err.Error(), http.StatusBadRequest)
+			return
+		}
+	}
+
+	res, err := h.b.NetworkLockGenerateRecoveryAUM(req.Keys, forkFrom)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	w.Header().Set("Content-Type", "application/octet-stream")
+	w.Write(res.Serialize())
+}
+
+func (h *Handler) serveTKACosignRecoveryAUM(w http.ResponseWriter, r *http.Request) {
+	if !h.PermitWrite {
+		http.Error(w, "access denied", http.StatusForbidden)
+		return
+	}
+	if r.Method != httpm.POST {
+		http.Error(w, "use POST", http.StatusMethodNotAllowed)
+		return
+	}
+
+	body := io.LimitReader(r.Body, 1024*1024)
+	aumBytes, err := io.ReadAll(body)
+	if err != nil {
+		http.Error(w, "reading AUM", http.StatusBadRequest)
+		return
+	}
+	var aum tka.AUM
+	if err := aum.Unserialize(aumBytes); err != nil {
+		http.Error(w, "decoding AUM", http.StatusBadRequest)
+		return
+	}
+
+	res, err := h.b.NetworkLockCosignRecoveryAUM(&aum)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	w.Header().Set("Content-Type", "application/octet-stream")
+	w.Write(res.Serialize())
+}
+
+func (h *Handler) serveTKASubmitRecoveryAUM(w http.ResponseWriter, r *http.Request) {
+	if !h.PermitWrite {
+		http.Error(w, "access denied", http.StatusForbidden)
+		return
+	}
+	if r.Method != httpm.POST {
+		http.Error(w, "use POST", http.StatusMethodNotAllowed)
+		return
+	}
+
+	body := io.LimitReader(r.Body, 1024*1024)
+	aumBytes, err := io.ReadAll(body)
+	if err != nil {
+		http.Error(w, "reading AUM", http.StatusBadRequest)
+		return
+	}
+	var aum tka.AUM
+	if err := aum.Unserialize(aumBytes); err != nil {
+		http.Error(w, "decoding AUM", http.StatusBadRequest)
+		return
+	}
+
+	if err := h.b.NetworkLockSubmitRecoveryAUM(&aum); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+}

+ 2 - 0
tka/aum.go

@@ -1,6 +1,8 @@
 // Copyright (c) Tailscale Inc & AUTHORS
 // SPDX-License-Identifier: BSD-3-Clause
 
+//go:build !ts_omit_tailnetlock
+
 package tka
 
 import (

+ 2 - 0
tka/builder.go

@@ -1,6 +1,8 @@
 // Copyright (c) Tailscale Inc & AUTHORS
 // SPDX-License-Identifier: BSD-3-Clause
 
+//go:build !ts_omit_tailnetlock
+
 package tka
 
 import (

+ 2 - 0
tka/deeplink.go

@@ -1,6 +1,8 @@
 // Copyright (c) Tailscale Inc & AUTHORS
 // SPDX-License-Identifier: BSD-3-Clause
 
+//go:build !ts_omit_tailnetlock
+
 package tka
 
 import (

+ 149 - 0
tka/disabled_stub.go

@@ -0,0 +1,149 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build ts_omit_tailnetlock
+
+package tka
+
+import (
+	"crypto/ed25519"
+	"errors"
+
+	"tailscale.com/types/key"
+	"tailscale.com/types/logger"
+	"tailscale.com/types/tkatype"
+)
+
+type Authority struct {
+	head           AUM
+	oldestAncestor AUM
+	state          State
+}
+
+func (*Authority) Head() AUMHash { return AUMHash{} }
+
+func (AUMHash) MarshalText() ([]byte, error) { return nil, errNoTailnetLock }
+
+type State struct{}
+
+// AUMKind describes valid AUM types.
+type AUMKind uint8
+
+type AUMHash [32]byte
+
+type AUM struct {
+	MessageKind AUMKind `cbor:"1,keyasint"`
+	PrevAUMHash []byte  `cbor:"2,keyasint"`
+
+	// Key encodes a public key to be added to the key authority.
+	// This field is used for AddKey AUMs.
+	Key *Key `cbor:"3,keyasint,omitempty"`
+
+	// KeyID references a public key which is part of the key authority.
+	// This field is used for RemoveKey and UpdateKey AUMs.
+	KeyID tkatype.KeyID `cbor:"4,keyasint,omitempty"`
+
+	// State describes the full state of the key authority.
+	// This field is used for Checkpoint AUMs.
+	State *State `cbor:"5,keyasint,omitempty"`
+
+	// Votes and Meta describe properties of a key in the key authority.
+	// These fields are used for UpdateKey AUMs.
+	Votes *uint             `cbor:"6,keyasint,omitempty"`
+	Meta  map[string]string `cbor:"7,keyasint,omitempty"`
+
+	// Signatures lists the signatures over this AUM.
+	// CBOR key 23 is the last key which can be encoded as a single byte.
+	Signatures []tkatype.Signature `cbor:"23,keyasint,omitempty"`
+}
+
+type Chonk interface {
+	// AUM returns the AUM with the specified digest.
+	//
+	// If the AUM does not exist, then os.ErrNotExist is returned.
+	AUM(hash AUMHash) (AUM, error)
+
+	// ChildAUMs returns all AUMs with a specified previous
+	// AUM hash.
+	ChildAUMs(prevAUMHash AUMHash) ([]AUM, error)
+
+	// CommitVerifiedAUMs durably stores the provided AUMs.
+	// Callers MUST ONLY provide AUMs which are verified (specifically,
+	// a call to aumVerify() must return a nil error).
+	// as the implementation assumes that only verified AUMs are stored.
+	CommitVerifiedAUMs(updates []AUM) error
+
+	// Heads returns AUMs for which there are no children. In other
+	// words, the latest AUM in all possible chains (the 'leaves').
+	Heads() ([]AUM, error)
+
+	// SetLastActiveAncestor is called to record the oldest-known AUM
+	// that contributed to the current state. This value is used as
+	// a hint on next startup to determine which chain to pick when computing
+	// the current state, if there are multiple distinct chains.
+	SetLastActiveAncestor(hash AUMHash) error
+
+	// LastActiveAncestor returns the oldest-known AUM that was (in a
+	// previous run) an ancestor of the current state. This is used
+	// as a hint to pick the correct chain in the event that the Chonk stores
+	// multiple distinct chains.
+	LastActiveAncestor() (*AUMHash, error)
+}
+
+// SigKind describes valid NodeKeySignature types.
+type SigKind uint8
+
+type NodeKeySignature struct {
+	// SigKind identifies the variety of signature.
+	SigKind SigKind `cbor:"1,keyasint"`
+	// Pubkey identifies the key.NodePublic which is being authorized.
+	// SigCredential signatures do not use this field.
+	Pubkey []byte `cbor:"2,keyasint,omitempty"`
+
+	// KeyID identifies which key in the tailnet key authority should
+	// be used to verify this signature. Only set for SigDirect and
+	// SigCredential signature kinds.
+	KeyID []byte `cbor:"3,keyasint,omitempty"`
+
+	// Signature is the packed (R, S) ed25519 signature over all other
+	// fields of the structure.
+	Signature []byte `cbor:"4,keyasint,omitempty"`
+
+	// Nested describes a NodeKeySignature which authorizes the node-key
+	// used as Pubkey. Only used for SigRotation signatures.
+	Nested *NodeKeySignature `cbor:"5,keyasint,omitempty"`
+
+	// WrappingPubkey specifies the ed25519 public key which must be used
+	// to sign a Signature which embeds this one.
+	//
+	// For SigRotation signatures multiple levels deep, intermediate
+	// signatures may omit this value, in which case the parent WrappingPubkey
+	// is used.
+	//
+	// SigCredential signatures use this field to specify the public key
+	// they are certifying, following the usual semanticsfor WrappingPubkey.
+	WrappingPubkey []byte `cbor:"6,keyasint,omitempty"`
+}
+
+type DeeplinkValidationResult struct {
+}
+
+func (h *AUMHash) UnmarshalText(text []byte) error {
+	return errNoTailnetLock
+}
+
+var errNoTailnetLock = errors.New("tailnet lock is not enabled")
+
+func DecodeWrappedAuthkey(wrappedAuthKey string, logf logger.Logf) (authKey string, isWrapped bool, sig *NodeKeySignature, priv ed25519.PrivateKey) {
+	return wrappedAuthKey, false, nil, nil
+}
+
+func ResignNKS(priv key.NLPrivate, nodeKey key.NodePublic, oldNKS tkatype.MarshaledSignature) (tkatype.MarshaledSignature, error) {
+	return nil, nil
+}
+
+func SignByCredential(privKey []byte, wrapped *NodeKeySignature, nodeKey key.NodePublic) (tkatype.MarshaledSignature, error) {
+	return nil, nil
+}
+
+func (s NodeKeySignature) String() string { return "" }

+ 2 - 0
tka/sig.go

@@ -1,6 +1,8 @@
 // Copyright (c) Tailscale Inc & AUTHORS
 // SPDX-License-Identifier: BSD-3-Clause
 
+//go:build !ts_omit_tailnetlock
+
 package tka
 
 import (

+ 2 - 0
tka/state.go

@@ -1,6 +1,8 @@
 // Copyright (c) Tailscale Inc & AUTHORS
 // SPDX-License-Identifier: BSD-3-Clause
 
+//go:build !ts_omit_tailnetlock
+
 package tka
 
 import (

+ 2 - 0
tka/state_test.go

@@ -1,6 +1,8 @@
 // Copyright (c) Tailscale Inc & AUTHORS
 // SPDX-License-Identifier: BSD-3-Clause
 
+//go:build !ts_omit_tailnetlock
+
 package tka
 
 import (

+ 2 - 0
tka/sync.go

@@ -1,6 +1,8 @@
 // Copyright (c) Tailscale Inc & AUTHORS
 // SPDX-License-Identifier: BSD-3-Clause
 
+//go:build !ts_omit_tailnetlock
+
 package tka
 
 import (

+ 2 - 0
tka/tailchonk.go

@@ -1,6 +1,8 @@
 // Copyright (c) Tailscale Inc & AUTHORS
 // SPDX-License-Identifier: BSD-3-Clause
 
+//go:build !ts_omit_tailnetlock
+
 package tka
 
 import (

+ 2 - 0
tka/tka.go

@@ -1,6 +1,8 @@
 // Copyright (c) Tailscale Inc & AUTHORS
 // SPDX-License-Identifier: BSD-3-Clause
 
+//go:build !ts_omit_tailnetlock
+
 // Package tka (WIP) implements the Tailnet Key Authority.
 package tka
 

+ 2 - 0
types/netlogtype/netlogtype_test.go

@@ -1,6 +1,8 @@
 // Copyright (c) Tailscale Inc & AUTHORS
 // SPDX-License-Identifier: BSD-3-Clause
 
+//go:build !ts_omit_tailnetlock
+
 package netlogtype
 
 import (