Make the SCRAM verifier the sole password credential; finish the account-registration relay
All checks were successful
CI / check (push) Successful in 4m1s

Credentials: the SCRAM-SHA-256 verifier is now the only password
credential. PLAIN and IDENTIFY verify the plaintext against it via
scram::verify_plain, exactly as a SCRAM login proves knowledge of it, so
an account provisioned from a verifier alone (external authority) works
over every mechanism, not only SCRAM. Removed the redundant argon2 hash
entirely: the field, the Credentials member, the AccountPasswordSet
field, hash_password/verify_password, and the argon2 crate. SASL SCRAM
already forces a PBKDF2 verifier into the store, so the hash never raised
the at-rest floor. OS RNG now comes from rand_core.

Registration: finished draft/account-registration over the ircd relay.
VERIFY confirms the emailed code, RESEND reissues it, STATUS reports
state, and REGISTER now emails the code and replies verification_required
on the relay path too, not only through NickServ.
This commit is contained in:
Jean Chevronnet 2026-07-15 15:47:41 +00:00
parent 9ed40a2e7f
commit 994e8c7347
No known key found for this signature in database
12 changed files with 221 additions and 101 deletions

40
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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<String>) -> 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<String> {
self.accounts.get(&key(name)).map(|a| a.password_hash.clone())
pub(crate) fn test_verifier(&self, name: &str) -> Option<String> {
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(())

View file

@ -15,7 +15,7 @@ pub enum Event {
CertRemoved { account: String, fp: String },
AccountEmailSet { account: String, email: Option<String> },
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<String, Account>, 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);
}

View file

@ -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<String>,
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<Credentials> {
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<String> {
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

View file

@ -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() {

View file

@ -566,8 +566,8 @@ impl Engine {
}
#[cfg(test)]
pub(crate) fn test_account_hash(&self, name: &str) -> Option<String> {
self.db.test_hash(name)
pub(crate) fn test_account_verifier(&self, name: &str) -> Option<String> {
self.db.test_verifier(name)
}
#[cfg(test)]
@ -1133,6 +1133,8 @@ fn sasl_success(agent: &str, client: &str, account: String) -> Vec<NetAction> {
// 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<NetAct
RegReply::Relay { reqid, kind } => {
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<NetAct
RegReply::NickServ { agent, uid, nick } => {
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).
// 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!")),
],

View file

@ -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<NetAction> {
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) };
vec![NetAction::DeferRegister { account, password: p3, email, reply: RegReply::Relay { reqid, kind } }]
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 <account> <code> — 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 <account> — 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 <account> — 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(),
}
}
// Cheap gate run before the expensive derivation: reject an already-taken name
@ -166,22 +218,27 @@ 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) });
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 <code>\x02.".to_string() });
}
}
}
out
}

View file

@ -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<Verifier> {
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");
}
}

View file

@ -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 <account> <email> :<password> 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() {

View file

@ -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;

View file

@ -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");