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