Files
tailscale-custom/cmd/tailscale/cli/network-lock.go
T
Will Norris 3ec5be3f51 all: remove AUTHORS file and references to it
This file was never truly necessary and has never actually been used in
the history of Tailscale's open source releases.

A Brief History of AUTHORS files
---

The AUTHORS file was a pattern developed at Google, originally for
Chromium, then adopted by Go and a bunch of other projects. The problem
was that Chromium originally had a copyright line only recognizing
Google as the copyright holder. Because Google (and most open source
projects) do not require copyright assignemnt for contributions, each
contributor maintains their copyright. Some large corporate contributors
then tried to add their own name to the copyright line in the LICENSE
file or in file headers. This quickly becomes unwieldy, and puts a
tremendous burden on anyone building on top of Chromium, since the
license requires that they keep all copyright lines intact.

The compromise was to create an AUTHORS file that would list all of the
copyright holders. The LICENSE file and source file headers would then
include that list by reference, listing the copyright holder as "The
Chromium Authors".

This also become cumbersome to simply keep the file up to date with a
high rate of new contributors. Plus it's not always obvious who the
copyright holder is. Sometimes it is the individual making the
contribution, but many times it may be their employer. There is no way
for the proejct maintainer to know.

Eventually, Google changed their policy to no longer recommend trying to
keep the AUTHORS file up to date proactively, and instead to only add to
it when requested: https://opensource.google/docs/releasing/authors.
They are also clear that:

> Adding contributors to the AUTHORS file is entirely within the
> project's discretion and has no implications for copyright ownership.

It was primarily added to appease a small number of large contributors
that insisted that they be recognized as copyright holders (which was
entirely their right to do). But it's not truly necessary, and not even
the most accurate way of identifying contributors and/or copyright
holders.

In practice, we've never added anyone to our AUTHORS file. It only lists
Tailscale, so it's not really serving any purpose. It also causes
confusion because Tailscalars put the "Tailscale Inc & AUTHORS" header
in other open source repos which don't actually have an AUTHORS file, so
it's ambiguous what that means.

Instead, we just acknowledge that the contributors to Tailscale (whoever
they are) are copyright holders for their individual contributions. We
also have the benefit of using the DCO (developercertificate.org) which
provides some additional certification of their right to make the
contribution.

The source file changes were purely mechanical with:

    git ls-files | xargs sed -i -e 's/\(Tailscale Inc &\) AUTHORS/\1 contributors/g'

Updates #cleanup

Change-Id: Ia101a4a3005adb9118051b3416f5a64a4a45987d
Signed-off-by: Will Norris <will@tailscale.com>
2026-01-23 15:49:45 -08:00

897 lines
28 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_tailnetlock
package cli
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
jsonv1 "encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
"github.com/mattn/go-isatty"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/cmd/tailscale/cli/jsonoutput"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tka"
"tailscale.com/tsconst"
"tailscale.com/types/key"
"tailscale.com/types/tkatype"
"tailscale.com/util/prompt"
)
func init() {
maybeNetlockCmd = func() *ffcli.Command { return netlockCmd }
}
var netlockCmd = &ffcli.Command{
Name: "lock",
ShortUsage: "tailscale lock <subcommand> [arguments...]",
ShortHelp: "Manage tailnet lock",
LongHelp: "Manage tailnet lock",
Subcommands: []*ffcli.Command{
nlInitCmd,
nlStatusCmd,
nlAddCmd,
nlRemoveCmd,
nlSignCmd,
nlDisableCmd,
nlDisablementKDFCmd,
nlLogCmd,
nlLocalDisableCmd,
nlRevokeKeysCmd,
},
Exec: runNetworkLockNoSubcommand,
}
func runNetworkLockNoSubcommand(ctx context.Context, args []string) error {
// Detect & handle the deprecated command 'lock tskey-wrap'.
if len(args) >= 2 && args[0] == "tskey-wrap" {
return runTskeyWrapCmd(ctx, args[1:])
}
if len(args) > 0 {
return fmt.Errorf("tailscale lock: unknown subcommand: %s", args[0])
}
return runNetworkLockStatus(ctx, args)
}
var nlInitArgs struct {
numDisablements int
disablementForSupport bool
confirm bool
}
var nlInitCmd = &ffcli.Command{
Name: "init",
ShortUsage: "tailscale lock init [--gen-disablement-for-support] --gen-disablements N <trusted-key>...",
ShortHelp: "Initialize tailnet lock",
LongHelp: strings.TrimSpace(`
The 'tailscale lock init' command initializes tailnet lock for the
entire tailnet. The tailnet lock keys specified are those initially
trusted to sign nodes or to make further changes to tailnet lock.
You can identify the tailnet lock key for a node you wish to trust by
running 'tailscale lock' on that node, and copying the node's tailnet
lock key.
To disable tailnet lock, use the 'tailscale lock disable' command
along with one of the disablement secrets.
The number of disablement secrets to be generated is specified using the
--gen-disablements flag. Initializing tailnet lock requires at least
one disablement.
If --gen-disablement-for-support is specified, an additional disablement secret
will be generated and transmitted to Tailscale, which support can use to disable
tailnet lock. We recommend setting this flag.
`),
Exec: runNetworkLockInit,
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("lock init")
fs.IntVar(&nlInitArgs.numDisablements, "gen-disablements", 1, "number of disablement secrets to generate")
fs.BoolVar(&nlInitArgs.disablementForSupport, "gen-disablement-for-support", false, "generates and transmits a disablement secret for Tailscale support")
fs.BoolVar(&nlInitArgs.confirm, "confirm", false, "do not prompt for confirmation")
return fs
})(),
}
func runNetworkLockInit(ctx context.Context, args []string) error {
st, err := localClient.NetworkLockStatus(ctx)
if err != nil {
return fixTailscaledConnectError(err)
}
if st.Enabled {
return errors.New("tailnet lock is already enabled")
}
// Parse initially-trusted keys & disablement values.
keys, disablementValues, err := parseNLArgs(args, true, true)
if err != nil {
return err
}
// Common mistake: Not specifying the current node's key as one of the trusted keys.
foundSelfKey := false
for _, k := range keys {
keyID, err := k.ID()
if err != nil {
return err
}
if bytes.Equal(keyID, st.PublicKey.KeyID()) {
foundSelfKey = true
break
}
}
if !foundSelfKey {
return errors.New("the tailnet lock key of the current node must be one of the trusted keys during initialization")
}
fmt.Println("You are initializing tailnet lock with the following trusted signing keys:")
for _, k := range keys {
fmt.Printf(" - tlpub:%x (%s key)\n", k.Public, k.Kind.String())
}
fmt.Println()
if !nlInitArgs.confirm {
fmt.Printf("%d disablement secrets will be generated.\n", nlInitArgs.numDisablements)
if nlInitArgs.disablementForSupport {
fmt.Println("A disablement secret will be generated and transmitted to Tailscale support.")
}
genSupportFlag := ""
if nlInitArgs.disablementForSupport {
genSupportFlag = "--gen-disablement-for-support "
}
fmt.Println("\nIf this is correct, please re-run this command with the --confirm flag:")
fmt.Printf("\t%s lock init --confirm --gen-disablements %d %s%s", os.Args[0], nlInitArgs.numDisablements, genSupportFlag, strings.Join(args, " "))
fmt.Println()
return nil
}
var successMsg strings.Builder
fmt.Fprintf(&successMsg, "%d disablement secrets have been generated and are printed below. Take note of them now, they WILL NOT be shown again.\n", nlInitArgs.numDisablements)
for range nlInitArgs.numDisablements {
var secret [32]byte
if _, err := rand.Read(secret[:]); err != nil {
return err
}
fmt.Fprintf(&successMsg, "\tdisablement-secret:%X\n", secret[:])
disablementValues = append(disablementValues, tka.DisablementKDF(secret[:]))
}
var supportDisablement []byte
if nlInitArgs.disablementForSupport {
supportDisablement = make([]byte, 32)
if _, err := rand.Read(supportDisablement); err != nil {
return err
}
disablementValues = append(disablementValues, tka.DisablementKDF(supportDisablement))
fmt.Fprintln(&successMsg, "A disablement secret for Tailscale support has been generated and transmitted to Tailscale.")
}
// The state returned by NetworkLockInit likely doesn't contain the initialized state,
// because that has to tick through from netmaps.
if _, err := localClient.NetworkLockInit(ctx, keys, disablementValues, supportDisablement); err != nil {
return err
}
fmt.Print(successMsg.String())
fmt.Println("Initialization complete.")
return nil
}
var nlStatusArgs struct {
json jsonoutput.JSONSchemaVersion
}
var nlStatusCmd = &ffcli.Command{
Name: "status",
ShortUsage: "tailscale lock status",
ShortHelp: "Output the state of tailnet lock",
Exec: runNetworkLockStatus,
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("lock status")
fs.Var(&nlStatusArgs.json, "json", "output in JSON format")
return fs
})(),
}
func runNetworkLockStatus(ctx context.Context, args []string) error {
if len(args) > 0 {
return fmt.Errorf("tailscale lock status: unexpected argument")
}
st, err := localClient.NetworkLockStatus(ctx)
if err != nil {
return fixTailscaledConnectError(err)
}
if nlStatusArgs.json.IsSet {
if nlStatusArgs.json.Value == 1 {
return jsonoutput.PrintNetworkLockStatusJSONV1(os.Stdout, st)
} else {
return fmt.Errorf("unrecognised version: %q", nlStatusArgs.json.Value)
}
}
if st.Enabled {
fmt.Println("Tailnet Lock is ENABLED.")
} else {
fmt.Println("Tailnet Lock is NOT enabled.")
}
fmt.Println()
if st.Enabled && st.NodeKey != nil && !st.PublicKey.IsZero() {
if st.NodeKeySigned {
fmt.Println("This node is accessible under Tailnet Lock. Node signature:")
fmt.Println(st.NodeKeySignature.String())
} else {
fmt.Println("This node is LOCKED OUT by Tailnet Lock, and action is required to establish connectivity.")
fmt.Printf("Run the following command on a node with a trusted key:\n\ttailscale lock sign %v %s\n", st.NodeKey, st.PublicKey.CLIString())
}
fmt.Println()
}
if !st.PublicKey.IsZero() {
fmt.Printf("This node's tailnet-lock key: %s\n", st.PublicKey.CLIString())
fmt.Println()
}
if st.Enabled && len(st.TrustedKeys) > 0 {
fmt.Println("Trusted signing keys:")
for _, k := range st.TrustedKeys {
var line strings.Builder
line.WriteString("\t")
line.WriteString(k.Key.CLIString())
line.WriteString("\t")
line.WriteString(fmt.Sprint(k.Votes))
line.WriteString("\t")
if k.Key == st.PublicKey {
line.WriteString("(self)")
}
if k.Metadata["purpose"] == "pre-auth key" {
if preauthKeyID := k.Metadata["authkey_stableid"]; preauthKeyID != "" {
line.WriteString("(pre-auth key ")
line.WriteString(preauthKeyID)
line.WriteString(")")
} else {
line.WriteString("(pre-auth key)")
}
}
fmt.Println(line.String())
}
}
if st.Enabled && len(st.FilteredPeers) > 0 {
fmt.Println()
fmt.Println("The following nodes are locked out by tailnet lock and cannot connect to other nodes:")
for _, p := range st.FilteredPeers {
var line strings.Builder
line.WriteString("\t")
line.WriteString(p.Name)
line.WriteString("\t")
for i, addr := range p.TailscaleIPs {
line.WriteString(addr.String())
if i < len(p.TailscaleIPs)-1 {
line.WriteString(",")
}
}
line.WriteString("\t")
line.WriteString(string(p.StableID))
line.WriteString("\t")
line.WriteString(p.NodeKey.String())
fmt.Println(line.String())
}
}
return nil
}
var nlAddCmd = &ffcli.Command{
Name: "add",
ShortUsage: "tailscale lock add <public-key>...",
ShortHelp: "Add one or more trusted signing keys to tailnet lock",
Exec: func(ctx context.Context, args []string) error {
return runNetworkLockModify(ctx, args, nil)
},
}
var nlRemoveArgs struct {
resign bool
}
var nlRemoveCmd = &ffcli.Command{
Name: "remove",
ShortUsage: "tailscale lock remove [--re-sign=false] <public-key>...",
ShortHelp: "Remove one or more trusted signing keys from tailnet lock",
Exec: runNetworkLockRemove,
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("lock remove")
fs.BoolVar(&nlRemoveArgs.resign, "re-sign", true, "resign signatures which would be invalidated by removal of trusted signing keys")
return fs
})(),
}
func runNetworkLockRemove(ctx context.Context, args []string) error {
removeKeys, _, err := parseNLArgs(args, true, false)
if err != nil {
return err
}
st, err := localClient.NetworkLockStatus(ctx)
if err != nil {
return fixTailscaledConnectError(err)
}
if !st.Enabled {
return errors.New("tailnet lock is not enabled")
}
if len(st.TrustedKeys) == 1 {
return errors.New("cannot remove the last trusted signing key; use 'tailscale lock disable' to disable tailnet lock instead, or add another signing key before removing one")
}
if nlRemoveArgs.resign {
// Validate we are not removing trust in ourselves while resigning. This is because
// we resign with our own key, so the signatures would be immediately invalid.
for _, k := range removeKeys {
kID, err := k.ID()
if err != nil {
return fmt.Errorf("computing KeyID for key %v: %w", k, err)
}
if bytes.Equal(st.PublicKey.KeyID(), kID) {
return errors.New("cannot remove local trusted signing key while resigning; run command on a different node or with --re-sign=false")
}
}
// Resign affected signatures for each of the keys we are removing.
for _, k := range removeKeys {
kID, _ := k.ID() // err already checked above
sigs, err := localClient.NetworkLockAffectedSigs(ctx, kID)
if err != nil {
return fmt.Errorf("affected sigs for key %X: %w", kID, err)
}
for _, sigBytes := range sigs {
var sig tka.NodeKeySignature
if err := sig.Unserialize(sigBytes); err != nil {
return fmt.Errorf("failed decoding signature: %w", err)
}
var nodeKey key.NodePublic
if err := nodeKey.UnmarshalBinary(sig.Pubkey); err != nil {
return fmt.Errorf("failed decoding pubkey for signature: %w", err)
}
// Safety: NetworkLockAffectedSigs() verifies all signatures before
// successfully returning.
rotationKey, _ := sig.UnverifiedWrappingPublic()
if err := localClient.NetworkLockSign(ctx, nodeKey, []byte(rotationKey)); err != nil {
return fmt.Errorf("failed to sign %v: %w", nodeKey, err)
}
}
}
} else {
if isatty.IsTerminal(os.Stdout.Fd()) {
fmt.Printf(`Warning
Removal of a signing key(s) without resigning nodes (--re-sign=false)
will cause any nodes signed by the the given key(s) to be locked out
of the Tailscale network. Proceed with caution.
`)
if !prompt.YesNo("Are you sure you want to remove the signing key(s)?", true) {
fmt.Printf("aborting removal of signing key(s)\n")
os.Exit(0)
}
}
}
return localClient.NetworkLockModify(ctx, nil, removeKeys)
}
// parseNLArgs parses a slice of strings into slices of tka.Key & disablement
// values/secrets.
// The keys encoded in args should be specified using their key.NLPublic.MarshalText
// representation with an optional '?<votes>' suffix.
// Disablement values or secrets must be encoded in hex with a prefix of 'disablement:' or
// 'disablement-secret:'.
//
// If any element could not be parsed,
// a nil slice is returned along with an appropriate error.
func parseNLArgs(args []string, parseKeys, parseDisablements bool) (keys []tka.Key, disablements [][]byte, err error) {
for i, a := range args {
if parseDisablements && (strings.HasPrefix(a, "disablement:") || strings.HasPrefix(a, "disablement-secret:")) {
b, err := hex.DecodeString(a[strings.Index(a, ":")+1:])
if err != nil {
return nil, nil, fmt.Errorf("parsing disablement %d: %v", i+1, err)
}
disablements = append(disablements, b)
continue
}
if !parseKeys {
return nil, nil, fmt.Errorf("parsing argument %d: expected value with \"disablement:\" or \"disablement-secret:\" prefix, got %q", i+1, a)
}
var nlpk key.NLPublic
spl := strings.SplitN(a, "?", 2)
if err := nlpk.UnmarshalText([]byte(spl[0])); err != nil {
return nil, nil, fmt.Errorf("parsing key %d: %v", i+1, err)
}
k := tka.Key{
Kind: tka.Key25519,
Public: nlpk.Verifier(),
Votes: 1,
}
if len(spl) > 1 {
votes, err := strconv.Atoi(spl[1])
if err != nil {
return nil, nil, fmt.Errorf("parsing key %d votes: %v", i+1, err)
}
k.Votes = uint(votes)
}
keys = append(keys, k)
}
return keys, disablements, nil
}
func runNetworkLockModify(ctx context.Context, addArgs, removeArgs []string) error {
st, err := localClient.NetworkLockStatus(ctx)
if err != nil {
return fixTailscaledConnectError(err)
}
if !st.Enabled {
return errors.New("tailnet lock is not enabled")
}
addKeys, _, err := parseNLArgs(addArgs, true, false)
if err != nil {
return err
}
removeKeys, _, err := parseNLArgs(removeArgs, true, false)
if err != nil {
return err
}
if err := localClient.NetworkLockModify(ctx, addKeys, removeKeys); err != nil {
return err
}
return nil
}
var nlSignCmd = &ffcli.Command{
Name: "sign",
ShortUsage: "tailscale lock sign <node-key> [<rotation-key>]\ntailscale lock sign <auth-key>",
ShortHelp: "Sign a node or pre-approved auth key",
LongHelp: `Either:
- signs a node key and transmits the signature to the coordination
server, or
- signs a pre-approved auth key, printing it in a form that can be
used to bring up nodes under tailnet lock
If any of the key arguments begin with "file:", the key is retrieved from
the file at the path specified in the argument suffix.`,
Exec: runNetworkLockSign,
}
func runNetworkLockSign(ctx context.Context, args []string) error {
// If any of the arguments start with "file:", replace that argument
// with the contents of the file. We do this early, before the check
// to see if the first argument is an auth key.
for i, arg := range args {
if filename, ok := strings.CutPrefix(arg, "file:"); ok {
b, err := os.ReadFile(filename)
if err != nil {
return err
}
args[i] = strings.TrimSpace(string(b))
}
}
if len(args) > 0 && strings.HasPrefix(args[0], "tskey-auth-") {
return runTskeyWrapCmd(ctx, args)
}
var (
nodeKey key.NodePublic
rotationKey key.NLPublic
)
if len(args) == 0 || len(args) > 2 {
return errors.New("usage: tailscale lock sign <node-key> [<rotation-key>]")
}
if err := nodeKey.UnmarshalText([]byte(args[0])); err != nil {
return fmt.Errorf("decoding node-key: %w", err)
}
if len(args) > 1 {
if err := rotationKey.UnmarshalText([]byte(args[1])); err != nil {
return fmt.Errorf("decoding rotation-key: %w", err)
}
}
err := localClient.NetworkLockSign(ctx, nodeKey, []byte(rotationKey.Verifier()))
// Provide a better help message for when someone clicks through the signing flow
// on the wrong device.
if err != nil && strings.Contains(err.Error(), tsconst.TailnetLockNotTrustedMsg) {
fmt.Fprintln(Stderr, "Error: Signing is not available on this device because it does not have a trusted tailnet lock key.")
fmt.Fprintln(Stderr)
fmt.Fprintln(Stderr, "Try again on a signing device instead. Tailnet admins can see signing devices on the admin panel.")
fmt.Fprintln(Stderr)
}
return err
}
var nlDisableCmd = &ffcli.Command{
Name: "disable",
ShortUsage: "tailscale lock disable <disablement-secret>",
ShortHelp: "Consume a disablement secret to shut down tailnet lock for the tailnet",
LongHelp: strings.TrimSpace(`
The 'tailscale lock disable' command uses the specified disablement
secret to disable tailnet lock.
If tailnet lock is re-enabled, new disablement secrets can be generated.
Once this secret is used, it has been distributed
to all nodes in the tailnet and should be considered public.
`),
Exec: runNetworkLockDisable,
}
func runNetworkLockDisable(ctx context.Context, args []string) error {
_, secrets, err := parseNLArgs(args, false, true)
if err != nil {
return err
}
if len(secrets) != 1 {
return errors.New("usage: tailscale lock disable <disablement-secret>")
}
return localClient.NetworkLockDisable(ctx, secrets[0])
}
var nlLocalDisableCmd = &ffcli.Command{
Name: "local-disable",
ShortUsage: "tailscale lock local-disable",
ShortHelp: "Disable tailnet lock for this node only",
LongHelp: strings.TrimSpace(`
The 'tailscale lock local-disable' command disables tailnet lock for only
the current node.
If the current node is locked out, this does not mean that it can initiate
connections in a tailnet with tailnet lock enabled. Rather, this means
that the current node will accept traffic from other nodes in the tailnet
that are locked out.
`),
Exec: runNetworkLockLocalDisable,
}
func runNetworkLockLocalDisable(ctx context.Context, args []string) error {
return localClient.NetworkLockForceLocalDisable(ctx)
}
var nlDisablementKDFCmd = &ffcli.Command{
Name: "disablement-kdf",
ShortUsage: "tailscale lock disablement-kdf <hex-encoded-disablement-secret>",
ShortHelp: "Compute a disablement value from a disablement secret (advanced users only)",
LongHelp: "Compute a disablement value from a disablement secret (advanced users only)",
Exec: runNetworkLockDisablementKDF,
}
func runNetworkLockDisablementKDF(ctx context.Context, args []string) error {
if len(args) != 1 {
return errors.New("usage: tailscale lock disablement-kdf <hex-encoded-disablement-secret>")
}
secret, err := hex.DecodeString(args[0])
if err != nil {
return err
}
fmt.Printf("disablement:%x\n", tka.DisablementKDF(secret))
return nil
}
var nlLogArgs struct {
limit int
json jsonoutput.JSONSchemaVersion
}
var nlLogCmd = &ffcli.Command{
Name: "log",
ShortUsage: "tailscale lock log [--limit N]",
ShortHelp: "List changes applied to tailnet lock",
LongHelp: "List changes applied to tailnet lock",
Exec: runNetworkLockLog,
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("lock log")
fs.IntVar(&nlLogArgs.limit, "limit", 50, "max number of updates to list")
fs.Var(&nlLogArgs.json, "json", "output in JSON format")
return fs
})(),
}
func nlDescribeUpdate(update ipnstate.NetworkLockUpdate, color bool) (string, error) {
terminalYellow := ""
terminalClear := ""
if color {
terminalYellow = "\x1b[33m"
terminalClear = "\x1b[0m"
}
var stanza strings.Builder
printKey := func(key *tka.Key, prefix string) {
fmt.Fprintf(&stanza, "%sType: %s\n", prefix, key.Kind.String())
if keyID, err := key.ID(); err == nil {
fmt.Fprintf(&stanza, "%sKeyID: tlpub:%x\n", prefix, keyID)
} else {
// Older versions of the client shouldn't explode when they encounter an
// unknown key type.
fmt.Fprintf(&stanza, "%sKeyID: <Error: %v>\n", prefix, err)
}
if key.Meta != nil {
fmt.Fprintf(&stanza, "%sMetadata: %+v\n", prefix, key.Meta)
}
}
var aum tka.AUM
if err := aum.Unserialize(update.Raw); err != nil {
return "", fmt.Errorf("decoding: %w", err)
}
tkaHead, err := aum.Hash().MarshalText()
if err != nil {
return "", fmt.Errorf("decoding AUM hash: %w", err)
}
fmt.Fprintf(&stanza, "%supdate %s (%s)%s\n", terminalYellow, string(tkaHead), update.Change, terminalClear)
switch update.Change {
case tka.AUMAddKey.String():
printKey(aum.Key, "")
case tka.AUMRemoveKey.String():
fmt.Fprintf(&stanza, "KeyID: tlpub:%x\n", aum.KeyID)
case tka.AUMUpdateKey.String():
fmt.Fprintf(&stanza, "KeyID: tlpub:%x\n", aum.KeyID)
if aum.Votes != nil {
fmt.Fprintf(&stanza, "Votes: %d\n", aum.Votes)
}
if aum.Meta != nil {
fmt.Fprintf(&stanza, "Metadata: %+v\n", aum.Meta)
}
case tka.AUMCheckpoint.String():
fmt.Fprintln(&stanza, "Disablement values:")
for _, v := range aum.State.DisablementSecrets {
fmt.Fprintf(&stanza, " - %x\n", v)
}
fmt.Fprintln(&stanza, "Keys:")
for _, k := range aum.State.Keys {
printKey(&k, " ")
}
default:
// Print a JSON encoding of the AUM as a fallback.
e := jsonv1.NewEncoder(&stanza)
e.SetIndent("", "\t")
if err := e.Encode(aum); err != nil {
return "", err
}
stanza.WriteRune('\n')
}
return stanza.String(), nil
}
func runNetworkLockLog(ctx context.Context, args []string) error {
st, err := localClient.NetworkLockStatus(ctx)
if err != nil {
return fixTailscaledConnectError(err)
}
if !st.Enabled {
return errors.New("Tailnet Lock is not enabled")
}
updates, err := localClient.NetworkLockLog(ctx, nlLogArgs.limit)
if err != nil {
return fixTailscaledConnectError(err)
}
out, useColor := colorableOutput()
return printNetworkLockLog(updates, out, nlLogArgs.json, useColor)
}
func printNetworkLockLog(updates []ipnstate.NetworkLockUpdate, out io.Writer, jsonSchema jsonoutput.JSONSchemaVersion, useColor bool) error {
if jsonSchema.IsSet {
if jsonSchema.Value == 1 {
return jsonoutput.PrintNetworkLockLogJSONV1(out, updates)
} else {
return fmt.Errorf("unrecognised version: %q", jsonSchema.Value)
}
}
for _, update := range updates {
stanza, err := nlDescribeUpdate(update, useColor)
if err != nil {
return err
}
fmt.Fprintln(out, stanza)
}
return nil
}
func runTskeyWrapCmd(ctx context.Context, args []string) error {
if len(args) != 1 {
return errors.New("usage: lock tskey-wrap <tailscale pre-auth key>")
}
if strings.Contains(args[0], "--TL") {
return errors.New("Error: provided key was already wrapped")
}
st, err := localClient.StatusWithoutPeers(ctx)
if err != nil {
return fixTailscaledConnectError(err)
}
return wrapAuthKey(ctx, args[0], st)
}
func wrapAuthKey(ctx context.Context, keyStr string, status *ipnstate.Status) error {
// Generate a separate tailnet-lock key just for the credential signature.
// We use the free-form meta strings to mark a little bit of metadata about this
// key.
priv := key.NewNLPrivate()
m := map[string]string{
"purpose": "pre-auth key",
"wrapper_stableid": string(status.Self.ID),
"wrapper_createtime": fmt.Sprint(time.Now().Unix()),
}
if strings.HasPrefix(keyStr, "tskey-auth-") && strings.Index(keyStr[len("tskey-auth-"):], "-") > 0 {
// We don't want to accidentally embed the nonce part of the authkey in
// the event the format changes. As such, we make sure its in the format we
// expect (tskey-auth-<stableID, inc CNTRL suffix>-nonce) before we parse
// out and embed the stableID.
s := strings.TrimPrefix(keyStr, "tskey-auth-")
m["authkey_stableid"] = s[:strings.Index(s, "-")]
}
k := tka.Key{
Kind: tka.Key25519,
Public: priv.Public().Verifier(),
Votes: 1,
Meta: m,
}
wrapped, err := localClient.NetworkLockWrapPreauthKey(ctx, keyStr, priv)
if err != nil {
return fmt.Errorf("wrapping failed: %w", err)
}
if err := localClient.NetworkLockModify(ctx, []tka.Key{k}, nil); err != nil {
return fmt.Errorf("add key failed: %w", err)
}
fmt.Println(wrapped)
return nil
}
var nlRevokeKeysArgs struct {
cosign bool
finish bool
forkFrom string
}
var nlRevokeKeysCmd = &ffcli.Command{
Name: "revoke-keys",
ShortUsage: "tailscale lock revoke-keys <tailnet-lock-key>...\n revoke-keys [--cosign] [--finish] <recovery-blob>",
ShortHelp: "Revoke compromised tailnet-lock keys",
LongHelp: `Retroactively revoke the specified tailnet lock keys (tlpub:abc).
Revoked keys are prevented from being used in the future. Any nodes previously signed
by revoked keys lose their authorization and must be signed again.
Revocation is a multi-step process that requires several signing nodes to ` + "`--cosign`" + ` the revocation. Use ` + "`tailscale lock remove`" + ` instead if the key has not been compromised.
1. To start, run ` + "`tailscale revoke-keys <tlpub-keys>`" + ` with the tailnet lock keys to revoke.
2. Re-run the ` + "`--cosign`" + ` command output by ` + "`revoke-keys`" + ` on other signing nodes. Use the
most recent command output on the next signing node in sequence.
3. Once the number of ` + "`--cosign`" + `s is greater than the number of keys being revoked,
run the command one final time with ` + "`--finish`" + ` instead of ` + "`--cosign`" + `.`,
Exec: runNetworkLockRevokeKeys,
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("lock revoke-keys")
fs.BoolVar(&nlRevokeKeysArgs.cosign, "cosign", false, "continue generating the recovery using the tailnet lock key on this device and the provided recovery blob")
fs.BoolVar(&nlRevokeKeysArgs.finish, "finish", false, "finish the recovery process by transmitting the revocation")
fs.StringVar(&nlRevokeKeysArgs.forkFrom, "fork-from", "", "parent AUM hash to rewrite from (advanced users only)")
return fs
})(),
}
func runNetworkLockRevokeKeys(ctx context.Context, args []string) error {
// First step in the process
if !nlRevokeKeysArgs.cosign && !nlRevokeKeysArgs.finish {
removeKeys, _, err := parseNLArgs(args, true, false)
if err != nil {
return err
}
keyIDs := make([]tkatype.KeyID, len(removeKeys))
for i, k := range removeKeys {
keyIDs[i], err = k.ID()
if err != nil {
return fmt.Errorf("generating keyID: %v", err)
}
}
var forkFrom tka.AUMHash
if nlRevokeKeysArgs.forkFrom != "" {
if len(nlRevokeKeysArgs.forkFrom) == (len(forkFrom) * 2) {
// Hex-encoded: like the output of the lock log command.
b, err := hex.DecodeString(nlRevokeKeysArgs.forkFrom)
if err != nil {
return fmt.Errorf("invalid fork-from hash: %v", err)
}
copy(forkFrom[:], b)
} else {
if err := forkFrom.UnmarshalText([]byte(nlRevokeKeysArgs.forkFrom)); err != nil {
return fmt.Errorf("invalid fork-from hash: %v", err)
}
}
}
aumBytes, err := localClient.NetworkLockGenRecoveryAUM(ctx, keyIDs, forkFrom)
if err != nil {
return fmt.Errorf("generation of recovery AUM failed: %w", err)
}
fmt.Printf(`Run the following command on another machine with a trusted tailnet lock key:
%s lock revoke-keys --cosign %X
`, os.Args[0], aumBytes)
return nil
}
// If we got this far, we need to co-sign the AUM and/or transmit it for distribution.
b, err := hex.DecodeString(args[0])
if err != nil {
return fmt.Errorf("parsing hex: %v", err)
}
var recoveryAUM tka.AUM
if err := recoveryAUM.Unserialize(b); err != nil {
return fmt.Errorf("decoding recovery AUM: %v", err)
}
if nlRevokeKeysArgs.cosign {
aumBytes, err := localClient.NetworkLockCosignRecoveryAUM(ctx, recoveryAUM)
if err != nil {
return fmt.Errorf("co-signing recovery AUM failed: %w", err)
}
fmt.Printf(`Co-signing completed successfully.
To accumulate an additional signature, run the following command on another machine with a trusted tailnet lock key:
%s lock revoke-keys --cosign %X
Alternatively if you are done with co-signing, complete recovery by running the following command:
%s lock revoke-keys --finish %X
`, os.Args[0], aumBytes, os.Args[0], aumBytes)
}
if nlRevokeKeysArgs.finish {
if err := localClient.NetworkLockSubmitRecoveryAUM(ctx, recoveryAUM); err != nil {
return fmt.Errorf("submitting recovery AUM failed: %w", err)
}
fmt.Println("Recovery completed.")
}
return nil
}