feat: velocity routing, server registry and RampartVelocity tests
This commit is contained in:
parent
fa6de281fb
commit
29ce6fb8e9
41 changed files with 1239 additions and 532 deletions
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue