diff --git a/tka/aum.go b/tka/aum.go index 7d0897cd8..5d0181feb 100644 --- a/tka/aum.go +++ b/tka/aum.go @@ -146,7 +146,7 @@ func (a *AUM) StaticValidate() error { } if a.State != nil { - if len(a.State.LastAUMHash) != 0 { + if a.State.LastAUMHash != nil { return errors.New("checkpoint state cannot specify a parent AUM") } if len(a.State.DisablementSecrets) == 0 { diff --git a/tka/tka.go b/tka/tka.go index 5974950e6..63992e3dc 100644 --- a/tka/tka.go +++ b/tka/tka.go @@ -7,12 +7,27 @@ package tka import ( "bytes" + "crypto/ed25519" "errors" "fmt" "os" "sort" ) +// Authority is a Tailnet Key Authority. This type is the main coupling +// point to the rest of the tailscale client. +// +// Authority objects can either be created from an existing, non-empty +// tailchonk (via tka.Open()), or created from scratch using tka.Bootstrap() +// or tka.Create(). +type Authority struct { + head AUM + oldestAncestor AUM + state State + + storage Chonk +} + // A chain describes a linear sequence of updates from Oldest to Head, // resulting in some State at Head. type chain struct { @@ -336,3 +351,195 @@ func computeActiveChain(storage Chonk, lastKnownOldest *AUMHash, maxIter int) (c } return out, nil } + +// aumVerify verifies if an AUM is well-formed, correctly signed, and +// can be accepted for storage. +func aumVerify(aum AUM, state State, isGenesisAUM bool) error { + if err := aum.StaticValidate(); err != nil { + return fmt.Errorf("invalid: %v", err) + } + if !isGenesisAUM { + if err := checkParent(aum, state); err != nil { + return err + } + } + + if len(aum.Signatures) == 0 { + return errors.New("unsigned AUM") + } + sigHash := aum.SigHash() + for i, sig := range aum.Signatures { + key, err := state.GetKey(sig.KeyID) + if err != nil { + return fmt.Errorf("bad keyID on signature %d: %v", i, err) + } + if err := sig.Verify(sigHash, key); err != nil { + return fmt.Errorf("signature %d: %v", i, err) + } + } + return nil +} + +func checkParent(aum AUM, state State) error { + parent, hasParent := aum.Parent() + if !hasParent { + return errors.New("aum has no parent") + } + if state.LastAUMHash == nil { + return errors.New("cannot check update parent hash against a state with no previous AUM") + } + if *state.LastAUMHash != parent { + return fmt.Errorf("aum with parent %x cannot be applied to a state with parent %x", state.LastAUMHash, parent) + } + return nil +} + +// Head returns the AUM digest of the latest update applied to the state +// machine. +func (a *Authority) Head() AUMHash { + return *a.state.LastAUMHash +} + +// Open initializes an existing TKA from the given tailchonk. +// +// Only use this if the current node has initialized an Authority before. +// If a TKA exists on other nodes but theres nothing locally, use Bootstrap(). +// If no TKA exists anywhere and you are creating it for the first +// time, use New(). +func Open(storage Chonk) (*Authority, error) { + a, err := storage.LastActiveAncestor() + if err != nil { + return nil, fmt.Errorf("reading last ancestor: %v", err) + } + + c, err := computeActiveChain(storage, a, 2000) + if err != nil { + return nil, fmt.Errorf("active chain: %v", err) + } + + return &Authority{ + head: c.Head, + oldestAncestor: c.Oldest, + storage: storage, + state: c.state, + }, nil +} + +// Create initializes a brand-new TKA, generating a genesis update +// and committing it to the given storage. +// +// The given signer must also be present in state as a trusted key. +// +// Do not use this to initialize a TKA that already exists, use Open() +// or Bootstrap() instead. +func Create(storage Chonk, state State, signer ed25519.PrivateKey) (*Authority, AUM, error) { + // Generate & sign a checkpoint, our genesis update. + genesis := AUM{ + MessageKind: AUMCheckpoint, + State: &state, + } + if err := genesis.StaticValidate(); err != nil { + // This serves as an easy way to validate the given state. + return nil, AUM{}, fmt.Errorf("invalid state: %v", err) + } + genesis.sign25519(signer) + + a, err := Bootstrap(storage, genesis) + return a, genesis, err +} + +// Bootstrap initializes a TKA based on the given checkpoint. +// +// Call this when setting up a new nodes' TKA, but other nodes +// with initialized TKA's exist. +// +// Pass the returned genesis AUM from Create(), or a later checkpoint AUM. +// +// TODO(tom): We should test an authority bootstrapped from a later checkpoint +// works fine with sync and everything. +func Bootstrap(storage Chonk, bootstrap AUM) (*Authority, error) { + heads, err := storage.Heads() + if err != nil { + return nil, fmt.Errorf("reading heads: %v", err) + } + if len(heads) != 0 { + return nil, errors.New("tailchonk is not empty") + } + + // Check the AUM is well-formed. + if bootstrap.MessageKind != AUMCheckpoint { + return nil, fmt.Errorf("bootstrap AUMs must be checkpoint messages, got %v", bootstrap.MessageKind) + } + if bootstrap.State == nil { + return nil, errors.New("bootstrap AUM is missing state") + } + if err := aumVerify(bootstrap, *bootstrap.State, true); err != nil { + return nil, fmt.Errorf("invalid bootstrap: %v", err) + } + + // Everything looks good, write it to storage. + if err := storage.CommitVerifiedAUMs([]AUM{bootstrap}); err != nil { + return nil, fmt.Errorf("commit: %v", err) + } + if err := storage.SetLastActiveAncestor(bootstrap.Hash()); err != nil { + return nil, fmt.Errorf("set ancestor: %v", err) + } + + return Open(storage) +} + +// Inform is called to tell the authority about new updates. Updates +// should be ordered oldest to newest. An error is returned if any +// of the updates could not be processed. +func (a *Authority) Inform(updates []AUM) error { + stateAt := make(map[AUMHash]State, len(updates)+1) + toCommit := make([]AUM, 0, len(updates)) + + for i, update := range updates { + hash := update.Hash() + if _, err := a.storage.AUM(hash); err == nil { + // Already have this AUM. + continue + } + + parent, hasParent := update.Parent() + if !hasParent { + return fmt.Errorf("update %d: missing parent", i) + } + + state, hasState := stateAt[parent] + var err error + if !hasState { + if state, err = computeStateAt(a.storage, 2000, parent); err != nil { + return fmt.Errorf("update %d computing state: %v", i, err) + } + stateAt[parent] = state + } + + if err := aumVerify(update, state, false); err != nil { + return fmt.Errorf("update %d invalid: %v", i, err) + } + if stateAt[hash], err = state.applyVerifiedAUM(update); err != nil { + return fmt.Errorf("update %d cannot be applied: %v", i, err) + } + toCommit = append(toCommit, update) + } + + if err := a.storage.CommitVerifiedAUMs(toCommit); err != nil { + return fmt.Errorf("commit: %v", err) + } + + // TODO(tom): Theres no need to recompute the state from scratch + // in every case. We should detect when updates were + // a linear, non-forking series applied to head, and + // just use the last State we computed. + oldestAncestor := a.oldestAncestor.Hash() + c, err := computeActiveChain(a.storage, &oldestAncestor, 2000) + if err != nil { + return fmt.Errorf("recomputing active chain: %v", err) + } + a.head = c.Head + a.oldestAncestor = c.Oldest + a.state = c.state + return nil +} diff --git a/tka/tka_test.go b/tka/tka_test.go index 72b6d3476..413f1ac4c 100644 --- a/tka/tka_test.go +++ b/tka/tka_test.go @@ -185,3 +185,188 @@ func TestComputeStateAt(t *testing.T) { } } } + +// fakeAUM generates an AUM structure based on the template. +// If parent is provided, PrevAUMHash is set to that value. +// +// If template is an AUM, the returned AUM is based on that. +// If template is an int, a NOOP AUM is returned, and the +// provided int can be used to tweak the resulting hash (needed +// for tests you want one AUM to be 'lower' than another, so that +// that chain is taken based on fork resolution rules). +func fakeAUM(t *testing.T, template interface{}, parent *AUMHash) (AUM, AUMHash) { + if seed, ok := template.(int); ok { + a := AUM{MessageKind: AUMNoOp, KeyID: []byte{byte(seed)}} + if parent != nil { + a.PrevAUMHash = (*parent)[:] + } + h := a.Hash() + return a, h + } + + if a, ok := template.(AUM); ok { + if parent != nil { + a.PrevAUMHash = (*parent)[:] + } + h := a.Hash() + return a, h + } + + panic("template must be an int or an AUM") +} + +func TestOpenAuthority(t *testing.T) { + pub, _ := testingKey25519(t, 1) + key := Key{Kind: Key25519, Public: pub, Votes: 2} + + // /- L1 + // G1 - I1 - I2 - I3 -L2 + // \-L3 + // G2 - L4 + // + // We set the previous-known ancestor to G1, so the + // ancestor to start from should be G1. + g1, g1H := fakeAUM(t, AUM{MessageKind: AUMAddKey, Key: &key}, nil) + i1, i1H := fakeAUM(t, 2, &g1H) // AUM{MessageKind: AUMAddKey, Key: &key2} + l1, l1H := fakeAUM(t, 13, &i1H) + + i2, i2H := fakeAUM(t, 2, &i1H) + i3, i3H := fakeAUM(t, 5, &i2H) + l2, l2H := fakeAUM(t, AUM{MessageKind: AUMNoOp, KeyID: []byte{7}, Signatures: []Signature{{KeyID: key.ID()}}}, &i3H) + l3, l3H := fakeAUM(t, 4, &i3H) + + g2, g2H := fakeAUM(t, 8, nil) + l4, _ := fakeAUM(t, 9, &g2H) + + // We make sure that I2 has a lower hash than L1, so + // it should take that path rather than L1. + if bytes.Compare(l1H[:], i2H[:]) < 0 { + t.Fatal("failed assert: h(i2) > h(l1)\nTweak parameters to fakeAUM till this passes") + } + // We make sure L2 has a signature with key, so it should + // take that path over L3. We assert that the L3 hash + // is less than L2 so the test will fail if the signature + // preference logic is broken. + if bytes.Compare(l2H[:], l3H[:]) < 0 { + t.Fatal("failed assert: h(l3) > h(l2)\nTweak parameters to fakeAUM till this passes") + } + + // Construct the state of durable storage. + chonk := &Mem{} + err := chonk.CommitVerifiedAUMs([]AUM{g1, i1, l1, i2, i3, l2, l3, g2, l4}) + if err != nil { + t.Fatal(err) + } + chonk.SetLastActiveAncestor(i1H) + + a, err := Open(chonk) + if err != nil { + t.Fatalf("New() failed: %v", err) + } + // Should include the key added in G1 + if _, err := a.state.GetKey(key.ID()); err != nil { + t.Errorf("missing G1 key: %v", err) + } + // The head of the chain should be L2. + if a.Head() != l2H { + t.Errorf("head was %x, want %x", a.state.LastAUMHash, l2H) + } +} + +func TestOpenAuthority_EmptyErrors(t *testing.T) { + _, err := Open(&Mem{}) + if err == nil { + t.Error("Expected an error initializing an empty authority, got nil") + } +} + +func TestAuthorityHead(t *testing.T) { + c := newTestchain(t, ` + G1 -> L1 + | -> L2 + + L1.hashSeed = 2 + `) + + a, _ := Open(c.Chonk()) + if got, want := a.head.Hash(), a.Head(); got != want { + t.Errorf("Hash() returned %x, want %x", got, want) + } +} + +func TestCreateBootstrapAuthority(t *testing.T) { + pub, priv := testingKey25519(t, 1) + key := Key{Kind: Key25519, Public: pub, Votes: 2} + + a1, genesisAUM, err := Create(&Mem{}, State{ + Keys: []Key{key}, + DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})}, + }, priv) + if err != nil { + t.Fatalf("Create() failed: %v", err) + } + + a2, err := Bootstrap(&Mem{}, genesisAUM) + if err != nil { + t.Fatalf("Bootstrap() failed: %v", err) + } + + if a1.Head() != a2.Head() { + t.Fatal("created and bootstrapped authority differ") + } + + // Both authorities should trust the key laid down in the genesis state. + if _, err := a1.state.GetKey(key.ID()); err != nil { + t.Errorf("reading genesis key from a1: %v", err) + } + if _, err := a2.state.GetKey(key.ID()); err != nil { + t.Errorf("reading genesis key from a2: %v", err) + } +} + +func TestAuthorityInform(t *testing.T) { + pub, priv := testingKey25519(t, 1) + key := Key{Kind: Key25519, Public: pub, Votes: 2} + + c := newTestchain(t, ` + G1 -> L1 + | -> L2 -> L3 + | -> L4 -> L5 + + G1.template = genesis + L2.hashSeed = 1 + L4.hashSeed = 2 + `, + optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{ + Keys: []Key{key}, + DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})}, + }}), + optKey("key", key, priv), + optSignAllUsing("key")) + + storage := &Mem{} + a, err := Bootstrap(storage, c.AUMs["G1"]) + if err != nil { + t.Fatalf("Bootstrap() failed: %v", err) + } + + informAUMs := []AUM{c.AUMs["L1"], c.AUMs["L2"], c.AUMs["L3"], c.AUMs["L4"], c.AUMs["L5"]} + + if err := a.Inform(informAUMs); err != nil { + t.Fatalf("Inform() failed: %v", err) + } + for i, update := range informAUMs { + stored, err := storage.AUM(update.Hash()) + if err != nil { + t.Errorf("reading stored update %d: %v", i, err) + continue + } + if diff := cmp.Diff(update, stored); diff != "" { + t.Errorf("update %d differs (-want, +got):\n%s", i, diff) + } + } + + if a.Head() != c.AUMHashes["L3"] { + t.Fatal("authority did not converge to correct AUM") + } +}