ruzor 0.1.2

Ruzor, a 1:1-compatible Rust port of the Pyzor UDP client and server
Documentation
use std::time::{SystemTime, UNIX_EPOCH};

use crate::error::PyzorError;
use crate::message::Message;
use crate::{ANONYMOUS_USER, MAX_TIMESTAMP_DIFFERENCE, Result, sha1};

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Account {
    pub username: String,
    pub salt: Option<String>,
    pub key: String,
}

impl Account {
    pub fn new(username: impl Into<String>, salt: Option<String>, key: impl Into<String>) -> Self {
        Self {
            username: username.into(),
            salt,
            key: key.into(),
        }
    }

    pub fn anonymous() -> Self {
        Self::new(ANONYMOUS_USER, None, "")
    }
}

pub fn hash_key(key: &str, user: &str) -> String {
    sha1::hexdigest(format!("{}:{}", user, key.to_lowercase()).as_bytes())
}

pub fn sign_msg(hashed_key: &str, timestamp: i64, msg: &Message) -> String {
    let stripped = msg.as_string().trim().as_bytes().to_vec();
    let first = sha1::digest(&stripped);
    let mut second_input = Vec::with_capacity(first.len() + hashed_key.len() + 32);
    second_input.extend_from_slice(&first);
    second_input.extend_from_slice(format!(":{}:{}", timestamp, hashed_key).as_bytes());
    sha1::hexdigest(&second_input)
}

pub fn sign_for_account(msg: &mut Message, account: &Account, timestamp: i64) {
    msg.set_header("User", &account.username);
    msg.set_header("Time", timestamp.to_string());
    let hashed_key = hash_key(&account.key, &account.username);
    let sig = sign_msg(&hashed_key, timestamp, msg);
    msg.set_header("Sig", sig);
}

pub fn verify_signature(msg: &Message, user_key: &str) -> Result<()> {
    let timestamp: i64 = msg
        .get("Time")
        .ok_or_else(|| PyzorError::Signature("Missing timestamp.".to_string()))?
        .parse()
        .map_err(|_| PyzorError::Signature("Invalid timestamp.".to_string()))?;
    let user = msg
        .get("User")
        .ok_or_else(|| PyzorError::Signature("Missing user.".to_string()))?;
    let provided = msg
        .get("Sig")
        .ok_or_else(|| PyzorError::Signature("Missing signature.".to_string()))?;

    if (now_timestamp() - timestamp).abs() > MAX_TIMESTAMP_DIFFERENCE {
        return Err(PyzorError::Signature(
            "Timestamp not within allowed range.".to_string(),
        ));
    }

    let hashed_user_key = hash_key(user_key, user);
    let mut unsigned = msg.clone();
    unsigned.remove_all("Sig");
    let correct = sign_msg(&hashed_user_key, timestamp, &unsigned);
    if correct != provided {
        return Err(PyzorError::Signature("Invalid signature.".to_string()));
    }
    Ok(())
}

pub fn key_from_hexstr(value: &str) -> std::result::Result<(String, String), String> {
    let mut parts = value.split(',');
    let Some(salt) = parts.next() else {
        return Err(invalid_key_parts());
    };
    let Some(key) = parts.next() else {
        return Err(invalid_key_parts());
    };
    if parts.next().is_some() {
        return Err(invalid_key_parts());
    }
    Ok((salt.to_string(), key.to_string()))
}

pub fn now_timestamp() -> i64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_secs() as i64)
        .unwrap_or(0)
}

fn invalid_key_parts() -> String {
    "Invalid number of parts for key; perhaps you forgot the comma at the beginning for the salt divider?".to_string()
}

#[cfg(test)]
mod tests {
    use super::{hash_key, sign_msg};
    use crate::message::Message;

    #[test]
    fn hash_key_matches_python() {
        assert_eq!(
            hash_key("testkey", "testuser"),
            "0957bd79b58263657127a39762879098286d8477"
        );
    }

    #[test]
    fn sign_msg_matches_python() {
        let timestamp = 1_381_219_396;
        let mut msg = Message::new();
        msg.add_header("Op", "ping");
        msg.add_header("Thread", "14941");
        msg.add_header("PV", "2.1");
        msg.add_header("User", "anonymous");
        msg.add_header("Time", timestamp.to_string());
        assert_eq!(
            sign_msg("00942f4668670f34c5943cf52c7ef3139fe2b8d6", timestamp, &msg),
            "2ab1bad2aae6fd80c656a896c82eef0ec1ec38a0"
        );
    }
}