feat: velocity routing, server registry and RampartVelocity tests

This commit is contained in:
loki5512344 2026-08-03 10:54:28 +02:00
parent fa6de281fb
commit 29ce6fb8e9
Signed by: boba
GPG key ID: 253067914055423B
41 changed files with 1239 additions and 532 deletions

View file

@ -1,73 +1,153 @@
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::time::{SystemTime, UNIX_EPOCH};
use subtle::ConstantTimeEq;
type HmacSha256 = Hmac<Sha256>;
pub fn sign(hostname: &str, secret: &[u8]) -> String {
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
mac.update(hostname.as_bytes());
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())
}
pub fn verify(hostname: &str, provided_sig: &str, secret: &[u8]) -> bool {
let expected = sign(hostname, secret);
expected.as_bytes().ct_eq(provided_sig.as_bytes()).into()
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()
}
pub fn sign_hostname(raw: &str, secret: &[u8]) -> String {
/// Подписывает 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 sig = sign(domain, secret);
format!("{raw}\0shield\0{sig}")
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}")
}
pub fn parse_hostname(raw: &str) -> (String, Option<String>) {
let parts: Vec<&str> = raw.split('\0').collect();
let domain = parts[0].to_string();
let hmac = parts
.iter()
.position(|&p| p == "shield")
.and_then(|i| parts.get(i + 1))
.map(|s| s.to_string());
(domain, hmac)
/// Проверяет подпись 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() {
let secret = b"test_secret_32_bytes_long_here!!";
let hostname = "play.example.com";
let sig = sign(hostname, secret);
assert!(verify(hostname, &sig, secret));
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 secret = b"test_secret_32_bytes_long_here!!";
let signed = sign_hostname("play.example.com", SECRET, 3600);
let wrong = b"wrong_secret_32_bytes_long_here!!!";
let hostname = "play.example.com";
let sig = sign(hostname, wrong);
assert!(!verify(hostname, &sig, secret));
assert!(!verify_hostname(&signed, wrong, 3600, 60));
}
#[test]
fn test_sign_hostname_suffix() {
let secret = b"test_secret";
let result = sign_hostname("play.example.com", secret);
assert!(result.starts_with("play.example.com\0shield\0"));
let sig = result.split("\0shield\0").nth(1).unwrap();
assert_eq!(sig.len(), 64);
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_constant_time() {
let secret = b"test_secret_32_bytes_long_here!!";
let hostname = "play.example.com";
let sig = sign(hostname, secret);
assert!(!verify("play.example.co", &sig, secret));
assert!(verify(hostname, &sig, secret));
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));
}
}