use borsh::{BorshDeserialize, BorshSerialize};
use hmac::{Hmac, KeyInit, Mac};
use parking_lot::RwLock;
use sha2::{Digest, Sha256};
use std::net::IpAddr;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use subtle::ConstantTimeEq;
use zeroize::ZeroizeOnDrop;
use crate::crypto::adaptive_crypto::{CipherSuite, CryptoSession};
use crate::crypto::hybrid_kem::{HybridCiphertext, HybridKeyPackage, HybridSecretKey};
use crate::crypto::hybrid_sign::{HybridSignature, HybridSigningKey, HybridVerifyingKey};
use crate::crypto::kdf::derive_early_data_keying;
use crate::crypto::pow::{PoWChallenge, PoWSolution};
use crate::errors::CoreError;
use crate::transport::reputation::ReputationTracker;
use crate::transport::session::{CryptoState, Session};
use crate::transport::session_cache::SessionCache;
use crate::transport::types::{SchedulerMode, SessionId};
use std::sync::Arc;
pub const EARLY_DATA_MAX_LEN: usize = 16 * 1024;
#[cfg(not(feature = "fips"))]
pub const PROTOCOL_VARIANT: &[u8] = b"phantom-default-1";
#[cfg(feature = "fips")]
pub const PROTOCOL_VARIANT: &[u8] = b"phantom-fips-1";
pub const PROTOCOL_VERSION: u8 = 3;
pub const SERVER_REJECT_MARKER: [u8; 4] = *b"PRJ1";
pub const REJECT_UNSUPPORTED_VERSION: u8 = 1;
#[derive(BorshSerialize, BorshDeserialize, Debug, Clone, PartialEq, Eq)]
pub struct ServerReject {
pub marker: [u8; 4],
pub code: u8,
pub supported_version: u8,
}
impl ServerReject {
pub fn unsupported_version() -> Self {
Self {
marker: SERVER_REJECT_MARKER,
code: REJECT_UNSUPPORTED_VERSION,
supported_version: PROTOCOL_VERSION,
}
}
pub fn has_marker(&self) -> bool {
self.marker == SERVER_REJECT_MARKER
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HandshakeStage {
Initial,
ClassicalReady,
Established,
Failed,
}
#[derive(BorshSerialize, BorshDeserialize, Debug, Clone)]
pub struct ClientHello {
pub client_key_package: HybridKeyPackage,
pub client_verify_key: HybridVerifyingKey,
pub nonce: [u8; 32],
pub version: u8,
pub cookie: Option<[u8; 32]>,
pub pow_solution: Option<PoWSolution>,
pub resume_session_id: Option<[u8; 32]>,
pub resumption_binder: Option<[u8; 32]>,
pub protocol_variant: Vec<u8>,
pub early_data: Option<Vec<u8>>,
}
const ML_KEM_PK_MAX: usize = 1184;
const ML_DSA_PK_MAX: usize = 1952;
const PROTOCOL_VARIANT_MAX: usize = 64;
pub(crate) fn client_hello_lengths_within_bounds(bytes: &[u8]) -> bool {
fn skip(bytes: &[u8], pos: &mut usize, n: usize) -> bool {
match pos.checked_add(n) {
Some(end) if end <= bytes.len() => {
*pos = end;
true
}
_ => false,
}
}
fn read_len(bytes: &[u8], pos: &mut usize) -> Option<usize> {
let end = pos.checked_add(4)?;
if end > bytes.len() {
return None;
}
let l = u32::from_le_bytes([
bytes[*pos],
bytes[*pos + 1],
bytes[*pos + 2],
bytes[*pos + 3],
]);
*pos = end;
Some(l as usize)
}
fn read_opt(bytes: &[u8], pos: &mut usize) -> Option<bool> {
let tag = *bytes.get(*pos)?;
*pos += 1;
match tag {
0 => Some(false),
1 => Some(true),
_ => None,
}
}
fn vec_le(bytes: &[u8], pos: &mut usize, max: usize) -> bool {
match read_len(bytes, pos) {
Some(l) if l <= max => skip(bytes, pos, l),
_ => false,
}
}
fn opt_fixed(bytes: &[u8], pos: &mut usize, n: usize) -> bool {
match read_opt(bytes, pos) {
Some(true) => skip(bytes, pos, n),
Some(false) => true,
None => false,
}
}
let mut pos = 0usize;
if !skip(
bytes,
&mut pos,
crate::crypto::hybrid_kem::CLASSICAL_PK_BYTES,
) || !vec_le(bytes, &mut pos, ML_KEM_PK_MAX)
{
return false;
}
if !skip(bytes, &mut pos, 32) || !vec_le(bytes, &mut pos, ML_DSA_PK_MAX) {
return false;
}
if !skip(bytes, &mut pos, 32 + 1) {
return false;
}
if !opt_fixed(bytes, &mut pos, 32)
|| !opt_fixed(bytes, &mut pos, 40)
|| !opt_fixed(bytes, &mut pos, 32)
|| !opt_fixed(bytes, &mut pos, 32)
{
return false;
}
if !vec_le(bytes, &mut pos, PROTOCOL_VARIANT_MAX) {
return false;
}
match read_opt(bytes, &mut pos) {
Some(true) => {
if !vec_le(bytes, &mut pos, EARLY_DATA_MAX_LEN) {
return false;
}
}
Some(false) => {}
None => return false,
}
pos == bytes.len()
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug)]
pub enum HandshakeResponse {
Success(ServerHello, Session, Option<Vec<u8>>),
Retry(HelloRetryRequest),
Reject(ServerReject),
Fail(HandshakeError),
}
#[derive(BorshSerialize, BorshDeserialize, Debug, Clone)]
pub struct HelloRetryRequest {
pub challenge: Option<PoWChallenge>,
pub cookie: Option<[u8; 32]>,
}
pub(crate) enum UdpAdmit {
Admit,
Retry(HelloRetryRequest),
Drop,
}
#[derive(BorshSerialize, BorshDeserialize, Debug, Clone)]
pub struct ServerHello {
pub server_nonce: [u8; 32],
pub ciphertext: HybridCiphertext,
pub server_verify_key: HybridVerifyingKey,
pub signature: HybridSignature,
pub session_id: [u8; 32],
pub early_data_accepted: bool,
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone)]
pub enum ServerReply {
Hello(ServerHello),
Retry(HelloRetryRequest),
Reject(ServerReject),
}
impl ServerReply {
const KIND_HELLO: u8 = 0;
const KIND_RETRY: u8 = 1;
const KIND_REJECT: u8 = 2;
pub fn to_wire(&self) -> Result<Vec<u8>, HandshakeError> {
let map = |e: borsh::io::Error| HandshakeError::SerializationError(e.to_string());
let (kind, body) = match self {
ServerReply::Hello(h) => (Self::KIND_HELLO, borsh::to_vec(h).map_err(map)?),
ServerReply::Retry(r) => (Self::KIND_RETRY, borsh::to_vec(r).map_err(map)?),
ServerReply::Reject(r) => (Self::KIND_REJECT, borsh::to_vec(r).map_err(map)?),
};
let mut out = Vec::with_capacity(1 + body.len());
out.push(kind);
out.extend_from_slice(&body);
Ok(out)
}
pub fn from_wire(bytes: &[u8]) -> Result<Self, HandshakeError> {
let (&kind, body) = bytes
.split_first()
.ok_or_else(|| HandshakeError::SerializationError("empty server reply".into()))?;
let map = |e: borsh::io::Error| HandshakeError::SerializationError(e.to_string());
match kind {
Self::KIND_HELLO => Ok(ServerReply::Hello(borsh::from_slice(body).map_err(map)?)),
Self::KIND_RETRY => Ok(ServerReply::Retry(borsh::from_slice(body).map_err(map)?)),
Self::KIND_REJECT => Ok(ServerReply::Reject(borsh::from_slice(body).map_err(map)?)),
other => Err(HandshakeError::SerializationError(format!(
"unknown server-reply discriminant {other}"
))),
}
}
}
#[derive(BorshSerialize)]
struct HandshakeTranscript<'a> {
protocol_variant: &'a [u8],
client_hello: &'a ClientHello,
server_nonce: &'a [u8; 32],
ciphertext: &'a HybridCiphertext,
server_verify_key: &'a HybridVerifyingKey,
session_id: &'a [u8; 32],
early_data_accepted: bool,
}
fn compute_transcript_hash<T: BorshSerialize>(transcript: &T) -> Result<[u8; 32], HandshakeError> {
let mut hasher = Sha256::new();
let bytes =
borsh::to_vec(transcript).map_err(|e| HandshakeError::SerializationError(e.to_string()))?;
hasher.update(&bytes);
Ok(hasher.finalize().into())
}
struct ConsumedTicket {
rid: [u8; 32],
secret: [u8; 32],
suite: CipherSuite,
created_at: std::time::Instant,
expires_at: std::time::Instant,
}
fn derive_resumption_binder(
resumption_secret: &[u8; 32],
resume_session_id: &[u8; 32],
client_nonce: &[u8; 32],
) -> [u8; 32] {
let mut ikm = zeroize::Zeroizing::new([0u8; 96]);
ikm[..32].copy_from_slice(resumption_secret);
ikm[32..64].copy_from_slice(resume_session_id);
ikm[64..].copy_from_slice(client_nonce);
crate::crypto::kdf::derive_key_32("phantom-resume-binder-v1", &*ikm)
}
pub trait ZeroRttAntiReplay: Send + Sync {
fn check_and_set(&self, ticket_id: &[u8; 32]) -> bool;
}
#[derive(ZeroizeOnDrop)]
pub struct HandshakeServer {
#[zeroize(skip)]
signing_key: HybridSigningKey,
#[zeroize(skip)]
verifying_key: HybridVerifyingKey,
master_secret: [u8; 32],
#[zeroize(skip)]
handshakes_this_minute: AtomicU64,
#[zeroize(skip)]
minute_start_unix_sec: AtomicU64,
#[zeroize(skip)]
session_cache: Arc<parking_lot::Mutex<SessionCache>>,
#[zeroize(skip)]
reputation: Arc<ReputationTracker>,
#[zeroize(skip)]
early_data_enabled: AtomicBool,
#[zeroize(skip)]
anti_replay: parking_lot::Mutex<Option<Arc<dyn ZeroRttAntiReplay>>>,
}
impl HandshakeServer {
pub fn new() -> Result<Self, HandshakeError> {
let (signing_key, verifying_key) = HybridSigningKey::generate();
signing_key
.pairwise_consistency_check(&verifying_key)
.map_err(|e| {
HandshakeError::RngError(format!(
"server signing identity failed its pairwise-consistency test: {e:?}"
))
})?;
Self::with_signing_key(signing_key)
}
pub fn with_signing_key(signing_key: HybridSigningKey) -> Result<Self, HandshakeError> {
let verifying_key = signing_key.verifying_key();
let mut master_secret = [0u8; 32];
getrandom::getrandom(&mut master_secret)
.map_err(|e| HandshakeError::RngError(e.to_string()))?;
let now_sec = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
Ok(Self {
signing_key,
verifying_key,
master_secret,
handshakes_this_minute: AtomicU64::new(0),
minute_start_unix_sec: AtomicU64::new(now_sec),
session_cache: Arc::new(parking_lot::Mutex::new(SessionCache::new())),
reputation: Arc::new(ReputationTracker::new()),
early_data_enabled: AtomicBool::new(true),
anti_replay: parking_lot::Mutex::new(None),
})
}
pub fn set_early_data_enabled(&self, enabled: bool) {
self.early_data_enabled.store(enabled, Ordering::Relaxed);
}
pub fn early_data_enabled(&self) -> bool {
self.early_data_enabled.load(Ordering::Relaxed)
}
pub fn set_zero_rtt_anti_replay(&self, store: Arc<dyn ZeroRttAntiReplay>) {
*self.anti_replay.lock() = Some(store);
}
fn record_handshake(&self) {
let now_sec = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let start = self.minute_start_unix_sec.load(Ordering::Relaxed);
if now_sec.saturating_sub(start) >= 60 {
self.handshakes_this_minute.store(0, Ordering::Relaxed);
self.minute_start_unix_sec.store(now_sec, Ordering::Relaxed);
}
self.handshakes_this_minute.fetch_add(1, Ordering::Relaxed);
}
pub fn adaptive_difficulty(&self) -> u8 {
let count = self.handshakes_this_minute.load(Ordering::Relaxed);
match count {
0..=99 => 0,
100..=499 => 4,
500..=1999 => 8,
2000..=9999 => 12,
_ => 16,
}
}
pub(crate) fn reputation_difficulty(&self, client_ip: IpAddr, has_ticket: bool) -> u8 {
self.reputation.calculate_difficulty(client_ip, has_ticket)
}
pub(crate) fn record_violation(&self, client_ip: IpAddr) {
self.reputation.record_violation(client_ip);
}
pub(crate) fn reset_violations(&self, client_ip: IpAddr) {
self.reputation.reset_violations(client_ip);
}
pub(crate) fn udp_admit(&self, client_hello: &ClientHello, client_ip: IpAddr) -> UdpAdmit {
let cookie_valid = match client_hello.cookie {
Some(c) => validate_cookie(&self.master_secret, client_ip, &c).unwrap_or(false),
None => false,
};
if cookie_valid {
return UdpAdmit::Admit;
}
match generate_cookie(&self.master_secret, client_ip) {
Ok(cookie) => UdpAdmit::Retry(HelloRetryRequest {
challenge: None,
cookie: Some(cookie),
}),
Err(_) => UdpAdmit::Drop,
}
}
pub(crate) fn has_valid_resume(&self, client_hello: &ClientHello) -> bool {
let Some(rid) = client_hello.resume_session_id else {
return false;
};
let Some((secret, _suite, _created_at, _expires_at)) = self.session_cache.lock().peek(&rid)
else {
return false;
};
let Some(presented) = client_hello.resumption_binder else {
return false;
};
let expected = derive_resumption_binder(&secret, &rid, &client_hello.nonce);
bool::from(presented.ct_eq(&expected))
}
pub(crate) fn gc_reputation(&self) {
self.reputation.gc();
}
pub fn handshakes_this_minute(&self) -> u64 {
self.handshakes_this_minute.load(Ordering::Relaxed)
}
#[tracing::instrument(
name = "phantom.handshake.process_client_hello",
skip_all,
// No `client_ip` field: this span is always-on in the library, and the
// peer IP is correlatable PII. The DoS gate already has the IP in-band;
// it does not need to leak into every handshake trace.
fields(
difficulty = difficulty,
has_cookie = client_hello.cookie.is_some(),
has_pow = client_hello.pow_solution.is_some(),
resume = client_hello.resume_session_id.is_some(),
has_early_data = client_hello.early_data.is_some(),
),
)]
pub fn process_client_hello(
&self,
client_hello: &ClientHello,
difficulty: u8,
client_ip: IpAddr,
) -> HandshakeResponse {
self.record_handshake();
if client_hello.protocol_variant != PROTOCOL_VARIANT {
return HandshakeResponse::Fail(HandshakeError::ProtocolVariantMismatch {
expected: PROTOCOL_VARIANT.to_vec(),
received: client_hello.protocol_variant.clone(),
});
}
if client_hello.version != PROTOCOL_VERSION {
return HandshakeResponse::Reject(ServerReject::unsupported_version());
}
let resumed: Option<ConsumedTicket> = client_hello.resume_session_id.and_then(|rid| {
let (secret, suite, created_at, expires_at) = self.session_cache.lock().peek(&rid)?;
let expected = derive_resumption_binder(&secret, &rid, &client_hello.nonce);
let presented = client_hello.resumption_binder?;
if !bool::from(presented.ct_eq(&expected)) {
return None;
}
if !self.session_cache.lock().remove(&rid) {
return None; }
let store = self.anti_replay.lock().clone();
if let Some(store) = store {
if !store.check_and_set(&rid) {
return None; }
}
Some(ConsumedTicket {
rid,
secret,
suite,
created_at,
expires_at,
})
});
let cookie_pow_bypass = resumed.is_some();
if let Err(resp) =
self.cookie_pow_gate(client_hello, difficulty, client_ip, cookie_pow_bypass)
{
return self.fail_and_reinsert(&resumed, resp);
}
let early_data_plaintext: Option<Vec<u8>> =
if self.early_data_enabled.load(Ordering::Relaxed) {
match (&resumed, &client_hello.early_data) {
(Some(t), Some(blob)) => {
decrypt_early_data(&t.secret, &client_hello.nonce, &t.rid, blob)
}
_ => None,
}
} else {
None
};
let early_data_accepted = early_data_plaintext.is_some();
let (shared_secret, ciphertext) = match client_hello.client_key_package.encapsulate() {
Ok((ss, ct)) => (zeroize::Zeroizing::new(ss), ct),
Err(e) => {
return self.fail_and_reinsert(
&resumed,
HandshakeResponse::Fail(HandshakeError::KemFailed(e.to_string())),
);
}
};
let mut server_nonce = [0u8; 32];
if let Err(e) = getrandom::getrandom(&mut server_nonce) {
return self.fail_and_reinsert(
&resumed,
HandshakeResponse::Fail(HandshakeError::RngError(e.to_string())),
);
}
let session_id_bytes = derive_session_id(&shared_secret, &client_hello.nonce);
let session_id = SessionId::from_bytes(session_id_bytes);
let transcript = HandshakeTranscript {
protocol_variant: PROTOCOL_VARIANT,
client_hello,
server_nonce: &server_nonce,
ciphertext: &ciphertext,
server_verify_key: &self.verifying_key,
session_id: &session_id_bytes,
early_data_accepted,
};
let transcript_hash = match compute_transcript_hash(&transcript) {
Ok(h) => h,
Err(e) => return self.fail_and_reinsert(&resumed, HandshakeResponse::Fail(e)),
};
let signature = self.signing_key.sign(&transcript_hash);
let server_hello = ServerHello {
server_nonce,
ciphertext,
server_verify_key: self.verifying_key.clone(),
signature,
session_id: session_id_bytes,
early_data_accepted,
};
let session = match self.finalize_session(&shared_secret, session_id, session_id_bytes) {
Ok(s) => s,
Err(resp) => return self.fail_and_reinsert(&resumed, resp),
};
HandshakeResponse::Success(server_hello, session, early_data_plaintext)
}
#[allow(clippy::result_large_err)]
fn cookie_pow_gate(
&self,
client_hello: &ClientHello,
difficulty: u8,
client_ip: IpAddr,
bypass: bool,
) -> Result<(), HandshakeResponse> {
let cookie_valid = match client_hello.cookie {
Some(c) => match validate_cookie(&self.master_secret, client_ip, &c) {
Ok(v) => v,
Err(e) => return Err(HandshakeResponse::Fail(e)),
},
None => false,
};
let expected_cookie = match generate_cookie(&self.master_secret, client_ip) {
Ok(c) => c,
Err(e) => return Err(HandshakeResponse::Fail(e)),
};
let mut pow_valid = true;
let mut challenge = None;
if difficulty > 0 {
let cur_hour = match current_secret_hour() {
Ok(h) => h,
Err(e) => return Err(HandshakeResponse::Fail(e)),
};
let prev_hour = cur_hour.saturating_sub(1);
let hours: &[u64] = if cur_hour == prev_hour {
&[cur_hour]
} else {
&[cur_hour, prev_hour]
};
if let Some(sol) = &client_hello.pow_solution {
let mut any_valid = false;
for &h in hours {
let derived = match derive_session_secret_for_hour(&self.master_secret, h) {
Ok(s) => s,
Err(e) => return Err(HandshakeResponse::Fail(e)),
};
let challenge_ref = PoWChallenge {
nonce: sol.nonce,
difficulty,
};
if challenge_ref.verify(sol, client_ip.to_string().as_bytes(), &derived) {
any_valid = true;
break;
}
}
pow_valid = any_valid;
} else {
pow_valid = false;
let derived = match derive_session_secret_for_hour(&self.master_secret, cur_hour) {
Ok(s) => s,
Err(e) => return Err(HandshakeResponse::Fail(e)),
};
challenge = Some(PoWChallenge::new_stateless(
difficulty,
client_ip.to_string().as_bytes(),
&derived,
));
}
}
if !bypass && (!cookie_valid || !pow_valid) {
return Err(HandshakeResponse::Retry(HelloRetryRequest {
challenge,
cookie: if !cookie_valid {
Some(expected_cookie)
} else {
None
},
}));
}
Ok(())
}
#[allow(clippy::result_large_err)]
fn finalize_session(
&self,
shared_secret: &[u8; 32],
session_id: SessionId,
session_id_bytes: [u8; 32],
) -> Result<Session, HandshakeResponse> {
let crypto = CryptoState::new(shared_secret, true)
.map_err(|e| HandshakeResponse::Fail(HandshakeError::KemFailed(e.to_string())))?;
let session = Session::from_derived(
session_id,
crypto,
SchedulerMode::LowLatency,
*shared_secret,
true,
);
let mut resumption_secret = [0u8; 32];
let hk = hkdf::Hkdf::<Sha256>::new(None, shared_secret);
if hk
.expand(b"phantom-resumption-secret-v1", &mut resumption_secret)
.is_ok()
{
session.set_resumption_secret(resumption_secret);
self.session_cache.lock().store(
session_id_bytes,
&resumption_secret,
CipherSuite::Aes256Gcm,
);
}
Ok(session)
}
#[allow(clippy::result_large_err)]
fn fail_and_reinsert(
&self,
resumed: &Option<ConsumedTicket>,
resp: HandshakeResponse,
) -> HandshakeResponse {
if let Some(t) = resumed {
self.session_cache.lock().reinsert_with_expiry(
t.rid,
&t.secret,
t.suite,
t.created_at,
t.expires_at,
);
}
resp
}
pub fn verifying_key(&self) -> &HybridVerifyingKey {
&self.verifying_key
}
pub fn session_cache_len(&self) -> usize {
self.session_cache.lock().len()
}
}
#[derive(ZeroizeOnDrop)]
pub struct HandshakeClient {
#[zeroize(skip)]
kem_secret: HybridSecretKey,
#[zeroize(skip)]
kem_public: HybridKeyPackage,
#[zeroize(skip)]
#[allow(dead_code)]
signing_key: HybridSigningKey,
#[zeroize(skip)]
verifying_key: HybridVerifyingKey,
nonce: [u8; 32],
#[zeroize(skip)]
early_data: RwLock<Vec<Vec<u8>>>,
#[zeroize(skip)]
stage: RwLock<HandshakeStage>,
}
impl HandshakeClient {
pub fn new() -> Result<Self, HandshakeError> {
let (kem_secret, kem_public) = HybridSecretKey::generate();
let (signing_key, verifying_key) = HybridSigningKey::generate();
let mut nonce = [0u8; 32];
getrandom::getrandom(&mut nonce).map_err(|e| HandshakeError::RngError(e.to_string()))?;
Ok(Self {
kem_secret,
kem_public,
signing_key,
verifying_key,
nonce,
early_data: RwLock::new(Vec::new()),
stage: RwLock::new(HandshakeStage::Initial),
})
}
pub fn create_client_hello(&self) -> ClientHello {
ClientHello {
client_key_package: self.kem_public.clone(),
client_verify_key: self.verifying_key.clone(),
nonce: self.nonce,
version: PROTOCOL_VERSION,
cookie: None,
pow_solution: None,
resume_session_id: None,
resumption_binder: None,
protocol_variant: PROTOCOL_VARIANT.to_vec(),
early_data: None,
}
}
pub fn create_client_hello_with_resume(
&self,
resume_session_id: [u8; 32],
resumption_secret: &[u8; 32],
early_data: Option<&[u8]>,
) -> ClientHello {
let sealed = early_data
.and_then(|pt| seal_early_data(resumption_secret, &self.nonce, &resume_session_id, pt));
let resumption_binder =
derive_resumption_binder(resumption_secret, &resume_session_id, &self.nonce);
ClientHello {
client_key_package: self.kem_public.clone(),
client_verify_key: self.verifying_key.clone(),
nonce: self.nonce,
version: PROTOCOL_VERSION,
cookie: None,
pow_solution: None,
resume_session_id: Some(resume_session_id),
resumption_binder: Some(resumption_binder),
protocol_variant: PROTOCOL_VARIANT.to_vec(),
early_data: sealed,
}
}
#[tracing::instrument(
name = "phantom.handshake.process_server_hello",
skip_all,
fields(
pinned = expected_server_key.is_some(),
),
)]
pub fn process_server_hello(
&self,
client_hello: &ClientHello,
server_hello: &ServerHello,
expected_server_key: Option<&HybridVerifyingKey>,
) -> Result<(Session, Option<bool>), HandshakeError> {
if let Some(expected) = expected_server_key {
if expected != &server_hello.server_verify_key {
return Err(HandshakeError::ServerIdentityMismatch);
}
}
let transcript = HandshakeTranscript {
protocol_variant: PROTOCOL_VARIANT,
client_hello,
server_nonce: &server_hello.server_nonce,
ciphertext: &server_hello.ciphertext,
server_verify_key: &server_hello.server_verify_key,
session_id: &server_hello.session_id,
early_data_accepted: server_hello.early_data_accepted,
};
let transcript_hash = compute_transcript_hash(&transcript)?;
server_hello
.server_verify_key
.verify(&transcript_hash, &server_hello.signature)
.map_err(|e| HandshakeError::KemFailed(format!("Signature check failed: {:?}", e)))?;
let shared_secret = zeroize::Zeroizing::new(
self.kem_secret
.decapsulate(&server_hello.ciphertext)
.map_err(|e| HandshakeError::KemFailed(e.to_string()))?,
);
let session_id = SessionId::from_bytes(server_hello.session_id);
let crypto = CryptoState::new(&shared_secret, false)
.map_err(|e| HandshakeError::KemFailed(e.to_string()))?;
let session = Session::from_derived(
session_id,
crypto,
SchedulerMode::LowLatency,
*shared_secret,
false,
);
let mut resumption_secret = [0u8; 32];
let hk = hkdf::Hkdf::<Sha256>::new(None, &shared_secret[..]);
if hk
.expand(b"phantom-resumption-secret-v1", &mut resumption_secret)
.is_ok()
{
session.set_resumption_secret(resumption_secret);
}
*self.stage.write() = HandshakeStage::Established;
let early_data_verdict = client_hello
.early_data
.as_ref()
.map(|_| server_hello.early_data_accepted);
Ok((session, early_data_verdict))
}
pub fn queue_early_data(&self, data: Vec<u8>) {
self.early_data.write().push(data);
}
#[allow(dead_code)]
pub fn take_early_data(&self) -> Vec<Vec<u8>> {
std::mem::take(&mut self.early_data.write())
}
pub fn stage(&self) -> HandshakeStage {
*self.stage.read()
}
}
fn derive_session_id(shared_secret: &[u8; 32], nonce: &[u8; 32]) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(b"phantom-session-id-v1");
hasher.update(shared_secret);
hasher.update(nonce);
hasher.finalize().into()
}
fn decrypt_early_data(
resumption_secret: &[u8; 32],
client_nonce: &[u8; 32],
resume_session_id: &[u8; 32],
sealed: &[u8],
) -> Option<Vec<u8>> {
if sealed.len() > EARLY_DATA_MAX_LEN + 16 {
return None;
}
let (key, nonce) = derive_early_data_keying(resumption_secret, client_nonce);
let aead = CryptoSession::with_suite_peer(&key, CipherSuite::Aes256Gcm).ok()?;
let mut aad = [0u8; 64];
aad[..32].copy_from_slice(resume_session_id);
aad[32..].copy_from_slice(client_nonce);
aead.decrypt_with_nonce(nonce, &aad, sealed).ok()
}
fn seal_early_data(
resumption_secret: &[u8; 32],
client_nonce: &[u8; 32],
resume_session_id: &[u8; 32],
plaintext: &[u8],
) -> Option<Vec<u8>> {
let (key, nonce) = derive_early_data_keying(resumption_secret, client_nonce);
let aead = CryptoSession::with_suite(&key, CipherSuite::Aes256Gcm).ok()?;
let mut aad = [0u8; 64];
aad[..32].copy_from_slice(resume_session_id);
aad[32..].copy_from_slice(client_nonce);
aead.encrypt_with_nonce(nonce, &aad, plaintext).ok()
}
const COOKIE_BUCKET_SECONDS: u64 = 300;
const SECRET_ROTATION_SECONDS: u64 = 3600;
fn current_cookie_bucket() -> Result<u64, HandshakeError> {
Ok(SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| HandshakeError::ClockBackwards)?
.as_secs()
/ COOKIE_BUCKET_SECONDS)
}
fn current_secret_hour() -> Result<u64, HandshakeError> {
Ok(SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| HandshakeError::ClockBackwards)?
.as_secs()
/ SECRET_ROTATION_SECONDS)
}
pub(crate) fn derive_session_secret_for_hour(
master: &[u8; 32],
hour: u64,
) -> Result<[u8; 32], HandshakeError> {
let hk = hkdf::Hkdf::<Sha256>::new(None, master);
let mut out = [0u8; 32];
let mut info = Vec::with_capacity(16 + 8);
info.extend_from_slice(b"phantom-pow-cookie-v1");
info.extend_from_slice(&hour.to_be_bytes());
hk.expand(&info, &mut out)
.map_err(|e| HandshakeError::InternalError(format!("HKDF expand: {}", e)))?;
Ok(out)
}
fn generate_cookie_for_bucket(
derived_secret: &[u8; 32],
ip: IpAddr,
bucket: u64,
) -> Result<[u8; 32], HandshakeError> {
let mut mac = Hmac::<Sha256>::new_from_slice(derived_secret)
.map_err(|e| HandshakeError::InternalError(format!("HMAC init: {}", e)))?;
mac.update(ip.to_string().as_bytes());
mac.update(&bucket.to_be_bytes());
let mut result = [0u8; 32];
result.copy_from_slice(&mac.finalize().into_bytes());
Ok(result)
}
fn generate_cookie(master: &[u8; 32], ip: IpAddr) -> Result<[u8; 32], HandshakeError> {
let hour = current_secret_hour()?;
let derived = derive_session_secret_for_hour(master, hour)?;
generate_cookie_for_bucket(&derived, ip, current_cookie_bucket()?)
}
fn validate_cookie(
master: &[u8; 32],
ip: IpAddr,
cookie: &[u8; 32],
) -> Result<bool, HandshakeError> {
let bucket = current_cookie_bucket()?;
let hour = current_secret_hour()?;
let prev_bucket = bucket.saturating_sub(1);
let prev_hour = hour.saturating_sub(1);
let bucket_candidates: [u64; 2] = if bucket == prev_bucket {
[bucket, bucket]
} else {
[bucket, prev_bucket]
};
let hour_candidates: [u64; 2] = if hour == prev_hour {
[hour, hour]
} else {
[hour, prev_hour]
};
let mut accept = subtle::Choice::from(0u8);
for h in hour_candidates {
let derived = derive_session_secret_for_hour(master, h)?;
for b in bucket_candidates {
let expected = generate_cookie_for_bucket(&derived, ip, b)?;
accept |= cookie.ct_eq(&expected);
}
}
Ok(bool::from(accept))
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum HandshakeError {
#[error("Unsupported version")]
UnsupportedVersion,
#[error("KEM failed: {0}")]
KemFailed(String),
#[error("Server identity mismatch")]
ServerIdentityMismatch,
#[error("RNG error: {0}")]
RngError(String),
#[error("serialization error during handshake: {0}")]
SerializationError(String),
#[error("system clock is before UNIX_EPOCH")]
ClockBackwards,
#[error("internal handshake error: {0}")]
InternalError(String),
#[error("protocol variant mismatch (expected {expected:?}, received {received:?})")]
ProtocolVariantMismatch {
expected: Vec<u8>,
received: Vec<u8>,
},
}
impl From<HandshakeError> for CoreError {
fn from(err: HandshakeError) -> Self {
CoreError::InternalError(err.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(not(feature = "fips"))]
#[test]
fn transcript_hash_wire_vector() {
fn pat(seed: u8, n: usize) -> Vec<u8> {
(0..n).map(|i| seed.wrapping_add(i as u8)).collect()
}
fn arr32(seed: u8) -> [u8; 32] {
pat(seed, 32).try_into().expect("pat(seed, 32) is 32 bytes")
}
let key_package = HybridKeyPackage {
classical_pk: arr32(0x10),
ml_kem_pk: pat(0x20, 1184),
};
let verify_key = HybridVerifyingKey {
ed25519_pk: arr32(0x50),
ml_dsa_pk: pat(0x60, 1952),
};
let client_hello = ClientHello {
client_key_package: key_package.clone(),
client_verify_key: verify_key.clone(),
nonce: arr32(0xA0),
version: PROTOCOL_VERSION,
cookie: Some(arr32(0xB0)),
pow_solution: Some(PoWSolution {
nonce: arr32(0x90),
solution: 0x0123_4567_89AB_CDEF,
}),
resume_session_id: Some(arr32(0xC0)),
resumption_binder: Some(arr32(0xC8)),
protocol_variant: PROTOCOL_VARIANT.to_vec(),
early_data: Some(pat(0xD0, 48)),
};
let ciphertext = HybridCiphertext {
classical_pk: arr32(0x30),
ml_kem_ct: pat(0x40, 1088),
};
let session_id = arr32(0xE0);
let transcript = HandshakeTranscript {
protocol_variant: PROTOCOL_VARIANT,
client_hello: &client_hello,
server_nonce: &arr32(0x70),
ciphertext: &ciphertext,
server_verify_key: &verify_key,
session_id: &session_id,
early_data_accepted: true,
};
let hash = compute_transcript_hash(&transcript).expect("transcript hash");
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/wire_vectors/transcript_hash.bin");
if std::env::var_os("PHANTOM_REGEN_WIRE_VECTORS").is_some() {
std::fs::create_dir_all(path.parent().expect("fixtures dir parent"))
.expect("create wire_vectors dir");
std::fs::write(&path, hash).expect("write transcript_hash.bin");
return;
}
let expected = std::fs::read(&path)
.expect("read transcript_hash.bin; regenerate with PHANTOM_REGEN_WIRE_VECTORS=1");
assert_eq!(
hash.as_slice(),
expected.as_slice(),
"handshake transcript hash changed — the signing input (Invariants 7 & 10) is \
wire-breaking. If intentional, bump PROTOCOL_VERSION and regenerate."
);
}
#[tokio::test]
async fn protocol_variant_mismatch_rejected() {
let server = HandshakeServer::new().expect("HandshakeServer::new");
let client = HandshakeClient::new().expect("HandshakeClient::new");
let client_ip = "127.0.0.1".parse().expect("parse client_ip");
let mut hello = client.create_client_hello();
hello.protocol_variant = b"phantom-some-other-mode-1".to_vec();
let response = server.process_client_hello(&hello, 0, client_ip);
match response {
HandshakeResponse::Fail(HandshakeError::ProtocolVariantMismatch {
expected,
received,
}) => {
assert_eq!(expected, PROTOCOL_VARIANT);
assert_eq!(received, b"phantom-some-other-mode-1");
}
other => panic!("expected ProtocolVariantMismatch, got {other:?}"),
}
}
#[tokio::test]
async fn unsupported_version_yields_typed_reject() {
let server = HandshakeServer::new().expect("HandshakeServer::new");
let client = HandshakeClient::new().expect("HandshakeClient::new");
let client_ip = "127.0.0.1".parse().expect("parse client_ip");
let mut hello = client.create_client_hello();
hello.version = PROTOCOL_VERSION.wrapping_add(7);
match server.process_client_hello(&hello, 0, client_ip) {
HandshakeResponse::Reject(reject) => {
assert!(reject.has_marker(), "reject must carry the marker");
assert_eq!(reject.code, REJECT_UNSUPPORTED_VERSION);
assert_eq!(reject.supported_version, PROTOCOL_VERSION);
}
other => panic!("expected Reject, got {other:?}"),
}
}
#[test]
fn server_reply_discriminant_dispatches_explicitly() {
let reject = ServerReject::unsupported_version();
let wire = ServerReply::Reject(reject.clone())
.to_wire()
.expect("frame reject");
assert_eq!(wire[0], 2, "reject discriminant byte");
assert!(matches!(
ServerReply::from_wire(&wire),
Ok(ServerReply::Reject(r)) if r == reject
));
let hrr = HelloRetryRequest {
challenge: None,
cookie: Some([7u8; 32]),
};
let wire = ServerReply::Retry(hrr.clone())
.to_wire()
.expect("frame retry");
assert_eq!(wire[0], 1, "retry discriminant byte");
assert!(matches!(
ServerReply::from_wire(&wire),
Ok(ServerReply::Retry(r)) if r.cookie == hrr.cookie && r.challenge.is_none()
));
assert!(
ServerReply::from_wire(&[0xFF, 0x00]).is_err(),
"unknown kind rejected"
);
assert!(ServerReply::from_wire(&[]).is_err(), "empty reply rejected");
}
#[test]
fn server_reject_roundtrips_and_is_shape_distinct() {
let reject = ServerReject::unsupported_version();
let bytes = borsh::to_vec(&reject).expect("encode reject");
let decoded: ServerReject = borsh::from_slice(&bytes).expect("decode reject");
assert_eq!(decoded, reject);
assert!(decoded.has_marker());
let hrr = HelloRetryRequest {
challenge: None,
cookie: None,
};
let hrr_bytes = borsh::to_vec(&hrr).expect("encode hrr");
assert!(
borsh::from_slice::<ServerReject>(&hrr_bytes).is_err(),
"a HelloRetryRequest must not parse as a ServerReject"
);
assert!(
borsh::from_slice::<HelloRetryRequest>(&bytes).is_err(),
"a ServerReject must not parse as a HelloRetryRequest"
);
}
#[tokio::test]
async fn handshake_succeeds_with_matching_protocol_variant() {
let server = HandshakeServer::new().expect("HandshakeServer::new");
let client = HandshakeClient::new().expect("HandshakeClient::new");
let client_ip = "127.0.0.1".parse().expect("parse client_ip");
let hello = client.create_client_hello();
assert_eq!(hello.protocol_variant, PROTOCOL_VARIANT);
let response = server.process_client_hello(&hello, 0, client_ip);
let cookie = match response {
HandshakeResponse::Retry(r) => r.cookie.expect("cookie"),
other => panic!("expected retry, got {other:?}"),
};
let mut hello_retry = hello.clone();
hello_retry.cookie = Some(cookie);
match server.process_client_hello(&hello_retry, 0, client_ip) {
HandshakeResponse::Success(..) => {}
other => panic!("expected success, got {other:?}"),
}
}
#[tokio::test]
async fn server_nonce_is_transcript_bound() {
let server = HandshakeServer::new().expect("HandshakeServer::new");
let client = HandshakeClient::new().expect("HandshakeClient::new");
let client_ip = "127.0.0.1".parse().expect("parse client_ip");
let hello = client.create_client_hello();
let cookie = match server.process_client_hello(&hello, 0, client_ip) {
HandshakeResponse::Retry(r) => r.cookie.expect("cookie"),
_ => panic!("expected retry"),
};
let mut hello_retry = hello.clone();
hello_retry.cookie = Some(cookie);
let server_hello = match server.process_client_hello(&hello_retry, 0, client_ip) {
HandshakeResponse::Success(h, _s, _) => h,
_ => panic!("expected success"),
};
assert!(
client
.process_server_hello(&hello_retry, &server_hello, Some(server.verifying_key()))
.is_ok(),
"untampered server_nonce must verify"
);
let mut tampered = server_hello.clone();
tampered.server_nonce[0] ^= 0xFF;
assert!(
client
.process_server_hello(&hello_retry, &tampered, Some(server.verifying_key()))
.is_err(),
"a flipped server_nonce byte must fail the transcript-signature check"
);
}
#[tokio::test]
async fn test_unified_handshake() {
let server = HandshakeServer::new().expect("HandshakeServer::new");
let client = HandshakeClient::new().expect("HandshakeClient::new");
let client_ip = "127.0.0.1".parse().expect("parse client_ip");
let hello = client.create_client_hello();
let response = server.process_client_hello(&hello, 0, client_ip);
let cookie = match response {
HandshakeResponse::Retry(r) => r.cookie.unwrap(),
_ => panic!("Expected retry"),
};
let mut hello_retry = hello.clone();
hello_retry.cookie = Some(cookie);
let response = server.process_client_hello(&hello_retry, 0, client_ip);
let (server_hello, _server_session) = match response {
HandshakeResponse::Success(h, s, _) => (h, s),
_ => panic!("Expected success"),
};
let _client_session = client
.process_server_hello(&hello_retry, &server_hello, Some(server.verifying_key()))
.unwrap();
assert_eq!(*client.stage.read(), HandshakeStage::Established);
}
#[tokio::test]
async fn first_handshake_caches_ticket_and_exposes_hint() {
let server = HandshakeServer::new().expect("HandshakeServer::new");
let client = HandshakeClient::new().expect("HandshakeClient::new");
let client_ip = "127.0.0.1".parse().unwrap();
let hello = client.create_client_hello();
let cookie = match server.process_client_hello(&hello, 0, client_ip) {
HandshakeResponse::Retry(r) => r.cookie.unwrap(),
_ => panic!("expected retry"),
};
let mut hello_retry = hello.clone();
hello_retry.cookie = Some(cookie);
let (server_hello, server_session) =
match server.process_client_hello(&hello_retry, 0, client_ip) {
HandshakeResponse::Success(h, s, _) => (h, s),
_ => panic!("expected success"),
};
let (client_session, _) = client
.process_server_hello(&hello_retry, &server_hello, Some(server.verifying_key()))
.unwrap();
assert_eq!(server.session_cache_len(), 1);
let s_hint = server_session.resumption_hint().expect("server hint");
let c_hint = client_session.resumption_hint().expect("client hint");
assert_eq!(s_hint.0, c_hint.0, "session id matches across sides");
assert_eq!(s_hint.1, c_hint.1, "resumption secret matches");
}
#[tokio::test]
async fn cached_resume_session_id_skips_cookie_and_pow() {
let server = HandshakeServer::new().expect("HandshakeServer::new");
let client_ip = "127.0.0.1".parse().unwrap();
let first_client = HandshakeClient::new().unwrap();
let first_hello = first_client.create_client_hello();
let cookie = match server.process_client_hello(&first_hello, 0, client_ip) {
HandshakeResponse::Retry(r) => r.cookie.unwrap(),
_ => panic!("expected retry"),
};
let mut hello_retry = first_hello.clone();
hello_retry.cookie = Some(cookie);
let (_first_server_hello, first_server_session) =
match server.process_client_hello(&hello_retry, 0, client_ip) {
HandshakeResponse::Success(h, s, _) => (h, s),
_ => panic!("expected success"),
};
let (resume_id, resume_secret) = first_server_session.resumption_hint().unwrap();
let second_client = HandshakeClient::new().unwrap();
let resume_hello =
second_client.create_client_hello_with_resume(resume_id, &resume_secret, None);
match server.process_client_hello(&resume_hello, 0, client_ip) {
HandshakeResponse::Success(..) => {} HandshakeResponse::Retry(_) => {
panic!("resume_session_id should bypass cookie/PoW gate")
}
HandshakeResponse::Reject(r) => panic!("unexpected reject: {:?}", r),
HandshakeResponse::Fail(e) => panic!("unexpected failure: {:?}", e),
}
}
#[tokio::test]
async fn unknown_resume_session_id_does_not_bypass_cookie() {
let server = HandshakeServer::new().unwrap();
let client = HandshakeClient::new().unwrap();
let client_ip = "127.0.0.1".parse().unwrap();
let bogus_id = [0xFFu8; 32];
let hello = client.create_client_hello_with_resume(bogus_id, &[0u8; 32], None);
match server.process_client_hello(&hello, 0, client_ip) {
HandshakeResponse::Retry(_) => {} other => panic!(
"expected Retry for unknown resume id, got {:?}",
matches!(other, HandshakeResponse::Success(..)),
),
}
}
fn first_handshake_for_hint(
server: &HandshakeServer,
client_ip: std::net::IpAddr,
) -> ([u8; 32], [u8; 32]) {
let client = HandshakeClient::new().unwrap();
let hello = client.create_client_hello();
let cookie = match server.process_client_hello(&hello, 0, client_ip) {
HandshakeResponse::Retry(r) => r.cookie.unwrap(),
_ => panic!("expected retry"),
};
let mut retry = hello.clone();
retry.cookie = Some(cookie);
match server.process_client_hello(&retry, 0, client_ip) {
HandshakeResponse::Success(_, session, _) => session.resumption_hint().unwrap(),
_ => panic!("expected success"),
}
}
#[tokio::test]
async fn early_data_round_trip() {
let server = HandshakeServer::new().unwrap();
let client_ip = "127.0.0.1".parse().unwrap();
let (resume_id, resume_secret) = first_handshake_for_hint(&server, client_ip);
let client = HandshakeClient::new().unwrap();
let early_payload = b"zero-rtt application bytes";
let hello =
client.create_client_hello_with_resume(resume_id, &resume_secret, Some(early_payload));
match server.process_client_hello(&hello, 0, client_ip) {
HandshakeResponse::Success(sh, _session, early_data) => {
assert!(sh.early_data_accepted, "server accepted the early-data");
assert_eq!(
early_data.as_deref(),
Some(&early_payload[..]),
"server decrypted the exact payload the client sealed"
);
let (_session, accepted) = client
.process_server_hello(&hello, &sh, Some(server.verifying_key()))
.expect("client verifies the ServerHello");
assert_eq!(accepted, Some(true), "client sees early-data accepted");
}
other => panic!(
"expected Success with accepted early-data, got {}",
match other {
HandshakeResponse::Retry(_) => "Retry",
HandshakeResponse::Reject(_) => "Reject",
HandshakeResponse::Fail(_) => "Fail",
HandshakeResponse::Success(..) => unreachable!(),
}
),
}
}
#[tokio::test]
async fn oversized_early_data_rejected_but_handshake_succeeds() {
let server = HandshakeServer::new().unwrap();
let client_ip = "127.0.0.1".parse().unwrap();
let (resume_id, resume_secret) = first_handshake_for_hint(&server, client_ip);
let huge = vec![0u8; EARLY_DATA_MAX_LEN + 1];
let client = HandshakeClient::new().unwrap();
let hello = client.create_client_hello_with_resume(resume_id, &resume_secret, Some(&huge));
match server.process_client_hello(&hello, 0, client_ip) {
HandshakeResponse::Success(sh, _session, early_data) => {
assert!(!sh.early_data_accepted, "oversized blob rejected");
assert!(early_data.is_none(), "no plaintext surfaces");
}
_ => panic!("handshake must still succeed as 1-RTT"),
}
}
#[tokio::test]
async fn corrupted_early_data_rejected_but_handshake_succeeds() {
let server = HandshakeServer::new().unwrap();
let client_ip = "127.0.0.1".parse().unwrap();
let (resume_id, resume_secret) = first_handshake_for_hint(&server, client_ip);
let client = HandshakeClient::new().unwrap();
let mut hello = client.create_client_hello_with_resume(resume_id, &resume_secret, None);
hello.early_data = Some(vec![0xFFu8; 128]);
match server.process_client_hello(&hello, 0, client_ip) {
HandshakeResponse::Success(sh, _session, early_data) => {
assert!(!sh.early_data_accepted, "AEAD failure → rejected");
assert!(early_data.is_none());
}
_ => panic!("handshake must still succeed as 1-RTT"),
}
}
#[tokio::test]
async fn unknown_ticket_with_early_data_falls_back_to_cookie_retry() {
let server = HandshakeServer::new().unwrap();
let client_ip = "127.0.0.1".parse().unwrap();
let client = HandshakeClient::new().unwrap();
let hello = client.create_client_hello_with_resume([0xAB; 32], &[0xCD; 32], Some(b"hi"));
assert!(
matches!(
server.process_client_hello(&hello, 0, client_ip),
HandshakeResponse::Retry(_)
),
"unknown ticket → no bypass → cookie Retry"
);
}
#[test]
fn reputation_wiring_escalates_and_resets_per_ip() {
let server = HandshakeServer::new().unwrap();
let ip: std::net::IpAddr = "203.0.113.7".parse().unwrap();
assert_eq!(
server.reputation_difficulty(ip, false),
0,
"clean IP adds nothing"
);
assert_eq!(
server.reputation_difficulty(ip, true),
0,
"ticket holder skips PoW"
);
server.record_violation(ip);
let d1 = server.reputation_difficulty(ip, false);
assert!(
d1 >= 8,
"a violation escalates the per-IP difficulty, got {d1}"
);
server.record_violation(ip);
assert!(
server.reputation_difficulty(ip, false) >= d1,
"more violations escalate further"
);
server.reset_violations(ip);
assert_eq!(
server.reputation_difficulty(ip, false),
0,
"reset clears it"
);
}
#[test]
fn client_hello_length_validator_rejects_a_forged_vector_length() {
let hello = HandshakeClient::new()
.expect("client")
.create_client_hello();
let bytes = borsh::to_vec(&hello).expect("serialize");
assert!(
client_hello_lengths_within_bounds(&bytes),
"a real ClientHello must pass the structural length pre-check"
);
let mut forged = bytes.clone();
let off = crate::crypto::hybrid_kem::CLASSICAL_PK_BYTES;
forged[off..off + 4].copy_from_slice(&u32::MAX.to_le_bytes());
assert!(
!client_hello_lengths_within_bounds(&forged),
"a forged ml_kem_pk length must be rejected before borsh allocates"
);
assert!(!client_hello_lengths_within_bounds(b"not-a-clienthello"));
assert!(!client_hello_lengths_within_bounds(&[]));
}
#[test]
fn has_valid_resume_requires_a_real_ticket_and_binder() {
let server = HandshakeServer::new().expect("server");
let server_pk = server.verifying_key().clone();
let client = HandshakeClient::new().expect("client");
let ip: IpAddr = "203.0.113.7".parse().unwrap();
let hello1 = client.create_client_hello();
let (effective, sh) = match server.process_client_hello(&hello1, 0, ip) {
HandshakeResponse::Retry(r) => {
let mut h = hello1.clone();
h.cookie = r.cookie;
match server.process_client_hello(&h, 0, ip) {
HandshakeResponse::Success(sh, _, _) => (h, sh),
o => panic!("unexpected after retry: {o:?}"),
}
}
HandshakeResponse::Success(sh, _, _) => (hello1.clone(), sh),
o => panic!("unexpected first response: {o:?}"),
};
let (session, _) = client
.process_server_hello(&effective, &sh, Some(&server_pk))
.expect("client establishes a session");
let secret = session
.resumption_secret()
.expect("resumption secret installed");
assert!(!server.has_valid_resume(&client.create_client_hello()));
let mut junk = client.create_client_hello();
junk.resume_session_id = Some([0x55u8; 32]);
junk.resumption_binder = Some([0xAAu8; 32]);
assert!(
!server.has_valid_resume(&junk),
"a junk resume_session_id must not count as a valid ticket (M-5)"
);
for _ in 0..5 {
server.record_violation(ip);
}
let flagged = server.reputation_difficulty(ip, false);
assert!(flagged > 0, "precondition: a flagged IP has difficulty > 0");
assert_eq!(
server.reputation_difficulty(ip, server.has_valid_resume(&junk)),
flagged,
"a junk resume must not zero a flagged IP's PoW difficulty (M-5)"
);
let resume = client.create_client_hello_with_resume(sh.session_id, &secret, None);
assert!(
server.has_valid_resume(&resume),
"a real cached ticket with a valid binder must count as a valid resume"
);
}
}