Add SASL SCRAM-SHA-256 and SCRAM-SHA-512
This commit is contained in:
parent
172e50c748
commit
496c0f99a6
8 changed files with 569 additions and 58 deletions
33
Cargo.lock
generated
33
Cargo.lock
generated
|
|
@ -114,8 +114,11 @@ dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
"base64",
|
"base64",
|
||||||
|
"hmac",
|
||||||
|
"pbkdf2",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|
@ -149,6 +152,15 @@ version = "0.17.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hmac"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.14.0"
|
version = "2.14.0"
|
||||||
|
|
@ -235,6 +247,16 @@ dependencies = [
|
||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pbkdf2"
|
||||||
|
version = "0.12.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
"hmac",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
|
|
@ -337,6 +359,17 @@ dependencies = [
|
||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "sharded-slab"
|
name = "sharded-slab"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ serde_json = "1"
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
argon2 = { version = "0.5", features = ["std"] }
|
argon2 = { version = "0.5", features = ["std"] }
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
sha2 = "0.10"
|
||||||
|
hmac = "0.12"
|
||||||
|
pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] }
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,21 @@ pub struct Server {
|
||||||
pub description: String,
|
pub description: String,
|
||||||
#[serde(default = "default_protocol")]
|
#[serde(default = "default_protocol")]
|
||||||
pub protocol: u32,
|
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 {
|
fn default_protocol() -> u32 {
|
||||||
1206 // InspIRCd 4 spanning-tree protocol (1205 = insp3)
|
1206 // InspIRCd 4 spanning-tree protocol (1205 = insp3)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_scram_iterations() -> u32 {
|
||||||
|
crate::engine::scram::DEFAULT_ITERATIONS
|
||||||
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub fn load(path: &str) -> anyhow::Result<Self> {
|
pub fn load(path: &str) -> anyhow::Result<Self> {
|
||||||
let raw = std::fs::read_to_string(path)?;
|
let raw = std::fs::read_to_string(path)?;
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,20 @@ use argon2::password_hash::SaltString;
|
||||||
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::scram::{self, Hash};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Account {
|
pub struct Account {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub password_hash: String,
|
pub password_hash: String,
|
||||||
pub email: Option<String>,
|
pub email: Option<String>,
|
||||||
pub ts: u64,
|
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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub scram512: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event-sourced persistence: every change is an Event appended to a JSONL log,
|
// Event-sourced persistence: every change is an Event appended to a JSONL log,
|
||||||
|
|
@ -33,6 +41,8 @@ pub enum RegError {
|
||||||
pub struct Db {
|
pub struct Db {
|
||||||
accounts: HashMap<String, Account>, // keyed by casefolded name
|
accounts: HashMap<String, Account>, // keyed by casefolded name
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
|
// PBKDF2 cost baked into new SCRAM verifiers; lowered by tests.
|
||||||
|
pub(crate) scram_iterations: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn key(name: &str) -> String {
|
fn key(name: &str) -> String {
|
||||||
|
|
@ -58,7 +68,7 @@ impl Db {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tracing::info!(accounts = accounts.len(), ?path, "account store loaded");
|
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 {
|
pub fn exists(&self, name: &str) -> bool {
|
||||||
|
|
@ -70,7 +80,14 @@ impl Db {
|
||||||
return Err(RegError::Exists);
|
return Err(RegError::Exists);
|
||||||
}
|
}
|
||||||
let password_hash = hash_password(password).ok_or(RegError::Internal)?;
|
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.append(&Event::AccountRegistered(account.clone())).map_err(|_| RegError::Internal)?;
|
||||||
self.accounts.insert(key(name), account);
|
self.accounts.insert(key(name), account);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -83,6 +100,17 @@ impl Db {
|
||||||
verify_password(password, &account.password_hash).then_some(account.name.as_str())
|
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<()> {
|
fn append(&self, event: &Event) -> std::io::Result<()> {
|
||||||
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&self.path)?;
|
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&self.path)?;
|
||||||
writeln!(f, "{}", serde_json::to_string(event).unwrap_or_default())
|
writeln!(f, "{}", serde_json::to_string(event).unwrap_or_default())
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,21 @@
|
||||||
pub mod db;
|
pub mod db;
|
||||||
|
pub mod scram;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||||
|
|
||||||
use crate::proto::{NetAction, NetEvent};
|
use crate::proto::{NetAction, NetEvent};
|
||||||
use db::{Db, RegError};
|
use db::{Db, RegError};
|
||||||
|
use scram::Verifier;
|
||||||
use service::{Sender, Service, ServiceCtx};
|
use service::{Sender, Service, ServiceCtx};
|
||||||
use state::Network;
|
use state::Network;
|
||||||
|
|
||||||
// SASL mechanisms we offer. Advertised to the uplink as the `sasl=` capability
|
// SASL mechanisms we offer, strongest first. Advertised to the uplink as the
|
||||||
// value (IRCv3 SASL 3.2 mechanism list) and the set we accept in the exchange.
|
// `sasl=` capability value (IRCv3 SASL 3.2 mechanism list) and the set we accept.
|
||||||
const SASL_MECHS: &str = "PLAIN";
|
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
|
// 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).
|
// 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.
|
// Upper bound on a reassembled response, to cap a pre-auth client's buffer.
|
||||||
const MAX_SASL_RESPONSE: usize = 8 * 1024;
|
const MAX_SASL_RESPONSE: usize = 8 * 1024;
|
||||||
|
|
||||||
// A client's in-progress SASL exchange: the chosen mechanism and the base64
|
// A client's in-progress SASL exchange.
|
||||||
// response reassembled from the uplink's fixed-size AUTHENTICATE chunks.
|
enum SaslSession {
|
||||||
#[derive(Default)]
|
// PLAIN: the base64 response reassembled from the uplink's chunks.
|
||||||
struct SaslSession {
|
Plain { response: String },
|
||||||
mech: String,
|
// SCRAM: which hash, and where we are in the challenge/response.
|
||||||
response: String,
|
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 {
|
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),
|
// SASL agent side of the exchange the ircd relays to us (modes H/S/C/D), per
|
||||||
// per IRCv3 SASL 3.2. PLAIN only: on a valid client response we set the
|
// IRCv3 SASL 3.2. On success we set the client's account (drives 900) then
|
||||||
// client's account (drives 900) then report success (drives 903); a bad
|
// report success (drives 903); a bad credential or unknown mechanism reports
|
||||||
// credential or unknown mechanism reports failure (drives 904).
|
// failure (drives 904). PLAIN and SCRAM-SHA-256/512 are supported.
|
||||||
fn sasl(&mut self, client: String, mode: String, data: Vec<String>) -> Vec<NetAction> {
|
fn sasl(&mut self, client: String, mode: String, data: Vec<String>) -> Vec<NetAction> {
|
||||||
let agent = match self.services.first() {
|
let agent = match self.services.first() {
|
||||||
Some(s) => s.uid().to_string(),
|
Some(s) => s.uid().to_string(),
|
||||||
|
|
@ -99,53 +119,39 @@ impl Engine {
|
||||||
"H" => Vec::new(), // host info
|
"H" => Vec::new(), // host info
|
||||||
"S" => match data.first().map(String::as_str) {
|
"S" => match data.first().map(String::as_str) {
|
||||||
Some("PLAIN") => {
|
Some("PLAIN") => {
|
||||||
self.sasl_sessions.insert(
|
self.sasl_sessions.insert(client.clone(), SaslSession::Plain { response: String::new() });
|
||||||
client.clone(),
|
|
||||||
SaslSession { mech: "PLAIN".to_string(), response: String::new() },
|
|
||||||
);
|
|
||||||
mk("C", vec!["+".to_string()]) // empty challenge -> client sends the payload
|
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
|
_ => mk("D", vec!["F".to_string()]), // unsupported mechanism
|
||||||
},
|
},
|
||||||
"C" => {
|
"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 chunk = data.first().map(String::as_str).unwrap_or("");
|
||||||
let overflowed = match self.sasl_sessions.get_mut(&client) {
|
match self.sasl_sessions.remove(&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()],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
None => mk("D", vec!["F".to_string()]),
|
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" => {
|
"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<NetAction> {
|
||||||
|
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
|
// Authority side of the IRCv3 account-registration relay: create the account
|
||||||
// (same store as classic NickServ REGISTER) and answer the requesting ircd.
|
// (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<NetAction> {
|
fn account_request(&mut self, reqid: String, kind: String, account: String, p2: String, p3: String) -> Vec<NetAction> {
|
||||||
|
|
@ -199,7 +258,6 @@ impl Engine {
|
||||||
// Decode a SASL PLAIN payload (authzid \0 authcid \0 passwd) and authenticate
|
// Decode a SASL PLAIN payload (authzid \0 authcid \0 passwd) and authenticate
|
||||||
// it, returning the canonical account name on success.
|
// it, returning the canonical account name on success.
|
||||||
fn login_plain(b64: &str, db: &Db) -> Option<String> {
|
fn login_plain(b64: &str, db: &Db) -> Option<String> {
|
||||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
|
||||||
let raw = STANDARD.decode(b64).ok()?;
|
let raw = STANDARD.decode(b64).ok()?;
|
||||||
let parts: Vec<&[u8]> = raw.split(|&b| b == 0).collect();
|
let parts: Vec<&[u8]> = raw.split(|&b| b == 0).collect();
|
||||||
if parts.len() != 3 {
|
if parts.len() != 3 {
|
||||||
|
|
@ -210,11 +268,19 @@ fn login_plain(b64: &str, db: &Db) -> Option<String> {
|
||||||
db.authenticate(authcid, passwd).map(str::to_string)
|
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<NetAction> {
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::services::nickserv::NickServ;
|
use crate::services::nickserv::NickServ;
|
||||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
|
||||||
|
|
||||||
fn plain(authzid: &[u8], authcid: &[u8], passwd: &[u8]) -> String {
|
fn plain(authzid: &[u8], authcid: &[u8], passwd: &[u8]) -> String {
|
||||||
let mut payload = Vec::new();
|
let mut payload = Vec::new();
|
||||||
|
|
@ -230,6 +296,7 @@ mod tests {
|
||||||
let path = std::env::temp_dir().join(format!("fedserv-sasl-{name}.jsonl"));
|
let path = std::env::temp_dir().join(format!("fedserv-sasl-{name}.jsonl"));
|
||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
let mut db = Db::open(&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());
|
assert!(db.register(account, password, None).is_ok());
|
||||||
Engine::new(vec![Box::new(NickServ { uid: "42SAAAAAA".to_string() })], db)
|
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"));
|
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:?}");
|
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<NetAction> {
|
||||||
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
299
src/engine/scram.rs
Normal file
299
src/engine/scram.rs
Normal file
|
|
@ -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<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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -35,7 +35,8 @@ async fn main() -> Result<()> {
|
||||||
let services: Vec<Box<dyn engine::service::Service>> = vec![Box::new(NickServ {
|
let services: Vec<Box<dyn engine::service::Service>> = vec![Box::new(NickServ {
|
||||||
uid: format!("{}AAAAAA", cfg.server.sid),
|
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 engine = Engine::new(services, db);
|
||||||
|
|
||||||
let addr = format!("{}:{}", cfg.uplink.host, cfg.uplink.port);
|
let addr = format!("{}:{}", cfg.uplink.host, cfg.uplink.port);
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ name = "My.Little.Services"
|
||||||
sid = "42S"
|
sid = "42S"
|
||||||
description = "Federated Services"
|
description = "Federated Services"
|
||||||
protocol = 1206
|
protocol = 1206
|
||||||
|
# Cheap verifiers so registration doesn't stall the link during the test run.
|
||||||
|
scram_iterations = 4096
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue