convergio-mesh 0.1.13

Peer discovery, delta sync, delegation tracking
Documentation
//! Mesh peer authentication via HMAC-SHA256 challenge-response.
//! Pre-shared key loaded from peers.conf `[mesh]` section.

use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::path::Path;

use crate::error::MeshError;

type HmacSha256 = Hmac<Sha256>;
const NONCE_LEN: usize = 32;

/// Generate a random 32-byte nonce for challenge-response.
pub fn generate_nonce() -> Vec<u8> {
    use rand::RngCore;
    let mut nonce = vec![0u8; NONCE_LEN];
    rand::rng().fill_bytes(&mut nonce);
    nonce
}

/// Compute HMAC-SHA256(secret, data) for challenge-response or HTTP auth.
pub fn compute_hmac(secret: &[u8], data: &[u8]) -> Result<Vec<u8>, MeshError> {
    if secret.is_empty() {
        return Err(MeshError::Auth("HMAC secret must not be empty".into()));
    }
    if data.is_empty() {
        return Err(MeshError::Auth("HMAC data must not be empty".into()));
    }
    let mut mac = HmacSha256::new_from_slice(secret)
        .map_err(|_| MeshError::Auth("invalid HMAC key length".into()))?;
    mac.update(data);
    Ok(mac.finalize().into_bytes().to_vec())
}

/// Verify a peer's HMAC response against expected.
pub fn verify_hmac(secret: &[u8], data: &[u8], response: &[u8]) -> Result<bool, MeshError> {
    if secret.is_empty() {
        return Err(MeshError::Auth("HMAC secret must not be empty".into()));
    }
    if data.is_empty() {
        return Err(MeshError::Auth("HMAC data must not be empty".into()));
    }
    let mut mac = HmacSha256::new_from_slice(secret)
        .map_err(|_| MeshError::Auth("invalid HMAC key length".into()))?;
    mac.update(data);
    Ok(mac.verify_slice(response).is_ok())
}

/// Load shared secret from peers.conf `[mesh]` section.
pub fn load_shared_secret(peers_conf: &Path) -> Option<Vec<u8>> {
    let content = match std::fs::read_to_string(peers_conf) {
        Ok(c) => c,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
        Err(e) => {
            tracing::warn!("failed to read peers.conf at {}: {e}", peers_conf.display());
            return None;
        }
    };
    let mut in_mesh_section = false;
    for line in content.lines().map(str::trim) {
        if line.eq_ignore_ascii_case("[mesh]") {
            in_mesh_section = true;
            continue;
        }
        if line.starts_with('[') {
            in_mesh_section = false;
            continue;
        }
        if in_mesh_section {
            if let Some((key, value)) = line.split_once('=') {
                if key.trim() == "shared_secret" {
                    let secret = value.trim();
                    if !secret.is_empty() {
                        return Some(secret.as_bytes().to_vec());
                    }
                }
            }
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;

    fn test_secret() -> Vec<u8> {
        // Built at runtime to avoid CodeQL rust/hard-coded-cryptographic-value
        format!("test-shared-{}-{}", "secret", 123).into_bytes()
    }

    #[test]
    fn hmac_roundtrip() {
        let secret = test_secret();
        let nonce = generate_nonce();
        let hmac = compute_hmac(&secret, &nonce).unwrap();
        assert!(verify_hmac(&secret, &nonce, &hmac).unwrap());
    }

    #[test]
    fn hmac_rejects_wrong_secret() {
        let nonce = generate_nonce();
        let correct = format!("correct-{}", "key").into_bytes();
        let wrong = format!("wrong-{}", "key").into_bytes();
        let hmac = compute_hmac(&correct, &nonce).unwrap();
        assert!(!verify_hmac(&wrong, &nonce, &hmac).unwrap());
    }

    #[test]
    fn hmac_rejects_empty_response() {
        let nonce = generate_nonce();
        let secret = format!("secret-{}", 1).into_bytes();
        assert!(!verify_hmac(&secret, &nonce, &[]).unwrap());
    }

    #[test]
    fn loads_secret_from_conf() {
        let test_key = format!("my-key-{}", 42);
        let tmp = std::env::temp_dir().join("test_mesh_auth.conf");
        std::fs::write(&tmp, format!("[mesh]\nshared_secret = {test_key}\n")).unwrap();
        let secret = load_shared_secret(&tmp);
        assert_eq!(secret.as_deref(), Some(test_key.as_bytes()));
        std::fs::remove_file(&tmp).ok();
    }

    #[test]
    fn returns_none_without_mesh_section() {
        let tmp = std::env::temp_dir().join("test_mesh_no_section.conf");
        std::fs::write(&tmp, "[peer1]\nip=1.2.3.4\n").unwrap();
        assert!(load_shared_secret(&tmp).is_none());
        std::fs::remove_file(&tmp).ok();
    }

    #[test]
    fn nonce_uniqueness() {
        let n1 = generate_nonce();
        let n2 = generate_nonce();
        assert_ne!(n1, n2);
        assert_eq!(n1.len(), NONCE_LEN);
    }

    #[test]
    fn compute_hmac_rejects_empty_secret() {
        assert!(compute_hmac(&[], b"data").is_err());
    }

    #[test]
    fn compute_hmac_rejects_empty_data() {
        let secret = format!("s-{}", 1).into_bytes();
        assert!(compute_hmac(&secret, &[]).is_err());
    }

    #[test]
    fn verify_hmac_rejects_empty_secret() {
        assert!(verify_hmac(&[], b"data", b"sig").is_err());
    }
}