diff --git a/crates/bridge-core/src/engine/mod.rs b/crates/bridge-core/src/engine/mod.rs index 4a2388be..d4480f94 100644 --- a/crates/bridge-core/src/engine/mod.rs +++ b/crates/bridge-core/src/engine/mod.rs @@ -400,7 +400,9 @@ impl Engine { match kind { TimerKind::Heartbeat => { self.prune_session_keys(); + self.forget_gone_devices(); self.persist_cursor(); + self.registry_dirty |= self.tombstones.prune(self.now()); self.registry_dirty |= self.activity_dirty; self.list_dirty = true; self.arm(self.config.heartbeat_interval_ms, TimerKind::Heartbeat); @@ -568,9 +570,15 @@ impl Engine { return; } if self.list_dirty { - self.list_wanted.clear(); self.publish_list(false, None); - } else if !self.list_wanted.is_empty() { + // The shared heartbeat reached the clients that asked too. Their + // own would differ only by the older tombstones, which a client + // coming back from away needs: sent only when there are some. + if self.tombstones.all_recent(self.now()) { + self.list_wanted.clear(); + } + } + if !self.list_wanted.is_empty() { let to = std::mem::take(&mut self.list_wanted); self.publish_list(false, Some(to)); } @@ -581,7 +589,7 @@ impl Engine { self.activity_dirty = false; let doc = RegistryDoc { sessions: self.sessions.values().filter(|s| s.listed).map(|s| s.rec.clone()).collect(), - removed_sessions: self.tombstones.ids(), + removed_sessions: self.tombstones.stored(), }; let json = serde_json::to_string(&doc).expect("the registry serializes"); if let Err(err) = self.store.set(store_keys::REGISTRY, &json) { @@ -590,6 +598,9 @@ impl Engine { } fn persist_cursor(&mut self) { + // Only ids a replay could still run are worth a byte of the state + // file, which this rewrites on every command. + self.ingest.forget_stale(self.now() / 1000); let processed = serde_json::to_string(&self.ingest.processed_ids()).expect("ids serialize"); let last_seen = self.ingest.last_seen().to_string(); for (key, value) in [(store_keys::LAST_SEEN, last_seen), (store_keys::PROCESSED_IDS, processed)] { @@ -619,6 +630,12 @@ impl Engine { /// capabilities, folders and tombstones. To every present client, or /// only to `to`. /// + /// The shared heartbeat carries only the recent tombstones: a present + /// client applied the older ones long ago, and repeating them every + /// minute to every client is traffic for nothing. A heartbeat to `to` is + /// one a client asked for, coming back or refreshing, so it carries every + /// tombstone kept. + /// /// A terminal belongs to the identity that opened it, and only that /// identity's clients may act on it, so its `terminals` list holds the /// receiving identity's own terminals and no other identity ever hears of @@ -643,7 +660,7 @@ impl Engine { } let agents = self.catalog.descriptors(|a| self.agent_credentials(a)); let credentials = self.bridge_credentials(); - let removed = self.tombstones.ids(); + let removed = if to.is_some() { self.tombstones.ids() } else { self.tombstones.recent_ids(self.now()) }; let rev = self.next_list_rev(); // The terminal capability is the owner's decision, so unlike the rest // it is advertised only when this machine runs terminals: a phone that diff --git a/crates/bridge-core/src/engine/phone.rs b/crates/bridge-core/src/engine/phone.rs index 2fba4513..500aa7f4 100644 --- a/crates/bridge-core/src/engine/phone.rs +++ b/crates/bridge-core/src/engine/phone.rs @@ -468,7 +468,8 @@ impl Engine { }); } } - self.tombstones.add(session_id); + let now = self.now(); + self.tombstones.add(session_id, now); self.registry_dirty = true; if let Err(err) = self.transcripts.remove(session_id) { log::warn!("[Engine] Removing the transcript of {session_id} failed: {err}"); diff --git a/crates/bridge-core/src/engine/session_keys.rs b/crates/bridge-core/src/engine/session_keys.rs index 07a355b6..1dc063c5 100644 --- a/crates/bridge-core/src/engine/session_keys.rs +++ b/crates/bridge-core/src/engine/session_keys.rs @@ -154,6 +154,7 @@ impl Engine { device, label, present_until_ms, + presumed: false, watch: WatchState::default(), watch_terminal: WatchState::default(), }); @@ -256,7 +257,11 @@ impl Engine { let Some(phone) = self.paired.iter_mut().find(|p| p.pubkey_hex == identity) else { return false }; let holds = |c: &&mut PhoneClient| c.key.pubkey_hex == key || c.previous.as_ref().is_some_and(|k| k.pubkey_hex == key); let Some(client) = phone.clients.iter_mut().find(holds) else { return false }; - let was_present = client.present_until_ms > now; + // Presence the bridge only assumed at its restart is not presence: + // the client may have been away for days, and what it missed (old + // tombstones) only rides a heartbeat of its own. + let was_present = client.present_until_ms > now && !client.presumed; + client.presumed = false; client.present_until_ms = if away { 0 } else { now + PRESENCE_LEASE_SECS * 1000 }; if away && was_present { log::info!("[Engine] A client of {}... is away", short(identity)); @@ -330,6 +335,7 @@ impl Engine { let until = self.now() + (PRESENCE_RENEW_SECS + 60) * 1000; for client in self.paired.iter_mut().flat_map(|p| p.clients.iter_mut()) { client.present_until_ms = until; + client.presumed = true; } } diff --git a/crates/bridge-core/src/engine/terminal.rs b/crates/bridge-core/src/engine/terminal.rs index 3d54d08a..dc36cff6 100644 --- a/crates/bridge-core/src/engine/terminal.rs +++ b/crates/bridge-core/src/engine/terminal.rs @@ -48,7 +48,7 @@ use protocol::events::{ use serde::{Deserialize, Serialize}; use super::Engine; -use crate::io::{Addressee, Effect, TimerId, Via, WatchState}; +use crate::io::{Addressee, Effect, TerminalDevices, TimerId, Via, WatchState}; use crate::out::{Out, TimerKind}; /// The most terminals one bridge runs at once: a fifth `terminal-open` is @@ -727,7 +727,31 @@ impl Engine { pending.drain(..drop); } let pending = pending.clone(); - self.out.push(Effect::TerminalPending(pending)); + let live = self.live_devices(); + self.out.push(Effect::TerminalDevices(TerminalDevices { pending, live })); + } + + /// Every device a client of a paired identity is now. + fn live_devices(&self) -> BTreeSet { + self.paired.iter().flat_map(|p| p.clients.iter()).map(|c| c.device.clone()).filter(|d| !d.is_empty()).collect() + } + + /// Forget the allowlist's devices that no client is any more, approved + /// or waiting, and have the file do the same. Such a device cannot ask + /// again: the phone it was comes back as a new device, so its approval + /// would only linger in the file. + pub(super) fn forget_gone_devices(&mut self) { + let live = self.live_devices(); + let list = &mut self.terminals.config.allowlist; + let before = (list.devices.len(), list.pending.len()); + list.devices.retain(|d| live.contains(d)); + list.pending.retain(|p| live.contains(&p.device)); + if (list.devices.len(), list.pending.len()) == before { + return; + } + log::info!("[Engine] Forgetting terminal devices whose client is gone"); + let pending = list.pending.clone(); + self.out.push(Effect::TerminalDevices(TerminalDevices { pending, live })); } /// `terminal-open`: the gates, then a shell for the runtime to start. The diff --git a/crates/bridge-core/src/ingest.rs b/crates/bridge-core/src/ingest.rs index 767ae3f1..9d0e04fa 100644 --- a/crates/bridge-core/src/ingest.rs +++ b/crates/bridge-core/src/ingest.rs @@ -10,10 +10,14 @@ //! - A paired phone's payload is encrypted with one of its live session //! keys, or its identity; the event itself is always signed by the //! identity. -//! - Event ids are remembered (the last [`MAX_PROCESSED_IDS`]) and persisted: -//! a reconnect subscribes with `since` = last seen − 5 s, so the relay -//! replays recent commands, and without the ids they would run twice -//! (duplicate sessions, re-sent input). +//! - Event ids are remembered and persisted: a reconnect subscribes with +//! `since` = last seen − 5 s, so the relay replays recent commands, and +//! without the ids they would run twice (duplicate sessions, re-sent +//! input). An id is kept only while its event is young enough to act on: +//! past [`MAX_EVENT_AGE_SECS`] a replay is dropped as stale whether or not +//! its id is remembered, so the set holds the last few minutes' commands +//! (written on every command) and [`MAX_PROCESSED_IDS`] only bounds a +//! flood. //! - Terminal keystrokes take the live kind and [`Ingest::accept_live`] //! instead: their per-terminal `seq` is the dedup, and remembering their //! ids here would age command ids out of the set (see below). @@ -50,7 +54,9 @@ pub struct CommandsFilter { } pub struct Ingest { - order: VecDeque, + /// Remembered ids with their events' `created_at`, oldest remembered + /// first. + order: VecDeque<(String, u64)>, seen: HashSet, last_seen: u64, } @@ -61,11 +67,15 @@ fn short(pubkey: &str) -> &str { impl Ingest { /// Resume from a persisted cursor and dedup set (oldest id first). + /// + /// Only the ids are stored, so each is given the cursor's time: no + /// command handled before the restart is newer than the cursor, so none + /// is forgotten while a replay of it could still be acted on. pub fn new(last_seen: u64, processed: Vec) -> Self { let mut ingest = Self { order: VecDeque::new(), seen: HashSet::new(), last_seen }; let skip = processed.len().saturating_sub(MAX_PROCESSED_IDS); for id in processed.into_iter().skip(skip) { - ingest.mark(&id); + ingest.mark(&id, last_seen); } ingest } @@ -77,7 +87,20 @@ impl Ingest { /// The dedup ids, oldest first, for persisting beside the cursor. pub fn processed_ids(&self) -> Vec { - self.order.iter().cloned().collect() + self.order.iter().map(|(id, _)| id.clone()).collect() + } + + /// Forget the ids of events too old to act on: a replay of one is + /// dropped as stale by the same rule, so its id guards nothing. + pub fn forget_stale(&mut self, now_secs: u64) { + let seen = &mut self.seen; + self.order.retain(|(id, at)| { + let keep = !is_stale(*at, now_secs); + if !keep { + seen.remove(id); + } + keep + }); } /// An event from the standing subscription, whose author the caller has @@ -98,9 +121,10 @@ impl Ingest { if self.stale(event, now_secs) { return None; } + self.forget_stale(now_secs); // Remembered before it is read, as it always was: a paired phone's // junk takes a slot rather than being read again on every redelivery. - if !self.mark(&event.id) { + if !self.mark(&event.id, event.created_at) { return None; } let opened = self.open(event, keys, session_keys); @@ -140,7 +164,7 @@ impl Ingest { /// Whether an event is too old to act on: a relay that does not honour /// `since` must not replay old commands (or type old keystrokes). fn stale(&self, event: &InboundEvent, now_secs: u64) -> bool { - if event.created_at + MAX_EVENT_AGE_SECS < now_secs { + if is_stale(event.created_at, now_secs) { log::info!("[Ingest] Ignoring stale event ({}s old)", now_secs - event.created_at); return true; } @@ -169,11 +193,12 @@ impl Ingest { /// An event from the pairing window's subscription. pub fn accept_pairing(&mut self, event: &InboundEvent, keys: &Keypair, now_secs: u64) -> Option { - if event.created_at + MAX_EVENT_AGE_SECS < now_secs { + if is_stale(event.created_at, now_secs) { return None; } let plaintext = decrypt_from(&keys.secret_key, &event.pubkey, &event.content).ok()?; - if !self.mark(&event.id) { + self.forget_stale(now_secs); + if !self.mark(&event.id, event.created_at) { return None; } match protocol::decode_phone_to_bridge(&plaintext) { @@ -187,14 +212,15 @@ impl Ingest { } } - /// Remember an id; false when it was already there. - fn mark(&mut self, id: &str) -> bool { + /// Remember an id, of an event created at `at`; false when it was + /// already there. + fn mark(&mut self, id: &str, at: u64) -> bool { if !self.seen.insert(id.to_string()) { return false; } - self.order.push_back(id.to_string()); + self.order.push_back((id.to_string(), at)); if self.order.len() > MAX_PROCESSED_IDS { - if let Some(old) = self.order.pop_front() { + if let Some((old, _)) = self.order.pop_front() { self.seen.remove(&old); } } @@ -202,6 +228,11 @@ impl Ingest { } } +/// Whether an event created at `created_at` is too old to act on. +fn is_stale(created_at: u64, now_secs: u64) -> bool { + created_at + MAX_EVENT_AGE_SECS < now_secs +} + #[cfg(test)] mod tests { use super::*; @@ -286,12 +317,44 @@ mod tests { let first = f.event(INPUT); assert!(f.accept(&first).is_some()); for i in 0..MAX_PROCESSED_IDS { - assert!(f.ingest.mark(&format!("filler{i}"))); + assert!(f.ingest.mark(&format!("filler{i}"), NOW)); } assert!(f.accept(&first).is_some(), "the oldest id aged out"); assert_eq!(f.ingest.processed_ids().len(), MAX_PROCESSED_IDS); } + #[test] + fn an_id_is_forgotten_once_its_event_is_too_old_to_act_on() { + let mut f = Fixture::new(); + let ev = f.event(INPUT); + assert!(f.accept(&ev).is_some()); + // Still guarded while a replay could be acted on. + let phone = f.phone.clone(); + let next = f.event_from(&phone, INPUT, NOW + MAX_EVENT_AGE_SECS); + assert!(f.ingest.accept(&next, &f.bridge, &[], NOW + MAX_EVENT_AGE_SECS).is_some()); + assert!(f.ingest.processed_ids().contains(&ev.id)); + assert!(f.ingest.accept(&ev, &f.bridge, &[], NOW + MAX_EVENT_AGE_SECS).is_none(), "a replay in the window is a duplicate"); + // One second later the event is stale: its id goes, and a replay of + // it is still refused, by its age. + let later = NOW + MAX_EVENT_AGE_SECS + 1; + let last = f.event_from(&phone, INPUT, later); + assert!(f.ingest.accept(&last, &f.bridge, &[], later).is_some()); + assert!(!f.ingest.processed_ids().contains(&ev.id), "the stale id is no longer stored"); + assert!(f.ingest.accept(&ev, &f.bridge, &[], later).is_none(), "a stale replay is still not run"); + } + + #[test] + fn ids_loaded_at_a_restart_are_kept_while_the_cursor_is_recent() { + // The store holds ids only; each takes the cursor's time on load. + let mut f = Fixture::new(); + let ev = f.event(INPUT); + f.ingest = Ingest::new(NOW, vec![ev.id.clone()]); + f.ingest.forget_stale(NOW + MAX_EVENT_AGE_SECS); + assert!(f.accept(&ev).is_none(), "the replay after a restart is a duplicate"); + f.ingest.forget_stale(NOW + MAX_EVENT_AGE_SECS + 1); + assert!(f.ingest.processed_ids().is_empty()); + } + #[test] fn live_input_is_read_without_taking_a_dedup_slot_or_moving_the_cursor() { let mut f = Fixture::new(); diff --git a/crates/bridge-core/src/io.rs b/crates/bridge-core/src/io.rs index b56505f3..1787ec1e 100644 --- a/crates/bridge-core/src/io.rs +++ b/crates/bridge-core/src/io.rs @@ -14,6 +14,18 @@ use serde::{Deserialize, Serialize}; use crate::engine::{PendingDevice, TerminalAllowlist, TerminalAuditRecord}; +/// What the allowlist file is told of the devices: the refused ones, and the +/// devices a client of a paired identity still is. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TerminalDevices { + pub pending: Vec, + /// A device is the first key its client granted; once that client is + /// gone (its key lapsed, it was dropped for a newer one, or its identity + /// was unpaired) the same phone comes back as a new device, so an + /// approval of the old one only lingers. + pub live: std::collections::BTreeSet, +} + /// A command event from a phone, signature already verified. `content` is /// still NIP-44 ciphertext. #[derive(Debug, Clone, PartialEq, Eq)] @@ -98,6 +110,12 @@ pub struct PhoneClient { /// misses what happens before it renews. #[serde(skip)] pub present_until_ms: u64, + /// The client is present only because the bridge restarted and assumed + /// so: it has not been heard from since. Its first command is then a + /// return, answered with a heartbeat of its own, since it may have been + /// away long before the restart. + #[serde(skip)] + pub presumed: bool, /// What the client watches for live text. Not stored either: a bridge /// that has just restarted does not know, so it sends every session's /// live text until the client says again. @@ -291,10 +309,11 @@ pub enum Effect { /// Hang up a terminal: SIGHUP, then kill once it does not go. The engine /// has already ended it; this is the shell's part. TerminalClose { term: String }, - /// The devices waiting for the owner's approval changed: write them where - /// the owner lists them (`codedeck-bridge terminal pending`). Sent whole, - /// so the file always says what the engine knows. - TerminalPending(Vec), + /// The allowlist's devices changed: write the ones waiting for the + /// owner's approval where the owner lists them (`codedeck-bridge terminal + /// pending`), and drop every approved device no client holds any more. + /// Sent whole, so the file always says what the engine knows. + TerminalDevices(TerminalDevices), /// One line for the terminal audit, in the order the engine decided it. /// The engine holds no file: the runtime chains the line to the one /// before it and stores it, and denies the audit to no one running as diff --git a/crates/bridge-core/src/registry.rs b/crates/bridge-core/src/registry.rs index 2c5029b0..0cb47d39 100644 --- a/crates/bridge-core/src/registry.rs +++ b/crates/bridge-core/src/registry.rs @@ -6,9 +6,13 @@ //! when anything in it changes. On start every record is resumed, so a //! bridge restart forgets nothing. //! - A session leaves the phone only through an explicit tombstone in the -//! heartbeat's `removedSessions` (the last [`TOMBSTONE_CAP`] ids). Absence -//! from the list never deletes, so a heartbeat that happens to be empty -//! cannot wipe the phone. +//! heartbeat's `removedSessions`. Absence from the list never deletes, so +//! a heartbeat that happens to be empty cannot wipe the phone. +//! - A tombstone is needed once per phone, not on every heartbeat: a client +//! that is present applies it from the next one. So the shared heartbeat +//! carries only the tombstones of the last [`TOMBSTONE_RECENT_MS`], and a +//! client coming back (whose heartbeat is its own) gets every one kept — +//! the last [`TOMBSTONE_CAP`], for at most [`TOMBSTONE_KEEP_MS`]. use std::collections::VecDeque; @@ -16,6 +20,12 @@ use protocol::common::{RemoteSessionInfo, SessionState}; use serde::{Deserialize, Serialize}; pub const TOMBSTONE_CAP: usize = 100; +/// How long a tombstone rides every heartbeat: far longer than any present +/// client goes without one. +pub const TOMBSTONE_RECENT_MS: u64 = 24 * 3600 * 1000; +/// How long a tombstone is kept for a client that was away. A phone away +/// longer than this keeps the session, shown stale, until removed by hand. +pub const TOMBSTONE_KEEP_MS: u64 = 30 * 24 * 3600 * 1000; fn is_false(b: &bool) -> bool { !*b @@ -95,12 +105,20 @@ impl SessionRecord { } } +/// A removed session's id, and when it was removed (ms). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Tombstone { + pub id: String, + pub removed_at: u64, +} + /// The stored registry document. #[derive(Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RegistryDoc { pub sessions: Vec, - pub removed_sessions: Vec, + pub removed_sessions: Vec, } impl RegistryDoc { @@ -138,41 +156,66 @@ impl RegistryDoc { } }) .collect(); - let mut removed: Vec = raw - .removed_sessions - .into_iter() - .filter_map(|v| v.as_str().map(str::to_string)) - .collect(); + // An entry that is not a tombstone is skipped like an unreadable + // record. + let mut removed: Vec = + raw.removed_sessions.into_iter().filter_map(|v| serde_json::from_value(v).ok()).collect(); let excess = removed.len().saturating_sub(TOMBSTONE_CAP); removed.drain(..excess); Self { sessions, removed_sessions: removed } } } -/// Ids of removed sessions, oldest first, capped at [`TOMBSTONE_CAP`]. +/// Removed sessions, oldest first, capped at [`TOMBSTONE_CAP`]. #[derive(Debug, Default)] -pub struct Tombstones(VecDeque); +pub struct Tombstones(VecDeque); impl Tombstones { - pub fn new(ids: Vec) -> Self { + pub fn new(stored: Vec) -> Self { let mut t = Self::default(); - for id in ids { - t.add(&id); + for stone in stored { + t.add(&stone.id, stone.removed_at); } t } - pub fn add(&mut self, id: &str) { - if self.0.iter().any(|x| x == id) { + /// `id` was removed at `now` (ms). A second removal keeps the first. + pub fn add(&mut self, id: &str, now: u64) { + if self.0.iter().any(|t| t.id == id) { return; } - self.0.push_back(id.to_string()); + self.0.push_back(Tombstone { id: id.to_string(), removed_at: now }); while self.0.len() > TOMBSTONE_CAP { self.0.pop_front(); } } + /// Drop tombstones older than [`TOMBSTONE_KEEP_MS`]; whether any went. + pub fn prune(&mut self, now: u64) -> bool { + let before = self.0.len(); + self.0.retain(|t| now.saturating_sub(t.removed_at) < TOMBSTONE_KEEP_MS); + self.0.len() != before + } + + /// Every id kept: what a client coming back may have missed. pub fn ids(&self) -> Vec { + self.0.iter().map(|t| t.id.clone()).collect() + } + + /// The ids removed within [`TOMBSTONE_RECENT_MS`] of `now`: what the + /// shared heartbeat carries. + pub fn recent_ids(&self, now: u64) -> Vec { + self.0.iter().filter(|t| now.saturating_sub(t.removed_at) < TOMBSTONE_RECENT_MS).map(|t| t.id.clone()).collect() + } + + /// Whether every tombstone kept is a recent one, so the shared + /// heartbeat carries them all. + pub fn all_recent(&self, now: u64) -> bool { + self.0.iter().all(|t| now.saturating_sub(t.removed_at) < TOMBSTONE_RECENT_MS) + } + + /// The stored form. + pub fn stored(&self) -> Vec { self.0.iter().cloned().collect() } } @@ -216,10 +259,11 @@ mod tests { #[test] fn a_registry_round_trips_through_its_stored_form() { - let doc = RegistryDoc { sessions: vec![record("a"), record("b")], removed_sessions: vec!["x".into()] }; + let stone = Tombstone { id: "x".into(), removed_at: 7 }; + let doc = RegistryDoc { sessions: vec![record("a"), record("b")], removed_sessions: vec![stone.clone()] }; let back = RegistryDoc::load(&serde_json::to_string(&doc).unwrap()); assert_eq!(back.sessions, doc.sessions); - assert_eq!(back.removed_sessions, ["x"]); + assert_eq!(back.removed_sessions, [stone]); } #[test] @@ -243,15 +287,29 @@ mod tests { fn tombstones_cap_at_100_fifo_without_duplicates() { let mut t = Tombstones::default(); for i in 0..105 { - t.add(&format!("s{i}")); + t.add(&format!("s{i}"), i); } - t.add("s104"); + t.add("s104", 999); let ids = t.ids(); assert_eq!(ids.len(), TOMBSTONE_CAP); assert_eq!(ids[0], "s5"); assert_eq!(ids.last().unwrap(), "s104"); } + #[test] + fn the_shared_heartbeat_carries_a_day_and_a_client_back_a_month() { + const DAY: u64 = 24 * 3600 * 1000; + let mut t = Tombstones::default(); + t.add("old", 0); + t.add("new", 40 * DAY); + let now = 40 * DAY + DAY / 2; + assert_eq!(t.recent_ids(now), ["new"]); + assert_eq!(t.ids(), ["old", "new"]); + assert!(t.prune(now), "a tombstone past the keep window goes"); + assert_eq!(t.ids(), ["new"]); + assert!(!t.prune(now)); + } + #[test] fn remote_info_maps_the_record() { let mut rec = record("0123456789ab"); diff --git a/crates/bridge-core/tests/session_keys.rs b/crates/bridge-core/tests/session_keys.rs index 6849aa1b..101941d8 100644 --- a/crates/bridge-core/tests/session_keys.rs +++ b/crates/bridge-core/tests/session_keys.rs @@ -370,6 +370,21 @@ fn a_restarted_bridge_counts_every_client_present() { assert_eq!(heartbeats(&rig.take())[0].len(), 2, "until each renews or says it is away"); } +/// Presence a restarted bridge only assumed is not presence: the client may +/// have been away for days before the restart, and what it missed rides a +/// heartbeat of its own. Its first word after the restart gets one; the next +/// is an ordinary renewal. +#[test] +fn a_clients_first_word_after_a_restart_gets_a_heartbeat_of_its_own() { + let mut rig = Rig::new(); + let key = generate_keypair(); + grant(&mut rig, &key, NOW_SECS + DAY); + let mut rig = rig.restart(); + rig.take(); + assert_eq!(heartbeats(&presence(&mut rig, &key, false)), to(&rig.phone, &key)); + assert!(heartbeats(&presence(&mut rig, &key, false)).is_empty(), "a renewal is not a return"); +} + #[test] fn a_sync_goes_to_the_client_that_asked_alone() { let mut rig = Rig::new(); diff --git a/crates/bridge-core/tests/sessions.rs b/crates/bridge-core/tests/sessions.rs index 827e125c..8ed3ceb6 100644 --- a/crates/bridge-core/tests/sessions.rs +++ b/crates/bridge-core/tests/sessions.rs @@ -588,6 +588,31 @@ fn close_session_ends_it_tombstones_it_and_forgets_its_transcript() { assert!(rig.transcripts.entries(&s).is_empty()); } +/// A tombstone is applied by a present phone from the next heartbeat, so the +/// shared heartbeat stops repeating it after a day; a phone coming back asks +/// for a heartbeat of its own, and that one still carries it — across a +/// restart too, since the removal time is stored with it. +#[test] +fn an_old_tombstone_leaves_the_shared_heartbeat_but_reaches_a_phone_that_asks() { + let mut rig = Rig::new(); + rig.host_up(); + let s = rig.ready_session("alpha"); + rig.send(json!({"v":12,"type":"close-session","sessionId":s})); + rig.advance(25 * 3600 * 1000); + rig.take(); + + let mut rig = rig.restart(); + rig.host_up(); + rig.take(); + rig.advance(60_000); + let shared = last_heartbeat(&rig.messages()); + assert_eq!(shared.removed_sessions, None, "a day-old tombstone rides the shared heartbeat no more"); + + rig.send(json!({"v":12,"type":"refresh-sessions"})); + let own = last_heartbeat(&rig.messages()); + assert_eq!(own.removed_sessions, Some(vec![s.clone()]), "the phone's own heartbeat carries every tombstone kept"); +} + fn conversation_deletes(frames: &[agent_protocol::BridgeFrame]) -> Vec<(String, String, String)> { frames .iter() diff --git a/crates/bridge-core/tests/terminal.rs b/crates/bridge-core/tests/terminal.rs index e758d01a..caaa17a9 100644 --- a/crates/bridge-core/tests/terminal.rs +++ b/crates/bridge-core/tests/terminal.rs @@ -62,7 +62,7 @@ impl World { &phone, json!({"v":12,"type":"session-key","sessionKey":{ "pubkeyHex": key.pubkey_hex, "bridgePubkeyHex": bridge, - "expiresAt": NOW_SECS + 90 * DAY, "label": label}}), + "expiresAt": NOW_SECS + 30 * DAY, "label": label}}), Via::Direct, ); self.rig.take(); @@ -110,7 +110,7 @@ impl World { json!({"v":12,"type":"pair-request","npub": phone.npub, "pubkeyHex": phone.pubkey_hex, "label": label, "token": token, "sessionKey": {"pubkeyHex": key.pubkey_hex, "bridgePubkeyHex": bridge, - "expiresAt": NOW_SECS + 90 * DAY, "label": label}}), + "expiresAt": NOW_SECS + 30 * DAY, "label": label}}), Via::Pairing, ); self.rig.take(); @@ -306,7 +306,7 @@ fn audits_of(acts: &[Effect], what: TerminalAuditWhat) -> Vec Vec> { acts.iter() .filter_map(|e| match e { - Effect::TerminalPending(p) => Some(p.clone()), + Effect::TerminalDevices(d) => Some(d.pending.clone()), _ => None, }) .collect() @@ -581,6 +581,45 @@ fn an_open_without_a_client_is_refused_rather_than_ignored() { assert!(pending(&acts).is_empty(), "and none to record"); } +/// A device is its client's first key: once the client is gone, the phone +/// comes back as a new device, so the old approval is dropped from the file +/// rather than left there for good. +#[test] +fn an_approved_device_whose_client_lapsed_leaves_the_allowlist() { + let mut world = World::new(); + let key = generate_keypair(); + let short_lived = device_id(&key.pubkey_hex); + let bridge = world.rig.bridge.pubkey_hex.clone(); + let phone = world.rig.phone.clone(); + world.rig.phone_event( + &phone, + json!({"v":12,"type":"session-key","sessionKey":{ + "pubkeyHex": key.pubkey_hex, "bridgePubkeyHex": bridge, "expiresAt": NOW_SECS + 120, "label": "Tablet"}}), + Via::Direct, + ); + let first = world.device.clone(); + world.allow(&[&first, &short_lived]); + world.rig.take(); + + world.rig.advance(180_000); + let written: Vec<_> = world + .rig + .take() + .into_iter() + .filter_map(|e| match e { + Effect::TerminalDevices(d) => Some(d), + _ => None, + }) + .collect(); + assert_eq!(written.len(), 1, "the file is told once: {written:?}"); + assert_eq!(written[0].live.iter().collect::>(), [&first], "only the device whose client remains is live"); + + // The device that remains still opens a terminal. + world.command(json!({"v":12,"type":"terminal-open","openId":"o1","cols":80,"rows":24})); + let (_, msgs) = world.take(); + assert!(exits(&msgs).is_empty(), "the remaining device is still approved"); +} + // --- input --- #[test] @@ -1269,7 +1308,7 @@ fn a_device_survives_its_keys_rotation_but_a_new_client_is_a_new_device() { &world.key, json!({"v":12,"type":"session-key","sessionKey":{ "pubkeyHex": next.pubkey_hex, "bridgePubkeyHex": bridge, - "expiresAt": NOW_SECS + 90 * DAY, "replaces": world.key.pubkey_hex, "label": "Pixel 8 Pro"}}), + "expiresAt": NOW_SECS + 30 * DAY, "replaces": world.key.pubkey_hex, "label": "Pixel 8 Pro"}}), Via::Direct, ); world.rig.take(); @@ -1302,7 +1341,7 @@ fn a_state_file_from_before_devices_existed_still_hydrates() { let phones = json!([{"npub": world.rig.phone.npub, "pubkeyHex": world.rig.phone.pubkey_hex, "label":"Pixel", "pairedAt":"t", "clients":[{"key":{"pubkeyHex": world.key.pubkey_hex, "bridgePubkeyHex": world.rig.bridge.pubkey_hex, - "expiresAt": NOW_SECS + 90 * DAY, "label":"Pixel 8"}}]}]); + "expiresAt": NOW_SECS + 30 * DAY, "label":"Pixel 8"}}]}]); world.rig.store.put("pairedPhones", &phones.to_string()); let mut restarted = world.rig.restart(); restarted.take(); diff --git a/crates/bridge-runtime/src/runtime.rs b/crates/bridge-runtime/src/runtime.rs index 0dc53304..ee84cc68 100644 --- a/crates/bridge-runtime/src/runtime.rs +++ b/crates/bridge-runtime/src/runtime.rs @@ -538,16 +538,16 @@ impl Runtime { } } Effect::TerminalClose { term } => self.terminals.close(&term), - Effect::TerminalPending(pending) => { - // The refused devices go to the allowlist's writer thread, - // which reads the file, replaces `pending` with this list and - // writes it — never the runtime's thread, which an unapproved + Effect::TerminalDevices(devices) => { + // The devices go to the allowlist's writer thread, which + // reads the file, replaces `pending`, drops the approved + // devices no client is any more, and writes it — never the runtime's thread, which an unapproved // device spamming `terminal-open` would otherwise stall with a // read per message. The engine already holds its own pending // list, and the write's new mtime reaches it through the // per-message and periodic allowlist checks, so nothing is // sent back to it here. - self.allowlist_writer.set_pending(pending); + self.allowlist_writer.set(devices); } Effect::Audit(record) => self.audit.record(record), } diff --git a/crates/bridge-runtime/src/terminal_allow.rs b/crates/bridge-runtime/src/terminal_allow.rs index 9aacfd2c..fb43961d 100644 --- a/crates/bridge-runtime/src/terminal_allow.rs +++ b/crates/bridge-runtime/src/terminal_allow.rs @@ -28,7 +28,7 @@ use std::sync::mpsc::{self, Receiver, Sender}; use std::thread::JoinHandle; use std::time::{Duration, Instant, SystemTime}; -use bridge_core::{PendingDevice, TerminalAllowlist}; +use bridge_core::{PendingDevice, TerminalAllowlist, TerminalDevices}; /// The file's name in the bridge home. pub const ALLOWLIST_FILE: &str = "terminal-allow.json"; @@ -134,12 +134,13 @@ impl Cache { /// The engine's refused-device list merged with the approved devices as they /// are on disk now (the owner may have approved or withdrawn one in the CLI -/// while this bridge ran). Only the allowlist's writer thread calls this, from -/// its own thread: reading the file is file work, and the bridge's runtime -/// thread must not do it. -fn merge_pending(path: &Path, pending: Vec) -> TerminalAllowlist { +/// while this bridge ran), less those no client is any more. Only the +/// allowlist's writer thread calls this, from its own thread: reading the +/// file is file work, and the bridge's runtime thread must not do it. +fn merge(path: &Path, devices: TerminalDevices) -> TerminalAllowlist { let mut list = read(path); - list.pending = pending; + list.pending = devices.pending; + list.devices.retain(|d| devices.live.contains(d)); list } @@ -151,13 +152,13 @@ fn merge_pending(path: &Path, pending: Vec) -> TerminalAllowlist /// channel and writes only the newest list, so a device spamming /// `terminal-open` costs the file one write, not one per message. pub struct Writer { - tx: Option>>, + tx: Option>, thread: Option>, } impl Writer { pub fn start(path: PathBuf) -> Writer { - let (tx, rx) = mpsc::channel::>(); + let (tx, rx) = mpsc::channel::(); let thread = std::thread::Builder::new() .name("terminal-allow".into()) .spawn(move || writer_loop(path, rx)) @@ -166,11 +167,11 @@ impl Writer { Writer { tx: Some(tx), thread } } - /// Hand the refused devices over to be written. Never blocks the caller on - /// the file, and only the newest list matters: a burst coalesces. - pub fn set_pending(&self, pending: Vec) { + /// Hand the devices over to be written. Never blocks the caller on the + /// file, and only the newest list matters: a burst coalesces. + pub fn set(&self, devices: TerminalDevices) { if let Some(tx) = &self.tx { - if tx.send(pending).is_err() { + if tx.send(devices).is_err() { log::warn!("[Terminal] the allowlist writer thread is gone; a change was lost"); } } @@ -187,15 +188,15 @@ impl Drop for Writer { } } -/// The writer thread: take the newest refused-device list, merge it with the -/// file as it is now, and write it — all under the lock, so the CLI editing -/// the file at the same moment cannot lose this write (nor this one the CLI's). -fn writer_loop(path: PathBuf, rx: Receiver>) { - while let Ok(mut pending) = rx.recv() { +/// The writer thread: take the newest device lists, merge them with the file +/// as it is now, and write it — all under the lock, so the CLI editing the +/// file at the same moment cannot lose this write (nor this one the CLI's). +fn writer_loop(path: PathBuf, rx: Receiver) { + while let Ok(mut devices) = rx.recv() { // Everything that arrived while this one waited is superseded by the // newest: only the last list needs writing. while let Ok(newer) = rx.try_recv() { - pending = newer; + devices = newer; } let _lock = match Lock::acquire(&path) { Ok(lock) => lock, @@ -204,7 +205,7 @@ fn writer_loop(path: PathBuf, rx: Receiver>) { continue; } }; - let list = merge_pending(&path, pending); + let list = merge(&path, devices); if let Err(err) = write(&path, &list) { log::warn!("[Terminal] cannot write the allowlist: {err}"); } @@ -458,21 +459,42 @@ mod tests { assert_eq!(cache.changed(), None); } + /// What the engine sends: `pending`, with `live` naming every device a + /// client still is (the pending ones are clients too). + fn devices(pending: &[(&str, &str)], live: &[&str]) -> TerminalDevices { + let pending = pending_of(pending); + let live = live.iter().map(|d| d.to_string()).chain(pending.iter().map(|p| p.device.clone())).collect(); + TerminalDevices { pending, live } + } + #[test] - fn merge_pending_keeps_the_approved_devices_as_on_disk() { + fn merge_keeps_the_approved_devices_as_on_disk() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join(ALLOWLIST_FILE); // The owner approved one device in the CLI since the bridge last read. write(&path, &list_with(&["aaaa"], &[])).unwrap(); - let pending = vec![PendingDevice { device: "cccc".into(), label: Some("Pixel 8".into()), last_seen_ms: 5 }]; - let merged = merge_pending(&path, pending.clone()); + let sent = devices(&[("cccc", "Pixel 8")], &["aaaa"]); + let merged = merge(&path, sent.clone()); assert!(merged.devices.contains("aaaa"), "the device on disk is kept"); - assert_eq!(merged.pending, pending); + assert_eq!(merged.pending, sent.pending); // Merging only reads: the file is unchanged until a write follows. assert!(read(&path).pending.is_empty()); } + #[test] + fn an_approved_device_no_client_is_any_more_is_dropped() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(ALLOWLIST_FILE); + write(&path, &list_with(&["aaaa", "gone"], &[])).unwrap(); + + let writer = Writer::start(path.clone()); + writer.set(devices(&[], &["aaaa"])); + drop(writer); // joins, so the write has landed + let list = read(&path); + assert_eq!(list.devices.iter().map(String::as_str).collect::>(), ["aaaa"]); + } + #[test] fn the_writer_merges_and_writes_on_its_own_thread() { let dir = tempfile::tempdir().unwrap(); @@ -481,7 +503,7 @@ mod tests { write(&path, &list_with(&["aaaa"], &[])).unwrap(); let writer = Writer::start(path.clone()); - writer.set_pending(pending_of(&[("cccc", "Pixel 8")])); + writer.set(devices(&[("cccc", "Pixel 8")], &["aaaa"])); drop(writer); // joins, so the write has landed let list = read(&path); assert!(list.devices.contains("aaaa"), "the approved device on disk is kept"); @@ -496,9 +518,9 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join(ALLOWLIST_FILE); let writer = Writer::start(path.clone()); - writer.set_pending(pending_of(&[("d1", "one")])); - writer.set_pending(pending_of(&[("d2", "two")])); - writer.set_pending(pending_of(&[("d3", "three")])); + writer.set(devices(&[("d1", "one")], &[])); + writer.set(devices(&[("d2", "two")], &[])); + writer.set(devices(&[("d3", "three")], &[])); drop(writer); // joins, so the queue has drained let list = read(&path); diff --git a/crates/bridge-runtime/src/web.rs b/crates/bridge-runtime/src/web.rs index c48fe845..e8a93942 100644 --- a/crates/bridge-runtime/src/web.rs +++ b/crates/bridge-runtime/src/web.rs @@ -1370,6 +1370,7 @@ mod tests { device: "3f9a1c0e7b2d4a58".into(), label: Some("Pixel 8a".into()), present_until_ms: 1_700_000_000_000, + presumed: false, watch: bridge_core::WatchState::default(), watch_terminal: bridge_core::WatchState::default(), }], diff --git a/crates/bridge-runtime/tests/state.rs b/crates/bridge-runtime/tests/state.rs index eb71de69..02f89f23 100644 --- a/crates/bridge-runtime/tests/state.rs +++ b/crates/bridge-runtime/tests/state.rs @@ -5,7 +5,7 @@ use std::fs; -use bridge_core::registry::{RegistryDoc, SessionRecord}; +use bridge_core::registry::{RegistryDoc, SessionRecord, Tombstone}; use bridge_core::settings::{ProviderProfile, StoredCredentials, StoredProfiles}; use bridge_core::{store_keys, PairedPhone}; use bridge_runtime::state::StateFile; @@ -64,7 +64,7 @@ fn a_representative_engine_state_survives_a_reopen() { }; let values = [ (store_keys::PAIRED_PHONES, serde_json::to_string(&vec![phone]).unwrap()), - (store_keys::REGISTRY, serde_json::to_string(&RegistryDoc { sessions: vec![record("s1"), record("s2")], removed_sessions: vec!["s0".into()] }).unwrap()), + (store_keys::REGISTRY, serde_json::to_string(&RegistryDoc { sessions: vec![record("s1"), record("s2")], removed_sessions: vec![Tombstone { id: "s0".into(), removed_at: 1_800_000_000_000 }] }).unwrap()), (store_keys::LAST_SEEN, "1800000000".into()), (store_keys::PROCESSED_IDS, serde_json::to_string(&["ev1", "ev2", "ev3"]).unwrap()), (store_keys::LIST_REV, "1800000123".into()), diff --git a/crates/client-core/src/stores/session_key.rs b/crates/client-core/src/stores/session_key.rs index 5dfd82a5..74c104a7 100644 --- a/crates/client-core/src/stores/session_key.rs +++ b/crates/client-core/src/stores/session_key.rs @@ -35,8 +35,10 @@ use super::machines::MachinesState; /// How long a key, and so every grant of it, runs: the protocol maximum, /// less an hour of headroom for a phone clock running ahead of the bridge's. pub const GRANT_LIFETIME_SECS: u64 = SESSION_KEY_MAX_LIFETIME_SECS - 3600; -/// A key is replaced by a fresh one once less than this is left of it. -pub const RENEW_BEFORE_SECS: u64 = 30 * 24 * 3600; +/// A key is replaced by a fresh one once less than this is left of it: a +/// third of its life, so a phone opened every few weeks rotates before its +/// key lapses and stays the same device. +pub const RENEW_BEFORE_SECS: u64 = 10 * 24 * 3600; /// A grant this close to lapsing is no longer used: commands go through the /// identity rather than risk arriving after the bridge dropped the key. const USE_MARGIN_SECS: u64 = 600; @@ -391,12 +393,13 @@ mod tests { } #[test] - fn a_key_is_replaced_a_month_before_it_lapses_and_kept_as_the_previous() { + fn a_key_is_replaced_before_it_lapses_and_kept_as_the_previous() { let (mut ring, _) = SessionKeyRing::load(None, NOW); let first = ring.current.pubkey_hex().to_string(); let lifetime_ms = GRANT_LIFETIME_SECS * 1000; - assert!(!ring.rotate_if_due(NOW + lifetime_ms - 31 * DAY_MS)); - let later = NOW + lifetime_ms - 29 * DAY_MS; + let renew_ms = RENEW_BEFORE_SECS * 1000; + assert!(!ring.rotate_if_due(NOW + lifetime_ms - renew_ms - DAY_MS)); + let later = NOW + lifetime_ms - renew_ms + DAY_MS; assert!(ring.rotate_if_due(later)); assert_ne!(ring.current.pubkey_hex(), first); assert_eq!(ring.previous.as_ref().unwrap().pubkey_hex(), first); @@ -416,10 +419,12 @@ mod tests { let first = ring.first_pubkey_hex().to_string(); assert_eq!(ring.device_id(), protocol::recipients::device_id(&first)); let lifetime_ms = GRANT_LIFETIME_SECS * 1000; - assert!(ring.rotate_if_due(NOW + lifetime_ms - 29 * DAY_MS)); + let renew_ms = RENEW_BEFORE_SECS * 1000; + let first_rotation = NOW + lifetime_ms - renew_ms + DAY_MS; + assert!(ring.rotate_if_due(first_rotation)); // The second rotation drops the first key from the ring; the device // this install is stays the same, since a bridge derives it once. - assert!(ring.rotate_if_due(NOW + 2 * lifetime_ms - 29 * DAY_MS)); + assert!(ring.rotate_if_due(first_rotation + lifetime_ms - renew_ms + DAY_MS)); assert_eq!(ring.first_pubkey_hex(), first); let (again, _) = SessionKeyRing::load(Some(&ring.encode()), NOW); assert_eq!(again.first_pubkey_hex(), first); diff --git a/crates/client-runtime/src/runtime/tests/session_keys.rs b/crates/client-runtime/src/runtime/tests/session_keys.rs index aad5382d..3a75d929 100644 --- a/crates/client-runtime/src/runtime/tests/session_keys.rs +++ b/crates/client-runtime/src/runtime/tests/session_keys.rs @@ -290,7 +290,9 @@ async fn a_quiet_client_renews_its_presence_and_says_when_it_goes_away() { let phone = keypair_from_secret_hex(SEC_PHONE).unwrap(); let machine = generate_keypair(); let start_ms: u64 = 1_000_000; - let key = SessionKey { keypair: generate_keypair(), expires_at: start_ms / 1000 + 80 * 24 * 3600 }; + // A fresh key: far from its renewal window. + let lifetime = client_core::stores::session_key::GRANT_LIFETIME_SECS; + let key = SessionKey { keypair: generate_keypair(), expires_at: start_ms / 1000 + lifetime }; let store = MemoryKeyStore::default(); *store.0.borrow_mut() = Some(SessionKeyRing::single(key.clone()).encode()); let mut state = client_core::stores::machines::MachinesState::default(); @@ -425,7 +427,9 @@ async fn a_rotated_key_is_granted_and_the_old_one_dropped_once_confirmed() { let phone = keypair_from_secret_hex(SEC_PHONE).unwrap(); let machine = generate_keypair(); let now_secs = 1_000; - let old = SessionKey { keypair: generate_keypair(), expires_at: now_secs + 10 * 24 * 3600 }; + // Inside the renewal window, so the boot rotates it. + let left = client_core::stores::session_key::RENEW_BEFORE_SECS / 2; + let old = SessionKey { keypair: generate_keypair(), expires_at: now_secs + left }; let store = MemoryKeyStore::default(); *store.0.borrow_mut() = Some(SessionKeyRing::single(old.clone()).encode()); let mut state = client_core::stores::machines::MachinesState::default(); diff --git a/crates/protocol/src/commands.rs b/crates/protocol/src/commands.rs index 20464234..b09ea410 100644 --- a/crates/protocol/src/commands.rs +++ b/crates/protocol/src/commands.rs @@ -458,8 +458,11 @@ pub const PRESENCE_LEASE_SECS: u64 = 25 * 60; /// How often a running client renews its presence. pub const PRESENCE_RENEW_SECS: u64 = 10 * 60; -/// The longest a [`SessionKeyGrant`] may run. -pub const SESSION_KEY_MAX_LIFETIME_SECS: u64 = 90 * 24 * 3600; +/// The longest a [`SessionKeyGrant`] may run. A key that leaks stops working +/// within this, and a phone not opened for this long is forgotten as a +/// client (it comes back as a new device); a running phone rotates its key +/// well before. +pub const SESSION_KEY_MAX_LIFETIME_SECS: u64 = 30 * 24 * 3600; /// `session-key`: grant (or rotate to) a session key. Like every command, /// authored by the paired identity. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 79dd62d5..60d01222 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -711,7 +711,7 @@ on relays and image servers only ever see the identity. From the grant on: never earlier. Each client's copy is encrypted to its newest key; the first heartbeat after the grant is the client's confirmation; -- `expiresAt` (seconds) is at most 90 days ahead; a client whose key lapses +- `expiresAt` (seconds) is at most 30 days ahead; a client whose key lapses is dropped, and an identity left with none is encrypted to again. The phone grants a new key before then; - an event authored by a session key is not heard (only paired identities @@ -866,6 +866,9 @@ is capped at 32 MiB. - Absence from `sessions[]` NEVER deletes on the phone — it marks `stale`. - Removal happens only via `removedSessions[]` tombstones (or a user delete). + The heartbeat every present client shares carries the tombstones of the + last day; a heartbeat a client asked for (coming back, or + `refresh-sessions`) carries every one kept: the last 100, for 30 days. - A clean bridge shutdown publishes the full list with every session `state: 'offline'` and `machineOffline: true` — never an empty list. diff --git a/docs/TERMINAL.md b/docs/TERMINAL.md index 4eea7f22..13d5d40f 100644 --- a/docs/TERMINAL.md +++ b/docs/TERMINAL.md @@ -34,6 +34,10 @@ Three gates, all of which must pass: the bridge runs (atomic file writes; the bridge re-reads the file, cached by modification time, on every terminal message), and **withdrawing a device closes its terminals at once** — the kill switch for a lost phone. + A device is its client's first session key, so once that client is gone + (its key lapsed, it was dropped for a newer client, or its identity was + unpaired) the bridge drops the device from the file at the next + heartbeat: the same phone comes back as a new device, to approve again. Optionally, the **phone can require fingerprint or PIN** (Android `BiometricPrompt`, device credential as fallback) before opening or attaching