use std::collections::HashMap;
use std::io;
use std::sync::{LazyLock, Mutex};
use constant_time_eq::constant_time_eq_32;
use iroh::endpoint::Connection;
use zeroize::Zeroizing;
const NO_PASS: u8 = 0;
const PASS_REQUIRED: u8 = 1;
const VERDICT_OK: u8 = 1;
const VERDICT_REJECT: u8 = 0;
fn fresh_nonce() -> [u8; 32] {
use rand::RngCore;
let mut nonce = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut nonce);
nonce
}
#[expect(
clippy::expect_used,
reason = "Params::new only errors on out-of-range values; these are compile-time constants"
)]
fn kdf_params() -> argon2::Params {
argon2::Params::new(64 * 1024, 3, 1, Some(32)).expect("static Argon2id params are in range")
}
fn kdf_salt() -> [u8; 16] {
let mut salt = [0u8; 16];
salt.copy_from_slice(&blake3::hash(b"koh-pass-kdf-v1").as_bytes()[..16]);
salt
}
#[expect(
clippy::expect_used,
reason = "hash_password_into only errors on invalid params/output-len, fixed valid here"
)]
fn derive_psk(passphrase: &str) -> Zeroizing<[u8; 32]> {
let argon = argon2::Argon2::new(
argon2::Algorithm::Argon2id,
argon2::Version::V0x13,
kdf_params(),
);
let mut psk = Zeroizing::new([0u8; 32]);
argon
.hash_password_into(passphrase.as_bytes(), &kdf_salt(), psk.as_mut_slice())
.expect("Argon2id derivation with valid static params and a 32-byte output cannot fail");
psk
}
type PskCache = HashMap<[u8; 32], Zeroizing<[u8; 32]>>;
static PSK_CACHE: LazyLock<Mutex<PskCache>> = LazyLock::new(|| Mutex::new(HashMap::new()));
#[expect(
clippy::expect_used,
reason = "a poisoned cache mutex is a panic-elsewhere bug, not peer-influenced input"
)]
fn cached_psk(passphrase: &str) -> Zeroizing<[u8; 32]> {
let key = *blake3::hash(passphrase.as_bytes()).as_bytes();
{
let cache = PSK_CACHE.lock().expect("PSK cache mutex poisoned");
if let Some(psk) = cache.get(&key) {
return psk.clone();
}
}
let psk = derive_psk(passphrase);
PSK_CACHE
.lock()
.expect("PSK cache mutex poisoned")
.insert(key, psk.clone());
psk
}
fn challenge_response(psk: &[u8; 32], nonce: &[u8; 32]) -> [u8; 32] {
let mut input = [0u8; 64];
input[..32].copy_from_slice(psk);
input[32..].copy_from_slice(nonce);
*blake3::hash(&input).as_bytes()
}
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error("auth stream error: {0}")]
Stream(#[from] io::Error),
#[error("passphrase challenge failed")]
ChallengeFailed,
}
pub async fn handshake_server(
conn: &Connection,
passphrase: Option<&str>,
) -> Result<(), AuthError> {
let (mut send, mut recv) = conn.open_bi().await.map_err(io::Error::other)?;
match passphrase {
None => {
send.write_all(&[NO_PASS]).await.map_err(io::Error::other)?;
let _ = send.finish();
}
Some(pass) => {
let psk = cached_psk(pass);
let nonce = fresh_nonce();
let mut msg = Vec::with_capacity(33);
msg.push(PASS_REQUIRED);
msg.extend_from_slice(&nonce);
send.write_all(&msg).await.map_err(io::Error::other)?;
let mut resp = [0u8; 32];
recv.read_exact(&mut resp).await.map_err(io::Error::other)?;
let expect = challenge_response(&psk, &nonce);
let ok = constant_time_eq_32(&resp, &expect);
let verdict = if ok { VERDICT_OK } else { VERDICT_REJECT };
send.write_all(&[verdict]).await.map_err(io::Error::other)?;
let _ = send.finish();
if !ok {
return Err(AuthError::ChallengeFailed);
}
}
}
Ok(())
}
pub async fn handshake_client(
conn: &Connection,
passphrase: Option<&str>,
) -> Result<(), AuthError> {
let (mut send, mut recv) = conn.accept_bi().await.map_err(io::Error::other)?;
let mut tag = [0u8; 1];
recv.read_exact(&mut tag).await.map_err(io::Error::other)?;
if tag[0] == PASS_REQUIRED {
let mut nonce = [0u8; 32];
recv.read_exact(&mut nonce)
.await
.map_err(io::Error::other)?;
let psk = cached_psk(passphrase.unwrap_or(""));
let resp = challenge_response(&psk, &nonce);
send.write_all(&resp).await.map_err(io::Error::other)?;
let _ = send.finish();
let mut verdict = [0u8; 1];
recv.read_exact(&mut verdict)
.await
.map_err(io::Error::other)?;
if verdict[0] != VERDICT_OK {
return Err(AuthError::ChallengeFailed);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn auth_error_variants_are_constructible_and_reachable() {
let rejected = AuthError::ChallengeFailed;
assert_eq!(rejected.to_string(), "passphrase challenge failed");
let io_err = io::Error::new(io::ErrorKind::UnexpectedEof, "stream closed");
let stream: AuthError = io_err.into();
assert!(matches!(stream, AuthError::Stream(_)));
assert!(stream.to_string().contains("auth stream error"));
let absorbed: anyhow::Error = AuthError::ChallengeFailed.into();
assert!(absorbed.to_string().contains("challenge failed"));
}
#[test]
fn successive_nonces_differ() {
let a = fresh_nonce();
let b = fresh_nonce();
assert_ne!(a, b, "two OsRng nonces must differ");
assert_ne!(a, [0u8; 32], "a nonce must not be all-zero");
}
#[test]
fn derive_psk_is_deterministic_and_both_peers_agree() {
let server_k = derive_psk("correct horse battery staple");
let client_k = derive_psk("correct horse battery staple");
assert_eq!(*server_k, *client_k, "both peers must derive the same PSK");
assert_eq!(*cached_psk("correct horse battery staple"), *server_k);
assert_ne!(
*server_k,
*derive_psk("wrong horse"),
"distinct passphrases -> distinct PSKs"
);
assert_eq!(kdf_salt(), kdf_salt());
}
#[test]
fn correct_response_verifies_and_wrong_one_does_not() {
let psk = cached_psk("hunter2");
let nonce = fresh_nonce();
let good = challenge_response(&psk, &nonce);
assert!(
constant_time_eq_32(&good, &challenge_response(&psk, &nonce)),
"the correct response must verify"
);
let bad = challenge_response(&cached_psk("nope"), &nonce);
assert!(
!constant_time_eq_32(&good, &bad),
"a wrong response must be rejected"
);
let other_nonce = fresh_nonce();
assert!(
!constant_time_eq_32(&good, &challenge_response(&psk, &other_nonce)),
"a response is bound to its nonce"
);
}
}