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
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue