From 34bc7d69f043b24f0040433bca754fff5e964a88 Mon Sep 17 00:00:00 2001 From: Jean Date: Thu, 16 Jul 2026 16:53:06 +0000 Subject: [PATCH] grpc: provision sets the verifier on an already-existing account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An Anope import creates accounts (channel ownership, vhosts, certs) but can't carry the one-way password hash, so they exist without a verifier. provision then refused them as AlreadyExists, so the website's backfill could never give migrated members a credential — every one was locked out of password login. Make provision update the verifier in place on an existing account (logged as AccountPasswordSet), and treat an empty scram512 as absent consistently in apply(). Keyed by name, so imported channel/vhost/ban data stays attached. --- Cargo.toml | 2 +- src/engine/db/account.rs | 22 +++++++++++++++++++--- src/engine/db/event.rs | 4 +++- src/engine/db/tests.rs | 27 +++++++++++++++++++++++++++ src/grpc.rs | 6 ++++-- 5 files changed, 54 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 169fb63..ff35178 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ members = [ name = "echo" version = "0.0.1" edition = "2021" -description = "Federated IRC services daemon (protocol-agnostic core, InspIRCd link first)" +description = "Echo the modern IRC Services" license = "AGPL-3.0-or-later" [dependencies] diff --git a/src/engine/db/account.rs b/src/engine/db/account.rs index 6947612..a9736d0 100644 --- a/src/engine/db/account.rs +++ b/src/engine/db/account.rs @@ -42,12 +42,28 @@ impl Db { /// can't clobber a fully-credentialed account). `scram512` empty = /// SCRAM-SHA-512 unavailable for this account (it falls back to 256). pub fn provision_account(&mut self, name: &str, scram256: &str, scram512: &str, email: Option) -> Result<(), RegError> { - if self.exists(name) { - return Err(RegError::Exists); - } if name.is_empty() || scram256.is_empty() { return Err(RegError::Internal); } + // The account may already exist without a verifier — an Anope import + // creates accounts (channel ownership, vhosts, etc.) but can't carry the + // one-way password hash. When the external authority then asserts a + // verifier, set it in place rather than refusing, or migrated users could + // never log in. Keyed by name, so all imported data stays attached. + if self.exists(name) { + self.log + .append(Event::AccountPasswordSet { + account: name.to_string(), + scram256: scram256.to_string(), + scram512: scram512.to_string(), + }) + .map_err(|_| RegError::Internal)?; + if let Some(acct) = self.accounts.get_mut(&key(name)) { + acct.scram256 = Some(scram256.to_string()); + acct.scram512 = (!scram512.is_empty()).then(|| scram512.to_string()); + } + return Ok(()); + } let account = Account { name: name.to_string(), email, diff --git a/src/engine/db/event.rs b/src/engine/db/event.rs index 132e229..21eee6e 100644 --- a/src/engine/db/event.rs +++ b/src/engine/db/event.rs @@ -333,7 +333,9 @@ pub(crate) fn apply(accounts: &mut HashMap, channels: &mut Hash Event::AccountPasswordSet { account, scram256, scram512 } => { if let Some(a) = accounts.get_mut(&key(&account)) { a.scram256 = Some(scram256); - a.scram512 = Some(scram512); + // Empty = not provided (e.g. a verifier-only backfill sets SHA-256 + // alone); keep this in step with the live provision update. + a.scram512 = (!scram512.is_empty()).then_some(scram512); } } Event::AccountDropped { account } => { diff --git a/src/engine/db/tests.rs b/src/engine/db/tests.rs index af2f242..b79afbe 100644 --- a/src/engine/db/tests.rs +++ b/src/engine/db/tests.rs @@ -382,6 +382,33 @@ assert!(db.authenticate("ext", "wrong").is_none(), "wrong password rejected"); } + // The migration flow: an Anope import creates the account with NO verifier + // (one-way hash can't move), then the authority provisions the verifier. That + // second provision must SET the credential on the existing account, not be + // refused — otherwise every migrated member is locked out of password login. + #[test] + fn provision_sets_verifier_on_an_imported_account() { + let p = tmp("prov-imported"); + { + let mut db = Db::open(&p, "N1"); + db.migrate_append(Event::AccountRegistered(Box::new(Account { + name: "mig".into(), email: Some("m@x".into()), ts: 1, home: "N1".into(), + scram256: None, 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: 1, + noexpire: false, expiry_warned: false, oper_note: None, + }))).unwrap(); + } + // Reopen so the imported account is live in memory (as after a restart). + let mut db = Db::open(&p, "N1"); + db.scram_iterations = 4096; + assert!(db.authenticate("mig", "hunter2").is_none(), "imported account has no verifier yet"); + let creds = Db::derive_credentials("hunter2", 4096).unwrap(); + db.provision_account("mig", &creds.scram256, "", None).unwrap(); + assert_eq!(db.authenticate("mig", "hunter2"), Some("mig"), "provision set the verifier on the existing account"); + } + // A peer with an empty log converges from a node that has already compacted. #[test] fn compacted_node_still_converges() { diff --git a/src/grpc.rs b/src/grpc.rs index 6de557c..d3f54a1 100644 --- a/src/grpc.rs +++ b/src/grpc.rs @@ -613,13 +613,15 @@ mod tests { .into_inner(); assert_eq!(prov.status, PbStatus::Ok as i32); - // Backfill is safe to re-run: an existing account isn't clobbered. + // Re-provisioning an existing account SETS its verifier (the migration + // case: an imported account with no credential gets one) — and is safe to + // re-run with the same verifier. let dup = svc .provision(authed(ProvisionRequest { name: "heidi".into(), scram256: creds.scram256.clone(), scram512: String::new(), email: String::new() }, "t")) .await .unwrap() .into_inner(); - assert_eq!(dup.status, PbStatus::AlreadyExists as i32); + assert_eq!(dup.status, PbStatus::Ok as i32); // A missing scram256 is rejected, and a bad bearer never gets in. let invalid = svc