all: implement lock revoke-keys command
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 <tom@tailscale.com> Updates ENG-1848
This commit is contained in:
+118
-3
@@ -28,6 +28,9 @@ var cborDecOpts = cbor.DecOptions{
|
||||
MaxMapPairs: 1024,
|
||||
}
|
||||
|
||||
// Arbitrarily chosen limit on scanning AUM trees.
|
||||
const maxScanIterations = 2000
|
||||
|
||||
// Authority is a Tailnet Key Authority. This type is the main coupling
|
||||
// point to the rest of the tailscale client.
|
||||
//
|
||||
@@ -471,7 +474,7 @@ func Open(storage Chonk) (*Authority, error) {
|
||||
return nil, fmt.Errorf("reading last ancestor: %v", err)
|
||||
}
|
||||
|
||||
c, err := computeActiveChain(storage, a, 2000)
|
||||
c, err := computeActiveChain(storage, a, maxScanIterations)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("active chain: %v", err)
|
||||
}
|
||||
@@ -604,7 +607,7 @@ func (a *Authority) InformIdempotent(storage Chonk, updates []AUM) (Authority, e
|
||||
state, hasState := stateAt[parent]
|
||||
var err error
|
||||
if !hasState {
|
||||
if state, err = computeStateAt(storage, 2000, parent); err != nil {
|
||||
if state, err = computeStateAt(storage, maxScanIterations, parent); err != nil {
|
||||
return Authority{}, fmt.Errorf("update %d computing state: %v", i, err)
|
||||
}
|
||||
stateAt[parent] = state
|
||||
@@ -639,7 +642,7 @@ func (a *Authority) InformIdempotent(storage Chonk, updates []AUM) (Authority, e
|
||||
}
|
||||
|
||||
oldestAncestor := a.oldestAncestor.Hash()
|
||||
c, err := computeActiveChain(storage, &oldestAncestor, 2000)
|
||||
c, err := computeActiveChain(storage, &oldestAncestor, maxScanIterations)
|
||||
if err != nil {
|
||||
return Authority{}, fmt.Errorf("recomputing active chain: %v", err)
|
||||
}
|
||||
@@ -721,3 +724,115 @@ func (a *Authority) Compact(storage CompactableChonk, o CompactionOptions) error
|
||||
a.oldestAncestor = ancestor
|
||||
return nil
|
||||
}
|
||||
|
||||
// findParentForRewrite finds the parent AUM to use when rewriting state to
|
||||
// retroactively remove trust in the specified keys.
|
||||
func (a *Authority) findParentForRewrite(storage Chonk, removeKeys []tkatype.KeyID, ourKey tkatype.KeyID) (AUMHash, error) {
|
||||
cursor := a.Head()
|
||||
|
||||
for {
|
||||
if cursor == a.oldestAncestor.Hash() {
|
||||
// We've reached as far back in our history as we can,
|
||||
// so we have to rewrite from here.
|
||||
break
|
||||
}
|
||||
|
||||
aum, err := storage.AUM(cursor)
|
||||
if err != nil {
|
||||
return AUMHash{}, fmt.Errorf("reading AUM %v: %w", cursor, err)
|
||||
}
|
||||
|
||||
// An ideal rewrite parent trusts none of the keys to be removed.
|
||||
state, err := computeStateAt(storage, maxScanIterations, cursor)
|
||||
if err != nil {
|
||||
return AUMHash{}, fmt.Errorf("computing state for %v: %w", cursor, err)
|
||||
}
|
||||
keyTrusted := false
|
||||
for _, key := range removeKeys {
|
||||
if _, err := state.GetKey(key); err == nil {
|
||||
keyTrusted = true
|
||||
}
|
||||
}
|
||||
if !keyTrusted {
|
||||
// Success: the revoked keys are not trusted!
|
||||
// Lets check that our key was trusted to ensure
|
||||
// we can sign a fork from here.
|
||||
if _, err := state.GetKey(ourKey); err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
parent, hasParent := aum.Parent()
|
||||
if !hasParent {
|
||||
// This is the genesis AUM, so we have to rewrite from here.
|
||||
break
|
||||
}
|
||||
cursor = parent
|
||||
}
|
||||
|
||||
return cursor, nil
|
||||
}
|
||||
|
||||
// MakeRetroactiveRevocation generates a forking update which revokes the specified keys, in
|
||||
// such a manner that any malicious use of those keys is erased.
|
||||
//
|
||||
// If forkFrom is specified, it is used as the parent AUM to fork from. If the zero value,
|
||||
// the parent AUM is determined automatically.
|
||||
//
|
||||
// The generated AUM must be signed with more signatures than the sum of key votes that
|
||||
// were compromised, before being consumed by tka.Authority methods.
|
||||
func (a *Authority) MakeRetroactiveRevocation(storage Chonk, removeKeys []tkatype.KeyID, ourKey tkatype.KeyID, forkFrom AUMHash) (*AUM, error) {
|
||||
var parent AUMHash
|
||||
if forkFrom == (AUMHash{}) {
|
||||
// Make sure at least one of the recovery keys is currently trusted.
|
||||
foundKey := false
|
||||
for _, k := range removeKeys {
|
||||
if _, err := a.state.GetKey(k); err == nil {
|
||||
foundKey = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundKey {
|
||||
return nil, errors.New("no provided key is currently trusted")
|
||||
}
|
||||
|
||||
p, err := a.findParentForRewrite(storage, removeKeys, ourKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("finding parent: %v", err)
|
||||
}
|
||||
parent = p
|
||||
} else {
|
||||
parent = forkFrom
|
||||
}
|
||||
|
||||
// Construct the new state where the revoked keys are no longer trusted.
|
||||
state := a.state.Clone()
|
||||
for _, keyToRevoke := range removeKeys {
|
||||
idx := -1
|
||||
for i := range state.Keys {
|
||||
keyID, err := state.Keys[i].ID()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("computing keyID: %v", err)
|
||||
}
|
||||
if bytes.Equal(keyToRevoke, keyID) {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx >= 0 {
|
||||
state.Keys = append(state.Keys[:idx], state.Keys[idx+1:]...)
|
||||
}
|
||||
}
|
||||
if len(state.Keys) == 0 {
|
||||
return nil, errors.New("cannot revoke all trusted keys")
|
||||
}
|
||||
state.LastAUMHash = nil // checkpoints can't specify a LastAUMHash
|
||||
|
||||
forkingAUM := &AUM{
|
||||
MessageKind: AUMCheckpoint,
|
||||
State: &state,
|
||||
PrevAUMHash: parent[:],
|
||||
}
|
||||
|
||||
return forkingAUM, forkingAUM.StaticValidate()
|
||||
}
|
||||
|
||||
+128
@@ -524,3 +524,131 @@ func TestAuthorityCompact(t *testing.T) {
|
||||
t.Errorf("ancestor = %v, want %v", anc, c.AUMHashes["C"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindParentForRewrite(t *testing.T) {
|
||||
pub, _ := testingKey25519(t, 1)
|
||||
k1 := Key{Kind: Key25519, Public: pub, Votes: 1}
|
||||
|
||||
pub2, _ := testingKey25519(t, 2)
|
||||
k2 := Key{Kind: Key25519, Public: pub2, Votes: 1}
|
||||
k2ID, _ := k2.ID()
|
||||
pub3, _ := testingKey25519(t, 3)
|
||||
k3 := Key{Kind: Key25519, Public: pub3, Votes: 1}
|
||||
|
||||
c := newTestchain(t, `
|
||||
A -> B -> C -> D -> E
|
||||
A.template = genesis
|
||||
B.template = add2
|
||||
C.template = add3
|
||||
D.template = remove2
|
||||
`,
|
||||
optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{
|
||||
Keys: []Key{k1},
|
||||
DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})},
|
||||
}}),
|
||||
optTemplate("add2", AUM{MessageKind: AUMAddKey, Key: &k2}),
|
||||
optTemplate("add3", AUM{MessageKind: AUMAddKey, Key: &k3}),
|
||||
optTemplate("remove2", AUM{MessageKind: AUMRemoveKey, KeyID: k2ID}))
|
||||
|
||||
a, err := Open(c.Chonk())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// k1 was trusted at genesis, so there's no better rewrite parent
|
||||
// than the genesis.
|
||||
k1ID, _ := k1.ID()
|
||||
k1P, err := a.findParentForRewrite(c.Chonk(), []tkatype.KeyID{k1ID}, k1ID)
|
||||
if err != nil {
|
||||
t.Fatalf("FindParentForRewrite(k1) failed: %v", err)
|
||||
}
|
||||
if k1P != a.oldestAncestor.Hash() {
|
||||
t.Errorf("FindParentForRewrite(k1) = %v, want %v", k1P, a.oldestAncestor.Hash())
|
||||
}
|
||||
|
||||
// k3 was trusted at C, so B would be an ideal rewrite point.
|
||||
k3ID, _ := k3.ID()
|
||||
k3P, err := a.findParentForRewrite(c.Chonk(), []tkatype.KeyID{k3ID}, k1ID)
|
||||
if err != nil {
|
||||
t.Fatalf("FindParentForRewrite(k3) failed: %v", err)
|
||||
}
|
||||
if k3P != c.AUMHashes["B"] {
|
||||
t.Errorf("FindParentForRewrite(k3) = %v, want %v", k3P, c.AUMHashes["B"])
|
||||
}
|
||||
|
||||
// k2 was added but then removed, so HEAD is an appropriate rewrite point.
|
||||
k2P, err := a.findParentForRewrite(c.Chonk(), []tkatype.KeyID{k2ID}, k1ID)
|
||||
if err != nil {
|
||||
t.Fatalf("FindParentForRewrite(k2) failed: %v", err)
|
||||
}
|
||||
if k3P != c.AUMHashes["B"] {
|
||||
t.Errorf("FindParentForRewrite(k2) = %v, want %v", k2P, a.Head())
|
||||
}
|
||||
|
||||
// There's no appropriate point where both k2 and k3 are simultaneously not trusted,
|
||||
// so the best rewrite point is the genesis AUM.
|
||||
doubleP, err := a.findParentForRewrite(c.Chonk(), []tkatype.KeyID{k2ID, k3ID}, k1ID)
|
||||
if err != nil {
|
||||
t.Fatalf("FindParentForRewrite({k2, k3}) failed: %v", err)
|
||||
}
|
||||
if doubleP != a.oldestAncestor.Hash() {
|
||||
t.Errorf("FindParentForRewrite({k2, k3}) = %v, want %v", doubleP, a.oldestAncestor.Hash())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeRetroactiveRevocation(t *testing.T) {
|
||||
pub, _ := testingKey25519(t, 1)
|
||||
k1 := Key{Kind: Key25519, Public: pub, Votes: 1}
|
||||
|
||||
pub2, _ := testingKey25519(t, 2)
|
||||
k2 := Key{Kind: Key25519, Public: pub2, Votes: 1}
|
||||
pub3, _ := testingKey25519(t, 3)
|
||||
k3 := Key{Kind: Key25519, Public: pub3, Votes: 1}
|
||||
|
||||
c := newTestchain(t, `
|
||||
A -> B -> C -> D
|
||||
A.template = genesis
|
||||
C.template = add2
|
||||
D.template = add3
|
||||
`,
|
||||
optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{
|
||||
Keys: []Key{k1},
|
||||
DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})},
|
||||
}}),
|
||||
optTemplate("add2", AUM{MessageKind: AUMAddKey, Key: &k2}),
|
||||
optTemplate("add3", AUM{MessageKind: AUMAddKey, Key: &k3}))
|
||||
|
||||
a, err := Open(c.Chonk())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// k2 was added by C, so a forking revocation should:
|
||||
// - have B as a parent
|
||||
// - trust the remaining keys at the time, k1 & k3.
|
||||
k1ID, _ := k1.ID()
|
||||
k2ID, _ := k2.ID()
|
||||
k3ID, _ := k3.ID()
|
||||
forkingAUM, err := a.MakeRetroactiveRevocation(c.Chonk(), []tkatype.KeyID{k2ID}, k1ID, AUMHash{})
|
||||
if err != nil {
|
||||
t.Fatalf("MakeRetroactiveRevocation(k2) failed: %v", err)
|
||||
}
|
||||
if bHash := c.AUMHashes["B"]; !bytes.Equal(forkingAUM.PrevAUMHash, bHash[:]) {
|
||||
t.Errorf("forking AUM has parent %v, want %v", forkingAUM.PrevAUMHash, bHash[:])
|
||||
}
|
||||
if _, err := forkingAUM.State.GetKey(k1ID); err != nil {
|
||||
t.Error("Forked state did not trust k1")
|
||||
}
|
||||
if _, err := forkingAUM.State.GetKey(k3ID); err != nil {
|
||||
t.Error("Forked state did not trust k3")
|
||||
}
|
||||
if _, err := forkingAUM.State.GetKey(k2ID); err == nil {
|
||||
t.Error("Forked state trusted removed-key k2")
|
||||
}
|
||||
|
||||
// Test that removing all trusted keys results in an error.
|
||||
_, err = a.MakeRetroactiveRevocation(c.Chonk(), []tkatype.KeyID{k1ID, k2ID, k3ID}, k1ID, AUMHash{})
|
||||
if wantErr := "cannot revoke all trusted keys"; err == nil || err.Error() != wantErr {
|
||||
t.Fatalf("MakeRetroactiveRevocation({k1, k2, k3}) returned %v, expected %q", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user