mirror of https://github.com/tailscale/tailscale/
tka: implement State and applying AUMs
Signed-off-by: Tom DNetto <tom@tailscale.com>pull/5025/head
parent
1cfd96cdc2
commit
3709074e55
@ -0,0 +1,204 @@
|
|||||||
|
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package tka
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNoSuchKey is returned if the key referenced by a KeyID does not exist.
|
||||||
|
var ErrNoSuchKey = errors.New("key not found")
|
||||||
|
|
||||||
|
// State describes Tailnet Key Authority state at an instant in time.
|
||||||
|
//
|
||||||
|
// State is mutated by applying Authority Update Messages (AUMs), resulting
|
||||||
|
// in a new State.
|
||||||
|
type State struct {
|
||||||
|
// LastAUMHash is the blake2s digest of the last-applied AUM.
|
||||||
|
// Because AUMs are strictly ordered and form a hash chain, we
|
||||||
|
// check the previous AUM hash in an update we are applying
|
||||||
|
// is the same as the LastAUMHash.
|
||||||
|
LastAUMHash *AUMHash `cbor:"1,keyasint"`
|
||||||
|
|
||||||
|
// DisablementSecrets are KDF-derived values which can be used
|
||||||
|
// to turn off the TKA in the event of a consensus-breaking bug.
|
||||||
|
// An AUM of type DisableNL should contain a secret when results
|
||||||
|
// in one of these values when run through the disablement KDF.
|
||||||
|
//
|
||||||
|
// TODO(tom): This is an alpha feature, remove this mechanism once
|
||||||
|
// we have confidence in our implementation.
|
||||||
|
DisablementSecrets [][]byte `cbor:"2,keyasint"`
|
||||||
|
|
||||||
|
// Keys are the public keys currently trusted by the TKA.
|
||||||
|
Keys []Key `cbor:"3,keyasint"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetKey returns the trusted key with the specified KeyID.
|
||||||
|
func (s State) GetKey(key KeyID) (Key, error) {
|
||||||
|
for _, k := range s.Keys {
|
||||||
|
if bytes.Equal(k.ID(), key) {
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Key{}, ErrNoSuchKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone makes an independent copy of State.
|
||||||
|
//
|
||||||
|
// NOTE: There is a difference between a nil slice and an empty
|
||||||
|
// slice for encoding purposes, so an implementation of Clone()
|
||||||
|
// must take care to preserve this.
|
||||||
|
func (s State) Clone() State {
|
||||||
|
out := State{}
|
||||||
|
|
||||||
|
if s.LastAUMHash != nil {
|
||||||
|
dupe := *s.LastAUMHash
|
||||||
|
out.LastAUMHash = &dupe
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.DisablementSecrets != nil {
|
||||||
|
out.DisablementSecrets = make([][]byte, len(s.DisablementSecrets))
|
||||||
|
for i := range s.DisablementSecrets {
|
||||||
|
out.DisablementSecrets[i] = make([]byte, len(s.DisablementSecrets[i]))
|
||||||
|
copy(out.DisablementSecrets[i], s.DisablementSecrets[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.Keys != nil {
|
||||||
|
out.Keys = make([]Key, len(s.Keys))
|
||||||
|
for i := range s.Keys {
|
||||||
|
out.Keys[i] = s.Keys[i].Clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// cloneForUpdate is like Clone, except LastAUMHash is set based
|
||||||
|
// on the hash of the given update.
|
||||||
|
func (s State) cloneForUpdate(update *AUM) State {
|
||||||
|
out := s.Clone()
|
||||||
|
aumHash := update.Hash()
|
||||||
|
out.LastAUMHash = &aumHash
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const disablementLength = 32
|
||||||
|
|
||||||
|
var disablementSalt = []byte("tailscale network-lock disablement salt")
|
||||||
|
|
||||||
|
func disablementKDF(secret []byte) []byte {
|
||||||
|
// time = 4 (3 recommended, booped to 4 to compensate for less memory)
|
||||||
|
// memory = 16 (32 recommended)
|
||||||
|
// threads = 4
|
||||||
|
// keyLen = 32 (256 bits)
|
||||||
|
return argon2.Key(secret, disablementSalt, 4, 16*1024, 4, disablementLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkDisablement returns true for a valid disablement secret.
|
||||||
|
func (s State) checkDisablement(secret []byte) bool {
|
||||||
|
derived := disablementKDF(secret)
|
||||||
|
for _, candidate := range s.DisablementSecrets {
|
||||||
|
if bytes.Equal(derived, candidate) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// parentMatches returns true if an AUM can chain to (be applied)
|
||||||
|
// to the current state.
|
||||||
|
//
|
||||||
|
// Specifically, the rules are:
|
||||||
|
// - The last AUM hash must match (transitively, this implies that this
|
||||||
|
// update follows the last update message applied to the state machine)
|
||||||
|
// - Or, the state machine knows no parent (its brand new).
|
||||||
|
func (s State) parentMatches(update AUM) bool {
|
||||||
|
if s.LastAUMHash == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return bytes.Equal(s.LastAUMHash[:], update.PrevAUMHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyVerifiedAUM computes a new state based on the update provided.
|
||||||
|
//
|
||||||
|
// The provided update MUST be verified: That is, the AUM must be well-formed
|
||||||
|
// (as defined by StaticValidate()), and signatures over the AUM must have
|
||||||
|
// been verified.
|
||||||
|
func (s State) applyVerifiedAUM(update AUM) (State, error) {
|
||||||
|
// Validate that the update message has the right parent.
|
||||||
|
if !s.parentMatches(update) {
|
||||||
|
return State{}, errors.New("parent AUMHash mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch update.MessageKind {
|
||||||
|
case AUMNoOp:
|
||||||
|
out := s.cloneForUpdate(&update)
|
||||||
|
return out, nil
|
||||||
|
|
||||||
|
case AUMCheckpoint:
|
||||||
|
return update.State.cloneForUpdate(&update), nil
|
||||||
|
|
||||||
|
case AUMAddKey:
|
||||||
|
if _, err := s.GetKey(update.Key.ID()); err == nil {
|
||||||
|
return State{}, errors.New("key already exists")
|
||||||
|
}
|
||||||
|
out := s.cloneForUpdate(&update)
|
||||||
|
out.Keys = append(out.Keys, *update.Key)
|
||||||
|
return out, nil
|
||||||
|
|
||||||
|
case AUMUpdateKey:
|
||||||
|
k, err := s.GetKey(update.KeyID)
|
||||||
|
if err != nil {
|
||||||
|
return State{}, err
|
||||||
|
}
|
||||||
|
if update.Votes != nil {
|
||||||
|
k.Votes = *update.Votes
|
||||||
|
}
|
||||||
|
if update.Meta != nil {
|
||||||
|
k.Meta = update.Meta
|
||||||
|
}
|
||||||
|
out := s.cloneForUpdate(&update)
|
||||||
|
for i := range out.Keys {
|
||||||
|
if bytes.Equal(out.Keys[i].ID(), update.KeyID) {
|
||||||
|
out.Keys[i] = k
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
|
||||||
|
case AUMRemoveKey:
|
||||||
|
idx := -1
|
||||||
|
for i := range s.Keys {
|
||||||
|
if bytes.Equal(update.KeyID, s.Keys[i].ID()) {
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx < 0 {
|
||||||
|
return State{}, ErrNoSuchKey
|
||||||
|
}
|
||||||
|
out := s.cloneForUpdate(&update)
|
||||||
|
out.Keys = append(out.Keys[:idx], out.Keys[idx+1:]...)
|
||||||
|
return out, nil
|
||||||
|
|
||||||
|
case AUMDisableNL:
|
||||||
|
// TODO(tom): We should handle this at a higher level than State.
|
||||||
|
if !s.checkDisablement(update.DisablementSecret) {
|
||||||
|
return State{}, errors.New("incorrect disablement secret")
|
||||||
|
}
|
||||||
|
// Valid disablement secret, lets reset
|
||||||
|
return State{}, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
// TODO(tom): Instead of erroring, update lastHash and
|
||||||
|
// continue (to preserve future compatibility).
|
||||||
|
return State{}, fmt.Errorf("unhandled message: %v", update.MessageKind)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,252 @@
|
|||||||
|
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package tka
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/google/go-cmp/cmp"
|
||||||
|
"github.com/google/go-cmp/cmp/cmpopts"
|
||||||
|
)
|
||||||
|
|
||||||
|
func fromHex(in string) []byte {
|
||||||
|
out, err := hex.DecodeString(in)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashFromHex(in string) *AUMHash {
|
||||||
|
var out AUMHash
|
||||||
|
copy(out[:], fromHex(in))
|
||||||
|
return &out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloneState(t *testing.T) {
|
||||||
|
tcs := []struct {
|
||||||
|
Name string
|
||||||
|
State State
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"Empty",
|
||||||
|
State{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Key",
|
||||||
|
State{
|
||||||
|
Keys: []Key{{Kind: Key25519, Votes: 2, Public: []byte{5, 6, 7, 8}, Meta: map[string]string{"a": "b"}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"DisablementSecrets",
|
||||||
|
State{
|
||||||
|
DisablementSecrets: [][]byte{
|
||||||
|
{1, 2, 3, 4},
|
||||||
|
{5, 6, 7, 8},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tcs {
|
||||||
|
t.Run(tc.Name, func(t *testing.T) {
|
||||||
|
if diff := cmp.Diff(tc.State, tc.State.Clone()); diff != "" {
|
||||||
|
t.Errorf("output state differs (-want, +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure the cloned State is the same even after
|
||||||
|
// an encode + decode into + from CBOR.
|
||||||
|
t.Run("cbor", func(t *testing.T) {
|
||||||
|
out := bytes.NewBuffer(nil)
|
||||||
|
encoder, err := cbor.CTAP2EncOptions().EncMode()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := encoder.NewEncoder(out).Encode(tc.State.Clone()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var decodedState State
|
||||||
|
if err := cbor.Unmarshal(out.Bytes(), &decodedState); err != nil {
|
||||||
|
t.Fatalf("Unmarshal failed: %v", err)
|
||||||
|
}
|
||||||
|
if diff := cmp.Diff(tc.State, decodedState); diff != "" {
|
||||||
|
t.Errorf("decoded state differs (-want, +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyUpdatesChain(t *testing.T) {
|
||||||
|
intOne := uint(1)
|
||||||
|
tcs := []struct {
|
||||||
|
Name string
|
||||||
|
Updates []AUM
|
||||||
|
Start State
|
||||||
|
End State
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"AddKey",
|
||||||
|
[]AUM{{MessageKind: AUMAddKey, Key: &Key{Kind: Key25519, Public: []byte{1, 2, 3, 4}}}},
|
||||||
|
State{},
|
||||||
|
State{
|
||||||
|
Keys: []Key{{Kind: Key25519, Public: []byte{1, 2, 3, 4}}},
|
||||||
|
LastAUMHash: hashFromHex("53898e4311d0b6087fcbb871563868a16c629d9267df851fcfa7b52b31d2bd03"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"RemoveKey",
|
||||||
|
[]AUM{{MessageKind: AUMRemoveKey, KeyID: []byte{1, 2, 3, 4}, PrevAUMHash: fromHex("53898e4311d0b6087fcbb871563868a16c629d9267df851fcfa7b52b31d2bd03")}},
|
||||||
|
State{
|
||||||
|
Keys: []Key{{Kind: Key25519, Public: []byte{1, 2, 3, 4}}},
|
||||||
|
LastAUMHash: hashFromHex("53898e4311d0b6087fcbb871563868a16c629d9267df851fcfa7b52b31d2bd03"),
|
||||||
|
},
|
||||||
|
State{
|
||||||
|
LastAUMHash: hashFromHex("15d65756abfafbb592279503f40759898590c9c59056be1e2e9f02684c15ba4b"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"UpdateKey",
|
||||||
|
[]AUM{{MessageKind: AUMUpdateKey, KeyID: []byte{1, 2, 3, 4}, Votes: &intOne, Meta: map[string]string{"a": "b"}, PrevAUMHash: fromHex("53898e4311d0b6087fcbb871563868a16c629d9267df851fcfa7b52b31d2bd03")}},
|
||||||
|
State{
|
||||||
|
Keys: []Key{{Kind: Key25519, Public: []byte{1, 2, 3, 4}}},
|
||||||
|
LastAUMHash: hashFromHex("53898e4311d0b6087fcbb871563868a16c629d9267df851fcfa7b52b31d2bd03"),
|
||||||
|
},
|
||||||
|
State{
|
||||||
|
LastAUMHash: hashFromHex("828fe04c16032cf3e0b021abca0b4d79924b0a18b2e627b308347aa87ce7c21c"),
|
||||||
|
Keys: []Key{{Kind: Key25519, Votes: 1, Meta: map[string]string{"a": "b"}, Public: []byte{1, 2, 3, 4}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ChainedKeyUpdates",
|
||||||
|
[]AUM{
|
||||||
|
{MessageKind: AUMAddKey, Key: &Key{Kind: Key25519, Public: []byte{5, 6, 7, 8}}},
|
||||||
|
{MessageKind: AUMRemoveKey, KeyID: []byte{1, 2, 3, 4}, PrevAUMHash: fromHex("f09bda3bb7cf6756ea9adc25770aede4b3ca8142949d6ef5ca0add29af912fd4")},
|
||||||
|
},
|
||||||
|
State{
|
||||||
|
Keys: []Key{{Kind: Key25519, Public: []byte{1, 2, 3, 4}}},
|
||||||
|
},
|
||||||
|
State{
|
||||||
|
Keys: []Key{{Kind: Key25519, Public: []byte{5, 6, 7, 8}}},
|
||||||
|
LastAUMHash: hashFromHex("218165fe5f757304b9deaff4ac742890364f5f509e533c74e80e0ce35e44ee1d"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Disablement",
|
||||||
|
[]AUM{{MessageKind: AUMDisableNL, DisablementSecret: []byte{1, 2, 3, 4}, PrevAUMHash: fromHex("53898e4311d0b6087fcbb871563868a16c629d9267df851fcfa7b52b31d2bd03")}},
|
||||||
|
State{
|
||||||
|
DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3, 4})},
|
||||||
|
LastAUMHash: hashFromHex("53898e4311d0b6087fcbb871563868a16c629d9267df851fcfa7b52b31d2bd03"),
|
||||||
|
},
|
||||||
|
State{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Checkpoint",
|
||||||
|
[]AUM{
|
||||||
|
{MessageKind: AUMAddKey, Key: &Key{Kind: Key25519, Public: []byte{5, 6, 7, 8}}},
|
||||||
|
{MessageKind: AUMCheckpoint, State: &State{
|
||||||
|
Keys: []Key{{Kind: Key25519, Public: []byte{1, 2, 3, 4}}},
|
||||||
|
}, PrevAUMHash: fromHex("f09bda3bb7cf6756ea9adc25770aede4b3ca8142949d6ef5ca0add29af912fd4")},
|
||||||
|
},
|
||||||
|
State{DisablementSecrets: [][]byte{[]byte{1, 2, 3, 4}}},
|
||||||
|
State{
|
||||||
|
Keys: []Key{{Kind: Key25519, Public: []byte{1, 2, 3, 4}}},
|
||||||
|
LastAUMHash: hashFromHex("2e34f7e21883c35c8e34ec06e735f7ed8a14c3ceeb11ccb18fcbc11d51c8dabb"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tcs {
|
||||||
|
t.Run(tc.Name, func(t *testing.T) {
|
||||||
|
state := tc.Start
|
||||||
|
for i := range tc.Updates {
|
||||||
|
var err error
|
||||||
|
// t.Logf("update[%d] start-state = %+v", i, state)
|
||||||
|
state, err = state.applyVerifiedAUM(tc.Updates[i])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Apply message[%d] failed: %v", i, err)
|
||||||
|
}
|
||||||
|
// t.Logf("update[%d] end-state = %+v", i, state)
|
||||||
|
|
||||||
|
updateHash := tc.Updates[i].Hash()
|
||||||
|
if tc.Updates[i].MessageKind != AUMDisableNL {
|
||||||
|
if got, want := *state.LastAUMHash, updateHash[:]; !bytes.Equal(got[:], want) {
|
||||||
|
t.Errorf("expected state.LastAUMHash = %x (update %d), got %x", want, i, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if diff := cmp.Diff(tc.End, state, cmpopts.EquateEmpty()); diff != "" {
|
||||||
|
t.Errorf("output state differs (+got, -want):\n%s", diff)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyUpdateErrors(t *testing.T) {
|
||||||
|
tcs := []struct {
|
||||||
|
Name string
|
||||||
|
Updates []AUM
|
||||||
|
Start State
|
||||||
|
Error error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"AddKey exists",
|
||||||
|
[]AUM{{MessageKind: AUMAddKey, Key: &Key{Kind: Key25519, Public: []byte{1, 2, 3, 4}}}},
|
||||||
|
State{Keys: []Key{{Kind: Key25519, Public: []byte{1, 2, 3, 4}}}},
|
||||||
|
errors.New("key already exists"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"RemoveKey notfound",
|
||||||
|
[]AUM{{MessageKind: AUMRemoveKey, Key: &Key{Kind: Key25519, Public: []byte{1, 2, 3, 4}}}},
|
||||||
|
State{},
|
||||||
|
ErrNoSuchKey,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"UpdateKey notfound",
|
||||||
|
[]AUM{{MessageKind: AUMUpdateKey, KeyID: []byte{1}}},
|
||||||
|
State{},
|
||||||
|
ErrNoSuchKey,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Bad lastAUMHash",
|
||||||
|
[]AUM{
|
||||||
|
{MessageKind: AUMAddKey, Key: &Key{Kind: Key25519, Public: []byte{5, 6, 7, 8}}},
|
||||||
|
{MessageKind: AUMRemoveKey, KeyID: []byte{1, 2, 3, 4}, PrevAUMHash: fromHex("1234")},
|
||||||
|
},
|
||||||
|
State{
|
||||||
|
Keys: []Key{{Kind: Key25519, Public: []byte{1, 2, 3, 4}}},
|
||||||
|
},
|
||||||
|
errors.New("parent AUMHash mismatch"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tcs {
|
||||||
|
t.Run(tc.Name, func(t *testing.T) {
|
||||||
|
state := tc.Start
|
||||||
|
for i := range tc.Updates {
|
||||||
|
var err error
|
||||||
|
// t.Logf("update[%d] start-state = %+v", i, state)
|
||||||
|
state, err = state.applyVerifiedAUM(tc.Updates[i])
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() != tc.Error.Error() {
|
||||||
|
t.Errorf("state[%d].Err = %v, want %v", i, err, tc.Error)
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// t.Logf("update[%d] end-state = %+v", i, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Errorf("did not error, expected %v", tc.Error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue