Skip to content

Commit

Permalink
all: implement lock revoke-keys command
Browse files Browse the repository at this point in the history
The revoke-keys command allows nodes with tailnet lock keys
to collaborate to erase the use of a compromised key, and remove trust
in it.

Signed-off-by: Tom DNetto <[email protected]>
Updates ENG-1848
  • Loading branch information
twitchyliquid64 committed Aug 1, 2023
1 parent 7adf15f commit 767e839
Show file tree
Hide file tree
Showing 7 changed files with 709 additions and 3 deletions.
36 changes: 36 additions & 0 deletions client/tailscale/localclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -961,6 +961,42 @@ func (lc *LocalClient) NetworkLockVerifySigningDeeplink(ctx context.Context, url
return decodeJSON[*tka.DeeplinkValidationResult](body)
}

// NetworkLockGenRecoveryAUM generates an AUM for recovering from a tailnet-lock key compromise.
func (lc *LocalClient) 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 *LocalClient) 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 *LocalClient) 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 *LocalClient) SetServeConfig(ctx context.Context, config *ipn.ServeConfig) error {
Expand Down
113 changes: 113 additions & 0 deletions cmd/tailscale/cli/network-lock.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"tailscale.com/ipn/ipnstate"
"tailscale.com/tka"
"tailscale.com/types/key"
"tailscale.com/types/tkatype"
)

var netlockCmd = &ffcli.Command{
Expand All @@ -40,6 +41,7 @@ var netlockCmd = &ffcli.Command{
nlDisablementKDFCmd,
nlLogCmd,
nlLocalDisableCmd,
nlRevokeKeysCmd,
},
Exec: runNetworkLockNoSubcommand,
}
Expand Down Expand Up @@ -711,3 +713,114 @@ func wrapAuthKey(ctx context.Context, keyStr string, status *ipnstate.Status) er
fmt.Println(wrapped)
return nil
}

var nlRevokeKeysArgs struct {
cosign bool
finish bool
forkFrom string
}

var nlRevokeKeysCmd = &ffcli.Command{
Name: "revoke-keys",
ShortUsage: "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 recover-compromised-key --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 recover-compromised-key --cosign %X
Alternatively if you are done with co-signing, complete recovery by running the following command:
%s lock recover-compromised-key --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
}
87 changes: 87 additions & 0 deletions ipn/ipnlocal/network-lock.go
Original file line number Diff line number Diff line change
Expand Up @@ -845,6 +845,93 @@ func (b *LocalBackend) NetworkLockAffectedSigs(keyID tkatype.KeyID) ([]tkatype.M
return resp.Signatures, nil
}

// NetworkLockGenerateRecoveryAUM generates an AUM which retroactively removes trust in the
// specified keys. This AUM is signed by the current node and returned.
//
// If forkFrom is specified, it is used as the parent AUM to fork from. If the zero value,
// the parent AUM is determined automatically.
func (b *LocalBackend) NetworkLockGenerateRecoveryAUM(removeKeys []tkatype.KeyID, forkFrom tka.AUMHash) (*tka.AUM, error) {
b.mu.Lock()
defer b.mu.Unlock()
if b.tka == nil {
return nil, errNetworkLockNotActive
}
var nlPriv key.NLPrivate
if p := b.pm.CurrentPrefs(); p.Valid() && p.Persist().Valid() {
nlPriv = p.Persist().NetworkLockKey()
}
if nlPriv.IsZero() {
return nil, errMissingNetmap
}

aum, err := b.tka.authority.MakeRetroactiveRevocation(b.tka.storage, removeKeys, nlPriv.KeyID(), forkFrom)
if err != nil {
return nil, err
}

// Sign it ourselves.
aum.Signatures, err = nlPriv.SignAUM(aum.SigHash())
if err != nil {
return nil, fmt.Errorf("signing failed: %w", err)
}

return aum, nil
}

// NetworkLockCosignRecoveryAUM co-signs the provided recovery AUM and returns
// the updated structure.
//
// The recovery AUM provided should be the output from a previous call to
// NetworkLockGenerateRecoveryAUM or NetworkLockCosignRecoveryAUM.
func (b *LocalBackend) NetworkLockCosignRecoveryAUM(aum *tka.AUM) (*tka.AUM, error) {
b.mu.Lock()
defer b.mu.Unlock()
if b.tka == nil {
return nil, errNetworkLockNotActive
}
var nlPriv key.NLPrivate
if p := b.pm.CurrentPrefs(); p.Valid() && p.Persist().Valid() {
nlPriv = p.Persist().NetworkLockKey()
}
if nlPriv.IsZero() {
return nil, errMissingNetmap
}
for _, sig := range aum.Signatures {
if bytes.Equal(sig.KeyID, nlPriv.KeyID()) {
return nil, errors.New("this node has already signed this recovery AUM")
}
}

// Sign it ourselves.
sigs, err := nlPriv.SignAUM(aum.SigHash())
if err != nil {
return nil, fmt.Errorf("signing failed: %w", err)
}
aum.Signatures = append(aum.Signatures, sigs...)

return aum, nil
}

func (b *LocalBackend) NetworkLockSubmitRecoveryAUM(aum *tka.AUM) error {
b.mu.Lock()
defer b.mu.Unlock()
if b.tka == nil {
return errNetworkLockNotActive
}
var ourNodeKey key.NodePublic
if p := b.pm.CurrentPrefs(); p.Valid() && p.Persist().Valid() && !p.Persist().PrivateNodeKey().IsZero() {
ourNodeKey = p.Persist().PublicNodeKey()
}
if ourNodeKey.IsZero() {
return errors.New("no node-key: is tailscale logged in?")
}

b.mu.Unlock()
_, err := b.tkaDoSyncSend(ourNodeKey, aum.Hash(), []tka.AUM{*aum}, false)
b.mu.Lock()
return err
}

var tkaSuffixEncoder = base64.RawStdEncoding

// NetworkLockWrapPreauthKey wraps a pre-auth key with information to
Expand Down
126 changes: 126 additions & 0 deletions ipn/ipnlocal/network-lock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -994,3 +994,129 @@ func TestTKAAffectedSigs(t *testing.T) {
})
}
}

func TestTKARecoverCompromisedKeyFlow(t *testing.T) {
nodePriv := key.NewNode()
nlPriv := key.NewNLPrivate()
cosignPriv := key.NewNLPrivate()
compromisedPriv := key.NewNLPrivate()

pm := must.Get(newProfileManager(new(mem.Store), t.Logf))
must.Do(pm.SetPrefs((&ipn.Prefs{
Persist: &persist.Persist{
PrivateNodeKey: nodePriv,
NetworkLockKey: nlPriv,
},
}).View()))

// Make a fake TKA authority, to seed local state.
disablementSecret := bytes.Repeat([]byte{0xa5}, 32)
key := tka.Key{Kind: tka.Key25519, Public: nlPriv.Public().Verifier(), Votes: 2}
cosignKey := tka.Key{Kind: tka.Key25519, Public: cosignPriv.Public().Verifier(), Votes: 2}
compromisedKey := tka.Key{Kind: tka.Key25519, Public: compromisedPriv.Public().Verifier(), Votes: 1}

temp := t.TempDir()
tkaPath := filepath.Join(temp, "tka-profile", string(pm.CurrentProfile().ID))
os.Mkdir(tkaPath, 0755)
chonk, err := tka.ChonkDir(tkaPath)
if err != nil {
t.Fatal(err)
}
authority, _, err := tka.Create(chonk, tka.State{
Keys: []tka.Key{key, compromisedKey, cosignKey},
DisablementSecrets: [][]byte{tka.DisablementKDF(disablementSecret)},
}, nlPriv)
if err != nil {
t.Fatalf("tka.Create() failed: %v", err)
}

ts, client := fakeNoiseServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
switch r.URL.Path {
case "/machine/tka/sync/send":
body := new(tailcfg.TKASyncSendRequest)
if err := json.NewDecoder(r.Body).Decode(body); err != nil {
t.Fatal(err)
}
t.Logf("got sync send:\n%+v", body)

var remoteHead tka.AUMHash
if err := remoteHead.UnmarshalText([]byte(body.Head)); err != nil {
t.Fatalf("head unmarshal: %v", err)
}
toApply := make([]tka.AUM, len(body.MissingAUMs))
for i, a := range body.MissingAUMs {
if err := toApply[i].Unserialize(a); err != nil {
t.Fatalf("decoding missingAUM[%d]: %v", i, err)
}
}

// Apply the recovery AUM to an authority to make sure it works.
if err := authority.Inform(chonk, toApply); err != nil {
t.Errorf("recovery AUM could not be applied: %v", err)
}
// Make sure the key we removed isn't trusted.
if authority.KeyTrusted(compromisedPriv.KeyID()) {
t.Error("compromised key was not removed from tka")
}

w.WriteHeader(200)
if err := json.NewEncoder(w).Encode(tailcfg.TKASubmitSignatureResponse{}); err != nil {
t.Fatal(err)
}

default:
t.Errorf("unhandled endpoint path: %v", r.URL.Path)
w.WriteHeader(404)
}
}))
defer ts.Close()
cc := fakeControlClient(t, client)
b := LocalBackend{
varRoot: temp,
cc: cc,
ccAuto: cc,
logf: t.Logf,
tka: &tkaState{
authority: authority,
storage: chonk,
},
pm: pm,
store: pm.Store(),
}

aum, err := b.NetworkLockGenerateRecoveryAUM([]tkatype.KeyID{compromisedPriv.KeyID()}, tka.AUMHash{})
if err != nil {
t.Fatalf("NetworkLockGenerateRecoveryAUM() failed: %v", err)
}

// Cosign using the cosigning key.
{
pm := must.Get(newProfileManager(new(mem.Store), t.Logf))
must.Do(pm.SetPrefs((&ipn.Prefs{
Persist: &persist.Persist{
PrivateNodeKey: nodePriv,
NetworkLockKey: cosignPriv,
},
}).View()))
b := LocalBackend{
varRoot: temp,
logf: t.Logf,
tka: &tkaState{
authority: authority,
storage: chonk,
},
pm: pm,
store: pm.Store(),
}
if aum, err = b.NetworkLockCosignRecoveryAUM(aum); err != nil {
t.Fatalf("NetworkLockCosignRecoveryAUM() failed: %v", err)
}
}

// Finally, submit the recovery AUM. Validation is done
// in the fake control handler.
if err := b.NetworkLockSubmitRecoveryAUM(aum); err != nil {
t.Errorf("NetworkLockSubmitRecoveryAUM() failed: %v", err)
}
}
Loading

0 comments on commit 767e839

Please sign in to comment.