ts_runtime/peer_tracker/mod.rs
1//! Peer delta update tracking.
2
3use std::{
4 collections::{HashMap, HashSet},
5 net::IpAddr,
6 sync::Arc,
7};
8
9use kameo::{
10 actor::ActorRef,
11 message::{Context, Message},
12 reply::ReplySender,
13};
14use tokio::sync::watch;
15use ts_control::{Node, UserId, UserProfile};
16use ts_keys::{DiscoPublicKey, NodePublicKey};
17use ts_transport::PeerId;
18
19use crate::{
20 Error, dataplane::PeerDiscoKeyAdvertisement, direct::DiscoKeyObserved, env::Env,
21 status::StatusNode,
22};
23
24mod peer_db;
25
26pub use peer_db::{DiscoKeyMatch, PeerDb};
27
28/// Whether `key` is the all-zero disco key, which Go spells `key.DiscoPublic.IsZero()` and treats
29/// everywhere as "this peer has no disco key" rather than as a usable key.
30fn disco_key_is_zero(key: &DiscoPublicKey) -> bool {
31 key.to_bytes() == [0u8; DiscoPublicKey::KEY_LEN_BYTES]
32}
33
34/// Normalize a disco key as it arrives from control: the all-zero key means "absent", exactly as
35/// Go's `IsZero()` checks in `endpoint.updateDiscoKey` read it.
36fn disco_key_from_control(key: Option<DiscoPublicKey>) -> Option<DiscoPublicKey> {
37 key.filter(|k| !disco_key_is_zero(k))
38}
39
40/// The two disco keys a peer can present, and which of them is currently active — Go
41/// [`magicsock.endpointDisco`] (`wgengine/magicsock/endpoint.go`).
42///
43/// A peer's disco key reaches us from two independent sources: **control**, in a netmap node or a
44/// `PeersChangedPatch`, and the **peer itself**, in a TSMP disco-key advertisement carried inside
45/// the WireGuard tunnel. Go keeps both side by side on the endpoint, and so do we, because control
46/// is the slower of the two: an advertisement exists precisely to cover the window where control has
47/// not caught up with the peer's current key, so collapsing the two into one field would let the
48/// next map poll overwrite a freshly-learned key with control's stale one — losing the feature's own
49/// motivating case.
50///
51/// Only one key is active for sending at a time ([`key`](Self::key)). That active key is what the
52/// peer db carries in [`Node::disco_key`], which is this fork's live lookup for every direct-path
53/// consumer (`direct::DiscoPeerLookup` resolves against it, and `PeerDb`'s disco index is built from
54/// it) — the stand-in for Go's per-endpoint `disco` pointer.
55///
56/// [`magicsock.endpointDisco`]: https://github.com/tailscale/tailscale/blob/49e148c4a30b4f8098f69468fd27a7021d85ea02/wgengine/magicsock/endpoint.go
57#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
58struct EndpointDisco {
59 /// The key learned from control (Go `endpointDisco.controlKey`).
60 control: Option<DiscoPublicKey>,
61 /// The key learned from a TSMP advertisement (Go `endpointDisco.tsmpKey`).
62 tsmp: Option<DiscoPublicKey>,
63 /// Whether [`tsmp`](Self::tsmp) is the active key (Go `endpointDisco.tsmpActive`).
64 tsmp_active: bool,
65}
66
67impl EndpointDisco {
68 /// The key currently regarded as active — Go `endpointDisco.key()`.
69 fn key(&self) -> Option<DiscoPublicKey> {
70 if self.tsmp_active {
71 self.tsmp
72 } else {
73 self.control
74 }
75 }
76
77 /// The control-learned key, active or not — Go `endpointDisco.keyFromControl()`.
78 fn key_from_control(&self) -> Option<DiscoPublicKey> {
79 self.control
80 }
81
82 /// The TSMP-learned key, active or not — Go `endpointDisco.keyFromTSMP()`.
83 fn key_from_tsmp(&self) -> Option<DiscoPublicKey> {
84 self.tsmp
85 }
86
87 /// Replace the control-learned key, leaving any TSMP-learned key in place — Go
88 /// [`endpoint.updateDiscoKey`].
89 ///
90 /// Control's key is always recorded in control's own slot, but it takes the *active* slot only
91 /// if no TSMP-learned key already holds it: Go `epDisco.tsmpActive = old.tsmpActive ||
92 /// key.IsZero()`. A key the peer told us itself is better evidence than a control server that
93 /// is, by construction, the slower of the two sources — so control changing its mind no longer
94 /// preempts an active TSMP key. Upstream returns to control's key when disco is actually
95 /// *received* under it (`endpoint.checkAndUpdateDiscoKey`), not when control asserts it.
96 ///
97 /// An absent (Go: zero) control key still hands the slot to the TSMP key, if there is one. When
98 /// there is neither key, the caller drops the whole entry ([`is_empty`](Self::is_empty)) — which
99 /// is what stops an active TSMP slot with no TSMP key in it outliving this call, exactly as Go
100 /// nils the endpoint's `disco` pointer in the same case.
101 ///
102 /// [`endpoint.updateDiscoKey`]: https://github.com/tailscale/tailscale/blob/9ea7cba44591e0cd840c6c94d23274dd222059bf/wgengine/magicsock/endpoint.go
103 fn update_from_control(&mut self, key: Option<DiscoPublicKey>) {
104 self.control = key;
105 self.tsmp_active = self.tsmp_active || key.is_none();
106 }
107
108 /// Replace the TSMP-learned key, leaving the control-learned key in place — Go
109 /// `endpoint.updateTSMPDiscoKey`.
110 fn update_from_tsmp(&mut self, key: Option<DiscoPublicKey>) {
111 self.tsmp = key;
112 self.tsmp_active = key.is_some();
113 }
114
115 /// The peer's other known key: the slot that is not active, when it holds a key that differs
116 /// from the active one.
117 ///
118 /// This is what makes ingress under the peer's *other* key resolvable
119 /// ([`PeerDb::set_inactive_disco_key`]). `None` when the inactive slot is empty or holds the
120 /// same key as the active one — there is no second key to accept in either case.
121 fn inactive_key(&self) -> Option<DiscoPublicKey> {
122 let inactive = if self.tsmp_active {
123 self.control
124 } else {
125 self.tsmp
126 };
127
128 inactive.filter(|k| Some(*k) != self.key())
129 }
130
131 /// Accept `key` as this peer's, switching the active slot to it when it is the currently
132 /// *inactive* one — Go [`endpoint.checkAndUpdateDiscoKey`].
133 ///
134 /// Called with the sender key of a disco frame we have opened, which proves the sender holds
135 /// that key's private half. Receiving under a key is therefore demonstrative: it is what the
136 /// peer is actually using, so upstream makes it the key we send to as well.
137 ///
138 /// Returns `None` when `key` belongs to **neither** slot — the refusal that is the whole
139 /// security value of the check, and the reason this is not simply "trust whatever key opened".
140 /// Otherwise `Some(changed)`, where `changed` reports whether the active key moved (and so
141 /// whether the direct path built under the old one has to be invalidated).
142 ///
143 /// [`endpoint.checkAndUpdateDiscoKey`]: https://github.com/tailscale/tailscale/blob/9ea7cba44591e0cd840c6c94d23274dd222059bf/wgengine/magicsock/endpoint.go
144 fn check_and_update(&mut self, key: DiscoPublicKey) -> Option<bool> {
145 if self.key() == Some(key) {
146 return Some(false);
147 }
148
149 // Not the active key. Go's compare-and-swap on `tsmpActive`: whichever slot holds it
150 // becomes the active one. Control's slot is tried first only for determinism — the two
151 // holding the same key is already handled by the equality check above.
152 if self.control == Some(key) {
153 self.tsmp_active = false;
154 return Some(true);
155 }
156 if self.tsmp == Some(key) {
157 self.tsmp_active = true;
158 return Some(true);
159 }
160
161 None
162 }
163
164 /// No key material from either source — Go nils out the endpoint's `disco` pointer here.
165 fn is_empty(&self) -> bool {
166 self.control.is_none() && self.tsmp.is_none()
167 }
168}
169
170/// Actor that tracks peer delta updates and emits new states.
171pub struct PeerTracker {
172 peer_db: PeerDb,
173 seen_state_update: bool,
174 pending_requests: Vec<Pending>,
175 /// Latest peer snapshot, published on every netmap update so embedders can watch for peer
176 /// changes ([`WatchNetmap`]).
177 peer_watch: watch::Sender<Vec<StatusNode>>,
178 /// Accumulated netmap user profiles (`MapResponse.UserProfiles`), keyed by user id, joined
179 /// against a node's [`Node::user_id`](ts_control::Node::user_id) to resolve the owning user's
180 /// login/display name for a [`WhoIs`](crate::status::WhoIs). Control sends these incrementally
181 /// (only new/changed profiles per response), so this map **accumulates** across updates rather
182 /// than being replaced — a peer upserted in one response may reference a profile delivered in an
183 /// earlier one.
184 user_profiles: HashMap<UserId, UserProfile>,
185 /// Per-peer disco-key provenance ([`EndpointDisco`]), keyed by the peer's node key.
186 ///
187 /// Go keeps this on the magicsock `endpoint`, which the peer map keys by node key; here the peer
188 /// db stores control's [`Node`] verbatim, so the second key (and which of the two is active)
189 /// lives beside it. Keying by node key reproduces Go's lifetime exactly: the state is dropped
190 /// when the peer leaves the netmap, and a peer that ROTATES its node key gets a fresh entry —
191 /// Go builds it a new endpoint, so a key learned over TSMP under the old node key is never
192 /// carried onto the new one. [`prune_endpoint_disco`](PeerTracker::prune_endpoint_disco) does
193 /// the dropping.
194 endpoint_disco: HashMap<NodePublicKey, EndpointDisco>,
195 /// Tailnet-Lock (TKA) authority enforced at the peer-trust chokepoint, matching Go
196 /// `tkaFilterNetmapLocked`. Read on demand from a [`watch`] cell the control runner owns: when it
197 /// holds `Some` (a verified lock has been synced from control), enforcement is **active** — every
198 /// upserted peer must present a `key_signature` this authority authorizes, or it is dropped
199 /// (fail-closed), exactly as Go drops peers with a missing or failing signature. When it holds
200 /// `None` (no lock, or the lock was disabled) enforcement is **inactive** and every peer is
201 /// upserted, identical to pre-TKA behavior and to Go's `b.tka == nil` early return.
202 ///
203 /// A `watch::Receiver` (not the bus) is the transport on purpose: the authority is a single
204 /// security-critical state cell, and `watch` is last-write-wins, never-dropped, and ordered by
205 /// the control runner's own writes — so a disable (`None`) can never be reordered behind or
206 /// silently dropped before a stale `Some` (which a best-effort broadcast bus could do, leaving a
207 /// defunct lock enforcing forever). The control runner is the sole writer; we only ever read.
208 ///
209 /// The authority always passes through `VerifiedAumChain::verify` before the control runner
210 /// publishes it, so enforcement only engages on a chain we have cryptographically verified.
211 /// Connectivity now depends on `ts_tka` verifying genuinely-good signatures correctly (see
212 /// SECURITY.md). Self is structurally never filtered here (the self node never enters `peer_db` —
213 /// it is routed to the control runner's `self_node` cell), so a node cannot lock itself out of
214 /// its own netmap.
215 tka_authority: watch::Receiver<Option<Arc<ts_tka::Authority>>>,
216 env: Env,
217}
218
219impl PeerTracker {
220 fn peer_by_name_opt(&self, name: &str) -> Option<&Node> {
221 // Canonicalization (case + trailing dot) is handled inside the name index lookup.
222 self.peer_db.get(&name).map(|(_id, node)| node)
223 }
224
225 fn peer_by_tailnet_ip_opt(&self, ip: IpAddr) -> Option<&Node> {
226 self.peer_db.get(&ip).map(|(_id, node)| node)
227 }
228
229 /// Build the peer entries for a [`Status`](crate::Status) snapshot from the current peer db.
230 ///
231 /// Connectivity fields (`cur_addr`/`relay`) are left at their `from_node` defaults (`None`) here:
232 /// this is the live-watch/hot path and must stay magicsock-free and synchronous. The explicit
233 /// [`GetStatus`] snapshot enriches them ([`status_peers_with_ids`](Self::status_peers_with_ids)).
234 fn status_peers(&self) -> Vec<StatusNode> {
235 self.peer_db
236 .peers()
237 .values()
238 .map(StatusNode::from_node)
239 .collect()
240 }
241
242 /// Like [`status_peers`](Self::status_peers) but pairs each entry with its [`PeerId`], so the
243 /// caller can join per-peer connectivity (the direct manager's `best_addrs`, keyed by `PeerId`)
244 /// onto the `StatusNode` before returning it. Order is unspecified (a `HashMap` walk).
245 fn status_peers_with_ids(&self) -> Vec<(PeerId, StatusNode)> {
246 self.peer_db
247 .peers()
248 .iter()
249 .map(|(id, node)| (*id, StatusNode::from_node(node)))
250 .collect()
251 }
252
253 fn whois_opt(&self, addr: std::net::SocketAddr) -> Option<crate::status::WhoIs> {
254 let ip = crate::status::whois_addr(addr);
255 let node = self.peer_by_tailnet_ip_opt(ip).cloned()?;
256 // Join the node's owning user id against the accumulated UserProfiles table to resolve a
257 // login/display name. `None` when control sent no profile for that user (e.g. tagged nodes
258 // with no human owner, or a profile not yet delivered).
259 let user = self.resolve_user(node.user_id);
260 Some(crate::status::WhoIs::from_node_with_user(node, user))
261 }
262
263 /// Resolve a user id to its best display label from the accumulated profile table.
264 fn resolve_user(&self, user_id: UserId) -> Option<String> {
265 self.user_profiles
266 .get(&user_id)
267 .and_then(UserProfile::best_label)
268 }
269
270 /// Whether `node` may be admitted to the peer db under Tailnet Lock, matching Go
271 /// `tkaFilterNetmapLocked`'s per-peer verdict (drop unsigned / failed-signature peers).
272 ///
273 /// This consults the live [`tka_authority`](Self::tka_authority) cell on each call (one `borrow`,
274 /// held only for the duration of the verdict). For a `Full` resync — which checks every peer —
275 /// prefer [`tka_authority_snapshot`](Self::tka_authority_snapshot) +
276 /// [`tka_snapshot_admits`](Self::tka_snapshot_admits) to borrow once and verify each peer a single
277 /// time; this method is the convenience wrapper for the single-peer (`Delta`/patch) sites.
278 ///
279 /// Fail-closed and gated:
280 /// - No authority ⇒ no lock synced ⇒ always admit (Go's `b.tka == nil` early return; identical to
281 /// pre-TKA behavior).
282 /// - **Empty trusted-key state** ⇒ always admit (logged at `error!` — see
283 /// [`tka_snapshot_admits`](Self::tka_snapshot_admits) for the full rationale).
284 /// - Authority present + peer carries a `key_signature` the authority authorizes for the peer's
285 /// node key ⇒ admit.
286 /// - Authority present + signature missing or unauthorized/invalid ⇒ **drop** (Go drops peers
287 /// with a missing signature or failed `NodeKeyAuthorized` under tailnet lock).
288 fn tka_admits(&self, node: &Node) -> bool {
289 // Single-peer sites (`Delta`/patch) only need the admit bool; the rotation details are used
290 // exclusively by the cross-peer `Full` filter (rotation obsolescence is whole-netmap).
291 Self::tka_snapshot_admits(self.tka_authority.borrow().as_deref(), node).admitted
292 }
293
294 /// Borrow the current TKA authority once (cloning the cheap `Arc`) for a batch verdict. Returns
295 /// `None` when no lock is synced (admit-all). Used by the `Full` path so a netmap of N peers
296 /// reads the cell once and runs at most one signature verify per peer (not two).
297 fn tka_authority_snapshot(&self) -> Option<Arc<ts_tka::Authority>> {
298 self.tka_authority.borrow().clone()
299 }
300
301 /// The per-peer Tailnet-Lock verdict against an already-borrowed `authority` snapshot. Factored
302 /// out so both the single-peer [`tka_admits`](Self::tka_admits) and the `Full` batch path share
303 /// one verdict implementation (no divergence) while the batch path verifies each peer exactly
304 /// once.
305 ///
306 /// Returns whether the peer is admitted AND, for an admitted peer signed by a rotation chain, the
307 /// [`RotationDetails`](ts_tka::RotationDetails) of that chain — so the `Full` path can run the
308 /// cross-peer rotation filter (Go's `rotationTracker`) without a second verify per peer. A peer
309 /// that is dropped, unsigned, or signed by a non-rotation chain carries `rotation == None`.
310 ///
311 /// Never logs key/signature bytes — only the `stable_id` and the `TkaError` Display (static
312 /// descriptors). One documented parity gap remains vs Go (in PARITY_ROADMAP): no
313 /// `UnsignedPeerAPIOnly` *admission* exemption — Go admits such a peer unsigned under an active
314 /// lock, we drop it (stricter, the safe direction). [`Node::unsigned_peer_api_only`] is now
315 /// carried, and the routes half of upstream's treatment is enforced at decode
316 /// (`ts_control::Node`'s `From` impl clamps such a peer's accepted routes to its own addresses,
317 /// unconditionally, whether or not a lock is active); only the admission carve-out is deferred.
318 fn tka_snapshot_admits(authority: Option<&ts_tka::Authority>, node: &Node) -> TkaVerdict {
319 let Some(auth) = authority else {
320 return TkaVerdict::admit();
321 };
322
323 // Brick-guard: an authority with no trusted keys would drop every peer. A verified chain is
324 // structurally guaranteed ≥1 key (genesis rejects an empty key set, and the last key cannot
325 // be removed), so reaching here means a `ts_tka` invariant was violated — admit rather than
326 // black-hole the whole netmap, and log at `error!` because it signals a real bug, not an
327 // expected runtime input. This is OUR fail-safe, not a Go behavior. NOTE: it only catches the
328 // empty-keyset shape; a non-empty authority that authorizes none of the offered peers still
329 // (correctly) drops them — that is what a lock that revoked everyone means. The
330 // "authorized-zero-peers" isolation case is surfaced separately by the caller.
331 if auth.state().keys.is_empty() {
332 tracing::error!(
333 "TKA: authority has an empty trusted-key set (verified chains never do — likely a \
334 ts_tka bug); not enforcing (admitting all) to avoid isolating the node"
335 );
336 return TkaVerdict::admit();
337 }
338
339 if node.key_signature.is_empty() {
340 tracing::warn!(
341 stable_id = ?node.stable_id,
342 "TKA: dropping unsigned peer under tailnet lock"
343 );
344 return TkaVerdict::drop();
345 }
346
347 match auth.node_key_authorized_with_details(&node.node_key.to_bytes(), &node.key_signature)
348 {
349 Ok(rotation) => {
350 tracing::debug!(stable_id = ?node.stable_id, "TKA: peer node-key authorized");
351 TkaVerdict {
352 admitted: true,
353 rotation,
354 }
355 }
356 Err(e) => {
357 tracing::warn!(
358 stable_id = ?node.stable_id,
359 error = %e,
360 "TKA: dropping peer with unauthorized node key"
361 );
362 TkaVerdict::drop()
363 }
364 }
365 }
366
367 /// The **keep** verdict for a whole batch of peers under `authority` — one complete Go
368 /// `tkaFilterNetmapLocked` pass (`ipn/ipnlocal/tailnet-lock.go`, v1.100.0), in Go's order:
369 ///
370 /// 1. the per-peer signature verdict ([`tka_snapshot_admits`](Self::tka_snapshot_admits)), then
371 /// 2. the cross-peer rotation filter (Go `rotationTracker`): a peer presenting a node key that a
372 /// newer rotation has superseded — or a tied clone of one — is dropped even though its own
373 /// signature verifies. That is whole-batch by nature (one peer's chain obsoletes another's
374 /// key), which is why it lives here and not in the per-peer verdict.
375 ///
376 /// Factored out because two call sites must agree exactly on what "admitted" means: the `Full`
377 /// netmap upsert in [`apply_peer_update`](Self::apply_peer_update), and
378 /// [`tka_reevaluate_peer_db`](Self::tka_reevaluate_peer_db), which re-runs the same pass over the
379 /// peers already in the db when a freshly-synced authority is installed. A divergence between
380 /// them would be a peer admitted by one path and dropped by the other.
381 ///
382 /// `authority` is borrowed once and each peer verified exactly once (the ed25519 verify is the
383 /// expensive part). Returns one `bool` per input node, in input order; `None` authority ⇒ all
384 /// `true` (no lock synced ⇒ admit all, Go's `b.tka == nil` early return).
385 ///
386 /// `pub(crate)` for a third caller with the same requirement: the cold-start replay of a cached
387 /// netmap ([`control_runner::load_cached_netmap`](crate::control_runner::load_cached_netmap)),
388 /// which must apply the same pass to the cached peers that the netmap they were cached from
389 /// already went through — Go replays its cached map through `setNetMapLocked`, so it runs this
390 /// very filter.
391 pub(crate) fn tka_keep_verdicts(
392 authority: Option<&ts_tka::Authority>,
393 nodes: &[&Node],
394 ) -> Vec<bool> {
395 let verdicts = nodes
396 .iter()
397 .map(|node| Self::tka_snapshot_admits(authority, node))
398 .collect::<Vec<_>>();
399
400 let mut rotation = RotationTracker::default();
401 for (node, verdict) in nodes.iter().zip(&verdicts) {
402 if verdict.admitted
403 && let Some(details) = &verdict.rotation
404 {
405 rotation.add(node.node_key.to_bytes().to_vec(), details);
406 }
407 }
408 let obsolete = rotation.obsolete_keys();
409
410 nodes
411 .iter()
412 .zip(&verdicts)
413 .map(|(node, v)| {
414 // `contains` takes `&[u8]` (HashSet<Vec<u8>> borrows as a slice) — no alloc.
415 v.admitted && !obsolete.contains(&node.node_key.to_bytes()[..])
416 })
417 .collect()
418 }
419}
420
421/// The outcome of a per-peer Tailnet-Lock check: whether the peer is admitted, plus (for an admitted
422/// peer signed by a rotation chain) the chain's [`RotationDetails`](ts_tka::RotationDetails) so the
423/// `Full` path can run the cross-peer rotation filter from the SAME verify pass (no second verify).
424struct TkaVerdict {
425 admitted: bool,
426 rotation: Option<ts_tka::RotationDetails>,
427}
428
429impl TkaVerdict {
430 /// Admitted, no rotation details (no lock / brick-guard / non-rotation signature).
431 fn admit() -> Self {
432 Self {
433 admitted: true,
434 rotation: None,
435 }
436 }
437 /// Dropped.
438 fn drop() -> Self {
439 Self {
440 admitted: false,
441 rotation: None,
442 }
443 }
444}
445
446/// Cross-peer rotation-obsolescence tracker, mirroring Go `ipnlocal.rotationTracker`. Fed the
447/// [`RotationDetails`](ts_tka::RotationDetails) of every admitted, rotation-signed peer in a `Full`
448/// netmap; [`obsolete_keys`](Self::obsolete_keys) then returns the node keys to drop on top of the
449/// per-peer verdict. Two rules (Go `tkaFilterNetmapLocked` + `rotationTracker.obsoleteKeys`):
450///
451/// 1. Every prior node key named in any rotation chain is obsolete (a newer chain rotated it away).
452/// 2. Among `Direct`-rooted chains sharing one wrapping pubkey (a clone signal), only the
453/// longest-chain peer survives; if the two longest are tied, ALL in that group are dropped (we
454/// cannot tell which is the latest, so reject for safety). `Credential`-rooted chains are exempt
455/// from rule 2 — several nodes can legitimately join under one reusable auth key (same wrapping
456/// pubkey), so sharing it is not a clone signal there. (Rule 1 still applies to them.)
457///
458/// Node keys are tracked as raw `Vec<u8>` (the verified 32-byte node-public bytes).
459#[derive(Default)]
460struct RotationTracker {
461 obsolete: HashSet<Vec<u8>>,
462 by_wrapping_key: HashMap<Vec<u8>, Vec<SigRotation>>,
463}
464
465/// One admitted peer's rotation entry within a wrapping-key group.
466struct SigRotation {
467 node_key: Vec<u8>,
468 num_prev_keys: usize,
469}
470
471impl RotationTracker {
472 /// Record an admitted peer `node_key` and its rotation `details` (Go `addRotationDetails`).
473 fn add(&mut self, node_key: Vec<u8>, details: &ts_tka::RotationDetails) {
474 // Rule 1: every prior key is obsolete — applied for ALL chains (incl. credential-rooted),
475 // matching Go's ungated `obsolete.AddSlice(d.PrevNodeKeys)`.
476 self.obsolete.extend(details.prev_node_keys.iter().cloned());
477 // Rule 2 (clone-uniqueness) is gated to Direct-rooted chains only.
478 if details.initial_sig_kind != ts_tka::SigKind::Direct {
479 return;
480 }
481 self.by_wrapping_key
482 .entry(details.initial_wrapping_pubkey.clone())
483 .or_default()
484 .push(SigRotation {
485 node_key,
486 num_prev_keys: details.prev_node_keys.len(),
487 });
488 }
489
490 /// Compute the full obsolete node-key set (Go `rotationTracker.obsoleteKeys`). Processes each
491 /// wrapping-key group, mutating the shared `obsolete` set as it goes (so a key obsoleted by one
492 /// group is seen as obsolete by later groups via the `retain` below — Go's
493 /// `slices.DeleteFunc(... Contains)`). Group iteration order (a `HashMap` drain) is
494 /// nondeterministic, but the result is order-INDEPENDENT: this only ever *inserts* into
495 /// `obsolete` (never removes), and rule 1 already obsoleted every prior key before this loop, so
496 /// the final set is a union that does not depend on which group runs first (as in Go).
497 fn obsolete_keys(mut self) -> HashSet<Vec<u8>> {
498 // Drain only the group map so the loop can mutate `self.obsolete` without aliasing it; the
499 // shared `obsolete` set itself is NOT drained, preserving the cross-group visibility above.
500 let groups: Vec<Vec<SigRotation>> = self.by_wrapping_key.drain().map(|(_k, v)| v).collect();
501 for mut group in groups {
502 // Drop entries already obsoleted (rotated away) by another chain.
503 group.retain(|rd| !self.obsolete.contains(&rd.node_key));
504 if group.is_empty() {
505 continue;
506 }
507 // Longest chain (most prior keys) is the newest ⇒ the survivor; sort decreasing.
508 // `sort_by_key` is stable (like Go's `SortStableFunc`); `Reverse` gives descending order.
509 group.sort_by_key(|rd| core::cmp::Reverse(rd.num_prev_keys));
510 if group.len() >= 2 && group[0].num_prev_keys == group[1].num_prev_keys {
511 // Tie for longest ⇒ cannot disambiguate the latest ⇒ drop the WHOLE group.
512 tracing::warn!(
513 "TKA: multiple peers share a wrapping key with equal rotation depth; dropping all (cannot determine the latest)"
514 );
515 for rd in &group {
516 self.obsolete.insert(rd.node_key.clone());
517 }
518 } else {
519 // Only the longest-chain peer survives; the rest are obsolete.
520 for rd in &group[1..] {
521 self.obsolete.insert(rd.node_key.clone());
522 }
523 }
524 }
525 self.obsolete
526 }
527}
528
529impl kameo::Actor for PeerTracker {
530 /// `(env, tka_authority)`: the bus/keys env, plus the read end of the control runner's TKA
531 /// enforcement-authority cell (Go `tkaFilterNetmapLocked`). The control runner is the sole
532 /// writer; it publishes the verified `Authority` after a successful `/machine/tka/sync` and
533 /// `None` when the lock is disabled. A `watch` cell (not a bus message) so the latest value is
534 /// always readable on demand, never dropped, and never reordered (see the control runner's
535 /// `tka_authority` cell).
536 type Args = (Env, watch::Receiver<Option<Arc<ts_tka::Authority>>>);
537 type Error = Error;
538
539 async fn on_start(
540 (env, tka_authority): Self::Args,
541 slf: ActorRef<Self>,
542 ) -> Result<Self, Self::Error> {
543 env.subscribe::<Arc<ts_control::StateUpdate>>(&slf).await?;
544 env.subscribe::<PeerDiscoKeyAdvertisement>(&slf).await?;
545 env.subscribe::<DiscoKeyObserved>(&slf).await?;
546
547 // Re-filter the peer db whenever the enforcement authority changes. Go gets this for free:
548 // `SetControlClientStatus` runs `tkaSyncIfNeeded` and `tkaFilterNetmapLocked` back to back
549 // over one netmap. Here the sync is asynchronous, so the peers admitted before the authority
550 // arrived need a second pass — see `tka_reevaluate_peer_db`. `changed()` resolves on every
551 // write to the cell (enable, re-sync, disable); the task ends when the control runner drops
552 // the sender (shutdown) or the tracker itself is gone.
553 //
554 // A **weak** ref on purpose: the runtime holds only a `WeakActorRef` to the peer tracker, so
555 // a strong one parked in this task would keep the actor's mailbox alive past shutdown.
556 let mut authority_changes = tka_authority.clone();
557 let notify = slf.downgrade();
558 tokio::spawn(async move {
559 while authority_changes.changed().await.is_ok() {
560 let Some(tracker) = notify.upgrade() else {
561 break; // the peer tracker is gone; nothing left to re-filter
562 };
563 if tracker.tell(TkaAuthorityChanged).await.is_err() {
564 break; // the peer tracker stopped
565 }
566 }
567 });
568
569 let (peer_watch, _) = watch::channel(Vec::new());
570
571 Ok(Self {
572 peer_db: PeerDb::default(),
573 pending_requests: Default::default(),
574 seen_state_update: false,
575 peer_watch,
576 user_profiles: HashMap::new(),
577 endpoint_disco: HashMap::new(),
578 // The cell starts `None` (no lock synced ⇒ enforcement inactive, admit all, matching
579 // Go's `b.tka == nil`); the control runner flips it to `Some` on the first sync.
580 tka_authority,
581 env,
582 })
583 }
584}
585
586enum Pending {
587 PeerByName(PeerByName, ReplySender<Option<Node>>),
588 AcceptedRoute(PeerByAcceptedRoute, ReplySender<Vec<Node>>),
589 TailnetIp(PeerByTailnetIp, ReplySender<Option<Node>>),
590 Status(ReplySender<Vec<(PeerId, StatusNode)>>),
591 WhoIs(Whois, ReplySender<Option<crate::status::WhoIs>>),
592}
593
594// For messages with arguments, a struct is generated with the args as fields. They aren't
595// documented, and we can't apply attributes directly to the fields. Hence, wrap in a module where
596// docs are turned off everywhere.
597#[allow(missing_docs)]
598mod msg_impl {
599 use std::net::IpAddr;
600
601 use kameo::prelude::DelegatedReply;
602
603 use super::*;
604
605 #[kameo::messages]
606 impl PeerTracker {
607 /// Lookup a peer by name.
608 ///
609 /// Waits until we've received at least one peer update from control.
610 #[message(ctx)]
611 pub async fn peer_by_name(
612 &mut self,
613 ctx: &mut Context<Self, DelegatedReply<Option<Node>>>,
614 name: String,
615 ) -> DelegatedReply<Option<Node>> {
616 let (deleg, sender) = ctx.reply_sender();
617 let Some(sender) = sender else { return deleg };
618
619 if !self.seen_state_update {
620 tracing::debug!(query = name, "no peer state seen yet, queueing request");
621
622 self.pending_requests
623 .push(Pending::PeerByName(PeerByName { name }, sender));
624
625 return deleg;
626 }
627
628 sender.send(self.peer_by_name_opt(&name).cloned());
629
630 deleg
631 }
632
633 /// Lookup all peers that accept packets addressed to the given IP.
634 ///
635 /// This includes the peer's tailnet address and any subnet routes it provides. Only
636 /// the peers with the most specific subnet route match that covers `ip` will be
637 /// returned.
638 ///
639 /// E.g., suppose:
640 ///
641 /// - We're querying for `10.1.2.3`
642 /// - `PeerA` and `PeerB` have accepted routes for `10.1.2.0/24`
643 /// - `PeerC` has an accepted route for `10.1.0.0/16`
644 ///
645 /// Only `PeerA` and `PeerB` will be returned, since they have the most specific
646 /// prefix match.
647 #[message(ctx)]
648 pub fn peer_by_accepted_route(
649 &mut self,
650 ctx: &mut Context<Self, DelegatedReply<Vec<Node>>>,
651 ip: IpAddr,
652 ) -> DelegatedReply<Vec<Node>> {
653 let (deleg, sender) = ctx.reply_sender();
654 let Some(sender) = sender else { return deleg };
655
656 if !self.seen_state_update {
657 tracing::debug!(query = %ip, "no peer state seen yet, queueing request");
658
659 self.pending_requests
660 .push(Pending::AcceptedRoute(PeerByAcceptedRoute { ip }, sender));
661
662 return deleg;
663 }
664
665 sender.send(
666 self.peer_db
667 .get_route(ip.into())
668 .map(|(_id, node)| node.clone())
669 .collect(),
670 );
671
672 deleg
673 }
674
675 /// Lookup the peer that has the given tailnet IP address.
676 #[message(ctx)]
677 pub fn peer_by_tailnet_ip(
678 &mut self,
679 ctx: &mut Context<Self, DelegatedReply<Option<Node>>>,
680 ip: IpAddr,
681 ) -> DelegatedReply<Option<Node>> {
682 let (deleg, sender) = ctx.reply_sender();
683 let Some(sender) = sender else { return deleg };
684
685 if !self.seen_state_update {
686 tracing::debug!(query = %ip, "no peer state seen yet, queueing request");
687
688 self.pending_requests
689 .push(Pending::TailnetIp(PeerByTailnetIp { ip }, sender));
690
691 return deleg;
692 }
693
694 sender.send(self.peer_by_tailnet_ip_opt(ip).cloned());
695
696 deleg
697 }
698
699 /// Build the peer entries of a [`Status`](crate::Status) snapshot, each paired with its
700 /// [`PeerId`] so [`Runtime::status`](crate::Runtime::status) can join per-peer connectivity
701 /// (`cur_addr`/`relay`) from the direct manager before returning. The self node is *not*
702 /// included here (it lives in the control runner); `Runtime::status` combines both and drops
703 /// the ids.
704 ///
705 /// Waits until we've received at least one peer update from control.
706 #[message(ctx)]
707 pub fn get_status(
708 &mut self,
709 ctx: &mut Context<Self, DelegatedReply<Vec<(PeerId, StatusNode)>>>,
710 ) -> DelegatedReply<Vec<(PeerId, StatusNode)>> {
711 let (deleg, sender) = ctx.reply_sender();
712 let Some(sender) = sender else { return deleg };
713
714 if !self.seen_state_update {
715 tracing::debug!("no peer state seen yet, queueing status request");
716 self.pending_requests.push(Pending::Status(sender));
717 return deleg;
718 }
719
720 sender.send(self.status_peers_with_ids());
721
722 deleg
723 }
724
725 /// Return every known peer's full domain [`Node`] (not the lossy [`StatusNode`]).
726 ///
727 /// Used by [`Runtime::file_targets`](crate::Runtime::file_targets), which needs the full node
728 /// (peerAPI address, owning user id, cap map) to compute Taildrop send targets. The self node
729 /// is not included (it lives in the control runner). Returns empty before the first netmap —
730 /// the natural "not connected yet" analog (an immediate answer, no queueing needed: callers
731 /// that need a populated list await `Running` first).
732 #[message]
733 pub fn all_peers(&self) -> Vec<Node> {
734 self.peer_db.peers().values().cloned().collect()
735 }
736
737 /// Resolve which node owns a tailnet source address.
738 ///
739 /// Maps the source IP of `addr` to the owning node via the tailnet-IP index, returning a
740 /// [`WhoIs`](crate::WhoIs). The port is ignored (a tailnet IP uniquely identifies a node).
741 ///
742 /// The resulting [`WhoIs`](crate::WhoIs) carries no user/login or capability data: this
743 /// fork's domain [`Node`] does not retain those wire fields. See the
744 /// [`status`](crate::status) module docs for the gap.
745 ///
746 /// Waits until we've received at least one peer update from control.
747 #[message(ctx)]
748 pub fn whois(
749 &mut self,
750 ctx: &mut Context<Self, DelegatedReply<Option<crate::status::WhoIs>>>,
751 addr: std::net::SocketAddr,
752 ) -> DelegatedReply<Option<crate::status::WhoIs>> {
753 let (deleg, sender) = ctx.reply_sender();
754 let Some(sender) = sender else { return deleg };
755
756 if !self.seen_state_update {
757 tracing::debug!(query = %addr, "no peer state seen yet, queueing whois request");
758 self.pending_requests
759 .push(Pending::WhoIs(Whois { addr }, sender));
760 return deleg;
761 }
762
763 sender.send(self.whois_opt(addr));
764
765 deleg
766 }
767
768 /// Subscribe to netmap peer-change events.
769 ///
770 /// Returns a [`watch::Receiver`] whose value is the current set of peer
771 /// [`StatusNode`]s, updated on every netmap state update from control. Embedders can await
772 /// changes via [`watch::Receiver::changed`] to react to peers joining, leaving, or changing.
773 ///
774 /// The receiver's initial value is the peer set at subscription time (empty before the
775 /// first netmap update). This is a peer-only view; combine with the self node from
776 /// [`Runtime::status`](crate::Runtime::status) when a full snapshot is needed.
777 #[message(derive(Clone))]
778 pub fn watch_netmap(&self) -> watch::Receiver<Vec<StatusNode>> {
779 self.peer_watch.subscribe()
780 }
781 }
782}
783
784pub use msg_impl::*;
785
786#[derive(Debug, Clone)]
787pub(crate) struct PeerState {
788 #[allow(unused)]
789 pub deletions: HashSet<PeerId>,
790 #[allow(unused)]
791 pub upserts: HashSet<PeerId>,
792 pub peers: Arc<PeerDb>,
793}
794
795impl Message<Arc<ts_control::StateUpdate>> for PeerTracker {
796 type Reply = ();
797
798 async fn handle(
799 &mut self,
800 msg: Arc<ts_control::StateUpdate>,
801 _ctx: &mut Context<Self, Self::Reply>,
802 ) {
803 // Accumulate user profiles first — control sends them incrementally and a response may
804 // carry profiles with no peer delta (or peers that reference a profile from an earlier
805 // response), so this must happen before the no-peer-update early return below.
806 for profile in &msg.user_profiles {
807 self.user_profiles.insert(profile.id, profile.clone());
808 }
809
810 // Apply the standalone online/last-seen delta maps (channels C/D, `MapResponse.OnlineChange`
811 // / `PeerSeenChange`). These arrive keyed by control node id and may ride a response that
812 // carries NO `peer_update` (a bare online flip is the common case), so they must be applied
813 // *before* the no-peer-update early return — otherwise online status freezes at the last
814 // full-node/patch value. Each entry only ever *sets* a value (never back to unknown).
815 // Wall clock for a `PeerSeenChange: true` (Go uses `clock.Now()`). chrono is built without
816 // its `clock` feature in this workspace, so derive it from `SystemTime` the same way the
817 // control runner / ssh-policy paths do (unix secs → `DateTime::from_timestamp`).
818 let now = std::time::SystemTime::now()
819 .duration_since(std::time::UNIX_EPOCH)
820 .ok()
821 .and_then(|d| chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos()))
822 .unwrap_or_default();
823 let liveness_changed =
824 self.apply_liveness_changes(&msg.online_change, &msg.peer_seen_change, now);
825
826 if msg.peer_update.is_none() && msg.peer_patches.is_empty() {
827 // No peer set or patch this response. If a liveness delta still mutated the netmap,
828 // publish the refreshed snapshot so watchers (and `GetStatus`) see the new online state.
829 if liveness_changed {
830 self.service_pending_requests();
831 self.peer_watch.send_replace(self.status_peers());
832 if let Err(e) = self
833 .env
834 .publish(Arc::new(PeerState {
835 upserts: HashSet::default(),
836 deletions: HashSet::default(),
837 peers: Arc::new(self.peer_db.clone()),
838 }))
839 .await
840 {
841 tracing::error!(error = %e, "publishing liveness-only peer state update");
842 }
843 }
844 return;
845 }
846
847 // Apply the whole-node peer set (if any) FIRST, then the field-level patches on top —
848 // mirroring Go's `controlclient` order (`Peers*` then `PeersChangedPatch`). A response may
849 // carry either, both, or (with a liveness-only delta) neither. Merge the upsert/deletion sets
850 // so the published `PeerState` reflects every node touched by both passes; a node both
851 // upserted by the set and patched stays in `upserts` (the patch removes it from `deletions`).
852 let (mut upserts, mut deletions) = msg
853 .peer_update
854 .as_ref()
855 .map(|u| self.apply_peer_update(u))
856 .unwrap_or_default();
857
858 if !msg.peer_patches.is_empty() {
859 let (patch_upserts, patch_deletions) = self.apply_peer_patches(&msg.peer_patches);
860 // A patch can evict a node the set just upserted (TKA rejection after key rotation), or
861 // re-admit/patch one not in the set — reconcile so each id lands in exactly one set.
862 for id in &patch_upserts {
863 deletions.remove(id);
864 }
865 for id in &patch_deletions {
866 upserts.remove(id);
867 }
868 upserts.extend(patch_upserts);
869 deletions.extend(patch_deletions);
870 }
871
872 tracing::debug!(
873 n_upsert = upserts.len(),
874 n_delete = deletions.len(),
875 peer_count = self.peer_db.peers().len(),
876 "new peer state"
877 );
878
879 self.service_pending_requests();
880
881 // Publish the latest peer snapshot to netmap watchers. `send_replace` keeps the receiver's
882 // value current even when there are no subscribers, so a late subscriber sees fresh state.
883 self.peer_watch.send_replace(self.status_peers());
884
885 if let Err(e) = self
886 .env
887 .publish(Arc::new(PeerState {
888 upserts,
889 deletions,
890 peers: Arc::new(self.peer_db.clone()),
891 }))
892 .await
893 {
894 tracing::error!(error = %e, "publishing peer state update");
895 }
896 }
897}
898
899impl Message<PeerDiscoKeyAdvertisement> for PeerTracker {
900 type Reply = ();
901
902 async fn handle(
903 &mut self,
904 msg: PeerDiscoKeyAdvertisement,
905 _ctx: &mut Context<Self, Self::Reply>,
906 ) {
907 if !self.learn_disco_key(msg.peer, msg.key) {
908 return;
909 }
910
911 // The key changed, so republish: the direct-path machinery resolves a peer's disco key out
912 // of the published `PeerState` snapshot (`direct::DiscoPeerLookup`), which is the whole
913 // point of learning it — it is what lets disco reach this peer without waiting for a
914 // netmap update. Go does the equivalent by writing the key straight into the magicsock
915 // endpoint and re-keying its peer map.
916 self.peer_watch.send_replace(self.status_peers());
917
918 if let Err(e) = self
919 .env
920 .publish(Arc::new(PeerState {
921 upserts: HashSet::from_iter([msg.peer]),
922 deletions: HashSet::default(),
923 peers: Arc::new(self.peer_db.clone()),
924 }))
925 .await
926 {
927 tracing::error!(error = %e, "publishing peer state after a TSMP disco-key advertisement");
928 }
929 }
930}
931
932impl Message<DiscoKeyObserved> for PeerTracker {
933 type Reply = ();
934
935 async fn handle(&mut self, msg: DiscoKeyObserved, _ctx: &mut Context<Self, Self::Reply>) {
936 if !self.observe_disco_key(msg.peer, msg.key) {
937 return;
938 }
939
940 // The active key moved, so republish. This is the *same* channel a TSMP advertisement and a
941 // netmap disco-key change use, and it is what makes the direct manager invalidate the
942 // trusted path built under the old key: it diffs consecutive snapshots
943 // (`direct::disco_key_rotations`) and calls `MagicSock::changed_active_disco` — this fork's
944 // `endpoint.changedActiveDiscoLocked`, which Go likewise reaches from
945 // `checkAndUpdateDiscoKey`. Keeping the switch and the invalidation on one path is why the
946 // switch is done here rather than on the packet path that spotted it.
947 self.peer_watch.send_replace(self.status_peers());
948
949 if let Err(e) = self
950 .env
951 .publish(Arc::new(PeerState {
952 upserts: HashSet::from_iter([msg.peer]),
953 deletions: HashSet::default(),
954 peers: Arc::new(self.peer_db.clone()),
955 }))
956 .await
957 {
958 tracing::error!(error = %e, "publishing peer state after a disco active-key switch");
959 }
960 }
961}
962
963/// Internal self-message: the Tailnet-Lock enforcement-authority cell changed — the control runner
964/// installed a freshly-synced [`Authority`](ts_tka::Authority) after a `/machine/tka/sync`, or
965/// cleared it because the lock was disabled. Sent by the watch task
966/// [`on_start`](kameo::Actor::on_start) spawns, so the peer db is re-filtered the moment enforcement
967/// changes instead of at whatever later `Full` netmap happens to arrive.
968#[derive(Debug, Clone, Copy)]
969pub(crate) struct TkaAuthorityChanged;
970
971impl Message<TkaAuthorityChanged> for PeerTracker {
972 type Reply = ();
973
974 async fn handle(&mut self, _msg: TkaAuthorityChanged, _ctx: &mut Context<Self, Self::Reply>) {
975 let deletions = self.tka_reevaluate_peer_db();
976 if deletions.is_empty() {
977 // The common case: enforcement is inactive, or every admitted peer still verifies.
978 return;
979 }
980
981 // An evicted peer must lose its data path, not just its db row, so republish the snapshot
982 // the `Arc<PeerState>` subscribers (route updater, source filter, dataplane) resolve
983 // against — the same publish the netmap handler does after a peer set changes.
984 self.peer_watch.send_replace(self.status_peers());
985
986 if let Err(e) = self
987 .env
988 .publish(Arc::new(PeerState {
989 upserts: HashSet::default(),
990 deletions,
991 peers: Arc::new(self.peer_db.clone()),
992 }))
993 .await
994 {
995 tracing::error!(error = %e, "publishing peer state after a TKA authority change");
996 }
997 }
998}
999
1000/// Ask the peer tracker to re-broadcast its current peer snapshot on the bus, without any peer
1001/// change. Sent after a runtime preference change so the route updater and source filter (both
1002/// `Arc<PeerState>` subscribers) re-resolve against the new value immediately, rather than waiting
1003/// for the next netmap update: `Device::set_exit_node` (new exit-node selector) and
1004/// `Device::set_accept_routes` (new accept-routes flag) both send it.
1005#[derive(Debug, Clone, Copy)]
1006pub struct RepublishState;
1007
1008impl Message<RepublishState> for PeerTracker {
1009 type Reply = ();
1010
1011 async fn handle(&mut self, _msg: RepublishState, _ctx: &mut Context<Self, Self::Reply>) {
1012 // An empty upsert/deletion set: this is a re-broadcast of the unchanged peer set, not a
1013 // delta. Subscribers recompute their routes/filters against the current peers and the
1014 // (just-updated) runtime preferences (exit-node selector, accept-routes flag).
1015 if let Err(e) = self
1016 .env
1017 .publish(Arc::new(PeerState {
1018 upserts: HashSet::default(),
1019 deletions: HashSet::default(),
1020 peers: Arc::new(self.peer_db.clone()),
1021 }))
1022 .await
1023 {
1024 tracing::error!(error = %e, "re-publishing peer state after a runtime preference change");
1025 }
1026 }
1027}
1028
1029impl PeerTracker {
1030 /// Learn a peer's disco key from a TSMP disco-key advertisement, returning whether the
1031 /// advertisement was applied.
1032 ///
1033 /// Go [`magicsock.Conn.HandleDiscoKeyAdvertisement`], reduced to the state this fork keeps:
1034 /// Go stores the learned key on the magicsock endpoint and re-keys its peer map, whereas here
1035 /// the peer db's `disco_key` (and its disco index) *is* the live lookup every direct-path
1036 /// consumer reads. The key is recorded in the peer's [`EndpointDisco`] TSMP slot — never on top
1037 /// of control's — and the peer db then carries whichever of the two is active, so the next
1038 /// netmap cannot silently undo it ([`upsert_from_control`](Self::upsert_from_control)).
1039 ///
1040 /// The three refusals are Go's, in Go's order:
1041 ///
1042 /// 1. **A zero key is never learned.** Go checks it twice — `tstun` publishes only
1043 /// `if !Key.IsZero()`, and `HandleDiscoKeyAdvertisement` rejects it again. The dataplane
1044 /// already dropped it here too; this is the second check, kept because the cost of getting
1045 /// it wrong is a peer bound to an unusable key.
1046 /// 2. **An unknown peer is ignored** (Go: "endpoint not found for node"). An advertisement
1047 /// never creates a peer — only control does — so one that arrives before or after the
1048 /// peer's netmap entry is a no-op, exactly like a `PeersChangedPatch` for an unknown node.
1049 /// 3. **An unchanged key is a no-op**, so a peer re-advertising the key we already hold costs
1050 /// no upsert and no republish (Go counts this as
1051 /// `magicsock_tsmp_disco_key_advertisement_unchanged` and returns). "Unchanged" is measured
1052 /// against the **TSMP-learned** key (Go compares `epDisco.keyFromTSMP()`), NOT against the
1053 /// effective one: an advertisement that merely restates what control already told us is new
1054 /// information — it is the peer itself confirming the key — so it is recorded as the active
1055 /// TSMP key and survives control later dropping or contradicting it.
1056 ///
1057 /// The tailnet-lock gate is deliberately *not* re-run: unlike a `PeersChangedPatch`, an
1058 /// advertisement cannot touch the node key or its TKA signature — only the disco key — so the
1059 /// peer-trust decision that admitted this node is unchanged by definition.
1060 ///
1061 /// [`magicsock.Conn.HandleDiscoKeyAdvertisement`]: https://github.com/tailscale/tailscale/blob/49e148c4a30b4f8098f69468fd27a7021d85ea02/wgengine/magicsock/magicsock.go
1062 fn learn_disco_key(&mut self, peer: PeerId, key: DiscoPublicKey) -> bool {
1063 if disco_key_is_zero(&key) {
1064 tracing::debug!(?peer, "TSMP-advertised disco key is the zero key; ignoring");
1065 return false;
1066 }
1067
1068 let Some((_id, existing)) = self.peer_db.get(&peer) else {
1069 tracing::debug!(
1070 ?peer,
1071 "TSMP disco-key advertisement for unknown peer; ignoring"
1072 );
1073 return false;
1074 };
1075
1076 let node_key = existing.node_key;
1077 if self
1078 .endpoint_disco
1079 .get(&node_key)
1080 .and_then(EndpointDisco::key_from_tsmp)
1081 == Some(key)
1082 {
1083 tracing::trace!(?peer, "TSMP-advertised disco key is unchanged");
1084 return false;
1085 }
1086
1087 let node = existing.clone();
1088 let disco = self.endpoint_disco.entry(node_key).or_default();
1089 disco.update_from_tsmp(Some(key));
1090 let disco = *disco;
1091 self.store_disco(&node, disco);
1092
1093 tracing::info!(
1094 ?peer,
1095 stable_id = ?node.stable_id,
1096 %key,
1097 "learned peer disco key from a TSMP advertisement"
1098 );
1099
1100 true
1101 }
1102
1103 /// Write a peer's resolved disco state onto the peer db.
1104 ///
1105 /// The node lands carrying the **effective** key ([`EndpointDisco::key`]), which is what the
1106 /// disco index — and so every *send* path — resolves against, and the peer's other known key
1107 /// (if any) is registered as its inactive ingress key so a frame arriving under it still
1108 /// attributes to this peer ([`PeerDb::peer_by_known_disco_key`]).
1109 ///
1110 /// Every disco-key writer goes through here — control, a TSMP advertisement, and an
1111 /// active-slot switch on receive — so the two cannot drift apart on which key is which.
1112 fn store_disco(&mut self, node: &Node, disco: EndpointDisco) -> PeerId {
1113 let effective = disco.key();
1114
1115 let id = if effective == node.disco_key {
1116 self.peer_db.upsert(node)
1117 } else {
1118 let mut node = node.clone();
1119 node.disco_key = effective;
1120 self.peer_db.upsert(&node)
1121 };
1122
1123 self.peer_db
1124 .set_inactive_disco_key(id, disco.inactive_key());
1125
1126 id
1127 }
1128
1129 /// Apply the sender key of an inbound disco frame to this peer's two-slot disco state — the
1130 /// `ts_runtime` half of Go [`endpoint.checkAndUpdateDiscoKey`].
1131 ///
1132 /// A peer mid-rotation keeps sending disco under the key it has not yet switched away from.
1133 /// Upstream accepts either of the two keys it knows for the peer and, when the one received is
1134 /// the currently-inactive one, makes it active: receiving under a key is proof of what the peer
1135 /// is using, and is stronger evidence than what control last said. Without this a rotation
1136 /// costs the peer its direct path until control catches up or the peer re-advertises.
1137 ///
1138 /// Returns whether the active key changed, so the caller can republish — which is how the
1139 /// direct manager learns to invalidate the trusted path built under the old key (Go's
1140 /// `changedActiveDiscoLocked`, reached here through the same snapshot diff every other
1141 /// disco-key transition uses).
1142 ///
1143 /// The refusals, all of which leave the peer db untouched:
1144 ///
1145 /// 1. **An unknown peer**, exactly as for a TSMP advertisement.
1146 /// 2. **A peer with no disco key material at all** (Go: `epDisco == nil` ⇒ `false`).
1147 /// 3. **A key belonging to neither slot.** This is the one that carries the security value:
1148 /// a peer must not be able to move itself onto a key nobody told us about, so a third key
1149 /// is refused even though the frame that carried it opened correctly.
1150 ///
1151 /// [`endpoint.checkAndUpdateDiscoKey`]: https://github.com/tailscale/tailscale/blob/9ea7cba44591e0cd840c6c94d23274dd222059bf/wgengine/magicsock/endpoint.go
1152 fn observe_disco_key(&mut self, peer: PeerId, key: DiscoPublicKey) -> bool {
1153 let Some((_id, existing)) = self.peer_db.get(&peer) else {
1154 tracing::debug!(?peer, "disco received for an unknown peer; ignoring");
1155 return false;
1156 };
1157
1158 let node = existing.clone();
1159 let Some(disco) = self.endpoint_disco.get_mut(&node.node_key) else {
1160 // Go's `epDisco == nil`: the peer has no key from either source, so there is nothing
1161 // this key could match and nothing to switch to.
1162 tracing::debug!(
1163 ?peer,
1164 "disco received for a peer with no known disco key; ignoring"
1165 );
1166 return false;
1167 };
1168
1169 let Some(changed) = disco.check_and_update(key) else {
1170 tracing::debug!(
1171 ?peer,
1172 %key,
1173 "refusing disco under a key that is neither of the peer's known disco keys"
1174 );
1175 return false;
1176 };
1177
1178 if !changed {
1179 return false;
1180 }
1181
1182 let disco = *disco;
1183 self.store_disco(&node, disco);
1184
1185 tracing::info!(
1186 ?peer,
1187 stable_id = ?node.stable_id,
1188 %key,
1189 "peer is sending disco under its other known key; making that key active"
1190 );
1191
1192 true
1193 }
1194
1195 /// Upsert a control-sourced [`Node`] into the peer db, resolving its disco key against anything
1196 /// this peer has told us over TSMP first.
1197 ///
1198 /// Every node built from control goes through here — `Full`, `Delta { upsert }`, and a
1199 /// `PeersChangedPatch` — so the three cannot diverge on which of the two keys wins. This is the
1200 /// disco half of Go [`endpoint.updateFromNode`]: control's key is written through
1201 /// [`EndpointDisco::update_from_control`] **only when it differs from what control last said**
1202 /// (Go's `if discoKey != n.DiscoKey()` guard, which compares `keyFromControl()`, never the
1203 /// effective key). So a netmap that merely restates the key control already sent leaves an
1204 /// active TSMP key alone — which is the entire point of the advertisement, whose motivating case
1205 /// is a peer whose key control has not caught up with. Control genuinely changing its mind is
1206 /// *recorded* in control's slot, but it does not take the active slot back from a TSMP-learned
1207 /// key: upstream switches back only when disco is received under control's key
1208 /// (`endpoint.checkAndUpdateDiscoKey`). See [`EndpointDisco::update_from_control`].
1209 ///
1210 /// The node lands in the db carrying the *effective* key ([`EndpointDisco::key`]), so the disco
1211 /// index and every send path resolve against the key we would actually send to; the other known
1212 /// key is registered for ingress attribution ([`store_disco`](Self::store_disco)).
1213 ///
1214 /// [`endpoint.updateFromNode`]: https://github.com/tailscale/tailscale/blob/49e148c4a30b4f8098f69468fd27a7021d85ea02/wgengine/magicsock/endpoint.go
1215 fn upsert_from_control(&mut self, node: &Node) -> PeerId {
1216 let node_key = node.node_key;
1217 let from_control = disco_key_from_control(node.disco_key);
1218
1219 let disco = self.endpoint_disco.entry(node_key).or_default();
1220 if disco.key_from_control() != from_control {
1221 disco.update_from_control(from_control);
1222 }
1223 let disco = *disco;
1224
1225 // No key material from either source: Go nils the endpoint's `disco` pointer, so a peer
1226 // that has never had a disco key costs us no entry either.
1227 if disco.is_empty() {
1228 self.endpoint_disco.remove(&node_key);
1229 }
1230
1231 self.store_disco(node, disco)
1232 }
1233
1234 /// The disco key control last gave us for `node_key` — Go `endpointDisco.keyFromControl()`.
1235 fn control_disco_key(&self, node_key: &NodePublicKey) -> Option<DiscoPublicKey> {
1236 self.endpoint_disco
1237 .get(node_key)
1238 .and_then(EndpointDisco::key_from_control)
1239 }
1240
1241 /// Drop [`EndpointDisco`] state for node keys the peer db no longer holds.
1242 ///
1243 /// Go gets this for free: the two keys live on the magicsock `endpoint`, which the peer map keys
1244 /// by node key and deletes when the peer leaves the netmap — and a peer that rotates its node
1245 /// key gets a brand-new endpoint, so a TSMP-learned key is not carried across a rotation. Here
1246 /// the state is a side table, so every control update prunes it to get the same lifetime.
1247 fn prune_endpoint_disco(&mut self) {
1248 if self.endpoint_disco.is_empty() {
1249 return;
1250 }
1251
1252 let peers = &self.peer_db;
1253 self.endpoint_disco
1254 .retain(|node_key, _| peers.has(node_key).is_some());
1255 }
1256
1257 /// Apply a single [`PeerUpdate`](ts_control::PeerUpdate) to the peer db, enforcing the
1258 /// Tailnet-Lock peer-trust chokepoint ([`tka_admits`](Self::tka_admits)) at every upsert site.
1259 ///
1260 /// This is the **single source of truth** for the peer-trust enforcement loop: the actor's
1261 /// netmap [`handle`](Message::handle) calls it, and so do the TKA enforcement tests, so the two
1262 /// real upsert sites (`Full` and `Delta { upsert }`) cannot diverge from what is tested.
1263 ///
1264 /// Returns `(upserts, deletions)` — the [`PeerId`]s touched — for downstream bookkeeping.
1265 fn apply_peer_update(
1266 &mut self,
1267 peer_update: &ts_control::PeerUpdate,
1268 ) -> (HashSet<PeerId>, HashSet<PeerId>) {
1269 let mut upserts = HashSet::default();
1270 let mut deletions = HashSet::default();
1271
1272 match peer_update {
1273 ts_control::PeerUpdate::Full(new_nodes) => {
1274 tracing::trace!("full peer update");
1275
1276 // Borrow the authority ONCE for the whole batch and verify each peer EXACTLY once
1277 // (Go runs `tkaFilterNetmapLocked` once over the assembled netmap; an earlier draft
1278 // verified every peer twice — once for `retained_ids`, once in the upsert loop —
1279 // doubling the ed25519 cost on the hot resync path). `tka_keep_verdicts` is that one
1280 // pass — per-peer signature verdict AND the cross-peer rotation filter — and is
1281 // shared verbatim with `tka_reevaluate_peer_db`, so the netmap path and the
1282 // authority-install path cannot drift apart on what "admitted" means.
1283 //
1284 // The result is a per-NODE keep vector (not a stable_id set), which drives both the
1285 // `retain` (evict revoked peers, keyed by stable_id) and the upsert loop. Judging
1286 // each node by its own verdict means a node whose signature fails is never admitted
1287 // on the strength of a different node that happens to share its stable_id.
1288 //
1289 // Revocation evicts: a peer re-included with a now-invalid/missing signature under an
1290 // active authority fails its verdict, so it is excluded from `retained_ids` and
1291 // `retain` drops the stale (previously-admitted) entry. With no authority the snapshot
1292 // is `None`, so every node passes — byte-for-byte the pre-TKA behavior (no regression).
1293 let authority = self.tka_authority_snapshot();
1294 let node_refs = new_nodes.iter().collect::<Vec<&Node>>();
1295 let keep = Self::tka_keep_verdicts(authority.as_deref(), &node_refs);
1296
1297 // `retained_ids` is the set of stable_ids that survive (drives `retain` to evict the
1298 // rest). It must agree with what the upsert loop below will leave in the db. Control
1299 // should never send two distinct nodes with the same `stable_id` in one `Full`, but if
1300 // it does, `peer_db.upsert` is last-writer-wins on `stable_id`, so the db ends holding
1301 // the LAST kept node for that id. Build `retained_ids` from kept nodes only — a
1302 // stable_id is retained iff at least one of its (possibly duplicate) nodes is kept, so
1303 // the upsert loop's last-kept node lands and `retain` never evicts a just-upserted id.
1304 let retained_ids = new_nodes
1305 .iter()
1306 .zip(keep.iter().copied())
1307 .filter(|(_, k)| *k)
1308 .map(|(node, _)| &node.stable_id)
1309 .collect::<HashSet<_>>();
1310
1311 // Isolation diagnostic: an ACTIVE lock that authorized none of the offered peers
1312 // leaves this node with no peers — surface it loudly so a self-lockout (vs an attack)
1313 // is diagnosable. `authority.is_some()` means a real keyed lock (the empty-keyset
1314 // brick-guard admits-all, so it never reaches here with zero retained).
1315 if authority.is_some() && !new_nodes.is_empty() && retained_ids.is_empty() {
1316 tracing::error!(
1317 offered = new_nodes.len(),
1318 "TKA: active lock authorized ZERO of the offered peers; node is isolated \
1319 (verify the lock state, or disable tailnet lock to recover)"
1320 );
1321 }
1322
1323 self.peer_db.retain(|id, peer| {
1324 let retain = retained_ids.contains(&peer.stable_id);
1325
1326 if !retain {
1327 deletions.insert(id);
1328 }
1329
1330 retain
1331 });
1332
1333 for (node, k) in new_nodes.iter().zip(keep.iter().copied()) {
1334 if !k {
1335 continue; // fail-CLOSED: rejected by tailnet lock or rotation-obsolete (above)
1336 }
1337 let peer_id = self.upsert_from_control(node);
1338 upserts.insert(peer_id);
1339 }
1340 }
1341
1342 ts_control::PeerUpdate::Delta { remove, upsert } => {
1343 tracing::trace!("delta peer update");
1344
1345 for peer in upsert {
1346 if !self.tka_admits(peer) {
1347 // fail-CLOSED: do not upsert a peer rejected by tailnet lock. If the peer is
1348 // ALREADY in the db (a delta re-upserting an existing peer whose signature is
1349 // now invalid — e.g. revoked between syncs), evict the stale entry rather than
1350 // leaving an unverified peer admitted; Go re-filters the whole netmap each map
1351 // response, so a now-unsigned peer would not survive there either.
1352 if let Some((id, _)) = self.peer_db.remove(&peer.stable_id) {
1353 tracing::warn!(
1354 stable_id = ?peer.stable_id,
1355 "TKA: delta re-upsert rejected; evicting now-unauthorized peer"
1356 );
1357 deletions.insert(id);
1358 }
1359 continue;
1360 }
1361 let id = self.upsert_from_control(peer);
1362
1363 upserts.insert(id);
1364 }
1365
1366 for peer in remove {
1367 let Some((id, _node)) = self.peer_db.remove(peer) else {
1368 // A benign, expected race: the peer may already be gone (dropped in a prior
1369 // `Full`, or fail-closed by TKA — whose now-"unknown" ids commonly reappear in
1370 // a trailing `peers_removed`). Go treats an unknown removal as a no-op; log at
1371 // debug, not error, to avoid false-alarm noise on a healthy node (matches the
1372 // unknown-node handling in `apply_peer_patches`).
1373 tracing::debug!(
1374 control_node_id = peer,
1375 "removed peer was unknown; ignoring"
1376 );
1377 continue;
1378 };
1379
1380 deletions.insert(id);
1381 }
1382 }
1383 }
1384
1385 self.prune_endpoint_disco();
1386
1387 (upserts, deletions)
1388 }
1389
1390 /// Re-run the Tailnet-Lock filter over the peers **already in the peer db**, evicting the ones
1391 /// the current authority does not admit. Returns the evicted [`PeerId`]s (empty when nothing
1392 /// changed, which is the overwhelmingly common case).
1393 ///
1394 /// # Why this exists (a Go-ordering gap, not an extra feature)
1395 /// Go filters the very netmap that announced the lock: `SetControlClientStatus`
1396 /// (`ipn/ipnlocal/local.go`, v1.100.0) calls `tkaSyncIfNeeded` and then, a few lines later,
1397 /// `tkaFilterNetmapLocked(st.NetMap)` — synchronously, on the same `st.NetMap`, in one pass. So
1398 /// the peers announced alongside `TKAEnabled` are checked by the authority that sync just built.
1399 ///
1400 /// Here the sync is a spawned task (`control_runner`'s `maybe_sync_tka`), so the ordering is
1401 /// inverted: the netmap that carried the `TkaStatus` reaches the peer db *before* the authority
1402 /// exists, and is admitted with enforcement inactive. Without this pass those peers stay
1403 /// admitted — unauthorized ones included — until control happens to send another `Full`, which on
1404 /// a steady map poll may be never. That is the whole initial peer set escaping a lock the node
1405 /// really did sync, so this runs the moment the authority is installed ([`TkaAuthorityChanged`])
1406 /// and brings the db back in line.
1407 ///
1408 /// No authority (nothing synced yet, or the lock was disabled) ⇒ no eviction: enforcement is
1409 /// inactive and every peer is admitted, exactly Go's `b.tka == nil` early return. A peer dropped
1410 /// while the lock was active is **not** resurrected by a later disable — the db no longer holds
1411 /// it and this fork keeps no shadow copy of filtered nodes (Go's `b.tka.filtered`); it returns on
1412 /// the next netmap that re-includes it. That is the safe direction: more restrictive, and
1413 /// connectivity-only.
1414 fn tka_reevaluate_peer_db(&mut self) -> HashSet<PeerId> {
1415 let Some(authority) = self.tka_authority_snapshot() else {
1416 return HashSet::default();
1417 };
1418
1419 // Verdicts first, under an immutable borrow of the db; the eviction below needs `&mut`.
1420 let evicted: HashSet<PeerId> = {
1421 let entries = self
1422 .peer_db
1423 .peers()
1424 .iter()
1425 .map(|(id, node)| (*id, node))
1426 .collect::<Vec<(PeerId, &Node)>>();
1427 let nodes = entries
1428 .iter()
1429 .map(|(_, node)| *node)
1430 .collect::<Vec<&Node>>();
1431 let keep = Self::tka_keep_verdicts(Some(&authority), &nodes);
1432 entries
1433 .iter()
1434 .zip(keep)
1435 .filter_map(|((id, _), keep)| (!keep).then_some(*id))
1436 .collect()
1437 };
1438
1439 if evicted.is_empty() {
1440 return evicted;
1441 }
1442
1443 tracing::warn!(
1444 n_evicted = evicted.len(),
1445 peer_count = self.peer_db.peers().len(),
1446 "TKA: re-filtered the peer db against the newly installed lock authority; evicted \
1447 already-admitted peers"
1448 );
1449 self.peer_db.retain(|id, _| !evicted.contains(&id));
1450 self.prune_endpoint_disco();
1451 evicted
1452 }
1453
1454 /// Apply field-level peer patches (`MapResponse.PeersChangedPatch`), returning the upserted /
1455 /// deleted [`PeerId`]s.
1456 ///
1457 /// This is a SEPARATE channel from [`apply_peer_update`](Self::apply_peer_update): Go's
1458 /// `controlclient` applies the whole-node `Peers*` set first and then `PeersChangedPatch`, so a
1459 /// response that carries both has the peer set applied first (by the caller) and these patches
1460 /// applied second, on top of the freshly-synced nodes. A patch only mutates a peer already in the
1461 /// netmap; an unknown node id is ignored (the wire contract — a patch never creates a node).
1462 fn apply_peer_patches(
1463 &mut self,
1464 patches: &[ts_control::PeerChange],
1465 ) -> (HashSet<PeerId>, HashSet<PeerId>) {
1466 let mut upserts = HashSet::default();
1467 let mut deletions = HashSet::default();
1468
1469 tracing::trace!(n = patches.len(), "peer patch update");
1470
1471 for patch in patches {
1472 // Clone the current node, apply the present fields, and re-upsert through the same path
1473 // as a delta so indexes/routes stay consistent.
1474 let Some((_id, existing)) = self.peer_db.get(&patch.id) else {
1475 tracing::debug!(
1476 control_node_id = patch.id,
1477 "peer patch for unknown node; ignoring"
1478 );
1479 continue;
1480 };
1481
1482 let mut node = existing.clone();
1483 if let Some(endpoints) = &patch.underlay_addresses {
1484 node.underlay_addresses = endpoints.clone();
1485 }
1486 if let Some(derp) = patch.derp_region {
1487 node.derp_region = Some(derp);
1488 }
1489 if let Some(cap) = patch.cap {
1490 node.cap = cap;
1491 }
1492 if let Some(cap_map) = &patch.cap_map {
1493 node.cap_map = cap_map.clone();
1494 }
1495 // The db entry carries the EFFECTIVE disco key, which may have been learned over TSMP,
1496 // so restate what CONTROL last said before folding the patch in. Otherwise a patch that
1497 // says nothing about the disco key would hand a TSMP-learned key back as if control had
1498 // sent it, and `upsert_from_control` would write it into control's slot — losing the key
1499 // control actually gave us, on a patch that never mentioned the disco key at all.
1500 node.disco_key = self.control_disco_key(&node.node_key);
1501 if let Some(disco_key) = patch.disco_key {
1502 node.disco_key = Some(disco_key);
1503 }
1504 if let Some(expiry) = patch.node_key_expiry {
1505 node.node_key_expiry = Some(expiry);
1506 }
1507 // Online/last-seen liveness deltas (`PeerChange.Online`/`LastSeen`) — the dominant
1508 // channel by which peer online transitions arrive mid-session. A patch only ever *sets*
1509 // a value (never patches back to unknown), so apply when present.
1510 if let Some(online) = patch.online {
1511 node.online = Some(online);
1512 }
1513 if let Some(last_seen) = patch.last_seen {
1514 node.last_seen = Some(last_seen);
1515 }
1516 // Key rotation: a patch may swap the node key (and its TKA signature). Apply both
1517 // together so the trust gate below verifies the new signature against the new key, never
1518 // a mismatched pair.
1519 if let Some(node_key) = patch.node_key {
1520 node.node_key = node_key;
1521 }
1522 if let Some(sig) = &patch.key_signature {
1523 node.key_signature = sig.clone();
1524 }
1525
1526 // Re-run the tailnet-lock gate on the patched node: a patch that rotates the key must
1527 // satisfy the active authority, exactly like a `Delta` upsert, or it would be a
1528 // trust-enforcement bypass. fail-CLOSED — if the patched node is no longer admitted,
1529 // evict it rather than keep the stale (now-unverified) entry.
1530 if !self.tka_admits(&node) {
1531 if let Some((id, _)) = self.peer_db.remove(&patch.id) {
1532 tracing::warn!(
1533 control_node_id = patch.id,
1534 "peer patch rejected by tailnet lock; evicting peer"
1535 );
1536 deletions.insert(id);
1537 }
1538 continue;
1539 }
1540
1541 let id = self.upsert_from_control(&node);
1542 upserts.insert(id);
1543 }
1544
1545 self.prune_endpoint_disco();
1546
1547 (upserts, deletions)
1548 }
1549
1550 /// Apply the standalone online/last-seen delta maps (`MapResponse.OnlineChange` /
1551 /// `PeerSeenChange`, channels C/D) onto the retained netmap. Returns `true` if any node was
1552 /// actually mutated (so the caller knows whether to re-publish).
1553 ///
1554 /// Mirrors Go `controlclient/map.go:updatePeersStateFromResponse` (the two channels are
1555 /// semantically DISTINCT and must not be conflated):
1556 /// - `OnlineChange` (channel C) is the sole driver of a peer's `online` flag (`mut.Online = v`).
1557 /// - `PeerSeenChange` (channel D) is the sole driver of `last_seen`: `true ⇒ LastSeen = now`,
1558 /// `false ⇒ LastSeen = nil` (cleared). It NEVER touches `online` — "not seen recently" is not
1559 /// the same as "offline", which only `OnlineChange` asserts.
1560 ///
1561 /// Each entry is keyed by control node id and applies to a peer already in the netmap; an unknown
1562 /// node id is ignored (these maps never create a node). `now` is the wall-clock timestamp for a
1563 /// `PeerSeenChange: true` (Go uses `clock.Now()`); the caller passes it so this stays a pure
1564 /// function of its inputs. Returns `true` if any node was actually mutated.
1565 fn apply_liveness_changes(
1566 &mut self,
1567 online_change: &std::collections::BTreeMap<ts_control::NodeId, bool>,
1568 peer_seen_change: &std::collections::BTreeMap<ts_control::NodeId, bool>,
1569 now: chrono::DateTime<chrono::Utc>,
1570 ) -> bool {
1571 let mut changed = false;
1572
1573 // Channel C — direct online flips (the only writer of `online`).
1574 for (&node_id, &online) in online_change {
1575 if let Some((_pid, existing)) = self.peer_db.get(&node_id)
1576 && existing.online != Some(online)
1577 {
1578 let mut node = existing.clone();
1579 node.online = Some(online);
1580 self.peer_db.upsert(&node);
1581 changed = true;
1582 }
1583 }
1584
1585 // Channel D — peer-seen flips (the only writer of `last_seen`; never touches `online`).
1586 // `true` ⇒ last-seen is now; `false` ⇒ last-seen cleared (Go map.go:820-830).
1587 for (&node_id, &seen) in peer_seen_change {
1588 let new_last_seen = if seen { Some(now) } else { None };
1589 if let Some((_pid, existing)) = self.peer_db.get(&node_id)
1590 && existing.last_seen != new_last_seen
1591 {
1592 let mut node = existing.clone();
1593 node.last_seen = new_last_seen;
1594 self.peer_db.upsert(&node);
1595 changed = true;
1596 }
1597 }
1598
1599 changed
1600 }
1601
1602 /// Test-only constructor: build a [`PeerTracker`] with a chosen initial TKA authority without
1603 /// going through the actor `on_start` path. Returns the tracker plus the **`watch::Sender`** for
1604 /// its enforcement-authority cell, so a test can drive the exact enable/disable transitions the
1605 /// control runner drives at runtime (`tx.send_replace(Some(..))` ⇒ enforce, `tx.send_replace(None)`
1606 /// ⇒ clear). The initial `Some` exercises the fail-closed chokepoint
1607 /// ([`tka_admits`](Self::tka_admits)); `None` is the no-lock admit-all path. The returned sender
1608 /// must be kept alive for the tracker to read updated values.
1609 #[cfg(test)]
1610 fn for_test(
1611 env: Env,
1612 tka_authority: Option<ts_tka::Authority>,
1613 ) -> (Self, watch::Sender<Option<Arc<ts_tka::Authority>>>) {
1614 let (peer_watch, _) = watch::channel(Vec::new());
1615 let (tka_tx, tka_rx) = watch::channel(tka_authority.map(Arc::new));
1616 let tracker = Self {
1617 peer_db: PeerDb::default(),
1618 seen_state_update: false,
1619 pending_requests: Vec::new(),
1620 peer_watch,
1621 user_profiles: HashMap::new(),
1622 endpoint_disco: HashMap::new(),
1623 tka_authority: tka_rx,
1624 env,
1625 };
1626 (tracker, tka_tx)
1627 }
1628
1629 fn service_pending_requests(&mut self) {
1630 if self.seen_state_update {
1631 return;
1632 }
1633
1634 self.seen_state_update = true;
1635
1636 if !self.pending_requests.is_empty() {
1637 tracing::debug!(
1638 n_pending = self.pending_requests.len(),
1639 "state update received, servicing pending requests"
1640 );
1641 }
1642
1643 for req in core::mem::take(&mut self.pending_requests) {
1644 match req {
1645 Pending::PeerByName(PeerByName { name }, reply) => {
1646 reply.send(self.peer_by_name_opt(&name).cloned());
1647 }
1648 Pending::TailnetIp(PeerByTailnetIp { ip }, reply) => {
1649 reply.send(self.peer_by_tailnet_ip_opt(ip).cloned());
1650 }
1651 Pending::AcceptedRoute(PeerByAcceptedRoute { ip }, reply) => {
1652 reply.send(
1653 self.peer_db
1654 .get_route(ip.into())
1655 .map(|(_id, node)| node.clone())
1656 .collect(),
1657 );
1658 }
1659 Pending::Status(reply) => {
1660 reply.send(self.status_peers_with_ids());
1661 }
1662 Pending::WhoIs(Whois { addr }, reply) => {
1663 reply.send(self.whois_opt(addr));
1664 }
1665 }
1666 }
1667 }
1668}
1669
1670#[cfg(test)]
1671pub(crate) mod tka_tests {
1672 //! Tailnet-Lock (TKA) enforcement tests for the peer-trust chokepoint.
1673 //!
1674 //! These exercise [`PeerTracker::tka_admits`] and the `tka_admits ⇒ upsert` loop the netmap
1675 //! handler runs. The test [`ts_tka::Authority`] is built with [`ts_tka::Authority::from_state`]
1676 //! over a known Ed25519 trusted key, and the signed node-key signature CBOR is produced through
1677 //! `ts_tka`'s public `cbor` encoder + `aum_hash` (the exact same canonical bytes `ts_tka`'s own
1678 //! `direct_signature_verifies_end_to_end` test signs, with no new crypto vectors invented and no
1679 //! private `ts_tka` API used).
1680
1681 use ed25519_dalek::{Signer, SigningKey};
1682 use ts_control::{Node, StableNodeId, TailnetAddress};
1683 use ts_tka::{
1684 AumHash, Authority, Key, KeyKind, State,
1685 cbor::{self, Value},
1686 };
1687
1688 use super::*;
1689
1690 /// `SigKind::Direct` wire value (Go `SigKind`; `ts_tka::SigKind::Direct = 1`).
1691 const SIG_KIND_DIRECT: u64 = 1;
1692
1693 /// The 32-byte node key used across the signed-peer fixtures.
1694 const NODE_KEY_BYTES: [u8; 32] = [7u8; 32];
1695
1696 /// Build a real [`Env`] for the tracker. Only the bus/keys/shutdown plumbing matters here; the
1697 /// TKA gate reads neither, so the forwarding preferences are all benign defaults.
1698 pub(super) fn test_env() -> Env {
1699 let (_shutdown_tx, shutdown_rx) = watch::channel(false);
1700 Env::new(
1701 ts_keys::NodeState::generate(),
1702 shutdown_rx,
1703 crate::env::ForwarderConfig {
1704 accept_routes: false,
1705 accept_dns: true,
1706 exit_node: None,
1707 forward_routes: Vec::new(),
1708 forward_tcp_ports: Vec::new(),
1709 forward_udp_ports: Vec::new(),
1710 forward_all_ports: false,
1711 forward_exit_egress: false,
1712 block_incoming: false,
1713 exit_proxy: None,
1714 peerapi_port: None,
1715 taildrop_dir: None,
1716 enable_ipv6: false,
1717 wireguard_listen_port: None,
1718 network_monitor: false,
1719 persistent_keepalive_interval: None,
1720 ingress_active: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1721 },
1722 )
1723 }
1724
1725 /// A minimal peer [`Node`] carrying `node_key` and the given `key_signature`.
1726 ///
1727 /// `pub(crate)` so the cold-start replay tests in `control_runner` build their peers the same way
1728 /// this module's TKA tests do — both run the same filter, and a second hand-rolled fixture could
1729 /// drift from it.
1730 pub(crate) fn peer_node(stable_id: &str, node_key: [u8; 32], key_signature: Vec<u8>) -> Node {
1731 Node {
1732 id: 1,
1733 stable_id: StableNodeId(stable_id.to_string()),
1734 hostname: stable_id.to_string(),
1735 user_id: 0,
1736 tailnet: Some("ts.net".to_string()),
1737 tags: Vec::new(),
1738 addresses: vec![
1739 "100.64.0.1/32".parse().unwrap(),
1740 "fd7a:115c:a1e0::1/128".parse().unwrap(),
1741 ],
1742 tailnet_address: TailnetAddress {
1743 ipv4: "100.64.0.1/32".parse().unwrap(),
1744 ipv6: "fd7a:115c:a1e0::1/128".parse().unwrap(),
1745 },
1746 node_key: node_key.into(),
1747 node_key_expiry: None,
1748 online: None,
1749 last_seen: None,
1750 key_signature,
1751 machine_key: None,
1752 disco_key: None,
1753 accepted_routes: Vec::new(),
1754 underlay_addresses: Vec::new(),
1755 derp_region: None,
1756 cap: Default::default(),
1757 cap_map: Default::default(),
1758 peerapi_port: None,
1759 peerapi_dns_proxy: false,
1760 is_wireguard_only: false,
1761 exit_node_dns_resolvers: Vec::new(),
1762 peer_relay: false,
1763 ssh_host_keys: Vec::new(),
1764 service_vips: Default::default(),
1765 unsigned_peer_api_only: false,
1766 }
1767 }
1768
1769 /// Encode a `Direct` [`ts_tka::NodeKeySignature`] CBOR exactly as `ts_tka`'s private `to_cbor`
1770 /// does (int-map keys: 1=kind, 2=pubkey, 3=key_id, 4=signature; empty byte fields omitted),
1771 /// using only the crate's *public* `cbor` encoder. `signature` of `None` produces the
1772 /// signing-digest preimage (the `SigHash` form).
1773 fn direct_sig_cbor(node_key: &[u8], key_id: &[u8], signature: Option<&[u8]>) -> Vec<u8> {
1774 let mut pairs = alloc_pairs(node_key, key_id);
1775 if let Some(sig) = signature {
1776 pairs.push((4, Some(Value::Bytes(sig.to_vec()))));
1777 }
1778 cbor::int_map(pairs).to_vec()
1779 }
1780
1781 fn alloc_pairs(node_key: &[u8], key_id: &[u8]) -> Vec<(u64, Option<Value>)> {
1782 vec![
1783 (1, Some(Value::Uint(SIG_KIND_DIRECT))),
1784 (2, Some(Value::Bytes(node_key.to_vec()))),
1785 (3, Some(Value::Bytes(key_id.to_vec()))),
1786 ]
1787 }
1788
1789 /// Build a TKA [`Authority`] that trusts `signing.verifying_key()`, plus a valid `Direct`
1790 /// node-key signature CBOR authorizing [`NODE_KEY_BYTES`] under it.
1791 fn authority_and_valid_sig() -> (Authority, Vec<u8>) {
1792 // A fixed, known Ed25519 trusted key (mirrors ts_tka's own end-to-end test seed).
1793 let signing = SigningKey::from_bytes(&[42u8; 32]);
1794 let trusted_pub = signing.verifying_key().to_bytes().to_vec();
1795
1796 let authority = Authority::from_state(
1797 AumHash([0; 32]),
1798 State {
1799 keys: vec![Key {
1800 kind: KeyKind::Ed25519,
1801 votes: 1,
1802 public: trusted_pub.clone(),
1803 }],
1804 },
1805 );
1806
1807 // SigHash preimage = canonical CBOR with the signature field omitted; sign its blake2s hash.
1808 let preimage = direct_sig_cbor(&NODE_KEY_BYTES, &trusted_pub, None);
1809 let sig_hash = ts_tka::aum_hash(&preimage).0;
1810 let signature = signing.sign(&sig_hash).to_bytes().to_vec();
1811
1812 let signed_cbor = direct_sig_cbor(&NODE_KEY_BYTES, &trusted_pub, Some(&signature));
1813 // Sanity: the authority accepts the signature we just built (same path the gate uses).
1814 assert!(
1815 authority
1816 .node_key_authorized(&NODE_KEY_BYTES, &signed_cbor)
1817 .is_ok()
1818 );
1819
1820 (authority, signed_cbor)
1821 }
1822
1823 #[tokio::test]
1824 async fn tka_inactive_upserts_all_peers() {
1825 // No authority ⇒ enforcement inactive ⇒ both a signed and an unsigned peer are admitted.
1826 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
1827
1828 let signed = peer_node("signed", [1u8; 32], vec![0xde, 0xad, 0xbe, 0xef]);
1829 let unsigned = peer_node("unsigned", [2u8; 32], vec![]);
1830
1831 assert!(tracker.tka_admits(&signed));
1832 assert!(tracker.tka_admits(&unsigned));
1833
1834 tracker.peer_db.upsert(&signed);
1835 tracker.peer_db.upsert(&unsigned);
1836 assert_eq!(tracker.peer_db.peers().len(), 2);
1837 }
1838
1839 #[tokio::test]
1840 async fn tka_active_rejects_unsigned_peer() {
1841 // Authority present + peer presents no signature ⇒ rejected (fail-closed), not in peer_db.
1842 let (authority, _sig) = authority_and_valid_sig();
1843 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
1844
1845 let unsigned = peer_node("unsigned", NODE_KEY_BYTES, vec![]);
1846 assert!(!tracker.tka_admits(&unsigned));
1847
1848 // Mirror the handler's `if !tka_admits { continue }` loop.
1849 if tracker.tka_admits(&unsigned) {
1850 tracker.peer_db.upsert(&unsigned);
1851 }
1852 assert_eq!(tracker.peer_db.peers().len(), 0);
1853 assert!(tracker.peer_db.get(&unsigned.node_key).is_none());
1854 }
1855
1856 #[tokio::test]
1857 async fn tka_active_rejects_unsigned_peer_api_only_peer() {
1858 // `UnsignedPeerAPIOnly` buys NO admission exemption here: Go admits such a peer unsigned
1859 // under an active lock (peerAPI-only, no network access), this fork drops it like any other
1860 // unsigned peer. Pins the documented parity gap (`docs/PARITY_ROADMAP.md`, and the
1861 // `ts_control::Node::unsigned_peer_api_only` field docs) so implementing the carve-out has
1862 // to update the prose that promises no peerAPI access today.
1863 let (authority, _sig) = authority_and_valid_sig();
1864 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
1865
1866 let mut peer_api_only = peer_node("peerapi-only", NODE_KEY_BYTES, vec![]);
1867 peer_api_only.unsigned_peer_api_only = true;
1868
1869 assert!(
1870 !tracker.tka_admits(&peer_api_only),
1871 "unsigned_peer_api_only must not exempt a peer from the tailnet-lock admission gate"
1872 );
1873
1874 // Mirror the handler's `if !tka_admits { continue }` loop: nothing reaches the peer db, so
1875 // the peer is not reachable for peerAPI either.
1876 if tracker.tka_admits(&peer_api_only) {
1877 tracker.peer_db.upsert(&peer_api_only);
1878 }
1879 assert_eq!(tracker.peer_db.peers().len(), 0);
1880 assert!(tracker.peer_db.get(&peer_api_only.node_key).is_none());
1881 }
1882
1883 #[tokio::test]
1884 async fn tka_empty_keyset_authority_admits_unsigned_peer_api_only_peer() {
1885 // The other side of `tka_active_rejects_unsigned_peer_api_only_peer`: "an authority is
1886 // present" is NOT on its own enough to drop an unsigned peer. The brick-guard fires first,
1887 // so an authority carrying no trusted keys enforces nothing and admits even the peer class
1888 // a keyed lock would reject. Pins the qualifier on the
1889 // `ts_control::Node::unsigned_peer_api_only` field docs — remove the guard and this fails.
1890 use ts_tka::{AumHash, Authority, State};
1891 let empty_auth = Authority::from_state(AumHash([0u8; 32]), State { keys: Vec::new() });
1892 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(empty_auth));
1893
1894 let mut peer_api_only = peer_node("peerapi-only", NODE_KEY_BYTES, vec![]);
1895 peer_api_only.unsigned_peer_api_only = true;
1896
1897 assert!(
1898 tracker.tka_admits(&peer_api_only),
1899 "an empty-keyset authority must not enforce, not even against an unsigned peer"
1900 );
1901
1902 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![peer_api_only.clone()]));
1903 assert!(
1904 tracker.peer_db.get(&peer_api_only.node_key).is_some(),
1905 "the peer reaches the peer db, so the gate's drop is keyset-conditional"
1906 );
1907 }
1908
1909 #[tokio::test]
1910 async fn tka_active_rejects_bad_signature() {
1911 // Authority present + a signature that fails to verify ⇒ rejected, not in peer_db.
1912 let (authority, mut sig) = authority_and_valid_sig();
1913 // Tamper the last byte (the trailing signature byte) so verification fails.
1914 let last = sig.len() - 1;
1915 sig[last] ^= 0xff;
1916
1917 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
1918 let bad = peer_node("bad", NODE_KEY_BYTES, sig);
1919 assert!(!tracker.tka_admits(&bad));
1920
1921 if tracker.tka_admits(&bad) {
1922 tracker.peer_db.upsert(&bad);
1923 }
1924 assert_eq!(tracker.peer_db.peers().len(), 0);
1925 }
1926
1927 #[tokio::test]
1928 async fn tka_active_admits_authorized_peer() {
1929 // Authority present + correctly-signed node key ⇒ admitted and upserted.
1930 let (authority, sig) = authority_and_valid_sig();
1931 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
1932
1933 let good = peer_node("good", NODE_KEY_BYTES, sig);
1934 assert!(tracker.tka_admits(&good));
1935
1936 if tracker.tka_admits(&good) {
1937 tracker.peer_db.upsert(&good);
1938 }
1939 assert_eq!(tracker.peer_db.peers().len(), 1);
1940 assert!(tracker.peer_db.get(&good.node_key).is_some());
1941 }
1942
1943 // ---------------------------------------------------------------------------------------------
1944 // Tests that drive REAL `PeerUpdate`s through the shared handler body
1945 // ([`PeerTracker::apply_peer_update`], the single source of truth the actor's netmap `handle`
1946 // also calls), so the two real upsert sites (`Full` and `Delta { upsert }`) are exercised via
1947 // the actual enforcement path — not by hand-mirroring `if !tka_admits { continue }`.
1948 // ---------------------------------------------------------------------------------------------
1949
1950 #[tokio::test]
1951 async fn tka_active_delta_upsert_rejects_unauthorized() {
1952 // Drive a real `Delta { upsert }` whose peer carries no signature. The Delta upsert site
1953 // must reject it under an active authority ⇒ not present in peer_db after the handler runs.
1954 let (authority, _sig) = authority_and_valid_sig();
1955 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
1956
1957 let unsigned = peer_node("unsigned", NODE_KEY_BYTES, vec![]);
1958 let update = ts_control::PeerUpdate::Delta {
1959 upsert: vec![unsigned.clone()],
1960 remove: Vec::new(),
1961 };
1962
1963 tracker.apply_peer_update(&update);
1964
1965 assert_eq!(tracker.peer_db.peers().len(), 0);
1966 assert!(tracker.peer_db.get(&unsigned.node_key).is_none());
1967 }
1968
1969 #[tokio::test]
1970 async fn tka_active_delta_upsert_admits_authorized() {
1971 // Drive a real `Delta { upsert }` with a correctly-signed peer ⇒ present in peer_db.
1972 let (authority, sig) = authority_and_valid_sig();
1973 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
1974
1975 let good = peer_node("good", NODE_KEY_BYTES, sig);
1976 let update = ts_control::PeerUpdate::Delta {
1977 upsert: vec![good.clone()],
1978 remove: Vec::new(),
1979 };
1980
1981 tracker.apply_peer_update(&update);
1982
1983 assert_eq!(tracker.peer_db.peers().len(), 1);
1984 assert!(tracker.peer_db.get(&good.node_key).is_some());
1985 }
1986
1987 #[tokio::test]
1988 async fn tka_active_full_admits_only_authorized_in_mixed_batch() {
1989 // Drive a real `Full` carrying a MIX of authorized + unauthorized peers. Only the
1990 // correctly-signed peer survives the Full upsert site; the unsigned and bad-sig peers are
1991 // dropped fail-closed.
1992 let (authority, sig) = authority_and_valid_sig();
1993 // A bad-sig variant of the same authorized signature (tamper the trailing byte).
1994 let mut bad_sig = sig.clone();
1995 let last = bad_sig.len() - 1;
1996 bad_sig[last] ^= 0xff;
1997
1998 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
1999
2000 // Only the authorized peer carries NODE_KEY_BYTES (the key the authority signed); the
2001 // rejected peers use distinct node keys so the survivor is unambiguous.
2002 let good = peer_node("good", NODE_KEY_BYTES, sig);
2003 let unsigned = peer_node("unsigned", [8u8; 32], vec![]);
2004 let bad = peer_node("bad", [9u8; 32], bad_sig);
2005
2006 let update =
2007 ts_control::PeerUpdate::Full(vec![good.clone(), unsigned.clone(), bad.clone()]);
2008
2009 tracker.apply_peer_update(&update);
2010
2011 assert_eq!(tracker.peer_db.peers().len(), 1);
2012 assert!(tracker.peer_db.get(&good.node_key).is_some());
2013 assert!(tracker.peer_db.get(&unsigned.node_key).is_none());
2014 assert!(tracker.peer_db.get(&bad.node_key).is_none());
2015 }
2016
2017 /// End-to-end through the REAL enforcement-authority transport (the `watch` cell the control
2018 /// runner writes), not a direct field poke: writing `Some(authority)` flips enforcement on so a
2019 /// mixed batch drops the unsigned/bad peers, and a subsequent `None` (lock disabled) clears
2020 /// enforcement so a peer DROPPED while enforced is re-admitted. Exercises the exact `borrow`-based
2021 /// read path `tka_admits` uses — a broken receiver wiring would pass every for_test-field test but
2022 /// fail here.
2023 #[tokio::test]
2024 async fn tka_authority_watch_enables_then_clears_enforcement() {
2025 let (authority, sig) = authority_and_valid_sig();
2026 let mut bad_sig = sig.clone();
2027 let last = bad_sig.len() - 1;
2028 bad_sig[last] ^= 0xff;
2029
2030 let (mut tracker, tka_tx) = PeerTracker::for_test(test_env(), None);
2031
2032 // 1) No authority yet ⇒ admit-all (Go b.tka == nil).
2033 let good = peer_node("good", NODE_KEY_BYTES, sig.clone());
2034 let unsigned = peer_node("unsigned", [8u8; 32], vec![]);
2035 let bad = peer_node("bad", [9u8; 32], bad_sig);
2036 let batch = ts_control::PeerUpdate::Full(vec![good.clone(), unsigned.clone(), bad.clone()]);
2037 tracker.apply_peer_update(&batch);
2038 assert_eq!(tracker.peer_db.peers().len(), 3, "no lock ⇒ admit all");
2039
2040 // 2) Publish the verified authority over the watch cell (exactly what the control runner does
2041 // on a successful sync) ⇒ enforcement ON. A re-applied Full now drops unsigned + bad.
2042 tka_tx.send_replace(Some(Arc::new(authority)));
2043 tracker.apply_peer_update(&batch);
2044 assert_eq!(
2045 tracker.peer_db.peers().len(),
2046 1,
2047 "lock active ⇒ only the signed peer survives"
2048 );
2049 assert!(tracker.peer_db.get(&good.node_key).is_some());
2050 assert!(tracker.peer_db.get(&unsigned.node_key).is_none());
2051 assert!(tracker.peer_db.get(&bad.node_key).is_none());
2052
2053 // 3) Lock disabled (None) ⇒ enforcement cleared ⇒ a peer that was DROPPED while enforced is
2054 // re-admitted by a fresh netmap. Assert the specific previously-dropped key returns (not
2055 // merely a count), so this proves the drop→clear→re-admit transition, not "admit-all-fresh".
2056 tka_tx.send_replace(None);
2057 tracker.apply_peer_update(&batch);
2058 assert_eq!(
2059 tracker.peer_db.peers().len(),
2060 3,
2061 "lock disabled ⇒ admit all again"
2062 );
2063 assert!(
2064 tracker.peer_db.get(&unsigned.node_key).is_some(),
2065 "the peer dropped under enforcement must come back once the lock is cleared"
2066 );
2067 assert!(tracker.peer_db.get(&bad.node_key).is_some());
2068 }
2069
2070 /// The ordering gap this closes. A peer admitted BEFORE the lock synced must be re-checked the
2071 /// moment the authority is installed — not left in the db until control happens to send another
2072 /// `Full`. Go never has this problem: `SetControlClientStatus` runs `tkaSyncIfNeeded` and then
2073 /// `tkaFilterNetmapLocked(st.NetMap)` on the SAME netmap in one pass, so the netmap that
2074 /// announced the lock is itself filtered. Here the sync is a spawned task, so the netmap lands
2075 /// first and `tka_reevaluate_peer_db` is what restores Go's ordering.
2076 ///
2077 /// Note this test applies NO second netmap: the eviction must come from the authority install
2078 /// alone, which is exactly what was missing before.
2079 #[tokio::test]
2080 async fn tka_authority_install_reevaluates_already_admitted_peers() {
2081 let (authority, sig) = authority_and_valid_sig();
2082 let mut bad_sig = sig.clone();
2083 let last = bad_sig.len() - 1;
2084 bad_sig[last] ^= 0xff;
2085
2086 let (mut tracker, tka_tx) = PeerTracker::for_test(test_env(), None);
2087
2088 // 1) A netmap arrives while nothing is synced ⇒ enforcement inactive ⇒ all three admitted.
2089 let good = peer_node("good", NODE_KEY_BYTES, sig);
2090 let unsigned = peer_node("unsigned", [8u8; 32], vec![]);
2091 let bad = peer_node("bad", [9u8; 32], bad_sig);
2092 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![
2093 good.clone(),
2094 unsigned.clone(),
2095 bad.clone(),
2096 ]));
2097 assert_eq!(tracker.peer_db.peers().len(), 3, "no lock yet ⇒ admit all");
2098 let unsigned_id = tracker
2099 .peer_db
2100 .get(&unsigned.node_key)
2101 .expect("unsigned peer admitted while no lock is synced")
2102 .0;
2103 let bad_id = tracker
2104 .peer_db
2105 .get(&bad.node_key)
2106 .expect("bad-sig peer admitted while no lock is synced")
2107 .0;
2108
2109 // 2) The sync completes and the control runner installs the verified authority.
2110 tka_tx.send_replace(Some(Arc::new(authority)));
2111 let evicted = tracker.tka_reevaluate_peer_db();
2112
2113 assert_eq!(
2114 evicted,
2115 HashSet::from_iter([unsigned_id, bad_id]),
2116 "the unsigned and bad-signature peers are the ones reported evicted"
2117 );
2118 assert_eq!(tracker.peer_db.peers().len(), 1);
2119 assert!(
2120 tracker.peer_db.get(&good.node_key).is_some(),
2121 "the authorized peer stays admitted"
2122 );
2123 assert!(tracker.peer_db.get(&unsigned.node_key).is_none());
2124 assert!(tracker.peer_db.get(&bad.node_key).is_none());
2125
2126 // 3) Idempotent: a second pass over the now-clean db evicts nobody.
2127 assert!(tracker.tka_reevaluate_peer_db().is_empty());
2128 }
2129
2130 /// With no authority the re-evaluation evicts nobody — enforcement is inactive and every peer is
2131 /// admitted, exactly Go's `b.tka == nil` early return. Covers both "never synced" and "the lock
2132 /// was disabled after enforcing", the two ways the cell holds `None`.
2133 #[tokio::test]
2134 async fn tka_reevaluate_without_authority_evicts_nothing() {
2135 let (authority, sig) = authority_and_valid_sig();
2136 let (mut tracker, tka_tx) = PeerTracker::for_test(test_env(), None);
2137
2138 let good = peer_node("good", NODE_KEY_BYTES, sig);
2139 let unsigned = peer_node("unsigned", [8u8; 32], vec![]);
2140 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![
2141 good.clone(),
2142 unsigned.clone(),
2143 ]));
2144
2145 // Never synced.
2146 assert!(tracker.tka_reevaluate_peer_db().is_empty());
2147 assert_eq!(tracker.peer_db.peers().len(), 2);
2148
2149 // Enforced, then disabled: the disable must not evict the peer the lock had authorized, and
2150 // must not start dropping the unsigned one either.
2151 tka_tx.send_replace(Some(Arc::new(authority)));
2152 assert_eq!(tracker.tka_reevaluate_peer_db().len(), 1);
2153 tka_tx.send_replace(None);
2154 assert!(tracker.tka_reevaluate_peer_db().is_empty());
2155 assert!(tracker.peer_db.get(&good.node_key).is_some());
2156 }
2157
2158 /// The re-evaluation runs the WHOLE Go `tkaFilterNetmapLocked` pass, not just the per-peer
2159 /// signature check: a peer presenting a node key that a newer rotation superseded is evicted too,
2160 /// even though its own `Direct` signature still verifies against the authority. Both peers are
2161 /// already in the db when the authority lands, so the cross-peer rotation filter has to run over
2162 /// the db contents — which is why `tka_keep_verdicts` is shared with the `Full` path rather than
2163 /// re-derived here.
2164 #[tokio::test]
2165 async fn tka_reevaluate_applies_the_cross_peer_rotation_filter() {
2166 use ed25519_dalek::SigningKey;
2167 use ts_tka::NodeKeySignature;
2168
2169 let trusted = SigningKey::from_bytes(&[42u8; 32]);
2170 let authority = Authority::from_state(
2171 AumHash([0; 32]),
2172 State {
2173 keys: vec![Key {
2174 kind: KeyKind::Ed25519,
2175 votes: 1,
2176 public: trusted.verifying_key().to_bytes().to_vec(),
2177 }],
2178 },
2179 );
2180 // `stale` holds the pivot key with a valid Direct signature; `rotated` holds a key whose
2181 // rotation chain rotated the pivot key AWAY, which obsoletes `stale`.
2182 let pivot = SigningKey::from_bytes(&[9u8; 32]);
2183 let pivot_pub: [u8; 32] = pivot.verifying_key().to_bytes();
2184 let stale = peer_node(
2185 "stale",
2186 pivot_pub,
2187 NodeKeySignature::sign_direct(&pivot_pub, &trusted).serialize(),
2188 );
2189 let new_key = [4u8; 32];
2190 let rotated = peer_node(
2191 "rotated",
2192 new_key,
2193 NodeKeySignature::sign_rotation(&new_key, &trusted, &pivot).serialize(),
2194 );
2195
2196 // Both admitted while nothing is synced.
2197 let (mut tracker, tka_tx) = PeerTracker::for_test(test_env(), None);
2198 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![
2199 stale.clone(),
2200 rotated.clone(),
2201 ]));
2202 assert_eq!(tracker.peer_db.peers().len(), 2, "no lock yet ⇒ admit all");
2203
2204 tka_tx.send_replace(Some(Arc::new(authority)));
2205 let evicted = tracker.tka_reevaluate_peer_db();
2206
2207 assert_eq!(
2208 evicted.len(),
2209 1,
2210 "only the rotation-obsolete peer is evicted"
2211 );
2212 assert!(
2213 tracker.peer_db.get(&rotated.node_key).is_some(),
2214 "the freshly-rotated peer stays"
2215 );
2216 assert!(
2217 tracker.peer_db.get(&stale.node_key).is_none(),
2218 "the peer whose key a rotation superseded is evicted, though its own signature verifies"
2219 );
2220 }
2221
2222 /// A `StateUpdate` carrying nothing but a `Full` peer set — the netmap shape the live-actor test
2223 /// publishes on the bus.
2224 fn netmap_with_peers(peers: Vec<Node>) -> ts_control::StateUpdate {
2225 ts_control::StateUpdate {
2226 session_handle: None,
2227 seq: 0,
2228 keep_alive: false,
2229 derp: None,
2230 node: None,
2231 peer_update: Some(ts_control::PeerUpdate::Full(peers)),
2232 peer_patches: Vec::new(),
2233 user_profiles: Vec::new(),
2234 ping: None,
2235 packetfilter: None,
2236 cap_grants: None,
2237 pop_browser_url: None,
2238 dial_plan: None,
2239 dns_config: None,
2240 ssh_policy: None,
2241 tka: None,
2242 online_change: Default::default(),
2243 peer_seen_change: Default::default(),
2244 }
2245 }
2246
2247 /// Poll a live [`PeerTracker`] until it holds exactly `want` peers, bounded by a timeout so a
2248 /// broken wiring fails the test instead of hanging the suite.
2249 async fn await_peer_count(tracker: &ActorRef<PeerTracker>, want: usize) -> Vec<Node> {
2250 let settled = tokio::time::timeout(std::time::Duration::from_secs(10), async {
2251 loop {
2252 let peers = tracker.ask(AllPeers).await.expect("peer tracker is alive");
2253 if peers.len() == want {
2254 return peers;
2255 }
2256 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2257 }
2258 })
2259 .await;
2260 settled.unwrap_or_else(|_| panic!("peer tracker never settled at {want} peer(s)"))
2261 }
2262
2263 /// End-to-end through the LIVE actor, which is the only thing that proves the wiring: the peer
2264 /// tracker watches its own enforcement cell, so the control runner's `send_replace` re-filters
2265 /// the peer db with no further netmap. If the watch task were never spawned (or the message not
2266 /// handled) the unsigned peer would stay admitted forever — a hole every `for_test` unit test
2267 /// above would still pass over, because they call the re-evaluation by hand.
2268 #[tokio::test]
2269 async fn tka_authority_change_refilters_through_the_live_actor() {
2270 use kameo::actor::Spawn as _;
2271
2272 let (authority, sig) = authority_and_valid_sig();
2273 let env = test_env();
2274 let (tka_tx, tka_rx) = watch::channel(None);
2275 let tracker = PeerTracker::spawn((env.clone(), tka_rx));
2276
2277 // Await one reply first: the actor's `on_start` (which registers it on the bus) has then
2278 // completed, so the netmap published below cannot race the subscription.
2279 assert!(
2280 tracker
2281 .ask(AllPeers)
2282 .await
2283 .expect("peer tracker started")
2284 .is_empty()
2285 );
2286
2287 let good = peer_node("good", NODE_KEY_BYTES, sig);
2288 let unsigned = peer_node("unsigned", [8u8; 32], vec![]);
2289 env.publish(Arc::new(netmap_with_peers(vec![
2290 good.clone(),
2291 unsigned.clone(),
2292 ])))
2293 .await
2294 .expect("publish netmap");
2295
2296 // No lock synced ⇒ both peers land.
2297 await_peer_count(&tracker, 2).await;
2298
2299 // The control runner installs the verified authority. No netmap follows.
2300 tka_tx.send_replace(Some(Arc::new(authority)));
2301
2302 let peers = await_peer_count(&tracker, 1).await;
2303 assert_eq!(
2304 peers[0].stable_id, good.stable_id,
2305 "only the authorized peer survives the authority install"
2306 );
2307 }
2308
2309 /// Degenerate input: two DISTINCT nodes sharing one `stable_id` in a single `Full`, one with a
2310 /// valid signature and one unsigned, under an active lock. Each node is judged by its OWN verdict
2311 /// (the per-node `admits` vector), so the unsigned node is never admitted on the strength of its
2312 /// signed twin. The single-verify `Full` refactor keeps this per-node semantics (a stable_id-set
2313 /// alone would have admitted whichever node was upserted last). Malformed control input; asserted
2314 /// only to lock the verdict-per-node behavior against regression.
2315 #[tokio::test]
2316 async fn tka_full_duplicate_stable_id_judges_each_node_on_its_own_signature() {
2317 let (authority, sig) = authority_and_valid_sig();
2318 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
2319
2320 // Both carry stable_id "dup"; the signed one authorizes NODE_KEY_BYTES, the other is unsigned
2321 // and uses a different node key. Order them unsigned-last so a last-writer-wins stable_id set
2322 // would (wrongly) leave the unsigned node's key in the db.
2323 let signed = peer_node("dup", NODE_KEY_BYTES, sig);
2324 let unsigned = peer_node("dup", [8u8; 32], vec![]);
2325 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![
2326 signed.clone(),
2327 unsigned.clone(),
2328 ]));
2329
2330 // The unsigned node's own verdict failed, so its key must NOT be present, regardless of the
2331 // shared stable_id. (The signed twin retained the stable_id; the db holds the signed key.)
2332 assert!(
2333 tracker.peer_db.get(&unsigned.node_key).is_none(),
2334 "a node whose own signature fails must not be admitted via a stable_id twin"
2335 );
2336 assert!(tracker.peer_db.get(&signed.node_key).is_some());
2337 }
2338
2339 /// Full-path consistency under two KEPT nodes sharing a `stable_id`: `peer_db.upsert` is
2340 /// last-writer-wins on `stable_id`, so the db ends holding exactly one node for that id (the last
2341 /// kept), and `retain` never evicts that just-upserted id (`retained_ids` contains the shared id
2342 /// because at least one of its nodes was kept). No lock here, so both nodes are "kept". This pins
2343 /// the published-state invariant the whole-surface audit flagged: `retain` and the upsert loop
2344 /// agree on the surviving stable_id. Malformed control input; asserted for robustness.
2345 #[tokio::test]
2346 async fn tka_full_duplicate_stable_id_both_kept_is_consistent() {
2347 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
2348 let first = peer_node("dup", [1u8; 32], vec![]);
2349 let last = peer_node("dup", [2u8; 32], vec![]);
2350 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![
2351 first.clone(),
2352 last.clone(),
2353 ]));
2354
2355 // Exactly one db entry for the shared stable_id, holding the LAST node (upsert is
2356 // last-writer-wins on stable_id); the first node's key was transparently superseded.
2357 assert_eq!(
2358 tracker.peer_db.peers().len(),
2359 1,
2360 "one entry for the shared stable_id"
2361 );
2362 assert!(
2363 tracker.peer_db.get(&last.node_key).is_some(),
2364 "the db holds the last-upserted node for the shared id"
2365 );
2366 assert!(
2367 tracker.peer_db.get(&first.node_key).is_none(),
2368 "the first node's key was superseded by the last at the shared id"
2369 );
2370 }
2371
2372 /// A peer admitted in one `Full`, then in a later `Full` presenting a key that a co-resident
2373 /// peer's rotation chain has rotated away, is EVICTED — the cross-peer rotation filter applies on
2374 /// every resync, not only at first admission. Exercises the rotation filter through two
2375 /// sequential `Full` updates with real signing.
2376 #[tokio::test]
2377 async fn tka_full_rotation_obsolete_evicts_on_resync() {
2378 use ed25519_dalek::SigningKey;
2379 use ts_tka::NodeKeySignature;
2380
2381 let trusted = SigningKey::from_bytes(&[42u8; 32]);
2382 let trusted_pub = trusted.verifying_key().to_bytes().to_vec();
2383 let authority = Authority::from_state(
2384 AumHash([0; 32]),
2385 State {
2386 keys: vec![Key {
2387 kind: KeyKind::Ed25519,
2388 votes: 1,
2389 public: trusted_pub.clone(),
2390 }],
2391 },
2392 );
2393 let pivot = SigningKey::from_bytes(&[9u8; 32]);
2394 let pivot_pub: [u8; 32] = pivot.verifying_key().to_bytes();
2395
2396 // First Full: the soon-to-be-stale peer presents the pivot key with a valid Direct sig.
2397 let stale_sig = NodeKeySignature::sign_direct(&pivot_pub, &trusted).serialize();
2398 let stale_peer = peer_node("stale", pivot_pub, stale_sig);
2399 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
2400 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![stale_peer.clone()]));
2401 assert!(
2402 tracker.peer_db.get(&stale_peer.node_key).is_some(),
2403 "the stale peer is admitted while no rotation has superseded it yet"
2404 );
2405
2406 // Second Full: a freshly-rotated peer (whose chain rotated AWAY the pivot key) joins, and the
2407 // stale peer is re-included. The rotation filter now obsoletes the pivot key ⇒ stale evicted.
2408 let new_key = [4u8; 32];
2409 let new_sig = NodeKeySignature::sign_rotation(&new_key, &trusted, &pivot).serialize();
2410 let new_peer = peer_node("rotated", new_key, new_sig);
2411 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![
2412 new_peer.clone(),
2413 stale_peer.clone(),
2414 ]));
2415 assert!(
2416 tracker.peer_db.get(&new_peer.node_key).is_some(),
2417 "the freshly-rotated peer is admitted"
2418 );
2419 assert!(
2420 tracker.peer_db.get(&stale_peer.node_key).is_none(),
2421 "the stale peer is EVICTED on the resync once a rotation supersedes its key"
2422 );
2423 }
2424
2425 /// The empty-trusted-key-state brick-guard: an authority with no keys must NOT drop the whole
2426 /// netmap (a `ts_tka` invariant violation / replayer edge). A verified chain always carries ≥1
2427 /// key, so this never weakens a genuine lock — it only prevents a black-hole. Uses ≥2 peers
2428 /// (one signed, one unsigned) to prove it admits **all**, not accidentally just one.
2429 #[tokio::test]
2430 async fn tka_empty_keyset_authority_admits_all() {
2431 use ts_tka::{AumHash, Authority, State};
2432 let empty_auth = Authority::from_state(AumHash([0u8; 32]), State { keys: Vec::new() });
2433 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(empty_auth));
2434 let signed = peer_node("signed", [7u8; 32], vec![0xde, 0xad]);
2435 let unsigned = peer_node("unsigned", [8u8; 32], vec![]);
2436 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![
2437 signed.clone(),
2438 unsigned.clone(),
2439 ]));
2440 assert_eq!(
2441 tracker.peer_db.peers().len(),
2442 2,
2443 "an empty-keyset authority must admit ALL peers (brick-guard), not enforce"
2444 );
2445 }
2446
2447 /// Signature-replay / `NodeKeyMismatch`: a structurally-valid signature that authorizes
2448 /// `NODE_KEY_BYTES` must NOT admit a DIFFERENT node key carrying that same signature blob. This is
2449 /// the highest-value bypass — if the sig↔node-key binding in `verify_signature` were dropped, this
2450 /// is the only test that would catch it (the other "bad" peers only flip a byte ⇒ `BadSignature`).
2451 #[tokio::test]
2452 async fn tka_active_rejects_valid_sig_for_wrong_node_key() {
2453 let (authority, sig) = authority_and_valid_sig();
2454 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
2455
2456 // The signature authorizes NODE_KEY_BYTES; attach it to an imposter with a different key.
2457 let imposter = peer_node("imposter", [0x55u8; 32], sig);
2458 assert!(
2459 !tracker.tka_admits(&imposter),
2460 "a signature bound to one node key must not authorize a different node key"
2461 );
2462 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![imposter.clone()]));
2463 assert!(tracker.peer_db.get(&imposter.node_key).is_none());
2464 }
2465
2466 /// `UntrustedKey`: a signature produced by a well-formed Ed25519 key that is NOT in the
2467 /// authority's trusted-key state must be rejected — distinct from a tampered-byte `BadSignature`.
2468 #[tokio::test]
2469 async fn tka_active_rejects_sig_from_untrusted_key() {
2470 use ed25519_dalek::{Signer, SigningKey};
2471 let (authority, _sig) = authority_and_valid_sig();
2472 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
2473
2474 // Sign a valid CBOR with a DIFFERENT key (not the one the authority trusts). The key_id in
2475 // the signature names this untrusted key, so `get_key` misses ⇒ UntrustedKey.
2476 let rogue = SigningKey::from_bytes(&[99u8; 32]);
2477 let rogue_pub = rogue.verifying_key().to_bytes().to_vec();
2478 let preimage = direct_sig_cbor(&NODE_KEY_BYTES, &rogue_pub, None);
2479 let sig_hash = ts_tka::aum_hash(&preimage).0;
2480 let signature = rogue.sign(&sig_hash).to_bytes().to_vec();
2481 let rogue_cbor = direct_sig_cbor(&NODE_KEY_BYTES, &rogue_pub, Some(&signature));
2482
2483 let peer = peer_node("rogue-signed", NODE_KEY_BYTES, rogue_cbor);
2484 assert!(
2485 !tracker.tka_admits(&peer),
2486 "a signature from a key outside the trusted set must be rejected"
2487 );
2488 // Drive the real upsert path too (match the sibling replay test's depth): an untrusted-key
2489 // signature must keep the peer out of the db, not merely fail the verdict in isolation.
2490 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![peer.clone()]));
2491 assert!(tracker.peer_db.get(&peer.node_key).is_none());
2492 }
2493
2494 /// Bus-enable analogue for `Delta`: enforcement engaged via the watch cell must also gate a
2495 /// `Delta { upsert }` (not only `Full`). Closes the "authority arrived over the transport AND the
2496 /// next update is a Delta" combination.
2497 #[tokio::test]
2498 async fn tka_watch_enable_enforces_delta_upsert() {
2499 let (authority, sig) = authority_and_valid_sig();
2500 let (mut tracker, tka_tx) = PeerTracker::for_test(test_env(), None);
2501 tka_tx.send_replace(Some(Arc::new(authority)));
2502
2503 let good = peer_node("good", NODE_KEY_BYTES, sig);
2504 let unsigned = peer_node("unsigned", [8u8; 32], vec![]);
2505 tracker.apply_peer_update(&ts_control::PeerUpdate::Delta {
2506 remove: vec![],
2507 upsert: vec![good.clone(), unsigned.clone()],
2508 });
2509 assert!(tracker.peer_db.get(&good.node_key).is_some());
2510 assert!(
2511 tracker.peer_db.get(&unsigned.node_key).is_none(),
2512 "delta upsert under an active lock must drop the unsigned peer"
2513 );
2514 }
2515
2516 /// A `Delta` re-upsert of an ALREADY-ADMITTED peer whose signature is now invalid must EVICT the
2517 /// stale entry (revocation-via-delta), not leave it admitted. Go re-filters the whole netmap each
2518 /// response, so a now-unsigned peer would not survive there either.
2519 #[tokio::test]
2520 async fn tka_delta_reupsert_with_invalid_sig_evicts_existing() {
2521 let (authority, sig) = authority_and_valid_sig();
2522 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
2523
2524 // Admit the signed peer.
2525 let good = peer_node("good", NODE_KEY_BYTES, sig.clone());
2526 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![good.clone()]));
2527 assert!(tracker.peer_db.get(&good.node_key).is_some());
2528
2529 // Re-upsert the SAME stable_id (now with no signature) via a delta ⇒ evicted, not retained.
2530 let revoked = peer_node("good", NODE_KEY_BYTES, vec![]);
2531 tracker.apply_peer_update(&ts_control::PeerUpdate::Delta {
2532 remove: vec![],
2533 upsert: vec![revoked],
2534 });
2535 assert!(
2536 tracker.peer_db.get(&good.node_key).is_none(),
2537 "a delta re-upsert that fails the lock must evict the previously-admitted peer"
2538 );
2539 }
2540
2541 #[tokio::test]
2542 async fn tka_full_resync_revocation_behavior() {
2543 // Revocation-on-resync: admit a peer, then re-include the SAME stable_id in a `Full` with a
2544 // now-invalid signature. Per the Logic review finding, the pre-fix `retain` kept the stale
2545 // (previously-admitted) entry because membership was decided purely by stable_id.
2546 //
2547 // FIXED (not merely documented): the `Full` `retain` now keys on `tka_admits`-passing
2548 // stable_ids, so a peer whose re-included signature no longer verifies under the active
2549 // authority is EVICTED. This test asserts eviction. The inactive (authority=None) path is
2550 // provably unchanged — `tka_admits` always returns `true` there, so the retained set equals
2551 // the set of re-included stable_ids exactly (see `tka_inactive_full_resync_keeps_*`).
2552 let (authority, sig) = authority_and_valid_sig();
2553 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
2554
2555 // 1) Admit the peer with a valid signature via a real `Full`.
2556 let good = peer_node("revoked", NODE_KEY_BYTES, sig.clone());
2557 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![good.clone()]));
2558 assert_eq!(tracker.peer_db.peers().len(), 1);
2559 assert!(tracker.peer_db.get(&good.node_key).is_some());
2560
2561 // 2) Re-sync the SAME stable_id, but with a now-invalid signature (tamper trailing byte).
2562 let mut bad_sig = sig;
2563 let last = bad_sig.len() - 1;
2564 bad_sig[last] ^= 0xff;
2565 let revoked = peer_node("revoked", NODE_KEY_BYTES, bad_sig);
2566 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![revoked.clone()]));
2567
2568 // Eviction: the stale entry is dropped because its re-included signature fails the gate.
2569 assert_eq!(tracker.peer_db.peers().len(), 0);
2570 assert!(tracker.peer_db.get(&revoked.node_key).is_none());
2571 }
2572
2573 #[tokio::test]
2574 async fn tka_inactive_full_resync_keeps_reincluded_peer() {
2575 // Guard the inactive (authority=None) path against the revocation fix: with no authority,
2576 // a peer re-included in a `Full` survives regardless of its signature bytes — byte-for-byte
2577 // pre-TKA behavior, proving the `Full` `retain` change does not regress the always-taken
2578 // branch this wave.
2579 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
2580
2581 let peer = peer_node("p", NODE_KEY_BYTES, vec![0xde, 0xad]);
2582 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![peer.clone()]));
2583 assert_eq!(tracker.peer_db.peers().len(), 1);
2584
2585 // Re-sync the same stable_id with garbage signature bytes; inactive enforcement keeps it.
2586 let resynced = peer_node("p", NODE_KEY_BYTES, vec![0x00]);
2587 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![resynced.clone()]));
2588 assert_eq!(tracker.peer_db.peers().len(), 1);
2589 assert!(tracker.peer_db.get(&resynced.node_key).is_some());
2590 }
2591
2592 /// A `Patch` for a peer already in the netmap merges only the fields it carries — here new UDP
2593 /// endpoints and a new home DERP — leaving the rest of the node intact. This is the fix for
2594 /// dropped `peers_changed_patch`: without it the netmap keeps stale endpoints and the peer can
2595 /// never re-handshake after it moves.
2596 #[tokio::test]
2597 async fn patch_merges_endpoints_and_derp_into_existing_peer() {
2598 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
2599
2600 // Seed a peer (id == 1, per `peer_node`) with no endpoints / no DERP.
2601 let peer = peer_node("mover", [1u8; 32], vec![]);
2602 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![peer.clone()]));
2603 let (_pid, before) = tracker.peer_db.get(&(1 as ts_control::NodeId)).unwrap();
2604 assert!(before.underlay_addresses.is_empty());
2605 assert!(before.derp_region.is_none());
2606
2607 // Patch in fresh reachability (the idle-peer-reconnect case).
2608 let new_ep: std::net::SocketAddr = "203.0.113.7:41641".parse().unwrap();
2609 let patch = ts_control::PeerChange {
2610 id: 1,
2611 derp_region: Some(ts_derp::RegionId(core::num::NonZeroU32::new(5).unwrap())),
2612 cap: None,
2613 cap_map: None,
2614 underlay_addresses: Some(vec![new_ep]),
2615 node_key: None,
2616 key_signature: None,
2617 disco_key: None,
2618 node_key_expiry: None,
2619 online: None,
2620 last_seen: None,
2621 };
2622 let (upserts, deletions) = tracker.apply_peer_patches(std::slice::from_ref(&patch));
2623
2624 assert_eq!(upserts.len(), 1);
2625 assert_eq!(deletions.len(), 0);
2626 // Same peer, now carrying the patched endpoint + DERP; node key untouched.
2627 assert_eq!(tracker.peer_db.peers().len(), 1);
2628 let (_pid, after) = tracker.peer_db.get(&(1 as ts_control::NodeId)).unwrap();
2629 assert_eq!(after.underlay_addresses, vec![new_ep]);
2630 assert_eq!(
2631 after.derp_region,
2632 Some(ts_derp::RegionId(core::num::NonZeroU32::new(5).unwrap()))
2633 );
2634 assert_eq!(after.node_key, peer.node_key);
2635 }
2636
2637 /// Regression for `tsr-5u0`: when a whole-node set (`Delta`/`Full`) and a patch co-occur in one
2638 /// response, the patch is applied *on top of* the node the set just upserted — mirroring the
2639 /// handler's apply-order (peer set first, then `peer_patches`). Before the fix the patch shared
2640 /// the single `peer_update` slot and the co-occurring set silently dropped it, so a peer brought
2641 /// in by the delta kept stale (empty) reachability.
2642 #[tokio::test]
2643 async fn patch_applies_on_top_of_co_occurring_delta() {
2644 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
2645
2646 // The whole-node delta upserts a brand-new peer (id == 1) with no reachability.
2647 let peer = peer_node("mover", [1u8; 32], vec![]);
2648 let (set_upserts, _) = tracker.apply_peer_update(&ts_control::PeerUpdate::Delta {
2649 upsert: vec![peer.clone()],
2650 remove: vec![],
2651 });
2652 assert_eq!(set_upserts.len(), 1, "delta upserts the new peer");
2653
2654 // The patch from the SAME response then sets that peer's endpoints + DERP. This is exactly
2655 // the consumer order the handler runs (apply_peer_update then apply_peer_patches).
2656 let new_ep: std::net::SocketAddr = "203.0.113.7:41641".parse().unwrap();
2657 let patch = ts_control::PeerChange {
2658 id: 1,
2659 derp_region: Some(ts_derp::RegionId(core::num::NonZeroU32::new(7).unwrap())),
2660 cap: None,
2661 cap_map: None,
2662 underlay_addresses: Some(vec![new_ep]),
2663 node_key: None,
2664 key_signature: None,
2665 disco_key: None,
2666 node_key_expiry: None,
2667 online: None,
2668 last_seen: None,
2669 };
2670 let (patch_upserts, patch_deletions) =
2671 tracker.apply_peer_patches(std::slice::from_ref(&patch));
2672
2673 assert_eq!(
2674 patch_upserts.len(),
2675 1,
2676 "patch re-upserts the just-added peer"
2677 );
2678 assert_eq!(patch_deletions.len(), 0);
2679 // The peer added by the delta now carries the patched reachability — the patch was NOT lost.
2680 let (_pid, after) = tracker.peer_db.get(&(1 as ts_control::NodeId)).unwrap();
2681 assert_eq!(after.underlay_addresses, vec![new_ep]);
2682 assert_eq!(
2683 after.derp_region,
2684 Some(ts_derp::RegionId(core::num::NonZeroU32::new(7).unwrap()))
2685 );
2686 }
2687
2688 /// A `Patch` whose node id is not in the current netmap is ignored (the wire contract: a patch
2689 /// never creates a node). No upsert, no deletion, peer set unchanged.
2690 #[tokio::test]
2691 async fn patch_for_unknown_node_is_ignored() {
2692 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
2693 let known = peer_node("known", [1u8; 32], vec![]); // id == 1
2694 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![known]));
2695
2696 let patch = ts_control::PeerChange {
2697 id: 999, // not in the netmap
2698 derp_region: None,
2699 cap: None,
2700 cap_map: None,
2701 underlay_addresses: Some(vec!["198.51.100.9:1".parse().unwrap()]),
2702 node_key: None,
2703 key_signature: None,
2704 disco_key: None,
2705 node_key_expiry: None,
2706 online: None,
2707 last_seen: None,
2708 };
2709 let (upserts, deletions) = tracker.apply_peer_patches(std::slice::from_ref(&patch));
2710
2711 assert_eq!(upserts.len(), 0);
2712 assert_eq!(deletions.len(), 0);
2713 assert_eq!(tracker.peer_db.peers().len(), 1);
2714 assert!(tracker.peer_db.get(&(999 as ts_control::NodeId)).is_none());
2715 }
2716
2717 /// An expiry-only `Patch` updates `node_key_expiry` on the matching peer (Go
2718 /// `PeerChange.KeyExpiry`), rather than being silently dropped until the next full resync.
2719 #[tokio::test]
2720 async fn patch_updates_node_key_expiry() {
2721 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
2722 let peer = peer_node("expiring", [1u8; 32], vec![]); // id == 1, node_key_expiry: None
2723 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![peer]));
2724
2725 let expiry = "2027-01-01T00:00:00Z"
2726 .parse::<chrono::DateTime<chrono::Utc>>()
2727 .unwrap();
2728 let patch = ts_control::PeerChange {
2729 id: 1,
2730 derp_region: None,
2731 cap: None,
2732 cap_map: None,
2733 underlay_addresses: None,
2734 node_key: None,
2735 key_signature: None,
2736 disco_key: None,
2737 node_key_expiry: Some(expiry),
2738 online: None,
2739 last_seen: None,
2740 };
2741 tracker.apply_peer_patches(std::slice::from_ref(&patch));
2742
2743 let (_pid, after) = tracker.peer_db.get(&(1 as ts_control::NodeId)).unwrap();
2744 assert_eq!(after.node_key_expiry, Some(expiry));
2745 }
2746
2747 /// Channel B: a `PeerChange.online` patch flips a peer's online state without a full node.
2748 #[tokio::test]
2749 async fn patch_updates_online() {
2750 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
2751 let peer = peer_node("p", [1u8; 32], vec![]); // id == 1, online: None
2752 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![peer]));
2753 assert_eq!(
2754 tracker
2755 .peer_db
2756 .get(&(1 as ts_control::NodeId))
2757 .unwrap()
2758 .1
2759 .online,
2760 None
2761 );
2762
2763 let mut patch = ts_control::PeerChange {
2764 id: 1,
2765 derp_region: None,
2766 cap: None,
2767 cap_map: None,
2768 underlay_addresses: None,
2769 node_key: None,
2770 key_signature: None,
2771 disco_key: None,
2772 node_key_expiry: None,
2773 online: Some(true),
2774 last_seen: None,
2775 };
2776 tracker.apply_peer_patches(std::slice::from_ref(&patch));
2777 assert_eq!(
2778 tracker
2779 .peer_db
2780 .get(&(1 as ts_control::NodeId))
2781 .unwrap()
2782 .1
2783 .online,
2784 Some(true),
2785 "PeerChange.online=Some(true) marks the peer online"
2786 );
2787
2788 // A subsequent patch flips it offline.
2789 patch.online = Some(false);
2790 tracker.apply_peer_patches(std::slice::from_ref(&patch));
2791 assert_eq!(
2792 tracker
2793 .peer_db
2794 .get(&(1 as ts_control::NodeId))
2795 .unwrap()
2796 .1
2797 .online,
2798 Some(false)
2799 );
2800 }
2801
2802 /// Channel C/D (Go `map.go:updatePeersStateFromResponse`): `online_change` is the sole driver of
2803 /// `online`; `peer_seen_change` is the sole driver of `last_seen` (true ⇒ now, false ⇒ cleared)
2804 /// and must NEVER touch `online`. Both apply to a peer already in the netmap and ignore unknown
2805 /// ids. This pins the fix for the prior bug where channel D wrote `online=false` (conflating
2806 /// "not seen recently" with "offline" — distinct signals in Go).
2807 #[tokio::test]
2808 async fn liveness_change_maps_apply_online() {
2809 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
2810 let peer = peer_node("p", [1u8; 32], vec![]); // id == 1
2811 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![peer]));
2812 // A fixed timestamp (chrono is built without its `clock` feature, so no `Utc::now()`).
2813 let now = chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap();
2814
2815 // Channel C: online_change sets online=true.
2816 let mut online_change = std::collections::BTreeMap::new();
2817 online_change.insert(1 as ts_control::NodeId, true);
2818 online_change.insert(999 as ts_control::NodeId, true); // unknown id — ignored
2819 let changed = tracker.apply_liveness_changes(&online_change, &Default::default(), now);
2820 assert!(changed);
2821 assert_eq!(
2822 tracker
2823 .peer_db
2824 .get(&(1 as ts_control::NodeId))
2825 .unwrap()
2826 .1
2827 .online,
2828 Some(true)
2829 );
2830
2831 // Channel D: peer_seen_change=true sets last_seen=now and leaves online UNTOUCHED.
2832 let mut seen_true = std::collections::BTreeMap::new();
2833 seen_true.insert(1 as ts_control::NodeId, true);
2834 let changed = tracker.apply_liveness_changes(&Default::default(), &seen_true, now);
2835 assert!(changed);
2836 {
2837 let (_id, node) = tracker.peer_db.get(&(1 as ts_control::NodeId)).unwrap();
2838 assert_eq!(
2839 node.last_seen,
2840 Some(now),
2841 "peer_seen_change=true sets last_seen=now"
2842 );
2843 assert_eq!(
2844 node.online,
2845 Some(true),
2846 "channel D must NOT touch online (still true from channel C)"
2847 );
2848 }
2849
2850 // Channel D: peer_seen_change=false clears last_seen, still leaving online untouched.
2851 let mut seen_false = std::collections::BTreeMap::new();
2852 seen_false.insert(1 as ts_control::NodeId, false);
2853 let changed = tracker.apply_liveness_changes(&Default::default(), &seen_false, now);
2854 assert!(changed);
2855 {
2856 let (_id, node) = tracker.peer_db.get(&(1 as ts_control::NodeId)).unwrap();
2857 assert_eq!(
2858 node.last_seen, None,
2859 "peer_seen_change=false clears last_seen"
2860 );
2861 assert_eq!(node.online, Some(true), "channel D must NOT mark offline");
2862 }
2863 assert_eq!(
2864 tracker.peer_db.peers().len(),
2865 1,
2866 "the node is retained, not removed"
2867 );
2868
2869 // No-op when nothing matches / changes.
2870 assert!(!tracker.apply_liveness_changes(&Default::default(), &Default::default(), now));
2871 }
2872
2873 /// Security: a `Patch` that rotates the node key must re-satisfy the tailnet-lock authority,
2874 /// exactly like a `Delta` upsert. A key-rotation patch whose new signature does NOT verify
2875 /// evicts the peer (fail-closed) rather than leaving a now-unverified entry — closing what would
2876 /// otherwise be a trust-enforcement bypass via the patch path.
2877 #[tokio::test]
2878 async fn patch_key_rotation_failing_tka_evicts_peer() {
2879 let (authority, sig) = authority_and_valid_sig();
2880 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
2881
2882 // Admit a correctly-signed peer (id == 1).
2883 let good = peer_node("rotator", NODE_KEY_BYTES, sig.clone());
2884 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![good.clone()]));
2885 assert_eq!(tracker.peer_db.peers().len(), 1);
2886
2887 // Patch a new node key whose signature is garbage under the active authority.
2888 let patch = ts_control::PeerChange {
2889 id: 1,
2890 derp_region: None,
2891 cap: None,
2892 cap_map: None,
2893 underlay_addresses: None,
2894 node_key: Some([0x33u8; 32].into()),
2895 key_signature: Some(vec![0x00, 0x01, 0x02]),
2896 disco_key: None,
2897 node_key_expiry: None,
2898 online: None,
2899 last_seen: None,
2900 };
2901 let (upserts, deletions) = tracker.apply_peer_patches(std::slice::from_ref(&patch));
2902
2903 assert_eq!(upserts.len(), 0);
2904 assert_eq!(deletions.len(), 1);
2905 assert_eq!(tracker.peer_db.peers().len(), 0);
2906 }
2907
2908 /// A node's `user_id` joins against the accumulated UserProfiles table to resolve the owning
2909 /// user's login name in `WhoIs.user`. With no matching profile, `user` is `None` (the
2910 /// pre-existing behavior); once a profile arrives, the same node resolves to its login. This
2911 /// proves the accumulate-then-join path the netmap handler builds.
2912 fn profile(id: ts_control::UserId, login: &str) -> ts_control::UserProfile {
2913 ts_control::UserProfile {
2914 id,
2915 login_name: login.to_string(),
2916 display_name: None,
2917 }
2918 }
2919
2920 #[tokio::test]
2921 async fn whois_resolves_user_from_accumulated_profiles() {
2922 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
2923
2924 // A peer owned by user id 42 at 100.64.0.1 (the peer_node fixture's address).
2925 let mut peer = peer_node("p", NODE_KEY_BYTES, Vec::new());
2926 peer.user_id = 42;
2927 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![peer]));
2928 let addr = "100.64.0.1:0".parse().unwrap();
2929
2930 // No profile yet: the node resolves but its owner is unknown.
2931 let who = tracker.whois_opt(addr).expect("peer is known");
2932 assert_eq!(who.user, None);
2933
2934 // Profile for a DIFFERENT user must not match.
2935 tracker
2936 .user_profiles
2937 .insert(7, profile(7, "someone-else@example.com"));
2938 assert_eq!(tracker.whois_opt(addr).unwrap().user, None);
2939
2940 // The owning user's profile arrives (as the netmap handler would accumulate it): now the
2941 // login resolves.
2942 tracker
2943 .user_profiles
2944 .insert(42, profile(42, "alice@example.com"));
2945 assert_eq!(
2946 tracker.whois_opt(addr).unwrap().user,
2947 Some("alice@example.com".to_string())
2948 );
2949 }
2950
2951 /// `UserProfile::best_label` prefers the login name, falling back to display name, else `None`.
2952 #[test]
2953 fn user_profile_best_label_prefers_login() {
2954 assert_eq!(
2955 profile(1, "alice@example.com").best_label(),
2956 Some("alice@example.com".to_string())
2957 );
2958 let display_only = ts_control::UserProfile {
2959 id: 2,
2960 login_name: String::new(),
2961 display_name: Some("Bob".to_string()),
2962 };
2963 assert_eq!(display_only.best_label(), Some("Bob".to_string()));
2964 let empty = ts_control::UserProfile {
2965 id: 3,
2966 login_name: String::new(),
2967 display_name: None,
2968 };
2969 assert_eq!(empty.best_label(), None);
2970 }
2971
2972 // ----- tsr-jo1: RotationTracker (Go ipnlocal.rotationTracker.obsoleteKeys) -----
2973
2974 /// A `RotationDetails` for a `Direct`-rooted chain with the given prior keys + wrapping key.
2975 fn rot_details(
2976 prev: &[&[u8]],
2977 wrapping: &[u8],
2978 kind: ts_tka::SigKind,
2979 ) -> ts_tka::RotationDetails {
2980 ts_tka::RotationDetails {
2981 prev_node_keys: prev.iter().map(|p| p.to_vec()).collect(),
2982 initial_sig_kind: kind,
2983 initial_wrapping_pubkey: wrapping.to_vec(),
2984 }
2985 }
2986
2987 /// Rule 1: every prior node key named by any rotation chain is obsolete, regardless of the
2988 /// chain's root kind (Go's ungated `obsolete.AddSlice(d.PrevNodeKeys)`).
2989 #[test]
2990 fn rotation_tracker_prev_keys_always_obsolete() {
2991 let mut t = RotationTracker::default();
2992 // A Direct-rooted chain that rotated away OLD1, and a Credential-rooted one that rotated OLD2.
2993 t.add(
2994 b"newA".to_vec(),
2995 &rot_details(&[b"OLD1"], b"wrapA", ts_tka::SigKind::Direct),
2996 );
2997 t.add(
2998 b"newB".to_vec(),
2999 &rot_details(&[b"OLD2"], b"wrapB", ts_tka::SigKind::Credential),
3000 );
3001 let obsolete = t.obsolete_keys();
3002 assert!(
3003 obsolete.contains(b"OLD1".as_slice()),
3004 "Direct chain's prior key obsolete"
3005 );
3006 assert!(
3007 obsolete.contains(b"OLD2".as_slice()),
3008 "Credential chain's prior key obsolete too (rule 1 is ungated)"
3009 );
3010 // The current keys themselves are not obsolete (only one peer per wrapping key here).
3011 assert!(!obsolete.contains(b"newA".as_slice()));
3012 assert!(!obsolete.contains(b"newB".as_slice()));
3013 }
3014
3015 /// Rule 2: among `Direct`-rooted chains sharing a wrapping key, only the longest survives; the
3016 /// shorter (older) clone's key is obsolete.
3017 #[test]
3018 fn rotation_tracker_unequal_chain_keeps_longest() {
3019 let mut t = RotationTracker::default();
3020 // Same wrapping key; "long" has 2 prior keys, "short" has 1 ⇒ "short" is the older clone.
3021 t.add(
3022 b"long".to_vec(),
3023 &rot_details(&[b"p1", b"p2"], b"wrap", ts_tka::SigKind::Direct),
3024 );
3025 t.add(
3026 b"short".to_vec(),
3027 &rot_details(&[b"q1"], b"wrap", ts_tka::SigKind::Direct),
3028 );
3029 let obsolete = t.obsolete_keys();
3030 assert!(
3031 obsolete.contains(b"short".as_slice()),
3032 "the shorter-chain clone is obsolete"
3033 );
3034 assert!(
3035 !obsolete.contains(b"long".as_slice()),
3036 "the longest-chain peer survives"
3037 );
3038 }
3039
3040 /// Rule 2 tie: two `Direct`-rooted chains sharing a wrapping key with EQUAL chain length cannot
3041 /// be disambiguated ⇒ BOTH are dropped (Go's safety branch).
3042 #[test]
3043 fn rotation_tracker_equal_chain_drops_both() {
3044 let mut t = RotationTracker::default();
3045 t.add(
3046 b"cloneA".to_vec(),
3047 &rot_details(&[b"p1"], b"wrap", ts_tka::SigKind::Direct),
3048 );
3049 t.add(
3050 b"cloneB".to_vec(),
3051 &rot_details(&[b"p2"], b"wrap", ts_tka::SigKind::Direct),
3052 );
3053 let obsolete = t.obsolete_keys();
3054 assert!(
3055 obsolete.contains(b"cloneA".as_slice()),
3056 "tied clone A dropped"
3057 );
3058 assert!(
3059 obsolete.contains(b"cloneB".as_slice()),
3060 "tied clone B dropped"
3061 );
3062 }
3063
3064 /// `Credential`-rooted chains sharing a wrapping key are EXEMPT from rule 2 (reusable-authkey
3065 /// carve-out): both are kept even with equal chain length.
3066 #[test]
3067 fn rotation_tracker_credential_root_clones_both_kept() {
3068 let mut t = RotationTracker::default();
3069 t.add(
3070 b"credA".to_vec(),
3071 &rot_details(&[b"p1"], b"wrap", ts_tka::SigKind::Credential),
3072 );
3073 t.add(
3074 b"credB".to_vec(),
3075 &rot_details(&[b"p2"], b"wrap", ts_tka::SigKind::Credential),
3076 );
3077 let obsolete = t.obsolete_keys();
3078 assert!(
3079 !obsolete.contains(b"credA".as_slice()),
3080 "credential-rooted clone A kept"
3081 );
3082 assert!(
3083 !obsolete.contains(b"credB".as_slice()),
3084 "credential-rooted clone B kept"
3085 );
3086 }
3087
3088 /// A peer that another chain already rotated away does not also act as a surviving clone: it is
3089 /// removed from its wrapping-key group before the longest-survivor pick (Go's `DeleteFunc`).
3090 #[test]
3091 fn rotation_tracker_already_obsolete_peer_not_a_survivor() {
3092 let mut t = RotationTracker::default();
3093 // "victim" is rotated away by "rotator" (different wrapping key), AND shares wrapping key
3094 // "w" with "other". Because "victim" is already obsolete, only "other" is in play for "w" and
3095 // survives (no spurious tie-drop of "other").
3096 t.add(
3097 b"rotator".to_vec(),
3098 &rot_details(&[b"victim"], b"wRot", ts_tka::SigKind::Direct),
3099 );
3100 t.add(
3101 b"victim".to_vec(),
3102 &rot_details(&[b"x"], b"w", ts_tka::SigKind::Direct),
3103 );
3104 t.add(
3105 b"other".to_vec(),
3106 &rot_details(&[b"y"], b"w", ts_tka::SigKind::Direct),
3107 );
3108 let obsolete = t.obsolete_keys();
3109 assert!(
3110 obsolete.contains(b"victim".as_slice()),
3111 "victim rotated away by rotator"
3112 );
3113 assert!(
3114 !obsolete.contains(b"other".as_slice()),
3115 "other survives — victim was removed from the group before the tie check"
3116 );
3117 }
3118
3119 /// Empty tracker (no rotation-signed peers) ⇒ no obsolete keys (the non-rotation netmap path).
3120 #[test]
3121 fn rotation_tracker_empty_is_noop() {
3122 let t = RotationTracker::default();
3123 assert!(t.obsolete_keys().is_empty());
3124 }
3125
3126 /// End-to-end through the real `Full` path: a peer presenting a freshly-rotated key (a Rotation
3127 /// chain) is admitted, while a second peer still presenting the rotated-AWAY pivot key — even with
3128 /// that key's own still-valid Direct signature — is DROPPED by the cross-peer rotation filter.
3129 /// This is the gap closed here: Go `tkaFilterNetmapLocked` drops the stale clone; we used to admit
3130 /// it. Uses real `ts_tka` signing (`sign_direct` + `sign_rotation`) so the whole
3131 /// verify → details → filter pipeline runs.
3132 ///
3133 /// Construction: the trusted key signs an inner `Direct` over the PIVOT keypair's public key; the
3134 /// pivot key then signs an outer `Rotation` authorizing `new_key`. That chain's `prev_node_keys`
3135 /// names the pivot pubkey — so a peer presenting the pivot pubkey as its node key is the
3136 /// rotated-away key the filter must drop.
3137 #[tokio::test]
3138 async fn tka_full_drops_rotated_away_key_e2e() {
3139 use ed25519_dalek::SigningKey;
3140 use ts_tka::NodeKeySignature;
3141
3142 let trusted = SigningKey::from_bytes(&[42u8; 32]);
3143 let trusted_pub = trusted.verifying_key().to_bytes().to_vec();
3144 let authority = Authority::from_state(
3145 AumHash([0; 32]),
3146 State {
3147 keys: vec![Key {
3148 kind: KeyKind::Ed25519,
3149 votes: 1,
3150 public: trusted_pub.clone(),
3151 }],
3152 },
3153 );
3154
3155 // The rotation pivot: a keypair whose public key the inner Direct authorizes and whose
3156 // private key signs the outer rotation wrap. This pivot pubkey IS the key being rotated away.
3157 let pivot = SigningKey::from_bytes(&[9u8; 32]);
3158 let pivot_pub: [u8; 32] = pivot.verifying_key().to_bytes();
3159
3160 let new_key = [4u8; 32]; // the freshly-rotated node key
3161
3162 // Fresh peer: a Rotation chain authorizing `new_key`, inner Direct over the pivot signed by
3163 // trusted, outer wrap signed by the pivot. Its prev_node_keys names `pivot_pub`.
3164 let new_sig = NodeKeySignature::sign_rotation(&new_key, &trusted, &pivot).serialize();
3165 let new_peer = peer_node("rotated", new_key, new_sig);
3166
3167 // Stale peer: still presents the pivot pubkey (the rotated-away key) with its own valid
3168 // Direct signature — valid in isolation, but obsoleted by the fresh peer's rotation chain.
3169 let stale_sig = NodeKeySignature::sign_direct(&pivot_pub, &trusted).serialize();
3170 let stale_peer = peer_node("stale", pivot_pub, stale_sig);
3171
3172 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
3173 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![
3174 new_peer.clone(),
3175 stale_peer.clone(),
3176 ]));
3177
3178 assert!(
3179 tracker.peer_db.get(&new_peer.node_key).is_some(),
3180 "the freshly-rotated peer is admitted"
3181 );
3182 assert!(
3183 tracker.peer_db.get(&stale_peer.node_key).is_none(),
3184 "the peer presenting the rotated-away key is dropped (Go tkaFilterNetmapLocked)"
3185 );
3186 }
3187}
3188
3189#[cfg(test)]
3190mod tsmp_disco_key_tests {
3191 //! Receive side of the TSMP disco-key advertisement, at the point the key is *learned*.
3192 //!
3193 //! These exercise [`PeerTracker::learn_disco_key`] — the fork's stand-in for Go
3194 //! `magicsock.Conn.HandleDiscoKeyAdvertisement` — which is the single place an advertisement
3195 //! reaches peer state. The wire decode and the "consumed, not delivered" drop are covered in
3196 //! `ts_packet::tsmp` and `ts_dataplane` respectively.
3197
3198 use ts_keys::DiscoPublicKey;
3199
3200 use super::{
3201 tka_tests::{peer_node, test_env},
3202 *,
3203 };
3204
3205 /// The key a peer advertises, and a second one for the re-advertise case.
3206 const ADVERTISED: [u8; 32] = [0xa5u8; 32];
3207 const READVERTISED: [u8; 32] = [0x5au8; 32];
3208 /// The (staler) key control has for that same peer, and the one control eventually catches up
3209 /// to.
3210 const FROM_CONTROL: [u8; 32] = [0xc0u8; 32];
3211 const CONTROL_CAUGHT_UP: [u8; 32] = [0x0cu8; 32];
3212
3213 /// The node key of the single peer these tests use.
3214 const PEER_NODE_KEY: [u8; 32] = [1u8; 32];
3215
3216 /// A tracker holding one peer with no disco key yet, plus that peer's [`PeerId`].
3217 fn tracker_with_peer() -> (PeerTracker, PeerId) {
3218 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
3219 let node = peer_node("peer", PEER_NODE_KEY, Vec::new());
3220 let id = tracker.peer_db.upsert(&node);
3221 (tracker, id)
3222 }
3223
3224 /// The peer as CONTROL describes it: the same node, carrying whatever disco key the netmap says
3225 /// it has (`None` for a peer control has no disco key for at all).
3226 fn node_from_control(disco_key: Option<[u8; 32]>) -> Node {
3227 let mut node = peer_node("peer", PEER_NODE_KEY, Vec::new());
3228 node.disco_key = disco_key.map(DiscoPublicKey::from);
3229 node
3230 }
3231
3232 /// A netmap `Full` carrying just this peer, as control currently describes it.
3233 fn control_full(disco_key: Option<[u8; 32]>) -> ts_control::PeerUpdate {
3234 ts_control::PeerUpdate::Full(vec![node_from_control(disco_key)])
3235 }
3236
3237 /// A tracker whose single peer arrived through the netmap carrying `disco_key`, exactly as the
3238 /// actor's handler applies it. Returns the peer's [`PeerId`] too.
3239 fn tracker_with_control_peer(disco_key: Option<[u8; 32]>) -> (PeerTracker, PeerId) {
3240 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
3241 let node = node_from_control(disco_key);
3242 tracker.apply_peer_update(&control_full(disco_key));
3243 let id = tracker
3244 .peer_db
3245 .has(&node.node_key)
3246 .expect("control delivered it");
3247 (tracker, id)
3248 }
3249
3250 /// The disco key the peer db currently holds for `peer` — the effective key every direct-path
3251 /// consumer resolves against.
3252 fn effective_key(tracker: &PeerTracker, peer: PeerId) -> Option<DiscoPublicKey> {
3253 tracker
3254 .peer_db
3255 .get(&peer)
3256 .expect("peer still present")
3257 .1
3258 .disco_key
3259 }
3260
3261 /// The happy path: an advertised key is applied to the peer AND lands in the disco index, which
3262 /// is what the direct-path machinery (`direct::DiscoPeerLookup`) reads. Re-advertising the same
3263 /// key is a no-op; advertising a different one replaces it, retracting the old index entry.
3264 #[tokio::test]
3265 async fn advertisement_learns_the_peers_disco_key() {
3266 let (mut tracker, peer) = tracker_with_peer();
3267 let key = DiscoPublicKey::from(ADVERTISED);
3268
3269 assert!(
3270 tracker.learn_disco_key(peer, key),
3271 "a first advertisement changes the peer db"
3272 );
3273 assert_eq!(
3274 tracker
3275 .peer_db
3276 .get(&peer)
3277 .expect("peer still present")
3278 .1
3279 .disco_key,
3280 Some(key),
3281 "the advertised disco key is learned"
3282 );
3283 assert_eq!(
3284 tracker.peer_db.has(&key),
3285 Some(peer),
3286 "and is reachable through the disco index the direct path resolves against"
3287 );
3288
3289 assert!(
3290 !tracker.learn_disco_key(peer, key),
3291 "re-advertising the same key is a no-op (Go counts it 'unchanged' and returns)"
3292 );
3293
3294 let rotated = DiscoPublicKey::from(READVERTISED);
3295 assert!(tracker.learn_disco_key(peer, rotated));
3296 assert_eq!(
3297 tracker
3298 .peer_db
3299 .get(&peer)
3300 .expect("peer still present")
3301 .1
3302 .disco_key,
3303 Some(rotated),
3304 "a later advertisement replaces the key without a netmap update"
3305 );
3306 assert_eq!(tracker.peer_db.has(&rotated), Some(peer));
3307 assert_eq!(
3308 tracker.peer_db.has(&key),
3309 None,
3310 "the superseded key no longer resolves to the peer"
3311 );
3312 }
3313
3314 /// The refusals, each of which must leave the peer db untouched: the zero key is never learned,
3315 /// and an advertisement never creates a peer.
3316 #[tokio::test]
3317 async fn refused_advertisements_change_nothing() {
3318 let (mut tracker, peer) = tracker_with_peer();
3319
3320 assert!(
3321 !tracker.learn_disco_key(peer, DiscoPublicKey::from([0u8; 32])),
3322 "the zero key is never learned"
3323 );
3324 assert_eq!(
3325 tracker
3326 .peer_db
3327 .get(&peer)
3328 .expect("peer still present")
3329 .1
3330 .disco_key,
3331 None,
3332 "a zero-key advertisement must not bind the peer to an unusable key"
3333 );
3334
3335 // An advertisement for a peer control has never told us about. Go logs "endpoint not found
3336 // for node" and returns; it must not conjure a peer into existence.
3337 let unknown = PeerId(4242);
3338 assert_eq!(tracker.peer_db.get(&unknown), None, "precondition");
3339 assert!(
3340 !tracker.learn_disco_key(unknown, DiscoPublicKey::from(ADVERTISED)),
3341 "an advertisement for an unknown peer is ignored"
3342 );
3343 assert_eq!(
3344 tracker.peer_db.peers().len(),
3345 1,
3346 "an advertisement never creates a peer — only control does"
3347 );
3348 assert_eq!(
3349 tracker.peer_db.has(&DiscoPublicKey::from(ADVERTISED)),
3350 None,
3351 "and never indexes a key against a peer that does not exist"
3352 );
3353 }
3354
3355 /// The feature's motivating case, end to end: the peer told us a key control has not caught up
3356 /// with, and then control polls again with the SAME stale key it had before. The advertisement
3357 /// must survive.
3358 ///
3359 /// Go keeps the two keys apart on the endpoint (`endpointDisco.controlKey` /
3360 /// `tsmpKey`), and `updateFromNode` only rewrites the control side when control's key actually
3361 /// changed — so a netmap restating the old key never touches the active TSMP key. With a single
3362 /// field the next map poll silently reverted the peer to control's stale key, which is precisely
3363 /// the state the advertisement exists to escape.
3364 #[tokio::test]
3365 async fn netmap_restating_controls_stale_key_keeps_the_tsmp_key() {
3366 let (mut tracker, peer) = tracker_with_control_peer(Some(FROM_CONTROL));
3367 let advertised = DiscoPublicKey::from(ADVERTISED);
3368 assert_eq!(
3369 effective_key(&tracker, peer),
3370 Some(DiscoPublicKey::from(FROM_CONTROL)),
3371 "precondition: the peer starts on the key control gave us"
3372 );
3373
3374 assert!(tracker.learn_disco_key(peer, advertised));
3375 assert_eq!(effective_key(&tracker, peer), Some(advertised));
3376
3377 // Control polls again, still behind: a `Full` resync, then a `Delta` re-upsert, both
3378 // carrying the key control already sent.
3379 tracker.apply_peer_update(&control_full(Some(FROM_CONTROL)));
3380 assert_eq!(
3381 effective_key(&tracker, peer),
3382 Some(advertised),
3383 "a Full restating control's stale key must not undo the TSMP-learned key"
3384 );
3385 tracker.apply_peer_update(&ts_control::PeerUpdate::Delta {
3386 upsert: vec![node_from_control(Some(FROM_CONTROL))],
3387 remove: vec![],
3388 });
3389 assert_eq!(
3390 effective_key(&tracker, peer),
3391 Some(advertised),
3392 "and neither must a Delta re-upsert of the same node"
3393 );
3394 assert_eq!(
3395 tracker.peer_db.has(&advertised),
3396 Some(peer),
3397 "the direct path still resolves the peer by the key it advertised"
3398 );
3399 assert_eq!(
3400 tracker.peer_db.has(&DiscoPublicKey::from(FROM_CONTROL)),
3401 None,
3402 "and control's superseded key does not resolve to it"
3403 );
3404
3405 // Control finally changes its mind. The new key is recorded in control's slot, but the key
3406 // the peer itself told us stays active — upstream returns to control's key only when disco
3407 // is received under it (`endpoint.checkAndUpdateDiscoKey`).
3408 tracker.apply_peer_update(&control_full(Some(CONTROL_CAUGHT_UP)));
3409 assert_eq!(
3410 effective_key(&tracker, peer),
3411 Some(advertised),
3412 "a control-side key change must not preempt an active TSMP-learned key"
3413 );
3414 assert_eq!(
3415 tracker.control_disco_key(&PEER_NODE_KEY.into()),
3416 Some(DiscoPublicKey::from(CONTROL_CAUGHT_UP)),
3417 "but control's new key IS recorded in control's slot"
3418 );
3419 }
3420
3421 /// An advertisement that merely restates the key control already gave us is still *new*
3422 /// information — it is the peer itself confirming the key — so Go records it as the TSMP key and
3423 /// makes it active. Its "unchanged" early return compares `epDisco.keyFromTSMP()`, the
3424 /// TSMP-learned key specifically, never the effective one.
3425 ///
3426 /// The observable consequence, asserted here: once the peer has confirmed the key, control
3427 /// dropping it (a netmap node with no disco key) leaves the confirmed key in place instead of
3428 /// blinding the direct path.
3429 #[tokio::test]
3430 async fn advertisement_restating_controls_key_is_recorded_as_the_tsmp_key() {
3431 let (mut tracker, peer) = tracker_with_control_peer(Some(FROM_CONTROL));
3432 let key = DiscoPublicKey::from(FROM_CONTROL);
3433
3434 assert!(
3435 tracker.learn_disco_key(peer, key),
3436 "an advertisement of the key control already sent is recorded, not dropped"
3437 );
3438 assert_eq!(
3439 tracker
3440 .endpoint_disco
3441 .get(&PEER_NODE_KEY.into())
3442 .and_then(EndpointDisco::key_from_tsmp),
3443 Some(key),
3444 "it lands in the TSMP slot (Go epDisco.tsmpKey), not only in control's"
3445 );
3446 assert!(
3447 !tracker.learn_disco_key(peer, key),
3448 "re-advertising it now IS unchanged, and is refused"
3449 );
3450
3451 // Control drops the peer's disco key. The key the peer itself confirmed stays active.
3452 tracker.apply_peer_update(&control_full(None));
3453 assert_eq!(
3454 effective_key(&tracker, peer),
3455 Some(key),
3456 "a control key going away hands the active slot to the TSMP-learned key"
3457 );
3458 assert_eq!(tracker.peer_db.has(&key), Some(peer));
3459 }
3460
3461 /// A `PeersChangedPatch` is a control write like any other: one that says nothing about the
3462 /// disco key must leave an active TSMP key alone, and one that carries a new key is control
3463 /// catching up, so it wins.
3464 ///
3465 /// The patch path is the subtle one — it starts from the db node, which carries the *effective*
3466 /// key, so without re-deriving what control last said it would hand the TSMP key back as if
3467 /// control had sent it.
3468 #[tokio::test]
3469 async fn patch_without_a_disco_key_leaves_the_tsmp_key_active() {
3470 let (mut tracker, peer) = tracker_with_control_peer(Some(FROM_CONTROL));
3471 let advertised = DiscoPublicKey::from(ADVERTISED);
3472 assert!(tracker.learn_disco_key(peer, advertised));
3473
3474 // A reachability-only patch (the idle-peer-reconnect case) for the same node.
3475 let endpoint: std::net::SocketAddr = "203.0.113.9:41641".parse().unwrap();
3476 let mut patch = ts_control::PeerChange {
3477 id: 1,
3478 derp_region: None,
3479 cap: None,
3480 cap_map: None,
3481 underlay_addresses: Some(vec![endpoint]),
3482 node_key: None,
3483 key_signature: None,
3484 disco_key: None,
3485 node_key_expiry: None,
3486 online: None,
3487 last_seen: None,
3488 };
3489 tracker.apply_peer_patches(std::slice::from_ref(&patch));
3490 assert_eq!(
3491 effective_key(&tracker, peer),
3492 Some(advertised),
3493 "a patch that never mentions the disco key must not revert it to control's"
3494 );
3495 assert_eq!(
3496 tracker
3497 .peer_db
3498 .get(&peer)
3499 .expect("peer still present")
3500 .1
3501 .underlay_addresses,
3502 vec![endpoint],
3503 "and the patch it DID carry still applied"
3504 );
3505
3506 // Now control changes the key through the patch channel. Same rule as the netmap path: the
3507 // key lands in control's slot, and the active TSMP key is left alone.
3508 patch.disco_key = Some(DiscoPublicKey::from(CONTROL_CAUGHT_UP));
3509 tracker.apply_peer_patches(std::slice::from_ref(&patch));
3510 assert_eq!(
3511 effective_key(&tracker, peer),
3512 Some(advertised),
3513 "a patch carrying a new disco key does not preempt the active TSMP-learned key either"
3514 );
3515 assert_eq!(
3516 tracker.control_disco_key(&PEER_NODE_KEY.into()),
3517 Some(DiscoPublicKey::from(CONTROL_CAUGHT_UP)),
3518 "the patched key is still recorded as what control now says"
3519 );
3520 }
3521
3522 /// The rule this whole pair of slots exists to express: once the peer has told us its key over
3523 /// TSMP, control changing its mind is *recorded* but does not take the active slot back — Go
3524 /// `endpoint.updateDiscoKey`'s `epDisco.tsmpActive = old.tsmpActive || key.IsZero()`.
3525 ///
3526 /// Control is the slower source; a key the peer sent us itself is the better evidence. Upstream
3527 /// hands the slot back only when disco is actually *received* under control's key
3528 /// (`endpoint.checkAndUpdateDiscoKey`). Here the peer re-advertising is the path back, and it is
3529 /// asserted at the end so the sticky rule cannot be read as "the TSMP key is now permanent".
3530 #[tokio::test]
3531 async fn a_control_key_change_does_not_preempt_an_active_tsmp_key() {
3532 let (mut tracker, peer) = tracker_with_control_peer(Some(FROM_CONTROL));
3533 let advertised = DiscoPublicKey::from(ADVERTISED);
3534 let caught_up = DiscoPublicKey::from(CONTROL_CAUGHT_UP);
3535 assert!(tracker.learn_disco_key(peer, advertised));
3536
3537 tracker.apply_peer_update(&control_full(Some(CONTROL_CAUGHT_UP)));
3538 assert_eq!(
3539 effective_key(&tracker, peer),
3540 Some(advertised),
3541 "the TSMP-learned key stays active across a control-side change"
3542 );
3543 assert_eq!(
3544 tracker.peer_db.has(&advertised),
3545 Some(peer),
3546 "so the direct path still resolves the peer by the key it advertised"
3547 );
3548 assert_eq!(
3549 tracker.peer_db.has(&caught_up),
3550 None,
3551 "and control's new key is not what we send to"
3552 );
3553 assert_eq!(
3554 tracker.control_disco_key(&PEER_NODE_KEY.into()),
3555 Some(caught_up),
3556 "control's new key is recorded all the same — it is not discarded, just not active"
3557 );
3558
3559 // Control changing its mind a second time, and then dropping the key entirely, changes
3560 // nothing about which key is active.
3561 tracker.apply_peer_update(&control_full(Some(FROM_CONTROL)));
3562 tracker.apply_peer_update(&control_full(None));
3563 assert_eq!(
3564 effective_key(&tracker, peer),
3565 Some(advertised),
3566 "neither a second control change nor control dropping the key moves the active slot"
3567 );
3568
3569 // The peer itself is what moves it: it advertises the key control had been trying to give
3570 // us, and that advertisement is what we act on.
3571 assert!(tracker.learn_disco_key(peer, caught_up));
3572 assert_eq!(
3573 effective_key(&tracker, peer),
3574 Some(caught_up),
3575 "a peer re-advertising moves the active key, because the peer is the evidence"
3576 );
3577 }
3578
3579 /// The sticky flag must not strand a peer that never had a TSMP key: control sending nothing
3580 /// leaves no key material at all, and the key control sends next must become the active one.
3581 ///
3582 /// This is the case Go covers by nil-ing the endpoint's `disco` pointer when both keys are
3583 /// zero; here [`PeerTracker::upsert_from_control`] drops the entry, so the "no control key means
3584 /// the TSMP slot is active" flag cannot survive to shadow a later control key with nothing.
3585 #[tokio::test]
3586 async fn a_first_control_key_is_active_even_after_control_sent_none() {
3587 let (mut tracker, peer) = tracker_with_control_peer(None);
3588 assert_eq!(effective_key(&tracker, peer), None, "precondition");
3589 assert!(
3590 tracker.endpoint_disco.is_empty(),
3591 "a peer with no key material from either source costs no entry"
3592 );
3593
3594 tracker.apply_peer_update(&control_full(Some(FROM_CONTROL)));
3595 assert_eq!(
3596 effective_key(&tracker, peer),
3597 Some(DiscoPublicKey::from(FROM_CONTROL)),
3598 "control's first key is active — there is no TSMP key for it to defer to"
3599 );
3600 assert_eq!(
3601 tracker.peer_db.has(&DiscoPublicKey::from(FROM_CONTROL)),
3602 Some(peer)
3603 );
3604 }
3605
3606 /// The other half of `tsmpActive = old.tsmpActive || key.IsZero()`, in the one state where the
3607 /// left operand is false *and* a TSMP key exists: after disco was received under control's key,
3608 /// which is upstream's only route back to control holding the active slot
3609 /// (`endpoint.checkAndUpdateDiscoKey`).
3610 ///
3611 /// Two things follow, and neither is obvious from the sticky rule alone. Control's later changes
3612 /// **do** land, because what is sticky is the flag, not the TSMP key — so this is not "the TSMP
3613 /// key wins forever", and a peer that genuinely rotated is not stranded. And control *dropping*
3614 /// its key does not leave the peer with no disco key at all: the `key.IsZero()` operand hands the
3615 /// slot to the TSMP key still sitting in the other slot, which is why Go only nils the endpoint's
3616 /// `disco` pointer when **both** keys are zero.
3617 #[tokio::test]
3618 async fn control_regains_the_slot_by_being_received_under_and_then_keeps_it() {
3619 let (mut tracker, peer) = tracker_with_control_peer(Some(FROM_CONTROL));
3620 let from_control = DiscoPublicKey::from(FROM_CONTROL);
3621 let advertised = DiscoPublicKey::from(ADVERTISED);
3622 let caught_up = DiscoPublicKey::from(CONTROL_CAUGHT_UP);
3623
3624 // Get into the state: the peer advertises, then sends disco under control's key anyway, so
3625 // control's key is active again with the TSMP key demoted but retained.
3626 assert!(tracker.learn_disco_key(peer, advertised));
3627 assert!(tracker.observe_disco_key(peer, from_control));
3628 assert_eq!(
3629 effective_key(&tracker, peer),
3630 Some(from_control),
3631 "precondition: control holds the active slot because we received under its key"
3632 );
3633 assert_eq!(
3634 ingress_match(&tracker, advertised),
3635 Some((peer, peer_db::DiscoKeyMatch::Inactive)),
3636 "precondition: the TSMP key is demoted, not discarded"
3637 );
3638
3639 // Control changes its key. With the TSMP key demoted the sticky operand is false, so this
3640 // one does take the active slot — the flag is what is sticky, not the TSMP key.
3641 tracker.apply_peer_update(&control_full(Some(CONTROL_CAUGHT_UP)));
3642 assert_eq!(
3643 effective_key(&tracker, peer),
3644 Some(caught_up),
3645 "a demoted TSMP key does not block control's next key from becoming active"
3646 );
3647 assert_eq!(
3648 ingress_match(&tracker, advertised),
3649 Some((peer, peer_db::DiscoKeyMatch::Inactive)),
3650 "and the TSMP key is still the peer's other known key for ingress"
3651 );
3652 assert_eq!(
3653 ingress_match(&tracker, from_control),
3654 None,
3655 "control's superseded key is not a third slot"
3656 );
3657
3658 // Control drops its key entirely. `key.IsZero()` is the operand that carries the peer here:
3659 // the retained TSMP key becomes active rather than the peer losing disco altogether.
3660 tracker.apply_peer_update(&control_full(None));
3661 assert_eq!(
3662 effective_key(&tracker, peer),
3663 Some(advertised),
3664 "control dropping its key falls back to the TSMP key, not to no key"
3665 );
3666 assert_eq!(
3667 ingress_match(&tracker, advertised),
3668 Some((peer, peer_db::DiscoKeyMatch::Active))
3669 );
3670 assert_eq!(
3671 tracker.control_disco_key(&PEER_NODE_KEY.into()),
3672 None,
3673 "control's slot is cleared, so there is no second key to accept"
3674 );
3675 assert_eq!(
3676 ingress_match(&tracker, caught_up),
3677 None,
3678 "the key control withdrew stops resolving on ingress"
3679 );
3680 }
3681
3682 /// The TSMP-learned key lives exactly as long as Go's endpoint does: it is dropped when the peer
3683 /// leaves the netmap, and it is not carried across a node-key rotation (Go builds the rotated
3684 /// peer a brand-new endpoint, with a brand-new `endpointDisco`).
3685 #[tokio::test]
3686 async fn tsmp_key_does_not_outlive_the_peer_or_its_node_key() {
3687 let (mut tracker, peer) = tracker_with_control_peer(Some(FROM_CONTROL));
3688 assert!(tracker.learn_disco_key(peer, DiscoPublicKey::from(ADVERTISED)));
3689
3690 // The peer leaves the netmap, then comes back on control's key.
3691 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![]));
3692 assert!(tracker.peer_db.peers().is_empty());
3693 assert!(
3694 tracker.endpoint_disco.is_empty(),
3695 "the departed peer's disco state goes with it"
3696 );
3697 tracker.apply_peer_update(&control_full(Some(FROM_CONTROL)));
3698 let readded = node_from_control(Some(FROM_CONTROL));
3699 let peer = tracker.peer_db.has(&readded.node_key).expect("re-added");
3700 assert_eq!(
3701 effective_key(&tracker, peer),
3702 Some(DiscoPublicKey::from(FROM_CONTROL)),
3703 "a peer that left and rejoined starts from control's key again"
3704 );
3705
3706 // Learn a key again, then rotate the node key underneath it.
3707 assert!(tracker.learn_disco_key(peer, DiscoPublicKey::from(READVERTISED)));
3708 let mut rotated = node_from_control(Some(FROM_CONTROL));
3709 rotated.node_key = [2u8; 32].into();
3710 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![rotated.clone()]));
3711 let peer = tracker
3712 .peer_db
3713 .has(&rotated.node_key)
3714 .expect("rotated peer");
3715 assert_eq!(
3716 effective_key(&tracker, peer),
3717 Some(DiscoPublicKey::from(FROM_CONTROL)),
3718 "a key learned under the old node key is not carried onto the new one"
3719 );
3720 assert_eq!(
3721 tracker.endpoint_disco.len(),
3722 1,
3723 "and the old node key's state is pruned"
3724 );
3725 }
3726
3727 /// How the peer db resolves `key` for an inbound disco frame: the peer it belongs to and which
3728 /// of that peer's two slots it matched.
3729 fn ingress_match(
3730 tracker: &PeerTracker,
3731 key: DiscoPublicKey,
3732 ) -> Option<(PeerId, peer_db::DiscoKeyMatch)> {
3733 tracker
3734 .peer_db
3735 .peer_by_known_disco_key(&key)
3736 .map(|(id, _node, matched)| (id, matched))
3737 }
3738
3739 /// The bead's case, end to end: the peer advertised K2 over TSMP so we send to K2, but it is
3740 /// still sending disco under the K1 control gave us. That frame must resolve to the peer, and
3741 /// receiving under K1 must make K1 the key we send to — because it is demonstrably what the
3742 /// peer uses.
3743 ///
3744 /// Go: every inbound disco comparison goes through `endpoint.checkAndUpdateDiscoKey`, which
3745 /// accepts either slot and compare-and-swaps `tsmpActive` when the key seen is the inactive one.
3746 #[tokio::test]
3747 async fn disco_under_the_inactive_key_is_accepted_and_makes_that_key_active() {
3748 let (mut tracker, peer) = tracker_with_control_peer(Some(FROM_CONTROL));
3749 let from_control = DiscoPublicKey::from(FROM_CONTROL);
3750 let advertised = DiscoPublicKey::from(ADVERTISED);
3751
3752 assert!(tracker.learn_disco_key(peer, advertised));
3753 assert_eq!(
3754 effective_key(&tracker, peer),
3755 Some(advertised),
3756 "precondition: we are sending to the TSMP-learned key"
3757 );
3758 assert_eq!(
3759 ingress_match(&tracker, from_control),
3760 Some((peer, peer_db::DiscoKeyMatch::Inactive)),
3761 "control's key is still the peer's other known key, and still resolves on ingress"
3762 );
3763
3764 // Disco arrives under control's key: accepted, and it becomes the active one.
3765 assert!(
3766 tracker.observe_disco_key(peer, from_control),
3767 "receiving under the inactive key switches the active key"
3768 );
3769 assert_eq!(
3770 effective_key(&tracker, peer),
3771 Some(from_control),
3772 "we now send to the key the peer is demonstrably using"
3773 );
3774 assert_eq!(
3775 tracker.peer_db.has(&from_control),
3776 Some(peer),
3777 "and it is the key the send-side disco index carries"
3778 );
3779 assert_eq!(
3780 ingress_match(&tracker, from_control),
3781 Some((peer, peer_db::DiscoKeyMatch::Active))
3782 );
3783 assert_eq!(
3784 ingress_match(&tracker, advertised),
3785 Some((peer, peer_db::DiscoKeyMatch::Inactive)),
3786 "the TSMP key is retained in the other slot, so ingress under it still resolves"
3787 );
3788
3789 assert!(
3790 !tracker.observe_disco_key(peer, from_control),
3791 "a second frame under the now-active key changes nothing (and forces no republish)"
3792 );
3793
3794 // And it switches back: the peer resumes sending under the key it advertised.
3795 assert!(tracker.observe_disco_key(peer, advertised));
3796 assert_eq!(effective_key(&tracker, peer), Some(advertised));
3797 assert_eq!(
3798 ingress_match(&tracker, from_control),
3799 Some((peer, peer_db::DiscoKeyMatch::Inactive))
3800 );
3801 }
3802
3803 /// The refusal that is the whole security value of the check: a key belonging to NEITHER slot
3804 /// is rejected, leaving the peer on the key it was on. Plus the two other refusals Go has —
3805 /// an unknown peer, and a peer with no disco key material at all (`epDisco == nil`).
3806 #[tokio::test]
3807 async fn disco_under_a_key_in_neither_slot_is_refused() {
3808 let (mut tracker, peer) = tracker_with_control_peer(Some(FROM_CONTROL));
3809 let from_control = DiscoPublicKey::from(FROM_CONTROL);
3810 let advertised = DiscoPublicKey::from(ADVERTISED);
3811 let third = DiscoPublicKey::from(READVERTISED);
3812
3813 assert!(tracker.learn_disco_key(peer, advertised));
3814
3815 assert!(
3816 !tracker.observe_disco_key(peer, third),
3817 "a third key is refused: a peer must not move itself onto a key nobody told us about"
3818 );
3819 assert_eq!(
3820 effective_key(&tracker, peer),
3821 Some(advertised),
3822 "and the peer stays on the key it was on"
3823 );
3824 assert_eq!(
3825 ingress_match(&tracker, third),
3826 None,
3827 "the refused key never becomes resolvable"
3828 );
3829 assert_eq!(
3830 ingress_match(&tracker, from_control),
3831 Some((peer, peer_db::DiscoKeyMatch::Inactive)),
3832 "the two real slots are untouched"
3833 );
3834
3835 // An unknown peer: like a TSMP advertisement, this never creates one.
3836 assert!(!tracker.observe_disco_key(PeerId(4242), from_control));
3837 assert_eq!(tracker.peer_db.peers().len(), 1);
3838
3839 // A peer with no disco key from either source — Go returns false on `epDisco == nil`.
3840 let (mut bare, bare_peer) = tracker_with_control_peer(None);
3841 assert_eq!(effective_key(&bare, bare_peer), None, "precondition");
3842 assert!(
3843 !bare.observe_disco_key(bare_peer, from_control),
3844 "a peer with no known disco key has no slot for this key to match"
3845 );
3846 assert_eq!(effective_key(&bare, bare_peer), None);
3847 }
3848
3849 /// A peer that has only ever had one key registers no inactive key at all, so the second index
3850 /// stays empty and an inbound frame under any other key is refused.
3851 #[tokio::test]
3852 async fn a_single_key_peer_has_no_second_slot() {
3853 let (tracker, peer) = tracker_with_control_peer(Some(FROM_CONTROL));
3854 let from_control = DiscoPublicKey::from(FROM_CONTROL);
3855
3856 assert_eq!(
3857 ingress_match(&tracker, from_control),
3858 Some((peer, peer_db::DiscoKeyMatch::Active))
3859 );
3860 assert_eq!(
3861 ingress_match(&tracker, DiscoPublicKey::from(ADVERTISED)),
3862 None,
3863 "no second key was ever learned, so nothing else resolves to this peer"
3864 );
3865 }
3866
3867 /// An advertisement that merely restates control's key must not leave the peer with the same
3868 /// key in both slots pretending to be two — `inactive_key` reports `None` when the inactive
3869 /// slot holds the active key, so ingress sees exactly one key.
3870 #[tokio::test]
3871 async fn the_same_key_in_both_slots_is_one_key() {
3872 let (mut tracker, peer) = tracker_with_control_peer(Some(FROM_CONTROL));
3873 let from_control = DiscoPublicKey::from(FROM_CONTROL);
3874
3875 assert!(tracker.learn_disco_key(peer, from_control));
3876 assert_eq!(
3877 tracker
3878 .endpoint_disco
3879 .get(&PEER_NODE_KEY.into())
3880 .and_then(EndpointDisco::inactive_key),
3881 None,
3882 "both slots hold the same key, so there is no second key"
3883 );
3884 assert_eq!(
3885 ingress_match(&tracker, from_control),
3886 Some((peer, peer_db::DiscoKeyMatch::Active))
3887 );
3888 assert!(
3889 !tracker.observe_disco_key(peer, from_control),
3890 "and receiving under it is a no-op, not a switch"
3891 );
3892 }
3893
3894 /// A peer that leaves the netmap takes BOTH its keys with it: the inactive-key index must not
3895 /// keep attributing frames to a peer that is gone.
3896 #[tokio::test]
3897 async fn a_departed_peer_stops_resolving_under_either_key() {
3898 let (mut tracker, peer) = tracker_with_control_peer(Some(FROM_CONTROL));
3899 let advertised = DiscoPublicKey::from(ADVERTISED);
3900 assert!(tracker.learn_disco_key(peer, advertised));
3901 assert!(ingress_match(&tracker, DiscoPublicKey::from(FROM_CONTROL)).is_some());
3902
3903 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![]));
3904 assert_eq!(ingress_match(&tracker, advertised), None);
3905 assert_eq!(
3906 ingress_match(&tracker, DiscoPublicKey::from(FROM_CONTROL)),
3907 None,
3908 "the inactive key is retracted with the peer, not left dangling"
3909 );
3910 }
3911}