mirror of https://github.com/telemt/telemt.git
1262 lines
48 KiB
Rust
1262 lines
48 KiB
Rust
use super::*;
|
|
use crate::crypto::sha256_hmac;
|
|
|
|
/// Build a TLS-handshake-like buffer that contains a valid HMAC digest
|
|
/// for the given `secret` and `timestamp`.
|
|
///
|
|
/// Layout (bytes):
|
|
/// [0..TLS_DIGEST_POS] : fixed filler (0x42)
|
|
/// [TLS_DIGEST_POS..+32] : digest = HMAC XOR [0..0 || timestamp_le]
|
|
/// [TLS_DIGEST_POS+32] : session_id_len = 32
|
|
/// [TLS_DIGEST_POS+33..+65] : session_id filler (0x42)
|
|
fn make_valid_tls_handshake(secret: &[u8], timestamp: u32) -> Vec<u8> {
|
|
let session_id_len: usize = 32;
|
|
let len = TLS_DIGEST_POS + TLS_DIGEST_LEN + 1 + session_id_len;
|
|
let mut handshake = vec![0x42u8; len];
|
|
|
|
handshake[TLS_DIGEST_POS + TLS_DIGEST_LEN] = session_id_len as u8;
|
|
// Zero the digest slot before computing HMAC (mirrors what validate does).
|
|
handshake[TLS_DIGEST_POS..TLS_DIGEST_POS + TLS_DIGEST_LEN].fill(0);
|
|
|
|
let computed = sha256_hmac(secret, &handshake);
|
|
|
|
// digest = HMAC such that XOR with stored digest yields [0..0, timestamp_le].
|
|
// bytes 0-27 of digest == computed[0..28] -> xored[..28] == 0
|
|
// bytes 28-31 of digest == computed[28..32] XOR timestamp_le
|
|
let mut digest = computed;
|
|
let ts = timestamp.to_le_bytes();
|
|
for i in 0..4 {
|
|
digest[28 + i] ^= ts[i];
|
|
}
|
|
|
|
handshake[TLS_DIGEST_POS..TLS_DIGEST_POS + TLS_DIGEST_LEN]
|
|
.copy_from_slice(&digest);
|
|
handshake
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Happy-path sanity
|
|
// ------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn valid_handshake_with_correct_secret_accepted() {
|
|
let secret = b"correct_horse_battery_staple_32b";
|
|
// timestamp = 0 triggers is_boot_time path, accepted without wall-clock check.
|
|
let handshake = make_valid_tls_handshake(secret, 0);
|
|
let secrets = vec![("alice".to_string(), secret.to_vec())];
|
|
let result = validate_tls_handshake(&handshake, &secrets, true);
|
|
assert!(result.is_some(), "Valid handshake must be accepted");
|
|
assert_eq!(result.unwrap().user, "alice");
|
|
}
|
|
|
|
#[test]
|
|
fn deterministic_external_vector_validates_without_helper() {
|
|
// Deterministic vector generated by an external Python stdlib HMAC script,
|
|
// not by this test module helper. This catches mirrored helper mistakes.
|
|
let secret = hex::decode("00112233445566778899aabbccddeeff").unwrap();
|
|
let handshake = hex::decode(
|
|
"4242424242424242424242a93225d1d6b46260bc9ce0cc48c7487d2b1ca5afa7ae9fc6609d9e60a3ca842b204242424242424242424242424242424242424242424242424242424242424242",
|
|
)
|
|
.unwrap();
|
|
|
|
let secrets = vec![("vector_user".to_string(), secret)];
|
|
let result = validate_tls_handshake(&handshake, &secrets, true).unwrap();
|
|
|
|
assert_eq!(result.user, "vector_user");
|
|
assert_eq!(result.timestamp, 0x01020304);
|
|
}
|
|
|
|
#[test]
|
|
fn valid_handshake_timestamp_extracted_correctly() {
|
|
let secret = b"ts_extraction_test";
|
|
let ts: u32 = 0xDEAD_BEEF;
|
|
let handshake = make_valid_tls_handshake(secret, ts);
|
|
let secrets = vec![("u".to_string(), secret.to_vec())];
|
|
let result = validate_tls_handshake(&handshake, &secrets, true);
|
|
assert!(result.is_some());
|
|
assert_eq!(result.unwrap().timestamp, ts);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// HMAC bit-flip rejection - adversarial HMAC forgery attempts
|
|
// ------------------------------------------------------------------
|
|
|
|
/// Flip every single bit across the 28-byte HMAC check window one at a
|
|
/// time. Each flip must cause rejection. This is the primary guard
|
|
/// against a censor gradually narrowing down a valid HMAC via partial
|
|
/// matches (which would be exploitable with a variable-time comparison).
|
|
#[test]
|
|
fn hmac_single_bit_flip_anywhere_in_check_window_rejected() {
|
|
let secret = b"flip_test_secret";
|
|
let base = make_valid_tls_handshake(secret, 0);
|
|
let secrets = vec![("u".to_string(), secret.to_vec())];
|
|
|
|
// First ensure the unmodified handshake is accepted.
|
|
assert!(
|
|
validate_tls_handshake(&base, &secrets, true).is_some(),
|
|
"Baseline handshake must be accepted before flip tests"
|
|
);
|
|
|
|
for byte_pos in 0..28usize {
|
|
for bit in 0u8..8 {
|
|
let mut h = base.clone();
|
|
h[TLS_DIGEST_POS + byte_pos] ^= 1 << bit;
|
|
assert!(
|
|
validate_tls_handshake(&h, &secrets, true).is_none(),
|
|
"Flip of bit {bit} in HMAC byte {byte_pos} must be rejected"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// XOR entire check window (bytes 0-27) with 0xFF - must still fail.
|
|
#[test]
|
|
fn hmac_full_window_corruption_rejected() {
|
|
let secret = b"full_window_test";
|
|
let mut h = make_valid_tls_handshake(secret, 0);
|
|
let secrets = vec![("u".to_string(), secret.to_vec())];
|
|
for i in 0..28 {
|
|
h[TLS_DIGEST_POS + i] ^= 0xFF;
|
|
}
|
|
assert!(validate_tls_handshake(&h, &secrets, true).is_none());
|
|
}
|
|
|
|
/// Byte 27 is the last byte in the checked window. A non-constant-time
|
|
/// `all(|b| b == 0)` that short-circuits on byte 0 would never even reach
|
|
/// byte 27, making this an effective "did the fix actually run to the end"
|
|
/// sentinel: if this passes but the earlier byte-0 test fails, the check
|
|
/// window is not being evaluated end-to-end.
|
|
#[test]
|
|
fn hmac_last_byte_of_check_window_enforced() {
|
|
let secret = b"last_byte_sentinel";
|
|
let mut h = make_valid_tls_handshake(secret, 0);
|
|
let secrets = vec![("u".to_string(), secret.to_vec())];
|
|
// Corrupt only byte 27.
|
|
h[TLS_DIGEST_POS + 27] ^= 0x01;
|
|
assert!(
|
|
validate_tls_handshake(&h, &secrets, true).is_none(),
|
|
"Corruption at byte 27 (end of HMAC window) must cause rejection"
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// User enumeration / multi-user ordering
|
|
// ------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn wrong_user_secret_rejected_even_with_valid_structure() {
|
|
let secret_a = b"secret_alpha";
|
|
let secret_b = b"secret_beta";
|
|
let handshake = make_valid_tls_handshake(secret_b, 0);
|
|
// Only user_a is configured.
|
|
let secrets = vec![("user_a".to_string(), secret_a.to_vec())];
|
|
assert!(
|
|
validate_tls_handshake(&handshake, &secrets, true).is_none(),
|
|
"Handshake for user_b must fail when only user_a is configured"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn second_user_in_list_found_when_first_does_not_match() {
|
|
let secret_a = b"secret_alpha";
|
|
let secret_b = b"secret_beta";
|
|
let handshake = make_valid_tls_handshake(secret_b, 0);
|
|
let secrets = vec![
|
|
("user_a".to_string(), secret_a.to_vec()),
|
|
("user_b".to_string(), secret_b.to_vec()),
|
|
];
|
|
let result = validate_tls_handshake(&handshake, &secrets, true);
|
|
assert!(result.is_some(), "user_b must be found even though user_a comes first");
|
|
assert_eq!(result.unwrap().user, "user_b");
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_secret_keeps_first_user_identity() {
|
|
// If multiple entries share the same secret, the selected identity must
|
|
// stay stable and deterministic (first entry wins).
|
|
let shared = b"same_secret_for_two_users";
|
|
let handshake = make_valid_tls_handshake(shared, 0);
|
|
let secrets = vec![
|
|
("first_user".to_string(), shared.to_vec()),
|
|
("second_user".to_string(), shared.to_vec()),
|
|
];
|
|
|
|
let result = validate_tls_handshake(&handshake, &secrets, true);
|
|
assert!(result.is_some());
|
|
assert_eq!(result.unwrap().user, "first_user");
|
|
}
|
|
|
|
#[test]
|
|
fn no_user_matches_returns_none() {
|
|
let secret_a = b"aaa";
|
|
let secret_b = b"bbb";
|
|
let secret_c = b"ccc";
|
|
let handshake = make_valid_tls_handshake(b"unknown_secret", 0);
|
|
let secrets = vec![
|
|
("a".to_string(), secret_a.to_vec()),
|
|
("b".to_string(), secret_b.to_vec()),
|
|
("c".to_string(), secret_c.to_vec()),
|
|
];
|
|
assert!(validate_tls_handshake(&handshake, &secrets, true).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn empty_secrets_list_rejects_everything() {
|
|
let secret = b"test";
|
|
let handshake = make_valid_tls_handshake(secret, 0);
|
|
let secrets: Vec<(String, Vec<u8>)> = Vec::new();
|
|
assert!(validate_tls_handshake(&handshake, &secrets, true).is_none());
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Timestamp / time-skew boundary attacks
|
|
// ------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn timestamp_at_time_skew_boundaries_accepted() {
|
|
let secret = b"skew_boundary_test_secret";
|
|
let now: i64 = 1_700_000_000;
|
|
let secrets = vec![("u".to_string(), secret.to_vec())];
|
|
|
|
// time_diff = now - ts = TIME_SKEW_MIN = -1200
|
|
// -> ts = now - TIME_SKEW_MIN = now + 1200 (20 min in the future).
|
|
let ts_at_future_limit = (now - TIME_SKEW_MIN) as u32;
|
|
let h = make_valid_tls_handshake(secret, ts_at_future_limit);
|
|
assert!(
|
|
validate_tls_handshake_at_time(&h, &secrets, false, now).is_some(),
|
|
"Timestamp at max-allowed future (time_diff = TIME_SKEW_MIN) must be accepted"
|
|
);
|
|
|
|
// time_diff = now - ts = TIME_SKEW_MAX = 600
|
|
// -> ts = now - TIME_SKEW_MAX = now - 600 (10 min in the past).
|
|
let ts_at_past_limit = (now - TIME_SKEW_MAX) as u32;
|
|
let h = make_valid_tls_handshake(secret, ts_at_past_limit);
|
|
assert!(
|
|
validate_tls_handshake_at_time(&h, &secrets, false, now).is_some(),
|
|
"Timestamp at max-allowed past (time_diff = TIME_SKEW_MAX) must be accepted"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn timestamp_one_second_outside_skew_window_rejected() {
|
|
let secret = b"skew_outside_test_secret";
|
|
let now: i64 = 1_700_000_000;
|
|
let secrets = vec![("u".to_string(), secret.to_vec())];
|
|
|
|
// time_diff = TIME_SKEW_MAX + 1 = 601 (one second too far in the past)
|
|
// -> ts = now - (TIME_SKEW_MAX + 1) = now - 601
|
|
let ts_too_past = (now - TIME_SKEW_MAX - 1) as u32;
|
|
let h = make_valid_tls_handshake(secret, ts_too_past);
|
|
assert!(
|
|
validate_tls_handshake_at_time(&h, &secrets, false, now).is_none(),
|
|
"Timestamp one second too far in the past must be rejected"
|
|
);
|
|
|
|
// time_diff = TIME_SKEW_MIN - 1 = -1201 (one second too far in the future)
|
|
// -> ts = now - (TIME_SKEW_MIN - 1) = now + 1201
|
|
let ts_too_future = (now - TIME_SKEW_MIN + 1) as u32;
|
|
let h = make_valid_tls_handshake(secret, ts_too_future);
|
|
assert!(
|
|
validate_tls_handshake_at_time(&h, &secrets, false, now).is_none(),
|
|
"Timestamp one second too far in the future must be rejected"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ignore_time_skew_accepts_far_future_timestamp() {
|
|
let secret = b"ignore_skew_test";
|
|
let now: i64 = 1_700_000_000;
|
|
let secrets = vec![("u".to_string(), secret.to_vec())];
|
|
|
|
// 1 hour in the future - outside TIME_SKEW_MAX but should pass with flag.
|
|
let future_ts = (now + 3600) as u32;
|
|
let h = make_valid_tls_handshake(secret, future_ts);
|
|
assert!(
|
|
validate_tls_handshake_at_time(&h, &secrets, true, now).is_some(),
|
|
"ignore_time_skew=true must override window rejection"
|
|
);
|
|
assert!(
|
|
validate_tls_handshake_at_time(&h, &secrets, false, now).is_none(),
|
|
"ignore_time_skew=false must still reject far-future timestamp"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn boot_time_timestamp_accepted_without_ignore_flag() {
|
|
// Timestamps below the boot-time threshold are treated as client uptime,
|
|
// not real wall-clock time. The proxy allows them regardless of skew.
|
|
let secret = b"boot_time_test";
|
|
// Keep this safely below BOOT_TIME_MAX_SECS to assert bypass behavior.
|
|
let boot_ts: u32 = BOOT_TIME_MAX_SECS / 2;
|
|
let handshake = make_valid_tls_handshake(secret, boot_ts);
|
|
let secrets = vec![("u".to_string(), secret.to_vec())];
|
|
assert!(
|
|
validate_tls_handshake(&handshake, &secrets, false).is_some(),
|
|
"Boot-time timestamp must be accepted even with ignore_time_skew=false"
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Structural / length boundary attacks
|
|
// ------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn too_short_handshake_rejected_without_panic() {
|
|
let secrets = vec![("u".to_string(), b"s".to_vec())];
|
|
// Exactly one byte short of the minimum required length.
|
|
let h = vec![0u8; TLS_DIGEST_POS + TLS_DIGEST_LEN];
|
|
assert!(validate_tls_handshake(&h, &secrets, true).is_none());
|
|
|
|
// Empty buffer.
|
|
assert!(validate_tls_handshake(&[], &secrets, true).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn claimed_session_id_overflows_buffer_rejected() {
|
|
let session_id_len: usize = 32;
|
|
let min_len = TLS_DIGEST_POS + TLS_DIGEST_LEN + 1 + session_id_len;
|
|
let mut h = vec![0u8; min_len];
|
|
// Claim session_id is 33 bytes - one more than the buffer holds.
|
|
h[TLS_DIGEST_POS + TLS_DIGEST_LEN] = (session_id_len + 1) as u8;
|
|
let secrets = vec![("u".to_string(), b"s".to_vec())];
|
|
assert!(validate_tls_handshake(&h, &secrets, true).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn max_session_id_len_255_does_not_panic() {
|
|
// session_id_len = 255 with a buffer that is far too small for it.
|
|
let min_len = TLS_DIGEST_POS + TLS_DIGEST_LEN + 1 + 32;
|
|
let mut h = vec![0u8; min_len];
|
|
h[TLS_DIGEST_POS + TLS_DIGEST_LEN] = 255;
|
|
let secrets = vec![("u".to_string(), b"s".to_vec())];
|
|
assert!(validate_tls_handshake(&h, &secrets, true).is_none());
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Adversarial digest values
|
|
// ------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn all_zeros_digest_rejected() {
|
|
// An all-zeros digest would only pass if HMAC(secret, msg) happens to
|
|
// have its first 28 bytes all zero, which is computationally infeasible.
|
|
let session_id_len: usize = 32;
|
|
let min_len = TLS_DIGEST_POS + TLS_DIGEST_LEN + 1 + session_id_len;
|
|
let mut h = vec![0x42u8; min_len];
|
|
h[TLS_DIGEST_POS + TLS_DIGEST_LEN] = session_id_len as u8;
|
|
h[TLS_DIGEST_POS..TLS_DIGEST_POS + TLS_DIGEST_LEN].fill(0);
|
|
let secrets = vec![("u".to_string(), b"test_secret".to_vec())];
|
|
assert!(validate_tls_handshake(&h, &secrets, true).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn all_ones_digest_rejected() {
|
|
let session_id_len: usize = 32;
|
|
let min_len = TLS_DIGEST_POS + TLS_DIGEST_LEN + 1 + session_id_len;
|
|
let mut h = vec![0x42u8; min_len];
|
|
h[TLS_DIGEST_POS + TLS_DIGEST_LEN] = session_id_len as u8;
|
|
h[TLS_DIGEST_POS..TLS_DIGEST_POS + TLS_DIGEST_LEN].fill(0xFF);
|
|
let secrets = vec![("u".to_string(), b"test_secret".to_vec())];
|
|
assert!(validate_tls_handshake(&h, &secrets, true).is_none());
|
|
}
|
|
|
|
/// Simulate a censor that sends 200 crafted packets with random digests.
|
|
/// Every single one must be rejected; no random digest should accidentally
|
|
/// pass (probability 2^{-224} per attempt; negligible for 200 trials).
|
|
#[test]
|
|
fn censor_probe_random_digests_all_rejected() {
|
|
use crate::crypto::SecureRandom;
|
|
let secret = b"production_like_secret_value_xyz";
|
|
let session_id_len: usize = 32;
|
|
let min_len = TLS_DIGEST_POS + TLS_DIGEST_LEN + 1 + session_id_len;
|
|
let secrets = vec![("u".to_string(), secret.to_vec())];
|
|
let rng = SecureRandom::new();
|
|
|
|
for attempt in 0..200 {
|
|
let mut h = vec![0x42u8; min_len];
|
|
h[TLS_DIGEST_POS + TLS_DIGEST_LEN] = session_id_len as u8;
|
|
let rand_digest = rng.bytes(TLS_DIGEST_LEN);
|
|
h[TLS_DIGEST_POS..TLS_DIGEST_POS + TLS_DIGEST_LEN]
|
|
.copy_from_slice(&rand_digest);
|
|
assert!(
|
|
validate_tls_handshake(&h, &secrets, true).is_none(),
|
|
"Random digest at attempt {attempt} must not match"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The check window is bytes 0-27 of the XOR result. Bytes 28-31 encode
|
|
/// the timestamp and must NOT affect whether the HMAC portion validates -
|
|
/// only the timestamp range check uses them. Build a valid handshake with
|
|
/// timestamp = 0 (boot-time), flip each of bytes 28-31 with ignore_time_skew
|
|
/// enabled, and verify the HMAC portion still passes (the timestamp changes
|
|
/// but the proxy still accepts the connection under ignore_time_skew).
|
|
#[test]
|
|
fn timestamp_bytes_28_31_do_not_affect_hmac_window() {
|
|
let secret = b"window_boundary_test";
|
|
let base = make_valid_tls_handshake(secret, 0);
|
|
let secrets = vec![("u".to_string(), secret.to_vec())];
|
|
|
|
// Baseline must pass.
|
|
assert!(validate_tls_handshake(&base, &secrets, true).is_some());
|
|
|
|
// Flip each of the timestamp bytes; with ignore_time_skew the
|
|
// modified timestamps (small absolute values) still pass boot-time check.
|
|
for i in 28..32usize {
|
|
let mut h = base.clone();
|
|
h[TLS_DIGEST_POS + i] ^= 0xFF;
|
|
// The new timestamp is non-zero but potentially still < boot threshold;
|
|
// use ignore_time_skew=true so wallet test is HMAC-only.
|
|
assert!(
|
|
validate_tls_handshake(&h, &secrets, true).is_some(),
|
|
"Flipping byte {i} (timestamp region) must not invalidate HMAC window"
|
|
);
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// session_id preservation
|
|
// ------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn session_id_is_preserved_verbatim_in_validation_result() {
|
|
// If session_id extraction is ever broken (wrong offset, wrong length,
|
|
// off-by-one), this test will catch it before it silently corrupts the
|
|
// ServerHello that echoes the session_id back to the client.
|
|
let secret = b"session_id_preservation_test";
|
|
let handshake = make_valid_tls_handshake(secret, 0);
|
|
let secrets = vec![("u".to_string(), secret.to_vec())];
|
|
let result = validate_tls_handshake(&handshake, &secrets, true).unwrap();
|
|
|
|
let sid_len_pos = TLS_DIGEST_POS + TLS_DIGEST_LEN;
|
|
let sid_len = handshake[sid_len_pos] as usize;
|
|
let expected = &handshake[sid_len_pos + 1..sid_len_pos + 1 + sid_len];
|
|
|
|
assert_eq!(
|
|
result.session_id, expected,
|
|
"session_id in TlsValidation must be the verbatim bytes from the handshake"
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Clock decoupling - ignore_time_skew must not consult the system clock
|
|
// ------------------------------------------------------------------
|
|
|
|
/// When `ignore_time_skew = true`, a valid HMAC must be accepted even if
|
|
/// `now = 0` (the sentinel used when the clock is not needed). A broken
|
|
/// system clock cannot silently deny service when the admin has explicitly
|
|
/// disabled timestamp checking.
|
|
#[test]
|
|
fn ignore_time_skew_accepts_valid_hmac_with_now_zero() {
|
|
let secret = b"clock_decoupling_test";
|
|
// Use a realistic Unix timestamp that would be far outside the window
|
|
// if compared against now=0 (time_diff would be ~-1_700_000_000).
|
|
let realistic_ts: u32 = 1_700_000_000;
|
|
let h = make_valid_tls_handshake(secret, realistic_ts);
|
|
let secrets = vec![("u".to_string(), secret.to_vec())];
|
|
|
|
assert!(
|
|
validate_tls_handshake_at_time(&h, &secrets, true, 0).is_some(),
|
|
"ignore_time_skew=true must accept a valid HMAC regardless of `now`"
|
|
);
|
|
|
|
// Confirm that the same handshake IS rejected when the window is enforced
|
|
// and now=0 (time_diff very negative -> outside window). This distinguishes
|
|
// "clock decoupling" from "always accept".
|
|
assert!(
|
|
validate_tls_handshake_at_time(&h, &secrets, false, 0).is_none(),
|
|
"ignore_time_skew=false with now=0 must still reject out-of-window timestamps"
|
|
);
|
|
}
|
|
|
|
/// An HMAC-invalid handshake must be rejected even when ignore_time_skew=true
|
|
/// and now=0. Verifies that the clock-decoupling fix did not weaken HMAC
|
|
/// enforcement in the ignore_time_skew path.
|
|
#[test]
|
|
fn ignore_time_skew_with_now_zero_still_rejects_bad_hmac() {
|
|
let secret = b"clock_no_backdoor_test";
|
|
let mut h = make_valid_tls_handshake(secret, 1_700_000_000);
|
|
let secrets = vec![("u".to_string(), secret.to_vec())];
|
|
// Corrupt the HMAC check window.
|
|
h[TLS_DIGEST_POS] ^= 0xFF;
|
|
assert!(
|
|
validate_tls_handshake_at_time(&h, &secrets, true, 0).is_none(),
|
|
"Broken HMAC must be rejected even with ignore_time_skew=true and now=0"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn system_time_before_unix_epoch_is_rejected_without_panic() {
|
|
let before_epoch = UNIX_EPOCH
|
|
.checked_sub(std::time::Duration::from_secs(1))
|
|
.expect("UNIX_EPOCH minus one second must be representable");
|
|
assert!(system_time_to_unix_secs(before_epoch).is_none());
|
|
}
|
|
|
|
/// `i64::MAX` is 9_223_372_036_854_775_807 seconds (~292 billion years CE).
|
|
/// Any `SystemTime` whose duration since epoch exceeds `i64::MAX` seconds
|
|
/// must return `None` rather than silently wrapping to a large negative
|
|
/// timestamp that would corrupt every subsequent time-skew comparison.
|
|
#[test]
|
|
fn system_time_far_future_overflowing_i64_returns_none() {
|
|
// i64::MAX + 1 seconds past epoch overflows i64 when cast naively with `as`.
|
|
let overflow_secs = u64::try_from(i64::MAX).unwrap() + 1;
|
|
if let Some(far_future) =
|
|
UNIX_EPOCH.checked_add(std::time::Duration::from_secs(overflow_secs))
|
|
{
|
|
assert!(
|
|
system_time_to_unix_secs(far_future).is_none(),
|
|
"Seconds > i64::MAX must return None, not a wrapped negative timestamp"
|
|
);
|
|
}
|
|
// If the platform cannot represent this SystemTime, the test is vacuously
|
|
// satisfied: `checked_add` returning None means the platform already rejects it.
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Message canonicalization — HMAC covers every byte of the handshake
|
|
// ------------------------------------------------------------------
|
|
|
|
/// Every byte before TLS_DIGEST_POS is part of the HMAC input (because msg
|
|
/// = full handshake with only the digest slot zeroed). An attacker cannot
|
|
/// replay a valid handshake with a modified ClientHello header while keeping
|
|
/// the stored digest; each such modification produces a different HMAC.
|
|
#[test]
|
|
fn pre_digest_bytes_are_hmac_covered() {
|
|
// TLS_DIGEST_POS = 11, so 11 bytes precede the digest.
|
|
let secret = b"pre_digest_coverage_test";
|
|
let base = make_valid_tls_handshake(secret, 0);
|
|
let secrets = vec![("u".to_string(), secret.to_vec())];
|
|
|
|
for byte_pos in 0..TLS_DIGEST_POS {
|
|
let mut h = base.clone();
|
|
h[byte_pos] ^= 0x01;
|
|
assert!(
|
|
validate_tls_handshake(&h, &secrets, true).is_none(),
|
|
"Flip in pre-digest byte {byte_pos} must cause HMAC check failure"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// session_id bytes follow the digest in the buffer and are also part of the
|
|
/// HMAC input. Flipping any of them invalidates the stored digest, preventing
|
|
/// a censor from capturing a valid session_id and replaying it with a different
|
|
/// one while keeping the rest of the packet intact.
|
|
#[test]
|
|
fn session_id_bytes_are_hmac_covered() {
|
|
let secret = b"session_id_coverage_test";
|
|
let base = make_valid_tls_handshake(secret, 0); // session_id_len = 32
|
|
let secrets = vec![("u".to_string(), secret.to_vec())];
|
|
|
|
let sid_start = TLS_DIGEST_POS + TLS_DIGEST_LEN + 1;
|
|
for byte_pos in sid_start..base.len() {
|
|
let mut h = base.clone();
|
|
h[byte_pos] ^= 0x01;
|
|
assert!(
|
|
validate_tls_handshake(&h, &secrets, true).is_none(),
|
|
"Flip in session_id byte at offset {byte_pos} must cause HMAC check failure"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Appending even one byte to a valid handshake changes the HMAC input (msg
|
|
/// includes all bytes) and therefore invalidates the stored digest. This
|
|
/// prevents a length-extension-style modification of the payload.
|
|
#[test]
|
|
fn appended_trailing_byte_causes_rejection() {
|
|
let secret = b"trailing_byte_test";
|
|
let mut h = make_valid_tls_handshake(secret, 0);
|
|
let secrets = vec![("u".to_string(), secret.to_vec())];
|
|
|
|
assert!(validate_tls_handshake(&h, &secrets, true).is_some(), "baseline");
|
|
|
|
h.push(0x00);
|
|
assert!(
|
|
validate_tls_handshake(&h, &secrets, true).is_none(),
|
|
"Appending a trailing byte to a valid handshake must invalidate the HMAC"
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Zero-length session_id (structural edge case)
|
|
// ------------------------------------------------------------------
|
|
|
|
/// session_id_len = 0 is legal in the TLS spec. The validator must accept a
|
|
/// valid handshake with an empty session_id and return an empty session_id
|
|
/// slice without panicking or accessing out-of-bounds memory.
|
|
#[test]
|
|
fn zero_length_session_id_accepted() {
|
|
let secret = b"zero_sid_test";
|
|
// Buffer: pre-digest | digest | session_id_len=0 (no session_id bytes follow)
|
|
let len = TLS_DIGEST_POS + TLS_DIGEST_LEN + 1;
|
|
let mut handshake = vec![0x42u8; len];
|
|
handshake[TLS_DIGEST_POS + TLS_DIGEST_LEN] = 0; // session_id_len = 0
|
|
handshake[TLS_DIGEST_POS..TLS_DIGEST_POS + TLS_DIGEST_LEN].fill(0);
|
|
|
|
let computed = sha256_hmac(secret, &handshake);
|
|
// timestamp = 0 → ts XOR bytes are all zero → digest = computed unchanged.
|
|
handshake[TLS_DIGEST_POS..TLS_DIGEST_POS + TLS_DIGEST_LEN]
|
|
.copy_from_slice(&computed);
|
|
|
|
let secrets = vec![("u".to_string(), secret.to_vec())];
|
|
let result = validate_tls_handshake(&handshake, &secrets, true);
|
|
assert!(result.is_some(), "zero-length session_id must be accepted");
|
|
assert!(
|
|
result.unwrap().session_id.is_empty(),
|
|
"session_id field must be empty when session_id_len = 0"
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Boot-time threshold — exact boundary precision
|
|
// ------------------------------------------------------------------
|
|
|
|
/// timestamp = BOOT_TIME_MAX_SECS - 1 is the last value inside the boot-time window.
|
|
/// is_boot_time = true → skew check is skipped entirely → accepted even
|
|
/// when `now` is far from the timestamp.
|
|
#[test]
|
|
fn timestamp_one_below_boot_threshold_bypasses_skew_check() {
|
|
let secret = b"boot_last_value_test";
|
|
let ts: u32 = BOOT_TIME_MAX_SECS - 1;
|
|
let h = make_valid_tls_handshake(secret, ts);
|
|
let secrets = vec![("u".to_string(), secret.to_vec())];
|
|
|
|
// now = 0 → time_diff would be -86_399_999, way outside [-1200, 600].
|
|
// Boot-time bypass must prevent the skew check from running.
|
|
assert!(
|
|
validate_tls_handshake_at_time(&h, &secrets, false, 0).is_some(),
|
|
"ts=BOOT_TIME_MAX_SECS-1 must bypass skew check regardless of now"
|
|
);
|
|
}
|
|
|
|
/// timestamp = BOOT_TIME_MAX_SECS is the first value outside the boot-time window.
|
|
/// is_boot_time = false → skew check IS applied. Two sub-cases confirm this:
|
|
/// once with now chosen so the skew passes (accepted) and once where it fails.
|
|
#[test]
|
|
fn timestamp_at_boot_threshold_triggers_skew_check() {
|
|
let secret = b"boot_exact_value_test";
|
|
let ts: u32 = BOOT_TIME_MAX_SECS;
|
|
let h = make_valid_tls_handshake(secret, ts);
|
|
let secrets = vec![("u".to_string(), secret.to_vec())];
|
|
|
|
// now = ts + 50 → time_diff = 50, within [-1200, 600] → accepted.
|
|
let now_valid: i64 = ts as i64 + 50;
|
|
assert!(
|
|
validate_tls_handshake_at_time(&h, &secrets, false, now_valid).is_some(),
|
|
"ts=BOOT_TIME_MAX_SECS within skew window must be accepted via skew check"
|
|
);
|
|
|
|
// now = 0 → time_diff = -86_400_000, outside window → rejected.
|
|
// If the boot-time bypass were wrongly applied here this would pass.
|
|
assert!(
|
|
validate_tls_handshake_at_time(&h, &secrets, false, 0).is_none(),
|
|
"ts=BOOT_TIME_MAX_SECS far from now must be rejected — no boot-time bypass"
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Extreme timestamp values
|
|
// ------------------------------------------------------------------
|
|
|
|
/// u32::MAX is a valid timestamp value. When ignore_time_skew=true the HMAC
|
|
/// is the only gate, and a correctly constructed handshake must be accepted.
|
|
#[test]
|
|
fn u32_max_timestamp_accepted_with_ignore_time_skew() {
|
|
let secret = b"u32_max_ts_accept_test";
|
|
let h = make_valid_tls_handshake(secret, u32::MAX);
|
|
let secrets = vec![("u".to_string(), secret.to_vec())];
|
|
|
|
let result = validate_tls_handshake(&h, &secrets, true);
|
|
assert!(result.is_some(), "u32::MAX timestamp must be accepted with ignore_time_skew=true");
|
|
assert_eq!(
|
|
result.unwrap().timestamp,
|
|
u32::MAX,
|
|
"timestamp field must equal u32::MAX verbatim"
|
|
);
|
|
}
|
|
|
|
/// u32::MAX > BOOT_TIME_MAX_SECS so the skew check runs. With any realistic `now`
|
|
/// (~1.7 billion), time_diff = now - u32::MAX is deeply negative — far outside
|
|
/// [-1200, 600] — so the handshake must be rejected without overflow.
|
|
#[test]
|
|
fn u32_max_timestamp_rejected_by_skew_enforcement() {
|
|
let secret = b"u32_max_ts_reject_test";
|
|
let h = make_valid_tls_handshake(secret, u32::MAX);
|
|
let secrets = vec![("u".to_string(), secret.to_vec())];
|
|
|
|
let now: i64 = 1_700_000_000;
|
|
assert!(
|
|
validate_tls_handshake_at_time(&h, &secrets, false, now).is_none(),
|
|
"u32::MAX timestamp must be rejected by skew check with realistic now"
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Validation result field correctness
|
|
// ------------------------------------------------------------------
|
|
|
|
/// result.digest must be the verbatim bytes stored in the handshake buffer,
|
|
/// not the freshly recomputed HMAC. Callers use this field directly when
|
|
/// constructing the ServerHello response digest.
|
|
#[test]
|
|
fn result_digest_field_is_verbatim_stored_digest() {
|
|
let secret = b"digest_field_verbatim_test";
|
|
let ts: u32 = 0xCAFE_BABE;
|
|
let h = make_valid_tls_handshake(secret, ts);
|
|
let secrets = vec![("u".to_string(), secret.to_vec())];
|
|
|
|
let result = validate_tls_handshake(&h, &secrets, true).unwrap();
|
|
|
|
let stored: [u8; TLS_DIGEST_LEN] = h[TLS_DIGEST_POS..TLS_DIGEST_POS + TLS_DIGEST_LEN]
|
|
.try_into()
|
|
.unwrap();
|
|
assert_eq!(
|
|
result.digest, stored,
|
|
"result.digest must equal the stored bytes, not the computed HMAC"
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Secret length edge cases
|
|
// ------------------------------------------------------------------
|
|
|
|
/// HMAC-SHA256 pads or hashes keys of any length; a single-byte key must work.
|
|
#[test]
|
|
fn single_byte_secret_works() {
|
|
let secret = b"x";
|
|
let h = make_valid_tls_handshake(secret, 0);
|
|
let secrets = vec![("u".to_string(), secret.to_vec())];
|
|
assert!(
|
|
validate_tls_handshake(&h, &secrets, true).is_some(),
|
|
"single-byte secret must produce a valid and verifiable HMAC"
|
|
);
|
|
}
|
|
|
|
/// Keys longer than the HMAC block size (64 bytes for SHA-256) are hashed
|
|
/// before use. A 256-byte key must work without truncation or panic.
|
|
#[test]
|
|
fn very_long_secret_256_bytes_works() {
|
|
let secret = vec![0xABu8; 256];
|
|
let h = make_valid_tls_handshake(&secret, 0);
|
|
let secrets = vec![("u".to_string(), secret.clone())];
|
|
assert!(
|
|
validate_tls_handshake(&h, &secrets, true).is_some(),
|
|
"256-byte secret must be accepted without truncation"
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Determinism — same input must always produce same result
|
|
// ------------------------------------------------------------------
|
|
|
|
/// Calling validate twice on the same input must return identical results.
|
|
/// Non-determinism (e.g. from an accidentally global mutable state or a
|
|
/// shared nonce) would be a critical security defect in a proxy that rejects
|
|
/// censors by relying on stable authentication outcomes.
|
|
#[test]
|
|
fn validation_is_deterministic() {
|
|
let secret = b"determinism_test_key";
|
|
let h = make_valid_tls_handshake(secret, 42);
|
|
let secrets = vec![("u".to_string(), secret.to_vec())];
|
|
|
|
let r1 = validate_tls_handshake(&h, &secrets, true).unwrap();
|
|
let r2 = validate_tls_handshake(&h, &secrets, true).unwrap();
|
|
|
|
assert_eq!(r1.user, r2.user);
|
|
assert_eq!(r1.session_id, r2.session_id);
|
|
assert_eq!(r1.digest, r2.digest);
|
|
assert_eq!(r1.timestamp, r2.timestamp);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Multi-user: scan-all correctness guarantees
|
|
// ------------------------------------------------------------------
|
|
|
|
/// The matching logic must scan through the entire secrets list. A user
|
|
/// at position 99 of 100 must be found; an implementation that stops early
|
|
/// on the first non-match would fail this test.
|
|
#[test]
|
|
fn last_user_in_large_list_is_found() {
|
|
let target_secret = b"needle_in_haystack";
|
|
let h = make_valid_tls_handshake(target_secret, 0);
|
|
|
|
let mut secrets: Vec<(String, Vec<u8>)> = (0..99)
|
|
.map(|i| (format!("decoy_{i}"), format!("wrong_{i}").into_bytes()))
|
|
.collect();
|
|
secrets.push(("needle".to_string(), target_secret.to_vec()));
|
|
|
|
let result = validate_tls_handshake(&h, &secrets, true);
|
|
assert!(result.is_some(), "100th user must be found");
|
|
assert_eq!(result.unwrap().user, "needle");
|
|
}
|
|
|
|
/// When multiple users share the same secret the first occurrence must always
|
|
/// win. The scan-all loop must not replace first_match with a later one.
|
|
#[test]
|
|
fn first_matching_user_wins_over_later_duplicate_secret() {
|
|
let shared = b"duplicated_secret_key";
|
|
let h = make_valid_tls_handshake(shared, 0);
|
|
|
|
let secrets = vec![
|
|
("decoy_1".to_string(), b"wrong_1".to_vec()),
|
|
("winner".to_string(), shared.to_vec()), // first match
|
|
("decoy_2".to_string(), b"wrong_2".to_vec()),
|
|
("loser".to_string(), shared.to_vec()), // second match — must not win
|
|
("decoy_3".to_string(), b"wrong_3".to_vec()),
|
|
];
|
|
|
|
let result = validate_tls_handshake(&h, &secrets, true);
|
|
assert!(result.is_some());
|
|
assert_eq!(
|
|
result.unwrap().user, "winner",
|
|
"first matching user must be returned even when a later entry also matches"
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Legacy tls.rs tests moved here
|
|
// ------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_is_tls_handshake() {
|
|
assert!(is_tls_handshake(&[0x16, 0x03, 0x01]));
|
|
assert!(is_tls_handshake(&[0x16, 0x03, 0x01, 0x02, 0x00]));
|
|
assert!(!is_tls_handshake(&[0x17, 0x03, 0x01]));
|
|
assert!(!is_tls_handshake(&[0x16, 0x03, 0x02]));
|
|
assert!(!is_tls_handshake(&[0x16, 0x03]));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_tls_record_header() {
|
|
let header = [0x16, 0x03, 0x01, 0x02, 0x00];
|
|
let result = parse_tls_record_header(&header).unwrap();
|
|
assert_eq!(result.0, TLS_RECORD_HANDSHAKE);
|
|
assert_eq!(result.1, 512);
|
|
|
|
let header = [0x17, 0x03, 0x03, 0x40, 0x00];
|
|
let result = parse_tls_record_header(&header).unwrap();
|
|
assert_eq!(result.0, TLS_RECORD_APPLICATION);
|
|
assert_eq!(result.1, 16384);
|
|
}
|
|
|
|
#[test]
|
|
fn test_gen_fake_x25519_key() {
|
|
let rng = crate::crypto::SecureRandom::new();
|
|
let key1 = gen_fake_x25519_key(&rng);
|
|
let key2 = gen_fake_x25519_key(&rng);
|
|
|
|
assert_eq!(key1.len(), 32);
|
|
assert_eq!(key2.len(), 32);
|
|
assert_ne!(key1, key2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_fake_x25519_key_is_quadratic_residue() {
|
|
use num_bigint::BigUint;
|
|
use num_traits::One;
|
|
|
|
let rng = crate::crypto::SecureRandom::new();
|
|
let key = gen_fake_x25519_key(&rng);
|
|
let p = curve25519_prime();
|
|
let k_num = BigUint::from_bytes_le(&key);
|
|
let exponent = (&p - BigUint::one()) >> 1;
|
|
let legendre = k_num.modpow(&exponent, &p);
|
|
assert_eq!(legendre, BigUint::one());
|
|
}
|
|
|
|
#[test]
|
|
fn test_tls_extension_builder() {
|
|
let key = [0x42u8; 32];
|
|
|
|
let mut builder = TlsExtensionBuilder::new();
|
|
builder.add_key_share(&key);
|
|
builder.add_supported_versions(0x0304);
|
|
|
|
let result = builder.build();
|
|
let len = u16::from_be_bytes([result[0], result[1]]) as usize;
|
|
|
|
assert_eq!(len, result.len() - 2);
|
|
assert!(result.len() > 40);
|
|
}
|
|
|
|
#[test]
|
|
fn test_server_hello_builder() {
|
|
let session_id = vec![0x01, 0x02, 0x03, 0x04];
|
|
let key = [0x55u8; 32];
|
|
|
|
let builder = ServerHelloBuilder::new(session_id.clone())
|
|
.with_x25519_key(&key)
|
|
.with_tls13_version();
|
|
|
|
let record = builder.build_record();
|
|
validate_server_hello_structure(&record).expect("Invalid ServerHello structure");
|
|
|
|
assert_eq!(record[0], TLS_RECORD_HANDSHAKE);
|
|
assert_eq!(&record[1..3], &TLS_VERSION);
|
|
assert_eq!(record[5], 0x02);
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_server_hello_structure() {
|
|
let secret = b"test secret";
|
|
let client_digest = [0x42u8; 32];
|
|
let session_id = vec![0xAA; 32];
|
|
|
|
let rng = crate::crypto::SecureRandom::new();
|
|
let response = build_server_hello(secret, &client_digest, &session_id, 2048, &rng, None, 0);
|
|
|
|
assert!(response.len() > 100);
|
|
assert_eq!(response[0], TLS_RECORD_HANDSHAKE);
|
|
validate_server_hello_structure(&response).expect("Invalid ServerHello");
|
|
|
|
let server_hello_len = 5 + u16::from_be_bytes([response[3], response[4]]) as usize;
|
|
let ccs_start = server_hello_len;
|
|
assert!(response.len() > ccs_start + 6);
|
|
assert_eq!(response[ccs_start], TLS_RECORD_CHANGE_CIPHER);
|
|
|
|
let ccs_len = 5 + u16::from_be_bytes([response[ccs_start + 3], response[ccs_start + 4]]) as usize;
|
|
let app_start = ccs_start + ccs_len;
|
|
assert!(response.len() > app_start + 5);
|
|
assert_eq!(response[app_start], TLS_RECORD_APPLICATION);
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_server_hello_digest() {
|
|
let secret = b"test secret key here";
|
|
let client_digest = [0x42u8; 32];
|
|
let session_id = vec![0xAA; 32];
|
|
|
|
let rng = crate::crypto::SecureRandom::new();
|
|
let response1 = build_server_hello(secret, &client_digest, &session_id, 1024, &rng, None, 0);
|
|
let response2 = build_server_hello(secret, &client_digest, &session_id, 1024, &rng, None, 0);
|
|
|
|
let digest1 = &response1[TLS_DIGEST_POS..TLS_DIGEST_POS + TLS_DIGEST_LEN];
|
|
assert!(!digest1.iter().all(|&b| b == 0));
|
|
|
|
let digest2 = &response2[TLS_DIGEST_POS..TLS_DIGEST_POS + TLS_DIGEST_LEN];
|
|
assert_ne!(digest1, digest2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_server_hello_extensions_length() {
|
|
let session_id = vec![0x01; 32];
|
|
let key = [0x55u8; 32];
|
|
|
|
let builder = ServerHelloBuilder::new(session_id)
|
|
.with_x25519_key(&key)
|
|
.with_tls13_version();
|
|
|
|
let record = builder.build_record();
|
|
let msg_start = 5;
|
|
let msg_len = u32::from_be_bytes([0, record[6], record[7], record[8]]) as usize;
|
|
let session_id_pos = msg_start + 4 + 2 + 32;
|
|
let session_id_len = record[session_id_pos] as usize;
|
|
let ext_len_pos = session_id_pos + 1 + session_id_len + 2 + 1;
|
|
let ext_len = u16::from_be_bytes([record[ext_len_pos], record[ext_len_pos + 1]]) as usize;
|
|
let extensions_data = &record[ext_len_pos + 2..msg_start + 4 + msg_len];
|
|
|
|
assert_eq!(
|
|
ext_len,
|
|
extensions_data.len(),
|
|
"Extension length mismatch: declared {}, actual {}",
|
|
ext_len,
|
|
extensions_data.len()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_tls_handshake_format() {
|
|
let mut handshake = vec![0u8; 100];
|
|
handshake[TLS_DIGEST_POS..TLS_DIGEST_POS + TLS_DIGEST_LEN].copy_from_slice(&[0x42; 32]);
|
|
handshake[TLS_DIGEST_POS + TLS_DIGEST_LEN] = 32;
|
|
|
|
let secrets = vec![("test".to_string(), b"secret".to_vec())];
|
|
let result = validate_tls_handshake(&handshake, &secrets, true);
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
fn build_client_hello_with_exts(exts: Vec<(u16, Vec<u8>)>, host: &str) -> Vec<u8> {
|
|
let mut body = Vec::new();
|
|
body.extend_from_slice(&TLS_VERSION);
|
|
body.extend_from_slice(&[0u8; 32]);
|
|
body.push(0);
|
|
body.extend_from_slice(&2u16.to_be_bytes());
|
|
body.extend_from_slice(&[0x13, 0x01]);
|
|
body.push(1);
|
|
body.push(0);
|
|
|
|
let host_bytes = host.as_bytes();
|
|
let mut sni_ext = Vec::new();
|
|
sni_ext.extend_from_slice(&(host_bytes.len() as u16 + 3).to_be_bytes());
|
|
sni_ext.push(0);
|
|
sni_ext.extend_from_slice(&(host_bytes.len() as u16).to_be_bytes());
|
|
sni_ext.extend_from_slice(host_bytes);
|
|
|
|
let mut ext_blob = Vec::new();
|
|
for (typ, data) in exts {
|
|
ext_blob.extend_from_slice(&typ.to_be_bytes());
|
|
ext_blob.extend_from_slice(&(data.len() as u16).to_be_bytes());
|
|
ext_blob.extend_from_slice(&data);
|
|
}
|
|
ext_blob.extend_from_slice(&0x0000u16.to_be_bytes());
|
|
ext_blob.extend_from_slice(&(sni_ext.len() as u16).to_be_bytes());
|
|
ext_blob.extend_from_slice(&sni_ext);
|
|
|
|
body.extend_from_slice(&(ext_blob.len() as u16).to_be_bytes());
|
|
body.extend_from_slice(&ext_blob);
|
|
|
|
let mut handshake = Vec::new();
|
|
handshake.push(0x01);
|
|
let len_bytes = (body.len() as u32).to_be_bytes();
|
|
handshake.extend_from_slice(&len_bytes[1..4]);
|
|
handshake.extend_from_slice(&body);
|
|
|
|
let mut record = Vec::new();
|
|
record.push(TLS_RECORD_HANDSHAKE);
|
|
record.extend_from_slice(&[0x03, 0x01]);
|
|
record.extend_from_slice(&(handshake.len() as u16).to_be_bytes());
|
|
record.extend_from_slice(&handshake);
|
|
record
|
|
}
|
|
|
|
fn build_client_hello_with_raw_extensions(ext_blob: &[u8]) -> Vec<u8> {
|
|
let mut body = Vec::new();
|
|
body.extend_from_slice(&TLS_VERSION);
|
|
body.extend_from_slice(&[0u8; 32]);
|
|
body.push(0);
|
|
body.extend_from_slice(&2u16.to_be_bytes());
|
|
body.extend_from_slice(&[0x13, 0x01]);
|
|
body.push(1);
|
|
body.push(0);
|
|
body.extend_from_slice(&(ext_blob.len() as u16).to_be_bytes());
|
|
body.extend_from_slice(ext_blob);
|
|
|
|
let mut handshake = Vec::new();
|
|
handshake.push(0x01);
|
|
let len_bytes = (body.len() as u32).to_be_bytes();
|
|
handshake.extend_from_slice(&len_bytes[1..4]);
|
|
handshake.extend_from_slice(&body);
|
|
|
|
let mut record = Vec::new();
|
|
record.push(TLS_RECORD_HANDSHAKE);
|
|
record.extend_from_slice(&[0x03, 0x01]);
|
|
record.extend_from_slice(&(handshake.len() as u16).to_be_bytes());
|
|
record.extend_from_slice(&handshake);
|
|
record
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_sni_with_grease_extension() {
|
|
let ch = build_client_hello_with_exts(vec![(0x0a0a, Vec::new())], "example.com");
|
|
let sni = extract_sni_from_client_hello(&ch);
|
|
assert_eq!(sni.as_deref(), Some("example.com"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_sni_tolerates_empty_unknown_extension() {
|
|
let ch = build_client_hello_with_exts(vec![(0x1234, Vec::new())], "test.local");
|
|
let sni = extract_sni_from_client_hello(&ch);
|
|
assert_eq!(sni.as_deref(), Some("test.local"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_alpn_single() {
|
|
let mut alpn_data = Vec::new();
|
|
alpn_data.extend_from_slice(&3u16.to_be_bytes());
|
|
alpn_data.push(2);
|
|
alpn_data.extend_from_slice(b"h2");
|
|
let ch = build_client_hello_with_exts(vec![(0x0010, alpn_data)], "alpn.test");
|
|
let alpn = extract_alpn_from_client_hello(&ch);
|
|
let alpn_str: Vec<String> = alpn
|
|
.iter()
|
|
.map(|p| std::str::from_utf8(p).unwrap().to_string())
|
|
.collect();
|
|
assert_eq!(alpn_str, vec!["h2"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_alpn_multiple() {
|
|
let mut alpn_data = Vec::new();
|
|
alpn_data.extend_from_slice(&11u16.to_be_bytes());
|
|
alpn_data.push(2);
|
|
alpn_data.extend_from_slice(b"h2");
|
|
alpn_data.push(4);
|
|
alpn_data.extend_from_slice(b"spdy");
|
|
alpn_data.push(2);
|
|
alpn_data.extend_from_slice(b"h3");
|
|
let ch = build_client_hello_with_exts(vec![(0x0010, alpn_data)], "alpn.test");
|
|
let alpn = extract_alpn_from_client_hello(&ch);
|
|
let alpn_str: Vec<String> = alpn
|
|
.iter()
|
|
.map(|p| std::str::from_utf8(p).unwrap().to_string())
|
|
.collect();
|
|
assert_eq!(alpn_str, vec!["h2", "spdy", "h3"]);
|
|
}
|
|
|
|
#[test]
|
|
fn extract_sni_rejects_zero_length_host_name() {
|
|
let mut sni_ext = Vec::new();
|
|
sni_ext.extend_from_slice(&3u16.to_be_bytes());
|
|
sni_ext.push(0);
|
|
sni_ext.extend_from_slice(&0u16.to_be_bytes());
|
|
|
|
let mut ext_blob = Vec::new();
|
|
ext_blob.extend_from_slice(&0x0000u16.to_be_bytes());
|
|
ext_blob.extend_from_slice(&(sni_ext.len() as u16).to_be_bytes());
|
|
ext_blob.extend_from_slice(&sni_ext);
|
|
|
|
let ch = build_client_hello_with_raw_extensions(&ext_blob);
|
|
assert!(extract_sni_from_client_hello(&ch).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn extract_sni_rejects_raw_ipv4_literals() {
|
|
let ch = build_client_hello_with_exts(Vec::new(), "203.0.113.10");
|
|
assert!(extract_sni_from_client_hello(&ch).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn extract_sni_rejects_invalid_label_characters() {
|
|
let ch = build_client_hello_with_exts(Vec::new(), "exa_mple.com");
|
|
assert!(extract_sni_from_client_hello(&ch).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn extract_sni_rejects_oversized_label() {
|
|
let oversized = format!("{}.example.com", "a".repeat(64));
|
|
let ch = build_client_hello_with_exts(Vec::new(), &oversized);
|
|
assert!(extract_sni_from_client_hello(&ch).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn extract_sni_rejects_when_extension_block_is_truncated() {
|
|
let mut ext_blob = Vec::new();
|
|
ext_blob.extend_from_slice(&0x0000u16.to_be_bytes());
|
|
ext_blob.extend_from_slice(&5u16.to_be_bytes());
|
|
ext_blob.extend_from_slice(&[0, 3, 0]);
|
|
|
|
let mut ch = build_client_hello_with_raw_extensions(&ext_blob);
|
|
ch.pop();
|
|
assert!(extract_sni_from_client_hello(&ch).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn extract_alpn_rejects_when_extension_block_is_truncated() {
|
|
let mut ext_blob = Vec::new();
|
|
ext_blob.extend_from_slice(&0x0010u16.to_be_bytes());
|
|
ext_blob.extend_from_slice(&5u16.to_be_bytes());
|
|
ext_blob.extend_from_slice(&[0, 3, 2, b'h']);
|
|
|
|
let ch = build_client_hello_with_raw_extensions(&ext_blob);
|
|
assert!(extract_alpn_from_client_hello(&ch).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn extract_alpn_rejects_nested_length_overflow() {
|
|
let mut alpn_data = Vec::new();
|
|
alpn_data.extend_from_slice(&10u16.to_be_bytes());
|
|
alpn_data.push(8);
|
|
alpn_data.extend_from_slice(b"h2");
|
|
|
|
let mut ext_blob = Vec::new();
|
|
ext_blob.extend_from_slice(&0x0010u16.to_be_bytes());
|
|
ext_blob.extend_from_slice(&(alpn_data.len() as u16).to_be_bytes());
|
|
ext_blob.extend_from_slice(&alpn_data);
|
|
|
|
let ch = build_client_hello_with_raw_extensions(&ext_blob);
|
|
assert!(extract_alpn_from_client_hello(&ch).is_empty());
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Additional adversarial checks
|
|
// ------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn empty_secret_hmac_is_supported() {
|
|
let secret: &[u8] = b"";
|
|
let handshake = make_valid_tls_handshake(secret, 0);
|
|
let secrets = vec![("empty".to_string(), secret.to_vec())];
|
|
let result = validate_tls_handshake(&handshake, &secrets, true);
|
|
assert!(result.is_some(), "Empty HMAC key must not panic and must validate when correct");
|
|
}
|
|
|
|
#[test]
|
|
fn server_hello_digest_verifies_against_full_response() {
|
|
let secret = b"fronting_digest_verify_key";
|
|
let client_digest = [0x42u8; TLS_DIGEST_LEN];
|
|
let session_id = vec![0xAA; 32];
|
|
let rng = crate::crypto::SecureRandom::new();
|
|
|
|
let response = build_server_hello(secret, &client_digest, &session_id, 1024, &rng, None, 1);
|
|
let mut zeroed = response.clone();
|
|
zeroed[TLS_DIGEST_POS..TLS_DIGEST_POS + TLS_DIGEST_LEN].fill(0);
|
|
|
|
let mut hmac_input = Vec::with_capacity(TLS_DIGEST_LEN + zeroed.len());
|
|
hmac_input.extend_from_slice(&client_digest);
|
|
hmac_input.extend_from_slice(&zeroed);
|
|
let expected = sha256_hmac(secret, &hmac_input);
|
|
|
|
assert_eq!(
|
|
&response[TLS_DIGEST_POS..TLS_DIGEST_POS + TLS_DIGEST_LEN],
|
|
&expected,
|
|
"ServerHello digest must be verifiable by a client that recomputes HMAC over full response"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn server_hello_digest_fails_after_single_byte_tamper() {
|
|
let secret = b"fronting_tamper_detect_key";
|
|
let client_digest = [0x24u8; TLS_DIGEST_LEN];
|
|
let session_id = vec![0xBB; 32];
|
|
let rng = crate::crypto::SecureRandom::new();
|
|
|
|
let mut response = build_server_hello(secret, &client_digest, &session_id, 1024, &rng, None, 0);
|
|
response[TLS_DIGEST_POS + TLS_DIGEST_LEN + 1] ^= 0x01;
|
|
|
|
let mut zeroed = response.clone();
|
|
zeroed[TLS_DIGEST_POS..TLS_DIGEST_POS + TLS_DIGEST_LEN].fill(0);
|
|
|
|
let mut hmac_input = Vec::with_capacity(TLS_DIGEST_LEN + zeroed.len());
|
|
hmac_input.extend_from_slice(&client_digest);
|
|
hmac_input.extend_from_slice(&zeroed);
|
|
let expected = sha256_hmac(secret, &hmac_input);
|
|
|
|
assert_ne!(
|
|
&response[TLS_DIGEST_POS..TLS_DIGEST_POS + TLS_DIGEST_LEN],
|
|
&expected,
|
|
"Tampering any response byte must invalidate the embedded digest"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn server_hello_application_data_payload_varies_across_runs() {
|
|
use std::collections::HashSet;
|
|
|
|
let secret = b"fronting_payload_variability_key";
|
|
let client_digest = [0x13u8; TLS_DIGEST_LEN];
|
|
let session_id = vec![0x44; 32];
|
|
let rng = crate::crypto::SecureRandom::new();
|
|
|
|
let mut unique_payloads: HashSet<Vec<u8>> = HashSet::new();
|
|
for _ in 0..16 {
|
|
let response = build_server_hello(secret, &client_digest, &session_id, 1024, &rng, None, 0);
|
|
|
|
let sh_len = u16::from_be_bytes([response[3], response[4]]) as usize;
|
|
let ccs_pos = 5 + sh_len;
|
|
let ccs_len = u16::from_be_bytes([response[ccs_pos + 3], response[ccs_pos + 4]]) as usize;
|
|
let app_pos = ccs_pos + 5 + ccs_len;
|
|
|
|
assert_eq!(response[app_pos], TLS_RECORD_APPLICATION);
|
|
let app_len = u16::from_be_bytes([response[app_pos + 3], response[app_pos + 4]]) as usize;
|
|
let payload = response[app_pos + 5..app_pos + 5 + app_len].to_vec();
|
|
|
|
assert!(payload.iter().any(|&b| b != 0), "Payload must not be all-zero deterministic filler");
|
|
unique_payloads.insert(payload);
|
|
}
|
|
|
|
assert!(
|
|
unique_payloads.len() >= 4,
|
|
"ApplicationData payload should vary across runs to reduce fingerprintability"
|
|
);
|
|
}
|