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

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