From 496c0f99a6fb29971ed9c3b1cb347ca62dcb48d1 Mon Sep 17 00:00:00 2001 From: Jean Date: Sun, 12 Jul 2026 01:34:53 +0000 Subject: [PATCH] Add SASL SCRAM-SHA-256 and SCRAM-SHA-512 --- Cargo.lock | 33 ++++ Cargo.toml | 3 + src/config.rs | 9 ++ src/engine/db.rs | 32 +++- src/engine/mod.rs | 246 ++++++++++++++++++++++------- src/engine/scram.rs | 299 ++++++++++++++++++++++++++++++++++++ src/main.rs | 3 +- testing/fedserv_services.py | 2 + 8 files changed, 569 insertions(+), 58 deletions(-) create mode 100644 src/engine/scram.rs diff --git a/Cargo.lock b/Cargo.lock index c5ac6bb..7865755 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,8 +114,11 @@ dependencies = [ "anyhow", "argon2", "base64", + "hmac", + "pbkdf2", "serde", "serde_json", + "sha2", "tokio", "toml", "tracing", @@ -149,6 +152,15 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -235,6 +247,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -337,6 +359,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" diff --git a/Cargo.toml b/Cargo.toml index 9fbfbe5..1f886e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,9 @@ serde_json = "1" toml = "0.8" argon2 = { version = "0.5", features = ["std"] } base64 = "0.22" +sha2 = "0.10" +hmac = "0.12" +pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] } anyhow = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/src/config.rs b/src/config.rs index 895d478..29d27c0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -20,12 +20,21 @@ pub struct Server { pub description: String, #[serde(default = "default_protocol")] pub protocol: u32, + // PBKDF2 cost baked into new SCRAM verifiers at registration. High by + // default for offline-attack resistance; lower it if registration latency + // on the single-threaded link matters more than verifier strength. + #[serde(default = "default_scram_iterations")] + pub scram_iterations: u32, } fn default_protocol() -> u32 { 1206 // InspIRCd 4 spanning-tree protocol (1205 = insp3) } +fn default_scram_iterations() -> u32 { + crate::engine::scram::DEFAULT_ITERATIONS +} + impl Config { pub fn load(path: &str) -> anyhow::Result { let raw = std::fs::read_to_string(path)?; diff --git a/src/engine/db.rs b/src/engine/db.rs index e1f8ad9..dfa2224 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -8,12 +8,20 @@ use argon2::password_hash::SaltString; use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; use serde::{Deserialize, Serialize}; +use super::scram::{self, Hash}; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Account { pub name: String, pub password_hash: String, pub email: Option, pub ts: u64, + // SCRAM verifiers (`v=1,i=,s=,sk=,sv=`), computed from the password at + // registration. Absent on accounts registered before SCRAM support. + #[serde(default)] + pub scram256: Option, + #[serde(default)] + pub scram512: Option, } // Event-sourced persistence: every change is an Event appended to a JSONL log, @@ -33,6 +41,8 @@ pub enum RegError { pub struct Db { accounts: HashMap, // keyed by casefolded name path: PathBuf, + // PBKDF2 cost baked into new SCRAM verifiers; lowered by tests. + pub(crate) scram_iterations: u32, } fn key(name: &str) -> String { @@ -58,7 +68,7 @@ impl Db { } } tracing::info!(accounts = accounts.len(), ?path, "account store loaded"); - Self { accounts, path } + Self { accounts, path, scram_iterations: scram::DEFAULT_ITERATIONS } } pub fn exists(&self, name: &str) -> bool { @@ -70,7 +80,14 @@ impl Db { return Err(RegError::Exists); } let password_hash = hash_password(password).ok_or(RegError::Internal)?; - let account = Account { name: name.to_string(), password_hash, email, ts: now() }; + let account = Account { + name: name.to_string(), + password_hash, + email, + ts: now(), + scram256: Some(scram::make_verifier(Hash::Sha256, password, self.scram_iterations)), + scram512: Some(scram::make_verifier(Hash::Sha512, password, self.scram_iterations)), + }; self.append(&Event::AccountRegistered(account.clone())).map_err(|_| RegError::Internal)?; self.accounts.insert(key(name), account); Ok(()) @@ -83,6 +100,17 @@ impl Db { verify_password(password, &account.password_hash).then_some(account.name.as_str()) } + /// The account's canonical name and its SCRAM verifier for `mech`, if any. + pub fn scram_lookup(&self, name: &str, mech: &str) -> Option<(&str, &str)> { + let account = self.accounts.get(&key(name))?; + let verifier = match mech { + "SCRAM-SHA-256" => account.scram256.as_deref(), + "SCRAM-SHA-512" => account.scram512.as_deref(), + _ => None, + }?; + Some((account.name.as_str(), verifier)) + } + fn append(&self, event: &Event) -> std::io::Result<()> { let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&self.path)?; writeln!(f, "{}", serde_json::to_string(event).unwrap_or_default()) diff --git a/src/engine/mod.rs b/src/engine/mod.rs index b5354c5..a94c3b4 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1,17 +1,21 @@ pub mod db; +pub mod scram; pub mod service; pub mod state; use std::collections::HashMap; +use base64::{engine::general_purpose::STANDARD, Engine as _}; + use crate::proto::{NetAction, NetEvent}; use db::{Db, RegError}; +use scram::Verifier; use service::{Sender, Service, ServiceCtx}; use state::Network; -// SASL mechanisms we offer. Advertised to the uplink as the `sasl=` capability -// value (IRCv3 SASL 3.2 mechanism list) and the set we accept in the exchange. -const SASL_MECHS: &str = "PLAIN"; +// SASL mechanisms we offer, strongest first. Advertised to the uplink as the +// `sasl=` capability value (IRCv3 SASL 3.2 mechanism list) and the set we accept. +const SASL_MECHS: &str = "SCRAM-SHA-512,SCRAM-SHA-256,PLAIN"; // A client's base64 response is split into chunks of this length; a chunk // shorter than this (or a lone "+") marks the end of the response (SASL 3.1). @@ -20,12 +24,28 @@ const MAX_AUTHENTICATE: usize = 400; // Upper bound on a reassembled response, to cap a pre-auth client's buffer. const MAX_SASL_RESPONSE: usize = 8 * 1024; -// A client's in-progress SASL exchange: the chosen mechanism and the base64 -// response reassembled from the uplink's fixed-size AUTHENTICATE chunks. -#[derive(Default)] -struct SaslSession { - mech: String, - response: String, +// A client's in-progress SASL exchange. +enum SaslSession { + // PLAIN: the base64 response reassembled from the uplink's chunks. + Plain { response: String }, + // SCRAM: which hash, and where we are in the challenge/response. + Scram { hash: scram::Hash, step: ScramStep }, +} + +// The SCRAM state advances client-first -> client-final -> ack. +enum ScramStep { + ClientFirst, + ClientFinal { + account: String, + verifier: Verifier, + client_first_bare: String, + server_first: String, + gs2_header: String, + nonce: String, + }, + Ack { + account: String, + }, } pub struct Engine { @@ -83,10 +103,10 @@ impl Engine { } } - // SASL agent side of the exchange the ircd relays to us (modes H/S/C/D), - // per IRCv3 SASL 3.2. PLAIN only: on a valid client response we set the - // client's account (drives 900) then report success (drives 903); a bad - // credential or unknown mechanism reports failure (drives 904). + // SASL agent side of the exchange the ircd relays to us (modes H/S/C/D), per + // IRCv3 SASL 3.2. On success we set the client's account (drives 900) then + // report success (drives 903); a bad credential or unknown mechanism reports + // failure (drives 904). PLAIN and SCRAM-SHA-256/512 are supported. fn sasl(&mut self, client: String, mode: String, data: Vec) -> Vec { let agent = match self.services.first() { Some(s) => s.uid().to_string(), @@ -99,53 +119,39 @@ impl Engine { "H" => Vec::new(), // host info "S" => match data.first().map(String::as_str) { Some("PLAIN") => { - self.sasl_sessions.insert( - client.clone(), - SaslSession { mech: "PLAIN".to_string(), response: String::new() }, - ); + self.sasl_sessions.insert(client.clone(), SaslSession::Plain { response: String::new() }); mk("C", vec!["+".to_string()]) // empty challenge -> client sends the payload } + Some(mech) if scram::Hash::from_mech(mech).is_some() => { + let hash = scram::Hash::from_mech(mech).unwrap(); + self.sasl_sessions.insert(client.clone(), SaslSession::Scram { hash, step: ScramStep::ClientFirst }); + mk("C", vec!["+".to_string()]) // client sends client-first next + } _ => mk("D", vec!["F".to_string()]), // unsupported mechanism }, "C" => { - // Reassemble the base64 response: append each chunk until one is - // shorter than a full chunk (or a lone "+"), which ends it. let chunk = data.first().map(String::as_str).unwrap_or(""); - let overflowed = match self.sasl_sessions.get_mut(&client) { - Some(s) => { - if chunk != "+" { - s.response.push_str(chunk); - } - s.response.len() > MAX_SASL_RESPONSE - } - None => return mk("D", vec!["F".to_string()]), - }; - if overflowed { - self.sasl_sessions.remove(&client); - return mk("D", vec!["F".to_string()]); - } - if chunk.len() >= MAX_AUTHENTICATE { - return Vec::new(); // more chunks still to come - } - let session = self.sasl_sessions.remove(&client).unwrap_or_default(); - let account = (session.mech == "PLAIN") - .then(|| login_plain(&session.response, &self.db)) - .flatten(); - match account { - Some(account) => vec![ - NetAction::Metadata { - target: client.clone(), - key: "accountname".to_string(), - value: account, - }, - NetAction::Sasl { - agent: agent.clone(), - client: client.clone(), - mode: "D".to_string(), - data: vec!["S".to_string()], - }, - ], + match self.sasl_sessions.remove(&client) { None => mk("D", vec!["F".to_string()]), + Some(SaslSession::Plain { mut response }) => { + // Reassemble the base64 response: append each chunk until + // one is shorter than a full chunk (or a lone "+"). + if chunk != "+" { + response.push_str(chunk); + } + if response.len() > MAX_SASL_RESPONSE { + return mk("D", vec!["F".to_string()]); + } + if chunk.len() >= MAX_AUTHENTICATE { + self.sasl_sessions.insert(client.clone(), SaslSession::Plain { response }); + return Vec::new(); // more chunks still to come + } + match login_plain(&response, &self.db) { + Some(account) => sasl_success(&agent, &client, account), + None => mk("D", vec!["F".to_string()]), + } + } + Some(SaslSession::Scram { hash, step }) => self.sasl_scram(&agent, &client, hash, step, chunk), } } "D" => { @@ -156,6 +162,59 @@ impl Engine { } } + // One SCRAM step. The client's messages arrive base64-encoded in the C data + // (except the final empty "+" acknowledgement). + fn sasl_scram(&mut self, agent: &str, client: &str, hash: scram::Hash, step: ScramStep, chunk: &str) -> Vec { + let fail = || vec![NetAction::Sasl { + agent: agent.to_string(), client: client.to_string(), mode: "D".to_string(), data: vec!["F".to_string()], + }]; + let challenge = |msg: String| vec![NetAction::Sasl { + agent: agent.to_string(), client: client.to_string(), mode: "C".to_string(), data: vec![STANDARD.encode(msg)], + }]; + let decode = |chunk: &str| STANDARD.decode(chunk).ok().and_then(|b| String::from_utf8(b).ok()); + + match step { + ScramStep::ClientFirst => { + let Some(cf) = decode(chunk).as_deref().and_then(scram::parse_client_first) else { + return fail(); + }; + let Some((account, verifier)) = self.db.scram_lookup(&cf.username, hash.mech()) else { + return fail(); + }; + let (account, Some(verifier)) = (account.to_string(), scram::parse_verifier(verifier)) else { + return fail(); + }; + let (server_first, nonce) = scram::server_first(&cf.cnonce, &verifier); + let out = challenge(server_first.clone()); + self.sasl_sessions.insert(client.to_string(), SaslSession::Scram { + hash, + step: ScramStep::ClientFinal { + account, + verifier, + client_first_bare: cf.client_first_bare, + server_first, + gs2_header: cf.gs2_header, + nonce, + }, + }); + out + } + ScramStep::ClientFinal { account, verifier, client_first_bare, server_first, gs2_header, nonce } => { + let Some(msg) = decode(chunk) else { return fail() }; + match scram::verify_final(hash, &verifier, &client_first_bare, &server_first, &gs2_header, &nonce, &msg) { + Some(server_final) => { + let out = challenge(server_final); + self.sasl_sessions.insert(client.to_string(), SaslSession::Scram { hash, step: ScramStep::Ack { account } }); + out + } + None => fail(), + } + } + // Client acknowledged our server-final ("+"); apply the login. + ScramStep::Ack { account } => sasl_success(agent, client, account), + } + } + // Authority side of the IRCv3 account-registration relay: create the account // (same store as classic NickServ REGISTER) and answer the requesting ircd. fn account_request(&mut self, reqid: String, kind: String, account: String, p2: String, p3: String) -> Vec { @@ -199,7 +258,6 @@ impl Engine { // Decode a SASL PLAIN payload (authzid \0 authcid \0 passwd) and authenticate // it, returning the canonical account name on success. fn login_plain(b64: &str, db: &Db) -> Option { - use base64::{engine::general_purpose::STANDARD, Engine}; let raw = STANDARD.decode(b64).ok()?; let parts: Vec<&[u8]> = raw.split(|&b| b == 0).collect(); if parts.len() != 3 { @@ -210,11 +268,19 @@ fn login_plain(b64: &str, db: &Db) -> Option { db.authenticate(authcid, passwd).map(str::to_string) } +// The two actions that complete any mechanism: set the account (drives 900), +// then report SASL success (drives 903). +fn sasl_success(agent: &str, client: &str, account: String) -> Vec { + vec![ + NetAction::Metadata { target: client.to_string(), key: "accountname".to_string(), value: account }, + NetAction::Sasl { agent: agent.to_string(), client: client.to_string(), mode: "D".to_string(), data: vec!["S".to_string()] }, + ] +} + #[cfg(test)] mod tests { use super::*; use crate::services::nickserv::NickServ; - use base64::{engine::general_purpose::STANDARD, Engine as _}; fn plain(authzid: &[u8], authcid: &[u8], passwd: &[u8]) -> String { let mut payload = Vec::new(); @@ -230,6 +296,7 @@ mod tests { let path = std::env::temp_dir().join(format!("fedserv-sasl-{name}.jsonl")); let _ = std::fs::remove_file(&path); let mut db = Db::open(&path); + db.scram_iterations = 4096; // keep the debug-build verifier cheap in tests assert!(db.register(account, password, None).is_ok()); Engine::new(vec![Box::new(NickServ { uid: "42SAAAAAA".to_string() })], db) } @@ -292,4 +359,73 @@ mod tests { let out = sasl(&mut e, "C", &plain(b"", b"foo", b"wrong")); assert!(out.iter().any(|a| matches!(a, NetAction::Sasl { mode, data, .. } if mode == "D" && data.as_slice() == ["F"])), "{out:?}"); } + + // The single reply datum of a C/D action (SCRAM messages are single-chunk). + fn datum(out: &[NetAction]) -> (&str, &str) { + match out.first() { + Some(NetAction::Sasl { mode, data, .. }) => (mode.as_str(), data[0].as_str()), + other => panic!("expected a SASL action, got {other:?}"), + } + } + + // Drive a full SCRAM exchange through the engine, playing the client, and + // return the final actions. `login_pw` may differ from the registered one. + fn scram_exchange(mech: &str, login_pw: &str) -> Vec { + use scram::Hash; + let hash = Hash::from_mech(mech).unwrap(); + let mut e = engine_with("scram", "foo", "sesame"); + + assert_eq!(datum(&sasl(&mut e, "S", mech)), ("C", "+")); + + let client_first_bare = "n=foo,r=cnonce"; + let client_first = format!("n,,{client_first_bare}"); + let first_out = sasl(&mut e, "C", &STANDARD.encode(&client_first)); + let (mode, b64) = datum(&first_out); + assert_eq!(mode, "C"); + let server_first = String::from_utf8(STANDARD.decode(b64).unwrap()).unwrap(); + + // Reconstruct salt/iterations from server-first and forge the proof. + let salt = STANDARD.decode(server_first.split(",s=").nth(1).unwrap().split(',').next().unwrap()).unwrap(); + let iters: u32 = server_first.rsplit(",i=").next().unwrap().parse().unwrap(); + let full_nonce = server_first.split("r=").nth(1).unwrap().split(',').next().unwrap(); + + let salted = scram::hi(hash, login_pw.as_bytes(), &salt, iters); + let client_key = scram::hmac(hash, &salted, b"Client Key"); + let stored_key = scram::h(hash, &client_key); + let without_proof = format!("c=biws,r={full_nonce}"); + let auth = format!("{client_first_bare},{server_first},{without_proof}"); + let proof = scram::xor(&client_key, &scram::hmac(hash, &stored_key, auth.as_bytes())); + let client_final = format!("{without_proof},p={}", STANDARD.encode(&proof)); + + let final_out = sasl(&mut e, "C", &STANDARD.encode(&client_final)); + // On success the engine sends server-final (C v=...); the client then acks. + match datum(&final_out) { + ("C", _) => sasl(&mut e, "C", "+"), + _ => final_out, + } + } + + #[test] + fn scram_sha256_success() { + assert!(is_success(&scram_exchange("SCRAM-SHA-256", "sesame"))); + } + + #[test] + fn scram_sha512_success() { + assert!(is_success(&scram_exchange("SCRAM-SHA-512", "sesame"))); + } + + #[test] + fn scram_bad_password() { + let out = scram_exchange("SCRAM-SHA-256", "millet"); + assert!(out.iter().any(|a| matches!(a, NetAction::Sasl { mode, data, .. } if mode == "D" && data.as_slice() == ["F"])), "{out:?}"); + } + + #[test] + fn scram_unknown_user_fails() { + let mut e = engine_with("scramnouser", "foo", "sesame"); + sasl(&mut e, "S", "SCRAM-SHA-256"); + let out = sasl(&mut e, "C", &STANDARD.encode("n,,n=ghost,r=cnonce")); + assert_eq!(datum(&out), ("D", "F")); + } } diff --git a/src/engine/scram.rs b/src/engine/scram.rs new file mode 100644 index 0000000..a3c743f --- /dev/null +++ b/src/engine/scram.rs @@ -0,0 +1,299 @@ +//! SCRAM (RFC 5802 / RFC 7677) server side for SASL SCRAM-SHA-256 and +//! SCRAM-SHA-512. The stored verifier is byte-compatible with the rest of the +//! stack (Anope m_apiauth / Django): `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 argon2::password_hash::rand_core::{OsRng, RngCore}; +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use hmac::{Hmac, Mac}; +use sha2::{Digest, Sha256, Sha512}; + +// 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. +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 { + 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 { + match hash { + Hash::Sha256 => { + let mut m = Hmac::::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::::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 { + 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 { + match hash { + Hash::Sha256 => { + let mut out = [0u8; 32]; + pbkdf2::pbkdf2_hmac::(password, salt, iterations, &mut out); + out.to_vec() + } + Hash::Sha512 => { + let mut out = [0u8; 64]; + pbkdf2::pbkdf2_hmac::(password, salt, iterations, &mut out); + out.to_vec() + } + } +} + +pub(crate) fn xor(a: &[u8], b: &[u8]) -> Vec { + a.iter().zip(b).map(|(x, y)| x ^ y).collect() +} + +// --- verifier --- + +pub struct Verifier { + pub iterations: u32, + pub salt: Vec, + pub stored_key: Vec, + pub server_key: Vec, +} + +/// 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), + ) +} + +pub fn parse_verifier(encoded: &str) -> Option { + 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 `,,n=,r=`. Channel binding (`p=`) is +/// rejected — we advertise the bare mechanisms only. +pub fn parse_client_first(msg: &str) -> Option { + 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=`) 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 { + 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); + if h(hash, &client_key) != v.stored_key { + 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()); + } +} diff --git a/src/main.rs b/src/main.rs index 24d09d2..e5210a1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,7 +35,8 @@ async fn main() -> Result<()> { let services: Vec> = vec![Box::new(NickServ { uid: format!("{}AAAAAA", cfg.server.sid), })]; - let db = engine::db::Db::open("fedserv.db.jsonl"); + let mut db = engine::db::Db::open("fedserv.db.jsonl"); + db.scram_iterations = cfg.server.scram_iterations; let engine = Engine::new(services, db); let addr = format!("{}:{}", cfg.uplink.host, cfg.uplink.port); diff --git a/testing/fedserv_services.py b/testing/fedserv_services.py index 19efc22..462758b 100644 --- a/testing/fedserv_services.py +++ b/testing/fedserv_services.py @@ -32,6 +32,8 @@ name = "My.Little.Services" sid = "42S" description = "Federated Services" protocol = 1206 +# Cheap verifiers so registration doesn't stall the link during the test run. +scram_iterations = 4096 """