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::{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 = 2;
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>>,
}
#[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]>,
}
#[derive(BorshSerialize, BorshDeserialize, Debug, Clone)]
pub struct ServerHello {
pub server_key_package: HybridKeyPackage,
pub ciphertext: HybridCiphertext,
pub server_verify_key: HybridVerifyingKey,
pub signature: HybridSignature,
pub session_id: [u8; 32],
pub early_data_accepted: bool,
}
#[derive(BorshSerialize)]
struct HandshakeTranscript<'a> {
protocol_variant: &'a [u8],
client_hello: &'a ClientHello,
server_key_package: &'a HybridKeyPackage,
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)
}
#[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>,
}
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()),
})
}
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 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; }
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>> = match (&resumed, &client_hello.early_data) {
(Some(t), Some(blob)) => {
decrypt_early_data(&t.secret, &client_hello.nonce, &t.rid, blob)
}
_ => None,
};
let early_data_accepted = early_data_plaintext.is_some();
let (shared_secret, ciphertext) = match client_hello.client_key_package.encapsulate() {
Ok(res) => res,
Err(e) => {
return self.fail_and_reinsert(
&resumed,
HandshakeResponse::Fail(HandshakeError::KemFailed(e.to_string())),
);
}
};
let (_ephemeral_kem_secret, ephemeral_kem_public) = HybridSecretKey::generate();
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_key_package: &ephemeral_kem_public,
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_key_package: ephemeral_kem_public,
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_key_package: &server_hello.server_key_package,
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 = 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_key_package: &key_package,
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_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 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"
);
}
}