use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use pamoja_session::{hkdf_sha256, hmac_sha256};
const INFO: &[u8] = b"pamoja/dashboard/cmd v1";
const SESSION_TTL: Duration = Duration::from_secs(30 * 60);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthError {
UnknownSession,
NotPaired,
Expired,
Replayed,
BadMac,
}
impl AuthError {
pub fn code(self) -> &'static str {
match self {
AuthError::UnknownSession => "auth.unknown_session",
AuthError::NotPaired => "auth.not_paired",
AuthError::Expired => "auth.expired",
AuthError::Replayed => "auth.replayed",
AuthError::BadMac => "auth.bad_mac",
}
}
}
struct Entry {
key: [u8; 32],
last_counter: u64,
paired: bool,
expires: Instant,
}
pub struct Challenge {
pub session_id: String,
pub nonce: String,
}
pub struct Auth {
secret: Vec<u8>,
sessions: Mutex<HashMap<String, Entry>>,
}
impl Auth {
pub fn new(secret: impl Into<String>) -> Self {
Self {
secret: secret.into().into_bytes(),
sessions: Mutex::new(HashMap::new()),
}
}
pub fn generate_secret() -> String {
to_hex(&random_bytes::<16>())
}
pub fn challenge(&self) -> Challenge {
let session_id = to_hex(&random_bytes::<16>());
let nonce = to_hex(&random_bytes::<16>());
let mut key = [0u8; 32];
hkdf_sha256(nonce.as_bytes(), &self.secret, INFO, &mut key);
let mut sessions = self.sessions.lock().expect("sessions lock");
prune(&mut sessions);
sessions.insert(
session_id.clone(),
Entry {
key,
last_counter: 0,
paired: false,
expires: Instant::now() + SESSION_TTL,
},
);
Challenge { session_id, nonce }
}
pub fn confirm(&self, session_id: &str, mac_hex: &str) -> Result<(), AuthError> {
let mut sessions = self.sessions.lock().expect("sessions lock");
let entry = sessions
.get_mut(session_id)
.ok_or(AuthError::UnknownSession)?;
if Instant::now() > entry.expires {
sessions.remove(session_id);
return Err(AuthError::Expired);
}
let expected = hmac_hex(&entry.key, format!("confirm\n{session_id}").as_bytes());
if !ct_eq(expected.as_bytes(), mac_hex.as_bytes()) {
return Err(AuthError::BadMac);
}
entry.paired = true;
Ok(())
}
pub fn verify_command(
&self,
session_id: &str,
counter: u64,
command: &str,
mac_hex: &str,
) -> Result<(), AuthError> {
let mut sessions = self.sessions.lock().expect("sessions lock");
let entry = sessions
.get_mut(session_id)
.ok_or(AuthError::UnknownSession)?;
if Instant::now() > entry.expires {
sessions.remove(session_id);
return Err(AuthError::Expired);
}
if !entry.paired {
return Err(AuthError::NotPaired);
}
if counter <= entry.last_counter {
return Err(AuthError::Replayed);
}
let expected = hmac_hex(&entry.key, format!("{counter}\n{command}").as_bytes());
if !ct_eq(expected.as_bytes(), mac_hex.as_bytes()) {
return Err(AuthError::BadMac);
}
entry.last_counter = counter;
Ok(())
}
}
fn prune(sessions: &mut HashMap<String, Entry>) {
let now = Instant::now();
sessions.retain(|_, entry| entry.expires > now);
}
fn random_bytes<const N: usize>() -> [u8; N] {
let mut bytes = [0u8; N];
getrandom::fill(&mut bytes).expect("system RNG");
bytes
}
fn hmac_hex(key: &[u8], message: &[u8]) -> String {
to_hex(&hmac_sha256(key, message))
}
fn to_hex(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push(char::from_digit((byte >> 4) as u32, 16).expect("nibble"));
out.push(char::from_digit((byte & 0xf) as u32, 16).expect("nibble"));
}
out
}
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b) {
diff |= x ^ y;
}
diff == 0
}
#[cfg(test)]
mod tests {
use super::*;
fn client_key(secret: &str, nonce: &str) -> [u8; 32] {
let mut key = [0u8; 32];
hkdf_sha256(nonce.as_bytes(), secret.as_bytes(), INFO, &mut key);
key
}
#[test]
fn a_correct_secret_pairs_and_commands() {
let secret = "s3cret";
let auth = Auth::new(secret);
let challenge = auth.challenge();
let key = client_key(secret, &challenge.nonce);
let confirm = to_hex(&hmac_sha256(
&key,
format!("confirm\n{}", challenge.session_id).as_bytes(),
));
auth.confirm(&challenge.session_id, &confirm)
.expect("paired");
let cmd = r#"{"type":"actuate"}"#;
let mac = to_hex(&hmac_sha256(&key, format!("1\n{cmd}").as_bytes()));
auth.verify_command(&challenge.session_id, 1, cmd, &mac)
.expect("first command");
}
#[test]
fn a_wrong_secret_cannot_pair() {
let auth = Auth::new("right");
let challenge = auth.challenge();
let key = client_key("wrong", &challenge.nonce);
let confirm = to_hex(&hmac_sha256(
&key,
format!("confirm\n{}", challenge.session_id).as_bytes(),
));
assert_eq!(
auth.confirm(&challenge.session_id, &confirm),
Err(AuthError::BadMac)
);
}
#[test]
fn a_replayed_counter_is_refused() {
let secret = "s3cret";
let auth = Auth::new(secret);
let challenge = auth.challenge();
let key = client_key(secret, &challenge.nonce);
let confirm = to_hex(&hmac_sha256(
&key,
format!("confirm\n{}", challenge.session_id).as_bytes(),
));
auth.confirm(&challenge.session_id, &confirm)
.expect("paired");
let cmd = r#"{"type":"actuate"}"#;
let mac = to_hex(&hmac_sha256(&key, format!("5\n{cmd}").as_bytes()));
auth.verify_command(&challenge.session_id, 5, cmd, &mac)
.expect("counter 5");
assert_eq!(
auth.verify_command(&challenge.session_id, 5, cmd, &mac),
Err(AuthError::Replayed)
);
}
#[test]
fn an_unpaired_session_cannot_command() {
let auth = Auth::new("s3cret");
let challenge = auth.challenge();
let key = client_key("s3cret", &challenge.nonce);
let cmd = "{}";
let mac = to_hex(&hmac_sha256(&key, format!("1\n{cmd}").as_bytes()));
assert_eq!(
auth.verify_command(&challenge.session_id, 1, cmd, &mac),
Err(AuthError::NotPaired)
);
}
#[test]
fn an_unknown_session_is_refused() {
let auth = Auth::new("s3cret");
assert_eq!(auth.confirm("nope", "00"), Err(AuthError::UnknownSession));
}
#[test]
fn a_tampered_command_fails_the_mac() {
let secret = "s3cret";
let auth = Auth::new(secret);
let challenge = auth.challenge();
let key = client_key(secret, &challenge.nonce);
let confirm = to_hex(&hmac_sha256(
&key,
format!("confirm\n{}", challenge.session_id).as_bytes(),
));
auth.confirm(&challenge.session_id, &confirm)
.expect("paired");
let cmd = r#"{"type":"actuate","action":"open"}"#;
let mac = to_hex(&hmac_sha256(&key, format!("1\n{cmd}").as_bytes()));
let tampered = r#"{"type":"actuate","action":"close"}"#;
assert_eq!(
auth.verify_command(&challenge.session_id, 1, tampered, &mac),
Err(AuthError::BadMac)
);
}
}