From db3e8180896eb8ffa592575b1dc439b061877417 Mon Sep 17 00:00:00 2001 From: Jean Date: Sat, 18 Jul 2026 03:04:50 +0000 Subject: [PATCH] db: a gossiped re-home of an account is only a takeover when the credential also changed, so an authority re-provision or migration re-home no longer spuriously logs the user out --- src/engine/db/mod.rs | 36 ++++++++++++++++++++++++++++-------- src/engine/db/tests.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/src/engine/db/mod.rs b/src/engine/db/mod.rs index 8b67c9b..7598131 100644 --- a/src/engine/db/mod.rs +++ b/src/engine/db/mod.rs @@ -1098,11 +1098,18 @@ impl Db { /// the account name if this ingest changed its owner (a registration conflict /// resolved against the local claim), so the caller can log out stale sessions. pub fn ingest(&mut self, entry: LogEntry) -> std::io::Result> { - // Snapshot the incoming account's local owner before applying, to detect a - // takeover (home changed) or a remote drop (it disappears). - let watched: Option<(String, Option)> = match &entry.event { - Event::AccountRegistered(a) => Some((a.name.clone(), self.account(&a.name).map(|c| c.home.clone()))), - Event::AccountDropped { account } => Some((account.clone(), self.account(account).map(|c| c.home.clone()))), + // Snapshot the incoming account's local owner AND credential before applying, + // to tell a takeover (home moved to a *different* account) from a benign + // re-home (the same account re-asserted), and a remote drop (it disappears). + let watched: Option<(String, Option, Option)> = match &entry.event { + Event::AccountRegistered(a) => { + let c = self.account(&a.name); + Some((a.name.clone(), c.map(|c| c.home.clone()), c.and_then(|c| c.scram256.clone()))) + } + Event::AccountDropped { account } => { + let c = self.account(account); + Some((account.clone(), c.map(|c| c.home.clone()), c.and_then(|c| c.scram256.clone()))) + } _ => None, }; let applied = self.log.ingest(entry)?; @@ -1126,9 +1133,22 @@ impl Db { if let Some(event) = applied { apply(&mut self.accounts, &mut self.channels, &mut self.grouped, &mut self.bots, &mut self.host_cfg, &mut self.net, event); } - if let Some((name, prev_home)) = watched { - match (prev_home, self.account(&name).map(|c| c.home.clone())) { - (Some(prev), Some(cur)) if cur != prev => return Ok(Some(IngestEffect::TakenOver(name))), + if let Some((name, prev_home, prev_verifier)) = watched { + let cur = self.account(&name); + match (prev_home, cur.map(|c| c.home.clone())) { + (Some(prev), Some(cur_home)) if cur_home != prev => { + // The account's home moved. Treat it as a hostile takeover — which + // logs the local user out with a "collided with another network" + // notice — ONLY if the credential changed too. A re-home that keeps + // the same verifier is the SAME account being re-asserted (an + // authority re-provision, an import, or an origin change during + // migration): the local login is still valid, so it must not be + // disrupted. This is what spuriously logged users out and (before + // the channel-keep fix) dropped their channels during the cutover. + if cur.and_then(|c| c.scram256.clone()) != prev_verifier { + return Ok(Some(IngestEffect::TakenOver(name))); + } + } (Some(_), None) => return Ok(Some(IngestEffect::Dropped(name))), _ => {} } diff --git a/src/engine/db/tests.rs b/src/engine/db/tests.rs index 99f8001..ac28d11 100644 --- a/src/engine/db/tests.rs +++ b/src/engine/db/tests.rs @@ -54,6 +54,34 @@ assert_eq!(converge(&a, &a), "A"); } + // A gossiped registration that wins the name (earlier ts) moves an account's + // home. That is a hostile takeover — logging the local user out — ONLY if the + // credential differs; re-asserting the SAME account (same verifier) must not. + #[test] + fn rehome_keeping_the_verifier_is_not_a_takeover() { + let acct = |home: &str, ts: u64, verifier: &str| Account { + name: "reverse".into(), email: None, ts, home: home.into(), + scram256: Some(verifier.into()), scram512: None, certfps: vec![], verified: true, + ajoin: vec![], suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: true, + memo_limit: None, greet: String::new(), no_autoop: false, no_protect: false, + hide_status: false, vhost: None, vhost_request: None, last_seen: ts, noexpire: false, + expiry_warned: false, oper_note: None, + }; + let reg = |origin: &str, a: Account| LogEntry::for_test(origin, 1, 1, Event::AccountRegistered(Box::new(a))); + + // Same account, re-homed to B with the SAME verifier — not a takeover. + let mut db = Db::open(tmp("rehome-same"), "A"); + db.ingest(reg("A", acct("A", 100, "V"))).unwrap(); + assert!(db.ingest(reg("B", acct("B", 1, "V"))).unwrap().is_none(), "same-verifier re-home is not a takeover"); + assert_eq!(db.account("reverse").unwrap().home, "B", "the home still moved (conflict resolved)"); + + // A genuinely different account (different verifier) winning the name is one. + let mut db2 = Db::open(tmp("rehome-diff"), "A"); + db2.ingest(reg("A", acct("A", 100, "V"))).unwrap(); + assert!(matches!(db2.ingest(reg("B", acct("B", 1, "OTHER"))).unwrap(), Some(IngestEffect::TakenOver(n)) if n == "reverse"), + "a different credential winning the name is a real takeover"); + } + #[test] fn channel_state_is_node_local_but_persists() { let path = tmp("scope");