echo/src/engine/scram.rs
Jean 994e8c7347
All checks were successful
CI / check (push) Successful in 4m1s
Make the SCRAM verifier the sole password credential; finish the account-registration relay
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.
2026-07-15 15:47:41 +00:00

354 lines
14 KiB
Rust

//! SCRAM (RFC 5802 / RFC 7677) server side for SASL SCRAM-SHA-256 and
//! SCRAM-SHA-512. Stored verifier format `v=1,i=,s=,sk=,sv=` where
//!
//! SaltedPassword = Hi(password, salt, i) (PBKDF2, dklen = hash len)
//! ClientKey = HMAC(SaltedPassword, "Client Key")
//! StoredKey = H(ClientKey)
//! ServerKey = HMAC(SaltedPassword, "Server Key")
//!
//! Passwords are used as raw UTF-8 (no SASLprep), matching that stack.
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: 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;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Hash {
Sha256,
Sha512,
}
impl Hash {
pub fn from_mech(mech: &str) -> Option<Hash> {
match mech {
"SCRAM-SHA-256" => Some(Hash::Sha256),
"SCRAM-SHA-512" => Some(Hash::Sha512),
_ => None,
}
}
pub fn mech(self) -> &'static str {
match self {
Hash::Sha256 => "SCRAM-SHA-256",
Hash::Sha512 => "SCRAM-SHA-512",
}
}
}
// --- primitives, dispatched on the hash ---
pub(crate) fn hmac(hash: Hash, key: &[u8], msg: &[u8]) -> Vec<u8> {
match hash {
Hash::Sha256 => {
let mut m = Hmac::<Sha256>::new_from_slice(key).expect("hmac takes any key length");
m.update(msg);
m.finalize().into_bytes().to_vec()
}
Hash::Sha512 => {
let mut m = Hmac::<Sha512>::new_from_slice(key).expect("hmac takes any key length");
m.update(msg);
m.finalize().into_bytes().to_vec()
}
}
}
pub(crate) fn h(hash: Hash, data: &[u8]) -> Vec<u8> {
match hash {
Hash::Sha256 => Sha256::digest(data).to_vec(),
Hash::Sha512 => Sha512::digest(data).to_vec(),
}
}
// SaltedPassword = Hi(password, salt, i): PBKDF2 with dklen == the hash length
// (one output block, all SCRAM needs). Keys the HMAC once and iterates.
pub(crate) fn hi(hash: Hash, password: &[u8], salt: &[u8], iterations: u32) -> Vec<u8> {
match hash {
Hash::Sha256 => {
let mut out = [0u8; 32];
pbkdf2::pbkdf2_hmac::<Sha256>(password, salt, iterations, &mut out);
out.to_vec()
}
Hash::Sha512 => {
let mut out = [0u8; 64];
pbkdf2::pbkdf2_hmac::<Sha512>(password, salt, iterations, &mut out);
out.to_vec()
}
}
}
pub(crate) fn xor(a: &[u8], b: &[u8]) -> Vec<u8> {
a.iter().zip(b).map(|(x, y)| x ^ y).collect()
}
// --- verifier ---
pub struct Verifier {
pub iterations: u32,
pub salt: Vec<u8>,
pub stored_key: Vec<u8>,
pub server_key: Vec<u8>,
}
/// Compute and encode a fresh verifier for `password`.
pub fn make_verifier(hash: Hash, password: &str, iterations: u32) -> String {
// A salt whose base64 avoids '+' and '/' keeps the verifier portable to
// stricter parsers in the wider stack; 16 random bytes clear it quickly.
let salt = loop {
let mut s = [0u8; SALT_LEN];
OsRng.fill_bytes(&mut s);
let b64 = STANDARD.encode(s);
if !b64.contains('+') && !b64.contains('/') {
break s;
}
};
let salted = hi(hash, password.as_bytes(), &salt, iterations);
let stored_key = h(hash, &hmac(hash, &salted, b"Client Key"));
let server_key = hmac(hash, &salted, b"Server Key");
format!(
"v=1,i={},s={},sk={},sv={}",
iterations,
STANDARD.encode(salt),
STANDARD.encode(&stored_key),
STANDARD.encode(&server_key),
)
}
/// 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(',') {
let (k, v) = part.split_once('=')?;
match k {
"v" if v != "1" => return None,
"v" => {}
"i" => iterations = v.parse().ok(),
"s" => salt = STANDARD.decode(v).ok(),
"sk" => stored_key = STANDARD.decode(v).ok(),
"sv" => server_key = STANDARD.decode(v).ok(),
_ => {}
}
}
Some(Verifier {
iterations: iterations?,
salt: salt?,
stored_key: stored_key?,
server_key: server_key?,
})
}
// --- exchange ---
pub struct ClientFirst {
pub username: String,
pub gs2_header: String,
pub client_first_bare: String,
pub cnonce: String,
}
/// Parse `<cbflag>,<authzid>,n=<user>,r=<cnonce>`. Channel binding (`p=`) is
/// rejected — we advertise the bare mechanisms only.
pub fn parse_client_first(msg: &str) -> Option<ClientFirst> {
let mut it = msg.splitn(3, ',');
let cbflag = it.next()?;
let authzid = it.next()?;
let bare = it.next()?;
if cbflag.starts_with('p') {
return None;
}
let mut fields = bare.split(',');
let username = fields.next()?.strip_prefix("n=")?;
let cnonce = fields.next()?.strip_prefix("r=")?;
Some(ClientFirst {
username: username.replace("=2C", ",").replace("=3D", "="),
gs2_header: format!("{cbflag},{authzid},"),
client_first_bare: bare.to_string(),
cnonce: cnonce.to_string(),
})
}
fn nonce() -> String {
let mut b = [0u8; 18];
OsRng.fill_bytes(&mut b);
STANDARD.encode(b) // base64 never contains ',', so it is a valid SCRAM value
}
/// Build the server-first message and return it with the full (combined) nonce.
pub fn server_first(cnonce: &str, v: &Verifier) -> (String, String) {
let full_nonce = format!("{cnonce}{}", nonce());
let msg = format!(
"r={},s={},i={}",
full_nonce,
STANDARD.encode(&v.salt),
v.iterations
);
(msg, full_nonce)
}
/// Verify the client-final message; on success return the server-final message
/// (`v=<ServerSignature>`) proving the server also knows the password.
pub fn verify_final(
hash: Hash,
v: &Verifier,
client_first_bare: &str,
server_first: &str,
gs2_header: &str,
full_nonce: &str,
client_final: &str,
) -> Option<String> {
let (without_proof, proof_b64) = client_final.rsplit_once(",p=")?;
let proof = STANDARD.decode(proof_b64).ok()?;
let (mut cbind, mut rnonce) = (None, None);
for field in without_proof.split(',') {
if let Some(x) = field.strip_prefix("c=") {
cbind = Some(x);
} else if let Some(x) = field.strip_prefix("r=") {
rnonce = Some(x);
}
}
if rnonce? != full_nonce {
return None;
}
// c= must echo the GS2 header we saw in client-first (no channel binding).
if STANDARD.decode(cbind?).ok()? != gs2_header.as_bytes() {
return None;
}
let auth_message = format!("{client_first_bare},{server_first},{without_proof}");
let client_signature = hmac(hash, &v.stored_key, auth_message.as_bytes());
if proof.len() != client_signature.len() {
return None;
}
let client_key = xor(&proof, &client_signature);
// Constant-time: StoredKey is a secret, so leak nothing through compare timing.
if h(hash, &client_key).ct_eq(&v.stored_key).unwrap_u8() != 1 {
return None;
}
let server_signature = hmac(hash, &v.server_key, auth_message.as_bytes());
Some(format!("v={}", STANDARD.encode(&server_signature)))
}
#[cfg(test)]
mod tests {
use super::*;
// RFC 7677 test vector (user "user", password "pencil").
#[test]
fn rfc7677_vector() {
let salt = STANDARD.decode("W22ZaJ0SNY7soEsUEjb6gQ==").unwrap();
let salted = hi(Hash::Sha256, b"pencil", &salt, 4096);
let client_key = hmac(Hash::Sha256, &salted, b"Client Key");
let stored_key = h(Hash::Sha256, &client_key);
let server_key = hmac(Hash::Sha256, &salted, b"Server Key");
let auth_message = "n=user,r=rOprNGfwEbeRWgbNEkqO,\
r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096,\
c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0";
let client_sig = hmac(Hash::Sha256, &stored_key, auth_message.as_bytes());
let client_proof = xor(&client_key, &client_sig);
let server_sig = hmac(Hash::Sha256, &server_key, auth_message.as_bytes());
assert_eq!(STANDARD.encode(&client_proof), "dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ=");
assert_eq!(STANDARD.encode(&server_sig), "6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4=");
}
// A full server-side exchange, playing the client with a fixed nonce, for
// both hashes and a right/wrong password.
fn run_exchange(hash: Hash, register_pw: &str, login_pw: &str) -> bool {
let verifier = parse_verifier(&make_verifier(hash, register_pw, 4096)).unwrap();
let cf = parse_client_first("n,,n=user,r=cnonce123").unwrap();
let (server_first, full_nonce) = server_first(&cf.cnonce, &verifier);
// Client computes its proof for login_pw against the server-provided salt/i.
let salted = hi(hash, login_pw.as_bytes(), &verifier.salt, verifier.iterations);
let client_key = hmac(hash, &salted, b"Client Key");
let stored_key = h(hash, &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, &stored_key, auth_message.as_bytes());
let proof = xor(&client_key, &client_sig);
let client_final = format!("{without_proof},p={}", STANDARD.encode(&proof));
verify_final(hash, &verifier, &cf.client_first_bare, &server_first, &cf.gs2_header, &full_nonce, &client_final).is_some()
}
#[test]
fn exchange_sha256_ok() {
assert!(run_exchange(Hash::Sha256, "sesame", "sesame"));
}
#[test]
fn exchange_sha512_ok() {
assert!(run_exchange(Hash::Sha512, "sesame", "sesame"));
}
#[test]
fn exchange_bad_password() {
assert!(!run_exchange(Hash::Sha256, "sesame", "millet"));
assert!(!run_exchange(Hash::Sha512, "sesame", "millet"));
}
#[test]
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");
}
}