Skip to content
157 changes: 150 additions & 7 deletions bin/yip-rendezvous/src/reality.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ pub struct ClientHelloInfo {
/// The x25519 (group `0x001d`) entry from the `key_share` extension, if
/// present and exactly 32 bytes.
pub key_share_x25519: Option<[u8; 32]>,
/// The ML-KEM-768 encapsulation key from the group-4588
/// (X25519MLKEM768) entry in the `key_share` extension, if present. The
/// 4588 entry's `key_exchange` is `mlkem_ek(1184) ‖ x25519(32)`; this is
/// only the first 1184 bytes (the embedded x25519 is NOT read here —
/// `key_share_x25519` above stays sourced from the standalone `0x001d`
/// entry).
pub key_share_mlkem_ek: Option<Vec<u8>>,
}

/// TLS extension type for `server_name` (SNI).
Expand All @@ -35,6 +42,14 @@ const EXT_SERVER_NAME: u16 = 0x0000;
const EXT_KEY_SHARE: u16 = 0x0033;
/// Named group id for x25519 in `key_share` entries.
const GROUP_X25519: u16 = 0x001d;
/// Named group id for the hybrid X25519MLKEM768 in `key_share` entries.
const GROUP_X25519MLKEM768: u16 = 4588;
/// Byte length of the ML-KEM-768 encapsulation key embedded in a
/// group-4588 `key_share` entry.
const MLKEM768_EK_LEN: usize = 1184;
/// Byte length of a group-4588 `key_share` entry's `key_exchange`:
/// `mlkem_ek(1184) ‖ x25519(32)`.
const X25519MLKEM768_KEY_LEN: usize = MLKEM768_EK_LEN + 32;
/// `HandshakeType::client_hello`.
const HANDSHAKE_TYPE_CLIENT_HELLO: u8 = 0x01;

Expand Down Expand Up @@ -85,34 +100,45 @@ pub fn parse_client_hello(msg: &[u8]) -> Option<ClientHelloInfo> {
let ext_total_len = usize::from(u16_be(rest.get(..2)?)?);
let extensions = rest.get(2..2 + ext_total_len)?;

let (sni, key_share_x25519) = parse_extensions(extensions)?;
let (sni, key_share_x25519, key_share_mlkem_ek) = parse_extensions(extensions)?;

Some(ClientHelloInfo {
sni,
client_random,
legacy_session_id,
key_share_x25519,
key_share_mlkem_ek,
})
}

/// Walk the extensions block, extracting `server_name` and `key_share`
/// (x25519 entry only). Returns `None` if the block is malformed; a missing
/// extension is not malformed, it's just absent from the returned tuple.
fn parse_extensions(mut buf: &[u8]) -> Option<(Option<String>, Option<[u8; 32]>)> {
/// Walk the extensions block, extracting `server_name` and `key_share` (the
/// x25519 entry and the group-4588 ML-KEM ek). Returns `None` if the block
/// is malformed; a missing extension is not malformed, it's just absent
/// from the returned tuple.
#[expect(
clippy::type_complexity,
reason = "internal helper; the 3-tuple mirrors ClientHelloInfo's three key_share/sni fields \
and a named struct would be pure ceremony for a single private call site"
)]
fn parse_extensions(mut buf: &[u8]) -> Option<(Option<String>, Option<[u8; 32]>, Option<Vec<u8>>)> {
let mut sni = None;
let mut key_share_x25519 = None;
let mut key_share_mlkem_ek = None;
while !buf.is_empty() {
let ext_type = u16_be(buf.get(..2)?)?;
let ext_len = usize::from(u16_be(buf.get(2..4)?)?);
let ext_body = buf.get(4..4 + ext_len)?;
match ext_type {
EXT_SERVER_NAME => sni = parse_server_name(ext_body),
EXT_KEY_SHARE => key_share_x25519 = parse_key_share_x25519(ext_body),
EXT_KEY_SHARE => {
key_share_x25519 = parse_key_share_x25519(ext_body);
key_share_mlkem_ek = parse_key_share_mlkem_ek(ext_body);
}
_ => {}
}
buf = buf.get(4 + ext_len..)?;
}
Some((sni, key_share_x25519))
Some((sni, key_share_x25519, key_share_mlkem_ek))
}

/// Parse a `server_name` extension body: `list_len(u16) | name_type(u8) |
Expand Down Expand Up @@ -151,6 +177,29 @@ fn parse_key_share_x25519(body: &[u8]) -> Option<[u8; 32]> {
None
}

/// Parse a `key_share` (ClientHello) extension body: `client_shares_len(u16)
/// | entries...`, each entry `group(u16) | key_len(u16) | key_bytes`. Finds
/// the group-4588 (X25519MLKEM768) entry whose key is exactly
/// `mlkem_ek(1184) ‖ x25519(32)` = 1216 bytes, and returns the first 1184
/// bytes (the ML-KEM ek only — the embedded x25519 is not extracted here).
/// A 4588 entry of any other length is skipped (scanning continues) rather
/// than treated as malformed.
fn parse_key_share_mlkem_ek(body: &[u8]) -> Option<Vec<u8>> {
let shares_len = usize::from(u16_be(body.get(..2)?)?);
let mut entries = body.get(2..2 + shares_len)?;
while !entries.is_empty() {
let group = u16_be(entries.get(..2)?)?;
let key_len = usize::from(u16_be(entries.get(2..4)?)?);
let key_bytes = entries.get(4..4 + key_len)?;
if group == GROUP_X25519MLKEM768 && key_bytes.len() == X25519MLKEM768_KEY_LEN {
let ek = key_bytes.get(..MLKEM768_EK_LEN)?;
return Some(ek.to_vec());
}
entries = entries.get(4 + key_len..)?;
}
None
}

/// Big-endian `u16` from exactly 2 bytes.
fn u16_be(b: &[u8]) -> Option<u16> {
Some(u16::from_be_bytes(b.try_into().ok()?))
Expand Down Expand Up @@ -450,6 +499,7 @@ mod tests {
client_random,
legacy_session_id: session_id.to_vec(),
key_share_x25519: Some(eph_pub),
key_share_mlkem_ek: None,
};

assert!(reality_auth_open(
Expand Down Expand Up @@ -479,6 +529,7 @@ mod tests {
client_random,
legacy_session_id: session_id.to_vec(),
key_share_x25519: Some(eph_pub),
key_share_mlkem_ek: None,
};

assert!(!reality_auth_open(
Expand Down Expand Up @@ -508,6 +559,7 @@ mod tests {
client_random,
legacy_session_id: session_id.to_vec(),
key_share_x25519: Some(eph_pub),
key_share_mlkem_ek: None,
};

// Client clock in the PAST (now > ts): at the boundary accepted, past it rejected.
Expand Down Expand Up @@ -563,6 +615,7 @@ mod tests {
client_random,
legacy_session_id: session_id.to_vec(),
key_share_x25519: Some(eph_pub),
key_share_mlkem_ek: None,
};

assert!(!reality_auth_open(
Expand Down Expand Up @@ -592,6 +645,7 @@ mod tests {
client_random,
legacy_session_id: session_id.to_vec(),
key_share_x25519: Some(eph_pub),
key_share_mlkem_ek: None,
};

assert!(!reality_auth_open(
Expand All @@ -611,6 +665,7 @@ mod tests {
client_random: [0u8; 32],
legacy_session_id: vec![0u8; SESSION_ID_LEN],
key_share_x25519: None,
key_share_mlkem_ek: None,
};
assert!(!reality_auth_open(&reality_priv, &info, &[[0u8; 8]], 0, 60));
}
Expand All @@ -623,6 +678,7 @@ mod tests {
client_random: [0u8; 32],
legacy_session_id: vec![0u8; 10], // not 32 bytes
key_share_x25519: Some([1u8; 32]),
key_share_mlkem_ek: None,
};
assert!(!reality_auth_open(&reality_priv, &info, &[[0u8; 8]], 0, 60));
}
Expand Down Expand Up @@ -720,6 +776,7 @@ mod tests {
client_random,
legacy_session_id: session_id.to_vec(),
key_share_x25519: Some(eph_pub),
key_share_mlkem_ek: None,
};

let ts_from_recover = reality_auth_recover(&reality_priv, &info, &[short_id], now, 10)
Expand Down Expand Up @@ -751,13 +808,99 @@ mod tests {
client_random: [0u8; 32],
legacy_session_id: vec![0u8; SESSION_ID_LEN],
key_share_x25519: None,
key_share_mlkem_ek: None,
};
assert_eq!(
reality_auth_recover_shared(&reality_priv, &info, &[[0u8; 8]], 0, 60),
None
);
}

// ---- Part E: ML-KEM ek extraction (REALITY.5b Task 3) ----

/// Build a ClientHello whose `key_share` extension carries BOTH a plain
/// x25519 (`0x001d`) entry and a hybrid group-4588 (X25519MLKEM768)
/// entry (`mlkem_ek(1184) ‖ x25519(32)`) — mirroring a real hybrid-PQ
/// client, which sends both. `key_share_x25519` stays sourced from the
/// `0x001d` entry (unchanged); `key_share_mlkem_ek` comes from the 4588
/// entry's first 1184 bytes.
/// `standalone_x25519` is the `0x001d` entry's key (what `key_share_x25519`
/// must resolve to); `hybrid_x25519` is the DISTINCT trailing key inside the
/// 4588 entry (must NOT feed `key_share_x25519`) — kept different on purpose
/// so the parse test would catch a sourcing regression.
fn build_test_client_hello_with_4588_key_share(
mlkem_ek: &[u8],
standalone_x25519: &[u8; 32],
hybrid_x25519: &[u8; 32],
) -> Vec<u8> {
let mut entries = Vec::new();
// 0x001d entry: group | key_len(32) | key(32)
entries.extend_from_slice(&super::GROUP_X25519.to_be_bytes());
entries.extend_from_slice(&32u16.to_be_bytes());
entries.extend_from_slice(standalone_x25519);
// 4588 entry: group | key_len(1216) | mlkem_ek(1184) ‖ x25519(32)
let mut hybrid_key = Vec::with_capacity(mlkem_ek.len() + 32);
hybrid_key.extend_from_slice(mlkem_ek);
hybrid_key.extend_from_slice(hybrid_x25519);
entries.extend_from_slice(&super::GROUP_X25519MLKEM768.to_be_bytes());
entries.extend_from_slice(&u16::try_from(hybrid_key.len()).unwrap().to_be_bytes());
entries.extend_from_slice(&hybrid_key);

let mut ks_body = Vec::new();
ks_body.extend_from_slice(&u16::try_from(entries.len()).unwrap().to_be_bytes());
ks_body.extend_from_slice(&entries);

let mut exts = Vec::new();
exts.extend_from_slice(&super::EXT_KEY_SHARE.to_be_bytes());
exts.extend_from_slice(&u16::try_from(ks_body.len()).unwrap().to_be_bytes());
exts.extend_from_slice(&ks_body);

build_client_hello_with_raw_exts([10u8; 32], &[11u8; 32], &exts)
}

#[test]
fn parse_client_hello_extracts_mlkem_ek() {
let mlkem_ek = vec![0xABu8; 1184];
// Distinct x25519 in the 0x001d entry vs. the 4588 tail: key_share_x25519
// must resolve to the standalone one, proving it is NOT read from the
// hybrid entry's trailing key.
let standalone_x25519 = [0xCDu8; 32];
let hybrid_x25519 = [0xEEu8; 32];
let ch = build_test_client_hello_with_4588_key_share(
&mlkem_ek,
&standalone_x25519,
&hybrid_x25519,
);
let info = parse_client_hello(&ch).expect("parse");
assert_eq!(info.key_share_mlkem_ek.as_deref(), Some(&mlkem_ek[..]));
assert_eq!(info.key_share_x25519, Some(standalone_x25519));
}

#[test]
fn parse_client_hello_mlkem_ek_wrong_length_is_none() {
// A 4588 entry that isn't 1184+32 = 1216 bytes.
let wrong_key = vec![0xEFu8; 100];

let mut entries = Vec::new();
entries.extend_from_slice(&super::GROUP_X25519MLKEM768.to_be_bytes());
entries.extend_from_slice(&u16::try_from(wrong_key.len()).unwrap().to_be_bytes());
entries.extend_from_slice(&wrong_key);

let mut ks_body = Vec::new();
ks_body.extend_from_slice(&u16::try_from(entries.len()).unwrap().to_be_bytes());
ks_body.extend_from_slice(&entries);

let mut exts = Vec::new();
exts.extend_from_slice(&super::EXT_KEY_SHARE.to_be_bytes());
exts.extend_from_slice(&u16::try_from(ks_body.len()).unwrap().to_be_bytes());
exts.extend_from_slice(&ks_body);

let msg = build_client_hello_with_raw_exts([12u8; 32], &[13u8; 32], &exts);
let info = parse_client_hello(&msg)
.expect("a wrong-length 4588 key_share entry must not fail the whole parse");
assert_eq!(info.key_share_mlkem_ek, None);
}

// ---- Part D: deferred parser-edge coverage (REALITY.1 Task 5, Test 3) ----

/// A `key_share` entry with the x25519 group but `key_len != 32` must be
Expand Down
10 changes: 9 additions & 1 deletion crates/yip-utls/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ pub enum Error {
/// CertificateVerify, wrong scheme, or a missing/malformed message). The
/// caller must treat the relay as unauthenticated and NOT tunnel.
RealityVerify(&'static str),
/// REALITY.5b: the relay's [`crate::server::server_key_share`] was asked
/// to key a TLS group it does not implement server-side KEX for (only
/// `29`/X25519 and `4588`/X25519MLKEM768 are supported).
UnsupportedGroup(u16),
}

impl fmt::Display for Error {
Expand All @@ -42,6 +46,7 @@ impl fmt::Display for Error {
Error::Protocol(msg) => write!(f, "REALITY/TLS protocol error: {msg}"),
Error::Clock => write!(f, "system clock reads before the Unix epoch"),
Error::RealityVerify(m) => write!(f, "REALITY relay verification failed: {m}"),
Error::UnsupportedGroup(g) => write!(f, "REALITY server cannot key TLS group {g}"),
}
}
}
Expand All @@ -52,7 +57,10 @@ impl std::error::Error for Error {
Error::Io(e) => Some(e),
Error::Handshake(e) => Some(e),
Error::Rng(e) => Some(e),
Error::Protocol(_) | Error::Clock | Error::RealityVerify(_) => None,
Error::Protocol(_)
| Error::Clock
| Error::RealityVerify(_)
| Error::UnsupportedGroup(_) => None,
}
}
}
Expand Down
7 changes: 5 additions & 2 deletions crates/yip-utls/src/handshake.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,11 @@ pub const GROUP_X25519: u16 = 0x001d;
/// this group is `mlkem768_ciphertext(1088) ‖ x25519_public(32)` = 1120
/// bytes.
pub const GROUP_X25519MLKEM768: u16 = 4588;
/// Byte length of an ML-KEM-768 ciphertext (FIPS 203 §7.2).
const MLKEM768_CIPHERTEXT_LEN: usize = 1088;
/// Byte length of an ML-KEM-768 ciphertext (FIPS 203 §7.2). `pub(crate)`
/// so [`crate::server`] (REALITY.5b's server-side KEX, the mirror of this
/// module's client-side group-4588 handling) can size the server
/// `key_share`'s ciphertext prefix without duplicating the constant.
pub(crate) const MLKEM768_CIPHERTEXT_LEN: usize = 1088;
/// Byte length of an X25519 public value (RFC 7748).
const X25519_LEN: usize = 32;
const SUITE_AES_128_GCM_SHA256: u16 = 0x1301;
Expand Down
2 changes: 2 additions & 0 deletions crates/yip-utls/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ pub mod error;
pub mod handshake;
pub mod hello;
pub mod ja;
pub mod server;
pub mod stream;
pub mod template;
pub mod wire;

pub use error::Error;
pub use server::server_key_share;
pub use stream::{capture_dest_flight, connect, RealityStream};
pub use template::{
CapturedFlight, CertChainShape, EncryptedFlightShape, ServerFlightTemplate, ServerHelloShape,
Expand Down
Loading
Loading