From 79905a11626b495e58ae6b707230736f1945e3f0 Mon Sep 17 00:00:00 2001
From: Tom DNetto <tom@tailscale.com>
Date: Fri, 26 Aug 2022 09:45:16 -0700
Subject: [PATCH] tka: make storage a parameter rather than an Authority struct
 member

Updates #5435

Based on the discussion in #5435, we can better support transactional data models
by making the underlying storage layer a parameter (which can be specialized for
the request) rather than a long-lived member of Authority.

Now that Authority is just an instantaneous snapshot of state, we can do things
like provide idempotent methods and make it cloneable, too.

Signed-off-by: Tom DNetto <tom@tailscale.com>
---
 ipn/ipnlocal/local.go        |  9 +++--
 ipn/ipnlocal/network-lock.go |  7 +++-
 ipn/ipnserver/server.go      |  6 +--
 tka/builder_test.go          | 25 +++++++-----
 tka/scenario_test.go         | 32 ++++++++-------
 tka/sync.go                  | 56 ++++++++++++--------------
 tka/sync_test.go             | 55 +++++++++++++------------
 tka/tka.go                   | 78 ++++++++++++++++++++++++------------
 tka/tka_test.go              |  4 +-
 9 files changed, 158 insertions(+), 114 deletions(-)

diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go
index 1b59bdd4c..c69bb54e9 100644
--- a/ipn/ipnlocal/local.go
+++ b/ipn/ipnlocal/local.go
@@ -148,7 +148,7 @@ type LocalBackend struct {
 	inServerMode   bool
 	machinePrivKey key.MachinePrivate
 	nlPrivKey      key.NLPrivate
-	tka            *tka.Authority
+	tka            *tkaState
 	state          ipn.State
 	capFileSharing bool // whether netMap contains the file sharing capability
 	// hostinfo is mutated in-place while mu is held.
@@ -2507,8 +2507,11 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, prefs *ipn.Prefs, logf logger.Log
 // used for locked tailnets.
 //
 // It should only be called before the LocalBackend is used.
-func (b *LocalBackend) SetTailnetKeyAuthority(a *tka.Authority) {
-	b.tka = a
+func (b *LocalBackend) SetTailnetKeyAuthority(a *tka.Authority, storage *tka.FS) {
+	b.tka = &tkaState{
+		authority: a,
+		storage:   storage,
+	}
 }
 
 // SetVarRoot sets the root directory of Tailscale's writable
diff --git a/ipn/ipnlocal/network-lock.go b/ipn/ipnlocal/network-lock.go
index 89164828c..22ddea105 100644
--- a/ipn/ipnlocal/network-lock.go
+++ b/ipn/ipnlocal/network-lock.go
@@ -26,6 +26,11 @@ import (
 
 var networkLockAvailable = envknob.Bool("TS_EXPERIMENTAL_NETWORK_LOCK")
 
+type tkaState struct {
+	authority *tka.Authority
+	storage   *tka.FS
+}
+
 // CanSupportNetworkLock returns true if tailscaled is able to operate
 // a local tailnet key authority (and hence enforce network lock).
 func (b *LocalBackend) CanSupportNetworkLock() bool {
@@ -54,7 +59,7 @@ func (b *LocalBackend) NetworkLockStatus() *ipnstate.NetworkLockStatus {
 	}
 
 	var head [32]byte
-	h := b.tka.Head()
+	h := b.tka.authority.Head()
 	copy(head[:], h[:])
 
 	return &ipnstate.NetworkLockStatus{
diff --git a/ipn/ipnserver/server.go b/ipn/ipnserver/server.go
index 42fe8c28e..0f23d56d5 100644
--- a/ipn/ipnserver/server.go
+++ b/ipn/ipnserver/server.go
@@ -775,15 +775,15 @@ func New(logf logger.Logf, logid string, store ipn.StateStore, eng wgengine.Engi
 		chonkDir := filepath.Join(root, "chonk")
 		if _, err := os.Stat(chonkDir); err == nil {
 			// The directory exists, which means network-lock has been initialized.
-			chonk, err := tka.ChonkDir(chonkDir)
+			storage, err := tka.ChonkDir(chonkDir)
 			if err != nil {
 				return nil, fmt.Errorf("opening tailchonk: %v", err)
 			}
-			authority, err := tka.Open(chonk)
+			authority, err := tka.Open(storage)
 			if err != nil {
 				return nil, fmt.Errorf("initializing tka: %v", err)
 			}
-			b.SetTailnetKeyAuthority(authority)
+			b.SetTailnetKeyAuthority(authority, storage)
 			logf("tka initialized at head %x", authority.Head())
 		}
 	} else {
diff --git a/tka/builder_test.go b/tka/builder_test.go
index b3c599700..10ea71d19 100644
--- a/tka/builder_test.go
+++ b/tka/builder_test.go
@@ -28,7 +28,8 @@ func TestAuthorityBuilderAddKey(t *testing.T) {
 	pub, priv := testingKey25519(t, 1)
 	key := Key{Kind: Key25519, Public: pub, Votes: 2}
 
-	a, _, err := Create(&Mem{}, State{
+	storage := &Mem{}
+	a, _, err := Create(storage, State{
 		Keys:               []Key{key},
 		DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})},
 	}, signer25519(priv))
@@ -50,7 +51,7 @@ func TestAuthorityBuilderAddKey(t *testing.T) {
 
 	// See if the update is valid by applying it to the authority
 	// + checking if the new key is there.
-	if err := a.Inform(updates); err != nil {
+	if err := a.Inform(storage, updates); err != nil {
 		t.Fatalf("could not apply generated updates: %v", err)
 	}
 	if _, err := a.state.GetKey(key2.ID()); err != nil {
@@ -64,7 +65,8 @@ func TestAuthorityBuilderRemoveKey(t *testing.T) {
 	pub2, _ := testingKey25519(t, 2)
 	key2 := Key{Kind: Key25519, Public: pub2, Votes: 1}
 
-	a, _, err := Create(&Mem{}, State{
+	storage := &Mem{}
+	a, _, err := Create(storage, State{
 		Keys:               []Key{key, key2},
 		DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})},
 	}, signer25519(priv))
@@ -83,7 +85,7 @@ func TestAuthorityBuilderRemoveKey(t *testing.T) {
 
 	// See if the update is valid by applying it to the authority
 	// + checking if the key has been removed.
-	if err := a.Inform(updates); err != nil {
+	if err := a.Inform(storage, updates); err != nil {
 		t.Fatalf("could not apply generated updates: %v", err)
 	}
 	if _, err := a.state.GetKey(key2.ID()); err != ErrNoSuchKey {
@@ -95,7 +97,8 @@ func TestAuthorityBuilderSetKeyVote(t *testing.T) {
 	pub, priv := testingKey25519(t, 1)
 	key := Key{Kind: Key25519, Public: pub, Votes: 2}
 
-	a, _, err := Create(&Mem{}, State{
+	storage := &Mem{}
+	a, _, err := Create(storage, State{
 		Keys:               []Key{key},
 		DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})},
 	}, signer25519(priv))
@@ -114,7 +117,7 @@ func TestAuthorityBuilderSetKeyVote(t *testing.T) {
 
 	// See if the update is valid by applying it to the authority
 	// + checking if the update is there.
-	if err := a.Inform(updates); err != nil {
+	if err := a.Inform(storage, updates); err != nil {
 		t.Fatalf("could not apply generated updates: %v", err)
 	}
 	k, err := a.state.GetKey(key.ID())
@@ -130,7 +133,8 @@ func TestAuthorityBuilderSetKeyMeta(t *testing.T) {
 	pub, priv := testingKey25519(t, 1)
 	key := Key{Kind: Key25519, Public: pub, Votes: 2, Meta: map[string]string{"a": "b"}}
 
-	a, _, err := Create(&Mem{}, State{
+	storage := &Mem{}
+	a, _, err := Create(storage, State{
 		Keys:               []Key{key},
 		DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})},
 	}, signer25519(priv))
@@ -149,7 +153,7 @@ func TestAuthorityBuilderSetKeyMeta(t *testing.T) {
 
 	// See if the update is valid by applying it to the authority
 	// + checking if the update is there.
-	if err := a.Inform(updates); err != nil {
+	if err := a.Inform(storage, updates); err != nil {
 		t.Fatalf("could not apply generated updates: %v", err)
 	}
 	k, err := a.state.GetKey(key.ID())
@@ -165,7 +169,8 @@ func TestAuthorityBuilderMultiple(t *testing.T) {
 	pub, priv := testingKey25519(t, 1)
 	key := Key{Kind: Key25519, Public: pub, Votes: 2}
 
-	a, _, err := Create(&Mem{}, State{
+	storage := &Mem{}
+	a, _, err := Create(storage, State{
 		Keys:               []Key{key},
 		DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})},
 	}, signer25519(priv))
@@ -193,7 +198,7 @@ func TestAuthorityBuilderMultiple(t *testing.T) {
 
 	// See if the update is valid by applying it to the authority
 	// + checking if the update is there.
-	if err := a.Inform(updates); err != nil {
+	if err := a.Inform(storage, updates); err != nil {
 		t.Fatalf("could not apply generated updates: %v", err)
 	}
 	k, err := a.state.GetKey(key2.ID())
diff --git a/tka/scenario_test.go b/tka/scenario_test.go
index eb96f1ec5..7aa7a960c 100644
--- a/tka/scenario_test.go
+++ b/tka/scenario_test.go
@@ -16,6 +16,8 @@ type scenarioNode struct {
 	Name string
 	A    *Authority
 	AUMs map[string]AUM
+
+	storage Chonk
 }
 
 type scenarioTest struct {
@@ -30,7 +32,8 @@ type scenarioTest struct {
 }
 
 func (s *scenarioTest) mkNode(name string) *scenarioNode {
-	authority, err := Open(s.initial.Chonk())
+	storage := s.initial.Chonk()
+	authority, err := Open(storage)
 	if err != nil {
 		s.t.Fatal(err)
 	}
@@ -41,9 +44,10 @@ func (s *scenarioTest) mkNode(name string) *scenarioNode {
 	}
 
 	n := &scenarioNode{
-		A:    authority,
-		AUMs: aums,
-		Name: name,
+		A:       authority,
+		AUMs:    aums,
+		Name:    name,
+		storage: storage,
 	}
 
 	s.nodes[name] = n
@@ -89,7 +93,7 @@ func (s *scenarioTest) mkNodeWithForks(name string, signWithDefault bool, chains
 			}
 			return false
 		})
-		if err := n.A.Inform(aums); err != nil {
+		if err := n.A.Inform(n.storage, aums); err != nil {
 			panic(err)
 		}
 	}
@@ -114,27 +118,27 @@ outer:
 }
 
 func (s *scenarioTest) syncBetween(n1, n2 *scenarioNode) error {
-	o1, err := n1.A.SyncOffer()
+	o1, err := n1.A.SyncOffer(n1.storage)
 	if err != nil {
 		return err
 	}
-	o2, err := n2.A.SyncOffer()
+	o2, err := n2.A.SyncOffer(n2.storage)
 	if err != nil {
 		return err
 	}
 
-	aumsFrom1, err := n1.A.MissingAUMs(o2)
+	aumsFrom1, err := n1.A.MissingAUMs(n1.storage, o2)
 	if err != nil {
 		return err
 	}
-	aumsFrom2, err := n2.A.MissingAUMs(o1)
+	aumsFrom2, err := n2.A.MissingAUMs(n2.storage, o1)
 	if err != nil {
 		return err
 	}
-	if err := n2.A.Inform(aumsFrom1); err != nil {
+	if err := n2.A.Inform(n2.storage, aumsFrom1); err != nil {
 		return err
 	}
-	if err := n1.A.Inform(aumsFrom2); err != nil {
+	if err := n1.A.Inform(n1.storage, aumsFrom2); err != nil {
 		return err
 	}
 	return nil
@@ -303,7 +307,7 @@ func TestInvalidAUMPropergationRejected(t *testing.T) {
 	l4 := AUM{MessageKind: AUMAddKey, PrevAUMHash: l3H[:]}
 	l4.sign25519(s.defaultPriv)
 	l4H := l4.Hash()
-	n1.A.storage.CommitVerifiedAUMs([]AUM{l4})
+	n1.storage.CommitVerifiedAUMs([]AUM{l4})
 	n1.A.state.LastAUMHash = &l4H
 
 	// Does control nope out with syncing?
@@ -336,7 +340,7 @@ func TestUnsignedAUMPropergationRejected(t *testing.T) {
 	l3H := l3.Hash()
 	l4 := AUM{MessageKind: AUMNoOp, PrevAUMHash: l3H[:]}
 	l4H := l4.Hash()
-	n1.A.storage.CommitVerifiedAUMs([]AUM{l4})
+	n1.storage.CommitVerifiedAUMs([]AUM{l4})
 	n1.A.state.LastAUMHash = &l4H
 
 	// Does control nope out with syncing?
@@ -370,7 +374,7 @@ func TestBadSigAUMPropergationRejected(t *testing.T) {
 	l4.sign25519(s.defaultPriv)
 	l4.Signatures[0].Signature[3] = 42
 	l4H := l4.Hash()
-	n1.A.storage.CommitVerifiedAUMs([]AUM{l4})
+	n1.storage.CommitVerifiedAUMs([]AUM{l4})
 	n1.A.state.LastAUMHash = &l4H
 
 	// Does control nope out with syncing?
diff --git a/tka/sync.go b/tka/sync.go
index 7d4443016..2594059e0 100644
--- a/tka/sync.go
+++ b/tka/sync.go
@@ -43,7 +43,19 @@ const (
 	ancestorsSkipShift = 2
 )
 
-func (a *Authority) syncOffer() (SyncOffer, error) {
+// SyncOffer returns an abbreviated description of the current AUM
+// chain, which can be used to synchronize with another (untrusted)
+// Authority instance.
+//
+// The returned SyncOffer structure should be transmitted to the remote
+// Authority, which should call MissingAUMs() using it to determine
+// AUMs which need to be transmitted. This list of AUMs from the remote
+// can then be applied locally with Inform().
+//
+// This SyncOffer + AUM exchange should be performed by both ends,
+// because its possible that either end has AUMs that the other needs
+// to find out about.
+func (a *Authority) SyncOffer(storage Chonk) (SyncOffer, error) {
 	oldest := a.oldestAncestor.Hash()
 
 	out := SyncOffer{
@@ -65,7 +77,7 @@ func (a *Authority) syncOffer() (SyncOffer, error) {
 			skipAmount = skipAmount << ancestorsSkipShift
 		}
 
-		parent, err := a.storage.AUM(curs)
+		parent, err := storage.AUM(curs)
 		if err != nil {
 			if err != os.ErrNotExist {
 				return SyncOffer{}, err
@@ -84,22 +96,6 @@ func (a *Authority) syncOffer() (SyncOffer, error) {
 	return out, nil
 }
 
-// SyncOffer returns an abbreviated description of the current AUM
-// chain, which can be used to synchronize with another (untrusted)
-// Authority instance.
-//
-// The returned SyncOffer structure should be transmitted to the remote
-// Authority, which should call MissingAUMs() using it to determine
-// AUMs which need to be transmitted. This list of AUMs from the remote
-// can then be applied locally with Inform().
-//
-// This SyncOffer + AUM exchange should be performed by both ends,
-// because its possible that either end has AUMs that the other needs
-// to find out about.
-func (a *Authority) SyncOffer() (SyncOffer, error) {
-	return a.syncOffer()
-}
-
 // intersection describes how to synchronize AUMs with a remote
 // authority.
 type intersection struct {
@@ -119,7 +115,7 @@ type intersection struct {
 // computeSyncIntersection determines the common AUMs between a local and
 // remote SyncOffer. This intersection can be used to synchronize both
 // sides.
-func computeSyncIntersection(authority *Authority, localOffer, remoteOffer SyncOffer) (*intersection, error) {
+func computeSyncIntersection(storage Chonk, localOffer, remoteOffer SyncOffer) (*intersection, error) {
 	// Simple case: up to date.
 	if remoteOffer.Head == localOffer.Head {
 		return &intersection{upToDate: true, headIntersection: &localOffer.Head}, nil
@@ -136,7 +132,7 @@ func computeSyncIntersection(authority *Authority, localOffer, remoteOffer SyncO
 	// <Them> A -> B
 	//   ∴ their head intersects with our chain, we need to send C
 	var hasRemoteHead bool
-	_, err := authority.storage.AUM(remoteOffer.Head)
+	_, err := storage.AUM(remoteOffer.Head)
 	if err != nil {
 		if err != os.ErrNotExist {
 			return nil, err
@@ -148,7 +144,7 @@ func computeSyncIntersection(authority *Authority, localOffer, remoteOffer SyncO
 	if hasRemoteHead {
 		curs := localOffer.Head
 		for i := 0; i < maxSyncHeadIntersectionIter; i++ {
-			parent, err := authority.storage.AUM(curs)
+			parent, err := storage.AUM(curs)
 			if err != nil {
 				if err != os.ErrNotExist {
 					return nil, err
@@ -176,7 +172,7 @@ func computeSyncIntersection(authority *Authority, localOffer, remoteOffer SyncO
 	// a bit of luck we can use an earlier one and hence do less work /
 	// transmit fewer AUMs.
 	for _, a := range remoteOffer.Ancestors {
-		state, err := computeStateAt(authority.storage, maxSyncIter, a)
+		state, err := computeStateAt(storage, maxSyncIter, a)
 		if err != nil {
 			if err != os.ErrNotExist {
 				return nil, fmt.Errorf("computeStateAt: %v", err)
@@ -184,7 +180,7 @@ func computeSyncIntersection(authority *Authority, localOffer, remoteOffer SyncO
 			continue
 		}
 
-		end, _, err := fastForward(authority.storage, maxSyncIter, state, func(curs AUM, _ State) bool {
+		end, _, err := fastForward(storage, maxSyncIter, state, func(curs AUM, _ State) bool {
 			return curs.Hash() == localOffer.Head
 		})
 		if err != nil {
@@ -203,12 +199,12 @@ func computeSyncIntersection(authority *Authority, localOffer, remoteOffer SyncO
 
 // MissingAUMs returns AUMs a remote may be missing based on the
 // remotes' SyncOffer.
-func (a *Authority) MissingAUMs(remoteOffer SyncOffer) ([]AUM, error) {
-	localOffer, err := a.syncOffer()
+func (a *Authority) MissingAUMs(storage Chonk, remoteOffer SyncOffer) ([]AUM, error) {
+	localOffer, err := a.SyncOffer(storage)
 	if err != nil {
 		return nil, fmt.Errorf("local syncOffer: %v", err)
 	}
-	intersection, err := computeSyncIntersection(a, localOffer, remoteOffer)
+	intersection, err := computeSyncIntersection(storage, localOffer, remoteOffer)
 	if err != nil {
 		return nil, fmt.Errorf("intersection: %v", err)
 	}
@@ -218,12 +214,12 @@ func (a *Authority) MissingAUMs(remoteOffer SyncOffer) ([]AUM, error) {
 	out := make([]AUM, 0, 12) // 12 chosen arbitrarily.
 
 	if intersection.headIntersection != nil {
-		state, err := computeStateAt(a.storage, maxSyncIter, *intersection.headIntersection)
+		state, err := computeStateAt(storage, maxSyncIter, *intersection.headIntersection)
 		if err != nil {
 			return nil, err
 		}
 
-		_, _, err = fastForward(a.storage, maxSyncIter, state, func(curs AUM, _ State) bool {
+		_, _, err = fastForward(storage, maxSyncIter, state, func(curs AUM, _ State) bool {
 			if curs.Hash() != *intersection.headIntersection {
 				out = append(out, curs)
 			}
@@ -233,12 +229,12 @@ func (a *Authority) MissingAUMs(remoteOffer SyncOffer) ([]AUM, error) {
 	}
 
 	if intersection.tailIntersection != nil {
-		state, err := computeStateAt(a.storage, maxSyncIter, *intersection.tailIntersection)
+		state, err := computeStateAt(storage, maxSyncIter, *intersection.tailIntersection)
 		if err != nil {
 			return nil, err
 		}
 
-		_, _, err = fastForward(a.storage, maxSyncIter, state, func(curs AUM, _ State) bool {
+		_, _, err = fastForward(storage, maxSyncIter, state, func(curs AUM, _ State) bool {
 			if curs.Hash() != *intersection.tailIntersection {
 				out = append(out, curs)
 			}
diff --git a/tka/sync_test.go b/tka/sync_test.go
index 65cba0ab8..47e0d7a05 100644
--- a/tka/sync_test.go
+++ b/tka/sync_test.go
@@ -18,11 +18,12 @@ func TestSyncOffer(t *testing.T) {
         A10 -> A11 -> A12 -> A13 -> A14 -> A15 -> A16 -> A17 -> A18
         A18 -> A19 -> A20 -> A21 -> A22 -> A23 -> A24 -> A25
     `)
-	a, err := Open(c.Chonk())
+	storage := c.Chonk()
+	a, err := Open(storage)
 	if err != nil {
 		t.Fatal(err)
 	}
-	got, err := a.SyncOffer()
+	got, err := a.SyncOffer(storage)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -56,7 +57,7 @@ func TestComputeSyncIntersection_FastForward(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
-	offer1, err := n1.SyncOffer()
+	offer1, err := n1.SyncOffer(chonk1)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -66,7 +67,7 @@ func TestComputeSyncIntersection_FastForward(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
-	offer2, err := n2.SyncOffer()
+	offer2, err := n2.SyncOffer(chonk2)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -74,7 +75,7 @@ func TestComputeSyncIntersection_FastForward(t *testing.T) {
 	// Node 1 only knows about the first two nodes, so the head of n2 is
 	// alien to it.
 	t.Run("n1", func(t *testing.T) {
-		got, err := computeSyncIntersection(n1, offer1, offer2)
+		got, err := computeSyncIntersection(chonk1, offer1, offer2)
 		if err != nil {
 			t.Fatalf("computeSyncIntersection() failed: %v", err)
 		}
@@ -89,7 +90,7 @@ func TestComputeSyncIntersection_FastForward(t *testing.T) {
 	// Node 2 knows about the full chain, so it can see that the head of n1
 	// intersects with a subset of its chain (a Head Intersection).
 	t.Run("n2", func(t *testing.T) {
-		got, err := computeSyncIntersection(n2, offer2, offer1)
+		got, err := computeSyncIntersection(chonk2, offer2, offer1)
 		if err != nil {
 			t.Fatalf("computeSyncIntersection() failed: %v", err)
 		}
@@ -122,11 +123,12 @@ func TestComputeSyncIntersection_ForkSmallDiff(t *testing.T) {
 		t.Fatal("failed assert: h(a9) > h(f1H)\nTweak hashSeed till this passes")
 	}
 
-	n1, err := Open(c.ChonkWith("A1", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "F1"))
+	chonk1 := c.ChonkWith("A1", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "F1")
+	n1, err := Open(chonk1)
 	if err != nil {
 		t.Fatal(err)
 	}
-	offer1, err := n1.SyncOffer()
+	offer1, err := n1.SyncOffer(chonk1)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -140,11 +142,12 @@ func TestComputeSyncIntersection_ForkSmallDiff(t *testing.T) {
 		t.Errorf("offer1 diff (-want, +got):\n%s", diff)
 	}
 
-	n2, err := Open(c.ChonkWith("A1", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "A9", "A10"))
+	chonk2 := c.ChonkWith("A1", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "A9", "A10")
+	n2, err := Open(chonk2)
 	if err != nil {
 		t.Fatal(err)
 	}
-	offer2, err := n2.SyncOffer()
+	offer2, err := n2.SyncOffer(chonk2)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -164,7 +167,7 @@ func TestComputeSyncIntersection_ForkSmallDiff(t *testing.T) {
 		// n2 has 10 nodes, so the first common ancestor should be 10-ancestorsSkipStart
 		wantIntersection := c.AUMHashes["A"+strconv.Itoa(10-ancestorsSkipStart)]
 
-		got, err := computeSyncIntersection(n1, offer1, offer2)
+		got, err := computeSyncIntersection(chonk1, offer1, offer2)
 		if err != nil {
 			t.Fatalf("computeSyncIntersection() failed: %v", err)
 		}
@@ -181,7 +184,7 @@ func TestComputeSyncIntersection_ForkSmallDiff(t *testing.T) {
 		// n1 has 9 nodes, so the first common ancestor should be 9-ancestorsSkipStart
 		wantIntersection := c.AUMHashes["A"+strconv.Itoa(9-ancestorsSkipStart)]
 
-		got, err := computeSyncIntersection(n2, offer2, offer1)
+		got, err := computeSyncIntersection(chonk2, offer2, offer1)
 		if err != nil {
 			t.Fatalf("computeSyncIntersection() failed: %v", err)
 		}
@@ -210,7 +213,7 @@ func TestMissingAUMs_FastForward(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
-	offer1, err := n1.SyncOffer()
+	offer1, err := n1.SyncOffer(chonk1)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -220,7 +223,7 @@ func TestMissingAUMs_FastForward(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
-	offer2, err := n2.SyncOffer()
+	offer2, err := n2.SyncOffer(chonk2)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -229,7 +232,7 @@ func TestMissingAUMs_FastForward(t *testing.T) {
 	// alien to it. As such, it should send history from the newest ancestor,
 	// A1 (if the chain was longer there would be one in the middle).
 	t.Run("n1", func(t *testing.T) {
-		got, err := n1.MissingAUMs(offer2)
+		got, err := n1.MissingAUMs(chonk1, offer2)
 		if err != nil {
 			t.Fatalf("MissingAUMs() failed: %v", err)
 		}
@@ -245,7 +248,7 @@ func TestMissingAUMs_FastForward(t *testing.T) {
 	// Node 2 knows about the full chain, so it can see that the head of n1
 	// intersects with a subset of its chain (a Head Intersection).
 	t.Run("n2", func(t *testing.T) {
-		got, err := n2.MissingAUMs(offer1)
+		got, err := n2.MissingAUMs(chonk2, offer1)
 		if err != nil {
 			t.Fatalf("MissingAUMs() failed: %v", err)
 		}
@@ -277,7 +280,7 @@ func TestMissingAUMs_Fork(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
-	offer1, err := n1.SyncOffer()
+	offer1, err := n1.SyncOffer(chonk1)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -287,13 +290,13 @@ func TestMissingAUMs_Fork(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
-	offer2, err := n2.SyncOffer()
+	offer2, err := n2.SyncOffer(chonk2)
 	if err != nil {
 		t.Fatal(err)
 	}
 
 	t.Run("n1", func(t *testing.T) {
-		got, err := n1.MissingAUMs(offer2)
+		got, err := n1.MissingAUMs(chonk1, offer2)
 		if err != nil {
 			t.Fatalf("MissingAUMs() failed: %v", err)
 		}
@@ -311,7 +314,7 @@ func TestMissingAUMs_Fork(t *testing.T) {
 	})
 
 	t.Run("n2", func(t *testing.T) {
-		got, err := n2.MissingAUMs(offer1)
+		got, err := n2.MissingAUMs(chonk2, offer1)
 		if err != nil {
 			t.Fatalf("MissingAUMs() failed: %v", err)
 		}
@@ -344,26 +347,28 @@ func TestSyncSimpleE2E(t *testing.T) {
 		optKey("key", key, priv),
 		optSignAllUsing("key"))
 
-	node, err := Bootstrap(&Mem{}, c.AUMs["G1"])
+	nodeStorage := &Mem{}
+	node, err := Bootstrap(nodeStorage, c.AUMs["G1"])
 	if err != nil {
 		t.Fatalf("node Bootstrap() failed: %v", err)
 	}
-	control, err := Open(c.Chonk())
+	controlStorage := c.Chonk()
+	control, err := Open(controlStorage)
 	if err != nil {
 		t.Fatalf("control Open() failed: %v", err)
 	}
 
 	// Control knows the full chain, node only knows the genesis. Lets see
 	// if they can sync.
-	nodeOffer, err := node.SyncOffer()
+	nodeOffer, err := node.SyncOffer(nodeStorage)
 	if err != nil {
 		t.Fatal(err)
 	}
-	controlAUMs, err := control.MissingAUMs(nodeOffer)
+	controlAUMs, err := control.MissingAUMs(controlStorage, nodeOffer)
 	if err != nil {
 		t.Fatalf("control.MissingAUMs(%v) failed: %v", nodeOffer, err)
 	}
-	if err := node.Inform(controlAUMs); err != nil {
+	if err := node.Inform(nodeStorage, controlAUMs); err != nil {
 		t.Fatalf("node.Inform(%v) failed: %v", controlAUMs, err)
 	}
 
diff --git a/tka/tka.go b/tka/tka.go
index 61434592c..17159d2e2 100644
--- a/tka/tka.go
+++ b/tka/tka.go
@@ -39,8 +39,15 @@ type Authority struct {
 	head           AUM
 	oldestAncestor AUM
 	state          State
+}
 
-	storage Chonk
+// Clone duplicates the Authority structure.
+func (a *Authority) Clone() *Authority {
+	return &Authority{
+		head:           a.head,
+		oldestAncestor: a.oldestAncestor,
+		state:          a.state.Clone(),
+	}
 }
 
 // A chain describes a linear sequence of updates from Oldest to Head,
@@ -477,7 +484,6 @@ func Open(storage Chonk) (*Authority, error) {
 	return &Authority{
 		head:           c.Head,
 		oldestAncestor: c.Oldest,
-		storage:        storage,
 		state:          c.state,
 	}, nil
 }
@@ -557,12 +563,18 @@ func (a *Authority) ValidDisablement(secret []byte) bool {
 	return a.state.checkDisablement(secret)
 }
 
-// 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 {
+// InformIdempotent returns a new Authority based on applying the given
+// updates, with the given updates committed to storage.
+//
+// If any of the updates could not be applied:
+//   - An error is returned
+//   - No changes to storage are made.
+//
+// MissingAUMs() should be used to get a list of updates appropriate for
+// this function. In any case, updates should be ordered oldest to newest.
+func (a *Authority) InformIdempotent(storage Chonk, updates []AUM) (Authority, error) {
 	if len(updates) == 0 {
-		return errors.New("inform called with empty slice")
+		return Authority{}, errors.New("inform called with empty slice")
 	}
 	stateAt := make(map[AUMHash]State, len(updates)+1)
 	toCommit := make([]AUM, 0, len(updates))
@@ -584,30 +596,30 @@ func (a *Authority) Inform(updates []AUM) error {
 	for i, update := range updates {
 		hash := update.Hash()
 		// Check if we already have this AUM thus don't need to process it.
-		if _, err := a.storage.AUM(hash); err == nil {
+		if _, err := storage.AUM(hash); err == nil {
 			isHeadChain = false // Disable the head-chain optimization.
 			continue
 		}
 
 		parent, hasParent := update.Parent()
 		if !hasParent {
-			return fmt.Errorf("update %d: missing parent", i)
+			return Authority{}, 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)
+			if state, err = computeStateAt(storage, 2000, parent); err != nil {
+				return Authority{}, 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)
+			return Authority{}, 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)
+			return Authority{}, fmt.Errorf("update %d cannot be applied: %v", i, err)
 		}
 
 		if isHeadChain && parent != prevHash {
@@ -617,26 +629,40 @@ func (a *Authority) Inform(updates []AUM) error {
 		toCommit = append(toCommit, update)
 	}
 
-	if err := a.storage.CommitVerifiedAUMs(toCommit); err != nil {
-		return fmt.Errorf("commit: %v", err)
+	if err := storage.CommitVerifiedAUMs(toCommit); err != nil {
+		return Authority{}, fmt.Errorf("commit: %v", err)
 	}
 
 	if isHeadChain {
 		// Head-chain fastpath: We can use the state we computed
 		// in the last iteration.
-		a.head = updates[len(updates)-1]
-		a.state = stateAt[prevHash]
-	} else {
-		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 Authority{
+			head:           updates[len(updates)-1],
+			oldestAncestor: a.oldestAncestor,
+			state:          stateAt[prevHash],
+		}, nil
 	}
 
+	oldestAncestor := a.oldestAncestor.Hash()
+	c, err := computeActiveChain(storage, &oldestAncestor, 2000)
+	if err != nil {
+		return Authority{}, fmt.Errorf("recomputing active chain: %v", err)
+	}
+	return Authority{
+		head:           c.Head,
+		oldestAncestor: c.Oldest,
+		state:          c.state,
+	}, nil
+}
+
+// Inform is the same as InformIdempotent, except the state of the Authority
+// is updated in-place.
+func (a *Authority) Inform(storage Chonk, updates []AUM) error {
+	newAuthority, err := a.InformIdempotent(storage, updates)
+	if err != nil {
+		return err
+	}
+	*a = newAuthority
 	return nil
 }
 
diff --git a/tka/tka_test.go b/tka/tka_test.go
index 2215b5d33..f975a6b7b 100644
--- a/tka/tka_test.go
+++ b/tka/tka_test.go
@@ -376,7 +376,7 @@ func TestAuthorityInformNonLinear(t *testing.T) {
 	// and forcing Inform() to take the slow path.
 	informAUMs := []AUM{c.AUMs["L1"], c.AUMs["L2"], c.AUMs["L3"], c.AUMs["L4"], c.AUMs["L5"]}
 
-	if err := a.Inform(informAUMs); err != nil {
+	if err := a.Inform(storage, informAUMs); err != nil {
 		t.Fatalf("Inform() failed: %v", err)
 	}
 	for i, update := range informAUMs {
@@ -419,7 +419,7 @@ func TestAuthorityInformLinear(t *testing.T) {
 
 	informAUMs := []AUM{c.AUMs["L1"], c.AUMs["L2"], c.AUMs["L3"]}
 
-	if err := a.Inform(informAUMs); err != nil {
+	if err := a.Inform(storage, informAUMs); err != nil {
 		t.Fatalf("Inform() failed: %v", err)
 	}
 	for i, update := range informAUMs {