Skip to main content

ts_runtime/
tka_sync.rs

1//! Tailnet-Lock (TKA) chain-sync orchestration: the runtime-layer driver that ties the transport
2//! RPCs (`ts_control::{tka_bootstrap, tka_sync_offer, tka_sync_send}`) to the chain logic
3//! (`ts_tka::{Aum, Authority, MemAumStore, VerifiedAumChain}`), mirroring Go's `tkaSyncIfNeeded`
4//! (`ipn/ipnlocal/tailnet-lock.go`, v1.100.0).
5//!
6//! This lives in `ts_runtime` because it is the only layer that depends on **both** the wire crate
7//! (`ts_control`, which deliberately knows nothing of `ts_tka`) and the chain crate (`ts_tka`). It
8//! converts between the wire forms (base32 head strings, base64'd raw-CBOR AUM bytes) and the domain
9//! types, and drives the two-phase flow:
10//!
11//! 1. **Bootstrap** (only when we hold no chain yet): `tka_bootstrap` fetches the genesis AUM; we
12//!    `Aum::from_cbor` it, build the initial [`Authority`] via the **un-bypassable trust boundary**
13//!    `VerifiedAumChain::verify` → `Authority::from_verified_chain`, and seed a [`MemAumStore`].
14//! 2. **Sync** (offer → send): compute our [`SyncOffer`], send it, decode the AUMs control says we're
15//!    missing, `Inform`-equivalent (verify + fold into a fresh Authority over the grown store), then
16//!    tell control the AUMs *it* is missing. The order matches Go exactly — we compute what to *send*
17//!    from the pre-Inform store, then advance.
18//!
19//! **Posture (this module fails open; the published `Authority` is then ENFORCED).** This is two
20//! distinct claims, kept distinct:
21//!
22//! - *Sync failure is fail-open here*: every failure path in **this** module (a transport error, a
23//!   malformed AUM, a verify failure) returns `Ok(None)` or an `Err` that the caller treats as "no
24//!   new Authority obtained this round" — a failed *sync* never blocks the netmap and leaves the
25//!   prior enforcement state untouched (see `control_runner`'s apply step). It does NOT mean TKA is
26//!   observe-only.
27//! - *A successfully synced `Authority` is actively enforced*: once this module returns an
28//!   `Authority`, the control runner publishes it to the peer tracker's enforcement cell and the
29//!   peer-trust chokepoint fails **closed** — a peer presenting a missing or unauthorized
30//!   `key_signature` is **dropped** at the peer-db upsert path (`peer_tracker::tka_snapshot_admits`,
31//!   matching Go's `tkaFilterNetmapLocked`). Installing the `Authority` also re-filters the peers
32//!   **already** in the db (`peer_tracker::tka_reevaluate_peer_db`), because this sync is
33//!   asynchronous: the netmap that announced the lock was applied before the `Authority` existed,
34//!   whereas Go filters that same netmap in the pass that synced it. With no lock synced, every peer
35//!   is admitted (Go's `b.tka == nil` early return); a control-signalled *disable* clears
36//!   enforcement back to admit-all.
37//!
38//! The chain always passes through the **un-bypassable trust boundary** `VerifiedAumChain::verify`
39//! before it can reach enforcement, so a malicious control plane cannot forge a trusted key to admit
40//! an unauthorized peer — it can only toggle the lock's enable/disable state. The authoritative
41//! description of the enforcement posture, threat model, and the remaining deferred gaps
42//! (disablement-secret verification, rotation-obsolete/clone-replay dropping) lives in `SECURITY.md`;
43//! keep this doc consistent with it.
44//!
45//! **Do not "simplify" by removing enforcement to match an outdated "observe-only" reading** — that
46//! would silently downgrade a working, fail-closed security control to verify-only.
47
48use std::sync::Arc;
49
50use ts_control::{
51    TkaSyncError, TkaSyncOfferRequest, TkaSyncSendRequest, tka_bootstrap, tka_sync_offer,
52    tka_sync_send,
53};
54use ts_tka::{Aum, AumHash, Authority, MemAumStore, SyncOffer, VerifiedAumChain};
55
56/// The synced TKA state a successful [`sync_tka`] produces: the verified [`Authority`] (for the
57/// verify-and-log consumer) plus the [`MemAumStore`] of AUMs gathered so far (so the next sync can
58/// compute offers/missing-sets without re-bootstrapping).
59pub(crate) struct SyncedTka {
60    pub authority: Arc<Authority>,
61    pub store: MemAumStore,
62    /// The genesis/oldest AUM hash, needed as the `oldest` argument to subsequent `sync_offer`s.
63    pub oldest: AumHash,
64}
65
66/// One entry of the Tailnet-Lock update-chain log, mirroring Go `ipnstate.NetworkLockUpdate` (the
67/// rows `tailscale lock log` prints). Produced by [`Device::tka_log`](crate::Runtime::tka_log) from
68/// the locally-synced AUM chain — a pure local read, no control round-trip.
69///
70/// `aum_hash` + `change` + `raw` are the exact Go `NetworkLockUpdate` fields (`Hash`, `Change`,
71/// `Raw`). `signer_key_ids` is an extra convenience this engine extracts from the decoded AUM —
72/// Go's struct has no `Signatures` field and recovers the signer only by decoding `Raw`; we surface
73/// the signer key ids directly so a daemon need not re-decode, while still carrying `raw` for a
74/// faithful full decode.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct TkaLogEntry {
77    /// The AUM's chain-link hash (Go `NetworkLockUpdate.Hash`): `BLAKE2s-256` of its serialization.
78    pub aum_hash: [u8; 32],
79    /// The human-readable change kind (Go `NetworkLockUpdate.Change`), e.g. `"add-key"` /
80    /// `"remove-key"` / `"checkpoint"` — [`AumKind::as_str`](ts_tka::AumKind::as_str).
81    pub change: String,
82    /// The id of each trusted key that signed this AUM (each
83    /// [`AumSignature::key_id`](ts_tka::AumSignature::key_id), the signer's 32-byte ed25519 public
84    /// key for an Ed25519 key). Convenience extraction; absent from Go's struct.
85    pub signer_key_ids: Vec<Vec<u8>>,
86    /// The AUM's canonical CBOR serialization (Go `NetworkLockUpdate.Raw` = `AUM.Serialize()`), so a
87    /// consumer can decode the full AUM (incl. signatures) faithfully.
88    pub raw: Vec<u8>,
89}
90
91/// Read up to `limit` entries of the TKA update-chain log from a synced AUM `store`, **head-first**
92/// (newest → oldest), mirroring Go `NetworkLockLog` which walks `Head` back toward genesis.
93///
94/// The store holds the chain genesis→head; [`MemAumStore::linear_chain_from`] yields that
95/// genesis→head order, which we **reverse** to match Go's head→genesis walk before truncating to
96/// `limit`. A pure function over the synced state (no crypto, no mutation, no RPC) so it is unit
97/// testable without standing up an actor. An unwalkable store (genesis missing / cycle) yields an
98/// empty log rather than erroring — the caller's "no readable chain" is an empty history, matching
99/// the no-lock-synced case.
100pub(crate) fn tka_log_entries(
101    store: &MemAumStore,
102    oldest: AumHash,
103    limit: usize,
104) -> Vec<TkaLogEntry> {
105    // genesis→head; an unwalkable store (missing genesis / cycle) → empty log.
106    let chain = store.linear_chain_from(oldest).unwrap_or_default();
107    chain
108        .iter()
109        .rev() // Go walks head→genesis; the store walk is genesis→head.
110        .take(limit)
111        .map(|aum| TkaLogEntry {
112            aum_hash: aum.hash().0,
113            change: aum.message_kind.as_str().to_string(),
114            signer_key_ids: aum.signatures.iter().map(|s| s.key_id.clone()).collect(),
115            raw: aum.serialize(),
116        })
117        .collect()
118}
119
120/// Magic first line of the persisted chain blob ([`encode_chain`]), so a decoder can tell this
121/// format from anything else that ever lands at that path — including an older or newer version of
122/// this format. An unrecognised first line is a decode error, i.e. no authority, i.e. the cached
123/// peers stay withheld.
124const TKA_CHAIN_BLOB_MAGIC: &str = "ts-tka-chain-v1";
125
126/// Encode a synced AUM chain for the cold-start store: [`TKA_CHAIN_BLOB_MAGIC`], then one
127/// standard-base64 raw-CBOR AUM per line, **genesis first**.
128///
129/// Go persists its authority as a directory of AUM files (`tka.ChonkDir`, `tka/chonk.go`, opened by
130/// `initTKALocked` in `ipn/ipnlocal/tailnet-lock.go`) and re-opens it at start-up, which is what lets
131/// its cold start filter a cached netmap. This is the same idea in one file: the chain in the linear
132/// genesis→head order [`VerifiedAumChain::verify`] wants, in the same base64-of-CBOR form the
133/// `/machine/tka/sync` RPC already moves AUMs in — so nothing new has to be understood to read it.
134///
135/// `None` when the store cannot be walked from `oldest` (a missing genesis or a broken link), which
136/// is the same "we hold no chain we can vouch with" outcome as never having written one.
137pub(crate) fn encode_chain(store: &MemAumStore, oldest: AumHash) -> Option<Vec<u8>> {
138    use base64::Engine as _;
139
140    let chain = store.linear_chain_from(oldest).ok()?;
141    let mut out = String::from(TKA_CHAIN_BLOB_MAGIC);
142    for aum in &chain {
143        out.push('\n');
144        out.push_str(&base64::engine::general_purpose::STANDARD.encode(aum.serialize()));
145    }
146    Some(out.into_bytes())
147}
148
149/// Rebuild an [`Authority`] from a blob [`encode_chain`] wrote, **through the trust boundary**.
150///
151/// The chain is re-verified from genesis on every load ([`VerifiedAumChain::verify`] →
152/// [`Authority::from_verified_chain`]) rather than trusting the file's contents, so a blob whose AUMs
153/// do not form a signed chain yields no authority at all. What the file's integrity is still load
154/// bearing for is *which* signed chain this is: an attacker who can write it can offer a chain of
155/// their own making, whose genesis introduces their own keys. That is the same trust the cached
156/// netmap itself carries, and it is why both entries are written into, and read back out of, a
157/// directory this user owns with no group/other bits (`ts_control`'s `NetmapCache` vets both ends).
158/// Go's on-disk chonk rests on exactly the same assumption.
159///
160/// # Errors
161/// [`ts_tka::TkaError::Decode`] for a blob that is not this format, and every error
162/// [`VerifiedAumChain::verify`] raises for a chain that does not verify.
163pub(crate) fn authority_from_encoded_chain(blob: &[u8]) -> Result<Authority, ts_tka::TkaError> {
164    use base64::Engine as _;
165
166    let text =
167        core::str::from_utf8(blob).map_err(|_| ts_tka::TkaError::Decode("chain not utf-8"))?;
168    let mut lines = text.lines();
169    if lines.next() != Some(TKA_CHAIN_BLOB_MAGIC) {
170        return Err(ts_tka::TkaError::Decode("not a tailnet-lock chain blob"));
171    }
172
173    let mut chain = Vec::new();
174    for line in lines.filter(|l| !l.is_empty()) {
175        let raw = base64::engine::general_purpose::STANDARD
176            .decode(line)
177            .map_err(|_| ts_tka::TkaError::Decode("bad base64 AUM in chain blob"))?;
178        chain.push(Aum::from_cbor(&raw)?);
179    }
180
181    // `verify` rejects an empty chain (`BadChain`), which is the right answer for an empty blob.
182    Ok(Authority::from_verified_chain(VerifiedAumChain::verify(
183        &chain,
184    )?))
185}
186
187/// Errors internal to the sync driver. All map to "no Authority obtained" at the caller — the netmap
188/// is never errored and peers are never dropped on any of these.
189#[derive(Debug, thiserror::Error)]
190pub(crate) enum TkaSyncDriverError {
191    /// A transport RPC failed (network / unsupported / HTTP). `Unsupported` means control has no TKA
192    /// endpoint — treat as "inert", not a hard error.
193    #[error("TKA sync RPC failed: {0}")]
194    Rpc(#[from] TkaSyncError),
195    /// An AUM from control failed to decode or verify. Fail-closed: we do NOT advance the Authority.
196    #[error("TKA chain verification failed: {0}")]
197    Chain(#[from] ts_tka::TkaError),
198}
199
200/// Decode a base64-of-CBOR AUM batch (the wire form of `MissingAUMs`) into domain [`Aum`]s.
201/// Fail-closed: a single undecodable AUM rejects the whole batch (we never partially trust).
202fn decode_aums(marshaled: &[Vec<u8>]) -> Result<Vec<Aum>, ts_tka::TkaError> {
203    marshaled.iter().map(|b| Aum::from_cbor(b)).collect()
204}
205
206/// Re-verify a chain (existing store contents + newly-received AUMs) into a fresh [`Authority`],
207/// the `Inform` analog. We replay the full known AUM set through the trust boundary rather than
208/// mutating in place, so the resulting Authority is always one `VerifiedAumChain::verify` proved.
209///
210/// The store's AUMs in linear genesis→head order are what `verify` expects; we reconstruct that order
211/// by walking from the genesis (`oldest`) forward via the store's child links.
212fn rebuild_authority(store: &MemAumStore, oldest: AumHash) -> Result<Authority, ts_tka::TkaError> {
213    let chain = store.linear_chain_from(oldest)?;
214    let verified = VerifiedAumChain::verify(&chain)?;
215    Ok(Authority::from_verified_chain(verified))
216}
217
218/// Run a TKA bootstrap+sync cycle against control.
219///
220/// `current` is our existing synced state (`None` on first run → bootstrap first). Returns
221/// `Ok(Some(SyncedTka))` with the advanced Authority on success, `Ok(None)` when control has no lock
222/// for us (inert), or `Err` on a transport/verify failure (caller stays inert).
223pub(crate) async fn sync_tka(
224    config: &ts_control::Config,
225    keys: &ts_keys::NodeState,
226    current: Option<SyncedTka>,
227) -> Result<Option<SyncedTka>, TkaSyncDriverError> {
228    let control_url = &config.server_url;
229    let allow_http_key_fetch = config.allow_http_key_fetch;
230
231    // Phase 1: bootstrap if we have no chain yet.
232    let (mut store, oldest, mut authority) = match current {
233        Some(s) => (s.store, s.oldest, (*s.authority).clone()),
234        None => {
235            let resp = tka_bootstrap(
236                control_url,
237                keys,
238                String::new(), // no local head yet
239                allow_http_key_fetch,
240            )
241            .await?;
242            if resp.genesis_aum.is_empty() {
243                // Control returned no genesis: TKA is not enabled for us. Stay inert (not an error).
244                return Ok(None);
245            }
246            let genesis = Aum::from_cbor(&resp.genesis_aum)?;
247            let oldest = genesis.hash();
248            let mut store = MemAumStore::new();
249            store.insert(genesis);
250            let authority = rebuild_authority(&store, oldest)?;
251            (store, oldest, authority)
252        }
253    };
254
255    // Phase 2: offer → (decode + inform) → send. Mirror Go's order exactly.
256    let local_offer = authority.sync_offer(&store, oldest)?;
257    let offer_req = TkaSyncOfferRequest {
258        version: Default::default(), // overwritten by the RPC with CURRENT
259        node_key: keys.node_keys.public,
260        head: local_offer.head.to_base32(),
261        ancestors: local_offer
262            .ancestors
263            .iter()
264            .map(|a| a.to_base32())
265            .collect(),
266    };
267    let offer_resp = tka_sync_offer(control_url, keys, offer_req, allow_http_key_fetch).await?;
268
269    // Reconstruct control's offer from the response so we can compute what *control* is missing —
270    // BEFORE we Inform ourselves with control's AUMs (Go computes missing-to-send pre-Inform).
271    let control_offer = parse_offer(&offer_resp.head, &offer_resp.ancestors)?;
272
273    // Decode + insert the AUMs control sent, then rebuild (verify) the advanced Authority.
274    let received = decode_aums(&offer_resp.missing_aums)?;
275    for aum in &received {
276        store.insert(aum.clone());
277    }
278    // Compute what control is missing from the store as it stands (post-insert is fine: missing_aums
279    // is computed against control's offer, and the gather is from our head — inserting control's own
280    // AUMs cannot make us think it lacks them).
281    let to_send = authority
282        .missing_aums(&store, &control_offer, oldest)
283        .unwrap_or_default();
284    // Advance our Authority over the grown store (the Inform analog) — through the trust boundary.
285    authority = rebuild_authority(&store, oldest)?;
286
287    // Phase 3: send control the AUMs it lacks (best-effort; a failure here doesn't undo our advance).
288    let send_req = TkaSyncSendRequest {
289        version: Default::default(),
290        node_key: keys.node_keys.public,
291        head: authority.head().to_base32(),
292        missing_aums: to_send.iter().map(Aum::serialize).collect(),
293        interactive: false,
294    };
295    if let Err(e) = tka_sync_send(control_url, keys, send_req, allow_http_key_fetch).await {
296        // We already advanced locally; control not accepting our AUMs is logged, not fatal.
297        tracing::warn!(error = ?e, "TKA sync/send failed (local Authority already advanced)");
298    }
299
300    Ok(Some(SyncedTka {
301        authority: Arc::new(authority),
302        store,
303        oldest,
304    }))
305}
306
307/// Parse a wire offer (base32 head + ancestors) into a domain [`SyncOffer`]. A malformed base32 hash
308/// is a decode error (fail-closed).
309fn parse_offer(head: &str, ancestors: &[String]) -> Result<SyncOffer, ts_tka::TkaError> {
310    let head = AumHash::from_base32(head).ok_or(ts_tka::TkaError::Decode("bad base32 head"))?;
311    let ancestors = ancestors
312        .iter()
313        .map(|a| AumHash::from_base32(a).ok_or(ts_tka::TkaError::Decode("bad base32 ancestor")))
314        .collect::<Result<Vec<_>, _>>()?;
315    Ok(SyncOffer { head, ancestors })
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn parse_offer_roundtrips_base32() {
324        // A head + two ancestors as base32 (no-pad) of 32-byte hashes parse back to those hashes.
325        let h0 = AumHash([0x11; 32]);
326        let h1 = AumHash([0x22; 32]);
327        let h2 = AumHash([0x33; 32]);
328        let offer = parse_offer(&h0.to_base32(), &[h1.to_base32(), h2.to_base32()]).expect("parse");
329        assert_eq!(offer.head, h0);
330        assert_eq!(offer.ancestors, vec![h1, h2]);
331    }
332
333    #[test]
334    fn parse_offer_rejects_bad_base32() {
335        // A non-base32 / wrong-length head fails closed (not a panic).
336        assert!(parse_offer("not valid base32!", &[]).is_err());
337        // A good head but a bad ancestor also fails.
338        let good = AumHash([1u8; 32]).to_base32();
339        assert!(parse_offer(&good, &["@@@@".to_string()]).is_err());
340    }
341
342    #[test]
343    fn decode_aums_roundtrips_and_rejects_garbage() {
344        // A valid AUM serializes → decode_aums reconstructs it; a garbage blob in the batch rejects
345        // the whole batch (fail-closed, never partial trust).
346        let aum = Aum {
347            message_kind: ts_tka::AumKind::NoOp,
348            prev_aum_hash: None,
349            key: None,
350            key_id: Vec::new(),
351            state: None,
352            votes: None,
353            meta: Vec::new(),
354            signatures: Vec::new(),
355        };
356        let good = aum.serialize();
357        let decoded = decode_aums(std::slice::from_ref(&good)).expect("decode");
358        assert_eq!(decoded.len(), 1);
359        assert_eq!(decoded[0].hash(), aum.hash());
360        // One garbage blob alongside a good one → the whole batch errors.
361        assert!(decode_aums(&[good, vec![0xff, 0x00, 0x13]]).is_err());
362    }
363
364    // ---- tka_log_entries (PR-A) ----------------------------------------------------------------
365
366    /// A test [`AumKey`](ts_tka::AumKey) from a seed byte (deterministic public key + given votes).
367    fn test_aum_key(seed: u8, votes: u32) -> ts_tka::AumKey {
368        use ed25519_dalek::SigningKey;
369        ts_tka::AumKey {
370            kind: ts_tka::KeyKind::Ed25519,
371            votes,
372            public: SigningKey::from_bytes(&[seed; 32])
373                .verifying_key()
374                .to_bytes()
375                .to_vec(),
376            meta: Vec::new(),
377        }
378    }
379
380    /// A genesis `Checkpoint` AUM trusting `key` (no parent). Mirrors the on-wire genesis a node
381    /// syncs; built directly (not via `new_genesis_checkpoint`) so the test stays a pure
382    /// ordering/mapping check independent of disablement-value construction.
383    fn genesis_checkpoint(key: ts_tka::AumKey) -> Aum {
384        Aum {
385            message_kind: ts_tka::AumKind::Checkpoint,
386            prev_aum_hash: None,
387            key: None,
388            key_id: Vec::new(),
389            state: Some(ts_tka::AumState {
390                last_aum_hash: None,
391                disablement_values: Some(vec![vec![0x11; 32]]),
392                keys: Some(vec![key]),
393                state_id1: 0,
394                state_id2: 0,
395            }),
396            votes: None,
397            meta: Vec::new(),
398            signatures: Vec::new(),
399        }
400    }
401
402    /// An `AddKey` child of `parent` adding `key`.
403    fn add_key_child(parent: &Aum, key: ts_tka::AumKey) -> Aum {
404        Aum {
405            message_kind: ts_tka::AumKind::AddKey,
406            prev_aum_hash: Some(parent.hash()),
407            key: Some(key),
408            key_id: Vec::new(),
409            state: None,
410            votes: None,
411            meta: Vec::new(),
412            signatures: Vec::new(),
413        }
414    }
415
416    /// `tka_log_entries` returns the chain **head-first** (Go `NetworkLockLog` walks head→genesis,
417    /// the opposite of the store's genesis→head order), with the correct `change` strings, an
418    /// `aum_hash` matching `Aum::hash`, and a `raw` that round-trips through the AUM decoder.
419    #[test]
420    fn tka_log_entries_head_first_with_fields() {
421        let g = genesis_checkpoint(test_aum_key(1, 1));
422        let a1 = add_key_child(&g, test_aum_key(2, 1));
423        let a2 = add_key_child(&a1, test_aum_key(3, 1));
424        // Insert in a scrambled order to prove ordering is by chain links, not insert order.
425        let mut store = MemAumStore::new();
426        store.insert(a1.clone());
427        store.insert(a2.clone());
428        store.insert(g.clone());
429
430        let log = tka_log_entries(&store, g.hash(), 100);
431
432        // (a) head-first: newest (a2) → genesis (g).
433        let got_hashes: Vec<[u8; 32]> = log.iter().map(|e| e.aum_hash).collect();
434        assert_eq!(
435            got_hashes,
436            vec![a2.hash().0, a1.hash().0, g.hash().0],
437            "log must be head-first (a2, a1, genesis)"
438        );
439        // (b) change strings.
440        let changes: Vec<&str> = log.iter().map(|e| e.change.as_str()).collect();
441        assert_eq!(changes, vec!["add-key", "add-key", "checkpoint"]);
442        // (c) aum_hash == Aum::hash().0 (re-checked against the genesis explicitly).
443        assert_eq!(log[2].aum_hash, g.hash().0);
444        // (d) raw round-trips through the AUM decoder back to the same AUM.
445        for (entry, aum) in log.iter().zip([&a2, &a1, &g]) {
446            let decoded = Aum::from_cbor(&entry.raw).expect("raw is canonical AUM CBOR");
447            assert_eq!(&decoded, aum, "raw must decode back to the source AUM");
448        }
449    }
450
451    /// `limit` truncates from the head (the most recent `limit` entries).
452    #[test]
453    fn tka_log_entries_limit_truncates_from_head() {
454        let g = genesis_checkpoint(test_aum_key(1, 1));
455        let a1 = add_key_child(&g, test_aum_key(2, 1));
456        let a2 = add_key_child(&a1, test_aum_key(3, 1));
457        let store = MemAumStore::from_aums([g.clone(), a1.clone(), a2.clone()]);
458
459        let log = tka_log_entries(&store, g.hash(), 2);
460        assert_eq!(log.len(), 2, "limit caps the row count");
461        assert_eq!(
462            log.iter().map(|e| e.aum_hash).collect::<Vec<_>>(),
463            vec![a2.hash().0, a1.hash().0],
464            "limit keeps the newest entries (head-first)"
465        );
466        // limit 0 → empty.
467        assert!(tka_log_entries(&store, g.hash(), 0).is_empty());
468    }
469
470    /// `signer_key_ids` is the `key_id` of each [`AumSignature`](ts_tka::AumSignature) on the AUM,
471    /// in order — what a daemon renders without re-decoding `raw`.
472    #[test]
473    fn tka_log_entries_extracts_signer_key_ids() {
474        use ed25519_dalek::SigningKey;
475        let mut g = genesis_checkpoint(test_aum_key(1, 1));
476        // Sign the genesis with the key it seeds (exactly what `Aum::sign` records: key_id = the
477        // signer's verifying-key bytes).
478        let sk = SigningKey::from_bytes(&[1u8; 32]);
479        g.sign(&sk);
480        let signer_id = sk.verifying_key().to_bytes().to_vec();
481        let store = MemAumStore::from_aums([g.clone()]);
482
483        let log = tka_log_entries(&store, g.hash(), 100);
484        assert_eq!(log.len(), 1);
485        assert_eq!(
486            log[0].signer_key_ids,
487            vec![signer_id],
488            "signer_key_ids carries each signature's key_id"
489        );
490        // An unsigned AUM yields no signer ids.
491        let unsigned = genesis_checkpoint(test_aum_key(2, 1));
492        let store2 = MemAumStore::from_aums([unsigned.clone()]);
493        assert!(
494            tka_log_entries(&store2, unsigned.hash(), 100)[0]
495                .signer_key_ids
496                .is_empty()
497        );
498    }
499
500    /// An empty / unwalkable store yields an empty log (mirrors the no-lock-synced case the actor
501    /// short-circuits before ever calling this): a missing genesis is an empty history, never an
502    /// error.
503    #[test]
504    fn tka_log_entries_unwalkable_store_is_empty() {
505        // Empty store: any `oldest` is absent → BadChain inside, mapped to an empty Vec.
506        let empty = MemAumStore::new();
507        assert!(tka_log_entries(&empty, AumHash([0u8; 32]), 100).is_empty());
508        // Non-empty store but `oldest` not present → still empty (not a panic / error).
509        let g = genesis_checkpoint(test_aum_key(1, 1));
510        let store = MemAumStore::from_aums([g]);
511        assert!(tka_log_entries(&store, AumHash([0xEE; 32]), 100).is_empty());
512    }
513}
514
515#[cfg(test)]
516mod chain_blob_tests {
517    //! The cold-start persistence of the synced chain: what `encode_chain` writes beside the cached
518    //! netmap is what `authority_from_encoded_chain` hands the replay, and nothing else gets through.
519
520    use ed25519_dalek::SigningKey;
521
522    use super::*;
523
524    /// A one-AUM chain: a genesis checkpoint trusting `signer`, signed by it — the shape a node holds
525    /// after bootstrapping from control.
526    fn signed_genesis(signer: &SigningKey) -> Aum {
527        let key = ts_tka::AumKey {
528            kind: ts_tka::KeyKind::Ed25519,
529            votes: 1,
530            public: signer.verifying_key().to_bytes().to_vec(),
531            meta: Vec::new(),
532        };
533        let mut genesis = Aum::new_genesis_checkpoint(vec![key], vec![vec![0x11; 32]])
534            .expect("a well-formed genesis checkpoint");
535        genesis.sign(signer);
536        genesis
537    }
538
539    /// The round trip: a synced store encodes, and decodes back to an Authority at the same head as
540    /// the one the sync produced — which is what makes the cold-start replay able to vouch for the
541    /// peers cached under that head.
542    #[test]
543    fn a_synced_chain_round_trips_to_the_same_authority() {
544        let signer = SigningKey::from_bytes(&[7u8; 32]);
545        let genesis = signed_genesis(&signer);
546        let oldest = genesis.hash();
547        let store = MemAumStore::from_aums([genesis]);
548        let synced = rebuild_authority(&store, oldest).expect("the sync's own authority");
549
550        let blob = encode_chain(&store, oldest).expect("a walkable chain encodes");
551        let loaded = authority_from_encoded_chain(&blob).expect("the blob decodes and verifies");
552
553        assert!(
554            loaded.head_matches(&synced.head()),
555            "the persisted chain must reload at the head the sync reached"
556        );
557        assert_eq!(
558            loaded.state().keys,
559            synced.state().keys,
560            "and with the same trusted keys, or it would authorize different peers"
561        );
562    }
563
564    /// Every way the blob can fail to be a chain this node may enforce with is an error — never a
565    /// half-trusted authority. The replay turns each of these into "withhold the cached peers".
566    #[test]
567    fn a_blob_that_is_not_a_verified_chain_is_refused() {
568        let signer = SigningKey::from_bytes(&[7u8; 32]);
569        let genesis = signed_genesis(&signer);
570        let oldest = genesis.hash();
571        let store = MemAumStore::from_aums([genesis.clone()]);
572        let blob = encode_chain(&store, oldest).expect("encode");
573
574        // Not this format at all (an empty file, or something else that landed at the path).
575        assert!(authority_from_encoded_chain(b"").is_err());
576        assert!(authority_from_encoded_chain(b"netmap json, not a chain").is_err());
577        // The right magic, no AUMs: an empty chain verifies nothing.
578        assert!(authority_from_encoded_chain(b"ts-tka-chain-v1").is_err());
579        // The right magic, a line that is not base64.
580        assert!(authority_from_encoded_chain(b"ts-tka-chain-v1\n!!!not base64!!!").is_err());
581        // A different version of this format is not this format.
582        let bumped = String::from_utf8(blob.clone())
583            .expect("utf-8")
584            .replace("ts-tka-chain-v1", "ts-tka-chain-v2");
585        assert!(authority_from_encoded_chain(bumped.as_bytes()).is_err());
586
587        // An unsigned genesis — the chain a local attacker would offer to introduce their own trusted
588        // key — does not verify, so it yields no authority at all.
589        let mut forged = genesis;
590        forged.signatures.clear();
591        let forged_store = MemAumStore::from_aums([forged.clone()]);
592        let forged_blob = encode_chain(&forged_store, forged.hash()).expect("encode");
593        assert!(
594            authority_from_encoded_chain(&forged_blob).is_err(),
595            "the chain is re-verified from genesis on load; an unsigned one must not become an \
596             authority"
597        );
598    }
599}