diff --git a/Cargo.lock b/Cargo.lock index 2750545..854c1bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,18 +26,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "argon2" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" -dependencies = [ - "base64ct", - "blake2", - "cpufeatures", - "password-hash", -] - [[package]] name = "async-stream" version = "0.3.6" @@ -189,27 +177,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - [[package]] name = "bitflags" version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" -[[package]] -name = "blake2" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" -dependencies = [ - "digest", -] - [[package]] name = "block-buffer" version = "0.10.4" @@ -293,7 +266,6 @@ name = "echo" version = "0.0.1" dependencies = [ "anyhow", - "argon2", "axum", "axum-server", "base64", @@ -317,6 +289,7 @@ dependencies = [ "pbkdf2", "prost", "protoc-bin-vendored", + "rand_core", "regex", "rustls-pemfile", "serde", @@ -859,17 +832,6 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "password-hash" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" -dependencies = [ - "base64ct", - "rand_core", - "subtle", -] - [[package]] name = "pbkdf2" version = "0.12.2" diff --git a/Cargo.toml b/Cargo.toml index 7340ef5..169fb63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,7 +48,7 @@ tokio = { version = "1", features = ["net", "io-util", "rt-multi-thread", "macro serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" -argon2 = { version = "0.5", features = ["std"] } +rand_core = { version = "0.6", features = ["getrandom"] } base64 = "0.22" sha2 = "0.10" hmac = "0.12" diff --git a/src/engine/db/account.rs b/src/engine/db/account.rs index 228ced7..f0e6efb 100644 --- a/src/engine/db/account.rs +++ b/src/engine/db/account.rs @@ -11,7 +11,6 @@ impl Db { let verified = !(self.email_enabled && email.is_some()); let account = Account { name: name.to_string(), - password_hash: creds.password_hash, email, ts: now(), home: self.log.origin.clone(), @@ -37,10 +36,10 @@ impl Db { /// Provision a new account directly from pre-derived SCRAM verifiers (no /// plaintext), for bulk backfill from an external authority that stores - /// verifiers rather than passwords. `password_hash` is left empty — typed- - /// password login stays disabled until a real password is set — but SCRAM and - /// keycard login work at once. Refuses if the account already exists (so a - /// backfill can't clobber a fully-credentialed account). `scram512` empty = + /// verifiers rather than passwords. The verifier is the account's sole + /// password credential, so SCRAM, PLAIN, and IDENTIFY all work at once (see + /// `authenticate`). Refuses if the account already exists (so a backfill + /// 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) { @@ -51,7 +50,6 @@ impl Db { } let account = Account { name: name.to_string(), - password_hash: String::new(), email, ts: now(), home: self.log.origin.clone(), @@ -84,15 +82,19 @@ impl Db { } #[cfg(test)] - pub(crate) fn test_hash(&self, name: &str) -> Option { - self.accounts.get(&key(name)).map(|a| a.password_hash.clone()) + pub(crate) fn test_verifier(&self, name: &str) -> Option { + self.accounts.get(&key(name)).and_then(|a| a.scram256.clone()) } /// Check credentials; on success return the account's canonical name (its - /// stored casing), else None. + /// stored casing), else None. The SCRAM-SHA-256 verifier is the sole password + /// credential, so PLAIN and IDENTIFY verify the plaintext against it exactly + /// as a SCRAM login proves knowledge of it (see `scram::verify_plain`). pub fn authenticate(&self, name: &str, password: &str) -> Option<&str> { let account = self.accounts.get(&self.resolved_key(name)?)?; - verify_password(password, &account.password_hash).then_some(account.name.as_str()) + let verifier = account.scram256.as_deref()?; + crate::engine::scram::verify_plain(crate::engine::scram::Hash::Sha256, verifier, password) + .then_some(account.name.as_str()) } /// The account's canonical name and its SCRAM verifier for `mech`, if any. @@ -425,13 +427,11 @@ impl Db { self.log .append(Event::AccountPasswordSet { account: account.to_string(), - password_hash: creds.password_hash.clone(), scram256: creds.scram256.clone(), scram512: creds.scram512.clone(), }) .map_err(|_| RegError::Internal)?; let a = self.accounts.get_mut(&k).unwrap(); - a.password_hash = creds.password_hash; a.scram256 = Some(creds.scram256); a.scram512 = Some(creds.scram512); Ok(()) diff --git a/src/engine/db/event.rs b/src/engine/db/event.rs index c8da7c3..c31071f 100644 --- a/src/engine/db/event.rs +++ b/src/engine/db/event.rs @@ -15,7 +15,7 @@ pub enum Event { CertRemoved { account: String, fp: String }, AccountEmailSet { account: String, email: Option }, AccountGreetSet { account: String, greet: String }, - AccountPasswordSet { account: String, password_hash: String, scram256: String, scram512: String }, + AccountPasswordSet { account: String, scram256: String, scram512: String }, AccountDropped { account: String }, AccountVerified { account: String }, AjoinAdded { account: String, channel: String, key: String }, @@ -256,9 +256,8 @@ pub(crate) fn apply(accounts: &mut HashMap, channels: &mut Hash a.greet = greet; } } - Event::AccountPasswordSet { account, password_hash, scram256, scram512 } => { + Event::AccountPasswordSet { account, scram256, scram512 } => { if let Some(a) = accounts.get_mut(&key(&account)) { - a.password_hash = password_hash; a.scram256 = Some(scram256); a.scram512 = Some(scram512); } diff --git a/src/engine/db/mod.rs b/src/engine/db/mod.rs index 26b5ecb..f41215b 100644 --- a/src/engine/db/mod.rs +++ b/src/engine/db/mod.rs @@ -18,9 +18,7 @@ pub fn build_badword_set(patterns: &[String]) -> regex::RegexSet { } use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use argon2::password_hash::rand_core::{OsRng, RngCore}; -use argon2::password_hash::SaltString; -use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; +use rand_core::{OsRng, RngCore}; use serde::{Deserialize, Serialize}; use tokio::sync::broadcast; @@ -48,7 +46,6 @@ pub use echo_api::{ #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Account { pub name: String, - pub password_hash: String, pub email: Option, pub ts: u64, // The node that first registered this account (its "home"). With `ts` it @@ -833,10 +830,12 @@ fn valid_fp(fp: &str) -> bool { } // The expensive, password-derived half of an account, computed once at -// registration. Split out from `register` so the derivation (argon2 + two SCRAM +// registration. Split out from `register` so the derivation (two SCRAM // verifiers, ~1s at the default cost) can run off the reactor via spawn_blocking. +// The SCRAM verifier is the sole password credential: SCRAM logins use it +// directly, and PLAIN/IDENTIFY verify the plaintext against it (see +// `scram::verify_plain`), so there is no separate password hash to keep in step. pub struct Credentials { - pub(crate) password_hash: String, pub(crate) scram256: String, pub(crate) scram512: String, } @@ -1155,7 +1154,6 @@ impl Db { /// `register_prepared` then commits the result. pub fn derive_credentials(password: &str, iterations: u32) -> Option { Some(Credentials { - password_hash: hash_password(password)?, scram256: scram::make_verifier(Hash::Sha256, password, iterations), scram512: scram::make_verifier(Hash::Sha512, password, iterations), }) @@ -1204,16 +1202,6 @@ fn gen_code() -> String { b.iter().map(|x| ALPHABET[(*x % 32) as usize] as char).collect() } -fn hash_password(password: &str) -> Option { - let salt = SaltString::generate(&mut OsRng); - Argon2::default().hash_password(password.as_bytes(), &salt).ok().map(|h| h.to_string()) -} - -fn verify_password(password: &str, hash: &str) -> bool { - PasswordHash::new(hash) - .map(|parsed| Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok()) - .unwrap_or(false) -} // The module-facing account/channel store. Every method forwards to Db's own // (fully-qualified so it is the inherent method, never this trait method), with diff --git a/src/engine/db/tests.rs b/src/engine/db/tests.rs index 07bb5f2..7dd0821 100644 --- a/src/engine/db/tests.rs +++ b/src/engine/db/tests.rs @@ -28,15 +28,17 @@ #[test] fn account_conflict_resolves_deterministically() { - let alice = |hash: &str, ts: u64, home: &str| Account { - name: "alice".into(), password_hash: hash.into(), email: None, + // `tag` (carried in the email field, a free identity slot here) labels + // which of the two competing registrations we're looking at. + let alice = |tag: &str, ts: u64, home: &str| Account { + name: "alice".into(), email: Some(tag.into()), ts, home: home.into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], greet: String::new(), vhost: None, vhost_request: None, last_seen: ts, noexpire: false, expiry_warned: false, oper_note: None, }; let converge = |first: &Account, second: &Account| { let (mut acc, mut ch, mut gr, mut bo, mut hc, mut nd) = (HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HostConfig::default(), NetData::default()); apply(&mut acc, &mut ch, &mut gr, &mut bo, &mut hc, &mut nd, Event::AccountRegistered(Box::new(first.clone()))); apply(&mut acc, &mut ch, &mut gr, &mut bo, &mut hc, &mut nd, Event::AccountRegistered(Box::new(second.clone()))); - acc["alice"].password_hash.clone() + acc["alice"].email.clone().unwrap() }; // Earlier registration wins, regardless of which claim applies first. let early = alice("EARLY", 100, "nodeB"); @@ -305,7 +307,7 @@ db.scram_iterations = 4096; db.register("alice", "pw", None).unwrap(); let bob = Account { - name: "bob".into(), password_hash: "x".into(), email: None, + name: "bob".into(), email: None, ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], greet: String::new(), vhost: None, vhost_request: None, last_seen: 0, noexpire: false, expiry_warned: false, oper_note: None, }; let entry = LogEntry { origin: "peer".into(), seq: 0, lamport: 1, event: Event::AccountRegistered(Box::new(bob)) }; @@ -342,6 +344,23 @@ assert_eq!(db.certfps("bob"), &[kept][..], "kept cert survives"); } + // An account provisioned from a SCRAM verifier alone (external authority) + // must authenticate over the PLAIN / IDENTIFY path (db.authenticate), not + // only over SCRAM — the verifier is the sole credential, so a backfilled + // member can still `/msg NickServ IDENTIFY`. + #[test] + fn provisioned_account_authenticates_over_plain() { + let mut db = Db::open(tmp("prov-plain"), "N1"); + db.scram_iterations = 4096; + let creds = Db::derive_credentials("hunter2", 4096).unwrap(); + db.provision_account("ext", &creds.scram256, "", None).unwrap(); + + // The verifier is the only credential on file, and PLAIN/IDENTIFY works. + assert!(db.test_verifier("ext").is_some(), "provisioned: verifier is the credential"); + assert_eq!(db.authenticate("ext", "hunter2"), Some("ext"), "right password logs in"); + assert!(db.authenticate("ext", "wrong").is_none(), "wrong password rejected"); + } + // A peer with an empty log converges from a node that has already compacted. #[test] fn compacted_node_still_converges() { diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 4f75825..eead1dd 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -566,8 +566,8 @@ impl Engine { } #[cfg(test)] - pub(crate) fn test_account_hash(&self, name: &str) -> Option { - self.db.test_hash(name) + pub(crate) fn test_account_verifier(&self, name: &str) -> Option { + self.db.test_verifier(name) } #[cfg(test)] @@ -1133,6 +1133,8 @@ fn sasl_success(agent: &str, client: &str, account: String) -> Vec { // Outcome of a registration attempt, rendered to the right wire response below. enum RegOutcome { Ok, + /// Registered, but an emailed code must be confirmed before it's verified. + VerifyRequired, Exists, RateLimited, Frozen, @@ -1147,6 +1149,7 @@ fn reg_reply(reply: &RegReply, outcome: RegOutcome, account: &str) -> Vec { let (status, code, message) = match outcome { RegOutcome::Ok => ("success", "*", "Account registered."), + RegOutcome::VerifyRequired => ("verification_required", "VERIFICATION_REQUIRED", "Registered — check your email for a code, then VERIFY."), RegOutcome::Exists => ("error", "ACCOUNT_EXISTS", "That account name is already registered."), RegOutcome::RateLimited => ("error", "TEMPORARILY_UNAVAILABLE", "Too many registrations, please wait a moment."), RegOutcome::Frozen => ("error", "TEMPORARILY_UNAVAILABLE", "Registrations are temporarily frozen by network staff."), @@ -1165,8 +1168,10 @@ fn reg_reply(reply: &RegReply, outcome: RegOutcome, account: &str) -> Vec { let notice = |text: String| NetAction::Notice { from: agent.clone(), to: uid.clone(), text }; match outcome { - RegOutcome::Ok => vec![ - // Registering identifies you to the nick right away (drives 900). + // Registering identifies you to the nick right away (drives 900). + // VerifyRequired still logs you in; the emailed-code notice is + // appended by complete_register. + RegOutcome::Ok | RegOutcome::VerifyRequired => vec![ NetAction::Metadata { target: uid.clone(), key: "accountname".to_string(), value: nick.clone() }, notice(format!("Your nick \x02{nick}\x02 is now registered and you're logged in. Welcome!")), ], diff --git a/src/engine/register.rs b/src/engine/register.rs index da39c6e..e43aed8 100644 --- a/src/engine/register.rs +++ b/src/engine/register.rs @@ -130,14 +130,66 @@ impl Engine { } } - // Authority side of the IRCv3 account-registration relay. Hands the work to - // the link layer (which derives the password off-thread) via DeferRegister. + // Authority side of the IRCv3 account-registration relay (draft/account- + // registration). REGISTER hands off to the link layer (which derives the + // password off-thread) via DeferRegister; VERIFY/RESEND/STATUS are answered + // here directly against the emailed-code flow. pub(crate) fn account_request(&mut self, reqid: String, kind: String, account: String, p2: String, p3: String) -> Vec { - if !kind.eq_ignore_ascii_case("REGISTER") { - return Vec::new(); // VERIFY / RESEND / STATUS: later + if kind.eq_ignore_ascii_case("REGISTER") { + let email = if p2.is_empty() || p2 == "*" { None } else { Some(p2) }; + return vec![NetAction::DeferRegister { account, password: p3, email, reply: RegReply::Relay { reqid, kind } }]; + } + + // Relay-only replies, built directly (REGISTER goes through complete_register). + let resp = |status: &str, code: &str, message: &str| { + vec![NetAction::AccountResponse { + reqid: reqid.clone(), + kind: kind.clone(), + account: account.clone(), + status: status.to_string(), + code: code.to_string(), + message: message.to_string(), + }] + }; + // Identity is the website's in external mode; the relay shouldn't manage it. + if self.db.external_accounts() { + return resp("error", "ACCOUNT_REGISTRATION_DISABLED", "Accounts are managed on the website."); + } + + match kind.to_ascii_uppercase().as_str() { + // VERIFY — confirm the emailed code. + "VERIFY" => { + let code = if !p2.is_empty() { p2 } else { p3 }; + if self.db.account(&account).is_none() { + resp("error", "ACCOUNT_UNKNOWN", "No such account.") + } else if self.db.take_code(&account, db::CodeKind::Confirm, &code) { + let _ = self.db.verify_account(&account); + resp("success", "*", "Account verified.") + } else { + resp("error", "INVALID_CODE", "That verification code is wrong or has expired.") + } + } + // RESEND — issue and email a fresh confirmation code. + "RESEND" => match self.db.account(&account).map(|a| (a.verified, a.email.clone())) { + None => resp("error", "ACCOUNT_UNKNOWN", "No such account."), + Some((true, _)) => resp("error", "ALREADY_VERIFIED", "That account is already verified."), + Some((false, None)) => resp("error", "NO_EMAIL", "No email address is on file for that account."), + Some((false, Some(addr))) => { + let code = self.db.issue_code(&account, db::CodeKind::Confirm); + let mail = echo_api::email::confirm(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), &account, &code); + let mut out = resp("verification_required", "VERIFICATION_REQUIRED", "A new confirmation code has been emailed."); + out.push(NetAction::SendEmail { to: addr, subject: mail.subject, text: mail.text, html: Some(mail.html) }); + out + } + }, + // STATUS — report registration/verification state. + "STATUS" => match self.db.account(&account).map(|a| a.verified) { + None => resp("error", "ACCOUNT_UNKNOWN", "No such account."), + Some(true) => resp("success", "VERIFIED", "Account is registered and verified."), + Some(false) => resp("verification_required", "VERIFICATION_REQUIRED", "Account is registered but not yet verified."), + }, + _ => Vec::new(), } - let email = if p2.is_empty() || p2 == "*" { None } else { Some(p2) }; - vec![NetAction::DeferRegister { account, password: p3, email, reply: RegReply::Relay { reqid, kind } }] } // Cheap gate run before the expensive derivation: reject an already-taken name @@ -166,20 +218,25 @@ impl Engine { }; let addr = email.clone(); let outcome = match self.db.register_prepared(account, creds, email) { - Ok(()) => RegOutcome::Ok, + Ok(()) if self.db.is_verified(account) => RegOutcome::Ok, + Ok(()) => RegOutcome::VerifyRequired, Err(RegError::Exists) => RegOutcome::Exists, Err(RegError::Internal) => RegOutcome::Internal, }; - let ok = matches!(outcome, RegOutcome::Ok); + let needs_verify = matches!(outcome, RegOutcome::VerifyRequired); let mut out = reg_reply(&reply, outcome, account); self.track_accounts(&out); - // If this created an unverified account (email confirmation applies), email a code. - if ok && !self.db.is_verified(account) { - if let (Some(addr), RegReply::NickServ { agent, uid, .. }) = (addr, &reply) { + // Unverified (email confirmation applies): email a code, on either the + // NickServ or the account-registration relay path. The relay reply + // already carries verification_required; NickServ users get a notice. + if needs_verify { + if let Some(addr) = addr { let code = self.db.issue_code(account, db::CodeKind::Confirm); let mail = echo_api::email::confirm(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), account, &code); out.push(NetAction::SendEmail { to: addr, subject: mail.subject, text: mail.text, html: Some(mail.html) }); - out.push(NetAction::Notice { from: agent.clone(), to: uid.clone(), text: "A confirmation code has been emailed to you. Confirm with \x02CONFIRM \x02.".to_string() }); + if let RegReply::NickServ { agent, uid, .. } = &reply { + out.push(NetAction::Notice { from: agent.clone(), to: uid.clone(), text: "A confirmation code has been emailed to you. Confirm with \x02CONFIRM \x02.".to_string() }); + } } } out diff --git a/src/engine/scram.rs b/src/engine/scram.rs index c468bd1..66353a9 100644 --- a/src/engine/scram.rs +++ b/src/engine/scram.rs @@ -8,15 +8,17 @@ //! //! Passwords are used as raw UTF-8 (no SASLprep), matching that stack. -use argon2::password_hash::rand_core::{OsRng, RngCore}; +use rand_core::{OsRng, RngCore}; use base64::{engine::general_purpose::STANDARD, Engine as _}; use hmac::{Hmac, Mac}; use sha2::{Digest, Sha256, Sha512}; use subtle::ConstantTimeEq; -// Provisioning cost. High on purpose: the client bears the Hi() work each login, -// the server only at REGISTER, and the verifier must not be a weaker -// password-equivalent than the argon2 hash beside it. +// Provisioning cost. High on purpose: over SCRAM the client bears the Hi() work +// each login and the server only at REGISTER; over PLAIN/IDENTIFY the server +// bears it (see `verify_plain`). This verifier is the sole password credential, +// so its cost is the account's whole at-rest password strength — exceeds OWASP's +// PBKDF2-HMAC-SHA256 floor (600k) with headroom. pub const DEFAULT_ITERATIONS: u32 = 1_200_000; const SALT_LEN: usize = 16; @@ -121,6 +123,23 @@ pub fn make_verifier(hash: Hash, password: &str, iterations: u32) -> String { ) } +/// Verify a plaintext password against a stored verifier, for the PLAIN / +/// NickServ IDENTIFY path where the server holds the plaintext. Recomputes the +/// StoredKey the same way a SCRAM client would and compares it constant-time. +/// +/// The verifier is the account's sole password credential, so this is the whole +/// PLAIN/IDENTIFY check — SCRAM logins prove knowledge of the same verifier +/// without the server seeing the plaintext. The cost is one `Hi()` (PBKDF2) at +/// the verifier's own iteration count. +pub fn verify_plain(hash: Hash, encoded: &str, password: &str) -> bool { + let Some(v) = parse_verifier(encoded) else { + return false; + }; + let salted = hi(hash, password.as_bytes(), &v.salt, v.iterations); + let stored_key = h(hash, &hmac(hash, &salted, b"Client Key")); + stored_key.ct_eq(&v.stored_key).unwrap_u8() == 1 +} + pub fn parse_verifier(encoded: &str) -> Option { let (mut iterations, mut salt, mut stored_key, mut server_key) = (None, None, None, None); for part in encoded.split(',') { @@ -297,4 +316,39 @@ mod tests { fn channel_binding_rejected() { assert!(parse_client_first("p=tls-unique,,n=user,r=abc").is_none()); } + + // A verifier produced by an *external account authority* — a website that + // owns member identity when running with `[auth] external = true` and hands + // us a `v=1,i=,s=,sk=,sv=` verifier over the Accounts API, never a password — + // must authenticate here byte-for-byte. That interop is the whole reason a + // provisioned member can log in over SCRAM against an account we only ever + // received a verifier for. This vector was generated by such an authority's + // own SCRAM code for the password below; if either side's derivation drifts + // (iteration count, salt encoding, key labels), this stops passing. + #[test] + fn external_authority_verifier_authenticates() { + const VERIFIER: &str = "v=1,i=1200000,s=jV9Tm09E7bT1s59EeQaMEw==,\ + sk=O4Sbg4ZsB0L2HAmTI2NlKu7rOL7tXBBMSIAjLeapOXA=,\ + sv=ghy7LNgX58CzMLRciK2OkfTu+ODC+iJAgibRMBXSH0c="; + const PASSWORD: &str = "correct horse battery staple"; + + let v = parse_verifier(VERIFIER).expect("authority verifier parses"); + assert_eq!(v.iterations, DEFAULT_ITERATIONS, "authority uses the same default cost"); + + // Play a real SCRAM-SHA-256 client against it, with a fixed client nonce. + let cf = parse_client_first("n,,n=member,r=cnonce123").unwrap(); + let (server_first, full_nonce) = server_first(&cf.cnonce, &v); + let prove = |pw: &str| { + let salted = hi(Hash::Sha256, pw.as_bytes(), &v.salt, v.iterations); + let client_key = hmac(Hash::Sha256, &salted, b"Client Key"); + let stored_key = h(Hash::Sha256, &client_key); + let without_proof = format!("c=biws,r={full_nonce}"); + let auth_message = format!("{},{},{}", cf.client_first_bare, server_first, without_proof); + let client_sig = hmac(Hash::Sha256, &stored_key, auth_message.as_bytes()); + let client_final = format!("{without_proof},p={}", STANDARD.encode(xor(&client_key, &client_sig))); + verify_final(Hash::Sha256, &v, &cf.client_first_bare, &server_first, &cf.gs2_header, &full_nonce, &client_final) + }; + assert!(prove(PASSWORD).is_some(), "the real password authenticates against the authority's verifier"); + assert!(prove("wrong").is_none(), "a wrong password is rejected"); + } } diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 17b08b4..ccd3813 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -410,7 +410,7 @@ // An earlier claim from another node wins and takes the name over. let winner = db::Account { - name: "alice".into(), password_hash: "OTHER".into(), email: None, + name: "alice".into(), email: None, ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], greet: String::new(), vhost: None, vhost_request: None, last_seen: 0, noexpire: false, expiry_warned: false, oper_note: None, }; let entry = LogEntry::for_test("peer", 0, 1, db::Event::AccountRegistered(Box::new(winner))); @@ -599,6 +599,43 @@ assert!(e.db.is_verified("newbie"), "confirmed after CONFIRM"); } + // draft/account-registration over the ircd relay: REGISTER defers, replies + // verification_required and emails a code; VERIFY confirms; STATUS reports it. + #[test] + fn account_registration_relay_verify_flow() { + let path = std::env::temp_dir().join("echo-acctreg-relay.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "test"); + db.scram_iterations = 4096; + db.set_email_enabled(true); + let mut e = Engine::new(vec![Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 })], db); + + // REGISTER : relayed from the ircd. + let reg = e.account_request("r1".into(), "REGISTER".into(), "neo".into(), "neo@example.org".into(), "trinity".into()); + let (account, password, email, reply) = reg.iter().find_map(|a| match a { + NetAction::DeferRegister { account, password, email, reply } => Some((account.clone(), password.clone(), email.clone(), reply.clone())), + _ => None, + }).expect("relay register defers"); + let out = e.complete_register(&account, Db::derive_credentials(&password, 4096), email, reply); + assert!(out.iter().any(|a| matches!(a, NetAction::AccountResponse { status, .. } if status == "verification_required")), "{out:?}"); + assert!(!e.db.is_verified("neo"), "unverified until the code is confirmed"); + let body = out.iter().find_map(|a| match a { NetAction::SendEmail { text, .. } => Some(text.clone()), _ => None }).expect("a code is emailed on the relay path too"); + let code = body.split("CONFIRM ").nth(1).unwrap().split_whitespace().next().unwrap().to_string(); + + // VERIFY with the code succeeds; a stale/duplicate VERIFY then fails. + let ok = e.account_request("r2".into(), "VERIFY".into(), "neo".into(), code, String::new()); + assert!(ok.iter().any(|a| matches!(a, NetAction::AccountResponse { status, .. } if status == "success")), "{ok:?}"); + assert!(e.db.is_verified("neo"), "verified after VERIFY"); + let again = e.account_request("r3".into(), "VERIFY".into(), "neo".into(), "000000".into(), String::new()); + assert!(again.iter().any(|a| matches!(a, NetAction::AccountResponse { code, .. } if code == "INVALID_CODE")), "{again:?}"); + + // STATUS reports verified; VERIFY on an unknown account is a clean error. + let st = e.account_request("r4".into(), "STATUS".into(), "neo".into(), String::new(), String::new()); + assert!(st.iter().any(|a| matches!(a, NetAction::AccountResponse { code, .. } if code == "VERIFIED")), "{st:?}"); + let unknown = e.account_request("r5".into(), "VERIFY".into(), "ghost".into(), "x".into(), String::new()); + assert!(unknown.iter().any(|a| matches!(a, NetAction::AccountResponse { code, .. } if code == "ACCOUNT_UNKNOWN")), "{unknown:?}"); + } + // GHOST renames off a session using a nick the caller owns. #[test] fn nickserv_ghost() { diff --git a/src/gossip.rs b/src/gossip.rs index 0f1881b..43e2db8 100644 --- a/src/gossip.rs +++ b/src/gossip.rs @@ -345,7 +345,7 @@ mod tests { let mut converged = false; for _ in 0..100 { - let (ha, hb) = (a.lock().await.test_account_hash("alice"), b.lock().await.test_account_hash("alice")); + let (ha, hb) = (a.lock().await.test_account_verifier("alice"), b.lock().await.test_account_verifier("alice")); if ha.is_some() && ha == hb { converged = true; break; diff --git a/src/grpc.rs b/src/grpc.rs index 6545b9a..fbe05a2 100644 --- a/src/grpc.rs +++ b/src/grpc.rs @@ -252,7 +252,7 @@ impl Accounts for AccountsService { if let Err(status) = self.engine.lock().await.authority_pre_check(&msg.name) { return Ok(Response::new(reply(status, "cannot register that name right now"))); } - // Expensive Argon2/SCRAM derivation runs off the shared engine lock, same + // Expensive SCRAM verifier derivation runs off the shared engine lock, same // as an IRC-originated REGISTER (see link.rs's DeferRegister handling). let iterations = self.engine.lock().await.scram_iterations(); let password = msg.password.clone(); @@ -441,7 +441,6 @@ mod tests { fn to_wire_filters_credentials_and_maps_directory_events() { let acct = Account { name: "alice".into(), - password_hash: "secret-hash".into(), email: Some("alice@example.com".into()), ts: 111, home: "A".into(), @@ -473,7 +472,7 @@ mod tests { let pw_change = LogEntry::for_test( "A", 1, 2, - Event::AccountPasswordSet { account: "alice".into(), password_hash: "x".into(), scram256: "x".into(), scram512: "x".into() }, + Event::AccountPasswordSet { account: "alice".into(), scram256: "x".into(), scram512: "x".into() }, ); assert!(to_wire(&pw_change).is_none(), "credential changes must never replicate");