diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index 7e17f785c..bb2119719 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -5614,6 +5614,7 @@ dependencies = [ "x25519-dalek", "x509-parser", "yasna", + "zeroize", ] [[package]] diff --git a/proxy/Cargo.lock b/proxy/Cargo.lock index de737970f..d680594cf 100644 --- a/proxy/Cargo.lock +++ b/proxy/Cargo.lock @@ -1668,6 +1668,7 @@ dependencies = [ "x25519-dalek", "x509-parser", "yasna", + "zeroize", ] [[package]] diff --git a/sdk/rust/Cargo.lock b/sdk/rust/Cargo.lock index ee58ebee1..9d19e31f5 100644 --- a/sdk/rust/Cargo.lock +++ b/sdk/rust/Cargo.lock @@ -1292,6 +1292,7 @@ dependencies = [ "x25519-dalek", "x509-parser", "yasna", + "zeroize", ] [[package]] diff --git a/sdk/rust/Cargo.toml b/sdk/rust/Cargo.toml index a1ed097af..00c93f3e8 100644 --- a/sdk/rust/Cargo.toml +++ b/sdk/rust/Cargo.toml @@ -34,6 +34,7 @@ sha2 = "0.10" base64 = "0.22" ring = "0.17" # For certificate validation hex = "0.4" # For debug output +zeroize = { version = "1.8", features = ["derive"] } # X.509 and certificate handling x509-parser = "0.16" diff --git a/sdk/rust/src/lib.rs b/sdk/rust/src/lib.rs index 4dd2ee9ed..9fb8fa06b 100644 --- a/sdk/rust/src/lib.rs +++ b/sdk/rust/src/lib.rs @@ -6,6 +6,7 @@ pub mod error; pub mod pcr; pub mod push; pub mod session; +mod transport_v2; pub mod types; pub use client::{InferenceRequest, InferenceResponse, OpenSecretClient, OpenSecretResponseBody}; diff --git a/sdk/rust/src/transport_v2/crypto.rs b/sdk/rust/src/transport_v2/crypto.rs new file mode 100644 index 000000000..e0f85c7db --- /dev/null +++ b/sdk/rust/src/transport_v2/crypto.rs @@ -0,0 +1,386 @@ +//! Direction-separated cryptographic primitives for transport v2. + +use std::fmt; + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use chacha20poly1305::{ + aead::{Aead, Payload}, + ChaCha20Poly1305, KeyInit, Nonce, +}; +use hkdf::Hkdf; +use p256::elliptic_curve::rand_core::{OsRng, RngCore}; +use sha2::Sha256; +use uuid::Uuid; +use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; + +use super::{ + envelope::{check_limit, RequestId}, + Result, TransportV2Error, +}; + +pub(super) const KEY_LEN: usize = 32; +pub(super) const RECORD_NONCE_LEN: usize = 12; +const RECORD_TAG_LEN: usize = 16; +pub(super) const MIN_RECORD_LEN: usize = RECORD_NONCE_LEN + RECORD_TAG_LEN; + +const HANDSHAKE_PAYLOAD_VERSION: u8 = 2; +pub(super) const HANDSHAKE_PAYLOAD_LEN: usize = 1 + 16 + KEY_LEN + 8; +pub(super) const HANDSHAKE_RECORD_LEN: usize = MIN_RECORD_LEN + HANDSHAKE_PAYLOAD_LEN; + +const HANDSHAKE_KEY_INFO: &[u8] = b"opensecret/transport-v2/handshake-key"; +const REQUEST_KEY_INFO: &[u8] = b"opensecret/transport-v2/client-request"; +const RESPONSE_KEY_INFO: &[u8] = b"opensecret/transport-v2/enclave-response"; + +const KEY_EXCHANGE_AAD: &[u8] = b"opensecret/transport-v2/key-exchange"; +const REQUEST_RECORD_AAD: &[u8] = b"opensecret/transport-v2/request-record"; +const UNARY_RESPONSE_RECORD_AAD: &[u8] = b"opensecret/transport-v2/unary-response-record"; +const STREAM_RESPONSE_RECORD_AAD: &[u8] = b"opensecret/transport-v2/stream-response-record"; + +#[derive(Zeroize, ZeroizeOnDrop)] +pub(super) struct SessionMaster([u8; KEY_LEN]); + +impl SessionMaster { + pub(super) const fn from_bytes(bytes: [u8; KEY_LEN]) -> Self { + Self(bytes) + } + + fn from_slice(bytes: &[u8]) -> Result { + if bytes.len() != KEY_LEN { + return Err(TransportV2Error::InvalidKeyExchange); + } + let mut master = Self([0; KEY_LEN]); + master.0.copy_from_slice(bytes); + Ok(master) + } + + fn as_bytes(&self) -> &[u8; KEY_LEN] { + &self.0 + } +} + +impl fmt::Debug for SessionMaster { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("SessionMaster([REDACTED])") + } +} + +pub(super) struct DecryptedHandshakePayload { + pub(super) session_id: Uuid, + pub(super) session_master: SessionMaster, + pub(super) expires_at_unix_seconds: u64, +} + +impl fmt::Debug for DecryptedHandshakePayload { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("DecryptedHandshakePayload") + .field("session_id", &self.session_id) + .field("session_master", &"[REDACTED]") + .field("expires_at_unix_seconds", &self.expires_at_unix_seconds) + .finish() + } +} + +#[derive(Zeroize, ZeroizeOnDrop)] +struct RecordKey([u8; KEY_LEN]); + +impl RecordKey { + fn derive(input_key_material: &[u8], info: &[u8]) -> Result { + let hkdf = Hkdf::::new(None, input_key_material); + let mut key = Self([0; KEY_LEN]); + hkdf.expand(info, &mut key.0) + .map_err(|_| TransportV2Error::KeyDerivationFailed)?; + Ok(key) + } + + fn encrypt(&self, plaintext: &[u8], aad: &[u8]) -> Result> { + let mut nonce = [0_u8; RECORD_NONCE_LEN]; + OsRng + .try_fill_bytes(&mut nonce) + .map_err(|_| TransportV2Error::RandomnessUnavailable)?; + self.encrypt_with_nonce(plaintext, aad, nonce) + } + + fn encrypt_with_nonce( + &self, + plaintext: &[u8], + aad: &[u8], + nonce: [u8; RECORD_NONCE_LEN], + ) -> Result> { + let cipher = ChaCha20Poly1305::new_from_slice(&self.0) + .map_err(|_| TransportV2Error::EncryptionFailed)?; + let nonce = Nonce::from(nonce); + let ciphertext = cipher + .encrypt( + &nonce, + Payload { + msg: plaintext, + aad, + }, + ) + .map_err(|_| TransportV2Error::EncryptionFailed)?; + + let mut record = Vec::with_capacity(RECORD_NONCE_LEN + ciphertext.len()); + record.extend_from_slice(&nonce); + record.extend_from_slice(&ciphertext); + Ok(record) + } + + fn decrypt(&self, record: &[u8], aad: &[u8]) -> Result> { + if record.len() < MIN_RECORD_LEN { + return Err(TransportV2Error::RecordTooShort); + } + + let (nonce, ciphertext) = record.split_at(RECORD_NONCE_LEN); + let nonce = Nonce::from( + <[u8; RECORD_NONCE_LEN]>::try_from(nonce) + .map_err(|_| TransportV2Error::RecordTooShort)?, + ); + let cipher = ChaCha20Poly1305::new_from_slice(&self.0) + .map_err(|_| TransportV2Error::AuthenticationFailed)?; + cipher + .decrypt( + &nonce, + Payload { + msg: ciphertext, + aad, + }, + ) + .map_err(|_| TransportV2Error::AuthenticationFailed) + } +} + +/// Direction-separated request and response keys for one v2 session. +#[derive(Zeroize, ZeroizeOnDrop)] +pub(super) struct DirectionalKeys { + request: RecordKey, + response: RecordKey, +} + +impl DirectionalKeys { + pub(super) fn derive(session_master: &SessionMaster) -> Result { + Ok(Self { + request: RecordKey::derive(session_master.as_bytes(), REQUEST_KEY_INFO)?, + response: RecordKey::derive(session_master.as_bytes(), RESPONSE_KEY_INFO)?, + }) + } + + pub(super) fn encrypt_request_record( + &self, + session_id: &Uuid, + plaintext: &[u8], + ) -> Result> { + self.request + .encrypt(plaintext, &request_record_aad(session_id)) + } + + pub(super) fn decrypt_unary_response_record( + &self, + session_id: &Uuid, + request_id: &RequestId, + record: &[u8], + ) -> Result> { + self.response + .decrypt(record, &unary_response_record_aad(session_id, request_id)) + } + + pub(super) fn decrypt_stream_response_record( + &self, + session_id: &Uuid, + request_id: &RequestId, + sequence: u64, + record: &[u8], + ) -> Result> { + self.response.decrypt( + record, + &stream_response_record_aad(session_id, request_id, sequence), + ) + } + + #[cfg(test)] + pub(super) fn encrypt_request_record_with_nonce( + &self, + session_id: &Uuid, + plaintext: &[u8], + nonce: [u8; RECORD_NONCE_LEN], + ) -> Result> { + self.request + .encrypt_with_nonce(plaintext, &request_record_aad(session_id), nonce) + } + + #[cfg(test)] + pub(super) fn encrypt_unary_response_record_for_test( + &self, + session_id: &Uuid, + request_id: &RequestId, + plaintext: &[u8], + ) -> Result> { + self.response.encrypt( + plaintext, + &unary_response_record_aad(session_id, request_id), + ) + } + + #[cfg(test)] + pub(super) fn encrypt_stream_response_record_for_test( + &self, + session_id: &Uuid, + request_id: &RequestId, + sequence: u64, + plaintext: &[u8], + ) -> Result> { + self.response.encrypt( + plaintext, + &stream_response_record_aad(session_id, request_id, sequence), + ) + } + + #[cfg(test)] + pub(super) fn request_key_bytes(&self) -> &[u8; KEY_LEN] { + &self.request.0 + } + + #[cfg(test)] + pub(super) fn response_key_bytes(&self) -> &[u8; KEY_LEN] { + &self.response.0 + } +} + +impl fmt::Debug for DirectionalKeys { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("DirectionalKeys([REDACTED])") + } +} + +pub(super) fn decrypt_key_exchange_record( + x25519_shared_secret: &[u8; KEY_LEN], + record: &[u8], +) -> Result { + if x25519_shared_secret.iter().all(|byte| *byte == 0) { + return Err(TransportV2Error::NonContributoryKeyExchange); + } + if record.len() != HANDSHAKE_RECORD_LEN { + return Err(TransportV2Error::InvalidKeyExchange); + } + + let key = RecordKey::derive(x25519_shared_secret, HANDSHAKE_KEY_INFO)?; + let plaintext = Zeroizing::new(key.decrypt(record, KEY_EXCHANGE_AAD)?); + if plaintext.len() != HANDSHAKE_PAYLOAD_LEN || plaintext[0] != HANDSHAKE_PAYLOAD_VERSION { + return Err(TransportV2Error::InvalidKeyExchange); + } + + let session_id = Uuid::from_bytes( + plaintext[1..17] + .try_into() + .map_err(|_| TransportV2Error::InvalidKeyExchange)?, + ); + let session_master = SessionMaster::from_slice(&plaintext[17..49])?; + let expires_at_unix_seconds = u64::from_be_bytes( + plaintext[49..57] + .try_into() + .map_err(|_| TransportV2Error::InvalidKeyExchange)?, + ); + + Ok(DecryptedHandshakePayload { + session_id, + session_master, + expires_at_unix_seconds, + }) +} + +pub(super) fn encode_canonical_base64(bytes: &[u8]) -> String { + STANDARD.encode(bytes) +} + +pub(super) fn decode_canonical_base64(encoded: &str, decoded_limit: usize) -> Result> { + let encoded_limit = decoded_limit + .checked_add(2) + .and_then(|length| length.checked_div(3)) + .and_then(|groups| groups.checked_mul(4)) + .ok_or(TransportV2Error::LimitExceeded { + field: "encoded record", + limit: decoded_limit, + })?; + check_limit(encoded.len(), encoded_limit, "encoded record")?; + let decoded = STANDARD + .decode(encoded) + .map_err(|_| TransportV2Error::InvalidEncoding)?; + if decoded.len() > decoded_limit || STANDARD.encode(&decoded) != encoded { + return Err(TransportV2Error::InvalidEncoding); + } + Ok(decoded) +} + +pub(super) fn request_record_aad(session_id: &Uuid) -> Vec { + let mut aad = Vec::with_capacity(REQUEST_RECORD_AAD.len() + 1 + 16); + aad.extend_from_slice(REQUEST_RECORD_AAD); + aad.push(0); + aad.extend_from_slice(session_id.as_bytes()); + aad +} + +pub(super) fn unary_response_record_aad(session_id: &Uuid, request_id: &RequestId) -> Vec { + let mut aad = Vec::with_capacity(UNARY_RESPONSE_RECORD_AAD.len() + 1 + 16 + 16); + aad.extend_from_slice(UNARY_RESPONSE_RECORD_AAD); + aad.push(0); + aad.extend_from_slice(session_id.as_bytes()); + aad.extend_from_slice(request_id.as_bytes()); + aad +} + +pub(super) fn stream_response_record_aad( + session_id: &Uuid, + request_id: &RequestId, + sequence: u64, +) -> Vec { + let mut aad = Vec::with_capacity(STREAM_RESPONSE_RECORD_AAD.len() + 1 + 16 + 16 + 8); + aad.extend_from_slice(STREAM_RESPONSE_RECORD_AAD); + aad.push(0); + aad.extend_from_slice(session_id.as_bytes()); + aad.extend_from_slice(request_id.as_bytes()); + aad.extend_from_slice(&sequence.to_be_bytes()); + aad +} + +#[cfg(test)] +pub(super) fn derive_handshake_key_for_test( + shared_secret: &[u8; KEY_LEN], +) -> Result<[u8; KEY_LEN]> { + if shared_secret.iter().all(|byte| *byte == 0) { + return Err(TransportV2Error::NonContributoryKeyExchange); + } + Ok(RecordKey::derive(shared_secret, HANDSHAKE_KEY_INFO)?.0) +} + +#[cfg(test)] +pub(super) fn encrypt_key_exchange_record_with_nonce( + shared_secret: &[u8; KEY_LEN], + plaintext: &[u8], + nonce: [u8; RECORD_NONCE_LEN], +) -> Result> { + if shared_secret.iter().all(|byte| *byte == 0) { + return Err(TransportV2Error::NonContributoryKeyExchange); + } + if plaintext.len() != HANDSHAKE_PAYLOAD_LEN { + return Err(TransportV2Error::InvalidKeyExchange); + } + RecordKey::derive(shared_secret, HANDSHAKE_KEY_INFO)?.encrypt_with_nonce( + plaintext, + KEY_EXCHANGE_AAD, + nonce, + ) +} + +#[cfg(test)] +pub(super) fn encrypt_key_exchange_record_for_test( + shared_secret: &[u8; KEY_LEN], + plaintext: &[u8], +) -> Result> { + if shared_secret.iter().all(|byte| *byte == 0) { + return Err(TransportV2Error::NonContributoryKeyExchange); + } + if plaintext.len() != HANDSHAKE_PAYLOAD_LEN { + return Err(TransportV2Error::InvalidKeyExchange); + } + RecordKey::derive(shared_secret, HANDSHAKE_KEY_INFO)?.encrypt(plaintext, KEY_EXCHANGE_AAD) +} diff --git a/sdk/rust/src/transport_v2/envelope.rs b/sdk/rust/src/transport_v2/envelope.rs new file mode 100644 index 000000000..373e1efe3 --- /dev/null +++ b/sdk/rust/src/transport_v2/envelope.rs @@ -0,0 +1,1126 @@ +use std::fmt; + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use p256::elliptic_curve::rand_core::{OsRng, RngCore}; +use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; +use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; +use uuid::Uuid; +use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; + +use super::{Result, TransportV2Error}; + +const KIB: usize = 1024; +const MIB: usize = 1024 * 1024; + +pub(super) const MAX_OUTER_REQUEST_BYTES: usize = 50 * MIB; +pub(super) const MAX_KEY_EXCHANGE_BYTES: usize = 4 * KIB; +pub(super) const MAX_STREAM_CHUNK_BYTES: usize = 64 * KIB; +pub(super) const MAX_STREAM_ERROR_BYTES: usize = 16 * KIB; + +const KV_ITEM_PATH_PREFIX: &str = "/protected/kv/"; +const API_KEY_ITEM_PATH_PREFIX: &str = "/protected/api-keys/"; +const VERIFY_EMAIL_PATH_PREFIX: &str = "/verify-email/"; +const PLATFORM_VERIFY_EMAIL_PATH_PREFIX: &str = "/platform/verify-email/"; +const PLATFORM_ORG_PATH_PREFIX: &str = "/platform/orgs/"; +const PLATFORM_ACCEPT_INVITE_PATH_PREFIX: &str = "/platform/accept_invite/"; +const CONVERSATION_PROJECT_ITEM_PATH_PREFIX: &str = "/v1/conversation-projects/"; +const CONVERSATION_ITEM_PATH_PREFIX: &str = "/v1/conversations/"; +const INSTRUCTION_ITEM_PATH_PREFIX: &str = "/v1/instructions/"; +const RESPONSE_ITEM_PATH_PREFIX: &str = "/v1/responses/"; + +/// Resource ceilings for one decrypted transport-v2 envelope. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct EnvelopeLimits { + pub(super) envelope_bytes: usize, + pub(super) logical_body_bytes: usize, + pub(super) path_bytes: usize, + pub(super) query_bytes: usize, + pub(super) header_count: usize, + pub(super) header_name_bytes: usize, + pub(super) header_value_bytes: usize, + pub(super) aggregate_header_bytes: usize, + pub(super) credential_bytes: usize, +} + +impl EnvelopeLimits { + pub(super) const DEFAULT: Self = Self { + envelope_bytes: 50 * MIB, + logical_body_bytes: 28 * MIB, + path_bytes: 4096, + query_bytes: 8192, + header_count: 64, + header_name_bytes: 128, + header_value_bytes: 16 * KIB, + aggregate_header_bytes: 64 * KIB, + credential_bytes: 16 * KIB, + }; +} + +impl Default for EnvelopeLimits { + fn default() -> Self { + Self::DEFAULT + } +} + +/// The protocol version has no invalid in-memory representation. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(super) struct Version2; + +impl Version2 { + pub(super) const VALUE: u8 = 2; +} + +impl Serialize for Version2 { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_u8(Self::VALUE) + } +} + +impl<'de> Deserialize<'de> for Version2 { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + if u8::deserialize(deserializer)? == Self::VALUE { + Ok(Self) + } else { + Err(de::Error::custom("transport version must be exactly 2")) + } + } +} + +/// A full 128-bit, per-session replay identifier. +#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub(super) struct RequestId([u8; 16]); + +impl RequestId { + pub(super) fn random() -> Result { + let mut bytes = [0_u8; 16]; + OsRng + .try_fill_bytes(&mut bytes) + .map_err(|_| TransportV2Error::RandomnessUnavailable)?; + Ok(Self(bytes)) + } + + pub(super) const fn from_bytes(bytes: [u8; 16]) -> Self { + Self(bytes) + } + + pub(super) const fn as_bytes(&self) -> &[u8; 16] { + &self.0 + } +} + +impl fmt::Debug for RequestId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, formatter) + } +} + +impl fmt::Display for RequestId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&hex::encode(self.0)) + } +} + +impl Serialize for RequestId { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(&hex::encode(self.0)) + } +} + +impl<'de> Deserialize<'de> for RequestId { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + struct RequestIdVisitor; + + impl de::Visitor<'_> for RequestIdVisitor { + type Value = RequestId; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("exactly 32 lowercase hexadecimal characters") + } + + fn visit_str(self, value: &str) -> std::result::Result + where + E: de::Error, + { + parse_request_id(value).ok_or_else(|| E::custom("non-canonical request ID")) + } + } + + deserializer.deserialize_str(RequestIdVisitor) + } +} + +fn parse_request_id(value: &str) -> Option { + let encoded = value.as_bytes(); + if encoded.len() != 32 + || !encoded + .iter() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + { + return None; + } + + let mut decoded = [0_u8; 16]; + for (destination, pair) in decoded.iter_mut().zip(encoded.chunks_exact(2)) { + *destination = (hex_nibble(pair[0])? << 4) | hex_nibble(pair[1])?; + } + Some(RequestId(decoded)) +} + +fn hex_nibble(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + _ => None, + } +} + +/// Exact bytes represented on the wire as padded standard base64. +#[derive(Clone, Eq, PartialEq, Zeroize, ZeroizeOnDrop)] +pub(super) struct EncodedBytes(Vec); + +impl EncodedBytes { + pub(super) fn from_bytes(bytes: impl Into>) -> Self { + Self(bytes.into()) + } + + pub(super) fn as_slice(&self) -> &[u8] { + &self.0 + } + + pub(super) fn into_bytes(mut self) -> Vec { + std::mem::take(&mut self.0) + } + + pub(super) fn len(&self) -> usize { + self.0.len() + } + + pub(super) fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl fmt::Debug for EncodedBytes { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("EncodedBytes") + .field("len", &self.0.len()) + .finish_non_exhaustive() + } +} + +impl Serialize for EncodedBytes { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + let encoded = Zeroizing::new(STANDARD.encode(self.0.as_slice())); + serializer.serialize_str(encoded.as_str()) + } +} + +impl<'de> Deserialize<'de> for EncodedBytes { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + struct EncodedBytesVisitor; + + impl de::Visitor<'_> for EncodedBytesVisitor { + type Value = EncodedBytes; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("canonical padded standard base64") + } + + fn visit_str(self, value: &str) -> std::result::Result + where + E: de::Error, + { + let mut bytes = Zeroizing::new( + STANDARD + .decode(value) + .map_err(|_| E::custom("invalid standard base64"))?, + ); + let canonical = Zeroizing::new(STANDARD.encode(bytes.as_slice())); + if canonical.as_str() != value { + return Err(E::custom("non-canonical standard base64")); + } + Ok(EncodedBytes(std::mem::take(&mut *bytes))) + } + } + + deserializer.deserialize_str(EncodedBytesVisitor) + } +} + +/// Stable client-generated provider-cache namespace root. +#[derive(Eq, PartialEq, Zeroize, ZeroizeOnDrop)] +pub(super) struct CacheNamespaceRoot([u8; 32]); + +impl CacheNamespaceRoot { + pub(super) fn random() -> Result { + let mut root = Self([0_u8; 32]); + OsRng + .try_fill_bytes(&mut root.0) + .map_err(|_| TransportV2Error::RandomnessUnavailable)?; + Ok(root) + } + + pub(super) const fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + pub(super) const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +impl fmt::Debug for CacheNamespaceRoot { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("CacheNamespaceRoot([REDACTED])") + } +} + +impl Serialize for CacheNamespaceRoot { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + let encoded = Zeroizing::new(STANDARD.encode(self.0.as_slice())); + serializer.serialize_str(encoded.as_str()) + } +} + +impl<'de> Deserialize<'de> for CacheNamespaceRoot { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + let encoded = EncodedBytes::deserialize(deserializer)?; + if encoded.len() != 32 { + return Err(de::Error::custom( + "cache namespace root must contain exactly 32 bytes", + )); + } + let mut root = Self([0_u8; 32]); + root.0.copy_from_slice(encoded.as_slice()); + Ok(root) + } +} + +/// Authentication material permitted only during an anonymous transition. +#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub(super) enum Credential { + ApiKey { value_base64: EncodedBytes }, + Resumption { value_base64: EncodedBytes }, +} + +impl Credential { + pub(super) fn api_key(bytes: impl Into>) -> Self { + Self::ApiKey { + value_base64: EncodedBytes::from_bytes(bytes), + } + } + + pub(super) fn resumption(bytes: impl Into>) -> Self { + Self::Resumption { + value_base64: EncodedBytes::from_bytes(bytes), + } + } + + fn len(&self) -> usize { + match self { + Self::ApiKey { value_base64 } | Self::Resumption { value_base64 } => value_base64.len(), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum ResponseMode { + Unary, + Stream, + Auto, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub(super) enum LogicalMethod { + #[serde(rename = "GET")] + Get, + #[serde(rename = "POST")] + Post, + #[serde(rename = "PUT")] + Put, + #[serde(rename = "PATCH")] + Patch, + #[serde(rename = "DELETE")] + Delete, +} + +impl LogicalMethod { + pub(super) const fn as_str(self) -> &'static str { + match self { + Self::Get => "GET", + Self::Post => "POST", + Self::Put => "PUT", + Self::Patch => "PATCH", + Self::Delete => "DELETE", + } + } +} + +#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct HeaderField { + pub(super) name: String, + pub(super) value_base64: EncodedBytes, +} + +impl HeaderField { + pub(super) fn new(name: impl Into, value: impl Into>) -> Self { + Self { + name: name.into(), + value_base64: EncodedBytes::from_bytes(value), + } + } + + pub(super) fn value(&self) -> &[u8] { + self.value_base64.as_slice() + } +} + +#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct LogicalRequest { + pub(super) method: LogicalMethod, + pub(super) path: String, + #[serde(deserialize_with = "deserialize_required_nullable")] + pub(super) query: Option, + pub(super) headers: Vec, + #[serde(deserialize_with = "deserialize_required_nullable")] + pub(super) body_base64: Option, +} + +impl LogicalRequest { + pub(super) fn new( + method: LogicalMethod, + path: impl Into, + query: Option, + headers: Vec, + body: Option>, + ) -> Self { + Self { + method, + path: path.into(), + query, + headers, + body_base64: body.map(EncodedBytes::from_bytes), + } + } + + pub(super) fn validate(&self, limits: &EnvelopeLimits) -> Result<()> { + validate_path(self.method, &self.path, limits)?; + if let Some(query) = self.query.as_deref() { + validate_query(query, limits)?; + } + validate_headers(&self.headers, limits)?; + if let Some(body) = &self.body_base64 { + check_limit(body.len(), limits.logical_body_bytes, "logical body")?; + } + Ok(()) + } +} + +#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RequestEnvelope { + pub(super) version: Version2, + pub(super) request_id: RequestId, + pub(super) response_mode: ResponseMode, + #[serde(deserialize_with = "deserialize_required_nullable")] + pub(super) credential: Option, + #[serde(deserialize_with = "deserialize_required_nullable")] + pub(super) cache_namespace_root_base64: Option, + pub(super) request: LogicalRequest, +} + +impl RequestEnvelope { + pub(super) fn from_json_slice(input: &[u8], limits: &EnvelopeLimits) -> Result { + check_limit(input.len(), limits.envelope_bytes, "envelope")?; + let envelope: Self = + serde_json::from_slice(input).map_err(|_| TransportV2Error::InvalidJson)?; + envelope.validate(limits)?; + Ok(envelope) + } + + pub(super) fn to_json_vec(&self, limits: &EnvelopeLimits) -> Result> { + self.validate(limits)?; + let encoded = serde_json::to_vec(self).map_err(|_| TransportV2Error::InvalidJson)?; + check_limit(encoded.len(), limits.envelope_bytes, "envelope")?; + Ok(encoded) + } + + pub(super) fn validate(&self, limits: &EnvelopeLimits) -> Result<()> { + self.request.validate(limits)?; + if let Some(credential) = &self.credential { + check_limit(credential.len(), limits.credential_bytes, "credential")?; + } + Ok(()) + } +} + +#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct UnaryResponseEnvelope { + pub(super) version: Version2, + pub(super) request_id: RequestId, + pub(super) status: u16, + pub(super) headers: Vec, + #[serde(deserialize_with = "deserialize_required_nullable")] + pub(super) body_base64: Option, +} + +impl UnaryResponseEnvelope { + pub(super) fn from_json_slice(input: &[u8], limits: &EnvelopeLimits) -> Result { + check_limit(input.len(), limits.envelope_bytes, "envelope")?; + let envelope: Self = + serde_json::from_slice(input).map_err(|_| TransportV2Error::InvalidJson)?; + envelope.validate(limits)?; + Ok(envelope) + } + + pub(super) fn validate(&self, limits: &EnvelopeLimits) -> Result<()> { + if !(100..=599).contains(&self.status) { + return Err(TransportV2Error::InvalidResponse); + } + validate_headers(&self.headers, limits)?; + if let Some(body) = &self.body_base64 { + check_limit(body.len(), limits.logical_body_bytes, "logical body")?; + } + Ok(()) + } +} + +#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub(super) enum StreamRecord { + Start { + version: Version2, + request_id: RequestId, + sequence: u64, + status: u16, + headers: Vec, + }, + Chunk { + version: Version2, + request_id: RequestId, + sequence: u64, + body_base64: EncodedBytes, + }, + End { + version: Version2, + request_id: RequestId, + sequence: u64, + }, + Error { + version: Version2, + request_id: RequestId, + sequence: u64, + status: u16, + body_base64: EncodedBytes, + }, +} + +impl StreamRecord { + pub(super) fn from_json_slice(input: &[u8], limits: &EnvelopeLimits) -> Result { + check_limit(input.len(), limits.envelope_bytes, "envelope")?; + let record: Self = + serde_json::from_slice(input).map_err(|_| TransportV2Error::InvalidJson)?; + record.validate(limits)?; + Ok(record) + } + + pub(super) fn validate(&self, limits: &EnvelopeLimits) -> Result<()> { + match self { + Self::Start { + sequence, + status, + headers, + .. + } => { + if *sequence != 0 || !(200..=299).contains(status) { + return Err(TransportV2Error::InvalidStreamRecord); + } + validate_headers(headers, limits) + } + Self::Chunk { + sequence, + body_base64, + .. + } => { + validate_non_initial_sequence(*sequence)?; + check_limit(body_base64.len(), MAX_STREAM_CHUNK_BYTES, "stream chunk") + } + Self::End { sequence, .. } => validate_non_initial_sequence(*sequence), + Self::Error { + sequence, + status, + body_base64, + .. + } => { + validate_non_initial_sequence(*sequence)?; + if !(400..=599).contains(status) { + return Err(TransportV2Error::InvalidStreamRecord); + } + check_limit(body_base64.len(), MAX_STREAM_ERROR_BYTES, "stream error") + } + } + } + + pub(super) const fn request_id(&self) -> &RequestId { + match self { + Self::Start { request_id, .. } + | Self::Chunk { request_id, .. } + | Self::End { request_id, .. } + | Self::Error { request_id, .. } => request_id, + } + } + + pub(super) const fn sequence(&self) -> u64 { + match self { + Self::Start { sequence, .. } + | Self::Chunk { sequence, .. } + | Self::End { sequence, .. } + | Self::Error { sequence, .. } => *sequence, + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct EncryptedOuterRecord { + pub(super) encrypted: EncodedBytes, +} + +impl EncryptedOuterRecord { + pub(super) fn from_json_slice(input: &[u8], limit: usize) -> Result { + check_limit(input.len(), limit, "outer record")?; + serde_json::from_slice(input).map_err(|_| TransportV2Error::InvalidJson) + } + + pub(super) fn to_json_vec(&self, limit: usize) -> Result> { + let encoded = serde_json::to_vec(self).map_err(|_| TransportV2Error::InvalidJson)?; + check_limit(encoded.len(), limit, "outer record")?; + Ok(encoded) + } +} + +fn deserialize_required_nullable<'de, D, T>( + deserializer: D, +) -> std::result::Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::::deserialize(deserializer) +} + +pub(super) fn check_limit(actual: usize, limit: usize, field: &'static str) -> Result<()> { + if actual > limit { + Err(TransportV2Error::LimitExceeded { field, limit }) + } else { + Ok(()) + } +} + +fn validate_non_initial_sequence(sequence: u64) -> Result<()> { + if sequence == 0 { + Err(TransportV2Error::InvalidStreamRecord) + } else { + Ok(()) + } +} + +fn validate_path(method: LogicalMethod, path: &str, limits: &EnvelopeLimits) -> Result<()> { + check_limit(path.len(), limits.path_bytes, "path")?; + if matches!( + method, + LogicalMethod::Get | LogicalMethod::Put | LogicalMethod::Delete + ) { + if let Some(segment) = path.strip_prefix(KV_ITEM_PATH_PREFIX) { + decode_canonical_opaque_segment(segment)?; + return Ok(()); + } + } + if method == LogicalMethod::Delete { + if let Some(segment) = path.strip_prefix(API_KEY_ITEM_PATH_PREFIX) { + let decoded = decode_canonical_opaque_segment(segment)?; + if decoded.len() > 50 + || decoded.starts_with(' ') + || decoded.ends_with(' ') + || !decoded + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b' ' | b'-' | b'_')) + { + return Err(TransportV2Error::InvalidRequest); + } + return Ok(()); + } + } + if validate_canonical_uuid_path(method, path)? { + return Ok(()); + } + if !path.starts_with('/') + || path.starts_with("//") + || path.contains('?') + || path.contains('#') + || path.contains('\\') + { + return Err(TransportV2Error::InvalidRequest); + } + + let bytes = path.as_bytes(); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'%' => { + let decoded = + decode_percent_triplet(bytes, index).ok_or(TransportV2Error::InvalidRequest)?; + if matches!(decoded, b'/' | b'\\') { + return Err(TransportV2Error::InvalidRequest); + } + index += 3; + } + byte if is_path_character(byte) => index += 1, + _ => return Err(TransportV2Error::InvalidRequest), + } + } + + for segment in path.split('/') { + if is_dot_segment(segment)? { + return Err(TransportV2Error::InvalidRequest); + } + } + Ok(()) +} + +/// Encode one opaque UTF-8 final path segment exactly as the released Rust SDK +/// does: ASCII alphanumeric bytes remain literal and every other byte becomes +/// one uppercase `%HH` triplet. +pub(super) fn encode_canonical_opaque_path_segment(value: &str) -> String { + utf8_percent_encode(value, NON_ALPHANUMERIC).to_string() +} + +fn decode_canonical_opaque_segment(segment: &str) -> Result> { + if segment.is_empty() { + return Err(TransportV2Error::InvalidRequest); + } + + let encoded = segment.as_bytes(); + let mut decoded = Zeroizing::new(Vec::with_capacity(encoded.len())); + let mut index = 0; + while index < encoded.len() { + let byte = encoded[index]; + if byte.is_ascii_alphanumeric() { + decoded.push(byte); + index += 1; + continue; + } + if byte != b'%' { + return Err(TransportV2Error::InvalidRequest); + } + let high = *encoded + .get(index + 1) + .ok_or(TransportV2Error::InvalidRequest)?; + let low = *encoded + .get(index + 2) + .ok_or(TransportV2Error::InvalidRequest)?; + let decoded_byte = (canonical_uri_hex_nibble(high)? << 4) | canonical_uri_hex_nibble(low)?; + if decoded_byte.is_ascii_alphanumeric() { + return Err(TransportV2Error::InvalidRequest); + } + decoded.push(decoded_byte); + index += 3; + } + + match String::from_utf8(std::mem::take(&mut *decoded)) { + Ok(value) => Ok(Zeroizing::new(value)), + Err(error) => { + let mut bytes = error.into_bytes(); + bytes.zeroize(); + Err(TransportV2Error::InvalidRequest) + } + } +} + +fn canonical_uri_hex_nibble(byte: u8) -> Result { + match byte { + b'0'..=b'9' => Ok(byte - b'0'), + b'A'..=b'F' => Ok(byte - b'A' + 10), + _ => Err(TransportV2Error::InvalidRequest), + } +} + +fn validate_canonical_uuid_path(method: LogicalMethod, path: &str) -> Result { + if method == LogicalMethod::Get { + if let Some(segment) = path.strip_prefix(VERIFY_EMAIL_PATH_PREFIX) { + decode_canonical_uuid_segment(segment)?; + return Ok(true); + } + if let Some(segment) = path.strip_prefix(PLATFORM_VERIFY_EMAIL_PATH_PREFIX) { + decode_canonical_uuid_segment(segment)?; + return Ok(true); + } + } + if validate_canonical_platform_resource_path(method, path)? { + return Ok(true); + } + if validate_canonical_conversation_project_path(method, path)? { + return Ok(true); + } + if validate_canonical_conversation_path(method, path)? { + return Ok(true); + } + if validate_canonical_instruction_path(method, path)? { + return Ok(true); + } + validate_canonical_response_path(method, path) +} + +fn validate_canonical_platform_resource_path(method: LogicalMethod, path: &str) -> Result { + if let Some(code) = path.strip_prefix(PLATFORM_ACCEPT_INVITE_PATH_PREFIX) { + if method != LogicalMethod::Post { + return Ok(false); + } + decode_canonical_uuid_segment(code)?; + return Ok(true); + } + + let Some(suffix) = path.strip_prefix(PLATFORM_ORG_PATH_PREFIX) else { + return Ok(false); + }; + let mut segments = suffix.split('/'); + decode_canonical_uuid_segment(segments.next().unwrap_or_default())?; + let remainder = segments.collect::>(); + + match remainder.as_slice() { + [] if method == LogicalMethod::Delete => Ok(true), + ["projects"] if matches!(method, LogicalMethod::Get | LogicalMethod::Post) => Ok(true), + ["projects", project] + if matches!( + method, + LogicalMethod::Get | LogicalMethod::Patch | LogicalMethod::Delete + ) => + { + decode_canonical_uuid_segment(project)?; + Ok(true) + } + ["projects", project, "secrets"] + if matches!(method, LogicalMethod::Get | LogicalMethod::Post) => + { + decode_canonical_uuid_segment(project)?; + Ok(true) + } + ["projects", project, "secrets", key_name] if method == LogicalMethod::Delete => { + decode_canonical_uuid_segment(project)?; + if key_name.is_empty() + || key_name.len() > 50 + || !key_name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + { + return Err(TransportV2Error::InvalidRequest); + } + Ok(true) + } + ["projects", project, "settings", "email"] + if matches!(method, LogicalMethod::Get | LogicalMethod::Put) => + { + decode_canonical_uuid_segment(project)?; + Ok(true) + } + ["projects", project, "settings", "oauth"] + if matches!(method, LogicalMethod::Get | LogicalMethod::Put) => + { + decode_canonical_uuid_segment(project)?; + Ok(true) + } + ["memberships"] if method == LogicalMethod::Get => Ok(true), + ["memberships", user] if matches!(method, LogicalMethod::Patch | LogicalMethod::Delete) => { + decode_canonical_uuid_segment(user)?; + Ok(true) + } + ["invites"] if matches!(method, LogicalMethod::Get | LogicalMethod::Post) => Ok(true), + ["invites", invite] if matches!(method, LogicalMethod::Get | LogicalMethod::Delete) => { + decode_canonical_uuid_segment(invite)?; + Ok(true) + } + _ => Ok(false), + } +} + +fn validate_canonical_conversation_project_path(method: LogicalMethod, path: &str) -> Result { + if !matches!( + method, + LogicalMethod::Get | LogicalMethod::Post | LogicalMethod::Delete + ) { + return Ok(false); + } + let Some(segment) = path.strip_prefix(CONVERSATION_PROJECT_ITEM_PATH_PREFIX) else { + return Ok(false); + }; + decode_canonical_uuid_segment(segment)?; + Ok(true) +} + +fn validate_canonical_instruction_path(method: LogicalMethod, path: &str) -> Result { + let Some(segment) = path.strip_prefix(INSTRUCTION_ITEM_PATH_PREFIX) else { + return Ok(false); + }; + let segment = if let Some(segment) = segment.strip_suffix("/set-default") { + if method != LogicalMethod::Post { + return Ok(false); + } + segment + } else { + if !matches!( + method, + LogicalMethod::Get | LogicalMethod::Post | LogicalMethod::Delete + ) { + return Ok(false); + } + segment + }; + decode_canonical_uuid_segment(segment)?; + Ok(true) +} + +fn validate_canonical_conversation_path(method: LogicalMethod, path: &str) -> Result { + let Some(suffix) = path.strip_prefix(CONVERSATION_ITEM_PATH_PREFIX) else { + return Ok(false); + }; + if matches!(suffix, "batch-delete" | "batch-update-project") { + return Ok(false); + } + + if method == LogicalMethod::Get { + if let Some((conversation, item)) = suffix.split_once("/items/") { + if item.contains('/') { + return Err(TransportV2Error::InvalidRequest); + } + decode_canonical_uuid_segment(conversation)?; + decode_canonical_uuid_segment(item)?; + return Ok(true); + } + if let Some(conversation) = suffix.strip_suffix("/items") { + decode_canonical_uuid_segment(conversation)?; + return Ok(true); + } + } + + if !matches!( + method, + LogicalMethod::Get | LogicalMethod::Post | LogicalMethod::Delete + ) { + return Ok(false); + } + decode_canonical_uuid_segment(suffix)?; + Ok(true) +} + +fn validate_canonical_response_path(method: LogicalMethod, path: &str) -> Result { + let Some(suffix) = path.strip_prefix(RESPONSE_ITEM_PATH_PREFIX) else { + return Ok(false); + }; + if let Some(response) = suffix.strip_suffix("/cancel") { + if method != LogicalMethod::Post { + return Ok(false); + } + decode_canonical_uuid_segment(response)?; + return Ok(true); + } + if !matches!(method, LogicalMethod::Get | LogicalMethod::Delete) { + return Ok(false); + } + decode_canonical_uuid_segment(suffix)?; + Ok(true) +} + +fn decode_canonical_uuid_segment(segment: &str) -> Result { + let id = Uuid::parse_str(segment).map_err(|_| TransportV2Error::InvalidRequest)?; + if id.hyphenated().to_string() != segment { + return Err(TransportV2Error::InvalidRequest); + } + Ok(id) +} + +fn validate_query(query: &str, limits: &EnvelopeLimits) -> Result<()> { + check_limit(query.len(), limits.query_bytes, "query")?; + if query.starts_with('?') || query.starts_with('#') || query.contains('#') { + return Err(TransportV2Error::InvalidRequest); + } + + let bytes = query.as_bytes(); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'%' => { + decode_percent_triplet(bytes, index).ok_or(TransportV2Error::InvalidRequest)?; + index += 3; + } + byte if is_query_character(byte) => index += 1, + _ => return Err(TransportV2Error::InvalidRequest), + } + } + Ok(()) +} + +fn decode_percent_triplet(bytes: &[u8], percent_index: usize) -> Option { + let high = *bytes.get(percent_index + 1)?; + let low = *bytes.get(percent_index + 2)?; + Some((uri_hex_nibble(high)? << 4) | uri_hex_nibble(low)?) +} + +fn uri_hex_nibble(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +fn is_dot_segment(segment: &str) -> Result { + let bytes = segment.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' { + decoded.push( + decode_percent_triplet(bytes, index).ok_or(TransportV2Error::InvalidRequest)?, + ); + index += 3; + } else { + decoded.push(bytes[index]); + index += 1; + } + } + Ok(matches!(decoded.as_slice(), b"." | b"..")) +} + +fn is_path_character(byte: u8) -> bool { + byte == b'/' || is_uri_pchar(byte) +} + +fn is_query_character(byte: u8) -> bool { + matches!(byte, b'/' | b'?') || is_uri_pchar(byte) +} + +fn is_uri_pchar(byte: u8) -> bool { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'-' | b'.' + | b'_' + | b'~' + | b'!' + | b'$' + | b'&' + | b'\'' + | b'(' + | b')' + | b'*' + | b'+' + | b',' + | b';' + | b'=' + | b':' + | b'@' + ) +} + +fn validate_headers(headers: &[HeaderField], limits: &EnvelopeLimits) -> Result<()> { + if headers.len() > limits.header_count { + return Err(TransportV2Error::LimitExceeded { + field: "header count", + limit: limits.header_count, + }); + } + + let mut aggregate_bytes = 0_usize; + for header in headers { + check_limit(header.name.len(), limits.header_name_bytes, "header name")?; + if header.name.is_empty() || !header.name.bytes().all(is_lowercase_http_token) { + return Err(TransportV2Error::InvalidRequest); + } + + check_limit( + header.value_base64.len(), + limits.header_value_bytes, + "header value", + )?; + if header + .value_base64 + .as_slice() + .iter() + .any(|byte| matches!(byte, b'\r' | b'\n' | 0)) + { + return Err(TransportV2Error::InvalidRequest); + } + + aggregate_bytes = aggregate_bytes + .checked_add(header.name.len()) + .and_then(|total| total.checked_add(header.value_base64.len())) + .ok_or(TransportV2Error::LimitExceeded { + field: "aggregate headers", + limit: limits.aggregate_header_bytes, + })?; + check_limit( + aggregate_bytes, + limits.aggregate_header_bytes, + "aggregate headers", + )?; + } + Ok(()) +} + +fn is_lowercase_http_token(byte: u8) -> bool { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) +} + +#[cfg(test)] +pub(super) fn encode_canonical_base64(bytes: &[u8]) -> String { + STANDARD.encode(bytes) +} diff --git a/sdk/rust/src/transport_v2/mod.rs b/sdk/rust/src/transport_v2/mod.rs new file mode 100644 index 000000000..9c0b21c1c --- /dev/null +++ b/sdk/rust/src/transport_v2/mod.rs @@ -0,0 +1,75 @@ +//! Dormant client engine for OpenSecret transport v2. +//! +//! Nothing in this module is selected by [`crate::OpenSecretClient`] yet. The +//! cutover layer will adapt existing public methods onto these primitives in a +//! later change. Keeping this module private prevents an incomplete transport +//! from becoming a compatibility surface. + +#![allow(dead_code)] + +mod crypto; +mod envelope; +mod session; +mod stream; + +use thiserror::Error; + +/// Stable failures from the dormant transport-v2 engine. +/// +/// Variants intentionally carry no credentials, plaintext bodies, ciphertext, +/// provider errors, or parser excerpts. A future public adapter can map them +/// into the SDK's public error contract without accidentally logging secrets. +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +pub(super) enum TransportV2Error { + #[error("secure randomness is unavailable")] + RandomnessUnavailable, + #[error("transport-v2 key derivation failed")] + KeyDerivationFailed, + #[error("transport-v2 key exchange was non-contributory")] + NonContributoryKeyExchange, + #[error("transport-v2 record encryption failed")] + EncryptionFailed, + #[error("transport-v2 record authentication failed")] + AuthenticationFailed, + #[error("transport-v2 encrypted record is too short")] + RecordTooShort, + #[error("transport-v2 encoding is invalid")] + InvalidEncoding, + #[error("transport-v2 JSON is invalid")] + InvalidJson, + #[error("transport-v2 envelope exceeds the {field} limit of {limit} bytes")] + LimitExceeded { field: &'static str, limit: usize }, + #[error("transport-v2 request is invalid")] + InvalidRequest, + #[error("transport-v2 response is invalid")] + InvalidResponse, + #[error("transport-v2 response mode does not match the prepared request")] + ResponseModeMismatch, + #[error("transport-v2 session has expired")] + SessionExpired, + #[error("transport-v2 request record budget is exhausted")] + RequestRecordBudgetExhausted, + #[error("transport-v2 response record budget is exhausted")] + ResponseRecordBudgetExhausted, + #[error("transport-v2 request identifier collided")] + RequestIdCollision, + #[error("transport-v2 session state is unavailable")] + SessionStateUnavailable, + #[error("transport-v2 key-exchange response is invalid")] + InvalidKeyExchange, + #[error("transport-v2 response binding does not match the request")] + BindingMismatch, + #[error("transport-v2 stream framing is invalid")] + InvalidStreamFraming, + #[error("transport-v2 stream record is invalid")] + InvalidStreamRecord, + #[error("transport-v2 stream ended without an authenticated terminal record")] + TruncatedStream, + #[error("transport-v2 stream is already terminal")] + StreamAlreadyTerminal, +} + +pub(super) type Result = std::result::Result; + +#[cfg(test)] +mod tests; diff --git a/sdk/rust/src/transport_v2/session.rs b/sdk/rust/src/transport_v2/session.rs new file mode 100644 index 000000000..68171f56c --- /dev/null +++ b/sdk/rust/src/transport_v2/session.rs @@ -0,0 +1,648 @@ +use std::{ + collections::HashSet, + fmt, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, Mutex, + }, + time::{SystemTime, UNIX_EPOCH}, +}; + +use p256::elliptic_curve::rand_core::{OsRng, RngCore}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use x25519_dalek::{PublicKey, StaticSecret}; +use zeroize::Zeroizing; + +use super::{ + crypto::{ + decrypt_key_exchange_record, DirectionalKeys, SessionMaster, KEY_LEN, MIN_RECORD_LEN, + }, + envelope::{ + CacheNamespaceRoot, Credential, EncodedBytes, EncryptedOuterRecord, EnvelopeLimits, + LogicalRequest, RequestEnvelope, RequestId, ResponseMode, UnaryResponseEnvelope, Version2, + MAX_KEY_EXCHANGE_BYTES, MAX_OUTER_REQUEST_BYTES, + }, + stream::StreamDecoder, + Result, TransportV2Error, +}; + +const MAX_ATTESTATION_NONCE_BYTES: usize = 512; +const MAX_REQUEST_RECORDS: usize = 65_536; +const MAX_RESPONSE_RECORDS: usize = 65_536; +const MAX_REQUEST_ID_GENERATION_ATTEMPTS: usize = 16; + +#[derive(Serialize)] +#[serde(deny_unknown_fields)] +struct KeyExchangeRequest<'a> { + nonce: &'a str, + client_public_key: EncodedBytes, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct KeyExchangeResponse { + session_id: String, + encrypted_session_key: EncodedBytes, +} + +/// One prepared, one-shot key-exchange request. +/// +/// The engine offers no resend loop. The future network adapter consumes this +/// value into one outer request body and one response completion capability. +pub(super) struct PreparedKeyExchange { + request_body: Vec, + completion: KeyExchangeCompletion, +} + +impl PreparedKeyExchange { + pub(super) fn new(nonce: String, enclave_public_key: [u8; KEY_LEN]) -> Result { + if nonce.is_empty() || nonce.len() > MAX_ATTESTATION_NONCE_BYTES { + return Err(TransportV2Error::InvalidKeyExchange); + } + + let mut secret_bytes = Zeroizing::new([0_u8; KEY_LEN]); + OsRng + .try_fill_bytes(&mut *secret_bytes) + .map_err(|_| TransportV2Error::RandomnessUnavailable)?; + let client_secret = StaticSecret::from(*secret_bytes); + let client_public_key = PublicKey::from(&client_secret); + let request = KeyExchangeRequest { + nonce: &nonce, + client_public_key: EncodedBytes::from_bytes(client_public_key.as_bytes().to_vec()), + }; + let request_body = + serde_json::to_vec(&request).map_err(|_| TransportV2Error::InvalidJson)?; + if request_body.len() > MAX_KEY_EXCHANGE_BYTES { + return Err(TransportV2Error::LimitExceeded { + field: "key exchange", + limit: MAX_KEY_EXCHANGE_BYTES, + }); + } + + Ok(Self { + request_body, + completion: KeyExchangeCompletion { + client_secret, + enclave_public_key: PublicKey::from(enclave_public_key), + }, + }) + } + + pub(super) fn into_parts(self) -> (Vec, KeyExchangeCompletion) { + (self.request_body, self.completion) + } +} + +impl fmt::Debug for PreparedKeyExchange { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PreparedKeyExchange") + .field("request_body_bytes", &self.request_body.len()) + .field("completion", &"[REDACTED]") + .finish() + } +} + +/// Consumed capability that turns one authenticated key-exchange response into +/// a v2 session. +pub(super) struct KeyExchangeCompletion { + client_secret: StaticSecret, + enclave_public_key: PublicKey, +} + +impl KeyExchangeCompletion { + pub(super) fn complete(self, response_body: &[u8]) -> Result { + if response_body.len() > MAX_KEY_EXCHANGE_BYTES { + return Err(TransportV2Error::LimitExceeded { + field: "key exchange", + limit: MAX_KEY_EXCHANGE_BYTES, + }); + } + let response: KeyExchangeResponse = + serde_json::from_slice(response_body).map_err(|_| TransportV2Error::InvalidJson)?; + + let outer_session_id = parse_canonical_session_id(&response.session_id)?; + let shared_secret = self.client_secret.diffie_hellman(&self.enclave_public_key); + if !shared_secret.was_contributory() { + return Err(TransportV2Error::NonContributoryKeyExchange); + } + let payload = decrypt_key_exchange_record( + shared_secret.as_bytes(), + response.encrypted_session_key.as_slice(), + )?; + if payload.session_id != outer_session_id { + return Err(TransportV2Error::BindingMismatch); + } + + V2Session::from_parts( + outer_session_id, + payload.session_master, + payload.expires_at_unix_seconds, + ) + } + + #[cfg(test)] + pub(super) fn from_parts_for_test( + client_secret: [u8; KEY_LEN], + enclave_public_key: [u8; KEY_LEN], + ) -> Self { + Self { + client_secret: StaticSecret::from(client_secret), + enclave_public_key: PublicKey::from(enclave_public_key), + } + } +} + +impl fmt::Debug for KeyExchangeCompletion { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("KeyExchangeCompletion([REDACTED])") + } +} + +fn parse_canonical_session_id(encoded: &str) -> Result { + let session_id = Uuid::parse_str(encoded).map_err(|_| TransportV2Error::InvalidKeyExchange)?; + if session_id.hyphenated().to_string() != encoded { + return Err(TransportV2Error::InvalidKeyExchange); + } + Ok(session_id) +} + +/// Exact crypto context for one attested transport-v2 session. +pub(super) struct V2Session { + session_id: Uuid, + expires_at_unix_seconds: u64, + keys: Arc, + usage: Arc, +} + +struct RequestMaterial { + request_id: RequestId, +} + +struct RequestUsage { + records: usize, + request_ids: HashSet, +} + +pub(super) struct SessionUsage { + request: Mutex, + response_records: AtomicUsize, + request_limit: usize, + response_limit: usize, +} + +impl SessionUsage { + pub(super) fn new(request_limit: usize, response_limit: usize) -> Self { + Self { + request: Mutex::new(RequestUsage { + records: 0, + request_ids: HashSet::new(), + }), + response_records: AtomicUsize::new(0), + request_limit, + response_limit, + } + } + + fn reserve_random_request(&self) -> Result { + let mut request = self + .request + .lock() + .map_err(|_| TransportV2Error::SessionStateUnavailable)?; + if request.records >= self.request_limit { + return Err(TransportV2Error::RequestRecordBudgetExhausted); + } + for _ in 0..MAX_REQUEST_ID_GENERATION_ATTEMPTS { + let request_id = RequestId::random()?; + if request.request_ids.insert(request_id) { + request.records += 1; + return Ok(request_id); + } + } + Err(TransportV2Error::RequestIdCollision) + } + + #[cfg(test)] + fn reserve_fixed_request(&self, request_id: RequestId) -> Result<()> { + let mut request = self + .request + .lock() + .map_err(|_| TransportV2Error::SessionStateUnavailable)?; + if request.records >= self.request_limit { + return Err(TransportV2Error::RequestRecordBudgetExhausted); + } + if !request.request_ids.insert(request_id) { + return Err(TransportV2Error::RequestIdCollision); + } + request.records += 1; + Ok(()) + } + + fn release_request(&self, request_id: RequestId) { + if let Ok(mut request) = self.request.lock() { + if request.request_ids.remove(&request_id) { + request.records = request.records.saturating_sub(1); + } + } + } + + fn reserve_response_records(&self, count: usize) -> Result<()> { + self.response_records + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |records| { + records + .checked_add(count) + .filter(|reserved| *reserved <= self.response_limit) + }) + .map(|_| ()) + .map_err(|_| TransportV2Error::ResponseRecordBudgetExhausted) + } + + fn release_response_records(&self, count: usize) { + let released = + self.response_records + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |records| { + records.checked_sub(count) + }); + debug_assert!(released.is_ok(), "response record accounting underflow"); + } + + fn reserve_initial_response(&self, response_mode: ResponseMode) -> Result { + let records = match response_mode { + ResponseMode::Unary => 1, + ResponseMode::Stream => 2, + ResponseMode::Auto => return Err(TransportV2Error::InvalidRequest), + }; + self.reserve_response_records(records)?; + Ok(records) + } + + pub(super) fn reserve_stream_chunk(&self) -> Result<()> { + self.reserve_response_records(1) + } +} + +impl V2Session { + fn from_parts( + session_id: Uuid, + session_master: SessionMaster, + expires_at_unix_seconds: u64, + ) -> Result { + Self::from_parts_with_budgets( + session_id, + session_master, + expires_at_unix_seconds, + MAX_REQUEST_RECORDS, + MAX_RESPONSE_RECORDS, + ) + } + + fn from_parts_with_budgets( + session_id: Uuid, + session_master: SessionMaster, + expires_at_unix_seconds: u64, + request_limit: usize, + response_limit: usize, + ) -> Result { + let keys = Arc::new(DirectionalKeys::derive(&session_master)?); + Ok(Self { + session_id, + expires_at_unix_seconds, + keys, + usage: Arc::new(SessionUsage::new(request_limit, response_limit)), + }) + } + + #[cfg(test)] + pub(super) fn from_master_for_test( + session_id: Uuid, + session_master: [u8; KEY_LEN], + expires_at_unix_seconds: u64, + ) -> Result { + Self::from_parts( + session_id, + SessionMaster::from_bytes(session_master), + expires_at_unix_seconds, + ) + } + + #[cfg(test)] + pub(super) fn from_master_with_budgets_for_test( + session_id: Uuid, + session_master: [u8; KEY_LEN], + expires_at_unix_seconds: u64, + request_limit: usize, + response_limit: usize, + ) -> Result { + Self::from_parts_with_budgets( + session_id, + SessionMaster::from_bytes(session_master), + expires_at_unix_seconds, + request_limit, + response_limit, + ) + } + + pub(super) const fn session_id(&self) -> Uuid { + self.session_id + } + + pub(super) const fn expires_at_unix_seconds(&self) -> u64 { + self.expires_at_unix_seconds + } + + pub(super) fn prepare_request( + &self, + response_mode: ResponseMode, + credential: Option, + cache_namespace_root: Option, + request: LogicalRequest, + ) -> Result { + let now_unix_seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| TransportV2Error::SessionExpired)? + .as_secs(); + self.validate_request_start(now_unix_seconds, response_mode)?; + let request_id = self.usage.reserve_random_request()?; + let initial_response_records = match self.usage.reserve_initial_response(response_mode) { + Ok(records) => records, + Err(error) => { + self.usage.release_request(request_id); + return Err(error); + } + }; + let result = self.prepare_reserved_request( + RequestMaterial { request_id }, + response_mode, + credential, + cache_namespace_root, + request, + ); + if result.is_err() { + self.usage.release_request(request_id); + self.usage + .release_response_records(initial_response_records); + } + result + } + + fn validate_request_start( + &self, + now_unix_seconds: u64, + response_mode: ResponseMode, + ) -> Result<()> { + if now_unix_seconds >= self.expires_at_unix_seconds { + return Err(TransportV2Error::SessionExpired); + } + if response_mode == ResponseMode::Auto { + return Err(TransportV2Error::InvalidRequest); + } + Ok(()) + } + + fn prepare_reserved_request( + &self, + material: RequestMaterial, + response_mode: ResponseMode, + credential: Option, + cache_namespace_root: Option, + request: LogicalRequest, + ) -> Result { + let envelope = RequestEnvelope { + version: Version2, + request_id: material.request_id, + response_mode, + credential, + cache_namespace_root_base64: cache_namespace_root, + request, + }; + let plaintext = Zeroizing::new(envelope.to_json_vec(&EnvelopeLimits::DEFAULT)?); + let encrypted = self + .keys + .encrypt_request_record(&self.session_id, &plaintext)?; + let outer_body = EncryptedOuterRecord { + encrypted: EncodedBytes::from_bytes(encrypted), + } + .to_json_vec(MAX_OUTER_REQUEST_BYTES)?; + + Ok(PreparedRequest { + session_id: self.session_id, + request_id: material.request_id, + response_mode, + outer_body, + response: ResponseContext { + session_id: self.session_id, + request_id: material.request_id, + response_mode, + keys: Arc::clone(&self.keys), + usage: Arc::clone(&self.usage), + }, + }) + } + + #[cfg(test)] + pub(super) fn prepare_request_for_test( + &self, + material: (u64, RequestId), + response_mode: ResponseMode, + credential: Option, + cache_namespace_root: Option, + request: LogicalRequest, + ) -> Result { + let (now_unix_seconds, request_id) = material; + self.validate_request_start(now_unix_seconds, response_mode)?; + self.usage.reserve_fixed_request(request_id)?; + let initial_response_records = match self.usage.reserve_initial_response(response_mode) { + Ok(records) => records, + Err(error) => { + self.usage.release_request(request_id); + return Err(error); + } + }; + let result = self.prepare_reserved_request( + RequestMaterial { request_id }, + response_mode, + credential, + cache_namespace_root, + request, + ); + if result.is_err() { + self.usage.release_request(request_id); + self.usage + .release_response_records(initial_response_records); + } + result + } +} + +impl fmt::Debug for V2Session { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("V2Session") + .field("session_id", &self.session_id) + .field("expires_at_unix_seconds", &self.expires_at_unix_seconds) + .field("keys", &"[REDACTED]") + .finish() + } +} + +/// One encrypted request body and its exact-session response capability. +/// +/// This type is deliberately not `Clone`. Consuming it into parts gives a +/// network adapter one body to send and one context with which to authenticate +/// the response; there is no retry or fallback behavior in this engine. +pub(super) struct PreparedRequest { + session_id: Uuid, + request_id: RequestId, + response_mode: ResponseMode, + outer_body: Vec, + response: ResponseContext, +} + +impl PreparedRequest { + pub(super) const fn session_id(&self) -> Uuid { + self.session_id + } + + pub(super) const fn request_id(&self) -> RequestId { + self.request_id + } + + pub(super) const fn response_mode(&self) -> ResponseMode { + self.response_mode + } + + pub(super) fn into_parts(self) -> (Vec, ResponseContext) { + (self.outer_body, self.response) + } +} + +impl fmt::Debug for PreparedRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PreparedRequest") + .field("session_id", &self.session_id) + .field("request_id", &self.request_id) + .field("response_mode", &self.response_mode) + .field("outer_body_bytes", &self.outer_body.len()) + .field("response", &"[BOUND]") + .finish() + } +} + +/// Response authenticator bound to the exact session and request that created +/// it. It cannot be rebound by looking up an attacker-controlled outer UUID. +pub(super) struct ResponseContext { + session_id: Uuid, + request_id: RequestId, + response_mode: ResponseMode, + keys: Arc, + usage: Arc, +} + +impl ResponseContext { + pub(super) fn decrypt_unary_outer(self, outer_body: &[u8]) -> Result { + if self.response_mode != ResponseMode::Unary { + return Err(TransportV2Error::ResponseModeMismatch); + } + self.decrypt_unary_envelope(outer_body) + } + + /// Authenticate an HTTP error returned before a requested stream starts. + /// + /// Successful stream responses must use authenticated stream records. The + /// gateway may only use this unary envelope shape for a pre-Start 4xx/5xx. + pub(super) fn decrypt_stream_pre_start_error_outer( + self, + outer_body: &[u8], + ) -> Result { + if self.response_mode != ResponseMode::Stream { + return Err(TransportV2Error::ResponseModeMismatch); + } + let response = self.decrypt_unary_envelope(outer_body)?; + if !(400..=599).contains(&response.status) { + return Err(TransportV2Error::InvalidResponse); + } + // A requested stream reserves Start and terminal capacity before its + // request record is emitted. An authenticated pre-Start unary error + // proves that only one of those two records was used. + self.usage.release_response_records(1); + Ok(response) + } + + fn decrypt_unary_envelope(&self, outer_body: &[u8]) -> Result { + let outer = EncryptedOuterRecord::from_json_slice(outer_body, MAX_OUTER_REQUEST_BYTES)?; + let encrypted_limit = EnvelopeLimits::DEFAULT + .envelope_bytes + .checked_add(MIN_RECORD_LEN) + .ok_or(TransportV2Error::InvalidResponse)?; + if outer.encrypted.len() > encrypted_limit { + return Err(TransportV2Error::LimitExceeded { + field: "encrypted response", + limit: encrypted_limit, + }); + } + let plaintext = Zeroizing::new(self.keys.decrypt_unary_response_record( + &self.session_id, + &self.request_id, + outer.encrypted.as_slice(), + )?); + let response = + UnaryResponseEnvelope::from_json_slice(&plaintext, &EnvelopeLimits::DEFAULT)?; + if response.request_id != self.request_id { + return Err(TransportV2Error::BindingMismatch); + } + + Ok(UnaryResponse { + status: response.status, + headers: response.headers, + body: response.body_base64.map(EncodedBytes::into_bytes), + }) + } + + pub(super) fn into_stream_decoder(self) -> Result { + if self.response_mode != ResponseMode::Stream { + return Err(TransportV2Error::ResponseModeMismatch); + } + Ok(StreamDecoder::new( + self.session_id, + self.request_id, + self.keys, + self.usage, + )) + } +} + +impl fmt::Debug for ResponseContext { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ResponseContext") + .field("session_id", &self.session_id) + .field("request_id", &self.request_id) + .field("response_mode", &self.response_mode) + .field("keys", &"[REDACTED]") + .field("usage", &"[BOUND]") + .finish() + } +} + +#[derive(Eq, PartialEq)] +pub(super) struct UnaryResponse { + pub(super) status: u16, + pub(super) headers: Vec, + pub(super) body: Option>, +} + +impl fmt::Debug for UnaryResponse { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("UnaryResponse") + .field("status", &self.status) + .field("header_count", &self.headers.len()) + .field( + "body_bytes", + &self.body.as_ref().map_or(0, std::vec::Vec::len), + ) + .finish() + } +} diff --git a/sdk/rust/src/transport_v2/stream.rs b/sdk/rust/src/transport_v2/stream.rs new file mode 100644 index 000000000..f4fdc28f1 --- /dev/null +++ b/sdk/rust/src/transport_v2/stream.rs @@ -0,0 +1,294 @@ +use std::{mem, sync::Arc}; + +use uuid::Uuid; +use zeroize::Zeroizing; + +use super::{ + crypto::{decode_canonical_base64, DirectionalKeys, MIN_RECORD_LEN}, + envelope::{ + EnvelopeLimits, HeaderField, RequestId, StreamRecord, MAX_STREAM_CHUNK_BYTES, + MAX_STREAM_ERROR_BYTES, + }, + session::SessionUsage, + Result, TransportV2Error, +}; + +// Valid start records are bounded by 64 headers and 64 KiB of decoded +// aggregate header bytes; valid chunk and error records are smaller. This +// ceiling therefore accepts every protocol-valid record while preventing a +// malicious carrier from buffering anywhere near the 50 MiB unary ceiling. +const MAX_STREAM_PLAINTEXT_BYTES: usize = 128 * 1024; +const MAX_STREAM_ENCRYPTED_BYTES: usize = MAX_STREAM_PLAINTEXT_BYTES + MIN_RECORD_LEN; +const MAX_STREAM_BASE64_BYTES: usize = 4 * MAX_STREAM_ENCRYPTED_BYTES.div_ceil(3); +const MAX_STREAM_CARRIER_FRAME_BYTES: usize = b"data: ".len() + MAX_STREAM_BASE64_BYTES + 2; +const MAX_LOGICAL_STREAM_BYTES: usize = 64 * 1024 * 1024; + +#[derive(Eq, PartialEq)] +pub(super) enum StreamEvent { + Start { + status: u16, + headers: Vec, + }, + Chunk(Vec), + End, + Error { + status: u16, + body: Vec, + }, +} + +impl std::fmt::Debug for StreamEvent { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Start { status, headers } => formatter + .debug_struct("Start") + .field("status", status) + .field("header_count", &headers.len()) + .finish(), + Self::Chunk(body) => formatter + .debug_struct("Chunk") + .field("body_bytes", &body.len()) + .finish(), + Self::End => formatter.write_str("End"), + Self::Error { status, body } => formatter + .debug_struct("Error") + .field("status", status) + .field("body_bytes", &body.len()) + .finish(), + } + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +enum StreamState { + AwaitingStart, + Open { next_sequence: u64 }, + Terminal, + Failed, +} + +/// Incremental decoder for the exact authenticated outer SSE carrier. +/// +/// It owns the response keys and request binding from the admitted request; +/// callers cannot substitute an outer session identifier. `finish` must +/// succeed before the outer carrier is considered complete. +pub(super) struct StreamDecoder { + session_id: Uuid, + request_id: RequestId, + keys: Arc, + usage: Arc, + state: StreamState, + carrier_buffer: Vec, + logical_chunk_bytes: usize, + logical_chunk_limit: usize, +} + +impl StreamDecoder { + pub(super) fn new( + session_id: Uuid, + request_id: RequestId, + keys: Arc, + usage: Arc, + ) -> Self { + Self { + session_id, + request_id, + keys, + usage, + state: StreamState::AwaitingStart, + carrier_buffer: Vec::new(), + logical_chunk_bytes: 0, + logical_chunk_limit: MAX_LOGICAL_STREAM_BYTES, + } + } + + #[cfg(test)] + pub(super) fn new_with_logical_limit( + session_id: Uuid, + request_id: RequestId, + keys: Arc, + logical_chunk_limit: usize, + ) -> Self { + Self { + session_id, + request_id, + keys, + usage: Arc::new(SessionUsage::new(usize::MAX, usize::MAX)), + state: StreamState::AwaitingStart, + carrier_buffer: Vec::new(), + logical_chunk_bytes: 0, + logical_chunk_limit, + } + } + + /// Add arbitrary HTTP-body bytes. Frames may be split across calls or + /// coalesced in one call, but each completed frame must be exactly + /// `data: \n\n`. + pub(super) fn push(&mut self, input: &[u8]) -> Result> { + if self.state == StreamState::Failed { + return Err(TransportV2Error::InvalidStreamRecord); + } + if self.state == StreamState::Terminal && !input.is_empty() { + self.state = StreamState::Failed; + return Err(TransportV2Error::StreamAlreadyTerminal); + } + + let mut events = Vec::new(); + for byte in input { + if *byte == b'\r' { + self.state = StreamState::Failed; + return Err(TransportV2Error::InvalidStreamFraming); + } + if self.state == StreamState::Terminal { + self.state = StreamState::Failed; + return Err(TransportV2Error::StreamAlreadyTerminal); + } + if self.carrier_buffer.len() == MAX_STREAM_CARRIER_FRAME_BYTES { + self.state = StreamState::Failed; + return Err(TransportV2Error::LimitExceeded { + field: "stream carrier frame", + limit: MAX_STREAM_CARRIER_FRAME_BYTES, + }); + } + + self.carrier_buffer.push(*byte); + if self.carrier_buffer.ends_with(b"\n\n") { + let frame = mem::take(&mut self.carrier_buffer); + match self.decode_frame(&frame) { + Ok(event) => events.push(event), + Err(error) => { + self.state = StreamState::Failed; + return Err(error); + } + } + } + } + Ok(events) + } + + /// Validate authenticated terminal delivery and an exact carrier boundary. + pub(super) fn finish(mut self) -> Result<()> { + if !self.carrier_buffer.is_empty() || self.state != StreamState::Terminal { + self.state = StreamState::Failed; + return Err(TransportV2Error::TruncatedStream); + } + Ok(()) + } + + fn decode_frame(&mut self, frame: &[u8]) -> Result { + let payload = frame + .strip_prefix(b"data: ") + .and_then(|frame| frame.strip_suffix(b"\n\n")) + .ok_or(TransportV2Error::InvalidStreamFraming)?; + if payload.is_empty() || payload.iter().any(|byte| matches!(byte, b'\r' | b'\n')) { + return Err(TransportV2Error::InvalidStreamFraming); + } + let encoded = + std::str::from_utf8(payload).map_err(|_| TransportV2Error::InvalidStreamFraming)?; + let encrypted = decode_canonical_base64(encoded, MAX_STREAM_ENCRYPTED_BYTES)?; + + let expected_sequence = match self.state { + StreamState::AwaitingStart => 0, + StreamState::Open { next_sequence } => next_sequence, + StreamState::Terminal => return Err(TransportV2Error::StreamAlreadyTerminal), + StreamState::Failed => return Err(TransportV2Error::InvalidStreamRecord), + }; + let plaintext = Zeroizing::new(self.keys.decrypt_stream_response_record( + &self.session_id, + &self.request_id, + expected_sequence, + &encrypted, + )?); + if plaintext.len() > MAX_STREAM_PLAINTEXT_BYTES { + return Err(TransportV2Error::LimitExceeded { + field: "stream record", + limit: MAX_STREAM_PLAINTEXT_BYTES, + }); + } + let record = StreamRecord::from_json_slice(&plaintext, &EnvelopeLimits::DEFAULT)?; + if record.request_id() != &self.request_id || record.sequence() != expected_sequence { + return Err(TransportV2Error::BindingMismatch); + } + if matches!(&record, StreamRecord::Chunk { .. }) { + // Start and terminal capacity were reserved atomically when the + // request was prepared. Only application chunks charge additional + // response capacity as they arrive. + self.usage.reserve_stream_chunk()?; + } + + match (&self.state, record) { + ( + StreamState::AwaitingStart, + StreamRecord::Start { + status, headers, .. + }, + ) => { + self.state = StreamState::Open { next_sequence: 1 }; + Ok(StreamEvent::Start { status, headers }) + } + (StreamState::Open { next_sequence }, StreamRecord::Chunk { body_base64, .. }) => { + debug_assert!(body_base64.len() <= MAX_STREAM_CHUNK_BYTES); + let logical_chunk_bytes = self + .logical_chunk_bytes + .checked_add(body_base64.len()) + .ok_or(TransportV2Error::LimitExceeded { + field: "logical stream", + limit: self.logical_chunk_limit, + })?; + if logical_chunk_bytes > self.logical_chunk_limit { + return Err(TransportV2Error::LimitExceeded { + field: "logical stream", + limit: self.logical_chunk_limit, + }); + } + let next_sequence = next_sequence + .checked_add(1) + .ok_or(TransportV2Error::InvalidStreamRecord)?; + self.logical_chunk_bytes = logical_chunk_bytes; + self.state = StreamState::Open { next_sequence }; + Ok(StreamEvent::Chunk(body_base64.into_bytes())) + } + (StreamState::Open { .. }, StreamRecord::End { .. }) => { + self.state = StreamState::Terminal; + Ok(StreamEvent::End) + } + ( + StreamState::Open { .. }, + StreamRecord::Error { + status, + body_base64, + .. + }, + ) => { + debug_assert!(body_base64.len() <= MAX_STREAM_ERROR_BYTES); + self.state = StreamState::Terminal; + Ok(StreamEvent::Error { + status, + body: body_base64.into_bytes(), + }) + } + _ => Err(TransportV2Error::InvalidStreamRecord), + } + } +} + +impl std::fmt::Debug for StreamDecoder { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("StreamDecoder") + .field("session_id", &self.session_id) + .field("request_id", &self.request_id) + .field("keys", &"[REDACTED]") + .field("usage", &"[BOUND]") + .field("state", &self.state) + .field("carrier_buffer_bytes", &self.carrier_buffer.len()) + .field("logical_chunk_bytes", &self.logical_chunk_bytes) + .field("logical_chunk_limit", &self.logical_chunk_limit) + .finish() + } +} + +#[cfg(test)] +pub(super) const fn max_stream_carrier_frame_bytes_for_test() -> usize { + MAX_STREAM_CARRIER_FRAME_BYTES +} diff --git a/sdk/rust/src/transport_v2/tests.rs b/sdk/rust/src/transport_v2/tests.rs new file mode 100644 index 000000000..95ae9fa63 --- /dev/null +++ b/sdk/rust/src/transport_v2/tests.rs @@ -0,0 +1,1573 @@ +use std::{ + sync::{Arc, Barrier}, + thread, +}; + +use serde::Deserialize; +use serde_json::json; +use uuid::Uuid; +use x25519_dalek::{PublicKey, StaticSecret}; + +use super::{ + crypto::{ + decode_canonical_base64, decrypt_key_exchange_record, derive_handshake_key_for_test, + encode_canonical_base64, encrypt_key_exchange_record_for_test, + encrypt_key_exchange_record_with_nonce, request_record_aad, stream_response_record_aad, + unary_response_record_aad, DirectionalKeys, SessionMaster, MIN_RECORD_LEN, + }, + envelope::{ + encode_canonical_opaque_path_segment, CacheNamespaceRoot, Credential, EncodedBytes, + EncryptedOuterRecord, EnvelopeLimits, HeaderField, LogicalMethod, LogicalRequest, + RequestEnvelope, RequestId, ResponseMode, StreamRecord, UnaryResponseEnvelope, Version2, + MAX_OUTER_REQUEST_BYTES, MAX_STREAM_CHUNK_BYTES, + }, + session::{KeyExchangeCompletion, PreparedKeyExchange, V2Session}, + stream::{max_stream_carrier_frame_bytes_for_test, StreamDecoder, StreamEvent}, + TransportV2Error, +}; + +#[derive(Deserialize)] +struct GoldenVectors { + fixture_version: u8, + protocol_version: u8, + shared_secret_hex: String, + session_master_hex: String, + session_id: String, + session_id_hex: String, + expires_at_unix_seconds: u64, + request_id_hex: String, + stream_sequence: u64, + handshake: HandshakeVector, + request: DirectionalVector, + unary_response: DirectionalVector, + stream_response: RecordVector, + request_without_body_json: String, + request_with_empty_body_json: String, +} + +#[derive(Deserialize)] +struct HandshakeVector { + info_utf8: String, + derived_key_hex: String, + aad_hex: String, + nonce_hex: String, + plaintext_hex: String, + record_hex: String, + record_base64: String, +} + +#[derive(Deserialize)] +struct DirectionalVector { + info_utf8: String, + derived_key_hex: String, + aad_hex: String, + nonce_hex: String, + plaintext_utf8: String, + plaintext_hex: String, + record_hex: String, + record_base64: String, +} + +#[derive(Deserialize)] +struct RecordVector { + aad_hex: String, + nonce_hex: String, + plaintext_utf8: String, + plaintext_hex: String, + record_hex: String, + record_base64: String, +} + +fn vectors() -> GoldenVectors { + serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/testdata/transport-v2-golden-vectors.json" + ))) + .expect("shared transport-v2 fixture") +} + +#[test] +fn package_fixture_matches_the_shared_sdk_fixture() { + let package_fixture = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/testdata/transport-v2-golden-vectors.json" + )); + let shared_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../testdata/transport-v2-golden-vectors.json"); + + // The shared cross-language fixture is intentionally outside the Cargo + // package. Compare it byte-for-byte in the repository while keeping the + // published crate independently testable when that parent file is absent. + if let Ok(shared_fixture) = std::fs::read(shared_path) { + assert_eq!(package_fixture.as_slice(), shared_fixture.as_slice()); + } +} + +fn fixed_hex(encoded: &str) -> [u8; N] { + hex::decode(encoded) + .expect("fixture hex") + .try_into() + .expect("fixture fixed length") +} + +fn assert_record(record: &[u8], expected_hex: &str, expected_base64: &str) { + assert_eq!( + record, + hex::decode(expected_hex).expect("fixture record hex") + ); + assert_eq!(encode_canonical_base64(record), expected_base64); + assert_eq!( + decode_canonical_base64(expected_base64, record.len()).expect("canonical fixture base64"), + record + ); +} + +#[test] +fn shared_golden_vectors_fix_all_keys_aad_and_records() { + let fixture = vectors(); + assert_eq!(fixture.fixture_version, 1); + assert_eq!(fixture.protocol_version, Version2::VALUE); + assert_eq!( + fixture.handshake.info_utf8, + "opensecret/transport-v2/handshake-key" + ); + assert_eq!( + fixture.request.info_utf8, + "opensecret/transport-v2/client-request" + ); + assert_eq!( + fixture.unary_response.info_utf8, + "opensecret/transport-v2/enclave-response" + ); + + let shared_secret = fixed_hex::<32>(&fixture.shared_secret_hex); + let session_master_bytes = fixed_hex::<32>(&fixture.session_master_hex); + let session_id = Uuid::parse_str(&fixture.session_id).expect("fixture session ID"); + assert_eq!( + session_id.as_bytes(), + &fixed_hex::<16>(&fixture.session_id_hex) + ); + let request_id = RequestId::from_bytes(fixed_hex(&fixture.request_id_hex)); + + assert_eq!( + derive_handshake_key_for_test(&shared_secret).expect("handshake key"), + fixed_hex::<32>(&fixture.handshake.derived_key_hex) + ); + assert_eq!( + b"opensecret/transport-v2/key-exchange", + hex::decode(&fixture.handshake.aad_hex) + .expect("fixture handshake AAD") + .as_slice() + ); + let handshake_plaintext = hex::decode(&fixture.handshake.plaintext_hex).expect("handshake"); + let handshake_record = encrypt_key_exchange_record_with_nonce( + &shared_secret, + &handshake_plaintext, + fixed_hex(&fixture.handshake.nonce_hex), + ) + .expect("encrypt fixture handshake"); + assert_record( + &handshake_record, + &fixture.handshake.record_hex, + &fixture.handshake.record_base64, + ); + let handshake = + decrypt_key_exchange_record(&shared_secret, &handshake_record).expect("decrypt handshake"); + assert_eq!(handshake.session_id, session_id); + assert_eq!( + handshake.expires_at_unix_seconds, + fixture.expires_at_unix_seconds + ); + + let master = SessionMaster::from_bytes(session_master_bytes); + let keys = DirectionalKeys::derive(&master).expect("directional keys"); + assert_eq!( + keys.request_key_bytes(), + &fixed_hex::<32>(&fixture.request.derived_key_hex) + ); + assert_eq!( + keys.response_key_bytes(), + &fixed_hex::<32>(&fixture.unary_response.derived_key_hex) + ); + + assert_eq!( + request_record_aad(&session_id), + hex::decode(&fixture.request.aad_hex).expect("request AAD") + ); + assert_eq!( + fixture.request.plaintext_utf8.as_bytes(), + hex::decode(&fixture.request.plaintext_hex) + .expect("request plaintext") + .as_slice() + ); + let request_record = keys + .encrypt_request_record_with_nonce( + &session_id, + fixture.request.plaintext_utf8.as_bytes(), + fixed_hex(&fixture.request.nonce_hex), + ) + .expect("request record"); + assert_record( + &request_record, + &fixture.request.record_hex, + &fixture.request.record_base64, + ); + + assert_eq!( + unary_response_record_aad(&session_id, &request_id), + hex::decode(&fixture.unary_response.aad_hex).expect("unary AAD") + ); + let unary_record = hex::decode(&fixture.unary_response.record_hex).expect("unary record"); + assert_record( + &unary_record, + &fixture.unary_response.record_hex, + &fixture.unary_response.record_base64, + ); + let unary_plaintext = keys + .decrypt_unary_response_record(&session_id, &request_id, &unary_record) + .expect("unary response"); + assert_eq!( + unary_plaintext, + fixture.unary_response.plaintext_utf8.as_bytes() + ); + assert_eq!( + unary_plaintext, + hex::decode(&fixture.unary_response.plaintext_hex).expect("unary plaintext") + ); + + assert_eq!( + stream_response_record_aad(&session_id, &request_id, fixture.stream_sequence), + hex::decode(&fixture.stream_response.aad_hex).expect("stream AAD") + ); + let stream_record = hex::decode(&fixture.stream_response.record_hex).expect("stream record"); + assert_record( + &stream_record, + &fixture.stream_response.record_hex, + &fixture.stream_response.record_base64, + ); + let stream_plaintext = keys + .decrypt_stream_response_record( + &session_id, + &request_id, + fixture.stream_sequence, + &stream_record, + ) + .expect("stream response"); + assert_eq!( + stream_plaintext, + fixture.stream_response.plaintext_utf8.as_bytes() + ); + assert_eq!( + stream_plaintext, + hex::decode(&fixture.stream_response.plaintext_hex).expect("stream plaintext") + ); +} + +#[test] +fn records_fail_closed_for_wrong_direction_or_binding() { + let fixture = vectors(); + let master = SessionMaster::from_bytes(fixed_hex(&fixture.session_master_hex)); + let keys = DirectionalKeys::derive(&master).expect("keys"); + let session_id = Uuid::parse_str(&fixture.session_id).expect("session ID"); + let request_id = RequestId::from_bytes(fixed_hex(&fixture.request_id_hex)); + let other_session = Uuid::from_bytes([0x55; 16]); + let other_request = RequestId::from_bytes([0x66; 16]); + + let request_record = hex::decode(&fixture.request.record_hex).expect("request record"); + assert_eq!( + keys.decrypt_unary_response_record(&session_id, &request_id, &request_record), + Err(TransportV2Error::AuthenticationFailed) + ); + + let unary_record = hex::decode(&fixture.unary_response.record_hex).expect("unary record"); + assert_eq!( + keys.decrypt_unary_response_record(&other_session, &request_id, &unary_record), + Err(TransportV2Error::AuthenticationFailed) + ); + assert_eq!( + keys.decrypt_unary_response_record(&session_id, &other_request, &unary_record), + Err(TransportV2Error::AuthenticationFailed) + ); + + let stream_record = hex::decode(&fixture.stream_response.record_hex).expect("stream record"); + assert_eq!( + keys.decrypt_stream_response_record( + &session_id, + &request_id, + fixture.stream_sequence + 1, + &stream_record, + ), + Err(TransportV2Error::AuthenticationFailed) + ); + + let mut tampered = stream_record; + *tampered.last_mut().expect("tag") ^= 1; + assert_eq!( + keys.decrypt_stream_response_record( + &session_id, + &request_id, + fixture.stream_sequence, + &tampered, + ), + Err(TransportV2Error::AuthenticationFailed) + ); + assert_eq!( + keys.decrypt_unary_response_record(&session_id, &request_id, &[0; MIN_RECORD_LEN - 1]), + Err(TransportV2Error::RecordTooShort) + ); + assert_eq!( + decrypt_key_exchange_record( + &[0; 32], + &hex::decode(&fixture.handshake.record_hex).expect("handshake record"), + ) + .expect_err("all-zero shared secret"), + TransportV2Error::NonContributoryKeyExchange + ); +} + +#[test] +fn shared_envelope_vectors_preserve_canonical_json_and_body_presence() { + let fixture = vectors(); + let without_body = RequestEnvelope::from_json_slice( + fixture.request_without_body_json.as_bytes(), + &EnvelopeLimits::DEFAULT, + ) + .expect("request without body"); + assert_eq!(without_body.response_mode, ResponseMode::Unary); + assert_eq!(without_body.request.method, LogicalMethod::Get); + assert!(without_body.request.body_base64.is_none()); + assert_eq!( + without_body + .to_json_vec(&EnvelopeLimits::DEFAULT) + .expect("serialize request"), + fixture.request_without_body_json.as_bytes() + ); + + let with_empty_body = RequestEnvelope::from_json_slice( + fixture.request_with_empty_body_json.as_bytes(), + &EnvelopeLimits::DEFAULT, + ) + .expect("request with empty body"); + assert_eq!(with_empty_body.request.method, LogicalMethod::Post); + assert!(with_empty_body + .request + .body_base64 + .as_ref() + .expect("present body") + .is_empty()); + assert_eq!( + with_empty_body + .to_json_vec(&EnvelopeLimits::DEFAULT) + .expect("serialize request"), + fixture.request_with_empty_body_json.as_bytes() + ); +} + +#[test] +fn credentials_and_cache_root_use_exact_non_null_wire_shapes() { + let request_id = RequestId::from_bytes([0x31; 16]); + let cache_root = [0x32; 32]; + let cache_root_base64 = encode_canonical_base64(&cache_root); + + for (credential, kind, value) in [ + ( + Credential::api_key(b"api-key".to_vec()), + "api_key", + b"api-key".as_slice(), + ), + ( + Credential::resumption(b"resumption-token".to_vec()), + "resumption", + b"resumption-token".as_slice(), + ), + ] { + let envelope = RequestEnvelope { + version: Version2, + request_id, + response_mode: ResponseMode::Unary, + credential: Some(credential), + cache_namespace_root_base64: Some(CacheNamespaceRoot::from_bytes(cache_root)), + request: test_request(ResponseMode::Unary), + }; + let encoded = envelope + .to_json_vec(&EnvelopeLimits::DEFAULT) + .expect("envelope"); + let value_json: serde_json::Value = serde_json::from_slice(&encoded).expect("JSON"); + assert_eq!(value_json["credential"]["kind"], kind); + assert_eq!( + value_json["credential"]["value_base64"], + encode_canonical_base64(value) + ); + assert_eq!(value_json["cache_namespace_root_base64"], cache_root_base64); + RequestEnvelope::from_json_slice(&encoded, &EnvelopeLimits::DEFAULT) + .expect("strict round trip"); + } +} + +#[test] +fn opaque_item_paths_preserve_released_rust_sdk_encoding_and_are_method_aware() { + for (value, encoded) in [ + ("simple123", "simple123"), + ("key/part", "key%2Fpart"), + ("key\\part", "key%5Cpart"), + (".", "%2E"), + ("..", "%2E%2E"), + ("%2F", "%252F"), + ("café", "caf%C3%A9"), + ("🔐", "%F0%9F%94%90"), + ] { + assert_eq!(encode_canonical_opaque_path_segment(value), encoded); + for method in [ + LogicalMethod::Get, + LogicalMethod::Put, + LogicalMethod::Delete, + ] { + LogicalRequest::new( + method, + format!("/protected/kv/{encoded}"), + None, + vec![], + None, + ) + .validate(&EnvelopeLimits::DEFAULT) + .expect("canonical KV item path"); + } + } + + LogicalRequest::new( + LogicalMethod::Delete, + "/protected/api-keys/Production%20Key", + None, + vec![], + None, + ) + .validate(&EnvelopeLimits::DEFAULT) + .expect("canonical API-key name"); + + for (method, path) in [ + (LogicalMethod::Post, "/protected/kv/key%2Fpart"), + (LogicalMethod::Get, "/protected/api-keys/name%2Fpart"), + (LogicalMethod::Get, "/protected/kv/%2f"), + (LogicalMethod::Put, "/protected/kv/%41"), + (LogicalMethod::Delete, "/protected/kv/raw_punctuation"), + (LogicalMethod::Delete, "/protected/api-keys/literal-hyphen"), + (LogicalMethod::Delete, "/protected/api-keys/%20leading"), + (LogicalMethod::Delete, "/protected/api-keys/caf%C3%A9"), + ] { + assert!( + LogicalRequest::new(method, path, None, vec![], None) + .validate(&EnvelopeLimits::DEFAULT) + .is_err(), + "accepted noncanonical opaque path {method:?} {path}" + ); + } +} + +#[test] +fn dynamic_uuid_routes_require_one_lowercase_hyphenated_spelling() { + let id = "00112233-4455-6677-8899-aabbccddeeff"; + let other = "10112233-4455-6677-8899-aabbccddeeff"; + for (method, path) in [ + (LogicalMethod::Get, format!("/verify-email/{id}")), + (LogicalMethod::Get, format!("/platform/verify-email/{id}")), + (LogicalMethod::Post, format!("/platform/accept_invite/{id}")), + (LogicalMethod::Delete, format!("/platform/orgs/{id}")), + ( + LogicalMethod::Get, + format!("/platform/orgs/{id}/projects/{other}"), + ), + ( + LogicalMethod::Delete, + format!("/platform/orgs/{id}/projects/{other}/secrets/key_name"), + ), + ( + LogicalMethod::Get, + format!("/v1/conversation-projects/{id}"), + ), + (LogicalMethod::Get, format!("/v1/conversations/{id}")), + (LogicalMethod::Get, format!("/v1/conversations/{id}/items")), + ( + LogicalMethod::Get, + format!("/v1/conversations/{id}/items/{other}"), + ), + ( + LogicalMethod::Post, + format!("/v1/instructions/{id}/set-default"), + ), + (LogicalMethod::Get, format!("/v1/responses/{id}")), + (LogicalMethod::Post, format!("/v1/responses/{id}/cancel")), + ] { + LogicalRequest::new(method, path, None, vec![], None) + .validate(&EnvelopeLimits::DEFAULT) + .expect("canonical dynamic route"); + } + + let uppercase = "00112233-4455-6677-8899-AABBCCDDEEFF"; + let simple = "00112233445566778899aabbccddeeff"; + for (method, path) in [ + (LogicalMethod::Get, format!("/verify-email/{uppercase}")), + ( + LogicalMethod::Get, + format!("/platform/verify-email/{simple}"), + ), + ( + LogicalMethod::Post, + format!("/platform/accept_invite/{uppercase}"), + ), + (LogicalMethod::Delete, format!("/platform/orgs/{simple}")), + ( + LogicalMethod::Get, + format!("/platform/orgs/{id}/projects/{uppercase}"), + ), + ( + LogicalMethod::Delete, + format!("/platform/orgs/{id}/projects/{other}/secrets/bad-name"), + ), + ( + LogicalMethod::Get, + format!("/v1/conversation-projects/{simple}"), + ), + (LogicalMethod::Get, format!("/v1/conversations/{uppercase}")), + ( + LogicalMethod::Get, + format!("/v1/conversations/{id}/items/{simple}"), + ), + ( + LogicalMethod::Post, + format!("/v1/instructions/{uppercase}/set-default"), + ), + (LogicalMethod::Get, format!("/v1/responses/{simple}")), + ( + LogicalMethod::Post, + format!("/v1/responses/{uppercase}/cancel"), + ), + ] { + assert!( + LogicalRequest::new(method, path, None, vec![], None) + .validate(&EnvelopeLimits::DEFAULT) + .is_err(), + "accepted noncanonical dynamic route {method:?}" + ); + } +} + +#[test] +fn envelopes_reject_noncanonical_or_structurally_ambiguous_input() { + let fixture = vectors(); + let base = fixture.request_without_body_json; + for invalid in [ + base.replace("\"version\":2,", "\"version\":2,\"version\":2,"), + base.replace("\"query\":\"limit=10\",", ""), + base.replace( + "\"body_base64\":null", + "\"body_base64\":null,\"extra\":true", + ), + base.replace("YmV0YQ==", "YmV0YQ"), + base.replace("YmV0YQ==", "YmV0YR=="), + base.replace("/v1/models", "/v1/%2e%2E/models"), + base.replace("limit=10", "?limit=10"), + base.replace("x-provider-beta", "X-Provider-Beta"), + ] { + assert!( + RequestEnvelope::from_json_slice(invalid.as_bytes(), &EnvelopeLimits::DEFAULT).is_err(), + "accepted malformed request" + ); + } + + let auto = base.replace("\"response_mode\":\"unary\"", "\"response_mode\":\"auto\""); + assert_eq!( + RequestEnvelope::from_json_slice(auto.as_bytes(), &EnvelopeLimits::DEFAULT) + .expect("codec retains reserved mode") + .response_mode, + ResponseMode::Auto + ); +} + +#[test] +fn key_exchange_is_one_shot_strict_and_binds_inner_and_outer_session_ids() { + let nonce = "attestation-nonce".to_string(); + let client_secret_bytes = [0x11; 32]; + let enclave_secret = StaticSecret::from([0x22; 32]); + let enclave_public = PublicKey::from(&enclave_secret); + let client_public = PublicKey::from(&StaticSecret::from(client_secret_bytes)); + let shared = enclave_secret.diffie_hellman(&client_public); + assert!(shared.was_contributory()); + + let prepared = PreparedKeyExchange::new(nonce.clone(), *enclave_public.as_bytes()) + .expect("prepared exchange"); + let (request_body, _) = prepared.into_parts(); + let request_json: serde_json::Value = + serde_json::from_slice(&request_body).expect("request JSON"); + assert_eq!(request_json["nonce"], nonce); + let client_key = request_json["client_public_key"] + .as_str() + .expect("client key"); + assert_eq!( + decode_canonical_base64(client_key, 32) + .expect("canonical client public key") + .len(), + 32 + ); + + let session_id = Uuid::parse_str("00112233-4455-6677-8899-aabbccddeeff").expect("session ID"); + let session_master = [0x33; 32]; + let expiry = 1_800_003_900_u64; + let mut payload = Vec::with_capacity(57); + payload.push(Version2::VALUE); + payload.extend_from_slice(session_id.as_bytes()); + payload.extend_from_slice(&session_master); + payload.extend_from_slice(&expiry.to_be_bytes()); + let encrypted = encrypt_key_exchange_record_for_test(shared.as_bytes(), &payload) + .expect("encrypted handshake"); + let response_body = serde_json::to_vec(&json!({ + "session_id": session_id.hyphenated().to_string(), + "encrypted_session_key": encode_canonical_base64(&encrypted), + })) + .expect("response JSON"); + let completion = + KeyExchangeCompletion::from_parts_for_test(client_secret_bytes, *enclave_public.as_bytes()); + let session = completion + .complete(&response_body) + .expect("completed exchange"); + assert_eq!(session.session_id(), session_id); + assert_eq!(session.expires_at_unix_seconds(), expiry); + + let mismatched = serde_json::to_vec(&json!({ + "session_id": "10112233-4455-6677-8899-aabbccddeeff", + "encrypted_session_key": encode_canonical_base64(&encrypted), + })) + .expect("mismatched response"); + let completion = + KeyExchangeCompletion::from_parts_for_test(client_secret_bytes, *enclave_public.as_bytes()); + assert_eq!( + completion + .complete(&mismatched) + .expect_err("binding mismatch"), + TransportV2Error::BindingMismatch + ); + + let noncanonical = response_body.to_vec(); + let noncanonical = String::from_utf8(noncanonical) + .expect("UTF-8") + .replace(&session_id.to_string(), &session_id.simple().to_string()); + let completion = + KeyExchangeCompletion::from_parts_for_test(client_secret_bytes, *enclave_public.as_bytes()); + assert_eq!( + completion + .complete(noncanonical.as_bytes()) + .expect_err("noncanonical UUID"), + TransportV2Error::InvalidKeyExchange + ); + + let completion = KeyExchangeCompletion::from_parts_for_test(client_secret_bytes, [0_u8; 32]); + assert_eq!( + completion + .complete(&response_body) + .expect_err("non-contributory exchange"), + TransportV2Error::NonContributoryKeyExchange + ); +} + +#[test] +fn key_exchange_rejects_empty_or_oversized_attestation_nonces() { + let enclave_public = *PublicKey::from(&StaticSecret::from([0x42; 32])).as_bytes(); + assert_eq!( + PreparedKeyExchange::new(String::new(), enclave_public) + .expect_err("empty challenge must fail"), + TransportV2Error::InvalidKeyExchange + ); + assert_eq!( + PreparedKeyExchange::new("x".repeat(513), enclave_public) + .expect_err("oversized challenge must fail"), + TransportV2Error::InvalidKeyExchange + ); +} + +fn test_request(_response_mode: ResponseMode) -> LogicalRequest { + LogicalRequest::new( + LogicalMethod::Post, + "/v1/responses", + None, + vec![HeaderField::new( + "content-type", + b"application/json".to_vec(), + )], + Some(br#"{"model":"test"}"#.to_vec()), + ) +} + +fn response_context( + session_id: Uuid, + master: [u8; 32], + request_id: RequestId, + response_mode: ResponseMode, +) -> super::session::ResponseContext { + let session = + V2Session::from_master_for_test(session_id, master, u64::MAX).expect("test session"); + let prepared = session + .prepare_request_for_test( + (0, request_id), + response_mode, + None, + None, + test_request(response_mode), + ) + .expect("prepared request"); + let (_, context) = prepared.into_parts(); + context +} + +#[test] +fn prepared_requests_are_exact_session_bound_and_reject_reserved_auto_mode() { + let fixture = vectors(); + let session_id = Uuid::parse_str(&fixture.session_id).expect("session ID"); + let master = fixed_hex::<32>(&fixture.session_master_hex); + let request_id = RequestId::from_bytes(fixed_hex(&fixture.request_id_hex)); + let session = V2Session::from_master_for_test(session_id, master, u64::MAX).expect("session"); + + assert_eq!( + session + .prepare_request( + ResponseMode::Auto, + None, + None, + test_request(ResponseMode::Auto) + ) + .expect_err("auto remains reserved"), + TransportV2Error::InvalidRequest + ); + + let prepared = session + .prepare_request_for_test( + (0, request_id), + ResponseMode::Unary, + None, + Some(CacheNamespaceRoot::from_bytes([0x7a; 32])), + test_request(ResponseMode::Unary), + ) + .expect("request"); + assert_eq!(prepared.session_id(), session_id); + assert_eq!(prepared.request_id(), request_id); + assert_eq!(prepared.response_mode(), ResponseMode::Unary); + let debug = format!("{prepared:?}"); + assert!(!debug.contains("test")); + assert!(!debug.contains(&encode_canonical_base64(&[0x7a; 32]))); + + let (outer_body, _) = prepared.into_parts(); + assert!(outer_body.len() <= MAX_OUTER_REQUEST_BYTES); + let outer = EncryptedOuterRecord::from_json_slice(&outer_body, MAX_OUTER_REQUEST_BYTES) + .expect("strict outer request"); + assert!(outer.encrypted.len() >= MIN_RECORD_LEN); +} + +#[test] +fn sessions_reject_requests_at_or_after_their_expiry() { + let session = V2Session::from_master_for_test(Uuid::nil(), [0x5a; 32], 100).expect("session"); + let request_id = RequestId::from_bytes([0x5b; 16]); + session + .prepare_request_for_test( + (99, request_id), + ResponseMode::Unary, + None, + None, + test_request(ResponseMode::Unary), + ) + .expect("request immediately before expiry"); + + assert_eq!( + session + .prepare_request_for_test( + (100, request_id), + ResponseMode::Unary, + None, + None, + test_request(ResponseMode::Unary), + ) + .expect_err("request at expiry must fail"), + TransportV2Error::SessionExpired + ); +} + +#[test] +fn sessions_enforce_request_id_uniqueness_and_request_record_budgets() { + let session_id = Uuid::nil(); + let master_bytes = [0x5e; 32]; + let first_id = RequestId::from_bytes([0x5f; 16]); + let second_id = RequestId::from_bytes([0x60; 16]); + let third_id = RequestId::from_bytes([0x61; 16]); + let session = + V2Session::from_master_with_budgets_for_test(session_id, master_bytes, u64::MAX, 2, 2) + .expect("session"); + + let first = session + .prepare_request_for_test( + (0, first_id), + ResponseMode::Unary, + None, + None, + test_request(ResponseMode::Unary), + ) + .expect("first request"); + assert_eq!( + session + .prepare_request_for_test( + (0, first_id), + ResponseMode::Unary, + None, + None, + test_request(ResponseMode::Unary), + ) + .expect_err("duplicate request ID"), + TransportV2Error::RequestIdCollision + ); + let second = session + .prepare_request_for_test( + (0, second_id), + ResponseMode::Unary, + None, + None, + test_request(ResponseMode::Unary), + ) + .expect("second request"); + assert_eq!( + session + .prepare_request_for_test( + (0, third_id), + ResponseMode::Unary, + None, + None, + test_request(ResponseMode::Unary), + ) + .expect_err("request record budget"), + TransportV2Error::RequestRecordBudgetExhausted + ); + + let keys = DirectionalKeys::derive(&SessionMaster::from_bytes(master_bytes)).expect("keys"); + let outer_response = |request_id| { + let response = UnaryResponseEnvelope { + version: Version2, + request_id, + status: 200, + headers: vec![], + body_base64: None, + }; + let plaintext = serde_json::to_vec(&response).expect("response JSON"); + let record = keys + .encrypt_unary_response_record_for_test(&session_id, &request_id, &plaintext) + .expect("response record"); + serde_json::to_vec(&EncryptedOuterRecord { + encrypted: EncodedBytes::from_bytes(record), + }) + .expect("outer response") + }; + let (_, first_context) = first.into_parts(); + first_context + .decrypt_unary_outer(&outer_response(first_id)) + .expect("first response record"); + let (_, second_context) = second.into_parts(); + second_context + .decrypt_unary_outer(&outer_response(second_id)) + .expect("second response record"); +} + +#[test] +fn sessions_reserve_response_capacity_before_emitting_requests() { + let session = + V2Session::from_master_with_budgets_for_test(Uuid::nil(), [0x68; 32], u64::MAX, 4, 2) + .expect("session"); + + let first = session + .prepare_request_for_test( + (0, RequestId::from_bytes([0x69; 16])), + ResponseMode::Unary, + None, + None, + test_request(ResponseMode::Unary), + ) + .expect("first unary reservation"); + drop(first); + + assert_eq!( + session + .prepare_request_for_test( + (0, RequestId::from_bytes([0x6b; 16])), + ResponseMode::Stream, + None, + None, + test_request(ResponseMode::Stream), + ) + .expect_err("a stream needs both Start and terminal capacity"), + TransportV2Error::ResponseRecordBudgetExhausted + ); + + let second = session + .prepare_request_for_test( + (0, RequestId::from_bytes([0x6d; 16])), + ResponseMode::Unary, + None, + None, + test_request(ResponseMode::Unary), + ) + .expect("second unary reservation"); + let (_, second_context) = second.into_parts(); + assert!(second_context.decrypt_unary_outer(b"{}").is_err()); + + assert_eq!( + session + .prepare_request_for_test( + (0, RequestId::from_bytes([0x6f; 16])), + ResponseMode::Unary, + None, + None, + test_request(ResponseMode::Unary), + ) + .expect_err("dropped and failed contexts retain their reservations"), + TransportV2Error::ResponseRecordBudgetExhausted + ); +} + +#[test] +fn authenticated_stream_pre_start_error_releases_the_unused_terminal_slot() { + let session_id = Uuid::nil(); + let master_bytes = [0x6f; 32]; + let stream_request_id = RequestId::from_bytes([0x70; 16]); + let session = + V2Session::from_master_with_budgets_for_test(session_id, master_bytes, u64::MAX, 3, 2) + .expect("session"); + let stream = session + .prepare_request_for_test( + (0, stream_request_id), + ResponseMode::Stream, + None, + None, + test_request(ResponseMode::Stream), + ) + .expect("stream reservation"); + + let response = UnaryResponseEnvelope { + version: Version2, + request_id: stream_request_id, + status: 503, + headers: vec![], + body_base64: None, + }; + let plaintext = serde_json::to_vec(&response).expect("response JSON"); + let keys = DirectionalKeys::derive(&SessionMaster::from_bytes(master_bytes)).expect("keys"); + let record = keys + .encrypt_unary_response_record_for_test(&session_id, &stream_request_id, &plaintext) + .expect("response record"); + let outer = serde_json::to_vec(&EncryptedOuterRecord { + encrypted: EncodedBytes::from_bytes(record), + }) + .expect("outer response"); + let (_, context) = stream.into_parts(); + context + .decrypt_stream_pre_start_error_outer(&outer) + .expect("authenticated pre-Start error"); + + session + .prepare_request_for_test( + (0, RequestId::from_bytes([0x73; 16])), + ResponseMode::Unary, + None, + None, + test_request(ResponseMode::Unary), + ) + .expect("released stream slot admits one unary request"); +} + +#[test] +fn concurrent_requests_cannot_overbook_the_last_response_slot() { + let session = Arc::new( + V2Session::from_master_with_budgets_for_test(Uuid::nil(), [0x75; 32], u64::MAX, 4, 1) + .expect("session"), + ); + let barrier = Arc::new(Barrier::new(3)); + let mut handles = Vec::new(); + for index in 0..2_u8 { + let session = Arc::clone(&session); + let barrier = Arc::clone(&barrier); + handles.push(thread::spawn(move || { + barrier.wait(); + session.prepare_request_for_test( + (0, RequestId::from_bytes([0x76 + index; 16])), + ResponseMode::Unary, + None, + None, + test_request(ResponseMode::Unary), + ) + })); + } + barrier.wait(); + + let mut admitted = 0; + let mut exhausted = 0; + for handle in handles { + match handle.join().expect("request thread") { + Ok(_) => admitted += 1, + Err(TransportV2Error::ResponseRecordBudgetExhausted) => exhausted += 1, + Err(error) => panic!("unexpected request error: {error:?}"), + } + } + assert_eq!(admitted, 1); + assert_eq!(exhausted, 1); +} + +#[test] +fn unary_response_requires_exact_aad_and_inner_request_id() { + let session_id = Uuid::parse_str("00112233-4455-6677-8899-aabbccddeeff").expect("session ID"); + let master_bytes = [0x61; 32]; + let request_id = RequestId::from_bytes([0x62; 16]); + let keys = DirectionalKeys::derive(&SessionMaster::from_bytes(master_bytes)).expect("keys"); + + let response = UnaryResponseEnvelope { + version: Version2, + request_id, + status: 200, + headers: vec![HeaderField::new( + "content-type", + b"application/json".to_vec(), + )], + body_base64: Some(EncodedBytes::from_bytes(br#"{"ok":true}"#.to_vec())), + }; + let plaintext = serde_json::to_vec(&response).expect("response JSON"); + let record = keys + .encrypt_unary_response_record_for_test(&session_id, &request_id, &plaintext) + .expect("response record"); + let outer = serde_json::to_vec(&EncryptedOuterRecord { + encrypted: EncodedBytes::from_bytes(record), + }) + .expect("outer response"); + let response = response_context(session_id, master_bytes, request_id, ResponseMode::Unary) + .decrypt_unary_outer(&outer) + .expect("authenticated unary response"); + assert_eq!(response.status, 200); + assert_eq!(response.body.as_deref(), Some(br#"{"ok":true}"#.as_slice())); + + let other_request_id = RequestId::from_bytes([0x64; 16]); + let mismatched = UnaryResponseEnvelope { + version: Version2, + request_id: other_request_id, + status: 200, + headers: vec![], + body_base64: None, + }; + let plaintext = serde_json::to_vec(&mismatched).expect("response JSON"); + let record = keys + .encrypt_unary_response_record_for_test(&session_id, &request_id, &plaintext) + .expect("response record"); + let outer = serde_json::to_vec(&EncryptedOuterRecord { + encrypted: EncodedBytes::from_bytes(record), + }) + .expect("outer response"); + assert_eq!( + response_context(session_id, master_bytes, request_id, ResponseMode::Unary) + .decrypt_unary_outer(&outer) + .expect_err("inner request mismatch"), + TransportV2Error::BindingMismatch + ); +} + +#[test] +fn response_context_enforces_mode_and_only_allows_pre_start_stream_errors() { + let session_id = Uuid::nil(); + let master_bytes = [0x66; 32]; + let request_id = RequestId::from_bytes([0x67; 16]); + let keys = DirectionalKeys::derive(&SessionMaster::from_bytes(master_bytes)).expect("keys"); + + let encrypted_outer = |status| { + let response = UnaryResponseEnvelope { + version: Version2, + request_id, + status, + headers: vec![], + body_base64: Some(EncodedBytes::from_bytes(b"redacted-error".to_vec())), + }; + let plaintext = serde_json::to_vec(&response).expect("response JSON"); + let record = keys + .encrypt_unary_response_record_for_test(&session_id, &request_id, &plaintext) + .expect("response record"); + serde_json::to_vec(&EncryptedOuterRecord { + encrypted: EncodedBytes::from_bytes(record), + }) + .expect("outer response") + }; + + assert_eq!( + response_context(session_id, master_bytes, request_id, ResponseMode::Stream) + .decrypt_unary_outer(&encrypted_outer(401)) + .expect_err("stream context must reject ordinary unary decoding"), + TransportV2Error::ResponseModeMismatch + ); + assert_eq!( + response_context(session_id, master_bytes, request_id, ResponseMode::Unary) + .into_stream_decoder() + .expect_err("unary context must reject stream decoding"), + TransportV2Error::ResponseModeMismatch + ); + + let error = response_context(session_id, master_bytes, request_id, ResponseMode::Stream) + .decrypt_stream_pre_start_error_outer(&encrypted_outer(401)) + .expect("authenticated pre-Start error"); + assert_eq!(error.status, 401); + assert_eq!( + response_context(session_id, master_bytes, request_id, ResponseMode::Stream) + .decrypt_stream_pre_start_error_outer(&encrypted_outer(200)) + .expect_err("successful stream response must use stream records"), + TransportV2Error::InvalidResponse + ); + assert_eq!( + response_context(session_id, master_bytes, request_id, ResponseMode::Unary) + .decrypt_stream_pre_start_error_outer(&encrypted_outer(401)) + .expect_err("unary context cannot select the stream error path"), + TransportV2Error::ResponseModeMismatch + ); +} + +fn encrypted_stream_frame( + keys: &DirectionalKeys, + session_id: &Uuid, + request_id: &RequestId, + sequence: u64, + record: StreamRecord, +) -> Vec { + let plaintext = serde_json::to_vec(&record).expect("stream JSON"); + let encrypted = keys + .encrypt_stream_response_record_for_test(session_id, request_id, sequence, &plaintext) + .expect("stream record"); + format!("data: {}\n\n", encode_canonical_base64(&encrypted)).into_bytes() +} + +fn stream_decoder(session_id: Uuid, master: [u8; 32], request_id: RequestId) -> StreamDecoder { + response_context(session_id, master, request_id, ResponseMode::Stream) + .into_stream_decoder() + .expect("stream decoder") +} + +#[test] +fn stream_chunks_charge_capacity_beyond_the_reserved_start_and_terminal() { + let session_id = Uuid::nil(); + let master_bytes = [0x80; 32]; + let request_id = RequestId::from_bytes([0x81; 16]); + let session = + V2Session::from_master_with_budgets_for_test(session_id, master_bytes, u64::MAX, 1, 3) + .expect("session"); + let prepared = session + .prepare_request_for_test( + (0, request_id), + ResponseMode::Stream, + None, + None, + test_request(ResponseMode::Stream), + ) + .expect("stream request"); + let (_, context) = prepared.into_parts(); + let mut decoder = context.into_stream_decoder().expect("stream decoder"); + let keys = DirectionalKeys::derive(&SessionMaster::from_bytes(master_bytes)).expect("keys"); + + let start = encrypted_stream_frame( + &keys, + &session_id, + &request_id, + 0, + StreamRecord::Start { + version: Version2, + request_id, + sequence: 0, + status: 200, + headers: vec![], + }, + ); + decoder.push(&start).expect("reserved Start record"); + + let first_chunk = encrypted_stream_frame( + &keys, + &session_id, + &request_id, + 1, + StreamRecord::Chunk { + version: Version2, + request_id, + sequence: 1, + body_base64: EncodedBytes::from_bytes(vec![1]), + }, + ); + decoder + .push(&first_chunk) + .expect("one dynamically charged chunk"); + + let second_chunk = encrypted_stream_frame( + &keys, + &session_id, + &request_id, + 2, + StreamRecord::Chunk { + version: Version2, + request_id, + sequence: 2, + body_base64: EncodedBytes::from_bytes(vec![2]), + }, + ); + assert_eq!( + decoder + .push(&second_chunk) + .expect_err("response budget must reject another chunk"), + TransportV2Error::ResponseRecordBudgetExhausted + ); +} + +#[test] +fn stream_decoder_handles_split_and_coalesced_frames_with_authenticated_terminal() { + let session_id = Uuid::parse_str("00112233-4455-6677-8899-aabbccddeeff").expect("session ID"); + let master_bytes = [0x71; 32]; + let request_id = RequestId::from_bytes([0x72; 16]); + let keys = DirectionalKeys::derive(&SessionMaster::from_bytes(master_bytes)).expect("keys"); + + let start = encrypted_stream_frame( + &keys, + &session_id, + &request_id, + 0, + StreamRecord::Start { + version: Version2, + request_id, + sequence: 0, + status: 200, + headers: vec![HeaderField::new( + "content-type", + b"text/event-stream".to_vec(), + )], + }, + ); + let chunk = encrypted_stream_frame( + &keys, + &session_id, + &request_id, + 1, + StreamRecord::Chunk { + version: Version2, + request_id, + sequence: 1, + body_base64: EncodedBytes::from_bytes(b"data: hello\n\n".to_vec()), + }, + ); + let end = encrypted_stream_frame( + &keys, + &session_id, + &request_id, + 2, + StreamRecord::End { + version: Version2, + request_id, + sequence: 2, + }, + ); + + let mut decoder = stream_decoder(session_id, master_bytes, request_id); + let split = start.len() / 2; + assert!(decoder + .push(&start[..split]) + .expect("partial frame") + .is_empty()); + assert!(matches!( + decoder.push(&start[split..]).expect("start").as_slice(), + [StreamEvent::Start { status: 200, .. }] + )); + let mut coalesced = chunk; + coalesced.extend_from_slice(&end); + assert_eq!( + decoder.push(&coalesced).expect("chunk and end"), + vec![ + StreamEvent::Chunk(b"data: hello\n\n".to_vec()), + StreamEvent::End + ] + ); + decoder + .finish() + .expect("authenticated terminal and clean EOF"); +} + +#[test] +fn stream_decoder_accepts_authenticated_error_as_the_only_terminal() { + let session_id = Uuid::nil(); + let master_bytes = [0x81; 32]; + let request_id = RequestId::from_bytes([0x82; 16]); + let keys = DirectionalKeys::derive(&SessionMaster::from_bytes(master_bytes)).expect("keys"); + let start = encrypted_stream_frame( + &keys, + &session_id, + &request_id, + 0, + StreamRecord::Start { + version: Version2, + request_id, + sequence: 0, + status: 200, + headers: vec![], + }, + ); + let error = encrypted_stream_frame( + &keys, + &session_id, + &request_id, + 1, + StreamRecord::Error { + version: Version2, + request_id, + sequence: 1, + status: 503, + body_base64: EncodedBytes::from_bytes(b"unavailable".to_vec()), + }, + ); + + let mut decoder = stream_decoder(session_id, master_bytes, request_id); + decoder.push(&start).expect("start"); + assert_eq!( + decoder.push(&error).expect("error terminal"), + vec![StreamEvent::Error { + status: 503, + body: b"unavailable".to_vec(), + }] + ); + decoder.finish().expect("terminal error is complete"); +} + +#[test] +fn stream_decoder_rejects_truncation_framing_tampering_and_extra_records() { + let session_id = Uuid::nil(); + let master_bytes = [0x91; 32]; + let request_id = RequestId::from_bytes([0x92; 16]); + let keys = DirectionalKeys::derive(&SessionMaster::from_bytes(master_bytes)).expect("keys"); + let start = encrypted_stream_frame( + &keys, + &session_id, + &request_id, + 0, + StreamRecord::Start { + version: Version2, + request_id, + sequence: 0, + status: 200, + headers: vec![], + }, + ); + let end = encrypted_stream_frame( + &keys, + &session_id, + &request_id, + 1, + StreamRecord::End { + version: Version2, + request_id, + sequence: 1, + }, + ); + + let mut truncated = stream_decoder(session_id, master_bytes, request_id); + truncated.push(&start).expect("start"); + assert_eq!( + truncated.finish().expect_err("missing terminal"), + TransportV2Error::TruncatedStream + ); + + for malformed in [ + b"data:not-spaced\n\n".as_slice(), + b"event: message\ndata: YQ==\n\n".as_slice(), + b"data: YQ==\r\n\r\n".as_slice(), + b": comment\n\n".as_slice(), + ] { + let mut decoder = stream_decoder(session_id, master_bytes, request_id); + assert!(decoder.push(malformed).is_err()); + } + + let mut tampered = start.clone(); + let payload = std::str::from_utf8(&tampered[6..tampered.len() - 2]).expect("base64 frame"); + let mut record = decode_canonical_base64(payload, 128 * 1024).expect("record"); + *record.last_mut().expect("tag") ^= 1; + tampered = format!("data: {}\n\n", encode_canonical_base64(&record)).into_bytes(); + let mut decoder = stream_decoder(session_id, master_bytes, request_id); + assert_eq!( + decoder.push(&tampered).expect_err("tampered tag"), + TransportV2Error::AuthenticationFailed + ); + + let mut extra = stream_decoder(session_id, master_bytes, request_id); + extra.push(&start).expect("start"); + let mut terminal_and_extra = end.clone(); + terminal_and_extra.extend_from_slice(&end); + assert_eq!( + extra + .push(&terminal_and_extra) + .expect_err("record after terminal"), + TransportV2Error::StreamAlreadyTerminal + ); + + let mut oversized = stream_decoder(session_id, master_bytes, request_id); + let carrier = vec![b'x'; max_stream_carrier_frame_bytes_for_test() + 1]; + assert!(matches!( + oversized.push(&carrier), + Err(TransportV2Error::LimitExceeded { + field: "stream carrier frame", + .. + }) + )); +} + +#[test] +fn stream_decoder_bounds_cumulative_logical_chunk_bytes() { + let session_id = Uuid::nil(); + let master_bytes = [0x9a; 32]; + let request_id = RequestId::from_bytes([0x9b; 16]); + let keys = DirectionalKeys::derive(&SessionMaster::from_bytes(master_bytes)).expect("keys"); + let start = encrypted_stream_frame( + &keys, + &session_id, + &request_id, + 0, + StreamRecord::Start { + version: Version2, + request_id, + sequence: 0, + status: 200, + headers: vec![], + }, + ); + let first = encrypted_stream_frame( + &keys, + &session_id, + &request_id, + 1, + StreamRecord::Chunk { + version: Version2, + request_id, + sequence: 1, + body_base64: EncodedBytes::from_bytes(b"one".to_vec()), + }, + ); + let second = encrypted_stream_frame( + &keys, + &session_id, + &request_id, + 2, + StreamRecord::Chunk { + version: Version2, + request_id, + sequence: 2, + body_base64: EncodedBytes::from_bytes(b"two".to_vec()), + }, + ); + + let mut decoder = + StreamDecoder::new_with_logical_limit(session_id, request_id, Arc::new(keys), 5); + decoder.push(&start).expect("start"); + assert_eq!( + decoder.push(&first).expect("first chunk"), + vec![StreamEvent::Chunk(b"one".to_vec())] + ); + assert_eq!( + decoder.push(&second).expect_err("cumulative limit"), + TransportV2Error::LimitExceeded { + field: "logical stream", + limit: 5, + } + ); +} + +#[test] +fn stream_decoder_rejects_wrong_inner_binding_and_oversized_chunks() { + let session_id = Uuid::nil(); + let master_bytes = [0xa1; 32]; + let request_id = RequestId::from_bytes([0xa2; 16]); + let keys = DirectionalKeys::derive(&SessionMaster::from_bytes(master_bytes)).expect("keys"); + + let wrong_inner = encrypted_stream_frame( + &keys, + &session_id, + &request_id, + 0, + StreamRecord::Start { + version: Version2, + request_id: RequestId::from_bytes([0xa3; 16]), + sequence: 0, + status: 200, + headers: vec![], + }, + ); + let mut decoder = stream_decoder(session_id, master_bytes, request_id); + assert_eq!( + decoder + .push(&wrong_inner) + .expect_err("wrong inner request ID"), + TransportV2Error::BindingMismatch + ); + + let start = encrypted_stream_frame( + &keys, + &session_id, + &request_id, + 0, + StreamRecord::Start { + version: Version2, + request_id, + sequence: 0, + status: 200, + headers: vec![], + }, + ); + let oversized_plaintext = format!( + "{{\"version\":2,\"request_id\":\"{}\",\"sequence\":1,\"kind\":\"chunk\",\"body_base64\":\"{}\"}}", + request_id, + encode_canonical_base64(&vec![0_u8; MAX_STREAM_CHUNK_BYTES + 1]) + ); + let oversized_record = keys + .encrypt_stream_response_record_for_test( + &session_id, + &request_id, + 1, + oversized_plaintext.as_bytes(), + ) + .expect("oversized encrypted record"); + let oversized_frame = + format!("data: {}\n\n", encode_canonical_base64(&oversized_record)).into_bytes(); + let mut decoder = stream_decoder(session_id, master_bytes, request_id); + decoder.push(&start).expect("start"); + assert!(matches!( + decoder.push(&oversized_frame), + Err(TransportV2Error::LimitExceeded { + field: "stream chunk", + limit: MAX_STREAM_CHUNK_BYTES, + }) + )); +} + +#[test] +fn secret_bearing_debug_output_is_redacted() { + let master = SessionMaster::from_bytes([0xbb; 32]); + let keys = Arc::new(DirectionalKeys::derive(&master).expect("keys")); + let cache_root = CacheNamespaceRoot::from_bytes([0xcc; 32]); + let decoder = StreamDecoder::new_with_logical_limit( + Uuid::nil(), + RequestId::from_bytes([0xdd; 16]), + keys, + usize::MAX, + ); + let plaintext = b"debug-plaintext-sentinel".to_vec(); + let unary = super::session::UnaryResponse { + status: 500, + headers: vec![HeaderField::new("x-secret", plaintext.clone())], + body: Some(plaintext.clone()), + }; + let chunk = StreamEvent::Chunk(plaintext.clone()); + let error = StreamEvent::Error { + status: 500, + body: plaintext, + }; + + assert_eq!(format!("{master:?}"), "SessionMaster([REDACTED])"); + assert_eq!(format!("{cache_root:?}"), "CacheNamespaceRoot([REDACTED])"); + assert!(!format!("{decoder:?}").contains(&hex::encode([0xbb; 32]))); + assert!(!format!("{decoder:?}").contains(&hex::encode([0xcc; 32]))); + assert!(!format!("{unary:?}").contains("debug-plaintext-sentinel")); + assert!(!format!("{chunk:?}").contains("debug-plaintext-sentinel")); + assert!(!format!("{error:?}").contains("debug-plaintext-sentinel")); +} diff --git a/sdk/rust/testdata/transport-v2-golden-vectors.json b/sdk/rust/testdata/transport-v2-golden-vectors.json new file mode 100644 index 000000000..47464fe3d --- /dev/null +++ b/sdk/rust/testdata/transport-v2-golden-vectors.json @@ -0,0 +1,50 @@ +{ + "fixture_version": 1, + "protocol_version": 2, + "shared_secret_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "session_master_hex": "202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "session_id": "00112233-4455-6677-8899-aabbccddeeff", + "session_id_hex": "00112233445566778899aabbccddeeff", + "expires_at_unix_seconds": 1800003900, + "request_id_hex": "ffeeddccbbaa99887766554433221100", + "stream_sequence": 7, + "handshake": { + "info_utf8": "opensecret/transport-v2/handshake-key", + "derived_key_hex": "d534157fe075f3c5c13899ec5cabf66121006330a15a9e340132aaec235fb634", + "aad_hex": "6f70656e7365637265742f7472616e73706f72742d76322f6b65792d65786368616e6765", + "nonce_hex": "000102030405060708090a0b", + "plaintext_hex": "0200112233445566778899aabbccddeeff202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f000000006b49e13c", + "record_hex": "000102030405060708090a0bb88afcfdf3ec719871b6844fbfd510210dcef6c86b1e217bd66a24ef9be9746e784de2d83125ec52cfe009e0826bb85bd029744bd2759acd8dd698091d54b34fa9dfe9b6f26138375b", + "record_base64": "AAECAwQFBgcICQoLuIr8/fPscZhxtoRPv9UQIQ3O9shrHiF71mok75vpdG54TeLYMSXsUs/gCeCCa7hb0Cl0S9J1ms2N1pgJHVSzT6nf6bbyYTg3Ww==" + }, + "request": { + "info_utf8": "opensecret/transport-v2/client-request", + "derived_key_hex": "c8865e05fdd7c0a8eada79cb117e97b1c11aeee640fd315f5971a89b3c8504f3", + "aad_hex": "6f70656e7365637265742f7472616e73706f72742d76322f726571756573742d7265636f72640000112233445566778899aabbccddeeff", + "nonce_hex": "0c0d0e0f1011121314151617", + "plaintext_utf8": "transport-v2 request vector", + "plaintext_hex": "7472616e73706f72742d7632207265717565737420766563746f72", + "record_hex": "0c0d0e0f1011121314151617ccab23f6818e4331eb8a631f5d0fb5c874bb20cc11efce9deeda2c8747fe81729890b1638dfac9cbc222f8", + "record_base64": "DA0ODxAREhMUFRYXzKsj9oGOQzHrimMfXQ+1yHS7IMwR786d7tosh0f+gXKYkLFjjfrJy8Ii+A==" + }, + "unary_response": { + "info_utf8": "opensecret/transport-v2/enclave-response", + "derived_key_hex": "ac0b87b944dd080ed214ee042458f159df7dad0bca10c3bb1caee552d8633a0a", + "aad_hex": "6f70656e7365637265742f7472616e73706f72742d76322f756e6172792d726573706f6e73652d7265636f72640000112233445566778899aabbccddeeffffeeddccbbaa99887766554433221100", + "nonce_hex": "18191a1b1c1d1e1f20212223", + "plaintext_utf8": "transport-v2 unary response vector", + "plaintext_hex": "7472616e73706f72742d763220756e61727920726573706f6e736520766563746f72", + "record_hex": "18191a1b1c1d1e1f2021222356b4bfabc752cd7a75ac37c452d93ca225ddacf1a266267a17c5fdfec9945ace742d11a6a91de3d90e475fe7455bab8ecdd1", + "record_base64": "GBkaGxwdHh8gISIjVrS/q8dSzXp1rDfEUtk8oiXdrPGiZiZ6F8X9/smUWs50LRGmqR3j2Q5HX+dFW6uOzdE=" + }, + "stream_response": { + "aad_hex": "6f70656e7365637265742f7472616e73706f72742d76322f73747265616d2d726573706f6e73652d7265636f72640000112233445566778899aabbccddeeffffeeddccbbaa998877665544332211000000000000000007", + "nonce_hex": "2425262728292a2b2c2d2e2f", + "plaintext_utf8": "transport-v2 stream record vector", + "plaintext_hex": "7472616e73706f72742d76322073747265616d207265636f726420766563746f72", + "record_hex": "2425262728292a2b2c2d2e2f7f7818dab8d77e819361a2b9cb7fbccc2ba70f4bb6b751e1329622624dfa35cefb9031a6e93e608bc9777a57a7f35480ba", + "record_base64": "JCUmJygpKissLS4vf3gY2rjXfoGTYaK5y3+8zCunD0u2t1HhMpYiYk36Nc77kDGm6T5gi8l3elen81SAug==" + }, + "request_without_body_json": "{\"version\":2,\"request_id\":\"ffeeddccbbaa99887766554433221100\",\"response_mode\":\"unary\",\"credential\":null,\"cache_namespace_root_base64\":null,\"request\":{\"method\":\"GET\",\"path\":\"/v1/models\",\"query\":\"limit=10\",\"headers\":[{\"name\":\"x-provider-beta\",\"value_base64\":\"YmV0YQ==\"}],\"body_base64\":null}}", + "request_with_empty_body_json": "{\"version\":2,\"request_id\":\"ffeeddccbbaa99887766554433221100\",\"response_mode\":\"unary\",\"credential\":null,\"cache_namespace_root_base64\":null,\"request\":{\"method\":\"POST\",\"path\":\"/v1/responses\",\"query\":null,\"headers\":[{\"name\":\"content-type\",\"value_base64\":\"YXBwbGljYXRpb24vanNvbg==\"}],\"body_base64\":\"\"}}" +} diff --git a/sdk/src/lib/test/transportV2.test.ts b/sdk/src/lib/test/transportV2.test.ts new file mode 100644 index 000000000..10f96c405 --- /dev/null +++ b/sdk/src/lib/test/transportV2.test.ts @@ -0,0 +1,830 @@ +import { describe, expect, test } from "bun:test"; +import vectors from "../../../testdata/transport-v2-golden-vectors.json"; +import { + TransportV2ProtocolError, + decodeCanonicalBase64, + decryptTransportV2Handshake, + decryptTransportV2Record, + deriveTransportV2DirectionalKeys, + encodeCanonicalBase64, + encodeCanonicalOpaquePathSegment, + encryptTransportV2Record, + parseUnaryResponseEnvelope, + requestRecordAad, + serializeRequestEnvelope, + streamResponseRecordAad, + TransportV2Handshake, + TransportV2StreamDecoder, + TransportV2Session, + unaryResponseRecordAad +} from "../transportV2"; +import { encodeUtf8, hexToBytes } from "../transportV2/encoding"; + +const encoder = new TextEncoder(); + +function fixedRequestIdRandom(...requestIds: Uint8Array[]): Crypto { + let index = 0; + return { + getRandomValues(array: T): T { + if (!array || !(array instanceof Uint8Array)) { + throw new Error("unexpected random request"); + } + if (array.length === 12) { + return globalThis.crypto.getRandomValues(array) as T; + } + if (array.length !== 16 || index >= requestIds.length) { + throw new Error("unexpected random request"); + } + const value = requestIds[index]; + index += 1; + if (value.length !== 16) throw new Error("unexpected request ID length"); + array.set(value); + return array; + } + } as Crypto; +} + +async function vectorKeys() { + return deriveTransportV2DirectionalKeys(hexToBytes(vectors.session_master_hex, 32)); +} + +describe("transport v2 cross-language vectors", () => { + test("derives and authenticates the frozen handshake and directional records", async () => { + const sharedSecret = hexToBytes(vectors.shared_secret_hex, 32); + const handshakeRecord = hexToBytes(vectors.handshake.record_hex, 85); + const handshake = await decryptTransportV2Handshake( + sharedSecret, + vectors.session_id, + handshakeRecord + ); + expect(handshake.sessionId).toBe(vectors.session_id); + expect(handshake.expiresAtUnixSeconds).toBe(vectors.expires_at_unix_seconds); + expect(Buffer.from(handshake.requestKey).toString("hex")).toBe(vectors.request.derived_key_hex); + expect(Buffer.from(handshake.responseKey).toString("hex")).toBe( + vectors.unary_response.derived_key_hex + ); + + const requestPlaintext = encoder.encode(vectors.request.plaintext_utf8); + const requestRecord = encryptTransportV2Record( + handshake.requestKey, + requestPlaintext, + requestRecordAad(vectors.session_id), + hexToBytes(vectors.request.nonce_hex, 12) + ); + expect(Buffer.from(requestRecord).toString("hex")).toBe(vectors.request.record_hex); + expect(encodeCanonicalBase64(requestRecord)).toBe(vectors.request.record_base64); + + const unaryRecord = encryptTransportV2Record( + handshake.responseKey, + encoder.encode(vectors.unary_response.plaintext_utf8), + unaryResponseRecordAad(vectors.session_id, vectors.request_id_hex), + hexToBytes(vectors.unary_response.nonce_hex, 12) + ); + expect(Buffer.from(unaryRecord).toString("hex")).toBe(vectors.unary_response.record_hex); + + const streamRecord = encryptTransportV2Record( + handshake.responseKey, + encoder.encode(vectors.stream_response.plaintext_utf8), + streamResponseRecordAad(vectors.session_id, vectors.request_id_hex, vectors.stream_sequence), + hexToBytes(vectors.stream_response.nonce_hex, 12) + ); + expect(Buffer.from(streamRecord).toString("hex")).toBe(vectors.stream_response.record_hex); + + const opened = decryptTransportV2Record( + handshake.responseKey, + streamRecord, + streamResponseRecordAad(vectors.session_id, vectors.request_id_hex, vectors.stream_sequence), + 1024 + ); + expect(new TextDecoder().decode(opened)).toBe(vectors.stream_response.plaintext_utf8); + }); + + test("serializes the frozen absent-body and explicit-empty-body envelopes", () => { + const withoutBody = serializeRequestEnvelope({ + requestId: vectors.request_id_hex, + responseMode: "unary", + credential: null, + cacheNamespaceRoot: null, + request: { + method: "GET", + path: "/v1/models", + query: "limit=10", + headers: [{ name: "x-provider-beta", value: encoder.encode("beta") }], + body: null + } + }); + expect(new TextDecoder().decode(withoutBody)).toBe(vectors.request_without_body_json); + + const withEmptyBody = serializeRequestEnvelope({ + requestId: vectors.request_id_hex, + responseMode: "unary", + credential: null, + cacheNamespaceRoot: null, + request: { + method: "POST", + path: "/v1/responses", + query: null, + headers: [{ name: "content-type", value: encoder.encode("application/json") }], + body: new Uint8Array(0) + } + }); + expect(new TextDecoder().decode(withEmptyBody)).toBe(vectors.request_with_empty_body_json); + }); + + test("serializes non-null credentials and cache roots without an outer credential", () => { + const cacheNamespaceRoot = hexToBytes( + "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + 32 + ); + for (const kind of ["api_key", "resumption"] as const) { + const encoded = serializeRequestEnvelope({ + requestId: vectors.request_id_hex, + responseMode: "unary", + credential: { kind, value: encoder.encode("sk-test") }, + cacheNamespaceRoot, + request: { + method: "POST", + path: "/v1/chat/completions", + query: null, + headers: [], + body: null + } + }); + expect(new TextDecoder().decode(encoded)).toBe( + `{"version":2,"request_id":"${vectors.request_id_hex}","response_mode":"unary","credential":{"kind":"${kind}","value_base64":"c2stdGVzdA=="},"cache_namespace_root_base64":"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=","request":{"method":"POST","path":"/v1/chat/completions","query":null,"headers":[],"body_base64":null}}` + ); + } + }); + + test("rejects alternate base64 text and changed response bindings", async () => { + const unpadded = vectors.request.record_base64.replace(/=+$/, ""); + expect(() => decodeCanonicalBase64(unpadded, 1024)).toThrow(TransportV2ProtocolError); + + const keys = await vectorKeys(); + expect(() => + decryptTransportV2Record( + keys.responseKey, + hexToBytes(vectors.unary_response.record_hex, 62), + unaryResponseRecordAad(vectors.session_id, "00112233445566778899aabbccddeeff"), + 1024 + ) + ).toThrow(TransportV2ProtocolError); + + const requestRecord = hexToBytes(vectors.request.record_hex, 55); + const unaryRecord = hexToBytes(vectors.unary_response.record_hex, 62); + const streamRecord = hexToBytes(vectors.stream_response.record_hex, 61); + const otherSessionId = "00112233-4455-6677-8899-aabbccddeefe"; + const otherRequestId = "00112233445566778899aabbccddeefe"; + const tampered = new Uint8Array(unaryRecord); + tampered[tampered.length - 1] ^= 1; + + const rejected = [ + () => + decryptTransportV2Record( + keys.responseKey, + requestRecord, + requestRecordAad(vectors.session_id), + 1024 + ), + () => + decryptTransportV2Record( + keys.requestKey, + unaryRecord, + unaryResponseRecordAad(vectors.session_id, vectors.request_id_hex), + 1024 + ), + () => + decryptTransportV2Record( + keys.responseKey, + unaryRecord, + unaryResponseRecordAad(otherSessionId, vectors.request_id_hex), + 1024 + ), + () => + decryptTransportV2Record( + keys.responseKey, + unaryRecord, + unaryResponseRecordAad(vectors.session_id, otherRequestId), + 1024 + ), + () => + decryptTransportV2Record( + keys.responseKey, + streamRecord, + streamResponseRecordAad( + vectors.session_id, + vectors.request_id_hex, + vectors.stream_sequence + 1 + ), + 1024 + ), + () => + decryptTransportV2Record( + keys.responseKey, + tampered, + unaryResponseRecordAad(vectors.session_id, vectors.request_id_hex), + 1024 + ), + () => decryptTransportV2Record(keys.responseKey, new Uint8Array(27), new Uint8Array(0), 1024) + ]; + for (const reject of rejected) expect(reject).toThrow(TransportV2ProtocolError); + await expect( + decryptTransportV2Handshake(new Uint8Array(32), vectors.session_id, new Uint8Array(85)) + ).rejects.toBeInstanceOf(TransportV2ProtocolError); + }); +}); + +describe("transport v2 dormant session engine", () => { + test("rejects an empty attestation nonce before generating key material", () => { + expect(() => new TransportV2Handshake("")).toThrow("invalid length"); + + const handshake = new TransportV2Handshake("nonce"); + const publicKey = handshake.clientPublicKey; + publicKey.fill(0); + const request = handshake.keyExchangeRequest(); + const requestPublicKey = JSON.parse(request.body) as { client_public_key: string }; + expect(requestPublicKey.client_public_key).not.toBe(encodeCanonicalBase64(publicKey)); + expect(() => handshake.keyExchangeRequest()).toThrow("already consumed"); + handshake.dispose(); + }); + + test("owns exactly one send and authenticates one matching unary response", async () => { + const keys = await vectorKeys(); + const session = new TransportV2Session( + { + sessionId: vectors.session_id, + expiresAtUnixSeconds: vectors.expires_at_unix_seconds, + ...keys + }, + 1 + ); + const requestIdBytes = hexToBytes(vectors.request_id_hex, 16); + const prepared = session.prepareRequest( + { + responseMode: "unary", + credential: null, + cacheNamespaceRoot: null, + request: { + method: "GET", + path: "/v1/models", + query: null, + headers: [], + body: null + } + }, + fixedRequestIdRandom(requestIdBytes), + vectors.expires_at_unix_seconds - 1 + ); + const outbound = prepared.takeHttpRequest(); + expect(outbound.path).toBe("/v2/request"); + expect(outbound.headers).toEqual({ + "content-type": "application/json", + "x-session-id": vectors.session_id + }); + expect(outbound.body).not.toContain("/v1/models"); + expect(() => prepared.takeHttpRequest()).toThrow("already been taken"); + expect(() => prepared.createStreamDecoder()).toThrow("did not select streaming"); + + const responsePlaintext = encodeUtf8( + JSON.stringify({ + version: 2, + request_id: vectors.request_id_hex, + status: 200, + headers: [{ name: "content-type", value_base64: "YXBwbGljYXRpb24vanNvbg==" }], + body_base64: "eyJvayI6dHJ1ZX0=" + }) + ); + const responseRecord = encryptTransportV2Record( + keys.responseKey, + responsePlaintext, + unaryResponseRecordAad(vectors.session_id, vectors.request_id_hex) + ); + session.dispose(); + expect(session.isDisposed).toBe(true); + const response = prepared.decryptUnaryResponse( + JSON.stringify({ encrypted: encodeCanonicalBase64(responseRecord) }) + ); + expect(response.status).toBe(200); + expect(JSON.parse(new TextDecoder().decode(response.body!))).toEqual({ ok: true }); + expect(() => prepared.decryptUnaryResponse("{}")).toThrow("already selected"); + }); + + test("decodes arbitrary carrier splits and requires ordered authenticated finality", async () => { + const keys = await vectorKeys(); + const session = new TransportV2Session( + { + sessionId: vectors.session_id, + expiresAtUnixSeconds: vectors.expires_at_unix_seconds, + ...keys + }, + 3 + ); + const prepared = session.prepareRequest( + { + responseMode: "stream", + credential: null, + cacheNamespaceRoot: null, + request: { + method: "POST", + path: "/v1/responses", + query: null, + headers: [{ name: "content-type", value: encoder.encode("application/json") }], + body: encoder.encode("{}") + } + }, + fixedRequestIdRandom(hexToBytes(vectors.request_id_hex, 16)), + vectors.expires_at_unix_seconds - 1 + ); + prepared.takeHttpRequest(); + expect(() => prepared.decryptUnaryResponse("{}")).toThrow("did not select a unary"); + const decoder = prepared.createStreamDecoder(); + session.dispose(); + + const plaintexts = [ + JSON.stringify({ + version: 2, + request_id: vectors.request_id_hex, + sequence: 0, + kind: "start", + status: 200, + headers: [{ name: "content-type", value_base64: "dGV4dC9ldmVudC1zdHJlYW0=" }] + }), + JSON.stringify({ + version: 2, + request_id: vectors.request_id_hex, + sequence: 1, + kind: "chunk", + body_base64: "ZGF0YTogaGkKCg==" + }), + JSON.stringify({ + version: 2, + request_id: vectors.request_id_hex, + sequence: 2, + kind: "end" + }) + ]; + const carrier = plaintexts + .map((plaintext, sequence) => { + const encrypted = encryptTransportV2Record( + keys.responseKey, + encodeUtf8(plaintext), + streamResponseRecordAad(vectors.session_id, vectors.request_id_hex, sequence) + ); + return `data: ${encodeCanonicalBase64(encrypted)}\n\n`; + }) + .join(""); + const carrierBytes = encodeUtf8(carrier); + const records = [ + ...decoder.push(carrierBytes.slice(0, 7)), + ...decoder.push(carrierBytes.slice(7, 131)), + ...decoder.push(carrierBytes.slice(131)) + ]; + expect(records.map((record) => record.kind)).toEqual(["start", "chunk", "end"]); + expect(records[1].kind === "chunk" && new TextDecoder().decode(records[1].body)).toBe( + "data: hi\n\n" + ); + decoder.finish(); + }); + + test("authenticates a streaming request's explicit pre-Start unary error", async () => { + const keys = await vectorKeys(); + const session = new TransportV2Session( + { + sessionId: vectors.session_id, + expiresAtUnixSeconds: vectors.expires_at_unix_seconds, + ...keys + }, + 2 + ); + const prepared = session.prepareRequest( + { + responseMode: "stream", + credential: null, + cacheNamespaceRoot: null, + request: { + method: "POST", + path: "/v1/responses", + query: null, + headers: [], + body: encoder.encode("{}") + } + }, + fixedRequestIdRandom(hexToBytes(vectors.request_id_hex, 16)), + vectors.expires_at_unix_seconds - 1 + ); + prepared.takeHttpRequest(); + const plaintext = encodeUtf8( + JSON.stringify({ + version: 2, + request_id: vectors.request_id_hex, + status: 409, + headers: [], + body_base64: "eyJlcnJvciI6eyJjb2RlIjoicmVwbGF5X2RldGVjdGVkIn19" + }) + ); + const encrypted = encryptTransportV2Record( + keys.responseKey, + plaintext, + unaryResponseRecordAad(vectors.session_id, vectors.request_id_hex) + ); + const afterErrorRandom = fixedRequestIdRandom(new Uint8Array(16).fill(0x5b)); + const unaryInput = { + responseMode: "unary" as const, + credential: null, + cacheNamespaceRoot: null, + request: { + method: "GET" as const, + path: "/v1/models", + query: null, + headers: [], + body: null + } + }; + expect(() => + session.prepareRequest(unaryInput, afterErrorRandom, vectors.expires_at_unix_seconds - 1) + ).toThrow("response record budget"); + const response = prepared.decryptPreStartUnaryError( + JSON.stringify({ encrypted: encodeCanonicalBase64(encrypted) }) + ); + expect(response.status).toBe(409); + expect(() => prepared.createStreamDecoder()).toThrow("already selected"); + const afterError = session.prepareRequest( + unaryInput, + afterErrorRandom, + vectors.expires_at_unix_seconds - 1 + ); + expect(afterError.takeHttpRequest().path).toBe("/v2/request"); + afterError.dispose(); + }); + + test("atomically reserves unary or stream response capacity before exposing a request", async () => { + const keys = await vectorKeys(); + const session = new TransportV2Session( + { + sessionId: vectors.session_id, + expiresAtUnixSeconds: vectors.expires_at_unix_seconds, + ...keys + }, + 2 + ); + const unaryInput = { + responseMode: "unary" as const, + credential: null, + cacheNamespaceRoot: null, + request: { + method: "GET" as const, + path: "/v1/models", + query: null, + headers: [], + body: null + } + }; + const streamInput = { + ...unaryInput, + responseMode: "stream" as const, + request: { + ...unaryInput.request, + method: "POST" as const, + path: "/v1/responses", + body: encoder.encode("{}") + } + }; + + const first = session.prepareRequest( + unaryInput, + fixedRequestIdRandom(new Uint8Array(16).fill(0x81)), + vectors.expires_at_unix_seconds - 1 + ); + expect(first.takeHttpRequest().path).toBe("/v2/request"); + + const finalSlotRandom = fixedRequestIdRandom(new Uint8Array(16).fill(0x83)); + expect(() => + session.prepareRequest(streamInput, finalSlotRandom, vectors.expires_at_unix_seconds - 1) + ).toThrow("response record budget"); + const final = session.prepareRequest( + unaryInput, + finalSlotRandom, + vectors.expires_at_unix_seconds - 1 + ); + expect(final.takeHttpRequest().path).toBe("/v2/request"); + expect(() => + session.prepareRequest( + unaryInput, + fixedRequestIdRandom(new Uint8Array(16).fill(0x85)), + vectors.expires_at_unix_seconds - 1 + ) + ).toThrow("response record budget"); + first.dispose(); + final.dispose(); + expect(() => + session.prepareRequest( + unaryInput, + fixedRequestIdRandom(new Uint8Array(16).fill(0x87)), + vectors.expires_at_unix_seconds - 1 + ) + ).toThrow("response record budget"); + }); + + test("rolls back initial response capacity when request preparation fails", async () => { + const keys = await vectorKeys(); + const session = new TransportV2Session( + { + sessionId: vectors.session_id, + expiresAtUnixSeconds: vectors.expires_at_unix_seconds, + ...keys + }, + 1 + ); + const requestId = new Uint8Array(16).fill(0x89); + const invalidInput = { + responseMode: "unary" as const, + credential: null, + cacheNamespaceRoot: null, + request: { + method: "GET" as const, + path: "not-origin-relative", + query: null, + headers: [], + body: null + } + }; + expect(() => + session.prepareRequest( + invalidInput, + fixedRequestIdRandom(requestId), + vectors.expires_at_unix_seconds - 1 + ) + ).toThrow("origin-relative"); + + const prepared = session.prepareRequest( + { + ...invalidInput, + request: { ...invalidInput.request, path: "/v1/models" } + }, + fixedRequestIdRandom(requestId), + vectors.expires_at_unix_seconds - 1 + ); + expect(prepared.takeHttpRequest().path).toBe("/v2/request"); + prepared.dispose(); + }); + + test("allows exactly one concurrent request to reserve the final response slot", async () => { + const keys = await vectorKeys(); + const session = new TransportV2Session( + { + sessionId: vectors.session_id, + expiresAtUnixSeconds: vectors.expires_at_unix_seconds, + ...keys + }, + 1 + ); + const input = { + responseMode: "unary" as const, + credential: null, + cacheNamespaceRoot: null, + request: { + method: "GET" as const, + path: "/v1/models", + query: null, + headers: [], + body: null + } + }; + const attempts = await Promise.allSettled([ + Promise.resolve().then(() => + session.prepareRequest( + input, + fixedRequestIdRandom(new Uint8Array(16).fill(0x91)), + vectors.expires_at_unix_seconds - 1 + ) + ), + Promise.resolve().then(() => + session.prepareRequest( + input, + fixedRequestIdRandom(new Uint8Array(16).fill(0x93)), + vectors.expires_at_unix_seconds - 1 + ) + ) + ]); + const fulfilled = attempts.filter( + (attempt): attempt is PromiseFulfilledResult> => + attempt.status === "fulfilled" + ); + const rejected = attempts.filter( + (attempt): attempt is PromiseRejectedResult => attempt.status === "rejected" + ); + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(1); + expect(rejected[0].reason).toBeInstanceOf(TransportV2ProtocolError); + expect((rejected[0].reason as Error).message).toContain("response record budget"); + expect(fulfilled[0].value.takeHttpRequest().path).toBe("/v2/request"); + fulfilled[0].value.dispose(); + }); + + test("releases explicitly abandoned prepared and streaming response contexts", async () => { + const keys = await vectorKeys(); + const session = new TransportV2Session({ + sessionId: vectors.session_id, + expiresAtUnixSeconds: vectors.expires_at_unix_seconds, + ...keys + }); + const prepared = session.prepareRequest( + { + responseMode: "unary", + credential: null, + cacheNamespaceRoot: null, + request: { + method: "GET", + path: "/v1/models", + query: null, + headers: [], + body: null + } + }, + fixedRequestIdRandom(hexToBytes(vectors.request_id_hex, 16)), + vectors.expires_at_unix_seconds - 1 + ); + prepared.dispose(); + expect(() => prepared.takeHttpRequest()).toThrow("already been taken"); + expect(() => prepared.decryptUnaryResponse("{}")).toThrow("already selected"); + + let releases = 0; + const decoder = new TransportV2StreamDecoder( + vectors.request_id_hex, + () => new Uint8Array(0), + undefined, + () => { + releases += 1; + } + ); + decoder.dispose(); + decoder.dispose(); + expect(releases).toBe(1); + expect(() => decoder.push(encoder.encode("x"))).toThrow("failed closed"); + }); + + test("fails closed on duplicate JSON fields and stream EOF before terminal", async () => { + const duplicate = encodeUtf8( + `{"version":2,"request_id":"${vectors.request_id_hex}","status":200,"status":201,"headers":[],"body_base64":null}` + ); + expect(() => parseUnaryResponseEnvelope(duplicate)).toThrow("duplicate field"); + + const keys = await vectorKeys(); + const session = new TransportV2Session({ + sessionId: vectors.session_id, + expiresAtUnixSeconds: vectors.expires_at_unix_seconds, + ...keys + }); + const prepared = session.prepareRequest( + { + responseMode: "stream", + credential: null, + cacheNamespaceRoot: null, + request: { + method: "POST", + path: "/v1/responses", + query: null, + headers: [], + body: encoder.encode("{}") + } + }, + fixedRequestIdRandom(hexToBytes(vectors.request_id_hex, 16)), + vectors.expires_at_unix_seconds - 1 + ); + prepared.takeHttpRequest(); + const decoder = prepared.createStreamDecoder(); + const start = encodeUtf8( + JSON.stringify({ + version: 2, + request_id: vectors.request_id_hex, + sequence: 0, + kind: "start", + status: 200, + headers: [] + }) + ); + const encrypted = encryptTransportV2Record( + keys.responseKey, + start, + streamResponseRecordAad(vectors.session_id, vectors.request_id_hex, 0) + ); + decoder.push(encodeUtf8(`data: ${encodeCanonicalBase64(encrypted)}\n\n`)); + expect(() => decoder.finish()).toThrow("without an authenticated terminal"); + }); + + test("enforces the cumulative logical stream bound independent of chunk boundaries", async () => { + const keys = await vectorKeys(); + const plaintexts = [ + JSON.stringify({ + version: 2, + request_id: vectors.request_id_hex, + sequence: 0, + kind: "start", + status: 200, + headers: [] + }), + JSON.stringify({ + version: 2, + request_id: vectors.request_id_hex, + sequence: 1, + kind: "chunk", + body_base64: "YWJj" + }), + JSON.stringify({ + version: 2, + request_id: vectors.request_id_hex, + sequence: 2, + kind: "chunk", + body_base64: "ZGU=" + }), + JSON.stringify({ + version: 2, + request_id: vectors.request_id_hex, + sequence: 3, + kind: "end" + }) + ]; + const frames = plaintexts.map((plaintext, sequence) => { + const encrypted = encryptTransportV2Record( + keys.responseKey, + encodeUtf8(plaintext), + streamResponseRecordAad(vectors.session_id, vectors.request_id_hex, sequence) + ); + return encodeUtf8(`data: ${encodeCanonicalBase64(encrypted)}\n\n`); + }); + const decrypt = (encrypted: Uint8Array, sequence: number) => + decryptTransportV2Record( + keys.responseKey, + encrypted, + streamResponseRecordAad(vectors.session_id, vectors.request_id_hex, sequence), + 1024 + ); + + const boundary = new TransportV2StreamDecoder(vectors.request_id_hex, decrypt, 5); + for (const frame of frames) boundary.push(frame); + boundary.finish(); + + let reservedChunks = 0; + const oversized = new TransportV2StreamDecoder( + vectors.request_id_hex, + decrypt, + 4, + () => {}, + () => { + reservedChunks += 1; + } + ); + oversized.push(frames[0]); + oversized.push(frames[1]); + expect(() => oversized.push(frames[2])).toThrow("logical stream exceeds"); + expect(reservedChunks).toBe(2); + expect(() => oversized.push(frames[3])).toThrow("failed closed"); + }); + + test("accepts authenticated Error as the sole stream terminal", async () => { + const keys = await vectorKeys(); + const records = [ + JSON.stringify({ + version: 2, + request_id: vectors.request_id_hex, + sequence: 0, + kind: "start", + status: 200, + headers: [] + }), + JSON.stringify({ + version: 2, + request_id: vectors.request_id_hex, + sequence: 1, + kind: "error", + status: 500, + body_base64: "eyJlcnJvciI6eyJjb2RlIjoic3RyZWFtX2ZhaWxlZCJ9fQ==" + }) + ]; + const decrypt = (encrypted: Uint8Array, sequence: number) => + decryptTransportV2Record( + keys.responseKey, + encrypted, + streamResponseRecordAad(vectors.session_id, vectors.request_id_hex, sequence), + 1024 + ); + const decoder = new TransportV2StreamDecoder(vectors.request_id_hex, decrypt); + for (const [sequence, plaintext] of records.entries()) { + const encrypted = encryptTransportV2Record( + keys.responseKey, + encodeUtf8(plaintext), + streamResponseRecordAad(vectors.session_id, vectors.request_id_hex, sequence) + ); + decoder.push(encodeUtf8(`data: ${encodeCanonicalBase64(encrypted)}\n\n`)); + } + decoder.finish(); + expect(() => decoder.push(encodeUtf8("data: AA==\n\n"))).toThrow("data after"); + expect(() => decoder.finish()).toThrow("failed closed"); + }); + + test("uses the backend's byte-exact opaque segment codec", () => { + expect(encodeCanonicalOpaquePathSegment("Production Key-1_test/é")).toBe( + "Production%20Key%2D1%5Ftest%2F%C3%A9" + ); + }); +}); diff --git a/sdk/src/lib/transportV2/crypto.ts b/sdk/src/lib/transportV2/crypto.ts new file mode 100644 index 000000000..a2ee8ca17 --- /dev/null +++ b/sdk/src/lib/transportV2/crypto.ts @@ -0,0 +1,220 @@ +import { ChaCha20Poly1305 } from "@stablelib/chacha20poly1305"; +import { + MIN_ENCRYPTED_RECORD_BYTES, + RECORD_NONCE_BYTES, + SESSION_KEY_BYTES, + TRANSPORT_V2_VERSION, + TransportV2ProtocolError, + bytesToUuid, + concatBytes, + encodeUtf8, + equalBytes, + readSafeUint64, + requestIdToBytes, + sequenceToBytes, + uuidToBytes +} from "./encoding"; + +const HANDSHAKE_KEY_INFO = encodeUtf8("opensecret/transport-v2/handshake-key"); +const REQUEST_KEY_INFO = encodeUtf8("opensecret/transport-v2/client-request"); +const RESPONSE_KEY_INFO = encodeUtf8("opensecret/transport-v2/enclave-response"); + +const KEY_EXCHANGE_AAD = encodeUtf8("opensecret/transport-v2/key-exchange"); +const REQUEST_RECORD_AAD = encodeUtf8("opensecret/transport-v2/request-record"); +const UNARY_RESPONSE_RECORD_AAD = encodeUtf8("opensecret/transport-v2/unary-response-record"); +const STREAM_RESPONSE_RECORD_AAD = encodeUtf8("opensecret/transport-v2/stream-response-record"); + +const HANDSHAKE_PAYLOAD_BYTES = 1 + 16 + SESSION_KEY_BYTES + 8; +const HANDSHAKE_RECORD_BYTES = RECORD_NONCE_BYTES + HANDSHAKE_PAYLOAD_BYTES + 16; + +export interface TransportV2DirectionalKeys { + requestKey: Uint8Array; + responseKey: Uint8Array; +} + +export interface TransportV2HandshakeResult extends TransportV2DirectionalKeys { + sessionId: string; + expiresAtUnixSeconds: number; +} + +async function hkdfSha256( + inputKeyMaterial: Uint8Array, + info: Uint8Array, + subtle: SubtleCrypto = globalThis.crypto.subtle +): Promise { + if (inputKeyMaterial.length === 0) { + throw new TransportV2ProtocolError("Transport v2 key material is empty."); + } + try { + const key = await subtle.importKey("raw", inputKeyMaterial, "HKDF", false, ["deriveBits"]); + const bits = await subtle.deriveBits( + { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info }, + key, + SESSION_KEY_BYTES * 8 + ); + return new Uint8Array(bits); + } catch { + throw new TransportV2ProtocolError("Transport v2 key derivation failed."); + } +} + +export async function deriveTransportV2DirectionalKeys( + sessionMaster: Uint8Array, + subtle: SubtleCrypto = globalThis.crypto.subtle +): Promise { + if (sessionMaster.length !== SESSION_KEY_BYTES) { + throw new TransportV2ProtocolError("Transport v2 session master has an invalid length."); + } + const requestKey = await hkdfSha256(sessionMaster, REQUEST_KEY_INFO, subtle); + try { + const responseKey = await hkdfSha256(sessionMaster, RESPONSE_KEY_INFO, subtle); + return { requestKey, responseKey }; + } catch (error) { + requestKey.fill(0); + throw error; + } +} + +async function deriveHandshakeKey( + sharedSecret: Uint8Array, + subtle: SubtleCrypto = globalThis.crypto.subtle +): Promise { + if (sharedSecret.length !== SESSION_KEY_BYTES || sharedSecret.every((byte) => byte === 0)) { + throw new TransportV2ProtocolError("Transport v2 key exchange is non-contributory."); + } + return hkdfSha256(sharedSecret, HANDSHAKE_KEY_INFO, subtle); +} + +function requireRecordKey(key: Uint8Array): void { + if (key.length !== SESSION_KEY_BYTES) { + throw new TransportV2ProtocolError("Transport v2 record key has an invalid length."); + } +} + +export function encryptTransportV2Record( + key: Uint8Array, + plaintext: Uint8Array, + aad: Uint8Array, + nonce?: Uint8Array, + random = globalThis.crypto +): Uint8Array { + requireRecordKey(key); + const recordNonce = nonce ? new Uint8Array(nonce) : new Uint8Array(RECORD_NONCE_BYTES); + if (recordNonce.length !== RECORD_NONCE_BYTES) { + throw new TransportV2ProtocolError("Transport v2 record nonce has an invalid length."); + } + if (!nonce) { + if (!random?.getRandomValues) { + throw new TransportV2ProtocolError("Secure randomness is unavailable."); + } + random.getRandomValues(recordNonce); + } + + const cipher = new ChaCha20Poly1305(key); + try { + return concatBytes(recordNonce, cipher.seal(recordNonce, plaintext, aad)); + } catch { + throw new TransportV2ProtocolError("Transport v2 record encryption failed."); + } finally { + cipher.clean(); + } +} + +export function decryptTransportV2Record( + key: Uint8Array, + record: Uint8Array, + aad: Uint8Array, + maxPlaintextBytes: number +): Uint8Array { + requireRecordKey(key); + if ( + record.length < MIN_ENCRYPTED_RECORD_BYTES || + record.length > maxPlaintextBytes + MIN_ENCRYPTED_RECORD_BYTES + ) { + throw new TransportV2ProtocolError("Transport v2 encrypted record has an invalid length."); + } + const nonce = record.subarray(0, RECORD_NONCE_BYTES); + const ciphertext = record.subarray(RECORD_NONCE_BYTES); + const cipher = new ChaCha20Poly1305(key); + try { + const plaintext = cipher.open(nonce, ciphertext, aad); + if (!plaintext || plaintext.length > maxPlaintextBytes) { + plaintext?.fill(0); + throw new TransportV2ProtocolError("Transport v2 record authentication failed."); + } + return plaintext; + } catch (error) { + if (error instanceof TransportV2ProtocolError) throw error; + throw new TransportV2ProtocolError("Transport v2 record authentication failed."); + } finally { + cipher.clean(); + } +} + +export function requestRecordAad(sessionId: string): Uint8Array { + return concatBytes(REQUEST_RECORD_AAD, new Uint8Array([0]), uuidToBytes(sessionId)); +} + +export function unaryResponseRecordAad(sessionId: string, requestId: string): Uint8Array { + return concatBytes( + UNARY_RESPONSE_RECORD_AAD, + new Uint8Array([0]), + uuidToBytes(sessionId), + requestIdToBytes(requestId) + ); +} + +export function streamResponseRecordAad( + sessionId: string, + requestId: string, + sequence: number +): Uint8Array { + return concatBytes( + STREAM_RESPONSE_RECORD_AAD, + new Uint8Array([0]), + uuidToBytes(sessionId), + requestIdToBytes(requestId), + sequenceToBytes(sequence) + ); +} + +export async function decryptTransportV2Handshake( + sharedSecret: Uint8Array, + outerSessionId: string, + encryptedRecord: Uint8Array, + subtle: SubtleCrypto = globalThis.crypto.subtle +): Promise { + const outerSessionBytes = uuidToBytes(outerSessionId); + if (encryptedRecord.length !== HANDSHAKE_RECORD_BYTES) { + throw new TransportV2ProtocolError("Transport v2 handshake record has an invalid length."); + } + const handshakeKey = await deriveHandshakeKey(sharedSecret, subtle); + let payload: Uint8Array | undefined; + try { + payload = decryptTransportV2Record( + handshakeKey, + encryptedRecord, + KEY_EXCHANGE_AAD, + HANDSHAKE_PAYLOAD_BYTES + ); + if (payload.length !== HANDSHAKE_PAYLOAD_BYTES || payload[0] !== TRANSPORT_V2_VERSION) { + throw new TransportV2ProtocolError("Transport v2 handshake payload is invalid."); + } + const innerSessionBytes = payload.subarray(1, 17); + if (!equalBytes(outerSessionBytes, innerSessionBytes)) { + throw new TransportV2ProtocolError("Transport v2 handshake session IDs do not match."); + } + const sessionId = bytesToUuid(innerSessionBytes); + const sessionMaster = new Uint8Array(payload.subarray(17, 49)); + const expiresAtUnixSeconds = readSafeUint64(payload.subarray(49, 57)); + try { + const keys = await deriveTransportV2DirectionalKeys(sessionMaster, subtle); + return { sessionId, expiresAtUnixSeconds, ...keys }; + } finally { + sessionMaster.fill(0); + } + } finally { + payload?.fill(0); + handshakeKey.fill(0); + } +} diff --git a/sdk/src/lib/transportV2/encoding.ts b/sdk/src/lib/transportV2/encoding.ts new file mode 100644 index 000000000..b85448e11 --- /dev/null +++ b/sdk/src/lib/transportV2/encoding.ts @@ -0,0 +1,335 @@ +import { decode, encode } from "@stablelib/base64"; + +export const TRANSPORT_V2_VERSION = 2 as const; +export const SESSION_ID_BYTES = 16; +export const REQUEST_ID_BYTES = 16; +export const SESSION_KEY_BYTES = 32; +export const RECORD_NONCE_BYTES = 12; +export const RECORD_TAG_BYTES = 16; +export const MIN_ENCRYPTED_RECORD_BYTES = RECORD_NONCE_BYTES + RECORD_TAG_BYTES; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const REQUEST_ID_PATTERN = /^[0-9a-f]{32}$/; +const JSON_WHITESPACE = new Set([" ", "\t", "\n", "\r"]); +const MAX_JSON_DEPTH = 64; + +export class TransportV2ProtocolError extends Error { + constructor(message: string) { + super(message); + this.name = "TransportV2ProtocolError"; + } +} + +export function concatBytes(...parts: readonly Uint8Array[]): Uint8Array { + const length = parts.reduce((total, part) => total + part.length, 0); + const output = new Uint8Array(length); + let offset = 0; + for (const part of parts) { + output.set(part, offset); + offset += part.length; + } + return output; +} + +export function encodeUtf8(value: string): Uint8Array { + return new TextEncoder().encode(value); +} + +export function decodeUtf8(value: Uint8Array): string { + try { + return new TextDecoder("utf-8", { fatal: true }).decode(value); + } catch { + throw new TransportV2ProtocolError("Transport v2 record is not valid UTF-8."); + } +} + +export function encodeCanonicalBase64(value: Uint8Array): string { + return encode(value); +} + +export function decodeCanonicalBase64(value: string, maxDecodedBytes: number): Uint8Array { + if (typeof value !== "string") { + throw new TransportV2ProtocolError("Transport v2 field is not base64 text."); + } + + const maximumEncodedLength = Math.ceil(maxDecodedBytes / 3) * 4; + if (value.length > maximumEncodedLength) { + throw new TransportV2ProtocolError("Transport v2 base64 field exceeds its size limit."); + } + + let decoded: Uint8Array; + try { + decoded = decode(value); + } catch { + throw new TransportV2ProtocolError("Transport v2 field is not valid standard base64."); + } + if (decoded.length > maxDecodedBytes || encode(decoded) !== value) { + decoded.fill(0); + throw new TransportV2ProtocolError("Transport v2 field is not canonical padded base64."); + } + return decoded; +} + +export function bytesToHex(value: Uint8Array): string { + return Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +export function hexToBytes(value: string, expectedBytes: number): Uint8Array { + if ( + value.length !== expectedBytes * 2 || + !Array.from(value).every((character) => /[0-9a-f]/.test(character)) + ) { + throw new TransportV2ProtocolError("Transport v2 field is not canonical lowercase hex."); + } + const bytes = new Uint8Array(expectedBytes); + for (let index = 0; index < expectedBytes; index += 1) { + bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16); + } + return bytes; +} + +export function requestIdToBytes(requestId: string): Uint8Array { + if (!REQUEST_ID_PATTERN.test(requestId)) { + throw new TransportV2ProtocolError("Transport v2 request ID is not canonical."); + } + return hexToBytes(requestId, REQUEST_ID_BYTES); +} + +export function generateRequestId(random = globalThis.crypto): string { + if (!random?.getRandomValues) { + throw new TransportV2ProtocolError("Secure randomness is unavailable."); + } + const bytes = new Uint8Array(REQUEST_ID_BYTES); + random.getRandomValues(bytes); + return bytesToHex(bytes); +} + +export function uuidToBytes(uuid: string): Uint8Array { + if (!UUID_PATTERN.test(uuid)) { + throw new TransportV2ProtocolError("Transport v2 session ID is not canonical."); + } + return hexToBytes(uuid.replaceAll("-", ""), SESSION_ID_BYTES); +} + +export function bytesToUuid(bytes: Uint8Array): string { + if (bytes.length !== SESSION_ID_BYTES) { + throw new TransportV2ProtocolError("Transport v2 session ID has an invalid length."); + } + const hex = bytesToHex(bytes); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice( + 16, + 20 + )}-${hex.slice(20)}`; +} + +export function sequenceToBytes(sequence: number): Uint8Array { + if (!Number.isSafeInteger(sequence) || sequence < 0) { + throw new TransportV2ProtocolError("Transport v2 stream sequence is invalid."); + } + const bytes = new Uint8Array(8); + new DataView(bytes.buffer).setBigUint64(0, BigInt(sequence), false); + return bytes; +} + +export function readSafeUint64(bytes: Uint8Array): number { + if (bytes.length !== 8) { + throw new TransportV2ProtocolError("Transport v2 integer has an invalid length."); + } + const value = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getBigUint64( + 0, + false + ); + if (value > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new TransportV2ProtocolError("Transport v2 integer exceeds the client range."); + } + return Number(value); +} + +export function equalBytes(left: Uint8Array, right: Uint8Array): boolean { + if (left.length !== right.length) return false; + let difference = 0; + for (let index = 0; index < left.length; index += 1) { + difference |= left[index] ^ right[index]; + } + return difference === 0; +} + +export function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function requireExactObject( + value: unknown, + expectedKeys: readonly string[], + description: string +): Record { + if (!isPlainObject(value)) { + throw new TransportV2ProtocolError(`${description} is not an object.`); + } + const actualKeys = Object.keys(value).sort(); + const sortedExpected = [...expectedKeys].sort(); + if ( + actualKeys.length !== sortedExpected.length || + actualKeys.some((key, index) => key !== sortedExpected[index]) + ) { + throw new TransportV2ProtocolError(`${description} has an unexpected shape.`); + } + return value; +} + +/** + * Parses JSON while rejecting duplicate object members before JSON.parse can + * collapse them. The protocol's schemas validate the returned value next. + */ +export function parseStrictJson(input: string): unknown { + let index = 0; + + const skipWhitespace = () => { + while (index < input.length && JSON_WHITESPACE.has(input[index])) index += 1; + }; + + const parseString = (): string => { + const start = index; + if (input[index] !== '"') throw new TransportV2ProtocolError("Invalid transport v2 JSON."); + index += 1; + while (index < input.length) { + const character = input[index]; + if (character === '"') { + index += 1; + try { + return JSON.parse(input.slice(start, index)) as string; + } catch { + throw new TransportV2ProtocolError("Invalid transport v2 JSON string."); + } + } + if (character === "\\") { + index += 1; + if (index >= input.length) break; + if (input[index] === "u") { + const unicode = input.slice(index + 1, index + 5); + if (!/^[0-9a-fA-F]{4}$/.test(unicode)) { + throw new TransportV2ProtocolError("Invalid transport v2 JSON escape."); + } + index += 5; + continue; + } + if (!'"\\/bfnrt'.includes(input[index])) { + throw new TransportV2ProtocolError("Invalid transport v2 JSON escape."); + } + index += 1; + continue; + } + if (character.charCodeAt(0) < 0x20) { + throw new TransportV2ProtocolError("Invalid transport v2 JSON string."); + } + index += 1; + } + throw new TransportV2ProtocolError("Unterminated transport v2 JSON string."); + }; + + const parseNumber = () => { + const rest = input.slice(index); + const match = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/.exec(rest); + if (!match) throw new TransportV2ProtocolError("Invalid transport v2 JSON number."); + index += match[0].length; + }; + + const parseValue = (depth: number): void => { + if (depth > MAX_JSON_DEPTH) { + throw new TransportV2ProtocolError("Transport v2 JSON nesting is too deep."); + } + skipWhitespace(); + const character = input[index]; + if (character === "{") { + index += 1; + skipWhitespace(); + if (input[index] === "}") { + index += 1; + return; + } + const keys = new Set(); + while (true) { + skipWhitespace(); + const key = parseString(); + if (keys.has(key)) { + throw new TransportV2ProtocolError("Transport v2 JSON contains a duplicate field."); + } + keys.add(key); + skipWhitespace(); + if (input[index] !== ":") { + throw new TransportV2ProtocolError("Invalid transport v2 JSON object."); + } + index += 1; + parseValue(depth + 1); + skipWhitespace(); + if (input[index] === "}") { + index += 1; + return; + } + if (input[index] !== ",") { + throw new TransportV2ProtocolError("Invalid transport v2 JSON object."); + } + index += 1; + } + } + if (character === "[") { + index += 1; + skipWhitespace(); + if (input[index] === "]") { + index += 1; + return; + } + while (true) { + parseValue(depth + 1); + skipWhitespace(); + if (input[index] === "]") { + index += 1; + return; + } + if (input[index] !== ",") { + throw new TransportV2ProtocolError("Invalid transport v2 JSON array."); + } + index += 1; + } + } + if (character === '"') { + parseString(); + return; + } + for (const literal of ["true", "false", "null"] as const) { + if (input.startsWith(literal, index)) { + index += literal.length; + return; + } + } + parseNumber(); + }; + + parseValue(0); + skipWhitespace(); + if (index !== input.length) { + throw new TransportV2ProtocolError("Invalid trailing transport v2 JSON data."); + } + try { + return JSON.parse(input) as unknown; + } catch { + throw new TransportV2ProtocolError("Invalid transport v2 JSON."); + } +} + +export function encodeCanonicalOpaquePathSegment(value: string): string { + const bytes = encodeUtf8(value); + let result = ""; + for (const byte of bytes) { + if ( + (byte >= 0x30 && byte <= 0x39) || + (byte >= 0x41 && byte <= 0x5a) || + (byte >= 0x61 && byte <= 0x7a) + ) { + result += String.fromCharCode(byte); + } else { + result += `%${byte.toString(16).toUpperCase().padStart(2, "0")}`; + } + } + return result; +} diff --git a/sdk/src/lib/transportV2/envelope.ts b/sdk/src/lib/transportV2/envelope.ts new file mode 100644 index 000000000..546352b16 --- /dev/null +++ b/sdk/src/lib/transportV2/envelope.ts @@ -0,0 +1,475 @@ +import { + TRANSPORT_V2_VERSION, + TransportV2ProtocolError, + decodeCanonicalBase64, + decodeUtf8, + encodeCanonicalBase64, + encodeCanonicalOpaquePathSegment, + encodeUtf8, + parseStrictJson, + requestIdToBytes, + requireExactObject +} from "./encoding"; + +const KIB = 1024; +const MIB = KIB * KIB; + +export const TRANSPORT_V2_LIMITS = Object.freeze({ + envelopeBytes: 50 * MIB, + logicalBodyBytes: 28 * MIB, + pathBytes: 4096, + queryBytes: 8192, + headerCount: 64, + headerNameBytes: 128, + headerValueBytes: 16 * KIB, + aggregateHeaderBytes: 64 * KIB, + credentialBytes: 16 * KIB, + streamChunkBytes: 64 * KIB, + streamErrorBytes: 16 * KIB +}); + +export type LogicalMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; +export type ResponseMode = "unary" | "stream"; +export type CredentialKind = "api_key" | "resumption"; + +export interface TransportV2Header { + name: string; + value: Uint8Array; +} + +export interface TransportV2Credential { + kind: CredentialKind; + value: Uint8Array; +} + +export interface TransportV2LogicalRequest { + method: LogicalMethod; + path: string; + query: string | null; + headers: readonly TransportV2Header[]; + body: Uint8Array | null; +} + +export interface TransportV2RequestEnvelope { + requestId: string; + responseMode: ResponseMode; + credential: TransportV2Credential | null; + cacheNamespaceRoot: Uint8Array | null; + request: TransportV2LogicalRequest; +} + +export interface TransportV2UnaryResponse { + requestId: string; + status: number; + headers: TransportV2Header[]; + body: Uint8Array | null; +} + +export type TransportV2StreamRecord = + | { + kind: "start"; + requestId: string; + sequence: number; + status: number; + headers: TransportV2Header[]; + } + | { + kind: "chunk"; + requestId: string; + sequence: number; + body: Uint8Array; + } + | { kind: "end"; requestId: string; sequence: number } + | { + kind: "error"; + requestId: string; + sequence: number; + status: number; + body: Uint8Array; + }; + +type WireHeader = { name: string; value_base64: string }; + +function checkByteLimit(value: string, limit: number, description: string): void { + if (encodeUtf8(value).length > limit) { + throw new TransportV2ProtocolError(`${description} exceeds its size limit.`); + } +} + +function isHexDigit(value: number): boolean { + return ( + (value >= 0x30 && value <= 0x39) || + (value >= 0x41 && value <= 0x46) || + (value >= 0x61 && value <= 0x66) + ); +} + +function decodePercentTriplet(value: string, index: number): number { + if ( + value[index] !== "%" || + index + 2 >= value.length || + !isHexDigit(value.charCodeAt(index + 1)) || + !isHexDigit(value.charCodeAt(index + 2)) + ) { + throw new TransportV2ProtocolError("Transport v2 URI contains invalid percent encoding."); + } + return Number.parseInt(value.slice(index + 1, index + 3), 16); +} + +function decodedSegment(value: string): Uint8Array { + const bytes: number[] = []; + for (let index = 0; index < value.length;) { + if (value[index] === "%") { + bytes.push(decodePercentTriplet(value, index)); + index += 3; + } else { + bytes.push(value.charCodeAt(index)); + index += 1; + } + } + return new Uint8Array(bytes); +} + +function validateOpaquePath(path: string): boolean { + for (const prefix of ["/protected/kv/", "/protected/api-keys/"] as const) { + if (!path.startsWith(prefix)) continue; + const segment = path.slice(prefix.length); + if (!segment || segment.includes("/")) { + throw new TransportV2ProtocolError("Transport v2 opaque path segment is invalid."); + } + const decoded = decodeUtf8(decodedSegment(segment)); + if (encodeCanonicalOpaquePathSegment(decoded) !== segment) { + throw new TransportV2ProtocolError("Transport v2 opaque path segment is not canonical."); + } + return true; + } + return false; +} + +function isUriPchar(byte: number): boolean { + return ( + (byte >= 0x30 && byte <= 0x39) || + (byte >= 0x41 && byte <= 0x5a) || + (byte >= 0x61 && byte <= 0x7a) || + "-._~!$&'()*+,;=:@".includes(String.fromCharCode(byte)) + ); +} + +function validatePath(path: string): void { + checkByteLimit(path, TRANSPORT_V2_LIMITS.pathBytes, "Transport v2 path"); + if (!path.startsWith("/") || path.startsWith("//")) { + throw new TransportV2ProtocolError("Transport v2 path is not origin-relative."); + } + if (path.includes("?") || path.includes("#") || path.includes("\\")) { + throw new TransportV2ProtocolError("Transport v2 path contains a forbidden delimiter."); + } + if (validateOpaquePath(path)) return; + + for (let index = 0; index < path.length;) { + const byte = path.charCodeAt(index); + if (byte === 0x25) { + const decoded = decodePercentTriplet(path, index); + if (decoded === 0x2f || decoded === 0x5c) { + throw new TransportV2ProtocolError("Transport v2 path contains an encoded separator."); + } + index += 3; + continue; + } + if (byte !== 0x2f && !isUriPchar(byte)) { + throw new TransportV2ProtocolError("Transport v2 path contains an invalid character."); + } + index += 1; + } + + for (const segment of path.split("/")) { + const decoded = decodedSegment(segment); + if ( + (decoded.length === 1 && decoded[0] === 0x2e) || + (decoded.length === 2 && decoded[0] === 0x2e && decoded[1] === 0x2e) + ) { + throw new TransportV2ProtocolError("Transport v2 path contains a dot-segment."); + } + } +} + +function validateQuery(query: string): void { + checkByteLimit(query, TRANSPORT_V2_LIMITS.queryBytes, "Transport v2 query"); + if (query.startsWith("?") || query.startsWith("#") || query.includes("#")) { + throw new TransportV2ProtocolError("Transport v2 query contains a forbidden delimiter."); + } + for (let index = 0; index < query.length;) { + const byte = query.charCodeAt(index); + if (byte === 0x25) { + decodePercentTriplet(query, index); + index += 3; + continue; + } + if (byte !== 0x2f && byte !== 0x3f && !isUriPchar(byte)) { + throw new TransportV2ProtocolError("Transport v2 query contains an invalid character."); + } + index += 1; + } +} + +function isLowercaseHttpToken(name: string): boolean { + return /^[a-z0-9!#$%&'*+.^_`|~-]+$/.test(name); +} + +function validateAndEncodeHeaders(headers: readonly TransportV2Header[]): WireHeader[] { + if (headers.length > TRANSPORT_V2_LIMITS.headerCount) { + throw new TransportV2ProtocolError("Transport v2 has too many headers."); + } + let aggregateBytes = 0; + return headers.map((header) => { + const nameBytes = encodeUtf8(header.name).length; + if ( + nameBytes === 0 || + nameBytes > TRANSPORT_V2_LIMITS.headerNameBytes || + !isLowercaseHttpToken(header.name) + ) { + throw new TransportV2ProtocolError("Transport v2 header name is invalid."); + } + if (header.value.length > TRANSPORT_V2_LIMITS.headerValueBytes) { + throw new TransportV2ProtocolError("Transport v2 header value exceeds its size limit."); + } + if (header.value.some((byte) => byte === 0 || byte === 0x0a || byte === 0x0d)) { + throw new TransportV2ProtocolError("Transport v2 header value is invalid."); + } + aggregateBytes += nameBytes + header.value.length; + if (aggregateBytes > TRANSPORT_V2_LIMITS.aggregateHeaderBytes) { + throw new TransportV2ProtocolError("Transport v2 headers exceed their aggregate limit."); + } + return { name: header.name, value_base64: encodeCanonicalBase64(header.value) }; + }); +} + +function parseHeaders(value: unknown): TransportV2Header[] { + if (!Array.isArray(value) || value.length > TRANSPORT_V2_LIMITS.headerCount) { + throw new TransportV2ProtocolError("Transport v2 response headers are invalid."); + } + const decoded: TransportV2Header[] = []; + let aggregateBytes = 0; + for (const candidate of value) { + const header = requireExactObject(candidate, ["name", "value_base64"], "Transport v2 header"); + if (typeof header.name !== "string" || !isLowercaseHttpToken(header.name)) { + throw new TransportV2ProtocolError("Transport v2 header name is invalid."); + } + const nameBytes = encodeUtf8(header.name).length; + if (nameBytes > TRANSPORT_V2_LIMITS.headerNameBytes) { + throw new TransportV2ProtocolError("Transport v2 header name exceeds its size limit."); + } + const bytes = decodeCanonicalBase64( + requireString(header.value_base64, "Transport v2 header value"), + TRANSPORT_V2_LIMITS.headerValueBytes + ); + if (bytes.some((byte) => byte === 0 || byte === 0x0a || byte === 0x0d)) { + bytes.fill(0); + throw new TransportV2ProtocolError("Transport v2 header value is invalid."); + } + aggregateBytes += nameBytes + bytes.length; + if (aggregateBytes > TRANSPORT_V2_LIMITS.aggregateHeaderBytes) { + bytes.fill(0); + throw new TransportV2ProtocolError("Transport v2 headers exceed their aggregate limit."); + } + decoded.push({ name: header.name, value: bytes }); + } + return decoded; +} + +function requireString(value: unknown, description: string): string { + if (typeof value !== "string") { + throw new TransportV2ProtocolError(`${description} is not text.`); + } + return value; +} + +function requireSafeInteger(value: unknown, description: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new TransportV2ProtocolError(`${description} is not a safe non-negative integer.`); + } + return value as number; +} + +function requireStatus(value: unknown, minimum: number, maximum: number): number { + const status = requireSafeInteger(value, "Transport v2 response status"); + if (status < minimum || status > maximum) { + throw new TransportV2ProtocolError("Transport v2 response status is invalid."); + } + return status; +} + +function requireVersion(value: unknown): void { + if (value !== TRANSPORT_V2_VERSION) { + throw new TransportV2ProtocolError("Transport v2 record has the wrong version."); + } +} + +function requireRequestId(value: unknown): string { + const requestId = requireString(value, "Transport v2 request ID"); + requestIdToBytes(requestId); + return requestId; +} + +function parseBody(value: unknown, limit: number, nullable: boolean): Uint8Array | null { + if (value === null && nullable) return null; + return decodeCanonicalBase64(requireString(value, "Transport v2 body"), limit); +} + +export function serializeRequestEnvelope(envelope: TransportV2RequestEnvelope): Uint8Array { + requestIdToBytes(envelope.requestId); + if (!(["unary", "stream"] as const).includes(envelope.responseMode)) { + throw new TransportV2ProtocolError("Transport v2 response mode is invalid."); + } + validatePath(envelope.request.path); + if (!(["GET", "POST", "PUT", "PATCH", "DELETE"] as const).includes(envelope.request.method)) { + throw new TransportV2ProtocolError("Transport v2 logical method is invalid."); + } + if (envelope.request.query !== null) validateQuery(envelope.request.query); + const headers = validateAndEncodeHeaders(envelope.request.headers); + if ( + envelope.request.body && + envelope.request.body.length > TRANSPORT_V2_LIMITS.logicalBodyBytes + ) { + throw new TransportV2ProtocolError("Transport v2 body exceeds its size limit."); + } + + let credential: { kind: CredentialKind; value_base64: string } | null = null; + if (envelope.credential) { + if (!(["api_key", "resumption"] as const).includes(envelope.credential.kind)) { + throw new TransportV2ProtocolError("Transport v2 credential kind is invalid."); + } + if (envelope.credential.value.length > TRANSPORT_V2_LIMITS.credentialBytes) { + throw new TransportV2ProtocolError("Transport v2 credential exceeds its size limit."); + } + credential = { + kind: envelope.credential.kind, + value_base64: encodeCanonicalBase64(envelope.credential.value) + }; + } + if (envelope.cacheNamespaceRoot && envelope.cacheNamespaceRoot.length !== 32) { + throw new TransportV2ProtocolError("Transport v2 cache namespace root must be 32 bytes."); + } + + const wire = { + version: TRANSPORT_V2_VERSION, + request_id: envelope.requestId, + response_mode: envelope.responseMode, + credential, + cache_namespace_root_base64: envelope.cacheNamespaceRoot + ? encodeCanonicalBase64(envelope.cacheNamespaceRoot) + : null, + request: { + method: envelope.request.method, + path: envelope.request.path, + query: envelope.request.query, + headers, + body_base64: + envelope.request.body === null ? null : encodeCanonicalBase64(envelope.request.body) + } + }; + const bytes = encodeUtf8(JSON.stringify(wire)); + if (bytes.length > TRANSPORT_V2_LIMITS.envelopeBytes) { + throw new TransportV2ProtocolError("Transport v2 envelope exceeds its size limit."); + } + return bytes; +} + +export function parseUnaryResponseEnvelope(plaintext: Uint8Array): TransportV2UnaryResponse { + if (plaintext.length > TRANSPORT_V2_LIMITS.envelopeBytes) { + throw new TransportV2ProtocolError("Transport v2 response exceeds its size limit."); + } + const value = requireExactObject( + parseStrictJson(decodeUtf8(plaintext)), + ["version", "request_id", "status", "headers", "body_base64"], + "Transport v2 unary response" + ); + requireVersion(value.version); + return { + requestId: requireRequestId(value.request_id), + status: requireStatus(value.status, 100, 599), + headers: parseHeaders(value.headers), + body: parseBody(value.body_base64, TRANSPORT_V2_LIMITS.logicalBodyBytes, true) + }; +} + +export function parseStreamRecord(plaintext: Uint8Array): TransportV2StreamRecord { + if (plaintext.length > TRANSPORT_V2_LIMITS.envelopeBytes) { + throw new TransportV2ProtocolError("Transport v2 stream record exceeds its size limit."); + } + const parsed = parseStrictJson(decodeUtf8(plaintext)); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new TransportV2ProtocolError("Transport v2 stream record is not an object."); + } + const kind = requireString((parsed as Record).kind, "Transport v2 record kind"); + + if (kind === "start") { + const value = requireExactObject( + parsed, + ["version", "request_id", "sequence", "kind", "status", "headers"], + "Transport v2 stream start" + ); + requireVersion(value.version); + const sequence = requireSafeInteger(value.sequence, "Transport v2 stream sequence"); + if (sequence !== 0) { + throw new TransportV2ProtocolError("Transport v2 stream start sequence is invalid."); + } + return { + kind, + requestId: requireRequestId(value.request_id), + sequence, + status: requireStatus(value.status, 200, 299), + headers: parseHeaders(value.headers) + }; + } + if (kind === "chunk") { + const value = requireExactObject( + parsed, + ["version", "request_id", "sequence", "kind", "body_base64"], + "Transport v2 stream chunk" + ); + requireVersion(value.version); + const sequence = requireSafeInteger(value.sequence, "Transport v2 stream sequence"); + if (sequence === 0) { + throw new TransportV2ProtocolError("Transport v2 stream chunk sequence is invalid."); + } + return { + kind, + requestId: requireRequestId(value.request_id), + sequence, + body: parseBody(value.body_base64, TRANSPORT_V2_LIMITS.streamChunkBytes, false)! + }; + } + if (kind === "end") { + const value = requireExactObject( + parsed, + ["version", "request_id", "sequence", "kind"], + "Transport v2 stream end" + ); + requireVersion(value.version); + const sequence = requireSafeInteger(value.sequence, "Transport v2 stream sequence"); + if (sequence === 0) { + throw new TransportV2ProtocolError("Transport v2 stream end sequence is invalid."); + } + return { kind, requestId: requireRequestId(value.request_id), sequence }; + } + if (kind === "error") { + const value = requireExactObject( + parsed, + ["version", "request_id", "sequence", "kind", "status", "body_base64"], + "Transport v2 stream error" + ); + requireVersion(value.version); + const sequence = requireSafeInteger(value.sequence, "Transport v2 stream sequence"); + if (sequence === 0) { + throw new TransportV2ProtocolError("Transport v2 stream error sequence is invalid."); + } + return { + kind, + requestId: requireRequestId(value.request_id), + sequence, + status: requireStatus(value.status, 400, 599), + body: parseBody(value.body_base64, TRANSPORT_V2_LIMITS.streamErrorBytes, false)! + }; + } + throw new TransportV2ProtocolError("Transport v2 stream record kind is invalid."); +} diff --git a/sdk/src/lib/transportV2/handshake.ts b/sdk/src/lib/transportV2/handshake.ts new file mode 100644 index 000000000..bb1689dab --- /dev/null +++ b/sdk/src/lib/transportV2/handshake.ts @@ -0,0 +1,137 @@ +import nacl from "tweetnacl"; +import { + SESSION_KEY_BYTES, + TransportV2ProtocolError, + decodeCanonicalBase64, + encodeCanonicalBase64, + encodeUtf8, + parseStrictJson, + requireExactObject, + uuidToBytes +} from "./encoding"; +import { decryptTransportV2Handshake } from "./crypto"; +import { TransportV2Session } from "./session"; + +const MAX_ATTESTATION_NONCE_BYTES = 512; +const MAX_KEY_EXCHANGE_BODY_BYTES = 4 * 1024; +const HANDSHAKE_ENCRYPTED_RECORD_BYTES = 85; + +export interface TransportV2KeyExchangeRequest { + path: "/v2/key_exchange"; + method: "POST"; + headers: Readonly>; + body: string; +} + +export class TransportV2Handshake { + #nonce: string; + #clientPublicKey: Uint8Array; + #clientSecretKey: Uint8Array; + #requestTaken = false; + #used = false; + + constructor(nonce: string) { + const nonceBytes = encodeUtf8(nonce).length; + if (nonceBytes === 0 || nonceBytes > MAX_ATTESTATION_NONCE_BYTES) { + throw new TransportV2ProtocolError("Transport v2 attestation nonce has an invalid length."); + } + const keyPair = nacl.box.keyPair(); + this.#nonce = nonce; + this.#clientPublicKey = new Uint8Array(keyPair.publicKey); + this.#clientSecretKey = new Uint8Array(keyPair.secretKey); + keyPair.secretKey.fill(0); + } + + get clientPublicKey(): Uint8Array { + return new Uint8Array(this.#clientPublicKey); + } + + keyExchangeRequest(): TransportV2KeyExchangeRequest { + if (this.#used || this.#requestTaken) { + throw new TransportV2ProtocolError("Transport v2 handshake is already consumed."); + } + this.#requestTaken = true; + const body = JSON.stringify({ + nonce: this.#nonce, + client_public_key: encodeCanonicalBase64(this.#clientPublicKey) + }); + if (encodeUtf8(body).length > MAX_KEY_EXCHANGE_BODY_BYTES) { + throw new TransportV2ProtocolError("Transport v2 key exchange request is too large."); + } + return { + path: "/v2/key_exchange", + method: "POST", + headers: { "content-type": "application/json" }, + body + }; + } + + async complete( + attestedServerPublicKey: Uint8Array, + keyExchangeResponseBody: string, + subtle: SubtleCrypto = globalThis.crypto.subtle + ): Promise { + if (this.#used) { + throw new TransportV2ProtocolError("Transport v2 handshake is already consumed."); + } + if (!this.#requestTaken) { + throw new TransportV2ProtocolError("Transport v2 key exchange request was not taken."); + } + this.#used = true; + this.#clientPublicKey.fill(0); + if ( + attestedServerPublicKey.length !== SESSION_KEY_BYTES || + encodeUtf8(keyExchangeResponseBody).length > MAX_KEY_EXCHANGE_BODY_BYTES + ) { + this.dispose(); + throw new TransportV2ProtocolError("Transport v2 key exchange response is invalid."); + } + + let sharedSecret: Uint8Array | undefined; + try { + const response = requireExactObject( + parseStrictJson(keyExchangeResponseBody), + ["session_id", "encrypted_session_key"], + "Transport v2 key exchange response" + ); + if (typeof response.session_id !== "string") { + throw new TransportV2ProtocolError("Transport v2 key exchange session ID is invalid."); + } + uuidToBytes(response.session_id); + const encrypted = decodeCanonicalBase64( + typeof response.encrypted_session_key === "string" ? response.encrypted_session_key : "", + HANDSHAKE_ENCRYPTED_RECORD_BYTES + ); + try { + if (encrypted.length !== HANDSHAKE_ENCRYPTED_RECORD_BYTES) { + throw new TransportV2ProtocolError("Transport v2 key exchange record is invalid."); + } + sharedSecret = nacl.scalarMult(this.#clientSecretKey, attestedServerPublicKey); + const handshake = await decryptTransportV2Handshake( + sharedSecret, + response.session_id, + encrypted, + subtle + ); + try { + return new TransportV2Session(handshake); + } finally { + handshake.requestKey.fill(0); + handshake.responseKey.fill(0); + } + } finally { + encrypted.fill(0); + } + } finally { + sharedSecret?.fill(0); + this.dispose(); + } + } + + dispose(): void { + this.#used = true; + this.#clientPublicKey.fill(0); + this.#clientSecretKey.fill(0); + this.#nonce = ""; + } +} diff --git a/sdk/src/lib/transportV2/index.ts b/sdk/src/lib/transportV2/index.ts new file mode 100644 index 000000000..f6ad8d1e0 --- /dev/null +++ b/sdk/src/lib/transportV2/index.ts @@ -0,0 +1,40 @@ +export { + TransportV2ProtocolError, + decodeCanonicalBase64, + encodeCanonicalBase64, + encodeCanonicalOpaquePathSegment, + generateRequestId +} from "./encoding"; +export { + decryptTransportV2Handshake, + decryptTransportV2Record, + deriveTransportV2DirectionalKeys, + encryptTransportV2Record, + requestRecordAad, + streamResponseRecordAad, + unaryResponseRecordAad, + type TransportV2DirectionalKeys, + type TransportV2HandshakeResult +} from "./crypto"; +export { + TRANSPORT_V2_LIMITS, + parseStreamRecord, + parseUnaryResponseEnvelope, + serializeRequestEnvelope, + type LogicalMethod, + type ResponseMode, + type TransportV2Credential, + type TransportV2Header, + type TransportV2LogicalRequest, + type TransportV2RequestEnvelope, + type TransportV2StreamRecord, + type TransportV2UnaryResponse +} from "./envelope"; +export { + PreparedTransportV2Request, + TransportV2Session, + type PrepareTransportV2Request, + type TransportV2HttpRequest +} from "./session"; +export { TransportV2Handshake, type TransportV2KeyExchangeRequest } from "./handshake"; +export { TransportV2StreamDecoder, type DecryptStreamRecord } from "./stream"; diff --git a/sdk/src/lib/transportV2/session.ts b/sdk/src/lib/transportV2/session.ts new file mode 100644 index 000000000..0a2fba32f --- /dev/null +++ b/sdk/src/lib/transportV2/session.ts @@ -0,0 +1,381 @@ +import { + MIN_ENCRYPTED_RECORD_BYTES, + TransportV2ProtocolError, + decodeCanonicalBase64, + encodeCanonicalBase64, + encodeUtf8, + generateRequestId, + parseStrictJson, + requireExactObject, + uuidToBytes +} from "./encoding"; +import { + TRANSPORT_V2_LIMITS, + parseUnaryResponseEnvelope, + serializeRequestEnvelope, + type ResponseMode, + type TransportV2RequestEnvelope, + type TransportV2UnaryResponse +} from "./envelope"; +import { + decryptTransportV2Record, + encryptTransportV2Record, + requestRecordAad, + streamResponseRecordAad, + unaryResponseRecordAad, + type TransportV2HandshakeResult +} from "./crypto"; +import { TransportV2StreamDecoder } from "./stream"; + +const MAX_REQUEST_RECORDS = 65_536; +const MAX_RESPONSE_RECORDS = 65_536; +const OUTER_RESPONSE_OVERHEAD_BYTES = 32; +const MAX_OUTER_RESPONSE_BODY_BYTES = + Math.ceil((TRANSPORT_V2_LIMITS.envelopeBytes + MIN_ENCRYPTED_RECORD_BYTES) / 3) * 4 + + OUTER_RESPONSE_OVERHEAD_BYTES; +const MAX_OUTER_REQUEST_BODY_BYTES = 50 * 1024 * 1024; + +export interface PrepareTransportV2Request extends Omit {} + +export interface TransportV2HttpRequest { + path: "/v2/request"; + method: "POST"; + headers: Readonly>; + body: string; +} + +export class PreparedTransportV2Request { + readonly requestId: string; + readonly responseMode: ResponseMode; + + #responseContext: TransportV2ResponseContext; + #httpRequest: TransportV2HttpRequest | null; + #responseSelected = false; + + constructor( + responseContext: TransportV2ResponseContext, + requestId: string, + responseMode: ResponseMode, + httpRequest: TransportV2HttpRequest + ) { + this.#responseContext = responseContext; + this.requestId = requestId; + this.responseMode = responseMode; + this.#httpRequest = httpRequest; + } + + /** + * Returns the one network send owned by this logical request. A sent request + * is never recreated with the same or a fresh request ID by this engine. + */ + takeHttpRequest(): TransportV2HttpRequest { + if (!this.#httpRequest) { + throw new TransportV2ProtocolError("Transport v2 request has already been taken for send."); + } + const request = this.#httpRequest; + this.#httpRequest = null; + return request; + } + + decryptUnaryResponse(outerBody: string): TransportV2UnaryResponse { + if (this.responseMode !== "unary") { + throw new TransportV2ProtocolError("Transport v2 request did not select a unary response."); + } + this.#selectResponse(); + return this.#responseContext.decryptUnaryResponse(outerBody); + } + + decryptPreStartUnaryError(outerBody: string): TransportV2UnaryResponse { + if (this.responseMode !== "stream") { + throw new TransportV2ProtocolError("Transport v2 request did not select streaming."); + } + this.#selectResponse(); + return this.#responseContext.decryptPreStartUnaryError(outerBody); + } + + createStreamDecoder(): TransportV2StreamDecoder { + if (this.responseMode !== "stream") { + throw new TransportV2ProtocolError("Transport v2 request did not select streaming."); + } + this.#selectResponse(); + return this.#responseContext.createStreamDecoder(); + } + + dispose(): void { + this.#httpRequest = null; + this.#responseSelected = true; + this.#responseContext.dispose(); + } + + #selectResponse(): void { + if (this.#responseSelected) { + throw new TransportV2ProtocolError("Transport v2 request response was already selected."); + } + this.#responseSelected = true; + } +} + +class TransportV2ResponseContext { + #sessionId: string; + #requestId: string; + #responseKey: Uint8Array | null; + #reserveChunkResponseRecord: () => void; + #releasePreStartTerminalRecord: () => void; + + constructor( + sessionId: string, + requestId: string, + responseKey: Uint8Array, + reserveChunkResponseRecord: () => void, + releasePreStartTerminalRecord: () => void + ) { + this.#sessionId = sessionId; + this.#requestId = requestId; + this.#responseKey = responseKey; + this.#reserveChunkResponseRecord = reserveChunkResponseRecord; + this.#releasePreStartTerminalRecord = releasePreStartTerminalRecord; + } + + decryptUnaryResponse(outerBody: string): TransportV2UnaryResponse { + return this.#decryptUnaryOuter(outerBody, false); + } + + decryptPreStartUnaryError(outerBody: string): TransportV2UnaryResponse { + return this.#decryptUnaryOuter(outerBody, true); + } + + createStreamDecoder(): TransportV2StreamDecoder { + const responseKey = this.#takeResponseKey(); + try { + return new TransportV2StreamDecoder( + this.#requestId, + (encrypted, sequence) => { + return decryptTransportV2Record( + responseKey, + encrypted, + streamResponseRecordAad(this.#sessionId, this.#requestId, sequence), + TRANSPORT_V2_LIMITS.envelopeBytes + ); + }, + undefined, + () => responseKey.fill(0), + this.#reserveChunkResponseRecord + ); + } catch (error) { + responseKey.fill(0); + throw error; + } + } + + dispose(): void { + this.#responseKey?.fill(0); + this.#responseKey = null; + } + + #decryptUnaryOuter(outerBody: string, requireError: boolean): TransportV2UnaryResponse { + const responseKey = this.#takeResponseKey(); + let encrypted: Uint8Array | undefined; + let plaintext: Uint8Array | undefined; + try { + if (encodeUtf8(outerBody).length > MAX_OUTER_RESPONSE_BODY_BYTES) { + throw new TransportV2ProtocolError("Transport v2 outer response exceeds its size limit."); + } + const outer = requireExactObject( + parseStrictJson(outerBody), + ["encrypted"], + "Transport v2 outer response" + ); + encrypted = decodeCanonicalBase64( + typeof outer.encrypted === "string" ? outer.encrypted : "", + TRANSPORT_V2_LIMITS.envelopeBytes + MIN_ENCRYPTED_RECORD_BYTES + ); + plaintext = decryptTransportV2Record( + responseKey, + encrypted, + unaryResponseRecordAad(this.#sessionId, this.#requestId), + TRANSPORT_V2_LIMITS.envelopeBytes + ); + const response = parseUnaryResponseEnvelope(plaintext); + if (response.requestId !== this.#requestId) { + zeroUnaryResponse(response); + throw new TransportV2ProtocolError("Transport v2 unary response binding is invalid."); + } + if (requireError && (response.status < 400 || response.status > 599)) { + zeroUnaryResponse(response); + throw new TransportV2ProtocolError( + "Transport v2 pre-stream unary response is not an error." + ); + } + if (requireError) this.#releasePreStartTerminalRecord(); + return response; + } finally { + encrypted?.fill(0); + plaintext?.fill(0); + responseKey.fill(0); + } + } + + #takeResponseKey(): Uint8Array { + if (!this.#responseKey) { + throw new TransportV2ProtocolError("Transport v2 response context is no longer available."); + } + const responseKey = this.#responseKey; + this.#responseKey = null; + return responseKey; + } +} + +export class TransportV2Session { + readonly sessionId: string; + readonly expiresAtUnixSeconds: number; + + #requestKey: Uint8Array; + #responseKey: Uint8Array; + #requestIds = new Set(); + #requestRecords = 0; + #responseRecords = 0; + #responseRecordLimit: number; + #disposed = false; + + constructor(handshake: TransportV2HandshakeResult, responseRecordLimit = MAX_RESPONSE_RECORDS) { + uuidToBytes(handshake.sessionId); + if ( + handshake.requestKey.length !== 32 || + handshake.responseKey.length !== 32 || + !Number.isSafeInteger(handshake.expiresAtUnixSeconds) || + handshake.expiresAtUnixSeconds < 0 || + !Number.isSafeInteger(responseRecordLimit) || + responseRecordLimit < 0 || + responseRecordLimit > MAX_RESPONSE_RECORDS + ) { + throw new TransportV2ProtocolError("Transport v2 handshake result is invalid."); + } + this.sessionId = handshake.sessionId; + this.expiresAtUnixSeconds = handshake.expiresAtUnixSeconds; + this.#requestKey = new Uint8Array(handshake.requestKey); + this.#responseKey = new Uint8Array(handshake.responseKey); + this.#responseRecordLimit = responseRecordLimit; + } + + prepareRequest( + input: PrepareTransportV2Request, + random: Crypto = globalThis.crypto, + nowUnixSeconds = Math.floor(Date.now() / 1000) + ): PreparedTransportV2Request { + this.#requireActive(); + if (nowUnixSeconds >= this.expiresAtUnixSeconds) { + throw new TransportV2ProtocolError("Transport v2 session has expired."); + } + if (this.#requestRecords >= MAX_REQUEST_RECORDS) { + throw new TransportV2ProtocolError("Transport v2 request record budget is exhausted."); + } + + const expectedResponseRecords = input.responseMode === "stream" ? 2 : 1; + this.#reserveResponseRecords(expectedResponseRecords); + + let requestId: string | undefined; + try { + for (let attempt = 0; attempt < 16; attempt += 1) { + const candidate = generateRequestId(random); + if (!this.#requestIds.has(candidate)) { + requestId = candidate; + break; + } + } + if (!requestId) { + throw new TransportV2ProtocolError("Secure request ID generation repeatedly collided."); + } + this.#requestIds.add(requestId); + + let plaintext: Uint8Array | undefined; + let encrypted: Uint8Array | undefined; + try { + plaintext = serializeRequestEnvelope({ ...input, requestId }); + encrypted = encryptTransportV2Record( + this.#requestKey, + plaintext, + requestRecordAad(this.sessionId), + undefined, + random + ); + const outerBody = JSON.stringify({ encrypted: encodeCanonicalBase64(encrypted) }); + if (encodeUtf8(outerBody).length > MAX_OUTER_REQUEST_BODY_BYTES) { + throw new TransportV2ProtocolError("Transport v2 outer request exceeds its size limit."); + } + this.#requestRecords += 1; + return new PreparedTransportV2Request( + new TransportV2ResponseContext( + this.sessionId, + requestId, + new Uint8Array(this.#responseKey), + () => this.#reserveResponseRecords(1), + () => this.#releaseResponseRecords(1) + ), + requestId, + input.responseMode, + { + path: "/v2/request", + method: "POST", + headers: { "content-type": "application/json", "x-session-id": this.sessionId }, + body: outerBody + } + ); + } catch (error) { + this.#requestIds.delete(requestId); + throw error; + } finally { + plaintext?.fill(0); + encrypted?.fill(0); + } + } catch (error) { + this.#releaseResponseRecords(expectedResponseRecords); + throw error; + } + } + + get isDisposed(): boolean { + return this.#disposed; + } + + dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + this.#requestKey.fill(0); + this.#responseKey.fill(0); + this.#requestIds.clear(); + } + + #reserveResponseRecords(records: number): void { + const nextResponseRecords = this.#responseRecords + records; + if ( + !Number.isSafeInteger(records) || + records <= 0 || + !Number.isSafeInteger(nextResponseRecords) || + nextResponseRecords > this.#responseRecordLimit + ) { + throw new TransportV2ProtocolError("Transport v2 response record budget is exhausted."); + } + // This method contains no asynchronous boundary. The check and increment + // therefore form one atomic reservation for all requests sharing this + // JavaScript session object, including a stream's Start + terminal pair. + this.#responseRecords = nextResponseRecords; + } + + #releaseResponseRecords(records: number): void { + if (!Number.isSafeInteger(records) || records <= 0 || records > this.#responseRecords) { + throw new TransportV2ProtocolError("Transport v2 response reservation is invalid."); + } + this.#responseRecords -= records; + } + + #requireActive(): void { + if (this.#disposed) { + throw new TransportV2ProtocolError("Transport v2 session is disposed."); + } + } +} + +function zeroUnaryResponse(response: TransportV2UnaryResponse): void { + response.body?.fill(0); + for (const header of response.headers) header.value.fill(0); +} diff --git a/sdk/src/lib/transportV2/stream.ts b/sdk/src/lib/transportV2/stream.ts new file mode 100644 index 000000000..ba455630e --- /dev/null +++ b/sdk/src/lib/transportV2/stream.ts @@ -0,0 +1,219 @@ +import { + MIN_ENCRYPTED_RECORD_BYTES, + TransportV2ProtocolError, + concatBytes, + decodeCanonicalBase64 +} from "./encoding"; +import { TRANSPORT_V2_LIMITS, type TransportV2StreamRecord, parseStreamRecord } from "./envelope"; + +// Stream records are structurally bounded far below the generic 50 MiB +// envelope limit: 64 KiB of decoded headers/chunk bytes plus JSON/base64 +// framing. This ceiling bounds partial-carrier buffering before decryption. +const MAX_OUTER_STREAM_FRAME_BYTES = 256 * 1024; +const MAX_LOGICAL_STREAM_BYTES = 64 * 1024 * 1024; +const FRAME_PREFIX = new TextEncoder().encode("data: "); + +export type DecryptStreamRecord = (encrypted: Uint8Array, sequence: number) => Uint8Array; +export type ReleaseStreamContext = () => void; +export type ReserveStreamChunk = () => void; + +export class TransportV2StreamDecoder { + readonly requestId: string; + + #buffer = new Uint8Array(0); + #decrypt: DecryptStreamRecord; + #expectedSequence = 0; + #started = false; + #terminal = false; + #failed = false; + #logicalBytes = 0; + #maxLogicalBytes: number; + #releaseContext: ReleaseStreamContext; + #reserveChunk: ReserveStreamChunk; + #released = false; + + constructor( + requestId: string, + decrypt: DecryptStreamRecord, + maxLogicalBytes = MAX_LOGICAL_STREAM_BYTES, + releaseContext: ReleaseStreamContext = () => {}, + reserveChunk: ReserveStreamChunk = () => {} + ) { + if ( + !Number.isSafeInteger(maxLogicalBytes) || + maxLogicalBytes < 0 || + maxLogicalBytes > MAX_LOGICAL_STREAM_BYTES + ) { + throw new TransportV2ProtocolError("Transport v2 logical stream limit is invalid."); + } + this.requestId = requestId; + this.#decrypt = decrypt; + this.#maxLogicalBytes = maxLogicalBytes; + this.#releaseContext = releaseContext; + this.#reserveChunk = reserveChunk; + } + + push(chunk: Uint8Array): TransportV2StreamRecord[] { + if (this.#failed) { + throw new TransportV2ProtocolError("Transport v2 stream decoder has failed closed."); + } + if (chunk.length === 0) return []; + if (this.#terminal) { + return this.#fail("Transport v2 stream contains data after its terminal record."); + } + for (const byte of chunk) { + if (byte === 0x0d || byte > 0x7f) { + return this.#fail("Transport v2 stream carrier contains invalid bytes."); + } + } + this.#buffer = concatBytes(this.#buffer, chunk); + + const records: TransportV2StreamRecord[] = []; + while (true) { + const boundary = findFrameBoundary(this.#buffer); + if (boundary < 0) { + if (this.#buffer.length > MAX_OUTER_STREAM_FRAME_BYTES) { + return this.#fail("Transport v2 stream carrier frame exceeds its size limit."); + } + break; + } + if (boundary > MAX_OUTER_STREAM_FRAME_BYTES) { + return this.#fail("Transport v2 stream carrier frame exceeds its size limit."); + } + const frame = this.#buffer.slice(0, boundary); + this.#buffer = this.#buffer.slice(boundary + 2); + try { + records.push(this.#decodeFrame(frame)); + } catch (error) { + this.#failed = true; + this.#buffer.fill(0); + this.#buffer = new Uint8Array(0); + this.#release(); + if (error instanceof TransportV2ProtocolError) throw error; + throw new TransportV2ProtocolError("Transport v2 stream decoding failed."); + } + if (this.#terminal && this.#buffer.length > 0) { + return this.#fail("Transport v2 stream contains data after its terminal record."); + } + } + return records; + } + + finish(): void { + if (this.#failed) { + throw new TransportV2ProtocolError("Transport v2 stream decoder has failed closed."); + } + if (this.#buffer.length !== 0) { + this.#failed = true; + this.#buffer.fill(0); + this.#buffer = new Uint8Array(0); + this.#release(); + throw new TransportV2ProtocolError("Transport v2 stream ended with a partial carrier frame."); + } + if (!this.#terminal) { + this.#failed = true; + this.#release(); + throw new TransportV2ProtocolError( + "Transport v2 stream ended without an authenticated terminal record." + ); + } + this.#release(); + } + + get isTerminal(): boolean { + return this.#terminal; + } + + dispose(): void { + if (this.#terminal || this.#failed) { + this.#release(); + return; + } + this.#failed = true; + this.#buffer.fill(0); + this.#buffer = new Uint8Array(0); + this.#release(); + } + + #decodeFrame(frame: Uint8Array): TransportV2StreamRecord { + if ( + frame.length <= FRAME_PREFIX.length || + !FRAME_PREFIX.every((byte, index) => frame[index] === byte) || + frame.subarray(FRAME_PREFIX.length).includes(0x0a) + ) { + throw new TransportV2ProtocolError("Transport v2 stream carrier framing is invalid."); + } + const encoded = new TextDecoder("ascii", { fatal: true }).decode( + frame.subarray(FRAME_PREFIX.length) + ); + const encrypted = decodeCanonicalBase64( + encoded, + TRANSPORT_V2_LIMITS.envelopeBytes + MIN_ENCRYPTED_RECORD_BYTES + ); + let plaintext: Uint8Array | undefined; + try { + plaintext = this.#decrypt(encrypted, this.#expectedSequence); + const record = parseStreamRecord(plaintext); + if (record.requestId !== this.requestId || record.sequence !== this.#expectedSequence) { + throw new TransportV2ProtocolError("Transport v2 stream record binding is invalid."); + } + if (record.kind === "chunk") { + // Start and terminal capacity was reserved before the request could be + // emitted. Every authenticated, bound Chunk permanently charges an + // additional slot while leaving that terminal reservation unavailable + // to application bytes, even when later state/size checks reject it. + this.#reserveChunk(); + } + if (!this.#started) { + if (record.kind !== "start") { + throw new TransportV2ProtocolError("Transport v2 stream does not begin with Start."); + } + this.#started = true; + } else if (record.kind === "start") { + throw new TransportV2ProtocolError("Transport v2 stream contains more than one Start."); + } + if (record.kind === "chunk") { + const nextLogicalBytes = this.#logicalBytes + record.body.length; + if (!Number.isSafeInteger(nextLogicalBytes) || nextLogicalBytes > this.#maxLogicalBytes) { + record.body.fill(0); + throw new TransportV2ProtocolError("Transport v2 logical stream exceeds its size limit."); + } + this.#logicalBytes = nextLogicalBytes; + } + if (record.kind === "end" || record.kind === "error") { + this.#terminal = true; + this.#release(); + } + this.#expectedSequence += 1; + if (!Number.isSafeInteger(this.#expectedSequence)) { + throw new TransportV2ProtocolError("Transport v2 stream sequence is exhausted."); + } + return record; + } finally { + encrypted.fill(0); + plaintext?.fill(0); + } + } + + #fail(message: string): never { + this.#failed = true; + this.#buffer.fill(0); + this.#buffer = new Uint8Array(0); + this.#release(); + throw new TransportV2ProtocolError(message); + } + + #release(): void { + if (this.#released) return; + this.#released = true; + this.#releaseContext(); + this.#releaseContext = () => {}; + } +} + +function findFrameBoundary(buffer: Uint8Array): number { + for (let index = 0; index + 1 < buffer.length; index += 1) { + if (buffer[index] === 0x0a && buffer[index + 1] === 0x0a) return index; + } + return -1; +} diff --git a/sdk/testdata/transport-v2-golden-vectors.json b/sdk/testdata/transport-v2-golden-vectors.json new file mode 100644 index 000000000..47464fe3d --- /dev/null +++ b/sdk/testdata/transport-v2-golden-vectors.json @@ -0,0 +1,50 @@ +{ + "fixture_version": 1, + "protocol_version": 2, + "shared_secret_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "session_master_hex": "202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "session_id": "00112233-4455-6677-8899-aabbccddeeff", + "session_id_hex": "00112233445566778899aabbccddeeff", + "expires_at_unix_seconds": 1800003900, + "request_id_hex": "ffeeddccbbaa99887766554433221100", + "stream_sequence": 7, + "handshake": { + "info_utf8": "opensecret/transport-v2/handshake-key", + "derived_key_hex": "d534157fe075f3c5c13899ec5cabf66121006330a15a9e340132aaec235fb634", + "aad_hex": "6f70656e7365637265742f7472616e73706f72742d76322f6b65792d65786368616e6765", + "nonce_hex": "000102030405060708090a0b", + "plaintext_hex": "0200112233445566778899aabbccddeeff202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f000000006b49e13c", + "record_hex": "000102030405060708090a0bb88afcfdf3ec719871b6844fbfd510210dcef6c86b1e217bd66a24ef9be9746e784de2d83125ec52cfe009e0826bb85bd029744bd2759acd8dd698091d54b34fa9dfe9b6f26138375b", + "record_base64": "AAECAwQFBgcICQoLuIr8/fPscZhxtoRPv9UQIQ3O9shrHiF71mok75vpdG54TeLYMSXsUs/gCeCCa7hb0Cl0S9J1ms2N1pgJHVSzT6nf6bbyYTg3Ww==" + }, + "request": { + "info_utf8": "opensecret/transport-v2/client-request", + "derived_key_hex": "c8865e05fdd7c0a8eada79cb117e97b1c11aeee640fd315f5971a89b3c8504f3", + "aad_hex": "6f70656e7365637265742f7472616e73706f72742d76322f726571756573742d7265636f72640000112233445566778899aabbccddeeff", + "nonce_hex": "0c0d0e0f1011121314151617", + "plaintext_utf8": "transport-v2 request vector", + "plaintext_hex": "7472616e73706f72742d7632207265717565737420766563746f72", + "record_hex": "0c0d0e0f1011121314151617ccab23f6818e4331eb8a631f5d0fb5c874bb20cc11efce9deeda2c8747fe81729890b1638dfac9cbc222f8", + "record_base64": "DA0ODxAREhMUFRYXzKsj9oGOQzHrimMfXQ+1yHS7IMwR786d7tosh0f+gXKYkLFjjfrJy8Ii+A==" + }, + "unary_response": { + "info_utf8": "opensecret/transport-v2/enclave-response", + "derived_key_hex": "ac0b87b944dd080ed214ee042458f159df7dad0bca10c3bb1caee552d8633a0a", + "aad_hex": "6f70656e7365637265742f7472616e73706f72742d76322f756e6172792d726573706f6e73652d7265636f72640000112233445566778899aabbccddeeffffeeddccbbaa99887766554433221100", + "nonce_hex": "18191a1b1c1d1e1f20212223", + "plaintext_utf8": "transport-v2 unary response vector", + "plaintext_hex": "7472616e73706f72742d763220756e61727920726573706f6e736520766563746f72", + "record_hex": "18191a1b1c1d1e1f2021222356b4bfabc752cd7a75ac37c452d93ca225ddacf1a266267a17c5fdfec9945ace742d11a6a91de3d90e475fe7455bab8ecdd1", + "record_base64": "GBkaGxwdHh8gISIjVrS/q8dSzXp1rDfEUtk8oiXdrPGiZiZ6F8X9/smUWs50LRGmqR3j2Q5HX+dFW6uOzdE=" + }, + "stream_response": { + "aad_hex": "6f70656e7365637265742f7472616e73706f72742d76322f73747265616d2d726573706f6e73652d7265636f72640000112233445566778899aabbccddeeffffeeddccbbaa998877665544332211000000000000000007", + "nonce_hex": "2425262728292a2b2c2d2e2f", + "plaintext_utf8": "transport-v2 stream record vector", + "plaintext_hex": "7472616e73706f72742d76322073747265616d207265636f726420766563746f72", + "record_hex": "2425262728292a2b2c2d2e2f7f7818dab8d77e819361a2b9cb7fbccc2ba70f4bb6b751e1329622624dfa35cefb9031a6e93e608bc9777a57a7f35480ba", + "record_base64": "JCUmJygpKissLS4vf3gY2rjXfoGTYaK5y3+8zCunD0u2t1HhMpYiYk36Nc77kDGm6T5gi8l3elen81SAug==" + }, + "request_without_body_json": "{\"version\":2,\"request_id\":\"ffeeddccbbaa99887766554433221100\",\"response_mode\":\"unary\",\"credential\":null,\"cache_namespace_root_base64\":null,\"request\":{\"method\":\"GET\",\"path\":\"/v1/models\",\"query\":\"limit=10\",\"headers\":[{\"name\":\"x-provider-beta\",\"value_base64\":\"YmV0YQ==\"}],\"body_base64\":null}}", + "request_with_empty_body_json": "{\"version\":2,\"request_id\":\"ffeeddccbbaa99887766554433221100\",\"response_mode\":\"unary\",\"credential\":null,\"cache_namespace_root_base64\":null,\"request\":{\"method\":\"POST\",\"path\":\"/v1/responses\",\"query\":null,\"headers\":[{\"name\":\"content-type\",\"value_base64\":\"YXBwbGljYXRpb24vanNvbg==\"}],\"body_base64\":\"\"}}" +}