tka: truncate long rotation signature chains

When a rotation signature chain reaches a certain size, remove the
oldest rotation signature from the chain before wrapping it in a new
rotation signature.

Since all previous rotation signatures are signed by the same wrapping
pubkey (node's own tailnet lock key), the node can re-construct the
chain, re-signing previous rotation signatures. This will satisfy the
existing certificate validation logic.

Updates #13185

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
This commit is contained in:
Anton Tolchanov
2024-08-19 19:32:14 +01:00
committed by Anton Tolchanov
parent bcc47d91ca
commit fd6686d81a
4 changed files with 221 additions and 11 deletions

View File

@@ -175,23 +175,24 @@ func (r *rotationTracker) addRotationDetails(np key.NodePublic, d *tka.RotationD
// obsoleteKeys returns the set of node keys that are obsolete due to key rotation.
func (r *rotationTracker) obsoleteKeys() set.Set[key.NodePublic] {
for _, v := range r.byWrappingKey {
// Do not consider signatures for keys that have been marked as obsolete
// by another signature.
v = slices.DeleteFunc(v, func(rd sigRotationDetails) bool {
return r.obsolete.Contains(rd.np)
})
if len(v) == 0 {
continue
}
// If there are multiple rotation signatures with the same wrapping
// pubkey, we need to decide which one is the "latest", and keep it.
// The signature with the largest number of previous keys is likely to
// be the latest, unless it has been marked as obsolete (rotated out) by
// another signature (which might happen in the future if we start
// compacting long rotated signature chains).
// be the latest.
slices.SortStableFunc(v, func(a, b sigRotationDetails) int {
// Group all obsolete keys after non-obsolete keys.
if ao, bo := r.obsolete.Contains(a.np), r.obsolete.Contains(b.np); ao != bo {
if ao {
return 1
}
return -1
}
// Sort by decreasing number of previous keys.
return b.numPrevKeys - a.numPrevKeys
})
// If there are several signatures with the same number of previous
// keys, we cannot determine which one is the latest, so all of them are
// rejected for safety.

View File

@@ -667,6 +667,31 @@ func TestTKAFilterNetmap(t *testing.T) {
if diff := cmp.Diff(want, nm.Peers, nodePubComparer); diff != "" {
t.Errorf("filtered netmap differs (-want, +got):\n%s", diff)
}
// Confirm that repeated rotation works correctly.
for range 100 {
n5Rotated, n5RotatedSig = resign(n5nl, n5RotatedSig)
}
n51, n51Sig := resign(n5nl, n5RotatedSig)
nm = &netmap.NetworkMap{
Peers: nodeViews([]*tailcfg.Node{
{ID: 1, Key: n1.Public(), KeySignature: n1GoodSig.Serialize()},
{ID: 5, Key: n5Rotated.Public(), KeySignature: n5RotatedSig}, // rotated
{ID: 51, Key: n51.Public(), KeySignature: n51Sig},
}),
}
b.tkaFilterNetmapLocked(nm)
want = nodeViews([]*tailcfg.Node{
{ID: 1, Key: n1.Public(), KeySignature: n1GoodSig.Serialize()},
{ID: 51, Key: n51.Public(), KeySignature: n51Sig},
})
if diff := cmp.Diff(want, nm.Peers, nodePubComparer); diff != "" {
t.Errorf("filtered netmap differs (-want, +got):\n%s", diff)
}
}
func TestTKADisable(t *testing.T) {