From 151b77f9d68a0bd212a60f1d40ce45b723263d9d Mon Sep 17 00:00:00 2001 From: Anton Tolchanov Date: Tue, 20 Aug 2024 18:36:30 +0100 Subject: [PATCH] cmd/tl-longchain: tool to re-sign nodes with long rotation signatures In Tailnet Lock, there is an implicit limit on the number of rotation signatures that can be chained before the signature becomes too long. This program helps tailnet admins to identify nodes that have signatures with long chains and prints commands to re-sign those node keys with a fresh direct signature. It's a temporary mitigation measure, and we will remove this tool as we design and implement a long-term approach for rotation signatures. Example output: ``` 2024/08/20 18:25:03 Self: does not need re-signing 2024/08/20 18:25:03 Visible peers with valid signatures: 2024/08/20 18:25:03 Peer xxx2.yy.ts.net. (100.77.192.34) nodeid=nyDmhiZiGA11KTM59, current signature kind=direct: does not need re-signing 2024/08/20 18:25:03 Peer xxx3.yy.ts.net. (100.84.248.22) nodeid=ndQ64mDnaB11KTM59, current signature kind=direct: does not need re-signing 2024/08/20 18:25:03 Peer xxx4.yy.ts.net. (100.85.253.53) nodeid=nmZfVygzkB21KTM59, current signature kind=rotation: chain length 4, printing command to re-sign tailscale lock sign nodekey:530bddbfbe69e91fe15758a1d6ead5337aa6307e55ac92dafad3794f8b3fc661 tlpub:4bf07597336703395f2149dce88e7c50dd8694ab5bbde3d7c2a1c7b3e231a3c2 ``` To support this, the NetworkLockStatus localapi response now includes information about signatures of all peers rather than just the invalid ones. This is not displayed by default in `tailscale lock status`, but will be surfaced in `tailscale lock status --json`. Updates #13185 Signed-off-by: Anton Tolchanov --- cmd/tl-longchain/tl-longchain.go | 93 ++++++++++++++++++++++++++++++++ ipn/ipnlocal/network-lock.go | 49 +++++++++++------ ipn/ipnstate/ipnstate.go | 25 +++++---- ipn/ipnstate/ipnstate_clone.go | 21 ++++---- tka/sig.go | 2 + tka/tka_clone.go | 32 +++++++++++ 6 files changed, 185 insertions(+), 37 deletions(-) create mode 100644 cmd/tl-longchain/tl-longchain.go create mode 100644 tka/tka_clone.go diff --git a/cmd/tl-longchain/tl-longchain.go b/cmd/tl-longchain/tl-longchain.go new file mode 100644 index 000000000..c92714505 --- /dev/null +++ b/cmd/tl-longchain/tl-longchain.go @@ -0,0 +1,93 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// Program tl-longchain prints commands to re-sign Tailscale nodes that have +// long rotation signature chains. +// +// There is an implicit limit on the number of rotation signatures that can +// be chained before the signature becomes too long. This program helps +// tailnet admins to identify nodes that have signatures with long chains and +// prints commands to re-sign those node keys with a fresh direct signature. +// Commands are printed to stdout, while log messages are printed to stderr. +// +// Note that the Tailscale client this command is executed on must have +// ACL visibility to all other nodes to be able to see their signatures. +// https://tailscale.com/kb/1087/device-visibility +package main + +import ( + "context" + "flag" + "fmt" + "log" + "time" + + "tailscale.com/client/tailscale" + "tailscale.com/ipn/ipnstate" + "tailscale.com/tka" + "tailscale.com/types/key" +) + +var ( + flagSocket = flag.String("socket", "", "custom path to tailscaled socket") + maxRotations = flag.Int("rotations", 10, "number of rotation signatures before re-signing (max 16)") + showFiltered = flag.Bool("show-filtered", false, "include nodes with invalid signatures") +) + +func main() { + flag.Parse() + + lc := tailscale.LocalClient{Socket: *flagSocket} + if lc.Socket != "" { + lc.UseSocketOnly = true + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + st, err := lc.NetworkLockStatus(ctx) + if err != nil { + log.Fatalf("could not get Tailnet Lock status: %v", err) + } + if !st.Enabled { + log.Print("Tailnet Lock is not enabled") + return + } + print("Self", *st.NodeKey, *st.NodeKeySignature) + if len(st.VisiblePeers) > 0 { + log.Print("Visible peers with valid signatures:") + for _, peer := range st.VisiblePeers { + print(peerInfo(peer), peer.NodeKey, peer.NodeKeySignature) + } + } + if *showFiltered && len(st.FilteredPeers) > 0 { + log.Print("Visible peers with invalid signatures:") + for _, peer := range st.FilteredPeers { + print(peerInfo(peer), peer.NodeKey, peer.NodeKeySignature) + } + } +} + +// peerInfo returns a string with information about a peer. +func peerInfo(peer *ipnstate.TKAPeer) string { + return fmt.Sprintf("Peer %s (%s) nodeid=%s, current signature kind=%v", peer.Name, peer.TailscaleIPs[0], peer.StableID, peer.NodeKeySignature.SigKind) +} + +// print prints a message about a node key signature and a re-signing command if needed. +func print(info string, nodeKey key.NodePublic, sig tka.NodeKeySignature) { + if l := chainLength(sig); l > *maxRotations { + log.Printf("%s: chain length %d, printing command to re-sign", info, l) + wrapping, _ := sig.UnverifiedWrappingPublic() + fmt.Printf("tailscale lock sign %s %s\n", nodeKey, key.NLPublicFromEd25519Unsafe(wrapping).CLIString()) + } else { + log.Printf("%s: does not need re-signing", info) + } +} + +// chainLength returns the length of the rotation signature chain. +func chainLength(sig tka.NodeKeySignature) int { + if sig.SigKind != tka.SigRotation { + return 1 + } + return 1 + chainLength(*sig.Nested) +} diff --git a/ipn/ipnlocal/network-lock.go b/ipn/ipnlocal/network-lock.go index 90d3e30e7..6b64ea625 100644 --- a/ipn/ipnlocal/network-lock.go +++ b/ipn/ipnlocal/network-lock.go @@ -53,7 +53,7 @@ type tkaState struct { profile ipn.ProfileID authority *tka.Authority storage *tka.FS - filtered []ipnstate.TKAFilteredPeer + filtered []ipnstate.TKAPeer } // tkaFilterNetmapLocked checks the signatures on each node key, dropping @@ -99,7 +99,7 @@ func (b *LocalBackend) tkaFilterNetmapLocked(nm *netmap.NetworkMap) { // nm.Peers is ordered, so deletion must be order-preserving. if len(toDelete) > 0 || len(obsoleteByRotation) > 0 { peers := make([]tailcfg.NodeView, 0, len(nm.Peers)) - filtered := make([]ipnstate.TKAFilteredPeer, 0, len(toDelete)+len(obsoleteByRotation)) + filtered := make([]ipnstate.TKAPeer, 0, len(toDelete)+len(obsoleteByRotation)) for i, p := range nm.Peers { if !toDelete[i] && !obsoleteByRotation.Contains(p.Key()) { peers = append(peers, p) @@ -108,20 +108,7 @@ func (b *LocalBackend) tkaFilterNetmapLocked(nm *netmap.NetworkMap) { b.logf("Network lock is dropping peer %v(%v) due to key rotation", p.ID(), p.StableID()) } // Record information about the node we filtered out. - fp := ipnstate.TKAFilteredPeer{ - Name: p.Name(), - ID: p.ID(), - StableID: p.StableID(), - TailscaleIPs: make([]netip.Addr, p.Addresses().Len()), - NodeKey: p.Key(), - } - for i := range p.Addresses().Len() { - addr := p.Addresses().At(i) - if addr.IsSingleIP() && tsaddr.IsTailscaleIP(addr.Addr()) { - fp.TailscaleIPs[i] = addr.Addr() - } - } - filtered = append(filtered, fp) + filtered = append(filtered, tkaStateFromPeer(p)) } } nm.Peers = peers @@ -546,11 +533,17 @@ func (b *LocalBackend) NetworkLockStatus() *ipnstate.NetworkLockStatus { } } - filtered := make([]*ipnstate.TKAFilteredPeer, len(b.tka.filtered)) + filtered := make([]*ipnstate.TKAPeer, len(b.tka.filtered)) for i := range len(filtered) { filtered[i] = b.tka.filtered[i].Clone() } + visible := make([]*ipnstate.TKAPeer, len(b.netMap.Peers)) + for i, p := range b.netMap.Peers { + s := tkaStateFromPeer(p) + visible[i] = &s + } + stateID1, _ := b.tka.authority.StateIDs() return &ipnstate.NetworkLockStatus{ @@ -562,10 +555,32 @@ func (b *LocalBackend) NetworkLockStatus() *ipnstate.NetworkLockStatus { NodeKeySignature: nodeKeySignature, TrustedKeys: outKeys, FilteredPeers: filtered, + VisiblePeers: visible, StateID: stateID1, } } +func tkaStateFromPeer(p tailcfg.NodeView) ipnstate.TKAPeer { + fp := ipnstate.TKAPeer{ + Name: p.Name(), + ID: p.ID(), + StableID: p.StableID(), + TailscaleIPs: make([]netip.Addr, 0, p.Addresses().Len()), + NodeKey: p.Key(), + } + for i := range p.Addresses().Len() { + addr := p.Addresses().At(i) + if addr.IsSingleIP() && tsaddr.IsTailscaleIP(addr.Addr()) { + fp.TailscaleIPs = append(fp.TailscaleIPs, addr.Addr()) + } + } + var decoded tka.NodeKeySignature + if err := decoded.Unserialize(p.KeySignature().AsSlice()); err == nil { + fp.NodeKeySignature = decoded + } + return fp +} + // NetworkLockInit enables network-lock for the tailnet, with the tailnets' // key authority initialized to trust the provided keys. // diff --git a/ipn/ipnstate/ipnstate.go b/ipn/ipnstate/ipnstate.go index b38d75e5a..9f8bd34f6 100644 --- a/ipn/ipnstate/ipnstate.go +++ b/ipn/ipnstate/ipnstate.go @@ -26,7 +26,7 @@ import ( "tailscale.com/version" ) -//go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=TKAFilteredPeer +//go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=TKAPeer // Status represents the entire state of the IPN network. type Status struct { @@ -94,15 +94,14 @@ type TKAKey struct { Votes uint } -// TKAFilteredPeer describes a peer which was removed from the netmap -// (i.e. no connectivity) because it failed tailnet lock -// checks. -type TKAFilteredPeer struct { - Name string // DNS - ID tailcfg.NodeID - StableID tailcfg.StableNodeID - TailscaleIPs []netip.Addr // Tailscale IP(s) assigned to this node - NodeKey key.NodePublic +// TKAPeer describes a peer and its network lock details. +type TKAPeer struct { + Name string // DNS + ID tailcfg.NodeID + StableID tailcfg.StableNodeID + TailscaleIPs []netip.Addr // Tailscale IP(s) assigned to this node + NodeKey key.NodePublic + NodeKeySignature tka.NodeKeySignature } // NetworkLockStatus represents whether network-lock is enabled, @@ -134,10 +133,14 @@ type NetworkLockStatus struct { // to network-lock. TrustedKeys []TKAKey + // VisiblePeers describes peers which are visible in the netmap that + // have valid Tailnet Lock signatures signatures. + VisiblePeers []*TKAPeer + // FilteredPeers describes peers which were removed from the netmap // (i.e. no connectivity) because they failed tailnet lock // checks. - FilteredPeers []*TKAFilteredPeer + FilteredPeers []*TKAPeer // StateID is a nonce associated with the network lock authority, // generated upon enablement. This field is not populated if the diff --git a/ipn/ipnstate/ipnstate_clone.go b/ipn/ipnstate/ipnstate_clone.go index 262daf3f2..20ae43c5f 100644 --- a/ipn/ipnstate/ipnstate_clone.go +++ b/ipn/ipnstate/ipnstate_clone.go @@ -9,26 +9,29 @@ import ( "net/netip" "tailscale.com/tailcfg" + "tailscale.com/tka" "tailscale.com/types/key" ) -// Clone makes a deep copy of TKAFilteredPeer. +// Clone makes a deep copy of TKAPeer. // The result aliases no memory with the original. -func (src *TKAFilteredPeer) Clone() *TKAFilteredPeer { +func (src *TKAPeer) Clone() *TKAPeer { if src == nil { return nil } - dst := new(TKAFilteredPeer) + dst := new(TKAPeer) *dst = *src dst.TailscaleIPs = append(src.TailscaleIPs[:0:0], src.TailscaleIPs...) + dst.NodeKeySignature = *src.NodeKeySignature.Clone() return dst } // A compilation failure here means this code must be regenerated, with the command at the top of this file. -var _TKAFilteredPeerCloneNeedsRegeneration = TKAFilteredPeer(struct { - Name string - ID tailcfg.NodeID - StableID tailcfg.StableNodeID - TailscaleIPs []netip.Addr - NodeKey key.NodePublic +var _TKAPeerCloneNeedsRegeneration = TKAPeer(struct { + Name string + ID tailcfg.NodeID + StableID tailcfg.StableNodeID + TailscaleIPs []netip.Addr + NodeKey key.NodePublic + NodeKeySignature tka.NodeKeySignature }{}) diff --git a/tka/sig.go b/tka/sig.go index 6c68a588e..d3fe0ff6c 100644 --- a/tka/sig.go +++ b/tka/sig.go @@ -19,6 +19,8 @@ import ( "tailscale.com/types/tkatype" ) +//go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=NodeKeySignature + // SigKind describes valid NodeKeySignature types. type SigKind uint8 diff --git a/tka/tka_clone.go b/tka/tka_clone.go new file mode 100644 index 000000000..323a824fe --- /dev/null +++ b/tka/tka_clone.go @@ -0,0 +1,32 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// Code generated by tailscale.com/cmd/cloner; DO NOT EDIT. + +package tka + +// Clone makes a deep copy of NodeKeySignature. +// The result aliases no memory with the original. +func (src *NodeKeySignature) Clone() *NodeKeySignature { + if src == nil { + return nil + } + dst := new(NodeKeySignature) + *dst = *src + dst.Pubkey = append(src.Pubkey[:0:0], src.Pubkey...) + dst.KeyID = append(src.KeyID[:0:0], src.KeyID...) + dst.Signature = append(src.Signature[:0:0], src.Signature...) + dst.Nested = src.Nested.Clone() + dst.WrappingPubkey = append(src.WrappingPubkey[:0:0], src.WrappingPubkey...) + return dst +} + +// A compilation failure here means this code must be regenerated, with the command at the top of this file. +var _NodeKeySignatureCloneNeedsRegeneration = NodeKeySignature(struct { + SigKind SigKind + Pubkey []byte + KeyID []byte + Signature []byte + Nested *NodeKeySignature + WrappingPubkey []byte +}{})