// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package ipnlocal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "os" "path/filepath" "time" "tailscale.com/envknob" "tailscale.com/ipn/ipnstate" "tailscale.com/logtail/backoff" "tailscale.com/tailcfg" "tailscale.com/tka" "tailscale.com/types/key" "tailscale.com/types/netmap" "tailscale.com/types/tkatype" ) var networkLockAvailable = envknob.RegisterBool("TS_EXPERIMENTAL_NETWORK_LOCK") type tkaState struct { authority *tka.Authority storage *tka.FS } // tkaSyncIfNeededLocked examines TKA info reported from the control plane, // performing the steps necessary to synchronize local tka state. // // There are 4 scenarios handled here: // - Enablement: nm.TKAEnabled but b.tka == nil // ∴ reach out to /machine/tka/boostrap to get the genesis AUM, then // initialize TKA. // - Disablement: !nm.TKAEnabled but b.tka != nil // ∴ reach out to /machine/tka/boostrap to read the disablement secret, // then verify and clear tka local state. // - Sync needed: b.tka.Head != nm.TKAHead // ∴ complete multi-step synchronization flow. // - Everything up to date: All other cases. // ∴ no action necessary. // // b.mu must be held. b.mu will be stepped out of (and back in) during network // RPCs. func (b *LocalBackend) tkaSyncIfNeededLocked(nm *netmap.NetworkMap) error { if !networkLockAvailable() { // If the feature flag is not enabled, pretend we don't exist. return nil } isEnabled := b.tka != nil wantEnabled := nm.TKAEnabled if isEnabled != wantEnabled { var ourHead tka.AUMHash if b.tka != nil { ourHead = b.tka.authority.Head() } // Regardless of whether we are moving to disabled or enabled, we // need information from the tka bootstrap endpoint. ourNodeKey := b.prefs.Persist.PrivateNodeKey.Public() b.mu.Unlock() bs, err := b.tkaFetchBootstrap(ourNodeKey, ourHead) b.mu.Lock() if err != nil { return fmt.Errorf("fetching bootstrap: %v", err) } if wantEnabled && !isEnabled { if err := b.tkaBootstrapFromGenesisLocked(bs.GenesisAUM); err != nil { return fmt.Errorf("bootstrap: %v", err) } 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) } } else { b.logf("Disablement secret did not verify, leaving TKA enabled.") } } else { return fmt.Errorf("[bug] unreachable invariant of wantEnabled /w isEnabled") } } if isEnabled && b.tka.authority.Head() != nm.TKAHead { // TODO(tom): Implement sync } return nil } // chonkPath returns the absolute path to the directory in which TKA // state (the 'tailchonk') is stored. func (b *LocalBackend) chonkPath() string { return filepath.Join(b.TailscaleVarRoot(), "tka") } // tkaBootstrapFromGenesisLocked initializes the local (on-disk) state of the // tailnet key authority, based on the given genesis AUM. // // b.mu must be held. func (b *LocalBackend) tkaBootstrapFromGenesisLocked(g tkatype.MarshaledAUM) error { if !b.CanSupportNetworkLock() { return errors.New("network lock not supported in this configuration") } var genesis tka.AUM if err := genesis.Unserialize(g); err != nil { return fmt.Errorf("reading genesis: %v", err) } chonkDir := b.chonkPath() if err := os.Mkdir(chonkDir, 0755); err != nil && !os.IsExist(err) { return fmt.Errorf("mkdir: %v", err) } chonk, err := tka.ChonkDir(chonkDir) if err != nil { return fmt.Errorf("chonk: %v", err) } authority, err := tka.Bootstrap(chonk, genesis) if err != nil { return fmt.Errorf("tka bootstrap: %v", err) } b.tka = &tkaState{ authority: authority, storage: chonk, } return nil } // CanSupportNetworkLock returns true if tailscaled is able to operate // a local tailnet key authority (and hence enforce network lock). func (b *LocalBackend) CanSupportNetworkLock() bool { if b.tka != nil { // The TKA is being used, so yeah its supported. return true } if b.TailscaleVarRoot() != "" { // Theres a var root (aka --statedir), so if network lock gets // initialized we have somewhere to store our AUMs. Thats all // we need. return true } return false } // NetworkLockStatus returns a structure describing the state of the // tailnet key authority, if any. func (b *LocalBackend) NetworkLockStatus() *ipnstate.NetworkLockStatus { if b.tka == nil { return &ipnstate.NetworkLockStatus{ Enabled: false, PublicKey: b.nlPrivKey.Public(), } } var head [32]byte h := b.tka.authority.Head() copy(head[:], h[:]) return &ipnstate.NetworkLockStatus{ Enabled: true, Head: &head, PublicKey: b.nlPrivKey.Public(), } } // NetworkLockInit enables network-lock for the tailnet, with the tailnets' // key authority initialized to trust the provided keys. // // Initialization involves two RPCs with control, termed 'begin' and 'finish'. // The Begin RPC transmits the genesis Authority Update Message, which // encodes the initial state of the authority, and the list of all nodes // 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 { if b.tka != nil { return errors.New("network-lock is already initialized") } if !networkLockAvailable() { return errors.New("this is an experimental feature in your version of tailscale - Please upgrade to the latest to use this.") } if !b.CanSupportNetworkLock() { return errors.New("network-lock is not supported in this configuration. Did you supply a --statedir?") } var ourNodeKey key.NodePublic b.mu.Lock() if b.prefs != nil { ourNodeKey = b.prefs.Persist.PrivateNodeKey.Public() } b.mu.Unlock() if ourNodeKey.IsZero() { return errors.New("no node-key: is tailscale logged in?") } // Generates a genesis AUM representing trust in the provided keys. // We use an in-memory tailchonk because we don't want to commit to // the filesystem until we've finished the initialization sequence, // 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)}, }, b.nlPrivKey) if err != nil { return fmt.Errorf("tka.Create: %v", err) } b.logf("Generated genesis AUM to initialize network lock, trusting the following keys:") for i, k := range genesisAUM.State.Keys { b.logf(" - key[%d] = nlpub:%x with %d votes", i, k.Public, k.Votes) } // Phase 1/2 of initialization: Transmit the genesis AUM to Control. initResp, err := b.tkaInitBegin(ourNodeKey, genesisAUM) if err != nil { return fmt.Errorf("tka init-begin RPC: %w", err) } // Our genesis AUM was accepted but before Control turns on enforcement of // node-key signatures, we need to sign keys for all the existing nodes. // If we don't get these signatures ahead of time, everyone will loose // connectivity because control won't have any signatures to send which // satisfy network-lock checks. sigs := make(map[tailcfg.NodeID]tkatype.MarshaledSignature, len(initResp.NeedSignatures)) for _, nodeInfo := range initResp.NeedSignatures { nks, err := signNodeKey(nodeInfo, b.nlPrivKey) if err != nil { return fmt.Errorf("generating signature: %v", err) } sigs[nodeInfo.NodeID] = nks.Serialize() } // Finalize enablement by transmitting signature for all nodes to Control. _, err = b.tkaInitFinish(ourNodeKey, sigs) return err } func signNodeKey(nodeInfo tailcfg.TKASignInfo, signer key.NLPrivate) (*tka.NodeKeySignature, error) { p, err := nodeInfo.NodePublic.MarshalBinary() if err != nil { return nil, err } sig := tka.NodeKeySignature{ SigKind: tka.SigDirect, KeyID: signer.KeyID(), Pubkey: p, WrappingPubkey: nodeInfo.RotationPubkey, } sig.Signature, err = signer.SignNKS(sig.SigHash()) if err != nil { return nil, fmt.Errorf("signature failed: %w", err) } return &sig, nil } func (b *LocalBackend) tkaInitBegin(ourNodeKey key.NodePublic, aum tka.AUM) (*tailcfg.TKAInitBeginResponse, error) { var req bytes.Buffer if err := json.NewEncoder(&req).Encode(tailcfg.TKAInitBeginRequest{ Version: tailcfg.CurrentCapabilityVersion, NodeKey: ourNodeKey, GenesisAUM: aum.Serialize(), }); err != nil { return nil, fmt.Errorf("encoding request: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() bo := backoff.NewBackoff("tka-init-begin", b.logf, 5*time.Second) for { if err := ctx.Err(); err != nil { return nil, fmt.Errorf("ctx: %w", err) } req, err := http.NewRequestWithContext(ctx, "GET", "https://unused/machine/tka/init/begin", &req) if err != nil { return nil, fmt.Errorf("req: %w", err) } res, err := b.DoNoiseRequest(req) if err != nil { bo.BackOff(ctx, err) continue } 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.TKAInitBeginResponse) err = json.NewDecoder(res.Body).Decode(a) res.Body.Close() if err != nil { return nil, fmt.Errorf("decoding JSON: %w", err) } return a, nil } } func (b *LocalBackend) tkaInitFinish(ourNodeKey key.NodePublic, nks map[tailcfg.NodeID]tkatype.MarshaledSignature) (*tailcfg.TKAInitFinishResponse, error) { var req bytes.Buffer if err := json.NewEncoder(&req).Encode(tailcfg.TKAInitFinishRequest{ Version: tailcfg.CurrentCapabilityVersion, NodeKey: ourNodeKey, Signatures: nks, }); err != nil { return nil, fmt.Errorf("encoding request: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() bo := backoff.NewBackoff("tka-init-finish", b.logf, 5*time.Second) for { if err := ctx.Err(); err != nil { return nil, fmt.Errorf("ctx: %w", err) } req, err := http.NewRequestWithContext(ctx, "GET", "https://unused/machine/tka/init/finish", &req) if err != nil { return nil, fmt.Errorf("req: %w", err) } res, err := b.DoNoiseRequest(req) if err != nil { bo.BackOff(ctx, err) continue } 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.TKAInitFinishResponse) err = json.NewDecoder(res.Body).Decode(a) res.Body.Close() if err != nil { return nil, fmt.Errorf("decoding JSON: %w", err) } return a, nil } } // tkaFetchBootstrap sends a /machine/tka/bootstrap RPC to the control plane // over noise. This is used to get values necessary to enable or disable TKA. func (b *LocalBackend) tkaFetchBootstrap(ourNodeKey key.NodePublic, head tka.AUMHash) (*tailcfg.TKABootstrapResponse, error) { bootstrapReq := tailcfg.TKABootstrapRequest{ Version: tailcfg.CurrentCapabilityVersion, NodeKey: ourNodeKey, } if !head.IsZero() { head, err := head.MarshalText() if err != nil { return nil, fmt.Errorf("head.MarshalText failed: %v", err) } bootstrapReq.Head = string(head) } var req bytes.Buffer if err := json.NewEncoder(&req).Encode(bootstrapReq); err != nil { return nil, fmt.Errorf("encoding request: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() if err := ctx.Err(); err != nil { return nil, fmt.Errorf("ctx: %w", err) } req2, err := http.NewRequestWithContext(ctx, "GET", "https://unused/machine/tka/bootstrap", &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.TKABootstrapResponse) err = json.NewDecoder(res.Body).Decode(a) res.Body.Close() if err != nil { return nil, fmt.Errorf("decoding JSON: %w", err) } return a, nil }