guard/crates/rampart-core/src/crypto/hmac.rs

153 lines
5.4 KiB
Rust

use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::time::{SystemTime, UNIX_EPOCH};
use subtle::ConstantTimeEq;
type HmacSha256 = Hmac<Sha256>;
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn hmac_hex(key: &[u8], message: &[u8]) -> String {
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(message);
hex::encode(mac.finalize().into_bytes())
}
fn derive_key(secret: &[u8], bucket: u64) -> Vec<u8> {
let msg = format!("rampart-key-{bucket}");
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
mac.update(msg.as_bytes());
mac.finalize().into_bytes().to_vec()
}
/// Подписывает hostname-поле: `domain\0shield\0<ts>\0<sig>`.
///
/// `sig` = HMAC-SHA256(derived_key, "domain|ts"), где
/// `derived_key` = HMAC-SHA256(master_secret, "rampart-key-{bucket}"), bucket = ts / rotation_secs.
pub fn sign_hostname(raw: &str, secret: &[u8], rotation_secs: u64) -> String {
let rotation_secs = rotation_secs.max(1);
let domain = raw.split('\0').next().unwrap_or(raw);
let ts = now_secs();
let bucket = ts / rotation_secs;
let key = derive_key(secret, bucket);
let sig = hmac_hex(&key, format!("{domain}|{ts}").as_bytes());
format!("{domain}\0shield\0{ts}\0{sig}")
}
/// Проверяет подпись hostname-поля по спецификации.
///
/// Парсит `domain\0shield\0<ts>\0<sig>`, проверяет `0 <= now - ts <= ttl_secs` и
/// сравнивает сигнатуру constant-time для bucket из `{ts_bucket, ts_bucket - 1}`.
pub fn verify_hostname(raw: &str, secret: &[u8], rotation_secs: u64, ttl_secs: u64) -> bool {
let rotation_secs = rotation_secs.max(1);
let mut parts = raw.split('\0');
let (Some(domain), Some(tag), Some(ts_str), Some(sig)) = (parts.next(), parts.next(), parts.next(), parts.next())
else {
return false;
};
if tag != "shield" || parts.next().is_some() {
return false;
}
let ts: u64 = match ts_str.parse() {
Ok(t) => t,
Err(_) => return false,
};
let now = now_secs();
if now < ts || now - ts > ttl_secs {
return false;
}
if sig.len() != 64 {
return false;
}
let bucket = ts / rotation_secs;
for candidate in [bucket, bucket.saturating_sub(1)] {
let key = derive_key(secret, candidate);
let expected = hmac_hex(&key, format!("{domain}|{ts}").as_bytes());
if expected.as_bytes().ct_eq(sig.as_bytes()).into() {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
const SECRET: &[u8] = b"test_secret_32_bytes_long_here!!";
fn build_signed(secret: &[u8], domain: &str, ts: u64, rotation_secs: u64) -> String {
let bucket = ts / rotation_secs.max(1);
let key = derive_key(secret, bucket);
let sig = hmac_hex(&key, format!("{domain}|{ts}").as_bytes());
format!("{domain}\0shield\0{ts}\0{sig}")
}
#[test]
fn test_sign_verify_roundtrip() {
let signed = sign_hostname("play.example.com", SECRET, 3600);
assert!(verify_hostname(&signed, SECRET, 3600, 60));
}
#[test]
fn test_sign_format() {
let signed = sign_hostname("play.example.com\0ignored", SECRET, 3600);
let parts: Vec<&str> = signed.split('\0').collect();
assert_eq!(parts.len(), 4);
assert_eq!(parts[0], "play.example.com");
assert_eq!(parts[1], "shield");
assert_eq!(parts[3].len(), 64);
assert!(parts[3].chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_verify_tampered_domain() {
let signed = sign_hostname("play.example.com", SECRET, 3600);
let tampered = signed.replace("play.example.com", "play.example.co");
assert!(!verify_hostname(&tampered, SECRET, 3600, 60));
}
#[test]
fn test_verify_wrong_secret() {
let signed = sign_hostname("play.example.com", SECRET, 3600);
let wrong = b"wrong_secret_32_bytes_long_here!!!";
assert!(!verify_hostname(&signed, wrong, 3600, 60));
}
#[test]
fn test_verify_expired_ts() {
let old_ts = now_secs().saturating_sub(120);
let signed = build_signed(SECRET, "play.example.com", old_ts, 3600);
assert!(!verify_hostname(&signed, SECRET, 3600, 60));
}
#[test]
fn test_verify_accepts_previous_bucket() {
let rotation = 10u64;
let now = now_secs();
let prev_bucket_ts = (now / rotation).saturating_sub(1) * rotation + 5;
let signed = build_signed(SECRET, "play.example.com", prev_bucket_ts, rotation);
assert!(verify_hostname(&signed, SECRET, rotation, 60));
}
#[test]
fn test_verify_rejects_tampered_ts() {
let ts = now_secs();
let signed = build_signed(SECRET, "play.example.com", ts, 3600);
let parts: Vec<&str> = signed.split('\0').collect();
let tampered = format!("{}\0{}\0{}\0{}", parts[0], parts[1], ts.saturating_sub(1), parts[3]);
assert!(!verify_hostname(&tampered, SECRET, 3600, 60));
}
#[test]
fn test_verify_garbage_input() {
assert!(!verify_hostname("", SECRET, 3600, 60));
assert!(!verify_hostname("no-separators", SECRET, 3600, 60));
assert!(!verify_hostname("a\0shield\0bad\0short", SECRET, 3600, 60));
}
}