Make the SCRAM verifier the sole password credential; finish the account-registration relay
All checks were successful
CI / check (push) Successful in 4m1s
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:
parent
9ed40a2e7f
commit
994e8c7347
12 changed files with 221 additions and 101 deletions
40
Cargo.lock
generated
40
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
// 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!")),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
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(),
|
||||
}
|
||||
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 <code>\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 <code>\x02.".to_string() });
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue