diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 594438d..3c64c09 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -41,6 +41,9 @@ jobs: pnpm --filter mtp-web-client run build node test/e2ee.mjs + pnpm run test:secrets + pnpm run test:types + pnpm run check:boundary ( cd example diff --git a/.gitignore b/.gitignore index 8ce8e7b..6dff05e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ node_modules/ dist/ *.tgz wasm/pkg/ +web_client/ diff --git a/Cargo.lock b/Cargo.lock index 4b6df1c..b38a29c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,18 @@ dependencies = [ "subtle", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "asn1-rs" version = "0.7.2" @@ -150,6 +162,15 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -465,6 +486,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -1320,6 +1342,7 @@ dependencies = [ "mtp-crypto", "mtp-type-map", "rand 0.10.2", + "thiserror 2.0.19", ] [[package]] @@ -1345,7 +1368,7 @@ dependencies = [ "ml-dsa", "mlkem-tls", "rand 0.10.2", - "rand_core 0.10.1", + "rand_core 0.6.4", "rcgen", "rustls", "serde", @@ -1360,6 +1383,7 @@ dependencies = [ name = "mtp-files" version = "0.2.0" dependencies = [ + "argon2", "mtp-crypto", "rand 0.10.2", "thiserror 1.0.69", @@ -1388,6 +1412,7 @@ dependencies = [ "mtp-codec", "mtp-common", "mtp-crypto", + "rand 0.10.2", "rcgen", "rustls", "rustls-native-certs", @@ -1395,6 +1420,7 @@ dependencies = [ "tokio", "tracing", "wtransport", + "zeroize", ] [[package]] @@ -1573,6 +1599,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pem" version = "3.0.6" diff --git a/Cargo.toml b/Cargo.toml index 11156a9..00f950e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -96,6 +96,10 @@ pipes = ["mtp-common/pipes", "mtp-codec/pipes", "mtp-transport?/pipes", "mtp-hos # Pulls in `crypto` so the `Keyring` / `PublicKeyBundle` types are in scope. files = ["dep:mtp-files", "crypto"] +# Development/migration-only access to the legacy plaintext keyring format. +# Production users should use the Argon2id-protected `.mk` APIs instead. +raw = ["mtp-files?/raw"] + # HTTP/3 routing and WebTransport-based MTP hosting on one QUIC endpoint. web-server = ["dep:mtp-webserver", "dep:mtp-host", "mtp-codec/registry", "transport"] diff --git a/README.md b/README.md index a7634df..37f90cf 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ The `mtp` facade re-exports the following modules: ### Codec -The codec encodes and decodes MTP frames using Communication Types and Data Types resolved through a version-specific type map. It supports containers, integers, booleans, floats, strings, arrays, bytes, null values, and optional signed or encrypted containers. See [Type Map](./docs/TYPE-MAP.md) for mapping configuration and [Connector](./docs/CONNECTOR.md) for negotiated codecs. +The codec encodes and decodes MTP frames using Communication Types and Data Types resolved through a version-specific type map. It supports self-delimiting containers, integers, booleans, floats, strings, arrays, bytes, null values, and composable `Signed` and `Encrypted` protection wrappers. Wrap in either order to choose whether signer metadata is public or encrypted. See [Type Map](./docs/TYPE-MAP.md) for mapping configuration and [Connector](./docs/CONNECTOR.md) for negotiated codecs. ### Transport @@ -90,7 +90,7 @@ The type-map build script reads YAML and generates `CommunicationType` and `Data ### Crypto -`mtp-crypto` provides AEAD encryption, Ed25519 and ML-DSA-65 signatures, X25519 plus ML-KEM-768 hybrid KEM support, HKDF, SHA-256, keyrings, encrypted containers, and certificate generation for development. Feature flags and security boundaries: [Security](./docs/SECURITY.md). +`mtp-crypto` provides AEAD encryption, Ed25519 and ML-DSA-65 signatures, X25519 plus ML-KEM-768 hybrid KEM support, HKDF, SHA-256, keyrings, composable protection envelopes, and certificate generation for development. Feature flags and security boundaries: [Security](./docs/SECURITY.md). ## Examples diff --git a/client/Cargo.toml b/client/Cargo.toml index 231875a..e915f8e 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" [dependencies] mtp-common = { version = "0.2.0", path = "../common" } -mtp-codec = { version = "0.2.0", path = "../codec" } +mtp-codec = { version = "0.2.0", path = "../codec", features = ["registry"] } mtp-transport = { version = "0.2.0", path = "../transport" } mtp-crypto = { version = "0.2.0", path = "../crypto", optional = true } rand = "0.10.1" diff --git a/client/src/connection.rs b/client/src/connection.rs index bf44b43..1218dbb 100644 --- a/client/src/connection.rs +++ b/client/src/connection.rs @@ -1,4 +1,4 @@ -use mtp_codec::{CommunicationValue, Version}; +use mtp_codec::{CommunicationValue, Version, registry::VersionedCodec}; #[cfg(feature = "pipes")] use mtp_codec::{DataType, DataValue}; use mtp_common::CommunicationError; @@ -17,6 +17,7 @@ use crate::pipe::{PendingRequest, PipeDispatcher, run_dispatcher}; pub struct MTPConnection { pub version: Version, + pub codec: VersionedCodec, pub sender: mtp_transport::Sender, pub receiver: mtp_transport::Receiver, pub description: Option, @@ -45,12 +46,19 @@ impl MTPConnection { request: &CommunicationValue, expected_response: Option, ) -> Result { - let request_id = request.get_id(); + let request_id = request + .id() + .ok_or_else(|| CommunicationError::Other("request frame must contain an id".into()))?; if request_id == 0 { return Err(CommunicationError::Other( "request frame must have a non-zero id".into(), )); } + if crate::pipe::is_expired_request(&self.pipe_dispatcher, request_id).await { + return Err(CommunicationError::Other(format!( + "request id {request_id} recently timed out; use a new request id" + ))); + } let (sender, receiver) = tokio::sync::oneshot::channel(); let token = Arc::new(()); @@ -86,7 +94,7 @@ impl MTPConnection { result? } Err(_) => { - crate::pipe::remove_pending_request(&self.pipe_dispatcher, request_id, &token) + crate::pipe::expire_pending_request(&self.pipe_dispatcher, request_id, &token) .await; return Err(CommunicationError::Other(format!( "request {request_id} timed out after {:?}", @@ -96,7 +104,7 @@ impl MTPConnection { }; if let Some(expected) = expected_response { - let expected_type = expected.try_to_id(&mtp_codec::TypeMap::latest()); + let expected_type = expected.try_to_id(self.codec.type_map()); if Some(response.get_type()) != expected_type { return Err(CommunicationError::Other(format!( "unexpected response type: expected {:?}, got {:?}; parsed {}", @@ -125,23 +133,35 @@ impl MTPConnection { &self, description: &str, ) -> Result { - let pipe_id = rand::random::(); let (tx, rx) = tokio::sync::oneshot::channel(); - - { + let pipe_id = { let mut pending = self.pipe_dispatcher.pending_creations.lock().await; + let pipe_id = loop { + let candidate = rand::random::(); + if candidate != 0 && !pending.contains_key(&candidate) { + break candidate; + } + }; pending.insert(pipe_id, tx); + pipe_id + }; + + let request = CommunicationValue::new_with_type_map( + mtp_codec::CommunicationType::PipeRequest, + self.codec.type_map(), + ) + .with_id(pipe_id) + .add_typed_default(DataType::Description, DataValue::Str(description.into())); + + if let Err(error) = self.sender.send(&request).await { + self.pipe_dispatcher + .pending_creations + .lock() + .await + .remove(&pipe_id); + return Err(mtp_common::PipeError::from(error)); } - let request = CommunicationValue::new(mtp_codec::CommunicationType::PipeRequest) - .with_id(pipe_id) - .add_typed_default(DataType::Description, DataValue::Str(description.into())); - - self.sender - .send(&request) - .await - .map_err(mtp_common::PipeError::from)?; - Ok(crate::pipe::PipeHandle { pipe_id, description: description.to_string(), @@ -164,11 +184,26 @@ pub(crate) async fn connection_from_parts( sender: mtp_transport::Sender, receiver: mtp_transport::Receiver, version: Version, + codec: VersionedCodec, #[cfg(feature = "crypto")] auth_state: AuthState, #[cfg(feature = "crypto")] client_id: u64, ) -> MTPConnection { + #[cfg(feature = "pipes")] + let type_map = codec.type_map().clone(); + receiver.set_type_map(codec.type_map()).await; let remote_addr = sender.handle().remote_addr(); - let ping = start_ping_session(&config, sender.clone(), &receiver).await; + #[cfg(feature = "crypto")] + let ping_client_id = client_id; + #[cfg(not(feature = "crypto"))] + let ping_client_id = config.client_id; + let ping = start_ping_session( + &config, + sender.clone(), + &receiver, + codec.type_map(), + ping_client_id, + ) + .await; #[cfg(feature = "pipes")] { @@ -180,6 +215,9 @@ pub(crate) async fn connection_from_parts( let dispatcher = Arc::new(PipeDispatcher { pending_requests: Mutex::new(std::collections::HashMap::new()), + expired_requests: Mutex::new(std::collections::HashMap::new()), + #[cfg(feature = "pipes")] + type_map: type_map.clone(), pending_creations: Mutex::new(std::collections::HashMap::new()), pending_pipes: Mutex::new(std::collections::HashMap::new()), policy: Arc::new(config.policy), @@ -197,6 +235,7 @@ pub(crate) async fn connection_from_parts( MTPConnection { version, + codec, sender, receiver, app_rx: Mutex::new(app_rx), @@ -221,11 +260,15 @@ pub(crate) async fn connection_from_parts( ); let dispatcher = Arc::new(PipeDispatcher { pending_requests: Mutex::new(std::collections::HashMap::new()), + expired_requests: Mutex::new(std::collections::HashMap::new()), + #[cfg(feature = "pipes")] + type_map, }); let task = tokio::spawn(run_dispatcher(receiver.clone(), app_tx, dispatcher.clone())); MTPConnection { version, + codec, sender, receiver, app_rx: Mutex::new(app_rx), diff --git a/client/src/crypto.rs b/client/src/crypto.rs index c6dcea7..21a8aa2 100644 --- a/client/src/crypto.rs +++ b/client/src/crypto.rs @@ -1,4 +1,4 @@ -use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue, Version}; +use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue, TypeMap, Version}; use mtp_common::CommunicationError; pub(crate) fn unexpected_response_type_error( @@ -24,7 +24,7 @@ pub(crate) async fn verify_host_challenge( use mtp_crypto::{auth, verify_ed25519}; let sig = match challenge.get_data(DataType::Signature) { - DataValue::Bytes(b) => b.clone(), + Some(DataValue::Bytes(b)) => b.clone(), _ => { return Err(CommunicationError::AuthenticationFailed( "Missing host challenge signature".into(), @@ -32,11 +32,11 @@ pub(crate) async fn verify_host_challenge( } }; let pq_sig = match challenge.get_data(DataType::PqSignature) { - DataValue::Bytes(b) => b.clone(), + Some(DataValue::Bytes(b)) => b.clone(), _ => vec![], }; - let host_requires_pq = challenge.get_data(DataType::RequirePq) == &DataValue::BoolTrue; + let host_requires_pq = challenge.get_data(DataType::RequirePq) == Some(&DataValue::BoolTrue); if host_requires_pq && host_pk.sig_pq_public_key.as_bytes().is_empty() { return Err(CommunicationError::AuthenticationFailed( "Host requires post-quantum authentication but its PQ public key is absent".into(), @@ -80,7 +80,7 @@ pub(crate) async fn verify_host_final( use mtp_crypto::{auth, verify_ed25519}; match response.get_data(DataType::ClientNonce) { - DataValue::UnsignedNumber(n) if *n == client_nonce => {} + Some(DataValue::UnsignedNumber(n)) if *n == client_nonce => {} _ => { return Err(CommunicationError::AuthenticationFailed( "Nonce mismatch".into(), @@ -89,7 +89,7 @@ pub(crate) async fn verify_host_final( } let sig = match response.get_data(DataType::Signature) { - DataValue::Bytes(b) => b.clone(), + Some(DataValue::Bytes(b)) => b.clone(), _ => { return Err(CommunicationError::AuthenticationFailed( "Missing signature".into(), @@ -97,7 +97,7 @@ pub(crate) async fn verify_host_final( } }; let pq_sig = match response.get_data(DataType::PqSignature) { - DataValue::Bytes(b) => b.clone(), + Some(DataValue::Bytes(b)) => b.clone(), _ => vec![], }; if require_pq && pq_sig.is_empty() { @@ -130,8 +130,8 @@ pub(crate) fn check_connected( reject_msg: &str, ) -> Result<(), CommunicationError> { match response.get_data(DataType::Connected) { - DataValue::BoolTrue => Ok(()), - DataValue::BoolFalse => Err(CommunicationError::AuthenticationFailed( + Some(DataValue::BoolTrue) => Ok(()), + Some(DataValue::BoolFalse) => Err(CommunicationError::AuthenticationFailed( response .get_str(DataType::ErrorMessage) .unwrap_or(reject_msg) @@ -147,7 +147,7 @@ pub(crate) fn negotiated_version( response: &CommunicationValue, ) -> Result { match response.get_data(DataType::Version) { - DataValue::Str(version) => Version::parse(version).ok_or_else(|| { + Some(DataValue::Str(version)) => Version::parse(version).ok_or_else(|| { CommunicationError::AuthenticationFailed( "Host returned an invalid negotiated protocol version".into(), ) @@ -162,16 +162,18 @@ pub(crate) async fn signed_challenge_response( keys: &mtp_crypto::Keyring, proof_payload: Vec, client_nonce: u128, + type_map: &TypeMap, ) -> Result { use mtp_crypto::{Ed25519Signer, MlDsaSigner, SignatureScheme}; let signer = Ed25519Signer::new(&keys.sig_cl_secret_key) .map_err(|e| CommunicationError::Other(e.to_string()))?; - let mut proof = CommunicationValue::new(CommunicationType::ChallengeResponse) - .add_typed_default( - DataType::ClientNonce, - DataValue::UnsignedNumber(client_nonce), - ); + let mut proof = + CommunicationValue::new_with_type_map(CommunicationType::ChallengeResponse, type_map) + .add_typed_default( + DataType::ClientNonce, + DataValue::UnsignedNumber(client_nonce), + ); if keys.sig_pq_secret_key.as_bytes().is_empty() { let signature = signer @@ -213,14 +215,14 @@ pub(crate) async fn receive_verified_challenge( } let server_challenge = match challenge.get_data(DataType::ServerNonce) { - DataValue::UnsignedNumber(n) => *n, + Some(DataValue::UnsignedNumber(n)) => *n, _ => { return Err(CommunicationError::AuthenticationFailed( "Missing server challenge".into(), )); } }; - if challenge.get_data(DataType::RequirePq) == &DataValue::BoolTrue && !client_has_pq_key { + if challenge.get_data(DataType::RequirePq) == Some(&DataValue::BoolTrue) && !client_has_pq_key { return Err(CommunicationError::AuthenticationFailed( "Host requires post-quantum authentication but the client PQ key is absent".into(), )); diff --git a/client/src/lib.rs b/client/src/lib.rs index f594200..bf603f8 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -31,20 +31,22 @@ mod error { } } -use mtp_codec::{CommunicationValue, DataType, DataValue, PROTOCOL_VERSION, Version}; +use mtp_codec::{ + CommunicationValue, DataType, DataValue, PROTOCOL_VERSION, Version, + registry::{Registry, VersionedCodec}, +}; use mtp_common::{CommunicationError, HandshakeOutcome, RejectionReason}; use connection::connection_from_parts; fn parse_handshake_response( response: &CommunicationValue, + type_map: &mtp_codec::TypeMap, ) -> Result { - let tm = mtp_codec::TypeMap::latest(); - - let bad_version = mtp_codec::CommunicationType::ErrorBadVersion.try_to_id(&tm); + let bad_version = mtp_codec::CommunicationType::ErrorBadVersion.try_to_id(type_map); if Some(response.get_type()) == bad_version { let supported_versions = match response.get_data(DataType::Version) { - DataValue::Str(v) if !v.is_empty() => v.split(',').map(String::from).collect(), + Some(DataValue::Str(v)) if !v.is_empty() => v.split(',').map(String::from).collect(), _ => vec![], }; return Ok(HandshakeOutcome::Rejected { @@ -53,7 +55,7 @@ fn parse_handshake_response( } let expected = mtp_codec::CommunicationType::IdentificationResponse - .try_to_id(&tm) + .try_to_id(type_map) .ok_or_else(|| { CommunicationError::Other("IdentificationResponse is absent from the type map".into()) })?; @@ -68,9 +70,9 @@ fn parse_handshake_response( } match response.get_data(DataType::Connected) { - DataValue::BoolTrue => { + Some(DataValue::BoolTrue) => { let version = match response.get_data(DataType::Version) { - DataValue::Str(v) => v.clone(), + Some(DataValue::Str(v)) => v.clone(), _ => { return Err(CommunicationError::Other( "host omitted the negotiated version".into(), @@ -78,15 +80,21 @@ fn parse_handshake_response( } }; let assigned_id = match response.get_data(DataType::Id) { - DataValue::UnsignedNumber(n) => *n as u64, - _ => 0, + Some(DataValue::UnsignedNumber(n)) => u64::try_from(*n).map_err(|_| { + CommunicationError::Other("host returned an out-of-range client id".into()) + })?, + _ => { + return Err(CommunicationError::Other( + "host omitted the assigned client id".into(), + )); + } }; Ok(HandshakeOutcome::Accepted { version, assigned_id, }) } - DataValue::BoolFalse => { + Some(DataValue::BoolFalse) => { let detail = response .get_str(DataType::ErrorMessage) .unwrap_or("host rejected the connection") @@ -99,20 +107,34 @@ fn parse_handshake_response( } } +fn codec_for_version(version: &Version) -> Result { + VersionedCodec::for_version(Registry::builtin(), version.clone()).ok_or_else(|| { + CommunicationError::Other(format!( + "host returned unsupported protocol version {version}" + )) + }) +} + pub struct MTPClient; impl MTPClient { pub async fn connect(config: ClientConfig) -> Result { let (sender, receiver) = mtp_transport::connect(&config.url, config.server_cert(), config.policy).await?; + let opening_codec = codec_for_version(&PROTOCOL_VERSION)?; + sender.set_type_map(opening_codec.type_map()).await; + receiver.set_type_map(opening_codec.type_map()).await; let version_str = format!("{}", PROTOCOL_VERSION); - let mut ident = CommunicationValue::new(mtp_codec::CommunicationType::Identification) - .add_typed_default(DataType::Version, DataValue::Str(version_str)) - .add_typed_default( - DataType::Id, - DataValue::UnsignedNumber(config.client_id.into()), - ); + let mut ident = CommunicationValue::new_with_type_map( + mtp_codec::CommunicationType::Identification, + opening_codec.type_map(), + ) + .add_typed_default(DataType::Version, DataValue::Str(version_str)) + .add_typed_default( + DataType::Id, + DataValue::UnsignedNumber(config.client_id.into()), + ); if let Some(desc) = &config.description { ident = ident.add_typed_default(DataType::Description, DataValue::Str(desc.clone())); } @@ -120,32 +142,47 @@ impl MTPClient { sender.send(&ident).await?; let response = receiver.receive().await?; - let outcome = parse_handshake_response(&response)?; - let negotiated = match outcome { - mtp_common::HandshakeOutcome::Accepted { version, .. } => Version::parse(&version) - .ok_or_else(|| { + let outcome = parse_handshake_response(&response, opening_codec.type_map())?; + let (negotiated, assigned_id) = match outcome { + mtp_common::HandshakeOutcome::Accepted { + version, + assigned_id, + } => ( + Version::parse(&version).ok_or_else(|| { CommunicationError::Other("host returned an invalid negotiated version".into()) })?, + assigned_id, + ), mtp_common::HandshakeOutcome::Rejected { reason } => { sender.close().await; return Err(CommunicationError::Other(reason.to_string())); } }; + #[cfg(not(feature = "crypto"))] + let _ = assigned_id; + if negotiated != PROTOCOL_VERSION { + sender.close().await; + return Err(CommunicationError::Other( + "host selected a protocol version the client did not offer".into(), + )); + } + let codec = codec_for_version(&negotiated)?; #[cfg(feature = "crypto")] - let client_id = config.client_id; + let client_id = assigned_id; #[cfg(feature = "crypto")] return Ok(connection_from_parts( config, sender, receiver, negotiated, + codec, error::AuthState::Unauthenticated, client_id, ) .await); #[cfg(not(feature = "crypto"))] - Ok(connection_from_parts(config, sender, receiver, negotiated).await) + Ok(connection_from_parts(config, sender, receiver, negotiated, codec).await) } } @@ -180,15 +217,26 @@ impl MTPClient { let (sender, receiver) = mtp_transport::connect(&config.url, config.server_cert(), config.policy).await?; - let tm = mtp_codec::TypeMap::latest(); + let handshake_codec = codec_for_version(&PROTOCOL_VERSION)?; + let tm = handshake_codec.type_map().clone(); + sender.set_type_map(&tm).await; + receiver.set_type_map(&tm).await; let version_str = format!("{}", PROTOCOL_VERSION); - let mut ident = CommunicationValue::new(CommunicationType::Identification) - .add_typed_default(DataType::Version, DataValue::Str(version_str.clone())) - .add_typed_default( - DataType::Id, - DataValue::UnsignedNumber(config.client_id as u128), - ); + let mut ident = + CommunicationValue::new_with_type_map(CommunicationType::Identification, &tm) + .add_typed_default(DataType::Version, DataValue::Str(version_str.clone())) + .add_typed_default( + DataType::Id, + DataValue::UnsignedNumber(config.client_id as u128), + ) + // This capability marker lets a non-crypto host reject an + // authentication attempt instead of treating it as a plain + // unauthenticated connection. + .add_typed_default( + DataType::PublicKeys, + DataValue::Bytes(keys.public_key_bundle().as_bytes()), + ); if let Some(desc) = &config.description { ident = ident.add_typed_default(DataType::Description, DataValue::Str(desc.clone())); } @@ -223,14 +271,14 @@ impl MTPClient { client_nonce, ); - let proof = match crypto::signed_challenge_response(keys, proof_payload, client_nonce).await - { - Ok(p) => p, - Err(e) => { - sender.close().await; - return Err(e); - } - }; + let proof = + match crypto::signed_challenge_response(keys, proof_payload, client_nonce, &tm).await { + Ok(p) => p, + Err(e) => { + sender.close().await; + return Err(e); + } + }; if let Err(e) = sender.send(&proof).await { sender.close().await; return Err(e); @@ -276,12 +324,41 @@ impl MTPClient { return Err(e); } + let assigned_id = match response.get_data(DataType::Id) { + Some(DataValue::UnsignedNumber(id)) => u64::try_from(*id).map_err(|_| { + CommunicationError::AuthenticationFailed( + "host returned an out-of-range client id".into(), + ) + })?, + _ => { + sender.close().await; + return Err(CommunicationError::AuthenticationFailed( + "host omitted the authenticated client id".into(), + )); + } + }; + if assigned_id != config.client_id { + sender.close().await; + return Err(CommunicationError::AuthenticationFailed( + "host returned a different authenticated client id".into(), + )); + } + + let negotiated = crypto::negotiated_version(&response)?; + if negotiated != PROTOCOL_VERSION { + sender.close().await; + return Err(CommunicationError::AuthenticationFailed( + "host selected a protocol version the client did not offer".into(), + )); + } + let codec = codec_for_version(&negotiated)?; let client_id = config.client_id; Ok(connection_from_parts( config, sender, receiver, - crypto::negotiated_version(&response)?, + negotiated, + codec, error::AuthState::Authenticated, client_id, ) @@ -332,12 +409,15 @@ impl MTPClient { let (sender, receiver) = mtp_transport::connect(&config.url, config.server_cert(), config.policy).await?; - let tm = mtp_codec::TypeMap::latest(); + let handshake_codec = codec_for_version(&PROTOCOL_VERSION)?; + let tm = handshake_codec.type_map().clone(); + sender.set_type_map(&tm).await; + receiver.set_type_map(&tm).await; let version_str = format!("{}", PROTOCOL_VERSION); let pk_bundle = keys.public_key_bundle(); let pk_bytes = pk_bundle.as_bytes(); - let mut register = CommunicationValue::new(CommunicationType::Register) + let mut register = CommunicationValue::new_with_type_map(CommunicationType::Register, &tm) .add_typed_default(DataType::Version, DataValue::Str(version_str.clone())) .add_typed_default(DataType::PublicKeys, DataValue::Bytes(pk_bytes.clone())); if let Some(desc) = &config.description { @@ -375,14 +455,14 @@ impl MTPClient { client_nonce, ); - let proof = match crypto::signed_challenge_response(keys, proof_payload, client_nonce).await - { - Ok(p) => p, - Err(e) => { - sender.close().await; - return Err(e); - } - }; + let proof = + match crypto::signed_challenge_response(keys, proof_payload, client_nonce, &tm).await { + Ok(p) => p, + Err(e) => { + sender.close().await; + return Err(e); + } + }; if let Err(e) = sender.send(&proof).await { sender.close().await; return Err(e); @@ -413,7 +493,11 @@ impl MTPClient { return Err(e); } let assigned_id = match response.get_data(DataType::Id) { - DataValue::UnsignedNumber(n) => *n as u64, + Some(DataValue::UnsignedNumber(n)) => u64::try_from(*n).map_err(|_| { + CommunicationError::AuthenticationFailed( + "host returned an out-of-range client id".into(), + ) + })?, _ => { sender.close().await; return Err(CommunicationError::AuthenticationFailed( @@ -435,11 +519,20 @@ impl MTPClient { return Err(e); } + let negotiated = crypto::negotiated_version(&response)?; + if negotiated != PROTOCOL_VERSION { + sender.close().await; + return Err(CommunicationError::AuthenticationFailed( + "host selected a protocol version the client did not offer".into(), + )); + } + let codec = codec_for_version(&negotiated)?; Ok(connection_from_parts( config, sender, receiver, - crypto::negotiated_version(&response)?, + negotiated, + codec, error::AuthState::Authenticated, assigned_id, ) @@ -504,6 +597,9 @@ mod tests { let dispatcher = pipe::PipeDispatcher { pending_requests: Mutex::new(HashMap::new()), + expired_requests: Mutex::new(HashMap::new()), + #[cfg(feature = "pipes")] + type_map: mtp_codec::TypeMap::latest(), #[cfg(feature = "pipes")] pending_creations: Mutex::new(HashMap::new()), #[cfg(feature = "pipes")] @@ -523,11 +619,47 @@ mod tests { let unrelated = CommunicationValue::new(mtp_codec::CommunicationType::Ping).with_id(8); assert!(pipe::route_message(unrelated, &app_tx, &dispatcher).await); - assert_eq!(app_rx.recv().await.unwrap().unwrap().get_id(), 8); + assert_eq!(app_rx.recv().await.unwrap().unwrap().id(), Some(8)); let response = CommunicationValue::new(mtp_codec::CommunicationType::Pong).with_id(7); assert!(pipe::route_message(response, &app_tx, &dispatcher).await); - assert_eq!(response_rx.await.unwrap().unwrap().get_id(), 7); + assert_eq!(response_rx.await.unwrap().unwrap().id(), Some(7)); + assert!(app_rx.try_recv().is_err()); + } + + #[tokio::test] + async fn test_expired_request_response_is_consumed() { + use std::collections::HashMap; + use std::sync::Arc; + use tokio::sync::Mutex; + + let dispatcher = pipe::PipeDispatcher { + pending_requests: Mutex::new(HashMap::new()), + expired_requests: Mutex::new(HashMap::new()), + #[cfg(feature = "pipes")] + type_map: mtp_codec::TypeMap::latest(), + #[cfg(feature = "pipes")] + pending_creations: Mutex::new(HashMap::new()), + #[cfg(feature = "pipes")] + pending_pipes: Mutex::new(HashMap::new()), + #[cfg(feature = "pipes")] + policy: Arc::new(Policy::default()), + }; + let token = Arc::new(()); + let (response_tx, response_rx) = tokio::sync::oneshot::channel(); + dispatcher.pending_requests.lock().await.insert( + 9, + pipe::PendingRequest { + token: token.clone(), + sender: response_tx, + }, + ); + pipe::expire_pending_request(&dispatcher, 9, &token).await; + + let (app_tx, mut app_rx) = tokio::sync::mpsc::channel(1); + let response = CommunicationValue::new(mtp_codec::CommunicationType::Pong).with_id(9); + assert!(pipe::route_message(response, &app_tx, &dispatcher).await); + assert!(response_rx.await.is_err()); assert!(app_rx.try_recv().is_err()); } diff --git a/client/src/ping.rs b/client/src/ping.rs index 83d5579..4c3f3c6 100644 --- a/client/src/ping.rs +++ b/client/src/ping.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use tokio::sync::{Mutex, mpsc}; use tokio::time::{Duration, Instant}; -use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue}; +use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue, TypeMap}; use mtp_transport::{Receiver, Sender}; pub(crate) struct PingSession { @@ -59,6 +59,8 @@ pub(crate) async fn start_ping_session( config: &crate::config::ClientConfig, sender: Sender, receiver: &Receiver, + type_map: &TypeMap, + client_id: u64, ) -> Option { if config.ping_interval.is_zero() { return None; @@ -72,6 +74,7 @@ pub(crate) async fn start_ping_session( let ping_jitter = config.ping_jitter; let max_missed_pings = config.max_missed_pings; let ping_timestamp = config.ping_timestamp; + let type_map = type_map.clone(); let mut close_rx = receiver.handle().subscribe_close(); let task = tokio::spawn(async move { @@ -99,7 +102,11 @@ pub(crate) async fn start_ping_session( tokio::time::sleep(Duration::from_millis(extra)).await; } - let mut ping = CommunicationValue::new(CommunicationType::Ping); + let mut ping = CommunicationValue::new_with_type_map( + CommunicationType::Ping, + &type_map, + ) + .with_sender(client_id); if ping_timestamp { let sent_at = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -110,7 +117,10 @@ pub(crate) async fn start_ping_session( DataValue::UnsignedNumber(sent_at), ); } - let id = ping.get_id(); + let Some(id) = ping.id() else { + sender.close().await; + break; + }; if sender.send(&ping).await.is_err() { sender.close().await; break; @@ -119,7 +129,9 @@ pub(crate) async fn start_ping_session( } pong = pong_rx.recv() => match pong { Some(pong) => { - if let Some(ping) = tracker.received(pong.get_id()) { + if let Some(id) = pong.id() + && let Some(ping) = tracker.received(id) + { let mut last_ping = ping_state.lock().await; *last_ping = Some(ping); } diff --git a/client/src/pipe.rs b/client/src/pipe.rs index 9c68e32..4dc590a 100644 --- a/client/src/pipe.rs +++ b/client/src/pipe.rs @@ -1,9 +1,12 @@ use mtp_codec::CommunicationValue; +#[cfg(feature = "pipes")] +use mtp_codec::TypeMap; use mtp_common::CommunicationError; use mtp_transport::Receiver; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::{Mutex, mpsc}; +use tokio::time::{Duration, Instant}; #[cfg(feature = "pipes")] use mtp_codec::{CommunicationType, DataType, DataValue}; @@ -72,22 +75,50 @@ impl PipeRequest { pending.insert(self.pipe_id, pipe_tx); } - let resp = CommunicationValue::new(CommunicationType::PipeResponse) - .with_id(self.pipe_id) - .add_typed_default(DataType::Accepted, DataValue::BoolTrue); - self.sender.send(&resp).await.map_err(PipeError::from)?; + let resp = CommunicationValue::new_with_type_map( + CommunicationType::PipeResponse, + &self.dispatcher.type_map, + ) + .with_id(self.pipe_id) + .add_typed_default(DataType::Accepted, DataValue::BoolTrue); + if let Err(error) = self.sender.send(&resp).await { + self.dispatcher + .pending_pipes + .lock() + .await + .remove(&self.pipe_id); + return Err(PipeError::from(error)); + } let timeout = self.dispatcher.policy.read_timeout; - tokio::time::timeout(timeout, pipe_rx) - .await - .map_err(|_| PipeError::HandshakeTimeout)? - .map_err(|_| PipeError::StreamClosed) + match tokio::time::timeout(timeout, pipe_rx).await { + Ok(Ok(reader)) => Ok(reader), + Ok(Err(_)) => { + self.dispatcher + .pending_pipes + .lock() + .await + .remove(&self.pipe_id); + Err(PipeError::StreamClosed) + } + Err(_) => { + self.dispatcher + .pending_pipes + .lock() + .await + .remove(&self.pipe_id); + Err(PipeError::HandshakeTimeout) + } + } } pub async fn deny(self) -> Result<(), PipeError> { - let resp = CommunicationValue::new(CommunicationType::PipeResponse) - .with_id(self.pipe_id) - .add_typed_default(DataType::Accepted, DataValue::BoolFalse); + let resp = CommunicationValue::new_with_type_map( + CommunicationType::PipeResponse, + &self.dispatcher.type_map, + ) + .with_id(self.pipe_id) + .add_typed_default(DataType::Accepted, DataValue::BoolFalse); self.sender.send(&resp).await.map_err(PipeError::from)?; Ok(()) } @@ -100,6 +131,9 @@ pub(crate) struct PendingRequest { pub(crate) struct PipeDispatcher { pub(crate) pending_requests: Mutex>, + pub(crate) expired_requests: Mutex>, + #[cfg(feature = "pipes")] + pub(crate) type_map: TypeMap, #[cfg(feature = "pipes")] pub(crate) pending_creations: Mutex>>>, @@ -115,14 +149,27 @@ pub(crate) async fn route_message( app_tx: &mpsc::Sender>, dispatcher: &PipeDispatcher, ) -> bool { - let pending = dispatcher - .pending_requests - .lock() - .await - .remove(&msg.get_id()); - if let Some(tx) = pending { - let _ = tx.sender.send(Ok(msg)); - return true; + if !matches!(msg.id(), Some(id) if id != 0) + && msg + .get_type_name() + .is_some_and(|name| name.ends_with("Response")) + { + return app_tx + .send(Err(CommunicationError::Other( + "response frame must contain a non-zero id".into(), + ))) + .await + .is_ok(); + } + if let Some(id) = msg.id() { + let pending = dispatcher.pending_requests.lock().await.remove(&id); + if let Some(tx) = pending { + let _ = tx.sender.send(Ok(msg)); + return true; + } + if consume_expired_request(dispatcher, id).await { + return true; + } } app_tx.send(Ok(msg)).await.is_ok() @@ -135,6 +182,44 @@ pub(crate) async fn fail_pending_requests(dispatcher: &PipeDispatcher, error: Co } } +const EXPIRED_REQUEST_TOMBSTONE_TTL: Duration = Duration::from_secs(60); +const MAX_EXPIRED_REQUEST_TOMBSTONES: usize = 1024; + +pub(crate) async fn expire_pending_request( + dispatcher: &PipeDispatcher, + request_id: u32, + token: &Arc<()>, +) { + let mut pending = dispatcher.pending_requests.lock().await; + if pending + .get(&request_id) + .is_some_and(|entry| Arc::ptr_eq(&entry.token, token)) + { + pending.remove(&request_id); + drop(pending); + let mut expired = dispatcher.expired_requests.lock().await; + let now = Instant::now(); + expired.retain(|_, expires_at| *expires_at > now); + if expired.len() >= MAX_EXPIRED_REQUEST_TOMBSTONES { + if let Some(oldest) = expired + .iter() + .min_by_key(|(_, expires_at)| **expires_at) + .map(|(id, _)| *id) + { + expired.remove(&oldest); + } + } + expired.insert(request_id, now + EXPIRED_REQUEST_TOMBSTONE_TTL); + } +} + +pub(crate) async fn is_expired_request(dispatcher: &PipeDispatcher, request_id: u32) -> bool { + let mut expired = dispatcher.expired_requests.lock().await; + let now = Instant::now(); + expired.retain(|_, expires_at| *expires_at > now); + expired.contains_key(&request_id) +} + pub(crate) async fn remove_pending_request( dispatcher: &PipeDispatcher, request_id: u32, @@ -149,6 +234,13 @@ pub(crate) async fn remove_pending_request( } } +async fn consume_expired_request(dispatcher: &PipeDispatcher, request_id: u32) -> bool { + let mut expired = dispatcher.expired_requests.lock().await; + let now = Instant::now(); + expired.retain(|_, expires_at| *expires_at > now); + expired.remove(&request_id).is_some() +} + #[cfg(feature = "pipes")] pub(crate) async fn run_dispatcher( receiver: Receiver, @@ -157,14 +249,19 @@ pub(crate) async fn run_dispatcher( pipe_req_tx: mpsc::Sender, dispatcher: Arc, ) { - let pipe_req_type = CommunicationType::PipeRequest.try_to_id(&mtp_codec::TypeMap::latest()); - let pipe_resp_type = CommunicationType::PipeResponse.try_to_id(&mtp_codec::TypeMap::latest()); - loop { match receiver.receive_event().await { Ok(mtp_transport::TransportEvent::Message(msg)) => { - if Some(msg.get_type()) == pipe_req_type { - let pipe_id = msg.get_id(); + if msg.is_type(CommunicationType::PipeRequest) { + let Some(pipe_id) = msg.id().filter(|id| *id != 0) else { + let error = CommunicationError::Other( + "PipeRequest frame must contain a non-zero id".into(), + ); + if app_tx.send(Err(error)).await.is_err() { + break; + } + continue; + }; let description = msg.get_str(DataType::Description).unwrap_or("").to_string(); let req = PipeRequest { pipe_id, @@ -176,8 +273,16 @@ pub(crate) async fn run_dispatcher( continue; } - if Some(msg.get_type()) == pipe_resp_type { - let pipe_id = msg.get_id(); + if msg.is_type(CommunicationType::PipeResponse) { + let Some(pipe_id) = msg.id().filter(|id| *id != 0) else { + let error = CommunicationError::Other( + "PipeResponse frame must contain a non-zero id".into(), + ); + if app_tx.send(Err(error)).await.is_err() { + break; + } + continue; + }; let accepted = msg.get_bool(DataType::Accepted).unwrap_or(false); let mut pending = dispatcher.pending_creations.lock().await; if let Some(tx) = pending.remove(&pipe_id) { diff --git a/codec/Cargo.toml b/codec/Cargo.toml index ab2c745..02e8dfe 100644 --- a/codec/Cargo.toml +++ b/codec/Cargo.toml @@ -10,6 +10,7 @@ mtp-crypto = { version = "0.2.0", path = "../crypto", optional = true } base64 = "0.23" byteorder = "1.5" rand = { version = "0.10.1", features = ["std", "std_rng"] } +thiserror = "2.0.18" [features] registry = ["mtp-type-map/registry"] diff --git a/codec/src/communication_value.rs b/codec/src/communication_value.rs index 0244f42..d7aac8e 100644 --- a/codec/src/communication_value.rs +++ b/codec/src/communication_value.rs @@ -1,136 +1,108 @@ use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; -use std::collections::BTreeMap; use std::fmt; -use std::io::{Cursor, Read}; +use std::io::Cursor; -use crate::data_value::{DataKind, DataValue}; +use crate::data_value::{DataKind, DataValue, DecodeLimits}; use crate::rand_u32; use mtp_common::CodecError; -#[cfg(all(test, feature = "registry"))] -use mtp_type_map::Version; use mtp_type_map::{ CommunicationType, CommunicationTypeId, DataType, DataTypeId, PROTOCOL_VERSION, TypeMap, }; -/// Largest sender or receiver identifier representable by the six-byte wire fields. -pub const MAX_WIRE_ID: u64 = (1 << 48) - 1; - -#[cfg(feature = "crypto")] -use mtp_crypto::{PublicKeyBundle, SigAlgorithm, SignatureScheme}; - -const FLAG_HAS_SENDER: u8 = 0b0000_0001; -const FLAG_HAS_RECEIVER: u8 = 0b0000_0010; -const FLAG_HAS_ID: u8 = 0b0000_0100; -const FLAG_ENCRYPTED: u8 = 0b0000_1000; -const FLAG_SIGNED: u8 = 0b0001_0000; -const FLAG_SIGNED_ENCRYPTED: u8 = 0b0010_0000; - -/// An opaque, frame-level encrypted payload. -/// -/// This is separate from [`DataValue`] because encrypted frame bytes are not a -/// typed data map until they have been decrypted. -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg(feature = "crypto")] -pub enum EncryptedPayload { - Plain(Vec), - Signed(Vec), -} - -#[cfg(feature = "crypto")] -impl EncryptedPayload { - #[must_use] - pub fn as_bytes(&self) -> &[u8] { - match self { - Self::Plain(bytes) | Self::Signed(bytes) => bytes, - } - } -} +const FLAG_HAS_ID: u8 = 0b0000_0001; +const FLAG_HAS_SENDER: u8 = 0b0000_0010; +const FLAG_HAS_RECEIVER: u8 = 0b0000_0100; +const FLAG_KNOWN: u8 = FLAG_HAS_ID | FLAG_HAS_SENDER | FLAG_HAS_RECEIVER; #[derive(Debug, Clone, PartialEq, Eq)] pub struct CommunicationValue { - id: u32, + id: Option, comm_type: CommunicationTypeId, - sender: u64, - receiver: u64, - data: BTreeMap, - #[cfg(feature = "crypto")] - encrypted_payload: Option, + sender: Option, + receiver: Option, + payload: DataValue, type_map: Option, mapping_error: Option, - #[cfg(feature = "crypto")] - frame_signature: Option<(u8, Vec)>, } impl CommunicationValue { #[must_use] pub fn new(comm_type: CommunicationType) -> Self { - let tm = TypeMap::new(PROTOCOL_VERSION); - let id = comm_type.try_to_id(&tm); + Self::new_with_type_map(comm_type, &TypeMap::new(PROTOCOL_VERSION)) + } + + /// Construct a frame using an explicitly negotiated type map. + /// + /// The type map is local codec context rather than wire data, so callers + /// that build a frame for a non-latest negotiated version must retain it + /// on the `CommunicationValue` as well as using it to resolve the fields. + #[must_use] + pub fn new_with_type_map(comm_type: CommunicationType, type_map: &TypeMap) -> Self { + let id = comm_type.try_to_id(type_map); Self { - id: rand_u32(), + id: Some(rand_u32()), comm_type: id.unwrap_or(CommunicationTypeId(0)), - sender: 0, - receiver: 0, - data: BTreeMap::new(), - #[cfg(feature = "crypto")] - encrypted_payload: None, - type_map: Some(tm), + sender: None, + receiver: None, + payload: DataValue::Container(Vec::new()), + type_map: Some(type_map.clone()), mapping_error: id .is_none() .then(|| CodecError::UnknownCommunicationType(comm_type.name().to_string())), - #[cfg(feature = "crypto")] - frame_signature: None, } } #[cfg(feature = "registry")] #[must_use] - pub fn from_comm(comm_type: CommunicationType, tm: &TypeMap) -> Self { - let id = comm_type.try_to_id(tm); - Self { - id: rand_u32(), - comm_type: id.unwrap_or(CommunicationTypeId(0)), - sender: 0, - receiver: 0, - data: BTreeMap::new(), - #[cfg(feature = "crypto")] - encrypted_payload: None, - type_map: Some(tm.clone()), - mapping_error: id - .is_none() - .then(|| CodecError::UnknownCommunicationType(comm_type.name().to_string())), - #[cfg(feature = "crypto")] - frame_signature: None, - } + pub fn from_comm(comm_type: CommunicationType, type_map: &TypeMap) -> Self { + Self::new_with_type_map(comm_type, type_map) } #[must_use] - pub fn with_id(mut self, p0: u32) -> Self { - self.id = p0; + pub fn with_id(mut self, id: u32) -> Self { + self.id = Some(id); self } - pub fn get_id(&self) -> u32 { + #[must_use] + pub fn without_id(mut self) -> Self { + self.id = None; + self + } + + pub fn id(&self) -> Option { self.id } #[must_use] pub fn with_sender(mut self, sender: u64) -> Self { - self.sender = sender; + self.sender = Some(sender); self } - pub fn get_sender(&self) -> u64 { + #[must_use] + pub fn without_sender(mut self) -> Self { + self.sender = None; + self + } + + pub fn sender(&self) -> Option { self.sender } #[must_use] pub fn with_receiver(mut self, receiver: u64) -> Self { - self.receiver = receiver; + self.receiver = Some(receiver); self } - pub fn get_receiver(&self) -> u64 { + #[must_use] + pub fn without_receiver(mut self) -> Self { + self.receiver = None; + self + } + + pub fn receiver(&self) -> Option { self.receiver } @@ -138,157 +110,117 @@ impl CommunicationValue { self.comm_type } - /// Returns the protocol type map attached to this frame. pub fn type_map(&self) -> Option<&TypeMap> { self.type_map.as_ref() } - /// Binds the frame's numeric type identifiers to a protocol version. - pub fn set_type_map(&mut self, tm: &TypeMap) { - self.type_map = Some(tm.clone()); + pub fn set_type_map(&mut self, type_map: &TypeMap) { + self.type_map = Some(type_map.clone()); } - #[must_use] - pub fn add_data(mut self, data: DataTypeId, value: DataValue) -> Self { - #[cfg(feature = "crypto")] - { - self.encrypted_payload = None; - } - self.data.insert(data, value); - self + /// Add a field to a clear container payload. + pub fn add_data(mut self, data_type: DataTypeId, value: DataValue) -> Result { + self.insert_data(data_type, value)?; + Ok(self) } #[cfg(feature = "registry")] #[must_use] - pub fn add_typed(mut self, data: DataType, tm: &TypeMap, value: DataValue) -> Self { - #[cfg(feature = "crypto")] - { - self.encrypted_payload = None; - } - if let Some(id) = data.try_to_id(tm) { - self.data.insert(id, value); - } else if self.mapping_error.is_none() { - self.mapping_error = Some(CodecError::UnknownDataType(data.name().to_string())); + pub fn add_typed(mut self, data: DataType, type_map: &TypeMap, value: DataValue) -> Self { + match data.try_to_id(type_map) { + Some(id) => { + self.insert_data_or_record_error(id, value); + } + None if self.mapping_error.is_none() => { + self.mapping_error = Some(CodecError::UnknownDataType(data.name().to_string())); + } + None => {} } self } #[must_use] pub fn add_typed_default(mut self, data: DataType, value: DataValue) -> Self { - #[cfg(feature = "crypto")] - { - self.encrypted_payload = None; - } - let tm = self.type_map.clone().unwrap_or_else(TypeMap::latest); - if let Some(id) = data.try_to_id(&tm) { - self.data.insert(id, value); - } else if self.mapping_error.is_none() { - self.mapping_error = Some(CodecError::UnknownDataType(data.name().to_string())); + let type_map = self.type_map.clone().unwrap_or_else(TypeMap::latest); + match data.try_to_id(&type_map) { + Some(id) => { + self.insert_data_or_record_error(id, value); + } + None if self.mapping_error.is_none() => { + self.mapping_error = Some(CodecError::UnknownDataType(data.name().to_string())); + } + None => {} } self } - pub fn get_data(&self, data_type: DataType) -> &DataValue { - let tm_owned; - let tm = match &self.type_map { - Some(tm) => tm, - None => { - tm_owned = TypeMap::latest(); - &tm_owned - } - }; - match tm.data_id_enum(data_type) { - Some(raw_id) => self - .data - .get(&DataTypeId(raw_id)) - .unwrap_or(&DataValue::Null), - None => &DataValue::Null, + fn insert_data(&mut self, data_type: DataTypeId, value: DataValue) -> Result<(), CodecError> { + let entries = self + .payload + .container_entries_mut() + .ok_or(CodecError::InvalidEncoding)?; + if let Some((_, existing)) = entries.iter_mut().find(|(id, _)| *id == data_type) { + *existing = value; + } else { + entries.push((data_type, value)); + } + Ok(()) + } + + fn insert_data_or_record_error(&mut self, data_type: DataTypeId, value: DataValue) { + if self.insert_data(data_type, value).is_err() { + self.mapping_error + .get_or_insert(CodecError::InvalidEncoding); } } - pub fn get_data_opt(&self, data_type: DataType) -> Option<&DataValue> { - let tm_owned; - let tm = match &self.type_map { - Some(tm) => tm, - None => { - tm_owned = TypeMap::latest(); - &tm_owned - } - }; - let raw_id = tm.data_id_enum(data_type)?; - self.data.get(&DataTypeId(raw_id)) + pub fn get_data(&self, data_type: DataType) -> Option<&DataValue> { + let type_map = self.type_map.clone().unwrap_or_else(TypeMap::latest); + let id = type_map.data_id_enum(data_type)?; + self.payload.get_field(DataTypeId(id)) } pub fn has_data(&self, data_type: DataType) -> Option { - self.get_data_opt(data_type).map(|v| v.kind()) + self.get_data(data_type).map(DataValue::kind) } pub fn get_comm_type_enum(&self) -> Option { - let tm_owned; - let tm = match &self.type_map { - Some(tm) => tm, - None => { - tm_owned = TypeMap::latest(); - &tm_owned - } - }; - tm.comm_enum_id(self.comm_type.0) + let type_map = self.type_map.clone().unwrap_or_else(TypeMap::latest); + type_map.comm_enum_id(self.comm_type.0) } - pub fn data(&self) -> &BTreeMap { - &self.data + /// Return clear container entries. Protected or scalar payloads return + /// `None` instead of being mistaken for an empty container. + pub fn data(&self) -> Option<&[(DataTypeId, DataValue)]> { + self.payload.container_entries() } - /// Returns the number of cleartext data entries. - /// - /// An encrypted frame has no cleartext entries until - /// [`Self::set_decrypted_container`] is called. - pub fn data_len(&self) -> usize { - self.data.len() + pub fn payload(&self) -> &DataValue { + &self.payload + } + + pub fn into_payload(self) -> DataValue { + self.payload } - #[cfg(feature = "crypto")] #[must_use] - pub fn with_encrypted_payload(mut self, payload: EncryptedPayload) -> Self { - self.data.clear(); - self.encrypted_payload = Some(payload); + pub fn with_payload(mut self, payload: DataValue) -> Self { + self.payload = payload; self } - #[cfg(feature = "crypto")] - #[must_use] - pub fn encrypted_payload(&self) -> Option<&EncryptedPayload> { - self.encrypted_payload.as_ref() + pub fn data_len(&self) -> usize { + self.payload + .container_entries() + .map_or(0, |entries| entries.len()) } - #[cfg(feature = "crypto")] - #[must_use] - pub fn is_encrypted(&self) -> bool { - self.encrypted_payload.is_some() - } - - /// Replaces an opaque encrypted payload with its decrypted typed entries. - #[cfg(feature = "crypto")] - pub fn set_decrypted_container( - &mut self, - entries: impl IntoIterator, - ) { - self.data = entries.into_iter().collect(); - self.encrypted_payload = None; - } - - /// Returns the number of logical payload items available in the frame. - #[must_use] pub fn payload_len(&self) -> usize { - #[cfg(feature = "crypto")] - if self.encrypted_payload.is_some() { - return 1; - } - self.data.len() + self.payload + .container_entries() + .map_or(1, |entries| entries.len()) } - // ── type checks ────────────────────────────────────────────────────────── - pub fn is_type(&self, comm_type: CommunicationType) -> bool { self.get_comm_type_enum() == Some(comm_type) } @@ -296,590 +228,268 @@ impl CommunicationValue { pub fn get_type_name(&self) -> Option<&'static str> { self.type_map .as_ref() - .and_then(|tm| tm.communication_type_name(self.comm_type.0)) + .and_then(|type_map| type_map.communication_type_name(self.comm_type.0)) } - // ── mutation ───────────────────────────────────────────────────────────── - - pub fn set_data(&mut self, data_type: DataType, value: DataValue) { - let tm = self.type_map.clone().unwrap_or_else(TypeMap::latest); - if let Some(id) = data_type.try_to_id(&tm) { - #[cfg(feature = "crypto")] - { - self.encrypted_payload = None; + pub fn set_data(&mut self, data: DataType, value: DataValue) { + let type_map = self.type_map.clone().unwrap_or_else(TypeMap::latest); + match data.try_to_id(&type_map) { + Some(id) => { + self.insert_data_or_record_error(id, value); } - self.data.insert(id, value); - } else if self.mapping_error.is_none() { - self.mapping_error = Some(CodecError::UnknownDataType(data_type.name().to_string())); + None if self.mapping_error.is_none() => { + self.mapping_error = Some(CodecError::UnknownDataType(data.name().to_string())); + } + None => {} } } #[must_use] - pub fn with_data(mut self, data_type: DataType, value: DataValue) -> Self { - self.set_data(data_type, value); + pub fn with_data(mut self, data: DataType, value: DataValue) -> Self { + self.set_data(data, value); self } - pub fn remove_data(&mut self, data_type: DataType) -> Option { - let tm_owned; - let tm = match &self.type_map { - Some(tm) => tm, - None => { - tm_owned = TypeMap::latest(); - &tm_owned - } - }; - let raw_id = tm.data_id_enum(data_type)?; - self.data.remove(&DataTypeId(raw_id)) + pub fn remove_data(&mut self, data: DataType) -> Option { + let type_map = self.type_map.clone().unwrap_or_else(TypeMap::latest); + let id = DataTypeId(type_map.data_id_enum(data)?); + let entries = self.payload.container_entries_mut()?; + let index = entries.iter().position(|(entry_id, _)| *entry_id == id)?; + Some(entries.remove(index).1) } #[must_use] pub fn reply_to(&self, comm_type: CommunicationType) -> Self { - Self::new(comm_type) - .with_sender(self.receiver) - .with_receiver(self.sender) + let mut response = Self::new(comm_type); + response.sender = self.receiver; + response.receiver = self.sender; + response } - pub fn merge(&mut self, other: &CommunicationValue) { + pub fn merge(&mut self, other: &Self) { if self.mapping_error.is_none() { self.mapping_error.clone_from(&other.mapping_error); } - #[cfg(feature = "crypto")] - if !other.data.is_empty() { - self.encrypted_payload = None; - } - for (id, value) in &other.data { - self.data.insert(*id, value.clone()); - } - } - - // ── typed iteration ────────────────────────────────────────────────────── - - pub fn iter_typed_data(&self) -> impl Iterator, &DataValue)> + '_ { - let tm = self.type_map.clone().unwrap_or_else(TypeMap::latest); - self.data - .iter() - .map(move |(id, val)| (tm.data_enum_id(id.0), val)) - } - - // ── typed field accessors ───────────────────────────────────────────────── - - pub fn get_bool(&self, data_type: DataType) -> Option { - self.get_data_opt(data_type)?.as_bool() - } - - pub fn get_str(&self, data_type: DataType) -> Option<&str> { - self.get_data_opt(data_type)?.as_str() - } - - pub fn get_u128(&self, data_type: DataType) -> Option { - self.get_data_opt(data_type)?.as_unsigned_number() - } - - pub fn get_i128(&self, data_type: DataType) -> Option { - self.get_data_opt(data_type)?.as_signed_number() - } - - pub fn get_float(&self, data_type: DataType) -> Option { - self.get_data_opt(data_type)?.as_float() - } - - pub fn get_bytes(&self, data_type: DataType) -> Option<&[u8]> { - self.get_data_opt(data_type)?.as_bytes_slice() - } - - pub fn get_array(&self, data_type: DataType) -> Option<&[DataValue]> { - self.get_data_opt(data_type)?.as_array_slice() - } -} - -impl CommunicationValue { - /* - * Frame format (strict new format): - * [4 bytes u32 total_length] // number of bytes after this field - * [2 bytes u16 communication_type] - * [1 byte flags] - * [optional 4 bytes id] // if flags bit2 set - * [optional 6 bytes sender] // if flags bit0 set - * [optional 6 bytes receiver] // if flags bit1 set - * [optional 1 byte signature type] // if flags bit4 set; Type defines length of signature - * [optional signature] // if flags bit4 set - * [data container bytes...] - * - * Flags: - * bit0 => has sender - * bit1 => has receiver - * bit2 => has id - * bit3 => is data encrypted If so data bytes will be an encrypted container - * bit4 => is communication value signed - * bit5 => encrypted payload contains a signed container - */ - /* - * Build the canonical metadata header and data payload shared by both - * `to_bytes` and `build_signed_payload`. Keeping a single source here - * guarantees the serialized frame and the signed-over bytes stay in sync. - * - * Returns `(metadata, data_bytes)` where - * metadata = comm_type || flags || id? || sender? || receiver? - * - * `force_signed` forces the `FLAG_SIGNED` bit on regardless of whether a - * signature is currently attached. The signed-payload path passes `true` so - * that the bytes signed by `sign_frame` (before the signature is stored) and - * the bytes verified by `verify_frame` (after it is stored) are identical. - */ - fn build_metadata_and_data( - &self, - force_signed: bool, - ) -> Result<(Vec, Vec), CodecError> { - if let Some(error) = &self.mapping_error { - return Err(error.clone()); - } - if self.sender > MAX_WIRE_ID || self.receiver > MAX_WIRE_ID { - return Err(CodecError::InvalidEncoding); - } - let has_sender = self.sender != 0; - let has_receiver = self.receiver != 0; - let has_id = self.id != 0; - - #[cfg(feature = "crypto")] - let is_encrypted = self.encrypted_payload.is_some(); - #[cfg(not(feature = "crypto"))] - let is_encrypted = false; - - #[cfg(feature = "crypto")] - let is_signed_encrypted = - matches!(self.encrypted_payload, Some(EncryptedPayload::Signed(_))); - - #[cfg(feature = "crypto")] - let has_frame_sig = self.frame_signature.is_some(); - #[cfg(not(feature = "crypto"))] - let has_frame_sig = false; - - let mut flags: u8 = 0; - if has_sender { - flags |= FLAG_HAS_SENDER; - } - if has_receiver { - flags |= FLAG_HAS_RECEIVER; - } - if has_id { - flags |= FLAG_HAS_ID; - } - if is_encrypted { - flags |= FLAG_ENCRYPTED; - } - #[cfg(feature = "crypto")] - if is_signed_encrypted { - flags |= FLAG_SIGNED_ENCRYPTED; - } - if has_frame_sig || force_signed { - flags |= FLAG_SIGNED; - } - - let mut metadata = Vec::new(); - let _ = metadata.write_u16::(self.comm_type.0); - metadata.push(flags); - - if has_id { - let _ = metadata.write_u32::(self.id); - } - - if has_sender { - let sender_be = self.sender.to_be_bytes(); - metadata.extend_from_slice(&sender_be[2..]); - } - - if has_receiver { - let receiver_be = self.receiver.to_be_bytes(); - metadata.extend_from_slice(&receiver_be[2..]); - } - - #[cfg(feature = "crypto")] - let data_bytes = match &self.encrypted_payload { - Some(payload) => payload.as_bytes().to_vec(), - None => DataValue::container_from_map(&self.data).to_bytes()?, + let Some(other_entries) = other.payload.container_entries() else { + self.mapping_error + .get_or_insert(CodecError::InvalidEncoding); + return; }; + for (id, value) in other_entries { + let _ = self.insert_data(*id, value.clone()); + } + } - #[cfg(not(feature = "crypto"))] - let data_bytes = DataValue::container_from_map(&self.data).to_bytes()?; + pub fn iter_typed_data(&self) -> Box, &DataValue)> + '_> { + let type_map = self.type_map.clone().unwrap_or_else(TypeMap::latest); + match &self.payload { + DataValue::Container(entries) => Box::new( + entries + .iter() + .map(move |(id, value)| (type_map.data_enum_id(id.0), value)), + ), + _ => Box::new(std::iter::empty()), + } + } - Ok((metadata, data_bytes)) + pub fn get_bool(&self, data: DataType) -> Option { + self.get_data(data)?.as_bool() + } + pub fn get_str(&self, data: DataType) -> Option<&str> { + self.get_data(data)?.as_str() + } + pub fn get_u128(&self, data: DataType) -> Option { + self.get_data(data)?.as_unsigned_number() + } + pub fn get_i128(&self, data: DataType) -> Option { + self.get_data(data)?.as_signed_number() + } + pub fn get_float(&self, data: DataType) -> Option { + self.get_data(data)?.as_float() + } + pub fn get_bytes(&self, data: DataType) -> Option<&[u8]> { + self.get_data(data)?.as_bytes_slice() + } + pub fn get_array(&self, data: DataType) -> Option<&[DataValue]> { + self.get_data(data)?.as_array_slice() } pub fn to_bytes(&self) -> Result, CodecError> { - let (metadata, data_bytes) = self.build_metadata_and_data(false)?; - - let mut payload = Vec::new(); - payload.extend_from_slice(&metadata); - - #[cfg(feature = "crypto")] - if let Some((alg, sig)) = &self.frame_signature { - // algorithm and signature are computed by sign_frame() and stored. - // The frame bytes are built by using the pre-computed signature. - payload.push(*alg); - payload.extend_from_slice(sig); + if let Some(error) = &self.mapping_error { + return Err(error.clone()); } - - payload.extend_from_slice(&data_bytes); - - let len = u32::try_from(payload.len()).map_err(|_| CodecError::TooManyEntries)?; - let mut frame = Vec::with_capacity(4 + payload.len()); - frame - .write_u32::(len) + let mut body = Vec::new(); + body.write_u16::(self.comm_type.0) .map_err(|_| CodecError::InvalidEncoding)?; - frame.extend_from_slice(&payload); - - Ok(frame) + let mut flags = 0; + if self.id.is_some() { + flags |= FLAG_HAS_ID; + } + if self.sender.is_some() { + flags |= FLAG_HAS_SENDER; + } + if self.receiver.is_some() { + flags |= FLAG_HAS_RECEIVER; + } + body.push(flags); + if let Some(id) = self.id { + body.write_u32::(id) + .map_err(|_| CodecError::InvalidEncoding)?; + } + if let Some(sender) = self.sender { + body.write_u64::(sender) + .map_err(|_| CodecError::InvalidEncoding)?; + } + if let Some(receiver) = self.receiver { + body.write_u64::(receiver) + .map_err(|_| CodecError::InvalidEncoding)?; + } + body.extend_from_slice(&self.payload.to_bytes()?); + let length = u32::try_from(body.len()).map_err(|_| CodecError::TooManyEntries)?; + let mut out = Vec::with_capacity(4 + body.len()); + out.write_u32::(length) + .map_err(|_| CodecError::InvalidEncoding)?; + out.extend_from_slice(&body); + Ok(out) } pub fn from_bytes(bytes: &[u8]) -> Result { - let mut cursor = Cursor::new(bytes); + Self::from_bytes_with_limits(bytes, DecodeLimits::default()) + } - let total_len = cursor + pub fn from_bytes_with_limits(bytes: &[u8], limits: DecodeLimits) -> Result { + let mut cursor = Cursor::new(bytes); + let length = cursor .read_u32::() .map_err(|_| CodecError::InvalidEncoding)? as usize; - let frame_end = 4usize - .checked_add(total_len) + let end = 4usize + .checked_add(length) .ok_or(CodecError::InvalidEncoding)?; - if bytes.len() != frame_end { + if end != bytes.len() { return Err(CodecError::InvalidEncoding); } - - let comm_type_num = cursor - .read_u16::() - .map_err(|_| CodecError::InvalidEncoding)?; - let comm_type = CommunicationTypeId(comm_type_num); - + let comm_type = CommunicationTypeId( + cursor + .read_u16::() + .map_err(|_| CodecError::InvalidEncoding)?, + ); let flags = cursor.read_u8().map_err(|_| CodecError::InvalidEncoding)?; - let has_sender = (flags & FLAG_HAS_SENDER) != 0; - let has_receiver = (flags & FLAG_HAS_RECEIVER) != 0; - let has_id = (flags & FLAG_HAS_ID) != 0; - let is_encrypted = (flags & FLAG_ENCRYPTED) != 0; - let is_signed = (flags & FLAG_SIGNED) != 0; - let is_signed_encrypted = (flags & FLAG_SIGNED_ENCRYPTED) != 0; - - if is_signed_encrypted && !is_encrypted { + if flags & !FLAG_KNOWN != 0 { return Err(CodecError::InvalidEncoding); } - - #[cfg(not(feature = "crypto"))] - if is_signed || is_encrypted || is_signed_encrypted { - return Err(CodecError::InvalidEncoding); - } - - let id = if has_id { - cursor - .read_u32::() - .map_err(|_| CodecError::InvalidEncoding)? - } else { - 0 - }; - - let sender = if has_sender { - let mut buf = [0u8; 8]; - cursor - .read_exact(&mut buf[2..]) - .map_err(|_| CodecError::InvalidEncoding)?; - u64::from_be_bytes(buf) - } else { - 0 - }; - - let receiver = if has_receiver { - let mut buf = [0u8; 8]; - cursor - .read_exact(&mut buf[2..]) - .map_err(|_| CodecError::InvalidEncoding)?; - u64::from_be_bytes(buf) - } else { - 0 - }; - - #[cfg(feature = "crypto")] - let frame_signature = if is_signed { - let alg = cursor.read_u8().map_err(|_| CodecError::InvalidEncoding)?; - let sig_len = SigAlgorithm::length(alg).ok_or(CodecError::InvalidEncoding)?; - let mut sig = vec![0u8; sig_len]; - cursor - .read_exact(&mut sig) - .map_err(|_| CodecError::InvalidEncoding)?; - Some((alg, sig)) + let id = if flags & FLAG_HAS_ID != 0 { + Some( + cursor + .read_u32::() + .map_err(|_| CodecError::InvalidEncoding)?, + ) } else { None }; - - let pos = cursor.position() as usize; - if pos > frame_end { + let sender = if flags & FLAG_HAS_SENDER != 0 { + Some( + cursor + .read_u64::() + .map_err(|_| CodecError::InvalidEncoding)?, + ) + } else { + None + }; + let receiver = if flags & FLAG_HAS_RECEIVER != 0 { + Some( + cursor + .read_u64::() + .map_err(|_| CodecError::InvalidEncoding)?, + ) + } else { + None + }; + let payload = DataValue::read_from_with_limits(&mut cursor, limits)?; + if cursor.position() as usize != end { return Err(CodecError::InvalidEncoding); } - - let data_bytes = &bytes[pos..frame_end]; - - #[cfg(feature = "crypto")] - let (encrypted_payload, data) = if is_encrypted { - let payload = if is_signed_encrypted { - EncryptedPayload::Signed(data_bytes.to_vec()) - } else { - EncryptedPayload::Plain(data_bytes.to_vec()) - }; - (Some(payload), BTreeMap::new()) - } else { - let data_value = - DataValue::from_bytes(data_bytes).ok_or(CodecError::InvalidEncoding)?; - ( - None, - data_value.as_map().ok_or(CodecError::InvalidEncoding)?, - ) - }; - - #[cfg(not(feature = "crypto"))] - let data = { - let data_value = - DataValue::from_bytes(data_bytes).ok_or(CodecError::InvalidEncoding)?; - data_value.as_map().ok_or(CodecError::InvalidEncoding)? - }; - Ok(Self { id, comm_type, sender, receiver, - data, - #[cfg(feature = "crypto")] - encrypted_payload, + payload, type_map: Some(TypeMap::new(PROTOCOL_VERSION)), mapping_error: None, - #[cfg(feature = "crypto")] - frame_signature, }) } - pub fn from_bytes_with(bytes: &[u8], tm: &TypeMap) -> Result { - let mut val = Self::from_bytes(bytes)?; - val.set_type_map(tm); - Ok(val) - } - - /* - * Sign the frame. Computes a signature over the canonical form: - * comm_type || flags || id? || sender? || receiver? || data_bytes - * - * After calling this, `to_bytes()` will embed the algorithm and - * signature before the data payload. - */ - #[cfg(feature = "crypto")] - pub fn sign_frame(&mut self, algorithm: u8, signer: &impl SignatureScheme) -> Option<()> { - let signed_payload = self.build_signed_payload().ok()?; - let sig = signer.sign(&signed_payload).ok()?; - self.frame_signature = Some((algorithm, sig)); - Some(()) - } - - /* - * Verify the frame signature. Reconstructs the signed payload from - * current state and checks it against the stored signature. - */ - #[cfg(feature = "crypto")] - pub fn verify_frame(&self, verifier: &impl SignatureScheme) -> Result<(), CodecError> { - let (_algorithm, sig) = self - .frame_signature - .as_ref() - .ok_or(CodecError::InvalidEncoding)?; - - let signed_payload = self.build_signed_payload()?; - verifier - .verify(&signed_payload, sig) - .map_err(|_| CodecError::InvalidEncoding) - } - - /* - * Reconstruct the signed payload that the frame signature covers: - * comm_type || flags || id? || sender? || receiver? || data_bytes - */ - #[cfg(feature = "crypto")] - fn build_signed_payload(&self) -> Result, CodecError> { - // Force FLAG_SIGNED on so the signed bytes match whether or not the - // signature has been attached yet (sign_frame runs before storing it). - let (metadata, data_bytes) = self.build_metadata_and_data(true)?; - Ok([metadata, data_bytes].concat()) - } - - #[cfg(feature = "crypto")] - pub fn get_frame_signature(&self) -> Option<&(u8, Vec)> { - self.frame_signature.as_ref() - } - - /* - * Verify the frame signature using a `PublicKeyBundle`. Dispatches to - * Ed25519, ML-DSA-65, or both (DUAL) based on the stored algorithm byte. - * Returns `false` if the frame has no signature or verification fails. - */ - #[cfg(feature = "crypto")] - pub fn validate_signature(&self, pk: &PublicKeyBundle) -> bool { - let Some((alg, _)) = &self.frame_signature else { - return false; - }; - struct Ed25519Verifier<'a>(&'a mtp_crypto::SignaturePublicKey); - impl SignatureScheme for Ed25519Verifier<'_> { - fn sign(&self, _: &[u8]) -> Result, mtp_crypto::CryptoError> { - Err(mtp_crypto::CryptoError::SigningFailed) - } - fn verify(&self, msg: &[u8], sig: &[u8]) -> Result<(), mtp_crypto::CryptoError> { - mtp_crypto::verify_ed25519(self.0, msg, sig) - } - } - struct MlDsaVerifier<'a>(&'a mtp_crypto::SignaturePqPublicKey); - impl SignatureScheme for MlDsaVerifier<'_> { - fn sign(&self, _: &[u8]) -> Result, mtp_crypto::CryptoError> { - Err(mtp_crypto::CryptoError::SigningFailed) - } - fn verify(&self, msg: &[u8], sig: &[u8]) -> Result<(), mtp_crypto::CryptoError> { - mtp_crypto::verify_ml_dsa(self.0, msg, sig) - } - } - match *alg { - SigAlgorithm::ED25519 => self - .verify_frame(&Ed25519Verifier(&pk.sig_cl_public_key)) - .is_ok(), - SigAlgorithm::ML_DSA_65 => self - .verify_frame(&MlDsaVerifier(&pk.sig_pq_public_key)) - .is_ok(), - SigAlgorithm::DUAL => { - // For DUAL, verify_frame passes the full combined sig to the verifier. - // We wrap a verifier that splits and checks both halves. - struct DualVerifier<'a>( - &'a mtp_crypto::SignaturePublicKey, - &'a mtp_crypto::SignaturePqPublicKey, - ); - impl SignatureScheme for DualVerifier<'_> { - fn sign(&self, _: &[u8]) -> Result, mtp_crypto::CryptoError> { - Err(mtp_crypto::CryptoError::SigningFailed) - } - fn verify( - &self, - msg: &[u8], - sig: &[u8], - ) -> Result<(), mtp_crypto::CryptoError> { - const ED_LEN: usize = 64; - if sig.len() < ED_LEN { - return Err(mtp_crypto::CryptoError::InvalidSignature); - } - mtp_crypto::verify_ed25519(self.0, msg, &sig[..ED_LEN])?; - mtp_crypto::verify_ml_dsa(self.1, msg, &sig[ED_LEN..]) - } - } - self.verify_frame(&DualVerifier(&pk.sig_cl_public_key, &pk.sig_pq_public_key)) - .is_ok() - } - _ => false, - } + pub fn from_bytes_with(bytes: &[u8], type_map: &TypeMap) -> Result { + let mut value = Self::from_bytes(bytes)?; + value.set_type_map(type_map); + Ok(value) } #[cfg(feature = "registry")] - /// Migrates this frame to `target_tm`. - /// - /// Migration changes the signed wire representation, so any existing frame - /// signature is discarded. Call [`Self::sign_frame`] after migration when - /// the migrated frame needs to be authenticated. - pub fn migrate(&self, target_tm: &TypeMap) -> Result { + pub fn migrate(&self, target: &TypeMap) -> Result { if let Some(error) = &self.mapping_error { return Err(error.clone()); } - let source_tm = self.type_map.as_ref().ok_or(CodecError::InvalidEncoding)?; - let comm_name = source_tm + let source = self.type_map.as_ref().ok_or(CodecError::InvalidEncoding)?; + let comm_name = source .communication_type_name(self.comm_type.0) .ok_or_else(|| CodecError::UnknownCommunicationType(self.comm_type.0.to_string()))?; - let comm_variant = CommunicationType::from_name(comm_name) + let comm = CommunicationType::from_name(comm_name) .ok_or_else(|| CodecError::UnknownCommunicationType(comm_name.to_string()))?; - let new_comm_id = CommunicationTypeId( - target_tm - .comm_id_enum(comm_variant) + let comm_type = CommunicationTypeId( + target + .comm_id_enum(comm) .ok_or_else(|| CodecError::UnknownCommunicationType(comm_name.to_string()))?, ); - - let mut new_data = BTreeMap::new(); - for (&old_id, value) in &self.data { - let name = source_tm - .data_type_name(old_id.0) - .ok_or_else(|| CodecError::UnknownDataType(old_id.0.to_string()))?; - let variant = DataType::from_name(name) - .ok_or_else(|| CodecError::UnknownDataType(name.to_string()))?; - let new_id = DataTypeId( - target_tm - .data_id_enum(variant) - .ok_or_else(|| CodecError::UnknownDataType(name.to_string()))?, - ); - new_data.insert(new_id, value.clone()); - } - + let payload = migrate_data_value(&self.payload, source, target)?; Ok(Self { id: self.id, - comm_type: new_comm_id, + comm_type, sender: self.sender, receiver: self.receiver, - data: new_data, - #[cfg(feature = "crypto")] - encrypted_payload: self.encrypted_payload.clone(), - type_map: Some(target_tm.clone()), + payload, + type_map: Some(target.clone()), mapping_error: None, - #[cfg(feature = "crypto")] - frame_signature: None, }) } } -fn fmt_data_value(val: &DataValue, tm: &TypeMap, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match val { +#[cfg(feature = "registry")] +fn migrate_data_value( + value: &DataValue, + source: &TypeMap, + target: &TypeMap, +) -> Result { + match value { DataValue::Container(entries) => { - write!(f, "{{")?; - for (i, (key, value)) in entries.iter().enumerate() { - if i > 0 { - write!(f, ", ")?; - } - let name = tm.data_type_name(key.0).unwrap_or("?"); - write!(f, "{}: ", name)?; - fmt_data_value(value, tm, f)?; + let mut migrated = Vec::with_capacity(entries.len()); + for (old_id, value) in entries { + let name = source + .data_type_name(old_id.0) + .ok_or_else(|| CodecError::UnknownDataType(old_id.0.to_string()))?; + let data = DataType::from_name(name) + .ok_or_else(|| CodecError::UnknownDataType(name.to_string()))?; + let new_id = DataTypeId( + target + .data_id_enum(data) + .ok_or_else(|| CodecError::UnknownDataType(name.to_string()))?, + ); + migrated.push((new_id, migrate_data_value(value, source, target)?)); } - write!(f, "}}") - } - DataValue::Array(arr) => { - write!(f, "[")?; - for (i, value) in arr.iter().enumerate() { - if i > 0 { - write!(f, ", ")?; - } - fmt_data_value(value, tm, f)?; - } - write!(f, "]") + Ok(DataValue::Container(migrated)) } + DataValue::Array(values) => Ok(DataValue::Array( + values + .iter() + .map(|value| migrate_data_value(value, source, target)) + .collect::, _>>()?, + )), #[cfg(feature = "crypto")] - DataValue::EncryptedContainer(_) => write!(f, "(Secure)"), - DataValue::Bytes(_) => write!(f, "(Binary)"), - other => write!(f, "{}", other), + DataValue::Signed(_) | DataValue::Encrypted(_) => Err(CodecError::InvalidEncoding), + scalar => Ok(scalar.clone()), } } -#[cfg(debug_assertions)] -const BOLD_BLUE: &str = "\x1b[1;34m"; -#[cfg(not(debug_assertions))] -const BOLD_BLUE: &str = ""; -#[cfg(debug_assertions)] -const GREEN: &str = "\x1b[32m"; -#[cfg(not(debug_assertions))] -const GREEN: &str = ""; -#[cfg(debug_assertions)] -const YELLOW: &str = "\x1b[33m"; -#[cfg(not(debug_assertions))] -const YELLOW: &str = ""; -#[cfg(debug_assertions)] -const ORANGE: &str = "\x1b[38;5;208m"; -#[cfg(not(debug_assertions))] -const ORANGE: &str = ""; -#[cfg(debug_assertions)] -const RESET: &str = "\x1b[0m"; -#[cfg(not(debug_assertions))] -const RESET: &str = ""; - impl fmt::Display for CommunicationValue { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let version = self @@ -887,409 +497,254 @@ impl fmt::Display for CommunicationValue { .as_ref() .map(|tm| &tm.version) .unwrap_or(&PROTOCOL_VERSION); - - write!(f, "V{}{}{}", BOLD_BLUE, version, RESET)?; - - if self.id != 0 { - write!(f, ", ID:{}{:X}{}", GREEN, self.id, RESET)?; + write!(f, "V{}, ", version)?; + if let Some(id) = self.id { + write!(f, "ID:{id:X}, ")?; } - if self.sender != 0 { - write!(f, ", S:{}{:X}{}", YELLOW, self.sender, RESET)?; + if let Some(sender) = self.sender { + write!(f, "S:{sender:X}, ")?; } - if self.receiver != 0 { - write!(f, ", R:{}{:X}{}", ORANGE, self.receiver, RESET)?; + if let Some(receiver) = self.receiver { + write!(f, "R:{receiver:X}, ")?; } + let name = self + .get_comm_type_enum() + .map(|value| value.name()) + .unwrap_or("?"); + write!(f, "{name}: ")?; + fmt_data_value( + &self.payload, + &self.type_map.clone().unwrap_or_else(TypeMap::latest), + f, + ) + } +} - let name = self.get_comm_type_enum().map(|t| t.name()).unwrap_or("?"); - write!(f, ", {}: ", name)?; - - let tm = self.type_map.clone().unwrap_or_else(TypeMap::latest); - write!(f, "{{")?; - #[cfg(feature = "crypto")] - if let Some(payload) = &self.encrypted_payload { - match payload { - EncryptedPayload::Plain(bytes) => write!(f, "(Encrypted, {} bytes)", bytes.len())?, - EncryptedPayload::Signed(bytes) => { - write!(f, "(SignedEncrypted, {} bytes)", bytes.len())? +fn fmt_data_value( + value: &DataValue, + type_map: &TypeMap, + f: &mut fmt::Formatter<'_>, +) -> fmt::Result { + match value { + DataValue::Container(entries) => { + f.write_str("{")?; + for (index, (id, value)) in entries.iter().enumerate() { + if index > 0 { + f.write_str(", ")?; } + write!(f, "{}: ", type_map.data_type_name(id.0).unwrap_or("?"))?; + fmt_data_value(value, type_map, f)?; } + f.write_str("}") } - for (i, (raw_id, value)) in self.data.iter().enumerate() { - if i > 0 { - write!(f, ", ")?; + DataValue::Array(values) => { + f.write_str("[")?; + for (index, value) in values.iter().enumerate() { + if index > 0 { + f.write_str(", ")?; + } + fmt_data_value(value, type_map, f)?; } - let dname = tm.data_enum_id(raw_id.0).map(|t| t.name()).unwrap_or("?"); - write!(f, "{}: ", dname)?; - fmt_data_value(value, &tm, f)?; + f.write_str("]") } - write!(f, "}}") + #[cfg(feature = "crypto")] + DataValue::Encrypted(_) => f.write_str("(Encrypted)"), + #[cfg(feature = "crypto")] + DataValue::Signed(_) => f.write_str("(Signed)"), + other => write!(f, "{other}"), } } -/* ================================ TESTS ================================ */ #[cfg(test)] mod tests { use super::*; - use crate::data_value::DataValue; - - fn roundtrip(cv: CommunicationValue) -> Result> { - let bytes = cv.to_bytes()?; - let decoded = CommunicationValue::from_bytes(&bytes)?; - let bytes2 = decoded.to_bytes()?; - assert_eq!(bytes, bytes2); - Ok(decoded) - } #[test] - fn test_flags_and_order_without_optional() -> Result<(), Box> { - let cv = CommunicationValue::new(CommunicationType::ErrorParsing).with_id(0); - let bytes = cv.to_bytes()?; + fn canonical_flags_and_eight_byte_ids_roundtrip() { + const SENDER_ID: u64 = 0x0102_0304_0506_0708; + const RECEIVER_ID: u64 = 0x1112_1314_1516_1718; - // [u32 len][u16 type][flags]... - assert!(bytes.len() >= 7); - let mut c = Cursor::new(bytes.as_slice()); - let total_len = c.read_u32::()?; - assert_eq!(total_len as usize + 4, bytes.len()); - - let typ = c.read_u16::()?; - assert_eq!(typ, 12); - - let flags = c.read_u8()?; - assert_eq!(flags & 0b0000_0111, 0); - Ok(()) - } - - #[test] - fn test_flags_and_order_with_all_optional() -> Result<(), Box> { - let cv = CommunicationValue::new(CommunicationType::ErrorBadVersion) - .with_id(0xAABBCCDD) - .with_sender(0x0000_1122_3344_5566) - .with_receiver(0x0000_6677_8899_AABB); - - let bytes = cv.to_bytes()?; - let mut c = Cursor::new(bytes.as_slice()); - - let total_len = c.read_u32::()?; - assert_eq!(total_len as usize + 4, bytes.len()); - - let typ = c.read_u16::()?; - assert_eq!(typ, 13); - - let flags = c.read_u8()?; - assert_eq!(flags & 0b0000_0111, 0b0000_0111); - - let id = c.read_u32::()?; - assert_eq!(id, 0xAABBCCDD); - - let mut sender6 = [0u8; 6]; - c.read_exact(&mut sender6)?; - assert_eq!(sender6, [0x11, 0x22, 0x33, 0x44, 0x55, 0x66]); - - let mut receiver6 = [0u8; 6]; - c.read_exact(&mut receiver6)?; - assert_eq!(receiver6, [0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB]); - Ok(()) - } - - #[test] - fn test_roundtrip_complex() -> Result<(), Box> { - let tm = TypeMap::latest(); - let cv = CommunicationValue::new(CommunicationType::Disconnect) - .with_id(1234) - .with_sender(111) - .with_receiver(222) - .add_typed_default(DataType::Id, DataValue::Str("alice".to_string())) - .add_typed_default(DataType::ClientNonce, DataValue::SignedNumber(42)) - .add_typed_default(DataType::ServerNonce, DataValue::BoolTrue) - .add_typed_default( - DataType::PublicKeys, - DataValue::Array(vec![DataValue::SignedNumber(1), DataValue::SignedNumber(2)]), - ); - - let decoded = roundtrip(cv.clone())?; - - assert_eq!(decoded.get_id(), 1234); - assert_eq!(decoded.get_sender(), 111); - assert_eq!(decoded.get_receiver(), 222); - assert_eq!( - decoded.get_type(), - CommunicationType::Disconnect - .try_to_id(&tm) - .expect("built-in type must be mapped") - ); - assert_eq!( - decoded.get_data(DataType::Id), - &DataValue::Str("alice".to_string()) - ); - assert_eq!( - decoded.get_data(DataType::ClientNonce), - &DataValue::SignedNumber(42) - ); - Ok(()) - } - - #[test] - fn endpoint_ids_are_limited_to_wire_width() -> Result<(), Box> { - let max = CommunicationValue::new(CommunicationType::Ping) - .with_sender(MAX_WIRE_ID) - .with_receiver(MAX_WIRE_ID); - let decoded = roundtrip(max)?; - assert_eq!(decoded.get_sender(), MAX_WIRE_ID); - assert_eq!(decoded.get_receiver(), MAX_WIRE_ID); - - assert!( - CommunicationValue::new(CommunicationType::Ping) - .with_sender(MAX_WIRE_ID + 1) - .to_bytes() - .is_err() - ); - assert!( - CommunicationValue::new(CommunicationType::Ping) - .with_receiver(MAX_WIRE_ID + 1) - .to_bytes() - .is_err() - ); - Ok(()) - } - - #[cfg(feature = "registry")] - #[test] - fn missing_version_mappings_return_codec_errors() { - let v0 = TypeMap::new(Version(0, 0)); - assert_eq!(DataType::AnotherType.try_to_id(&v0), None); - - let data_error = CommunicationValue::from_comm(CommunicationType::Ping, &v0) - .add_typed( - DataType::AnotherType, - &v0, - DataValue::Str("not available in v0".into()), - ) - .to_bytes(); - assert_eq!( - data_error, - Err(CodecError::UnknownDataType("AnotherType".into())) - ); - - let unknown_version = TypeMap::new(Version(99, 0)); - assert_eq!(CommunicationType::Ping.try_to_id(&unknown_version), None); - assert_eq!( - CommunicationValue::from_comm(CommunicationType::Ping, &unknown_version).to_bytes(), - Err(CodecError::UnknownCommunicationType("Ping".into())) - ); - } - - #[cfg(feature = "registry")] - #[test] - fn decoded_and_migrated_frames_use_the_source_version_map() - -> Result<(), Box> { - let v1 = TypeMap::new(Version(1, 0)); - let v2 = TypeMap::new(Version(2, 0)); - let original = CommunicationValue::from_comm(CommunicationType::Ping, &v1).add_typed( - DataType::SomeType, - &v1, - DataValue::Str("v1 value".into()), - ); - let bytes = original.to_bytes()?; - - let decoded = CommunicationValue::from_bytes_with(&bytes, &v1)?; - assert_eq!(decoded.get_type_name(), Some("Ping")); - assert_eq!( - decoded.get_data(DataType::SomeType), - &DataValue::Str("v1 value".into()) - ); - assert_eq!( - decoded.type_map().map(|tm| &tm.version), - Some(&Version(1, 0)) - ); - - let migrated = decoded.migrate(&v2)?; - assert_eq!( - migrated.get_data(DataType::SomeType).as_str(), - Some("v1 value") - ); - assert_eq!( - migrated.data().get( - &DataType::SomeType - .try_to_id(&v2) - .expect("SomeType must be mapped in v2"), + let cases = [ + ( + CommunicationValue::new(CommunicationType::Ping) + .with_id(0x0102_0304) + .without_sender() + .without_receiver(), + 0x01, ), - Some(&DataValue::Str("v1 value".into())) - ); - Ok(()) - } + ( + CommunicationValue::new(CommunicationType::Ping) + .without_id() + .with_sender(SENDER_ID) + .without_receiver(), + 0x02, + ), + ( + CommunicationValue::new(CommunicationType::Ping) + .without_id() + .without_sender() + .with_receiver(RECEIVER_ID), + 0x04, + ), + ( + CommunicationValue::new(CommunicationType::Ping) + .with_id(0) + .with_sender(SENDER_ID) + .with_receiver(RECEIVER_ID), + 0x07, + ), + ]; - #[cfg(feature = "crypto")] - #[test] - fn test_plain_encrypted_payload_roundtrip() -> Result<(), Box> { - let ciphertext = vec![1, 2, 3, 4, 5]; - let cv = CommunicationValue::new(CommunicationType::Ping) - .with_encrypted_payload(EncryptedPayload::Plain(ciphertext.clone())); - - let bytes = cv.to_bytes()?; - assert_ne!(bytes[6] & FLAG_ENCRYPTED, 0); - assert_eq!(bytes[6] & FLAG_SIGNED_ENCRYPTED, 0); - - let decoded = roundtrip(cv)?; - assert_eq!( - decoded.encrypted_payload(), - Some(&EncryptedPayload::Plain(ciphertext)) - ); - assert!(decoded.data().is_empty()); - assert_eq!(decoded.data_len(), 0); - assert_eq!(decoded.payload_len(), 1); - assert_eq!(decoded.get_data(DataType::Version), &DataValue::Null); - Ok(()) - } - - #[cfg(feature = "crypto")] - #[test] - fn test_signed_encrypted_payload_roundtrip() -> Result<(), Box> { - let ciphertext = vec![9, 8, 7, 6]; - let cv = CommunicationValue::new(CommunicationType::Ping) - .with_encrypted_payload(EncryptedPayload::Signed(ciphertext.clone())); - - let bytes = cv.to_bytes()?; - assert_ne!(bytes[6] & FLAG_ENCRYPTED, 0); - assert_ne!(bytes[6] & FLAG_SIGNED_ENCRYPTED, 0); - - let decoded = roundtrip(cv)?; - assert_eq!( - decoded.encrypted_payload(), - Some(&EncryptedPayload::Signed(ciphertext)) - ); - assert!(decoded.data().is_empty()); - Ok(()) - } - - #[cfg(feature = "crypto")] - #[test] - fn test_decrypted_payload_serializes_as_cleartext() -> Result<(), Box> { - let tm = TypeMap::latest(); - let data_id = DataType::Version - .try_to_id(&tm) - .expect("built-in type must be mapped"); - let mut cv = CommunicationValue::new(CommunicationType::Ping) - .with_encrypted_payload(EncryptedPayload::Plain(vec![1, 2, 3])); - - cv.set_decrypted_container([(data_id, DataValue::Str("clear".into()))]); - - assert!(!cv.is_encrypted()); - assert_eq!(cv.payload_len(), 1); - assert_eq!(cv.get_data(DataType::Version).as_str(), Some("clear")); - let bytes = cv.to_bytes()?; - assert_eq!(bytes[6] & FLAG_ENCRYPTED, 0); - assert_eq!(bytes[6] & FLAG_SIGNED_ENCRYPTED, 0); - Ok(()) - } - - #[cfg(feature = "crypto")] - #[test] - fn test_nested_encrypted_value_remains_typed_data() -> Result<(), Box> { - let cv = CommunicationValue::new(CommunicationType::Ping).add_typed_default( - DataType::Version, - DataValue::SignedEncryptedContainer(vec![4, 3, 2, 1]), - ); - - let bytes = cv.to_bytes()?; - assert_eq!(bytes[6] & FLAG_ENCRYPTED, 0); - assert_eq!(bytes[6] & FLAG_SIGNED_ENCRYPTED, 0); - - let decoded = roundtrip(cv)?; - assert!(!decoded.is_encrypted()); - assert!(matches!( - decoded.get_data(DataType::Version), - DataValue::SignedEncryptedContainer(bytes) if bytes == &[4, 3, 2, 1] - )); - Ok(()) + for (value, expected_flags) in cases { + let bytes = value.to_bytes().unwrap(); + assert_eq!(bytes[6], expected_flags); + if expected_flags == FLAG_HAS_SENDER { + assert_eq!(&bytes[7..15], &SENDER_ID.to_be_bytes()); + } + if expected_flags == FLAG_HAS_RECEIVER { + assert_eq!(&bytes[7..15], &RECEIVER_ID.to_be_bytes()); + } + assert_eq!(CommunicationValue::from_bytes(&bytes).unwrap(), value); + } } #[test] - fn test_signed_encrypted_flag_requires_encrypted_flag() { - let mut bytes = CommunicationValue::new(CommunicationType::Ping) + fn absent_and_zero_are_distinct() { + let absent = CommunicationValue::new(CommunicationType::Ping).without_id(); + let zero = CommunicationValue::new(CommunicationType::Ping).with_id(0); + assert!(absent.id().is_none()); + assert_eq!(zero.id(), Some(0)); + assert_ne!(absent.to_bytes().unwrap(), zero.to_bytes().unwrap()); + } + + #[test] + fn reserved_flags_are_rejected() { + let bytes = CommunicationValue::new(CommunicationType::Ping) .to_bytes() - .expect("frame should encode"); - bytes[6] |= FLAG_SIGNED_ENCRYPTED; - assert!(CommunicationValue::from_bytes(&bytes).is_err()); + .unwrap(); + for unknown_flag in [0x08, 0x10, 0x20, 0x40, 0x80] { + let mut invalid = bytes.clone(); + invalid[6] |= unknown_flag; + assert_eq!( + CommunicationValue::from_bytes(&invalid), + Err(CodecError::InvalidEncoding), + "flag bit {unknown_flag:#04x} must be rejected" + ); + } } #[test] - fn test_corrupted_length_returns_none() { - let mut bad = vec![0u8; 8]; - // total_length claims more than available - bad[0..4].copy_from_slice(&(1000u32.to_be_bytes())); - assert!(CommunicationValue::from_bytes(&bad).is_err()); + fn protected_or_scalar_payload_is_not_treated_as_data() { + let frame = CommunicationValue::new(CommunicationType::Ping) + .with_payload(DataValue::Bytes(vec![1])); + assert!(frame.data().is_none()); + assert_eq!(frame.get_data(DataType::Version), None); } #[test] - fn test_trailing_bytes_are_rejected() { - let mut bytes = CommunicationValue::new(CommunicationType::Ping) + fn generic_payload_roundtrips_without_becoming_a_container() { + let payload = DataValue::Array(vec![ + DataValue::Str("arbitrary".into()), + DataValue::UnsignedNumber(7), + ]); + let encoded = CommunicationValue::new(CommunicationType::Ping) + .with_payload(payload.clone()) .to_bytes() - .expect("frame should encode"); - bytes.extend_from_slice(&[0xAA, 0xBB]); + .unwrap(); - assert!(CommunicationValue::from_bytes(&bytes).is_err()); + let decoded = CommunicationValue::from_bytes(&encoded).unwrap(); + assert_eq!(decoded.payload(), &payload); + assert_eq!(decoded.into_payload(), payload); + } + + #[test] + fn add_data_rejects_a_non_container_payload() { + let type_map = TypeMap::latest(); + let data_type = DataType::Version.try_to_id(&type_map).unwrap(); + let result = CommunicationValue::new(CommunicationType::Ping) + .with_payload(DataValue::Null) + .add_data(data_type, DataValue::Str("1".into())); + + assert_eq!(result, Err(CodecError::InvalidEncoding)); + } + + #[test] + fn trailing_value_after_payload_is_rejected() { + let mut encoded = CommunicationValue::new(CommunicationType::Ping) + .with_payload(DataValue::Null) + .to_bytes() + .unwrap(); + encoded.push(DataValue::BoolTrue.to_bytes().unwrap()[0]); + let body_len = u32::try_from(encoded.len() - 4).unwrap(); + encoded[..4].copy_from_slice(&body_len.to_be_bytes()); + + assert_eq!( + CommunicationValue::from_bytes(&encoded), + Err(CodecError::InvalidEncoding) + ); } #[cfg(feature = "crypto")] #[test] - fn test_sign_verify_frame_roundtrip() -> Result<(), Box> { - use mtp_crypto::{Ed25519Signer, SigAlgorithm}; + fn sealed_sender_is_a_frame_construction_rule() -> Result<(), Box> { + use crate::data_value::ProtectionPurpose; + use mtp_crypto::{Ed25519Signer, Keyring}; - let (signer, sk, _pk) = Ed25519Signer::generate(); + const SENDER_ID: u64 = 0x0102_0304_0506_0708; + const RECEIVER_ID: u64 = 0x1112_1314_1516_1718; - let mut cv = CommunicationValue::new(CommunicationType::Ping) - .with_id(7) - .with_sender(1) - .with_receiver(2) - .add_typed_default(DataType::PqSignature, DataValue::UnsignedNumber(42)); + let (signer, _, signer_public_key) = Ed25519Signer::generate(); + let recipient = Keyring::generate(); + let clear_payload = DataValue::Container(vec![( + DataTypeId(32), + DataValue::Str("sealed content".into()), + )]); + let protected_payload = clear_payload + .clone() + .sign(SENDER_ID, ProtectionPurpose::from(1), &signer)? + .encrypt_for( + std::slice::from_ref(&recipient.public_key_bundle()), + ProtectionPurpose::from(2), + )?; - assert!(cv.sign_frame(SigAlgorithm::ED25519, &signer).is_some()); + let frame = CommunicationValue::new(CommunicationType::Ping) + .without_sender() + .with_receiver(RECEIVER_ID) + .with_payload(protected_payload); + assert!(frame.sender().is_none()); + assert!(frame.receiver().is_some()); + assert!(matches!(frame.payload(), DataValue::Encrypted(_))); - // Same in-memory value verifies (FLAG_SIGNED forced on both sides). - let verifier = Ed25519Signer::new(&sk)?; - assert!(cv.verify_frame(&verifier).is_ok()); + let frame_id = frame.id(); + let encoded = frame.to_bytes()?; - // Survives a wire round-trip. - let bytes = cv.to_bytes()?; - let decoded = CommunicationValue::from_bytes(&bytes)?; - assert!(decoded.verify_frame(&verifier).is_ok()); + // Payload protection does not introduce frame flags. The header only + // advertises the transport ID and visible next-hop receiver. + assert_eq!(encoded[6], FLAG_HAS_ID | FLAG_HAS_RECEIVER); + assert_eq!(&encoded[11..19], &RECEIVER_ID.to_be_bytes()); + assert_eq!(encoded[19], 0x0A); - Ok(()) - } + let decoded = CommunicationValue::from_bytes(&encoded)?; + assert_eq!(decoded.id(), frame_id); + assert_eq!(decoded.sender(), None); + assert_eq!(decoded.receiver(), Some(RECEIVER_ID)); + assert!(matches!(decoded.payload(), DataValue::Encrypted(_))); - #[cfg(feature = "crypto")] - #[test] - fn test_verify_frame_wrong_key_fails() -> Result<(), Box> { - use mtp_crypto::{Ed25519Signer, SigAlgorithm}; + let signed = decoded + .payload() + .decrypt(&recipient, ProtectionPurpose::from(2))?; + let DataValue::Signed(signed_value) = &signed else { + return Err("expected signed value inside encrypted payload".into()); + }; + assert_eq!(signed_value.signer_id, SENDER_ID); - let (signer, _, _) = Ed25519Signer::generate(); - let (_, other_sk, _) = Ed25519Signer::generate(); - - let mut cv = CommunicationValue::new(CommunicationType::Ping) - .add_typed_default(DataType::PqSignature, DataValue::UnsignedNumber(42)); - assert!(cv.sign_frame(SigAlgorithm::ED25519, &signer).is_some()); - - let wrong = Ed25519Signer::new(&other_sk)?; - assert!(cv.verify_frame(&wrong).is_err()); - - Ok(()) - } - - #[cfg(all(feature = "crypto", feature = "registry"))] - #[test] - fn test_migrate_discards_frame_signature() -> Result<(), Box> { - use mtp_crypto::{Ed25519Signer, SigAlgorithm}; - use mtp_type_map::Version; - - let (signer, _, _) = Ed25519Signer::generate(); - let mut cv = CommunicationValue::new(CommunicationType::Ping) - .add_typed_default(DataType::Version, DataValue::Str("1.0".into())); - assert!(cv.sign_frame(SigAlgorithm::ED25519, &signer).is_some()); - assert!(cv.get_frame_signature().is_some()); - - let migrated = cv.migrate(&TypeMap::new(Version(2, 0)))?; - - assert!(migrated.get_frame_signature().is_none()); + let mut signer_public_keys = recipient.public_key_bundle(); + signer_public_keys.sig_cl_public_key = signer_public_key; + signed.verify(SENDER_ID, &signer_public_keys, ProtectionPurpose::from(1))?; + assert_eq!( + signed.into_verified(SENDER_ID, &signer_public_keys, ProtectionPurpose::from(1))?, + clear_payload + ); Ok(()) } } diff --git a/codec/src/data_value.rs b/codec/src/data_value.rs index 21ab0f7..44ab92c 100644 --- a/codec/src/data_value.rs +++ b/codec/src/data_value.rs @@ -1,7 +1,7 @@ use base64::Engine; use base64::engine::general_purpose; use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::hash::{Hash, Hasher}; use std::io::Cursor; @@ -9,955 +9,1250 @@ use std::io::Cursor; use mtp_common::CodecError; use mtp_type_map::DataTypeId; -#[cfg(test)] -use mtp_type_map::{DataType, TypeMap}; - #[cfg(feature = "crypto")] -use mtp_crypto::{EncryptionType, Keyring, PublicKeyBundle, SigAlgorithm, SignatureScheme}; +use mtp_crypto::{ + EncryptionType, Keyring, PublicKeyBundle, RecipientEntry, SigAlgorithm, SignatureScheme, +}; + +/// Protocol context authenticated by every signed [`DataValue`]. +/// +/// This is intentionally not serialized: it separates MTP data-value +/// signatures from signatures generated for every other MTP purpose. +#[cfg(feature = "crypto")] +const SIGN_DOMAIN: &[u8] = b"MTP-DATA-SIGN-1"; #[derive(Debug, Clone, PartialEq, Eq)] pub enum DataKind { Bool, - SignedNumber, UnsignedNumber, Float, - Str, Bytes, - Array(Box), - + /// Arrays may contain heterogeneous recursive values. + Array, Container, - #[cfg(feature = "crypto")] - EncryptedContainer, + Encrypted, #[cfg(feature = "crypto")] - SignedContainer, - #[cfg(feature = "crypto")] - SignedEncryptedContainer, - + Signed, Null, } +/// Resource limits applied while decoding recursive `DataValue` structures. +/// +/// The wire format deliberately uses recursive values, so decoding must not +/// let attacker-controlled nesting or allocation sizes become process-wide +/// limits. These are conservative defaults for transported frames; callers +/// handling a different trust boundary can opt into stricter limits with +/// [`DataValue::from_bytes_with_limits`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DecodeLimits { + /// Maximum number of nested `Array`, `Container`, and `Signed` wrappers. + pub max_depth: usize, + /// Maximum number of `DataValue` nodes in one decoded value. + pub max_values: usize, + /// Maximum size of one string, binary blob, signed wrapper, or encrypted + /// envelope body. + pub max_blob_size: usize, + /// Maximum number of recipients in one encrypted envelope. + pub max_recipients: usize, +} + +impl Default for DecodeLimits { + fn default() -> Self { + Self { + max_depth: 64, + max_values: 65_536, + max_blob_size: 16 * 1024 * 1024, + max_recipients: 64, + } + } +} + +impl DecodeLimits { + /// Derive codec allocation limits from the transport's admitted complete + /// frame size. This keeps a larger explicitly configured transport policy + /// from being rejected by an unrelated hard-coded blob bound while still + /// preserving recursive and recipient-count limits. + pub fn for_transport_message_size(max_message_size: u64) -> Self { + let max_blob_size = usize::try_from(max_message_size.saturating_sub(4)) + .unwrap_or(usize::MAX) + .min(u32::MAX as usize); + Self { + max_blob_size, + ..Self::default() + } + } +} + +#[derive(Debug, Clone, Copy)] +struct DecodeContext { + limits: DecodeLimits, + depth: usize, + values: usize, +} + +impl DecodeContext { + fn new(limits: DecodeLimits) -> Self { + Self { + limits, + depth: 0, + values: 0, + } + } + + fn value(&mut self) -> Option<()> { + self.values = self.values.checked_add(1)?; + (self.values <= self.limits.max_values).then_some(()) + } + + fn enter(&mut self) -> Option<()> { + self.depth = self.depth.checked_add(1)?; + if self.depth <= self.limits.max_depth { + Some(()) + } else { + None + } + } + + fn leave(&mut self) { + self.depth = self.depth.saturating_sub(1); + } +} + impl fmt::Display for DataKind { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - DataKind::Bool => f.write_str("Bool"), - DataKind::SignedNumber => f.write_str("SignedNumber"), - DataKind::UnsignedNumber => f.write_str("UnsignedNumber"), - DataKind::Float => f.write_str("Float"), - DataKind::Str => f.write_str("Str"), - DataKind::Bytes => f.write_str("Bytes"), - DataKind::Array(inner) => write!(f, "Array<{}>", inner), - DataKind::Container => f.write_str("Container"), + Self::Bool => f.write_str("Bool"), + Self::SignedNumber => f.write_str("SignedNumber"), + Self::UnsignedNumber => f.write_str("UnsignedNumber"), + Self::Float => f.write_str("Float"), + Self::Str => f.write_str("Str"), + Self::Bytes => f.write_str("Bytes"), + Self::Array => f.write_str("Array"), + Self::Container => f.write_str("Container"), #[cfg(feature = "crypto")] - DataKind::EncryptedContainer => f.write_str("EncryptedContainer"), + Self::Encrypted => f.write_str("Encrypted"), #[cfg(feature = "crypto")] - DataKind::SignedContainer => f.write_str("SignedContainer"), - #[cfg(feature = "crypto")] - DataKind::SignedEncryptedContainer => f.write_str("SignedEncryptedContainer"), - DataKind::Null => f.write_str("Null"), + Self::Signed => f.write_str("Signed"), + Self::Null => f.write_str("Null"), } } } +#[cfg(feature = "crypto")] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ProtectionPurpose(pub u8); + +#[cfg(feature = "crypto")] +impl From for ProtectionPurpose { + fn from(value: u8) -> Self { + Self(value) + } +} + +#[cfg(feature = "crypto")] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ApplicationProtectionPurpose(u8); + +#[cfg(feature = "crypto")] +#[derive(Debug, thiserror::Error, Clone, Copy, PartialEq, Eq)] +pub enum ProtectionPurposeError { + #[error("protection purpose 0x{0:02x} is reserved for MTP")] + Reserved(u8), +} + +/// MTP-owned protection-purpose registry. +/// +/// Applications may still use [`ProtectionPurpose::from`] for their own +/// domain-separated values, but protocol code should use this enum so the +/// reserved values are defined in one place. +#[cfg(feature = "crypto")] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MtpProtectionPurpose { + RelayMetadataEncryption = 0x30, + RelayContentSignature = 0x31, + RelayContentEncryption = 0x32, + RelayMetadataSignature = 0x33, + PipeSessionSignature = 0x50, + PipeSessionEncryption = 0x51, +} + +#[cfg(feature = "crypto")] +impl MtpProtectionPurpose { + pub const fn value(self) -> u8 { + self as u8 + } + + pub const fn is_reserved(value: u8) -> bool { + value == Self::RelayMetadataEncryption as u8 + || value == Self::RelayContentSignature as u8 + || value == Self::RelayContentEncryption as u8 + || value == Self::RelayMetadataSignature as u8 + || value == Self::PipeSessionSignature as u8 + || value == Self::PipeSessionEncryption as u8 + } +} + +#[cfg(feature = "crypto")] +impl ApplicationProtectionPurpose { + pub fn new(value: u8) -> Result { + if MtpProtectionPurpose::is_reserved(value) { + return Err(ProtectionPurposeError::Reserved(value)); + } + Ok(Self(value)) + } + + pub const fn value(self) -> u8 { + self.0 + } +} + +#[cfg(feature = "crypto")] +impl TryFrom for ApplicationProtectionPurpose { + type Error = ProtectionPurposeError; + + fn try_from(value: u8) -> Result { + Self::new(value) + } +} + +#[cfg(feature = "crypto")] +impl From for ProtectionPurpose { + fn from(value: ApplicationProtectionPurpose) -> Self { + Self(value.value()) + } +} + +#[cfg(feature = "crypto")] +impl From for ProtectionPurpose { + fn from(value: MtpProtectionPurpose) -> Self { + Self(value.value()) + } +} + +/// Signature algorithms a receiver is willing to accept for a protected +/// value. The policy is deliberately supplied by the receiver; accepting +/// the algorithm selected by an untrusted wrapper is not a security policy. +#[cfg(feature = "crypto")] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SignaturePolicy { + /// Accept a classical Ed25519 signature only. + Ed25519, + /// Require the hybrid Ed25519 + ML-DSA signature. + Dual, + /// Accept any signature algorithm supported by this build. + AnySupported, +} + +#[cfg(feature = "crypto")] +impl SignaturePolicy { + pub const fn accepts(self, algorithm: u8) -> bool { + match self { + Self::Ed25519 => algorithm == SigAlgorithm::ED25519, + Self::Dual => algorithm == SigAlgorithm::DUAL, + Self::AnySupported => matches!( + algorithm, + SigAlgorithm::ED25519 | SigAlgorithm::ML_DSA_65 | SigAlgorithm::DUAL + ), + } + } +} + +/// Receiver-side protection policy. This is a struct so additional +/// authenticated-value requirements can be added without continually +/// changing every verification function signature. +#[cfg(feature = "crypto")] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ProtectionPolicy { + pub signature: SignaturePolicy, +} + +#[cfg(feature = "crypto")] +impl Default for ProtectionPolicy { + fn default() -> Self { + Self { + signature: SignaturePolicy::AnySupported, + } + } +} + +#[cfg(feature = "crypto")] +impl From for ProtectionPolicy { + fn from(signature: SignaturePolicy) -> Self { + Self { signature } + } +} + +#[cfg(feature = "crypto")] +#[derive(Debug, thiserror::Error)] +pub enum ProtectionError { + #[error("value is not encrypted")] + NotEncrypted, + #[error("value is not signed")] + NotSigned, + #[error("malformed protected value")] + Malformed, + #[error("no matching recipient")] + NoMatchingRecipient, + #[error("invalid signature")] + InvalidSignature, + #[error("signature algorithm {actual} does not satisfy the receiver policy {expected:?}")] + SignaturePolicyMismatch { + expected: SignaturePolicy, + actual: u8, + }, + #[error("protection purpose mismatch: expected {expected}, got {actual}")] + PurposeMismatch { expected: u8, actual: u8 }, + #[error("signer ID mismatch: expected {expected}, got {actual}")] + SignerIdMismatch { expected: u64, actual: u64 }, + #[error("no verification key for signer ID {0}")] + SignerKeyNotFound(u64), + #[error("codec error: {0}")] + Codec(#[from] CodecError), + #[error("crypto error: {0}")] + Crypto(#[from] mtp_crypto::CryptoError), +} + +#[cfg(feature = "crypto")] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SignedValue { + pub algorithm: u8, + pub purpose: u8, + pub signer_id: u64, + pub signature: Vec, + pub value: Box, +} + +#[cfg(feature = "crypto")] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EncryptedValue { + pub encryption_type: EncryptionType, + pub purpose: u8, + pub recipients: Vec, + pub ciphertext: Vec, +} + #[derive(Debug, Clone)] pub enum DataValue { BoolTrue, BoolFalse, Bool(bool), - SignedNumber(i128), UnsignedNumber(u128), - /// An IEEE-754 double-precision floating-point value. Float(f64), - Str(String), Bytes(Vec), Array(Vec), - /* - * Container format: - * [2 bytes u16 entry_count] // number of entries - * [1 byte kind] // DataValue kind marker - * [if kind == BOOL_TRUE or BOOL_FALSE:] - * [2 bytes u16 key] // DataTypeId discriminant - * [else:] - * [4 bytes u32 payload_len] // length of the value payload - * [2 bytes u16 key] // DataTypeId discriminant - * [payload_len bytes payload] // value data (interpreted based on kind) - */ Container(Vec<(DataTypeId, DataValue)>), - - /* - * Container format: - * [4 bytes u32 entry_count] // length of the container - * [binary data] - * -> After decryption, the container is parsed as a regular container - */ #[cfg(feature = "crypto")] - EncryptedContainer(Vec), - - /* - * Container format: - * [4 bytes u32 entry_count] // length of the container - * [binary data] - * -> Can be turned into Container - * -> Can be used with a public key to verify integrity - */ + Encrypted(EncryptedValue), #[cfg(feature = "crypto")] - SignedContainer(Vec), - - /* - * Container format: - * [4 bytes u32 entry_count] // length of the container - * [binary data] - * -> After decryption, the container is parsed as a signed container - */ - #[cfg(feature = "crypto")] - SignedEncryptedContainer(Vec), - + Signed(SignedValue), Null, } impl DataValue { - /* - * Top-level format: - * [1 byte kind] - * [remaining bytes payload] // interpreted according to kind - * - * Container format: - * [2 bytes u16 entry_count] // number of entries - * [1 byte kind] // DataValue kind marker - * [if kind == BOOL_TRUE or BOOL_FALSE:] - * [2 bytes u16 key] // DataTypeId discriminant - * [else:] - * [4 bytes u32 payload_len] // length of the value payload - * [2 bytes u16 key] // DataTypeId discriminant - * [payload_len bytes payload] // value data (interpreted based on kind) - * - * Array format (same as container but no keys): - * [2 bytes u16 entry_count] - * for each entry: - * [1 byte kind] - * [if kind == BOOL_TRUE or BOOL_FALSE:] - * (no payload) - * [else:] - * [4 bytes u32 payload_len] - * [payload_len bytes payload] - * - * Kind markers: - * 0x01 => BoolTrue - * 0x02 => BoolFalse - * 0x03 => Signed Number (i128, 16 bytes big-endian) - * 0x04 => Unsigned Number (u128, 16 bytes big-endian) - * 0x05 => Float (1 byte exponent, 4 bytes mantissa) - * 0x06 => Str (UTF-8 bytes) - * 0x07 => Bytes - * 0x08 => Array - * 0x09 => Container - * 0x0A => EncryptedContainer (1 byte EncryptionType + KEM ciphertext + AEAD payload) - * 0x0B => SignedContainer (1 byte SigAlgorithm + signature + serialized container) - * 0x0C => SignedEncryptedContainer (encrypted blob that decrypts to a SignedContainer) - * 0xFF => Null - */ const KIND_BOOL_TRUE: u8 = 0x01; const KIND_BOOL_FALSE: u8 = 0x02; - const KIND_SIGNED_NUMBER: u8 = 0x03; const KIND_UNSIGNED_NUMBER: u8 = 0x04; const KIND_FLOAT: u8 = 0x05; - const KIND_STR: u8 = 0x06; const KIND_BYTES: u8 = 0x07; const KIND_ARRAY: u8 = 0x08; - const KIND_CONTAINER: u8 = 0x09; #[cfg(feature = "crypto")] - const KIND_ENCRYPTED_CONTAINER: u8 = 0x0A; + const KIND_ENCRYPTED: u8 = 0x0A; #[cfg(feature = "crypto")] - const KIND_SIGNED_CONTAINER: u8 = 0x0B; - #[cfg(feature = "crypto")] - const KIND_SIGNED_ENCRYPTED_CONTAINER: u8 = 0x0C; - + const KIND_SIGNED: u8 = 0x0B; const KIND_NULL: u8 = 0xFF; - /* - * Smallest possible encoded entry, used to cap pre-reservation when - * decoding containers/arrays so a small frame cannot force a huge - * allocation from an attacker-controlled count. A bool/null entry in a - * container is 3 bytes (1 kind + 2 key); a bare value in an array is 1 - * byte, so 1 is the safe lower bound shared by both. - */ - const MIN_ENTRY_BYTES: usize = 1; - - pub fn container_from_map(map: &BTreeMap) -> DataValue { - let mut container = Vec::new(); - for (key, value) in map { - container.push((*key, value.clone())); - } - DataValue::Container(container) + pub fn container_from_map(map: &BTreeMap) -> Self { + Self::Container(map.iter().map(|(id, value)| (*id, value.clone())).collect()) } pub fn kind(&self) -> DataKind { match self { - DataValue::Bool(_) | DataValue::BoolTrue | DataValue::BoolFalse => DataKind::Bool, - DataValue::SignedNumber(_) => DataKind::SignedNumber, - DataValue::UnsignedNumber(_) => DataKind::UnsignedNumber, - DataValue::Float(_) => DataKind::Float, - DataValue::Str(_) => DataKind::Str, - DataValue::Array(a) => { - if let Some(first) = a.first() { - DataKind::Array(Box::new(first.kind())) - } else { - DataKind::Array(Box::new(DataKind::Null)) - } - } - DataValue::Bytes(_) => DataKind::Bytes, - DataValue::Container(_) => DataKind::Container, + Self::BoolTrue | Self::BoolFalse | Self::Bool(_) => DataKind::Bool, + Self::SignedNumber(_) => DataKind::SignedNumber, + Self::UnsignedNumber(_) => DataKind::UnsignedNumber, + Self::Float(_) => DataKind::Float, + Self::Str(_) => DataKind::Str, + Self::Bytes(_) => DataKind::Bytes, + Self::Array(_) => DataKind::Array, + Self::Container(_) => DataKind::Container, #[cfg(feature = "crypto")] - DataValue::EncryptedContainer(_) => DataKind::EncryptedContainer, + Self::Encrypted(_) => DataKind::Encrypted, #[cfg(feature = "crypto")] - DataValue::SignedContainer(_) => DataKind::SignedContainer, - #[cfg(feature = "crypto")] - DataValue::SignedEncryptedContainer(_) => DataKind::SignedEncryptedContainer, - DataValue::Null => DataKind::Null, + Self::Signed(_) => DataKind::Signed, + Self::Null => DataKind::Null, } } pub fn as_bool(&self) -> Option { match self { - DataValue::BoolTrue => Some(true), - DataValue::BoolFalse => Some(false), - DataValue::Bool(v) => Some(*v), + Self::BoolTrue => Some(true), + Self::BoolFalse => Some(false), + Self::Bool(value) => Some(*value), _ => None, } } pub fn as_str(&self) -> Option<&str> { match self { - DataValue::Str(s) => Some(s), + Self::Str(value) => Some(value), _ => None, } } pub fn as_string(&self) -> Option { - self.as_str().map(|s| s.to_string()) + self.as_str().map(str::to_owned) } pub fn as_signed_number(&self) -> Option { match self { - DataValue::SignedNumber(n) => Some(*n), + Self::SignedNumber(value) => Some(*value), _ => None, } } pub fn as_unsigned_number(&self) -> Option { match self { - DataValue::UnsignedNumber(n) => Some(*n), + Self::UnsignedNumber(value) => Some(*value), _ => None, } } pub fn as_float(&self) -> Option { match self { - DataValue::Float(value) => Some(*value), + Self::Float(value) => Some(*value), _ => None, } } pub fn as_array(&self) -> Option> { match self { - DataValue::Array(a) => Some(a.clone()), - _ => None, - } - } - - pub fn as_bytes(&self) -> Option> { - match self { - DataValue::Bytes(b) => Some(b.clone()), - _ => None, - } - } - - pub fn as_container(&self) -> Option> { - match self { - DataValue::Container(c) => Some(c.clone()), - _ => None, - } - } - - pub fn as_number(&self) -> Option { - match self { - DataValue::SignedNumber(n) => Some(*n), - DataValue::UnsignedNumber(n) => i128::try_from(*n).ok(), - _ => None, - } - } - - pub fn is_null(&self) -> bool { - matches!(self, DataValue::Null) - } - - pub fn is_truthy(&self) -> bool { - match self { - DataValue::BoolTrue | DataValue::Bool(true) => true, - DataValue::BoolFalse | DataValue::Bool(false) | DataValue::Null => false, - DataValue::UnsignedNumber(0) | DataValue::SignedNumber(0) => false, - _ => true, - } - } - - pub fn get_field(&self, key: DataTypeId) -> Option<&DataValue> { - match self { - DataValue::Container(entries) => { - entries.iter().find(|(k, _)| *k == key).map(|(_, v)| v) - } - _ => None, - } - } - - pub fn as_container_map(&self) -> Option> { - match self { - DataValue::Container(entries) => Some(entries.iter().cloned().collect()), - _ => None, - } - } - - pub fn as_bytes_slice(&self) -> Option<&[u8]> { - match self { - DataValue::Bytes(b) => Some(b), + Self::Array(value) => Some(value.clone()), _ => None, } } pub fn as_array_slice(&self) -> Option<&[DataValue]> { match self { - DataValue::Array(a) => Some(a), + Self::Array(value) => Some(value), _ => None, } } + pub fn as_bytes(&self) -> Option> { + match self { + Self::Bytes(value) => Some(value.clone()), + _ => None, + } + } + + pub fn as_bytes_slice(&self) -> Option<&[u8]> { + match self { + Self::Bytes(value) => Some(value), + _ => None, + } + } + + pub fn as_container(&self) -> Option> { + self.container_entries().map(<[_]>::to_vec) + } + + pub fn container_entries(&self) -> Option<&[(DataTypeId, DataValue)]> { + match self { + Self::Container(entries) => Some(entries), + _ => None, + } + } + + pub fn container_entries_mut(&mut self) -> Option<&mut Vec<(DataTypeId, DataValue)>> { + match self { + Self::Container(entries) => Some(entries), + _ => None, + } + } + + pub fn as_container_map(&self) -> Option> { + self.container_entries() + .map(|entries| entries.iter().cloned().collect()) + } + + pub fn as_number(&self) -> Option { + match self { + Self::SignedNumber(value) => Some(*value), + Self::UnsignedNumber(value) => i128::try_from(*value).ok(), + _ => None, + } + } + + pub fn is_null(&self) -> bool { + matches!(self, Self::Null) + } + + pub fn is_truthy(&self) -> bool { + match self { + Self::BoolTrue | Self::Bool(true) => true, + Self::BoolFalse | Self::Bool(false) | Self::Null => false, + Self::SignedNumber(0) | Self::UnsignedNumber(0) => false, + _ => true, + } + } + + pub fn get_field(&self, key: DataTypeId) -> Option<&DataValue> { + match self { + Self::Container(entries) => entries.iter().find(|(id, _)| *id == key).map(|(_, v)| v), + _ => None, + } + } + + pub fn as_map(&self) -> Option> { + self.as_container_map() + } + pub fn type_name(&self) -> &'static str { match self { - DataValue::Bool(_) | DataValue::BoolTrue | DataValue::BoolFalse => "Bool", - DataValue::SignedNumber(_) => "SignedNumber", - DataValue::UnsignedNumber(_) => "UnsignedNumber", - DataValue::Float(_) => "Float", - DataValue::Str(_) => "Str", - DataValue::Bytes(_) => "Bytes", - DataValue::Array(_) => "Array", - DataValue::Container(_) => "Container", + Self::BoolTrue | Self::BoolFalse | Self::Bool(_) => "Bool", + Self::SignedNumber(_) => "SignedNumber", + Self::UnsignedNumber(_) => "UnsignedNumber", + Self::Float(_) => "Float", + Self::Str(_) => "Str", + Self::Bytes(_) => "Bytes", + Self::Array(_) => "Array", + Self::Container(_) => "Container", #[cfg(feature = "crypto")] - DataValue::EncryptedContainer(_) => "EncryptedContainer", + Self::Encrypted(_) => "Encrypted", #[cfg(feature = "crypto")] - DataValue::SignedContainer(_) => "SignedContainer", - #[cfg(feature = "crypto")] - DataValue::SignedEncryptedContainer(_) => "SignedEncryptedContainer", - DataValue::Null => "Null", + Self::Signed(_) => "Signed", + Self::Null => "Null", } } #[cfg(feature = "crypto")] - pub fn as_encrypted_container(&self) -> Option> { + pub fn as_encrypted(&self) -> Option<&EncryptedValue> { match self { - DataValue::EncryptedContainer(c) => Some(c.clone()), + Self::Encrypted(value) => Some(value), _ => None, } } #[cfg(feature = "crypto")] - pub fn as_signed_container(&self) -> Option> { + pub fn as_signed(&self) -> Option<&SignedValue> { match self { - DataValue::SignedContainer(b) => Some(b.clone()), + Self::Signed(value) => Some(value), _ => None, } } #[cfg(feature = "crypto")] - pub fn as_signed_encrypted_container(&self) -> Option> { - match self { - DataValue::SignedEncryptedContainer(c) => Some(c.clone()), - _ => None, - } + pub fn sign( + self, + signer_id: u64, + purpose: ProtectionPurpose, + signer: &(impl SignatureScheme + ?Sized), + ) -> Result { + let inner = self.to_bytes()?; + let algorithm = signer.algorithm(); + let signing_bytes = signed_message(algorithm, purpose.0, signer_id, &inner); + let signature = signer.sign(&signing_bytes)?; + validate_signature(algorithm, &signature)?; + Ok(Self::Signed(SignedValue { + algorithm, + purpose: purpose.0, + signer_id, + signature, + value: Box::new(self), + })) } - /* - * Decrypt an `EncryptedContainer` in-place, replacing it with the - * deserialized `Container`. The algorithm (and which keypair to use) is read - * from the blob's leading `EncryptionType` byte; the matching key is taken - * from `keyring`. Returns `None` if decryption or deserialization fails. - */ #[cfg(feature = "crypto")] - pub fn decrypt_into_container(&mut self, keyring: &Keyring, aad: &[u8]) -> Option<()> { - let data = self.as_encrypted_container()?; - let plaintext = mtp_crypto::decrypt_with(&data, keyring, aad).ok()?; - let dv = DataValue::from_bytes(&plaintext)?; - match dv { - DataValue::Container(entries) => { - *self = DataValue::Container(entries); - Some(()) + /// Verify with the compatibility policy that accepts any supported suite. + /// Protocol boundaries should prefer [`Self::verify_with_policy`]. + pub fn verify( + &self, + expected_signer_id: u64, + public_keys: &PublicKeyBundle, + expected_purpose: ProtectionPurpose, + ) -> Result<(), ProtectionError> { + self.verify_with_policy( + expected_signer_id, + public_keys, + expected_purpose, + ProtectionPolicy::default(), + ) + } + + #[cfg(feature = "crypto")] + pub fn verify_with_policy( + &self, + expected_signer_id: u64, + public_keys: &PublicKeyBundle, + expected_purpose: ProtectionPurpose, + policy: ProtectionPolicy, + ) -> Result<(), ProtectionError> { + match self { + Self::Signed(value) => { + value.verify_with_policy(expected_signer_id, public_keys, expected_purpose, policy) } - _ => None, + _ => Err(ProtectionError::NotSigned), } } - /* - * Encrypt a `Container` into an `EncryptedContainer` in-place. - * `enc_type` selects the algorithm and `recipient` provides the public key - * encapsulated to. The resulting blob is self-describing: its leading byte - * is `enc_type`, so `decrypt_into_container` needs only a `Keyring`. - * Returns `None` if the value is not a `Container` or encryption fails. - */ #[cfg(feature = "crypto")] - pub fn encrypt_container( - &mut self, - enc_type: EncryptionType, - recipient: &PublicKeyBundle, - aad: &[u8], - ) -> Option<()> { - let entries = self.as_container()?; - let plaintext = DataValue::Container(entries).to_bytes().ok()?; - let ct = mtp_crypto::encrypt_for(enc_type, recipient, &plaintext, aad).ok()?; - *self = DataValue::EncryptedContainer(ct); - Some(()) + pub fn verify_with( + &self, + resolve: F, + expected_purpose: ProtectionPurpose, + ) -> Result<(), ProtectionError> + where + F: FnOnce(u64) -> Option, + { + self.verify_with_resolver_policy(resolve, expected_purpose, ProtectionPolicy::default()) } - /* - * Sign a `Container` in-place, replacing it with a `SignedContainer`. - * The wire blob is: [1 byte alg] [N bytes sig] [serialized container bytes]. - * The signature covers only the serialized container bytes (not the alg byte). - * Returns `None` if the value is not a `Container` or signing fails. - */ #[cfg(feature = "crypto")] - pub fn sign_container(&mut self, algorithm: u8, signer: &impl SignatureScheme) -> Option<()> { - let entries = self.as_container()?; - let container_bytes = DataValue::Container(entries).to_bytes().ok()?; - - let sig = signer.sign(&container_bytes).ok()?; - - let mut blob = Vec::with_capacity(1 + sig.len() + container_bytes.len()); - blob.push(algorithm); - blob.extend_from_slice(&sig); - blob.extend_from_slice(&container_bytes); - - *self = DataValue::SignedContainer(blob); - Some(()) - } - - /* - * Verify a `SignedContainer` in-place, replacing it with the deserialized - * `Container` on success. Returns `None` if verification fails or the - * blob is malformed. - */ - #[cfg(feature = "crypto")] - pub fn verify_into_container(&mut self, verifier: &impl SignatureScheme) -> Option<()> { - let blob = self.as_signed_container()?; - if blob.len() < 1 + 64 + 2 { - return None; - } - - let algorithm = blob[0]; - let sig_len = SigAlgorithm::length(algorithm)?; - if blob.len() < 1 + sig_len + 2 { - return None; - } - - let signature = &blob[1..1 + sig_len]; - let container_bytes = &blob[1 + sig_len..]; - - verifier.verify(container_bytes, signature).ok()?; - - let entries = DataValue::from_bytes(container_bytes)?.as_container()?; - *self = DataValue::Container(entries); - Some(()) - } - - /* - * Verify a `SignedContainer` without mutating self. Dispatches to - * Ed25519, ML-DSA-65, or both (DUAL) based on the algorithm byte - * embedded in the blob. Returns `false` for any other variant. - */ - #[cfg(feature = "crypto")] - pub fn validate_signature(&self, pk: &PublicKeyBundle) -> bool { - let blob = match self { - DataValue::SignedContainer(b) => b, - _ => return false, + pub fn verify_with_resolver_policy( + &self, + resolve: F, + expected_purpose: ProtectionPurpose, + policy: ProtectionPolicy, + ) -> Result<(), ProtectionError> + where + F: FnOnce(u64) -> Option, + { + let signed = match self { + Self::Signed(value) => value, + _ => return Err(ProtectionError::NotSigned), }; - if blob.is_empty() { - return false; - } - let alg = blob[0]; - let sig_len = match SigAlgorithm::length(alg) { - Some(n) => n, - None => return false, - }; - if blob.len() < 1 + sig_len + 2 { - return false; - } - let signature = &blob[1..1 + sig_len]; - let container_bytes = &blob[1 + sig_len..]; - match alg { - SigAlgorithm::ED25519 => { - mtp_crypto::verify_ed25519(&pk.sig_cl_public_key, container_bytes, signature) - .is_ok() - } - SigAlgorithm::ML_DSA_65 => { - mtp_crypto::verify_ml_dsa(&pk.sig_pq_public_key, container_bytes, signature).is_ok() - } - SigAlgorithm::DUAL => { - const ED_LEN: usize = 64; - if signature.len() < ED_LEN { - return false; - } - let ed_ok = mtp_crypto::verify_ed25519( - &pk.sig_cl_public_key, - container_bytes, - &signature[..ED_LEN], - ) - .is_ok(); - let ml_ok = mtp_crypto::verify_ml_dsa( - &pk.sig_pq_public_key, - container_bytes, - &signature[ED_LEN..], - ) - .is_ok(); - ed_ok && ml_ok - } - _ => false, + let signer_id = signed.signer_id; + let public_keys = + resolve(signer_id).ok_or(ProtectionError::SignerKeyNotFound(signer_id))?; + signed.verify_with_policy(signer_id, &public_keys, expected_purpose, policy) + } + + #[cfg(feature = "crypto")] + /// Consume a signed value using the compatibility policy that accepts any + /// supported suite. Protocol boundaries should prefer the policy-aware + /// counterpart. + pub fn into_verified( + self, + expected_signer_id: u64, + public_keys: &PublicKeyBundle, + expected_purpose: ProtectionPurpose, + ) -> Result { + self.into_verified_with_policy( + expected_signer_id, + public_keys, + expected_purpose, + ProtectionPolicy::default(), + ) + } + + #[cfg(feature = "crypto")] + pub fn into_verified_with_policy( + self, + expected_signer_id: u64, + public_keys: &PublicKeyBundle, + expected_purpose: ProtectionPurpose, + policy: ProtectionPolicy, + ) -> Result { + match self { + Self::Signed(value) => value.into_verified_with_policy( + expected_signer_id, + public_keys, + expected_purpose, + policy, + ), + _ => Err(ProtectionError::NotSigned), } } - /* - * Encrypt a `Container` into a `SignedEncryptedContainer` in-place. - * The container is first signed (with `algorithm`/`signer`), then the signed - * blob is encrypted with `enc_type` to `recipient`. The result is an opaque - * ciphertext that decrypts to a `SignedContainer`. - */ #[cfg(feature = "crypto")] - pub fn sign_and_encrypt_container( - &mut self, - algorithm: u8, - signer: &impl SignatureScheme, - enc_type: EncryptionType, - recipient: &PublicKeyBundle, - aad: &[u8], - ) -> Option<()> { - self.sign_container(algorithm, signer)?; - let blob = self.as_signed_container()?; - let ct = mtp_crypto::encrypt_for(enc_type, recipient, &blob, aad).ok()?; - *self = DataValue::SignedEncryptedContainer(ct); - Some(()) + pub fn encrypt_for( + self, + recipients: &[PublicKeyBundle], + purpose: ProtectionPurpose, + ) -> Result { + let plaintext = self.to_bytes()?; + let message = mtp_crypto::encrypt_multi_for( + EncryptionType::MlKemChaCha20Poly1305, + purpose.0, + &plaintext, + recipients, + )?; + Ok(Self::Encrypted(EncryptedValue { + encryption_type: message.encryption_type, + purpose: purpose.0, + recipients: message.recipients, + ciphertext: message.ciphertext, + })) } - /* - * Decrypt a `SignedEncryptedContainer` in-place, replacing it with a - * `SignedContainer`. The algorithm and keypair are resolved from the blob's - * leading byte and `keyring`. Does NOT verify; call `verify_into_container` - * next. - */ #[cfg(feature = "crypto")] - pub fn decrypt_signed_encrypted_container( - &mut self, + pub fn decrypt( + &self, keyring: &Keyring, - aad: &[u8], - ) -> Option<()> { - let data = self.as_signed_encrypted_container()?; - let plaintext = mtp_crypto::decrypt_with(&data, keyring, aad).ok()?; - *self = DataValue::SignedContainer(plaintext); - Some(()) + expected_purpose: ProtectionPurpose, + ) -> Result { + self.decrypt_with_limits(keyring, expected_purpose, DecodeLimits::default()) } - pub fn as_map(&self) -> Option> { - match self { - DataValue::Container(c) => { - let mut out = BTreeMap::new(); - for (k, v) in c { - out.insert(*k, v.clone()); - } - Some(out) - } - _ => None, + /// Try a local key history without exposing recipient-key identifiers on + /// the wire. Entries are attempted in the caller's preferred order. + #[cfg(feature = "crypto")] + pub fn decrypt_with_keyrings( + &self, + keyrings: &[&Keyring], + expected_purpose: ProtectionPurpose, + ) -> Result { + if keyrings.is_empty() { + return Err(ProtectionError::NoMatchingRecipient); } + for keyring in keyrings { + match self.decrypt(keyring, expected_purpose) { + Ok(value) => return Ok(value), + Err(ProtectionError::NoMatchingRecipient) => {} + Err(error) => return Err(error), + } + } + Err(ProtectionError::NoMatchingRecipient) + } + + /// Decrypt an envelope and parse its plaintext with caller-supplied + /// recursive/resource limits. + #[cfg(feature = "crypto")] + pub fn decrypt_with_limits( + &self, + keyring: &Keyring, + expected_purpose: ProtectionPurpose, + limits: DecodeLimits, + ) -> Result { + let value = match self { + Self::Encrypted(value) => value, + _ => return Err(ProtectionError::NotEncrypted), + }; + let message = mtp_crypto::MultiEncryptedMessage { + encryption_type: value.encryption_type, + purpose: value.purpose, + recipients: value.recipients.clone(), + ciphertext: value.ciphertext.clone(), + }; + let plaintext = mtp_crypto::decrypt_multi_for(&message, expected_purpose.0, keyring) + .map_err(protection_error_from_decryption)?; + Self::from_bytes_with_limits(&plaintext, limits).ok_or(ProtectionError::Malformed) } pub fn to_bytes(&self) -> Result, CodecError> { let mut out = Vec::new(); - out.push(Self::kind_marker(self)); - Self::write_value_payload(&mut out, self)?; + self.write_to(&mut out)?; Ok(out) } - pub fn from_bytes(bytes: &[u8]) -> Option { - let mut cursor = Cursor::new(bytes); - let kind = cursor.read_u8().ok()?; - let payload_len = bytes.len().checked_sub(1)?; - let value = Self::read_value_by_kind(&mut cursor, kind, Some(payload_len))?; - if cursor.position() as usize != bytes.len() { - return None; + pub fn write_to(&self, out: &mut Vec) -> Result<(), CodecError> { + out.push(Self::kind_marker(self)); + match self { + Self::BoolTrue | Self::BoolFalse | Self::Bool(_) | Self::Null => {} + Self::SignedNumber(value) => out + .write_i128::(*value) + .map_err(|_| CodecError::InvalidEncoding)?, + Self::UnsignedNumber(value) => out + .write_u128::(*value) + .map_err(|_| CodecError::InvalidEncoding)?, + Self::Float(value) => out + .write_f64::(*value) + .map_err(|_| CodecError::InvalidEncoding)?, + Self::Str(value) => write_blob(out, value.as_bytes())?, + Self::Bytes(value) => write_blob(out, value)?, + Self::Array(values) => { + write_count(out, values.len())?; + for value in values { + value.write_to(out)?; + } + } + Self::Container(entries) => { + ensure_unique_container_fields(entries)?; + write_count(out, entries.len())?; + for (id, value) in entries { + out.write_u16::(id.0) + .map_err(|_| CodecError::InvalidEncoding)?; + value.write_to(out)?; + } + } + #[cfg(feature = "crypto")] + Self::Signed(value) => { + let mut wrapper = Vec::new(); + wrapper.push(value.algorithm); + wrapper.push(value.purpose); + wrapper + .write_u64::(value.signer_id) + .map_err(|_| CodecError::InvalidEncoding)?; + let expected = + SigAlgorithm::length(value.algorithm).ok_or(CodecError::InvalidEncoding)?; + if value.signature.len() != expected { + return Err(CodecError::InvalidEncoding); + } + wrapper.extend_from_slice(&value.signature); + value.value.write_to(&mut wrapper)?; + write_blob(out, &wrapper)?; + } + #[cfg(feature = "crypto")] + Self::Encrypted(value) => { + let message = mtp_crypto::MultiEncryptedMessage { + encryption_type: value.encryption_type, + purpose: value.purpose, + recipients: value.recipients.clone(), + ciphertext: value.ciphertext.clone(), + }; + let envelope = message + .to_bytes() + .map_err(|_| CodecError::InvalidEncoding)?; + write_blob(out, &envelope)?; + } } - Some(value) + Ok(()) + } + + pub fn from_bytes(bytes: &[u8]) -> Option { + Self::from_bytes_with_limits(bytes, DecodeLimits::default()) + } + + pub fn from_bytes_with_limits(bytes: &[u8], limits: DecodeLimits) -> Option { + let mut cursor = Cursor::new(bytes); + let value = Self::read_from_with_limits(&mut cursor, limits).ok()?; + (cursor.position() as usize == bytes.len()).then_some(value) + } + + pub fn read_from(cursor: &mut Cursor<&[u8]>) -> Result { + Self::read_from_with_limits(cursor, DecodeLimits::default()) + } + + pub fn read_from_with_limits( + cursor: &mut Cursor<&[u8]>, + limits: DecodeLimits, + ) -> Result { + let mut context = DecodeContext::new(limits); + Self::read_value(cursor, &mut context).ok_or(CodecError::InvalidEncoding) } pub fn to_base64(&self) -> Result { Ok(general_purpose::STANDARD.encode(self.to_bytes()?)) } - pub fn from_base64(base64_str: &str) -> Option { - let bytes = general_purpose::STANDARD.decode(base64_str).ok()?; - Self::from_bytes(&bytes) + pub fn from_base64(value: &str) -> Option { + general_purpose::STANDARD + .decode(value) + .ok() + .and_then(|bytes| Self::from_bytes(&bytes)) } - fn encode_container(entries: &[(DataTypeId, DataValue)]) -> Result, CodecError> { - let mut out = Vec::new(); - let count = u16::try_from(entries.len()).map_err(|_| CodecError::TooManyEntries)?; - out.write_u16::(count) - .map_err(|_| CodecError::InvalidEncoding)?; - - for (key, value) in entries { - Self::write_container_entry(&mut out, *key, value)?; - } - Ok(out) - } - - fn write_container_entry( - buf: &mut Vec, - key: DataTypeId, - value: &DataValue, - ) -> Result<(), CodecError> { - let kind = Self::kind_marker(value); - buf.push(kind); - - if Self::kind_has_no_payload(kind) { - buf.write_u16::(key.0) - .map_err(|_| CodecError::InvalidEncoding)?; - return Ok(()); - } - - let mut payload = Vec::new(); - Self::write_value_payload(&mut payload, value)?; - - let len = u32::try_from(payload.len()).map_err(|_| CodecError::TooManyEntries)?; - buf.write_u32::(len) - .map_err(|_| CodecError::InvalidEncoding)?; - buf.write_u16::(key.0) - .map_err(|_| CodecError::InvalidEncoding)?; - buf.extend_from_slice(&payload); - Ok(()) - } - - fn encode_array(arr: &[DataValue]) -> Result, CodecError> { - let mut out = Vec::new(); - let count = u16::try_from(arr.len()).map_err(|_| CodecError::TooManyEntries)?; - out.write_u16::(count) - .map_err(|_| CodecError::InvalidEncoding)?; - - for value in arr { - Self::write_array_entry(&mut out, value)?; - } - - Ok(out) - } - - fn write_array_entry(buf: &mut Vec, value: &DataValue) -> Result<(), CodecError> { - let kind = Self::kind_marker(value); - buf.push(kind); - - if Self::kind_has_no_payload(kind) { - return Ok(()); - } - - let mut payload = Vec::new(); - Self::write_value_payload(&mut payload, value)?; - - let len = u32::try_from(payload.len()).map_err(|_| CodecError::TooManyEntries)?; - buf.write_u32::(len) - .map_err(|_| CodecError::InvalidEncoding)?; - buf.extend_from_slice(&payload); - Ok(()) - } - - fn write_value_payload(buf: &mut Vec, value: &DataValue) -> Result<(), CodecError> { + fn kind_marker(value: &Self) -> u8 { match value { - DataValue::BoolTrue => Ok(()), - DataValue::BoolFalse => Ok(()), - #[allow(clippy::if_same_then_else)] - DataValue::Bool(v) => { - // Kept intentionally: the kind marker already encodes the boolean, - // so both arms carry no payload. Retained for clear compatibility. - if *v { Ok(()) } else { Ok(()) } - } - DataValue::SignedNumber(n) => { - buf.write_i128::(*n) - .map_err(|_| CodecError::InvalidEncoding)?; - Ok(()) - } - DataValue::UnsignedNumber(n) => { - buf.write_u128::(*n) - .map_err(|_| CodecError::InvalidEncoding)?; - Ok(()) - } - DataValue::Float(value) => { - buf.write_f64::(*value) - .map_err(|_| CodecError::InvalidEncoding)?; - Ok(()) - } - DataValue::Str(s) => { - buf.extend_from_slice(s.as_bytes()); - Ok(()) - } - DataValue::Array(arr) => { - let bytes = Self::encode_array(arr)?; - buf.extend_from_slice(&bytes); - Ok(()) - } - DataValue::Bytes(b) => { - buf.extend_from_slice(b); - Ok(()) - } - DataValue::Container(entries) => { - let bytes = Self::encode_container(entries)?; - buf.extend_from_slice(&bytes); - Ok(()) - } - #[cfg(feature = "crypto")] - DataValue::EncryptedContainer(data) => { - buf.extend_from_slice(data); - Ok(()) - } - #[cfg(feature = "crypto")] - DataValue::SignedContainer(data) => { - buf.extend_from_slice(data); - Ok(()) - } - #[cfg(feature = "crypto")] - DataValue::SignedEncryptedContainer(data) => { - buf.extend_from_slice(data); - Ok(()) - } - - DataValue::Null => Ok(()), - } - } - - fn try_read_container(cursor: &mut Cursor<&[u8]>) -> Option { - let count = cursor.read_u16::().ok()? as usize; - let remaining = cursor - .get_ref() - .len() - .saturating_sub(cursor.position() as usize); - let mut entries = Vec::with_capacity(count.min(remaining / Self::MIN_ENTRY_BYTES)); - - for _ in 0..count { - let kind = cursor.read_u8().ok()?; - - if Self::kind_has_no_payload(kind) { - let key = DataTypeId(cursor.read_u16::().ok()?); - let value = Self::read_payloadless_value(kind)?; - entries.push((key, value)); - continue; - } - - let len = cursor.read_u32::().ok()? as usize; - let key = DataTypeId(cursor.read_u16::().ok()?); - - let payload = Self::read_payload_slice(cursor, len)?; - let mut inner = Cursor::new(payload); - let value = Self::read_value_by_kind(&mut inner, kind, Some(len))?; - if inner.position() as usize != len { - return None; - } - entries.push((key, value)); - } - - Some(DataValue::Container(entries)) - } - - fn read_array(cursor: &mut Cursor<&[u8]>) -> Option { - let count = cursor.read_u16::().ok()? as usize; - let remaining = cursor - .get_ref() - .len() - .saturating_sub(cursor.position() as usize); - let mut out = Vec::with_capacity(count.min(remaining / Self::MIN_ENTRY_BYTES)); - - for _ in 0..count { - let kind = cursor.read_u8().ok()?; - - if Self::kind_has_no_payload(kind) { - let value = Self::read_payloadless_value(kind)?; - out.push(value); - continue; - } - - let len = cursor.read_u32::().ok()? as usize; - let payload = Self::read_payload_slice(cursor, len)?; - let mut inner = Cursor::new(payload); - let value = Self::read_value_by_kind(&mut inner, kind, Some(len))?; - if inner.position() as usize != len { - return None; - } - out.push(value); - } - - Some(DataValue::Array(out)) - } - - fn read_value_by_kind( - cursor: &mut Cursor<&[u8]>, - kind: u8, - payload_len: Option, - ) -> Option { - match kind { - Self::KIND_BOOL_TRUE => Some(DataValue::BoolTrue), - Self::KIND_BOOL_FALSE => Some(DataValue::BoolFalse), - Self::KIND_SIGNED_NUMBER => Some(DataValue::SignedNumber( - cursor.read_i128::().ok()?, - )), - Self::KIND_UNSIGNED_NUMBER => Some(DataValue::UnsignedNumber( - cursor.read_u128::().ok()?, - )), - Self::KIND_FLOAT => Some(DataValue::Float(cursor.read_f64::().ok()?)), - Self::KIND_STR => { - let s = std::str::from_utf8(Self::read_payload_slice(cursor, payload_len?)?) - .ok()? - .to_string(); - Some(DataValue::Str(s)) - } - Self::KIND_BYTES => Some(DataValue::Bytes(Self::read_blob_payload( - cursor, - payload_len?, - )?)), - Self::KIND_ARRAY => { - let len = payload_len?; - let mut inner = Cursor::new(Self::read_payload_slice(cursor, len)?); - let arr = Self::read_array(&mut inner)?; - if inner.position() as usize != len { - return None; - } - Some(arr) - } - Self::KIND_CONTAINER => { - let len = payload_len?; - let mut inner = Cursor::new(Self::read_payload_slice(cursor, len)?); - let c = Self::try_read_container(&mut inner)?; - if inner.position() as usize != len { - return None; - } - Some(c) - } - #[cfg(feature = "crypto")] - Self::KIND_ENCRYPTED_CONTAINER => Some(DataValue::EncryptedContainer( - Self::read_blob_payload(cursor, payload_len?)?, - )), - #[cfg(feature = "crypto")] - Self::KIND_SIGNED_CONTAINER => Some(DataValue::SignedContainer( - Self::read_blob_payload(cursor, payload_len?)?, - )), - #[cfg(feature = "crypto")] - Self::KIND_SIGNED_ENCRYPTED_CONTAINER => Some(DataValue::SignedEncryptedContainer( - Self::read_blob_payload(cursor, payload_len?)?, - )), - Self::KIND_NULL => Some(DataValue::Null), - #[cfg(not(feature = "crypto"))] - 0x0A..=0x0C => None, - _ => None, - } - } - - fn kind_marker(value: &DataValue) -> u8 { - match value { - DataValue::BoolTrue => Self::KIND_BOOL_TRUE, - DataValue::BoolFalse => Self::KIND_BOOL_FALSE, - DataValue::Bool(v) => { - if *v { + Self::BoolTrue => Self::KIND_BOOL_TRUE, + Self::BoolFalse => Self::KIND_BOOL_FALSE, + Self::Bool(value) => { + if *value { Self::KIND_BOOL_TRUE } else { Self::KIND_BOOL_FALSE } } - DataValue::SignedNumber(_) => Self::KIND_SIGNED_NUMBER, - DataValue::UnsignedNumber(_) => Self::KIND_UNSIGNED_NUMBER, - DataValue::Float(_) => Self::KIND_FLOAT, - DataValue::Str(_) => Self::KIND_STR, - DataValue::Array(_) => Self::KIND_ARRAY, - DataValue::Bytes(_) => Self::KIND_BYTES, - DataValue::Container(_) => Self::KIND_CONTAINER, + Self::SignedNumber(_) => Self::KIND_SIGNED_NUMBER, + Self::UnsignedNumber(_) => Self::KIND_UNSIGNED_NUMBER, + Self::Float(_) => Self::KIND_FLOAT, + Self::Str(_) => Self::KIND_STR, + Self::Bytes(_) => Self::KIND_BYTES, + Self::Array(_) => Self::KIND_ARRAY, + Self::Container(_) => Self::KIND_CONTAINER, #[cfg(feature = "crypto")] - DataValue::EncryptedContainer(_) => Self::KIND_ENCRYPTED_CONTAINER, + Self::Encrypted(_) => Self::KIND_ENCRYPTED, #[cfg(feature = "crypto")] - DataValue::SignedContainer(_) => Self::KIND_SIGNED_CONTAINER, - #[cfg(feature = "crypto")] - DataValue::SignedEncryptedContainer(_) => Self::KIND_SIGNED_ENCRYPTED_CONTAINER, - DataValue::Null => Self::KIND_NULL, + Self::Signed(_) => Self::KIND_SIGNED, + Self::Null => Self::KIND_NULL, } } - fn kind_has_no_payload(kind: u8) -> bool { - kind == Self::KIND_BOOL_TRUE || kind == Self::KIND_BOOL_FALSE || kind == Self::KIND_NULL - } - - fn read_payloadless_value(kind: u8) -> Option { - match kind { - Self::KIND_BOOL_TRUE => Some(DataValue::BoolTrue), - Self::KIND_BOOL_FALSE => Some(DataValue::BoolFalse), - Self::KIND_NULL => Some(DataValue::Null), + fn read_value(cursor: &mut Cursor<&[u8]>, context: &mut DecodeContext) -> Option { + context.value()?; + match cursor.read_u8().ok()? { + Self::KIND_BOOL_TRUE => Some(Self::BoolTrue), + Self::KIND_BOOL_FALSE => Some(Self::BoolFalse), + Self::KIND_SIGNED_NUMBER => { + Some(Self::SignedNumber(cursor.read_i128::().ok()?)) + } + Self::KIND_UNSIGNED_NUMBER => { + Some(Self::UnsignedNumber(cursor.read_u128::().ok()?)) + } + Self::KIND_FLOAT => Some(Self::Float(cursor.read_f64::().ok()?)), + Self::KIND_STR => { + let bytes = read_blob(cursor, context.limits.max_blob_size)?; + Some(Self::Str(String::from_utf8(bytes).ok()?)) + } + Self::KIND_BYTES => Some(Self::Bytes(read_blob( + cursor, + context.limits.max_blob_size, + )?)), + Self::KIND_ARRAY => { + context.enter()?; + let count = cursor.read_u16::().ok()? as usize; + let mut values = Vec::with_capacity(count.min(remaining(cursor))); + for _ in 0..count { + values.push(Self::read_value(cursor, context)?); + } + context.leave(); + Some(Self::Array(values)) + } + Self::KIND_CONTAINER => { + context.enter()?; + let count = cursor.read_u16::().ok()? as usize; + let mut values = Vec::with_capacity(count.min(remaining(cursor) / 3)); + let mut seen = BTreeSet::new(); + for _ in 0..count { + let id = DataTypeId(cursor.read_u16::().ok()?); + if !seen.insert(id) { + return None; + } + values.push((id, Self::read_value(cursor, context)?)); + } + context.leave(); + Some(Self::Container(values)) + } + #[cfg(feature = "crypto")] + Self::KIND_SIGNED => { + context.enter()?; + let wrapper = read_blob(cursor, context.limits.max_blob_size)?; + let mut inner = Cursor::new(wrapper.as_slice()); + let algorithm = inner.read_u8().ok()?; + let purpose = inner.read_u8().ok()?; + let signer_id = inner.read_u64::().ok()?; + let signature_len = SigAlgorithm::length(algorithm)?; + let signature = read_slice(&mut inner, signature_len)?.to_vec(); + let value = Self::read_value(&mut inner, context)?; + if inner.position() as usize != wrapper.len() { + return None; + } + context.leave(); + Some(Self::Signed(SignedValue { + algorithm, + purpose, + signer_id, + signature, + value: Box::new(value), + })) + } + #[cfg(feature = "crypto")] + Self::KIND_ENCRYPTED => { + let envelope = read_blob(cursor, context.limits.max_blob_size)?; + let message = mtp_crypto::MultiEncryptedMessage::from_bytes(&envelope).ok()?; + if message.recipients.len() > context.limits.max_recipients { + return None; + } + Some(Self::Encrypted(EncryptedValue { + encryption_type: message.encryption_type, + purpose: message.purpose, + recipients: message.recipients, + ciphertext: message.ciphertext, + })) + } + Self::KIND_NULL => Some(Self::Null), + // 0x0C was the old SignedEncryptedContainer kind and is reserved. _ => None, } } +} - fn read_payload_slice<'a>(cursor: &mut Cursor<&'a [u8]>, len: usize) -> Option<&'a [u8]> { - let start = cursor.position() as usize; - let end = start.checked_add(len)?; - if end > cursor.get_ref().len() { - return None; +#[cfg(feature = "crypto")] +impl SignedValue { + pub fn verify( + &self, + expected_signer_id: u64, + public_keys: &PublicKeyBundle, + expected_purpose: ProtectionPurpose, + ) -> Result<(), ProtectionError> { + self.verify_with_policy( + expected_signer_id, + public_keys, + expected_purpose, + ProtectionPolicy::default(), + ) + } + + pub fn verify_with_policy( + &self, + expected_signer_id: u64, + public_keys: &PublicKeyBundle, + expected_purpose: ProtectionPurpose, + policy: ProtectionPolicy, + ) -> Result<(), ProtectionError> { + if self.signer_id != expected_signer_id { + return Err(ProtectionError::SignerIdMismatch { + expected: expected_signer_id, + actual: self.signer_id, + }); } - cursor.set_position(end as u64); - Some(&cursor.get_ref()[start..end]) + if self.purpose != expected_purpose.0 { + return Err(ProtectionError::PurposeMismatch { + expected: expected_purpose.0, + actual: self.purpose, + }); + } + if !policy.signature.accepts(self.algorithm) { + return Err(ProtectionError::SignaturePolicyMismatch { + expected: policy.signature, + actual: self.algorithm, + }); + } + validate_signature(self.algorithm, &self.signature)?; + let inner = self.value.to_bytes()?; + let message = signed_message(self.algorithm, self.purpose, self.signer_id, &inner); + let result = match self.algorithm { + SigAlgorithm::ED25519 => mtp_crypto::verify_ed25519( + &public_keys.sig_cl_public_key, + &message, + &self.signature, + ), + SigAlgorithm::ML_DSA_65 => { + mtp_crypto::verify_ml_dsa(&public_keys.sig_pq_public_key, &message, &self.signature) + } + SigAlgorithm::DUAL => { + let ed_len = SigAlgorithm::length(SigAlgorithm::ED25519).unwrap(); + if self.signature.len() + != ed_len + SigAlgorithm::length(SigAlgorithm::ML_DSA_65).unwrap() + { + return Err(ProtectionError::Malformed); + } + mtp_crypto::verify_ed25519( + &public_keys.sig_cl_public_key, + &message, + &self.signature[..ed_len], + ) + .and_then(|_| { + mtp_crypto::verify_ml_dsa( + &public_keys.sig_pq_public_key, + &message, + &self.signature[ed_len..], + ) + }) + } + _ => return Err(ProtectionError::Malformed), + }; + result.map_err(|_| ProtectionError::InvalidSignature) } - fn read_blob_payload(cursor: &mut Cursor<&[u8]>, len: usize) -> Option> { - Some(Self::read_payload_slice(cursor, len)?.to_vec()) + /// Verify this signed wrapper and return its inner value. + pub fn into_verified( + self, + expected_signer_id: u64, + public_keys: &PublicKeyBundle, + expected_purpose: ProtectionPurpose, + ) -> Result { + self.into_verified_with_policy( + expected_signer_id, + public_keys, + expected_purpose, + ProtectionPolicy::default(), + ) } + + /// Verify this signed wrapper with an explicit receiver policy and return + /// its inner value. + pub fn into_verified_with_policy( + self, + expected_signer_id: u64, + public_keys: &PublicKeyBundle, + expected_purpose: ProtectionPurpose, + policy: ProtectionPolicy, + ) -> Result { + self.verify_with_policy(expected_signer_id, public_keys, expected_purpose, policy)?; + Ok(*self.value) + } + + pub fn verify_with( + &self, + resolve: F, + expected_purpose: ProtectionPurpose, + ) -> Result<(), ProtectionError> + where + F: FnOnce(u64) -> Option, + { + self.verify_with_resolver_policy(resolve, expected_purpose, ProtectionPolicy::default()) + } + + pub fn verify_with_resolver_policy( + &self, + resolve: F, + expected_purpose: ProtectionPurpose, + policy: ProtectionPolicy, + ) -> Result<(), ProtectionError> + where + F: FnOnce(u64) -> Option, + { + let public_keys = + resolve(self.signer_id).ok_or(ProtectionError::SignerKeyNotFound(self.signer_id))?; + self.verify_with_policy(self.signer_id, &public_keys, expected_purpose, policy) + } + + /// Verify against a local signing-key history without exposing a key + /// identifier in the signed wire value. The first trusted key that + /// verifies is accepted. + pub fn verify_with_key_history( + &self, + expected_signer_id: u64, + public_keys: &[PublicKeyBundle], + expected_purpose: ProtectionPurpose, + policy: ProtectionPolicy, + ) -> Result<(), ProtectionError> { + self.verify_with_key_history_index( + expected_signer_id, + public_keys, + expected_purpose, + policy, + ) + .map(|_| ()) + } + + /// Verify against a local signing-key history and return the index of the + /// trusted key that authenticated the value. + pub fn verify_with_key_history_index( + &self, + expected_signer_id: u64, + public_keys: &[PublicKeyBundle], + expected_purpose: ProtectionPurpose, + policy: ProtectionPolicy, + ) -> Result { + let mut last_error = None; + for (index, public_key) in public_keys.iter().enumerate() { + match self.verify_with_policy(expected_signer_id, public_key, expected_purpose, policy) + { + Ok(()) => return Ok(index), + Err(error @ ProtectionError::InvalidSignature) => last_error = Some(error), + Err(error @ ProtectionError::Crypto(_)) => last_error = Some(error), + Err(error) => return Err(error), + } + } + Err(last_error.unwrap_or(ProtectionError::SignerKeyNotFound(expected_signer_id))) + } +} + +#[cfg(feature = "crypto")] +fn signed_message(algorithm: u8, purpose: u8, signer_id: u64, inner: &[u8]) -> Vec { + let mut message = Vec::with_capacity(SIGN_DOMAIN.len() + 10 + inner.len()); + message.extend_from_slice(SIGN_DOMAIN); + message.push(algorithm); + message.push(purpose); + message.extend_from_slice(&signer_id.to_be_bytes()); + message.extend_from_slice(inner); + message +} + +#[cfg(feature = "crypto")] +fn validate_signature(algorithm: u8, signature: &[u8]) -> Result<(), ProtectionError> { + let expected = SigAlgorithm::length(algorithm).ok_or(ProtectionError::Malformed)?; + if signature.len() == expected { + Ok(()) + } else { + Err(ProtectionError::Malformed) + } +} + +#[cfg(feature = "crypto")] +fn protection_error_from_decryption(error: mtp_crypto::CryptoError) -> ProtectionError { + match error { + mtp_crypto::CryptoError::MalformedEnvelope => ProtectionError::Malformed, + mtp_crypto::CryptoError::NoMatchingRecipient => ProtectionError::NoMatchingRecipient, + other => ProtectionError::Crypto(other), + } +} + +fn write_count(out: &mut Vec, count: usize) -> Result<(), CodecError> { + let count = u16::try_from(count).map_err(|_| CodecError::TooManyEntries)?; + out.write_u16::(count) + .map_err(|_| CodecError::InvalidEncoding) +} + +fn ensure_unique_container_fields(entries: &[(DataTypeId, DataValue)]) -> Result<(), CodecError> { + let mut seen = BTreeSet::new(); + if entries.iter().all(|(id, _)| seen.insert(*id)) { + Ok(()) + } else { + Err(CodecError::InvalidEncoding) + } +} + +fn write_blob(out: &mut Vec, bytes: &[u8]) -> Result<(), CodecError> { + let len = u32::try_from(bytes.len()).map_err(|_| CodecError::InvalidEncoding)?; + out.write_u32::(len) + .map_err(|_| CodecError::InvalidEncoding)?; + out.extend_from_slice(bytes); + Ok(()) +} + +fn read_blob(cursor: &mut Cursor<&[u8]>, max_size: usize) -> Option> { + let len = cursor.read_u32::().ok()? as usize; + if len > max_size { + return None; + } + Some(read_slice(cursor, len)?.to_vec()) +} + +fn read_slice<'a>(cursor: &mut Cursor<&'a [u8]>, len: usize) -> Option<&'a [u8]> { + let start = cursor.position() as usize; + let end = start.checked_add(len)?; + if end > cursor.get_ref().len() { + return None; + } + cursor.set_position(end as u64); + Some(&cursor.get_ref()[start..end]) +} + +fn remaining(cursor: &Cursor<&[u8]>) -> usize { + cursor + .get_ref() + .len() + .saturating_sub(cursor.position() as usize) } impl fmt::Display for DataValue { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - DataValue::BoolTrue => write!(f, "true"), - DataValue::BoolFalse => write!(f, "false"), - DataValue::Bool(v) => write!(f, "{}", v), - DataValue::SignedNumber(n) => write!(f, "{}", n), - DataValue::UnsignedNumber(n) => write!(f, "{}", n), - DataValue::Float(value) => write!(f, "{}", value), - DataValue::Str(s) => write!(f, "\"{}\"", s), - DataValue::Container(entries) => { - write!(f, "{{")?; - for (i, (key, value)) in entries.iter().enumerate() { - if i > 0 { - write!(f, ", ")?; + Self::BoolTrue => f.write_str("true"), + Self::BoolFalse => f.write_str("false"), + Self::Bool(value) => write!(f, "{value}"), + Self::SignedNumber(value) => write!(f, "{value}"), + Self::UnsignedNumber(value) => write!(f, "{value}"), + Self::Float(value) => write!(f, "{value}"), + Self::Str(value) => write!(f, "\"{value}\""), + Self::Bytes(_) => f.write_str("(Binary)"), + Self::Array(values) => { + f.write_str("[")?; + for (index, value) in values.iter().enumerate() { + if index > 0 { + f.write_str(", ")?; } - write!(f, "{}: {}", key.0, value)?; + write!(f, "{value}")?; } - write!(f, "}}") + f.write_str("]") } - DataValue::Array(arr) => { - write!(f, "[")?; - for (i, value) in arr.iter().enumerate() { - if i > 0 { - write!(f, ", ")?; + Self::Container(entries) => { + f.write_str("{")?; + for (index, (id, value)) in entries.iter().enumerate() { + if index > 0 { + f.write_str(", ")?; } - write!(f, "{}", value)?; + write!(f, "{}: {value}", id.0)?; } - write!(f, "]") + f.write_str("}") } - DataValue::Bytes(_) => write!(f, "(Binary)"), #[cfg(feature = "crypto")] - DataValue::EncryptedContainer(_) => write!(f, "(Secure)"), + Self::Encrypted(_) => f.write_str("(Encrypted)"), #[cfg(feature = "crypto")] - DataValue::SignedContainer(_) => write!(f, "(Signed)"), - #[cfg(feature = "crypto")] - DataValue::SignedEncryptedContainer(_) => write!(f, "(SignedSecure)"), - DataValue::Null => write!(f, "null"), + Self::Signed(_) => f.write_str("(Signed)"), + Self::Null => f.write_str("null"), } } } @@ -966,23 +1261,24 @@ impl PartialEq for DataValue { fn eq(&self, other: &Self) -> bool { use DataValue::*; match (self, other) { - (BoolTrue, BoolTrue) | (BoolFalse, BoolFalse) => true, - (BoolTrue, Bool(true)) | (Bool(true), BoolTrue) => true, - (BoolFalse, Bool(false)) | (Bool(false), BoolFalse) => true, + (BoolTrue, BoolTrue) + | (BoolFalse, BoolFalse) + | (BoolTrue, Bool(true)) + | (Bool(true), BoolTrue) + | (BoolFalse, Bool(false)) + | (Bool(false), BoolFalse) => true, (Bool(a), Bool(b)) => a == b, (SignedNumber(a), SignedNumber(b)) => a == b, (UnsignedNumber(a), UnsignedNumber(b)) => a == b, (Float(a), Float(b)) => a.to_bits() == b.to_bits(), (Str(a), Str(b)) => a == b, - (Array(a), Array(b)) => a == b, (Bytes(a), Bytes(b)) => a == b, + (Array(a), Array(b)) => a == b, (Container(a), Container(b)) => a == b, #[cfg(feature = "crypto")] - (EncryptedContainer(a), EncryptedContainer(b)) => a == b, + (Encrypted(a), Encrypted(b)) => a == b, #[cfg(feature = "crypto")] - (SignedContainer(a), SignedContainer(b)) => a == b, - #[cfg(feature = "crypto")] - (SignedEncryptedContainer(a), SignedEncryptedContainer(b)) => a == b, + (Signed(a), Signed(b)) => a == b, (Null, Null) => true, _ => false, } @@ -993,87 +1289,48 @@ impl Eq for DataValue {} impl Hash for DataValue { fn hash(&self, state: &mut H) { - use DataValue::*; - // Use the wire kind marker as the per-variant discriminant. It is unique - // per kind and maps BoolTrue/Bool(true) (and BoolFalse/Bool(false)) to the - // same marker, keeping the hash consistent with the Eq bool equivalence. Self::kind_marker(self).hash(state); match self { - BoolTrue | BoolFalse | Bool(_) | Null => {} - SignedNumber(n) => n.hash(state), - UnsignedNumber(n) => n.hash(state), - Float(value) => value.to_bits().hash(state), - Str(s) => s.hash(state), - Array(a) => a.hash(state), - Bytes(a) => a.hash(state), - Container(c) => c.hash(state), + Self::BoolTrue | Self::BoolFalse | Self::Bool(_) | Self::Null => {} + Self::SignedNumber(value) => value.hash(state), + Self::UnsignedNumber(value) => value.hash(state), + Self::Float(value) => value.to_bits().hash(state), + Self::Str(value) => value.hash(state), + Self::Bytes(value) => value.hash(state), + Self::Array(value) => value.hash(state), + Self::Container(value) => value.hash(state), #[cfg(feature = "crypto")] - EncryptedContainer(c) => c.hash(state), + Self::Encrypted(value) => value.to_bytes().hash(state), #[cfg(feature = "crypto")] - SignedContainer(c) => c.hash(state), - #[cfg(feature = "crypto")] - SignedEncryptedContainer(c) => c.hash(state), + Self::Signed(value) => value.to_bytes().hash(state), } } } -/* ================================ FROM / TRY-FROM ================================ */ - -impl From for DataValue { - fn from(v: bool) -> Self { - if v { - DataValue::BoolTrue - } else { - DataValue::BoolFalse +#[cfg(feature = "crypto")] +impl EncryptedValue { + fn to_bytes(&self) -> Vec { + mtp_crypto::MultiEncryptedMessage { + encryption_type: self.encryption_type, + purpose: self.purpose, + recipients: self.recipients.clone(), + ciphertext: self.ciphertext.clone(), } + .to_bytes() + .unwrap_or_default() } } -impl From<&str> for DataValue { - fn from(s: &str) -> Self { - DataValue::Str(s.to_string()) - } -} - -impl From for DataValue { - fn from(s: String) -> Self { - DataValue::Str(s) - } -} - -impl From for DataValue { - fn from(n: i64) -> Self { - DataValue::SignedNumber(n as i128) - } -} - -impl From for DataValue { - fn from(n: i128) -> Self { - DataValue::SignedNumber(n) - } -} - -impl From for DataValue { - fn from(n: u64) -> Self { - DataValue::UnsignedNumber(n as u128) - } -} - -impl From for DataValue { - fn from(n: u128) -> Self { - DataValue::UnsignedNumber(n) - } -} - -impl From> for DataValue { - fn from(b: Vec) -> Self { - DataValue::Bytes(b) - } -} - -impl From<&[u8]> for DataValue { - fn from(b: &[u8]) -> Self { - DataValue::Bytes(b.to_vec()) +#[cfg(feature = "crypto")] +impl SignedValue { + fn to_bytes(&self) -> Vec { + let mut out = Vec::new(); + out.push(self.algorithm); + out.push(self.purpose); + out.extend_from_slice(&self.signer_id.to_be_bytes()); + out.extend_from_slice(&self.signature); + let _ = self.value.write_to(&mut out); + out } } @@ -1091,21 +1348,79 @@ impl fmt::Display for DataValueTypeMismatch { impl std::error::Error for DataValueTypeMismatch {} +impl From for DataValue { + fn from(value: bool) -> Self { + if value { + Self::BoolTrue + } else { + Self::BoolFalse + } + } +} + +impl From<&str> for DataValue { + fn from(value: &str) -> Self { + Self::Str(value.to_owned()) + } +} + +impl From for DataValue { + fn from(value: String) -> Self { + Self::Str(value) + } +} + +impl From for DataValue { + fn from(value: i64) -> Self { + Self::SignedNumber(value as i128) + } +} + +impl From for DataValue { + fn from(value: i128) -> Self { + Self::SignedNumber(value) + } +} + +impl From for DataValue { + fn from(value: u64) -> Self { + Self::UnsignedNumber(value as u128) + } +} + +impl From for DataValue { + fn from(value: u128) -> Self { + Self::UnsignedNumber(value) + } +} + +impl From> for DataValue { + fn from(value: Vec) -> Self { + Self::Bytes(value) + } +} + +impl From<&[u8]> for DataValue { + fn from(value: &[u8]) -> Self { + Self::Bytes(value.to_vec()) + } +} + impl TryFrom for bool { type Error = DataValueTypeMismatch; - fn try_from(v: DataValue) -> Result { - v.as_bool().ok_or(DataValueTypeMismatch { + fn try_from(value: DataValue) -> Result { + value.as_bool().ok_or(DataValueTypeMismatch { expected: "Bool", - got: v.type_name(), + got: value.type_name(), }) } } impl TryFrom for String { type Error = DataValueTypeMismatch; - fn try_from(v: DataValue) -> Result { - match v { - DataValue::Str(s) => Ok(s), + fn try_from(value: DataValue) -> Result { + match value { + DataValue::Str(value) => Ok(value), other => Err(DataValueTypeMismatch { expected: "Str", got: other.type_name(), @@ -1116,51 +1431,51 @@ impl TryFrom for String { impl TryFrom for i128 { type Error = DataValueTypeMismatch; - fn try_from(v: DataValue) -> Result { - v.as_signed_number().ok_or(DataValueTypeMismatch { + fn try_from(value: DataValue) -> Result { + value.as_signed_number().ok_or(DataValueTypeMismatch { expected: "SignedNumber", - got: v.type_name(), + got: value.type_name(), }) } } impl TryFrom for i64 { type Error = DataValueTypeMismatch; - fn try_from(v: DataValue) -> Result { - let n = v.as_signed_number().ok_or(DataValueTypeMismatch { - expected: "SignedNumber", - got: v.type_name(), - })?; - Ok(n as i64) + fn try_from(value: DataValue) -> Result { + let value = i128::try_from(value)?; + i64::try_from(value).map_err(|_| DataValueTypeMismatch { + expected: "i64", + got: "SignedNumber", + }) } } impl TryFrom for u128 { type Error = DataValueTypeMismatch; - fn try_from(v: DataValue) -> Result { - v.as_unsigned_number().ok_or(DataValueTypeMismatch { + fn try_from(value: DataValue) -> Result { + value.as_unsigned_number().ok_or(DataValueTypeMismatch { expected: "UnsignedNumber", - got: v.type_name(), + got: value.type_name(), }) } } impl TryFrom for u64 { type Error = DataValueTypeMismatch; - fn try_from(v: DataValue) -> Result { - let n = v.as_unsigned_number().ok_or(DataValueTypeMismatch { - expected: "UnsignedNumber", - got: v.type_name(), - })?; - Ok(n as u64) + fn try_from(value: DataValue) -> Result { + let value = u128::try_from(value)?; + u64::try_from(value).map_err(|_| DataValueTypeMismatch { + expected: "u64", + got: "UnsignedNumber", + }) } } impl TryFrom for Vec { type Error = DataValueTypeMismatch; - fn try_from(v: DataValue) -> Result { - match v { - DataValue::Bytes(b) => Ok(b), + fn try_from(value: DataValue) -> Result { + match value { + DataValue::Bytes(value) => Ok(value), other => Err(DataValueTypeMismatch { expected: "Bytes", got: other.type_name(), @@ -1169,854 +1484,642 @@ impl TryFrom for Vec { } } -/* ================================ TESTS ================================ */ #[cfg(test)] mod tests { use super::*; - fn container_roundtrip( - values: Vec<(DataTypeId, DataValue)>, - ) -> Result<(), Box> { - let dv = DataValue::Container(values.clone()); - let bytes = dv.to_bytes()?; - let decoded = DataValue::from_bytes(&bytes).ok_or("roundtrip failed")?; - assert_eq!(dv, decoded, "container roundtrip mismatch"); - Ok(()) - } - - fn array_roundtrip(values: Vec) -> Result<(), Box> { - let dv = DataValue::Array(values.clone()); - let bytes = dv.to_bytes()?; - let decoded = DataValue::from_bytes(&bytes).ok_or("roundtrip failed")?; - assert_eq!(dv, decoded, "array roundtrip mismatch"); - Ok(()) - } - - fn value_roundtrip(value: DataValue) -> Result<(), Box> { - let bytes = value.to_bytes()?; - assert_eq!(bytes.first(), Some(&DataValue::kind_marker(&value))); - let decoded = DataValue::from_bytes(&bytes).ok_or("roundtrip failed")?; - assert_eq!(value, decoded, "value roundtrip mismatch"); - Ok(()) - } - #[test] - fn test_every_top_level_variant_roundtrips() -> Result<(), Box> { - let values = vec![ - DataValue::BoolTrue, - DataValue::BoolFalse, - DataValue::Bool(true), - DataValue::Bool(false), - DataValue::SignedNumber(i128::MIN), - DataValue::UnsignedNumber(u128::MAX), - DataValue::Float(-0.125), - DataValue::Str("top level".to_string()), - DataValue::Bytes(vec![0x00, 0xFF, 0x42]), - DataValue::Array(vec![DataValue::Str("nested".to_string())]), - DataValue::Container(vec![(DataTypeId(7), DataValue::BoolTrue)]), - DataValue::Null, + fn canonical_data_value_vectors() { + let vectors = [ + (DataValue::BoolTrue, vec![0x01]), + (DataValue::BoolFalse, vec![0x02]), + ( + DataValue::Str("Hello".into()), + vec![0x06, 0, 0, 0, 5, b'H', b'e', b'l', b'l', b'o'], + ), + (DataValue::Bytes(vec![1, 2]), vec![0x07, 0, 0, 0, 2, 1, 2]), + ( + DataValue::Array(vec![ + DataValue::BoolTrue, + DataValue::Str("A".into()), + DataValue::Bytes(vec![0xFF]), + ]), + vec![ + 0x08, 0, 3, // array kind and value count + 0x01, // true + 0x06, 0, 0, 0, 1, b'A', // string + 0x07, 0, 0, 0, 1, 0xFF, // bytes + ], + ), + ( + DataValue::Container(vec![ + (DataTypeId(9), DataValue::Str("Hello".into())), + (DataTypeId(10), DataValue::BoolTrue), + ]), + vec![ + 0x09, 0, 2, // container kind and entry count + 0, 9, // field ID + 0x06, 0, 0, 0, 5, b'H', b'e', b'l', b'l', b'o', // string + 0, 10, // field ID + 0x01, // true has no payload or entry length + ], + ), ]; - for value in values { - value_roundtrip(value)?; + for (value, expected) in vectors { + assert_eq!(value.to_bytes().unwrap(), expected); + assert_eq!(DataValue::from_bytes(&expected), Some(value)); } - - #[cfg(feature = "crypto")] - for value in [ - DataValue::EncryptedContainer(vec![1, 2, 3]), - DataValue::SignedContainer(vec![4, 5, 6]), - DataValue::SignedEncryptedContainer(vec![7, 8, 9]), - ] { - value_roundtrip(value)?; - } - Ok(()) } #[test] - fn unsigned_number_conversion_rejects_i128_overflow() { + fn decode_limits_bound_recursive_values_and_blobs() { + let nested = DataValue::Array(vec![DataValue::Array(vec![DataValue::BoolTrue])]); + let bytes = nested.to_bytes().expect("nested value should encode"); + let mut limits = DecodeLimits { + max_depth: 1, + ..DecodeLimits::default() + }; + assert!(DataValue::from_bytes_with_limits(&bytes, limits).is_none()); + + let many = DataValue::Array(vec![DataValue::BoolTrue, DataValue::BoolFalse]); + let bytes = many.to_bytes().expect("array should encode"); + limits = DecodeLimits::default(); + limits.max_values = 2; + assert!(DataValue::from_bytes_with_limits(&bytes, limits).is_none()); + + let blob = DataValue::Bytes(vec![1, 2, 3]); + let bytes = blob.to_bytes().expect("blob should encode"); + limits = DecodeLimits::default(); + limits.max_blob_size = 2; + assert!(DataValue::from_bytes_with_limits(&bytes, limits).is_none()); + } + + #[test] + fn transport_decode_limits_follow_admitted_frame_size() { + let limits = DecodeLimits::for_transport_message_size(1024); + assert_eq!(limits.max_blob_size, 1020); + assert_eq!(limits.max_depth, DecodeLimits::default().max_depth); assert_eq!( - DataValue::UnsignedNumber(i128::MAX as u128).as_number(), - Some(i128::MAX) - ); - assert_eq!( - DataValue::UnsignedNumber(i128::MAX as u128 + 1).as_number(), - None + limits.max_recipients, + DecodeLimits::default().max_recipients ); } #[test] - fn test_empty_container_and_array_have_distinct_framing() { - let container = DataValue::Container(vec![]) - .to_bytes() - .expect("container should encode"); - let array = DataValue::Array(vec![]) - .to_bytes() - .expect("array should encode"); - - assert_ne!(container, array); - assert_eq!( - DataValue::from_bytes(&container), - Some(DataValue::Container(vec![])) - ); - assert_eq!( - DataValue::from_bytes(&array), - Some(DataValue::Array(vec![])) - ); + fn integer_conversions_reject_narrowing_overflow() { + assert!(i64::try_from(DataValue::SignedNumber(i64::MAX as i128 + 1)).is_err()); + assert!(i64::try_from(DataValue::SignedNumber(i64::MIN as i128 - 1)).is_err()); + assert!(u64::try_from(DataValue::UnsignedNumber(u64::MAX as u128 + 1)).is_err()); } #[test] - fn test_top_level_trailing_bytes_are_rejected() { - let mut boolean = DataValue::BoolTrue - .to_bytes() - .expect("boolean should encode"); - boolean.push(0x00); - assert!(DataValue::from_bytes(&boolean).is_none()); - - let mut number = DataValue::SignedNumber(42) - .to_bytes() - .expect("number should encode"); - number.push(0x00); - assert!(DataValue::from_bytes(&number).is_none()); - } - - #[test] - fn test_bool_in_container() -> Result<(), Box> { - let tm = TypeMap::latest(); - container_roundtrip(vec![ - ( - DataType::Id - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::BoolTrue, - ), - ( - DataType::ClientNonce - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::BoolFalse, - ), - ])?; - Ok(()) - } - - #[test] - fn test_bool_true_eq() { - assert_eq!(DataValue::BoolTrue, DataValue::Bool(true)); - assert_eq!(DataValue::BoolFalse, DataValue::Bool(false)); - assert_ne!(DataValue::BoolTrue, DataValue::Bool(false)); - } - - #[test] - fn test_bool_as_bool() { - assert_eq!(DataValue::BoolTrue.as_bool(), Some(true)); - assert_eq!(DataValue::BoolFalse.as_bool(), Some(false)); - assert_eq!(DataValue::Bool(true).as_bool(), Some(true)); - assert_eq!(DataValue::Null.as_bool(), None); - } - - #[test] - fn test_signed_number_in_container() -> Result<(), Box> { - let tm = TypeMap::latest(); - container_roundtrip(vec![ - ( - DataType::Version - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::SignedNumber(0), - ), - ( - DataType::Id - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::SignedNumber(42), - ), - ( - DataType::ClientNonce - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::SignedNumber(-42), - ), - ( - DataType::ServerNonce - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::SignedNumber(i128::MAX), - ), - ( - DataType::PublicKeys - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::SignedNumber(i128::MIN), - ), - ])?; - Ok(()) - } - - #[test] - fn test_unsigned_number_in_container() -> Result<(), Box> { - let tm = TypeMap::latest(); - container_roundtrip(vec![ - ( - DataType::Version - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::UnsignedNumber(0), - ), - ( - DataType::Id - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::UnsignedNumber(42), - ), - ( - DataType::ClientNonce - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::UnsignedNumber(u128::MAX), - ), - ])?; - Ok(()) - } - - #[test] - fn test_float_in_container() -> Result<(), Box> { - let tm = TypeMap::latest(); - container_roundtrip(vec![ - ( - DataType::Version - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::Float(0.0), - ), - ( - DataType::Id - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::Float(1_234_500.0), - ), - ( - DataType::ClientNonce - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::Float(f64::MAX), - ), - ])?; - Ok(()) - } - - #[test] - fn test_str_in_container() -> Result<(), Box> { - let tm = TypeMap::latest(); - container_roundtrip(vec![ - ( - DataType::Version - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::Str(String::new()), - ), - ( - DataType::Id - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::Str("hello".to_string()), - ), - ( - DataType::ClientNonce - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::Str("a".repeat(1000)), - ), - ])?; - Ok(()) - } - - #[test] - fn test_bytes_in_container() -> Result<(), Box> { - let tm = TypeMap::latest(); - container_roundtrip(vec![ - ( - DataType::Version - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::Bytes(vec![]), - ), - ( - DataType::Id - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::Bytes(vec![0x00, 0xFF, 0xAB]), - ), - ( - DataType::ClientNonce - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::Bytes(vec![0x42; 100]), - ), - ])?; - Ok(()) - } - - #[test] - fn test_null_in_container() -> Result<(), Box> { - let tm = TypeMap::latest(); - container_roundtrip(vec![( - DataType::Version - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::Null, - )])?; - Ok(()) - } - - #[test] - fn test_array_non_empty_roundtrip() -> Result<(), Box> { - array_roundtrip(vec![ - DataValue::BoolTrue, - DataValue::SignedNumber(42), - DataValue::Str("hello".to_string()), - DataValue::Null, - ])?; - Ok(()) - } - - #[test] - fn test_array_nested_roundtrip() -> Result<(), Box> { - array_roundtrip(vec![ - DataValue::Array(vec![DataValue::BoolTrue, DataValue::BoolFalse]), - DataValue::Array(vec![DataValue::SignedNumber(1), DataValue::SignedNumber(2)]), - ])?; - Ok(()) - } - - #[test] - fn test_container_empty_roundtrip() -> Result<(), Box> { - container_roundtrip(vec![])?; - Ok(()) - } - - #[test] - fn test_container_mixed_roundtrip() -> Result<(), Box> { - let tm = TypeMap::latest(); - container_roundtrip(vec![ - ( - DataType::Version - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::BoolTrue, - ), - ( - DataType::Id - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::SignedNumber(-100), - ), - ( - DataType::ClientNonce - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::Str("test".to_string()), - ), - ( - DataType::ServerNonce - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::UnsignedNumber(u128::MAX), - ), - ( - DataType::PublicKeys - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::Null, - ), - ])?; - Ok(()) - } - - #[test] - fn test_container_nested_roundtrip() -> Result<(), Box> { - let tm = TypeMap::latest(); - container_roundtrip(vec![ - ( - DataType::Version - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::Container(vec![( - DataType::Error - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::BoolTrue, - )]), - ), - ( - DataType::Id - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::Array(vec![DataValue::SignedNumber(1), DataValue::SignedNumber(2)]), - ), - ])?; - Ok(()) - } - - #[test] - fn test_container_base64_roundtrip() { - let tm = TypeMap::latest(); - let dv = DataValue::Container(vec![( - DataType::Description - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::Bytes(vec![0xDE, 0xAD, 0xBE, 0xEF]), - )]); - let b64 = dv.to_base64().expect("encode failed"); - let decoded = DataValue::from_base64(&b64).expect("base64 roundtrip failed"); - assert_eq!(dv, decoded); - } - - #[test] - fn test_kind_classification() { - assert_eq!(DataValue::BoolTrue.kind(), DataKind::Bool); - assert_eq!(DataValue::Bool(false).kind(), DataKind::Bool); - assert_eq!(DataValue::SignedNumber(0).kind(), DataKind::SignedNumber); - assert_eq!( - DataValue::UnsignedNumber(0).kind(), - DataKind::UnsignedNumber - ); - assert_eq!(DataValue::Float(0.0).kind(), DataKind::Float); - assert_eq!(DataValue::Str(String::new()).kind(), DataKind::Str); - assert_eq!(DataValue::Bytes(vec![]).kind(), DataKind::Bytes); - assert_eq!( - DataValue::Array(vec![]).kind(), - DataKind::Array(Box::new(DataKind::Null)) - ); - assert_eq!(DataValue::Container(vec![]).kind(), DataKind::Container); - assert_eq!(DataValue::Null.kind(), DataKind::Null); - } - - #[test] - fn test_as_accessors() { - let tm = TypeMap::latest(); - let dv = DataValue::Container(vec![ - ( - DataType::Version - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::Str("alice".to_string()), - ), - ( - DataType::Id - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::SignedNumber(42), - ), - ( - DataType::ClientNonce - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::Bytes(vec![0x01, 0x02]), - ), - ( - DataType::ServerNonce - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::Array(vec![DataValue::BoolTrue]), - ), - ]); - - let map = dv.as_map().expect("should be a container"); - assert_eq!( - map.get( - &DataType::Version - .try_to_id(&tm) - .expect("test type must be mapped") - ) - .and_then(|v| v.as_str()), - Some("alice") - ); - assert_eq!( - map.get( - &DataType::Id - .try_to_id(&tm) - .expect("test type must be mapped") - ) - .and_then(|v| v.as_signed_number()), - Some(42) - ); - assert_eq!( - map.get( - &DataType::ClientNonce - .try_to_id(&tm) - .expect("test type must be mapped") - ) - .and_then(|v| v.as_bytes()), - Some(vec![0x01, 0x02]) - ); - assert_eq!( - map.get( - &DataType::ServerNonce - .try_to_id(&tm) - .expect("test type must be mapped") - ) - .and_then(|v| v.as_array()), - Some(vec![DataValue::BoolTrue]) - ); - } - - #[test] - fn test_as_string() { - let dv = DataValue::Str("hello".to_string()); - assert_eq!(dv.as_string(), Some("hello".to_string())); - assert_eq!(dv.as_str(), Some("hello")); - assert_eq!(DataValue::Null.as_string(), None); - } - - #[test] - fn test_as_float() { - assert_eq!(DataValue::Float(-0.125).as_float(), Some(-0.125)); - assert_eq!(DataValue::Null.as_float(), None); - } - - #[test] - fn test_container_from_map() { - let tm = TypeMap::latest(); - let mut map = BTreeMap::new(); - map.insert( - DataType::Version - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::BoolTrue, - ); - map.insert( - DataType::Id - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::SignedNumber(99), - ); - let dv = DataValue::container_from_map(&map); - let container = dv.as_container().expect("should be container"); - assert_eq!(container.len(), 2); - } - - #[test] - fn test_invalid_short_input() { - assert!(DataValue::from_bytes(&[]).is_none()); - assert!(DataValue::from_bytes(&[DataValue::KIND_SIGNED_NUMBER]).is_none()); - } - - #[test] - fn test_invalid_kind_rejected() { - let bytes = vec![0x00, 0x01, 0x0D, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x41]; + fn duplicate_container_fields_are_rejected() { + let bytes = [0x09, 0, 2, 0, 1, 0x01, 0, 1, 0x02]; assert!(DataValue::from_bytes(&bytes).is_none()); - } - #[test] - fn test_truncated_container_rejected() { - let tm = TypeMap::latest(); - let dv = DataValue::Container(vec![( - DataType::Version - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::Str("hello".to_string()), - )]); - let bytes = dv.to_bytes().expect("encode failed"); - // Truncate to fewer than 2 bytes so neither container nor array can be read - assert!(DataValue::from_bytes(&bytes[..1]).is_none()); - assert!(DataValue::from_bytes(&bytes[..0]).is_none()); - } - - #[test] - fn test_oversized_count_does_not_overallocate() { - // A frame declaring 65535 entries but carrying almost no payload must be - // rejected without pre-reserving a Vec for 65535 entries. The capacity is - // capped against remaining bytes, so these decode attempts allocate at - // most a handful of slots before failing. - // Container path: count = 0xFFFF, no entries follow. - assert!(DataValue::from_bytes(&[DataValue::KIND_CONTAINER, 0xFF, 0xFF]).is_none()); - // Container path with one stray byte after the count. - assert!(DataValue::from_bytes(&[DataValue::KIND_CONTAINER, 0xFF, 0xFF, 0x01]).is_none()); - // Array path: the tagged array also declares 65535 entries but carries - // only a single entry byte. - assert!(DataValue::from_bytes(&[DataValue::KIND_ARRAY, 0xFF, 0xFF, 0x01]).is_none()); - } - - #[test] - fn test_display_basic() { - assert_eq!(format!("{}", DataValue::BoolTrue), "true"); - assert_eq!(format!("{}", DataValue::BoolFalse), "false"); - assert_eq!(format!("{}", DataValue::Null), "null"); - assert_eq!(format!("{}", DataValue::SignedNumber(42)), "42"); - assert_eq!(format!("{}", DataValue::UnsignedNumber(42)), "42"); - assert_eq!(format!("{}", DataValue::Str("hi".to_string())), "\"hi\""); - assert_eq!(format!("{}", DataValue::Bytes(vec![])), "(Binary)"); - } - - #[test] - fn test_hash_consistency() { - use std::collections::HashSet; - let mut set = HashSet::new(); - set.insert(DataValue::BoolTrue); - set.insert(DataValue::BoolFalse); - set.insert(DataValue::Null); - set.insert(DataValue::SignedNumber(1)); - set.insert(DataValue::UnsignedNumber(1)); - assert_eq!(set.len(), 5); - set.insert(DataValue::Bool(true)); - assert_eq!(set.len(), 5); - } - - #[test] - fn test_float_display() { - let s = format!("{}", DataValue::Float(1.25)); - assert_eq!(s, "1.25"); - } - - #[test] - fn test_container_display() { - let tm = TypeMap::latest(); - let dv = DataValue::Container(vec![ - ( - DataType::ServerNonce - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::Str("v2.0".to_string()), - ), - ( - DataType::PqSignature - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::UnsignedNumber(42), - ), + let value = DataValue::Container(vec![ + (DataTypeId(1), DataValue::BoolTrue), + (DataTypeId(1), DataValue::BoolFalse), ]); - let s = format!("{}", dv); - assert!(s.contains("3:")); - assert!(s.contains("6:")); + assert_eq!(value.to_bytes(), Err(CodecError::InvalidEncoding)); } #[test] - fn test_from_primitives() { - assert_eq!(DataValue::from(true), DataValue::BoolTrue); - assert_eq!(DataValue::from(false), DataValue::BoolFalse); + fn read_from_stops_at_each_self_delimiting_value() { + let bytes = [0x01, 0x03, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 42]; + let mut cursor = Cursor::new(bytes.as_slice()); + assert_eq!( - DataValue::from("hello"), - DataValue::Str("hello".to_string()) + DataValue::read_from(&mut cursor).unwrap(), + DataValue::BoolTrue ); assert_eq!( - DataValue::from("hello".to_string()), - DataValue::Str("hello".to_string()) - ); - assert_eq!(DataValue::from(42i64), DataValue::SignedNumber(42)); - assert_eq!(DataValue::from(42i128), DataValue::SignedNumber(42)); - assert_eq!(DataValue::from(42u64), DataValue::UnsignedNumber(42)); - assert_eq!(DataValue::from(42u128), DataValue::UnsignedNumber(42)); - assert_eq!( - DataValue::from(vec![1u8, 2, 3]), - DataValue::Bytes(vec![1, 2, 3]) - ); - assert_eq!( - DataValue::from([1u8, 2, 3].as_ref()), - DataValue::Bytes(vec![1, 2, 3]) + DataValue::read_from(&mut cursor).unwrap(), + DataValue::SignedNumber(42) ); + assert_eq!(cursor.position() as usize, bytes.len()); } #[test] - fn test_try_from_ok() -> Result<(), Box> { - assert!(bool::try_from(DataValue::BoolTrue)?); - assert!(!bool::try_from(DataValue::BoolFalse)?); - assert_eq!(String::try_from(DataValue::Str("hi".to_string()))?, "hi"); - assert_eq!(i128::try_from(DataValue::SignedNumber(-1))?, -1i128); - assert_eq!(i64::try_from(DataValue::SignedNumber(10))?, 10i64); - assert_eq!(u128::try_from(DataValue::UnsignedNumber(99))?, 99u128); - assert_eq!(u64::try_from(DataValue::UnsignedNumber(7))?, 7u64); - assert_eq!( - Vec::::try_from(DataValue::Bytes(vec![0xAB]))?, - vec![0xABu8] - ); - Ok(()) + fn removed_signed_encrypted_container_kind_is_rejected() { + assert_eq!(DataValue::from_bytes(&[0x0C]), None); } - #[test] - fn test_try_from_err() { - assert!(bool::try_from(DataValue::Null).is_err()); - assert!(String::try_from(DataValue::SignedNumber(1)).is_err()); - assert!(i128::try_from(DataValue::BoolTrue).is_err()); - assert!(u128::try_from(DataValue::Str("x".to_string())).is_err()); - assert!(Vec::::try_from(DataValue::Null).is_err()); - } - - #[test] - fn test_array_display() { - let dv = DataValue::Array(vec![DataValue::SignedNumber(1), DataValue::SignedNumber(2)]); - let s = format!("{}", dv); - assert_eq!(s, "[1, 2]"); - } - - /* ===== Crypto container tests ===== */ - #[cfg(feature = "crypto")] #[test] - fn test_encrypt_decrypt_container_roundtrip() -> Result<(), Box> { - use mtp_crypto::{EncryptionType, Keyring}; - let tm = TypeMap::latest(); - let keyring = Keyring::generate(); - let bundle = keyring.public_key_bundle(); + fn signed_values_have_canonical_layout_and_verify() -> Result<(), Box> { + use mtp_crypto::{Ed25519Signer, Keyring, SigAlgorithm}; - let mut dv = DataValue::Container(vec![ - ( - DataType::Version - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::Str("secret".to_string()), - ), - ( - DataType::Id - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::UnsignedNumber(42), - ), + let (signer, _, signer_public) = Ed25519Signer::generate(); + let mut public_keys = Keyring::generate().public_key_bundle(); + public_keys.sig_cl_public_key = signer_public; + + let original = DataValue::Container(vec![ + (DataTypeId(20), DataValue::BoolTrue), + (DataTypeId(21), DataValue::Str("signed".into())), ]); + let inner = original.to_bytes()?; + let signed = original.clone().sign( + 0x0102_0304_0506_0708, + ProtectionPurpose::from(0xA5), + &signer, + )?; + let encoded = signed.to_bytes()?; - assert!( - dv.encrypt_container(EncryptionType::MlKemChaCha20Poly1305, &bundle, b"aad") - .is_some() + let wrapper_len = u32::from_be_bytes(encoded[1..5].try_into()?) as usize; + let signature_len = SigAlgorithm::length(SigAlgorithm::ED25519).unwrap(); + assert_eq!(encoded[0], DataValue::KIND_SIGNED); + assert_eq!(wrapper_len, encoded.len() - 5); + assert_eq!(encoded[5], SigAlgorithm::ED25519); + assert_eq!(encoded[6], 0xA5); + assert_eq!(&encoded[7..15], &0x0102_0304_0506_0708u64.to_be_bytes()); + assert_eq!(&encoded[15 + signature_len..], inner); + + let decoded = DataValue::from_bytes(&encoded).ok_or("signed value did not decode")?; + decoded.verify( + 0x0102_0304_0506_0708, + &public_keys, + ProtectionPurpose::from(0xA5), + )?; + assert!(matches!( + decoded.clone().into_verified_with_policy( + 0x0102_0304_0506_0708, + &public_keys, + ProtectionPurpose::from(0xA5), + ProtectionPolicy::from(SignaturePolicy::Dual), + ), + Err(ProtectionError::SignaturePolicyMismatch { .. }) + )); + // Verification is non-consuming, so it can safely be repeated. + decoded.verify( + 0x0102_0304_0506_0708, + &public_keys, + ProtectionPurpose::from(0xA5), + )?; + assert_eq!( + decoded.clone().into_verified( + 0x0102_0304_0506_0708, + &public_keys, + ProtectionPurpose::from(0xA5), + )?, + original ); - assert!(matches!(dv, DataValue::EncryptedContainer(_))); - assert!(dv.decrypt_into_container(&keyring, b"aad").is_some()); - assert!(matches!(dv, DataValue::Container(_))); - - let entries = dv.as_container().ok_or("expected container")?; - assert_eq!(entries.len(), 2); + let DataValue::Signed(wrapper) = decoded else { + return Err("expected signed value".into()); + }; + assert_eq!( + wrapper.into_verified( + 0x0102_0304_0506_0708, + &public_keys, + ProtectionPurpose::from(0xA5), + )?, + original + ); Ok(()) } #[cfg(feature = "crypto")] #[test] - fn test_encrypt_container_wrong_key_fails() { - use mtp_crypto::{EncryptionType, Keyring}; - let tm = TypeMap::latest(); - let keyring_a = Keyring::generate(); - let keyring_b = Keyring::generate(); + fn signed_value_authenticates_its_metadata_and_inner_value() + -> Result<(), Box> { + use mtp_crypto::{Ed25519Signer, Keyring}; - let mut dv = DataValue::Container(vec![( - DataType::Version - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::Str("secret".to_string()), - )]); + let (signer, _, signer_public) = Ed25519Signer::generate(); + let mut public_keys = Keyring::generate().public_key_bundle(); + public_keys.sig_cl_public_key = signer_public; + let signed = + DataValue::Str("original".into()).sign(41, ProtectionPurpose::from(7), &signer)?; - assert!( - dv.encrypt_container( - EncryptionType::MlKemChaCha20Poly1305, - &keyring_a.public_key_bundle(), - b"aad" - ) - .is_some() - ); - assert!(dv.decrypt_into_container(&keyring_b, b"aad").is_none()); - } + let DataValue::Signed(mut wrong_purpose) = signed.clone() else { + return Err("expected signed value".into()); + }; + wrong_purpose.purpose ^= 1; + assert!(matches!( + wrong_purpose.verify(41, &public_keys, ProtectionPurpose::from(7)), + Err(ProtectionError::PurposeMismatch { .. }) + )); - #[cfg(feature = "crypto")] - #[test] - fn test_encrypt_container_wrong_aad_fails() { - use mtp_crypto::{EncryptionType, Keyring}; - let tm = TypeMap::latest(); - let keyring = Keyring::generate(); + let DataValue::Signed(mut wrong_signer_id) = signed.clone() else { + return Err("expected signed value".into()); + }; + wrong_signer_id.signer_id ^= 1; + assert!(matches!( + wrong_signer_id.verify(41, &public_keys, ProtectionPurpose::from(7)), + Err(ProtectionError::SignerIdMismatch { .. }) + )); - let mut dv = DataValue::Container(vec![( - DataType::Version - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::Str("secret".to_string()), - )]); + let DataValue::Signed(mut wrong_signature) = signed.clone() else { + return Err("expected signed value".into()); + }; + wrong_signature.signature[0] ^= 1; + assert!(matches!( + wrong_signature.verify(41, &public_keys, ProtectionPurpose::from(7)), + Err(ProtectionError::InvalidSignature) + )); - assert!( - dv.encrypt_container( - EncryptionType::MlKemChaCha20Poly1305, - &keyring.public_key_bundle(), - b"correct-aad" - ) - .is_some() - ); - assert!(dv.decrypt_into_container(&keyring, b"wrong-aad").is_none()); - } - - #[cfg(feature = "crypto")] - #[test] - fn test_encrypt_non_container_fails() { - use mtp_crypto::{EncryptionType, Keyring}; - let keyring = Keyring::generate(); - - let mut dv = DataValue::Str("not a container".to_string()); - assert!( - dv.encrypt_container( - EncryptionType::MlKemChaCha20Poly1305, - &keyring.public_key_bundle(), - b"aad" - ) - .is_none() - ); - } - - #[cfg(feature = "crypto")] - #[test] - fn test_sign_verify_container_roundtrip() -> Result<(), Box> { - use mtp_crypto::{Ed25519Signer, EncryptionType, Keyring, SigAlgorithm}; - let tm = TypeMap::latest(); - - let keyring = Keyring::generate(); - let (signer, sk, _pk) = Ed25519Signer::generate(); - - let mut dv = DataValue::Container(vec![( - DataType::Version - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::Str("signed data".to_string()), - )]); - - assert!( - dv.sign_and_encrypt_container( - SigAlgorithm::ED25519, - &signer, - EncryptionType::MlKemChaCha20Poly1305, - &keyring.public_key_bundle(), - b"aad" - ) - .is_some() - ); - assert!(matches!(dv, DataValue::SignedEncryptedContainer(_))); - - assert!( - dv.decrypt_signed_encrypted_container(&keyring, b"aad") - .is_some() - ); - assert!(matches!(dv, DataValue::SignedContainer(_))); - - let verifier = Ed25519Signer::new(&sk)?; - assert!(dv.verify_into_container(&verifier).is_some()); - assert!(matches!(dv, DataValue::Container(_))); - - let entries = dv.as_container().ok_or("expected container")?; - assert_eq!(entries.len(), 1); + let DataValue::Signed(mut wrong_value) = signed else { + return Err("expected signed value".into()); + }; + *wrong_value.value = DataValue::Str("replacement".into()); + assert!(matches!( + wrong_value.verify(41, &public_keys, ProtectionPurpose::from(7)), + Err(ProtectionError::InvalidSignature) + )); Ok(()) } #[cfg(feature = "crypto")] #[test] - fn test_sign_container_wrong_key_fails() -> Result<(), Box> { - use mtp_crypto::{Ed25519Signer, SigAlgorithm}; - let tm = TypeMap::latest(); + fn signing_rejects_an_unknown_algorithm_or_wrong_signature_size() { + use mtp_crypto::{CryptoError, SigAlgorithm, SignatureScheme}; - let (signer, _, _) = Ed25519Signer::generate(); - let (_, sk2, _) = Ed25519Signer::generate(); - let wrong_verifier = Ed25519Signer::new(&sk2)?; + struct InvalidSigner(u8); - let mut dv = DataValue::Container(vec![( - DataType::Version - .try_to_id(&tm) - .expect("test type must be mapped"), - DataValue::Str("signed data".to_string()), - )]); + impl SignatureScheme for InvalidSigner { + fn algorithm(&self) -> u8 { + self.0 + } - assert!(dv.sign_container(SigAlgorithm::ED25519, &signer).is_some()); - assert!(dv.verify_into_container(&wrong_verifier).is_none()); + fn sign(&self, _: &[u8]) -> Result, CryptoError> { + Ok(vec![0; 63]) + } + + fn verify(&self, _: &[u8], _: &[u8]) -> Result<(), CryptoError> { + Ok(()) + } + } + + assert!(matches!( + DataValue::Null.sign( + 1, + ProtectionPurpose::from(1), + &InvalidSigner(SigAlgorithm::ED25519) + ), + Err(ProtectionError::Malformed) + )); + assert!(matches!( + DataValue::Null.sign(1, ProtectionPurpose::from(1), &InvalidSigner(0xFE)), + Err(ProtectionError::Malformed) + )); + } + + #[cfg(feature = "crypto")] + #[test] + fn signed_then_encrypted_composition_roundtrips() -> Result<(), Box> { + use mtp_crypto::{Ed25519Signer, Keyring}; + + let (signer, _, signer_public) = Ed25519Signer::generate(); + let keyring = Keyring::generate(); + let value = DataValue::Container(vec![(DataTypeId(32), DataValue::Str("secret".into()))]); + let protected = value + .clone() + .sign(7, ProtectionPurpose::from(1), &signer)? + .encrypt_for( + std::slice::from_ref(&keyring.public_key_bundle()), + ProtectionPurpose::from(2), + )?; + + let encoded = protected.to_bytes()?; + assert_eq!(encoded[0], DataValue::KIND_ENCRYPTED); + let decoded = DataValue::from_bytes(&encoded).ok_or("protected value did not decode")?; + let opened = decoded.decrypt(&keyring, ProtectionPurpose::from(2))?; + let mut public_keys = keyring.public_key_bundle(); + public_keys.sig_cl_public_key = signer_public; + opened.verify(7, &public_keys, ProtectionPurpose::from(1))?; + assert_eq!( + opened.into_verified(7, &public_keys, ProtectionPurpose::from(1))?, + value + ); Ok(()) } + + #[cfg(feature = "crypto")] + #[test] + fn encrypted_then_signed_composition_roundtrips_and_exposes_signer() + -> Result<(), Box> { + use mtp_crypto::{Ed25519Signer, Keyring}; + + let (signer, _, signer_public) = Ed25519Signer::generate(); + let keyring = Keyring::generate(); + let value = DataValue::Container(vec![(DataTypeId(32), DataValue::Str("secret".into()))]); + let protected = value + .clone() + .encrypt_for( + std::slice::from_ref(&keyring.public_key_bundle()), + ProtectionPurpose::from(2), + )? + .sign(7, ProtectionPurpose::from(1), &signer)?; + + let encoded = protected.to_bytes()?; + assert_eq!(encoded[0], DataValue::KIND_SIGNED); + let decoded = DataValue::from_bytes(&encoded).ok_or("protected value did not decode")?; + let mut public_keys = keyring.public_key_bundle(); + public_keys.sig_cl_public_key = signer_public; + + // The signer metadata is available before opening the encrypted value. + let DataValue::Signed(signed) = decoded else { + return Err("expected signed outer wrapper".into()); + }; + assert_eq!(signed.signer_id, 7); + signed.verify(7, &public_keys, ProtectionPurpose::from(1))?; + let encrypted = signed.into_verified(7, &public_keys, ProtectionPurpose::from(1))?; + assert!(matches!(encrypted, DataValue::Encrypted(_))); + assert_eq!( + encrypted.decrypt(&keyring, ProtectionPurpose::from(2))?, + value + ); + Ok(()) + } + + #[cfg(feature = "crypto")] + #[test] + fn deeply_nested_protection_composition_roundtrips() -> Result<(), Box> { + use mtp_crypto::{Ed25519Signer, Keyring}; + + const OUTER_SIGNER_ID: u64 = 0x0102_0304_0506_0708; + const INNER_SIGNER_ID: u64 = 0x1112_1314_1516_1718; + + let (signer, _, signer_public) = Ed25519Signer::generate(); + let outer_recipient = Keyring::generate(); + let inner_recipient = Keyring::generate(); + let leaf = + DataValue::Container(vec![(DataTypeId(60), DataValue::Str("deep secret".into()))]); + let nested = leaf + .clone() + .sign(INNER_SIGNER_ID, ProtectionPurpose::from(3), &signer)? + .encrypt_for( + std::slice::from_ref(&inner_recipient.public_key_bundle()), + ProtectionPurpose::from(4), + )?; + let middle = DataValue::Container(vec![(DataTypeId(50), nested)]); + let protected = middle + .clone() + .sign(OUTER_SIGNER_ID, ProtectionPurpose::from(1), &signer)? + .encrypt_for( + std::slice::from_ref(&outer_recipient.public_key_bundle()), + ProtectionPurpose::from(2), + )?; + + let encoded = protected.to_bytes()?; + let decoded = DataValue::from_bytes(&encoded).ok_or("nested value did not decode")?; + assert!(matches!(decoded, DataValue::Encrypted(_))); + + let outer_signed = decoded.decrypt(&outer_recipient, ProtectionPurpose::from(2))?; + let DataValue::Signed(outer_wrapper) = &outer_signed else { + return Err("expected signed value inside outer encryption".into()); + }; + assert_eq!(outer_wrapper.signer_id, OUTER_SIGNER_ID); + + let mut signer_keys = outer_recipient.public_key_bundle(); + signer_keys.sig_cl_public_key = signer_public; + let middle = outer_signed.into_verified( + OUTER_SIGNER_ID, + &signer_keys, + ProtectionPurpose::from(1), + )?; + let DataValue::Container(entries) = middle else { + return Err("expected container inside outer signature".into()); + }; + let nested = entries + .into_iter() + .find_map(|(id, value)| (id == DataTypeId(50)).then_some(value)) + .ok_or("nested field missing")?; + assert!(matches!(nested, DataValue::Encrypted(_))); + + let inner_signed = nested.decrypt(&inner_recipient, ProtectionPurpose::from(4))?; + let DataValue::Signed(inner_wrapper) = &inner_signed else { + return Err("expected signed value inside nested encryption".into()); + }; + assert_eq!(inner_wrapper.signer_id, INNER_SIGNER_ID); + assert_eq!( + inner_signed.into_verified( + INNER_SIGNER_ID, + &signer_keys, + ProtectionPurpose::from(3), + )?, + leaf + ); + Ok(()) + } + + #[cfg(feature = "crypto")] + #[test] + fn encrypted_authenticated_purpose_cannot_be_changed() -> Result<(), Box> + { + use mtp_crypto::Keyring; + + let keyring = Keyring::generate(); + let value = DataValue::Bytes(vec![1, 2, 3]).encrypt_for( + std::slice::from_ref(&keyring.public_key_bundle()), + ProtectionPurpose::from(9), + )?; + let DataValue::Encrypted(mut encrypted) = value else { + return Err("expected encrypted value".into()); + }; + encrypted.purpose ^= 1; + assert!( + DataValue::Encrypted(encrypted) + .decrypt(&keyring, ProtectionPurpose::from(9)) + .is_err() + ); + Ok(()) + } + + #[cfg(feature = "crypto")] + #[test] + fn encrypted_values_use_one_authenticated_envelope_for_all_recipients() + -> Result<(), Box> { + use mtp_crypto::Keyring; + + let recipient_a = Keyring::generate(); + let recipient_b = Keyring::generate(); + let recipient_c = Keyring::generate(); + let original = DataValue::Container(vec![ + (DataTypeId(40), DataValue::Str("shared secret".into())), + (DataTypeId(41), DataValue::UnsignedNumber(42)), + ]); + let inner = original.to_bytes()?; + let purpose = ProtectionPurpose::from(0xA5); + let encrypted = original.clone().encrypt_for( + &[ + recipient_a.public_key_bundle(), + recipient_b.public_key_bundle(), + recipient_c.public_key_bundle(), + ], + purpose, + )?; + + let DataValue::Encrypted(value) = &encrypted else { + return Err("expected encrypted value".into()); + }; + let suite = value.encryption_type; + assert_eq!(value.recipients.len(), 3); + assert!( + value + .recipients + .iter() + .all( + |entry| entry.kem_ciphertext.len() == suite.kem_ciphertext_len() + && entry.encrypted_key.len() == suite.wrapped_key_len() + ) + ); + + let encoded = encrypted.to_bytes()?; + let envelope_len = u32::from_be_bytes(encoded[1..5].try_into()?) as usize; + assert_eq!(encoded[0], DataValue::KIND_ENCRYPTED); + assert_eq!(envelope_len, encoded.len() - 5); + assert_eq!(encoded[5], suite.to_byte()); + assert_eq!(encoded[6], purpose.0); + assert_eq!(u16::from_be_bytes(encoded[7..9].try_into()?), 3); + assert_eq!( + envelope_len, + 4 + 3 * (suite.kem_ciphertext_len() + suite.wrapped_key_len()) + + suite.encrypted_len(inner.len()) + ); + + for keyring in [&recipient_a, &recipient_b, &recipient_c] { + assert_eq!(encrypted.decrypt(keyring, purpose)?, original); + } + assert!(matches!( + encrypted.decrypt(&Keyring::generate(), purpose), + Err(ProtectionError::NoMatchingRecipient) + )); + Ok(()) + } + + #[cfg(feature = "crypto")] + #[test] + fn encrypted_recipient_table_is_authenticated() -> Result<(), Box> { + use mtp_crypto::{CryptoError, Keyring}; + + let recipient_a = Keyring::generate(); + let recipient_b = Keyring::generate(); + let encrypted = DataValue::Str("secret".into()).encrypt_for( + &[ + recipient_a.public_key_bundle(), + recipient_b.public_key_bundle(), + ], + ProtectionPurpose::from(1), + )?; + let DataValue::Encrypted(mut value) = encrypted else { + return Err("expected encrypted value".into()); + }; + + // Keep recipient A's wrapped CEK valid. Altering B's table entry must + // still invalidate the payload because that complete table is AAD. + value.recipients[1].encrypted_key[0] ^= 1; + assert!(matches!( + DataValue::Encrypted(value).decrypt(&recipient_a, ProtectionPurpose::from(1)), + Err(ProtectionError::Crypto(CryptoError::DecryptionFailed)) + )); + Ok(()) + } + + #[cfg(feature = "crypto")] + #[test] + fn decryption_rejects_a_trailing_inner_value() -> Result<(), Box> { + use mtp_crypto::{EncryptionType, Keyring}; + + let recipient = Keyring::generate(); + let original = DataValue::BoolTrue; + let mut plaintext = original.to_bytes()?; + plaintext.push(DataValue::KIND_NULL); + let message = mtp_crypto::encrypt_multi_for( + EncryptionType::MlKemChaCha20Poly1305, + 3, + &plaintext, + std::slice::from_ref(&recipient.public_key_bundle()), + )?; + let encrypted = DataValue::Encrypted(EncryptedValue { + encryption_type: message.encryption_type, + purpose: message.purpose, + recipients: message.recipients, + ciphertext: message.ciphertext, + }); + + assert!(matches!( + encrypted.decrypt(&recipient, ProtectionPurpose::from(3)), + Err(ProtectionError::Malformed) + )); + Ok(()) + } + + #[cfg(feature = "crypto")] + #[test] + fn protection_operations_preserve_failure_reasons() -> Result<(), Box> { + use mtp_crypto::{EncryptionType, Keyring}; + + let keyring = Keyring::generate(); + let public_keys = keyring.public_key_bundle(); + + assert!(matches!( + DataValue::Null.verify(1, &public_keys, ProtectionPurpose::from(1)), + Err(ProtectionError::NotSigned) + )); + assert!(matches!( + DataValue::Null.into_verified(1, &public_keys, ProtectionPurpose::from(1)), + Err(ProtectionError::NotSigned) + )); + assert!(matches!( + DataValue::Null.decrypt(&keyring, ProtectionPurpose::from(1)), + Err(ProtectionError::NotEncrypted) + )); + assert!(matches!( + DataValue::Null.encrypt_for(&[], ProtectionPurpose::from(1)), + Err(ProtectionError::Crypto( + mtp_crypto::CryptoError::NoRecipients + )) + )); + + let malformed_encrypted = DataValue::Encrypted(EncryptedValue { + encryption_type: EncryptionType::MlKemChaCha20Poly1305, + purpose: 1, + recipients: Vec::new(), + ciphertext: Vec::new(), + }); + assert!(matches!( + malformed_encrypted.decrypt(&keyring, ProtectionPurpose::from(1)), + Err(ProtectionError::Malformed) + )); + + let (signer, _, signer_public) = mtp_crypto::Ed25519Signer::generate(); + let mut signing_keys = keyring.public_key_bundle(); + signing_keys.sig_cl_public_key = signer_public; + + let duplicate_fields = DataValue::Container(vec![ + (DataTypeId(1), DataValue::Null), + (DataTypeId(1), DataValue::Null), + ]); + assert!(matches!( + duplicate_fields.sign(1, ProtectionPurpose::from(1), &signer), + Err(ProtectionError::Codec(CodecError::InvalidEncoding)) + )); + + let signed = DataValue::Null.sign(1, ProtectionPurpose::from(1), &signer)?; + let DataValue::Signed(mut signed) = signed else { + return Err("expected signed value".into()); + }; + signed.signature[0] ^= 1; + assert!(matches!( + DataValue::Signed(signed).verify(1, &signing_keys, ProtectionPurpose::from(1)), + Err(ProtectionError::InvalidSignature) + )); + + Ok(()) + } + + #[cfg(feature = "crypto")] + #[test] + fn application_purposes_cannot_collide_with_mtp_registry() { + assert!( + ApplicationProtectionPurpose::new( + MtpProtectionPurpose::RelayMetadataEncryption.value() + ) + .is_err() + ); + let application = ApplicationProtectionPurpose::new(0x40).expect("application purpose"); + assert_eq!(ProtectionPurpose::from(application).0, 0x40); + } } diff --git a/codec/src/lib.rs b/codec/src/lib.rs index fa354f6..7249251 100644 --- a/codec/src/lib.rs +++ b/codec/src/lib.rs @@ -1,11 +1,31 @@ pub mod communication_value; pub mod data_value; - #[cfg(feature = "crypto")] -pub use communication_value::EncryptedPayload; -pub use communication_value::{CommunicationValue, MAX_WIRE_ID}; -pub use data_value::{DataKind, DataValue}; -pub use mtp_common::CodecError; +pub mod protected; +#[cfg(feature = "crypto")] +pub mod relay; + +pub use communication_value::CommunicationValue; +#[cfg(feature = "crypto")] +pub use data_value::{ + ApplicationProtectionPurpose, EncryptedValue, MtpProtectionPurpose, ProtectionError, + ProtectionPolicy, ProtectionPurpose, ProtectionPurposeError, SignaturePolicy, SignedValue, +}; +pub use data_value::{DataKind, DataValue, DecodeLimits}; +pub use mtp_common::{CodecError, TimeError, unix_time_millis}; +#[cfg(feature = "crypto")] +pub use protected::{ + CURRENT_PROTECTED_VERSION, InMemoryReplayGuard, ProtectedError, ProtectedMessageBuilder, + ReplayError, ReplayGuard, VerifiedProtectedMessage, open_protected, open_protected_with, + open_protected_with_keys, protected_claimed_signer_id, +}; +#[cfg(feature = "crypto")] +pub use relay::{ + CURRENT_RELAY_VERSION, RelayError, SealedRelayBuilder, VerifiedRelayContent, + VerifiedRelayMetadata, forward_relay_frame, open_relay_content, + open_relay_content_with_keyrings, open_relay_content_with_keys, open_relay_metadata, + open_relay_metadata_with, open_relay_metadata_with_keys, relay_metadata_claimed_signer_id, +}; pub use mtp_type_map::{ CommunicationType, CommunicationTypeId, DataType, DataTypeId, PROTOCOL_VERSION, TypeMap, @@ -13,7 +33,12 @@ pub use mtp_type_map::{ }; pub(crate) fn rand_u32() -> u32 { - rand::random() + loop { + let value = rand::random(); + if value != 0 { + return value; + } + } } #[cfg(feature = "registry")] diff --git a/codec/src/protected.rs b/codec/src/protected.rs new file mode 100644 index 0000000..f423ec9 --- /dev/null +++ b/codec/src/protected.rs @@ -0,0 +1,1186 @@ +//! Native codec for direct protected application messages. +//! +//! The protected envelope is an MTP protocol structure. Keeping its schema, +//! version checks, and routing authentication here gives native applications +//! and language bindings one implementation to consume. + +#![cfg(feature = "crypto")] + +use mtp_crypto::{Keyring, PublicKeyBundle, SignatureScheme}; +use mtp_type_map::{CommunicationType, DataType, TypeMap}; +use std::collections::HashSet; + +use crate::{CommunicationValue, DataValue, ProtectionError, ProtectionPolicy, ProtectionPurpose}; + +/// The direct protected-message envelope schema version emitted by this +/// codec. +pub const CURRENT_PROTECTED_VERSION: u64 = 1; + +#[derive(Debug, thiserror::Error)] +pub enum ProtectedError { + #[error("value is not an application communication frame")] + NotApplicationFrame, + #[error("protected frame must contain an explicit receiver")] + MissingReceiver, + #[error("protected frame payload is not encrypted")] + PayloadNotEncrypted, + #[error("protected frame payload is not signed")] + PayloadNotSigned, + #[error("protected payload does not contain an MTP envelope")] + MissingEnvelope, + #[error("protected frame has an invalid protected layout: {0}")] + InvalidLayout(&'static str), + #[error("protected message does not declare a protected version")] + MissingProtectedVersion, + #[error("unsupported protected message version {0}")] + UnsupportedProtectedVersion(u64), + #[error("protected message type does not match outer routing")] + MessageTypeMismatch, + #[error("protected final recipient does not match outer routing receiver")] + FinalRecipientMismatch, + #[error("protected frame sender does not match authenticated signer")] + SenderMismatch, + #[error("protected receiver does not match the expected recipient")] + ExpectedReceiverMismatch, + #[error("protected application communication type is reserved: {0}")] + ReservedApplicationType(String), + #[error("protected message was already accepted")] + Replay, + #[error("protection error: {0}")] + Protection(#[from] ProtectionError), + #[error("replay guard error: {0}")] + ReplayGuard(#[from] ReplayError), +} + +/// A storage-backed caller hook for authenticated message deduplication. +/// +/// The codec deliberately does not decide where durable state lives. An +/// application can implement this over persistent storage. The key is the +/// authenticated `(signer_id, message_id)` pair, never the transport-visible +/// communication ID. `created_at` is supplied as authenticated retention +/// metadata; implementations must not use it as the replay identity. +pub trait ReplayGuard { + /// Return `true` when the message is new and has been recorded. Return + /// `false` for a message that was already recorded. + fn accept( + &mut self, + signer_id: u64, + message_id: &str, + created_at: u64, + ) -> Result; +} + +#[derive(Debug, thiserror::Error)] +pub enum ReplayError { + #[error("replay store error: {0}")] + Store(String), +} + +/// Small in-memory guard useful for tests and short-lived clients. Production +/// consumers should implement [`ReplayGuard`] over persistent storage. +#[derive(Debug, Default)] +pub struct InMemoryReplayGuard { + accepted: HashSet<(u64, String)>, +} + +impl ReplayGuard for InMemoryReplayGuard { + fn accept( + &mut self, + signer_id: u64, + message_id: &str, + _created_at: u64, + ) -> Result { + Ok(self.accepted.insert((signer_id, message_id.to_owned()))) + } +} + +/// A direct protected-message builder shared by native applications and +/// language bindings. +pub struct ProtectedMessageBuilder<'a> { + message_type: String, + content: DataValue, + signer_id: u64, + final_recipient_id: u64, + message_id: Option, + created_at: Option, + signer: &'a dyn SignatureScheme, + recipients: Vec, + signature_purpose: ProtectionPurpose, + encryption_purpose: ProtectionPurpose, + type_map: Option, + frame_id: Option, + expose_sender: bool, +} + +impl<'a> ProtectedMessageBuilder<'a> { + pub fn new( + message_type: impl Into, + content: DataValue, + signer_id: u64, + final_recipient_id: u64, + signer: &'a dyn SignatureScheme, + signature_purpose: ProtectionPurpose, + encryption_purpose: ProtectionPurpose, + ) -> Self { + Self { + message_type: message_type.into(), + content, + signer_id, + final_recipient_id, + message_id: None, + created_at: None, + signer, + recipients: Vec::new(), + signature_purpose, + encryption_purpose, + type_map: None, + frame_id: None, + expose_sender: false, + } + } + + pub fn message_id(mut self, value: impl Into) -> Self { + self.message_id = Some(value.into()); + self + } + + /// Set `CreatedAt` as Unix epoch milliseconds. + pub fn created_at(mut self, value: u64) -> Self { + self.created_at = Some(value); + self + } + + pub fn recipients(mut self, recipients: Vec) -> Self { + self.recipients = recipients; + self + } + + /// Build against an explicitly negotiated type map. + pub fn type_map(mut self, type_map: &TypeMap) -> Self { + self.type_map = Some(type_map.clone()); + self + } + + /// Set the clear outer MTP frame ID. The protected envelope's + /// authenticated `MessageId` remains independent from this transport + /// correlation field. + pub fn frame_id(mut self, value: u32) -> Self { + self.frame_id = Some(value); + self + } + + /// Include the authenticated signer ID in the clear outer frame sender + /// field. The default keeps the outer sender hidden. + pub fn expose_sender(mut self, expose: bool) -> Self { + self.expose_sender = expose; + self + } + + pub fn build(self) -> Result { + let message_id = self.message_id.ok_or(ProtectedError::InvalidLayout( + "protected builder requires a message ID", + ))?; + let created_at = self.created_at.ok_or(ProtectedError::InvalidLayout( + "protected builder requires a creation timestamp", + ))?; + if self.message_type.is_empty() || message_id.is_empty() { + return Err(ProtectedError::InvalidLayout( + "protected identifiers must be non-empty", + )); + } + if self.recipients.is_empty() { + return Err(ProtectedError::InvalidLayout( + "protected builder requires at least one recipient", + )); + } + + let type_map = self.type_map.unwrap_or_else(TypeMap::latest); + let application_type = validate_application_message_type(&self.message_type, &type_map)?; + let protected_version_id = protected_field_id(DataType::ProtectedVersion, &type_map)?; + let message_type_id = protected_field_id(DataType::MessageType, &type_map)?; + let final_recipient_id = protected_field_id(DataType::FinalRecipientId, &type_map)?; + let message_id_id = protected_field_id(DataType::MessageId, &type_map)?; + let created_at_id = protected_field_id(DataType::CreatedAt, &type_map)?; + let content_id = protected_field_id(DataType::Content, &type_map)?; + + let envelope = DataValue::Container(vec![ + ( + protected_version_id, + DataValue::UnsignedNumber(CURRENT_PROTECTED_VERSION as u128), + ), + (message_type_id, DataValue::Str(self.message_type)), + ( + final_recipient_id, + DataValue::UnsignedNumber(self.final_recipient_id as u128), + ), + (message_id_id, DataValue::Str(message_id)), + (created_at_id, DataValue::UnsignedNumber(created_at as u128)), + (content_id, self.content), + ]); + let signed = envelope.sign(self.signer_id, self.signature_purpose, self.signer)?; + let encrypted = signed.encrypt_for(&self.recipients, self.encryption_purpose)?; + + let mut frame = CommunicationValue::new_with_type_map(application_type, &type_map) + .with_receiver(self.final_recipient_id) + .with_payload(encrypted); + if let Some(frame_id) = self.frame_id { + frame = frame.with_id(frame_id); + } + if self.expose_sender { + frame = frame.with_sender(self.signer_id); + } + Ok(frame) + } +} + +/// A direct protected message after the encrypted envelope and its signature +/// have been authenticated. +#[derive(Debug, Clone, PartialEq)] +pub struct VerifiedProtectedMessage { + pub protected_version: u64, + pub signer_id: u64, + pub final_recipient_id: u64, + pub message_id: String, + pub created_at: u64, + pub message_type: String, + pub content: DataValue, + pub matched_signer_key_index: usize, +} + +fn protected_field_id( + data_type: DataType, + type_map: &TypeMap, +) -> Result { + data_type + .try_to_id(type_map) + .ok_or(ProtectedError::InvalidLayout( + "reserved protected type is unavailable", + )) +} + +fn validate_application_message_type( + message_type: &str, + type_map: &TypeMap, +) -> Result { + let communication_type = CommunicationType::from_name(message_type).ok_or( + ProtectedError::InvalidLayout("protected message type is unknown"), + )?; + let communication_id = + communication_type + .try_to_id(type_map) + .ok_or(ProtectedError::InvalidLayout( + "protected message type is unavailable", + ))?; + if communication_id.is_reserved() { + return Err(ProtectedError::ReservedApplicationType( + message_type.to_owned(), + )); + } + Ok(communication_type) +} + +fn validate_protected_frame(frame: &CommunicationValue) -> Result { + let type_map = frame.type_map().cloned().unwrap_or_else(TypeMap::latest); + let Some(communication_type) = frame.get_comm_type_enum() else { + return Err(ProtectedError::NotApplicationFrame); + }; + let communication_id = communication_type + .try_to_id(&type_map) + .ok_or(ProtectedError::NotApplicationFrame)?; + if communication_id.is_reserved() { + return Err(ProtectedError::ReservedApplicationType( + communication_type.name().to_owned(), + )); + } + if frame.receiver().is_none() { + return Err(ProtectedError::MissingReceiver); + } + Ok(type_map) +} + +fn field<'a>( + value: &'a DataValue, + data_type: DataType, + type_map: &TypeMap, +) -> Result<&'a DataValue, ProtectedError> { + value + .get_field(protected_field_id(data_type, type_map)?) + .ok_or(ProtectedError::InvalidLayout( + "required protected field is missing", + )) +} + +fn unsigned_field( + value: &DataValue, + data_type: DataType, + type_map: &TypeMap, +) -> Result { + field(value, data_type, type_map)? + .as_unsigned_number() + .ok_or(ProtectedError::InvalidLayout( + "protected field is not unsigned", + )) +} + +fn string_field( + value: &DataValue, + data_type: DataType, + type_map: &TypeMap, +) -> Result { + field(value, data_type, type_map)? + .as_string() + .filter(|value| !value.is_empty()) + .ok_or(ProtectedError::InvalidLayout( + "protected field is not a non-empty string", + )) +} + +fn protected_version(value: &DataValue, type_map: &TypeMap) -> Result { + let version_id = protected_field_id(DataType::ProtectedVersion, type_map)?; + let version = value + .get_field(version_id) + .ok_or(ProtectedError::MissingProtectedVersion)? + .as_unsigned_number() + .ok_or(ProtectedError::InvalidLayout( + "protected version is not unsigned", + ))?; + u64::try_from(version) + .map_err(|_| ProtectedError::InvalidLayout("protected version is out of range")) +} + +fn decrypt_protected_payload( + frame: &CommunicationValue, + keyrings: &[&Keyring], + encryption_purpose: ProtectionPurpose, +) -> Result { + frame + .payload() + .decrypt_with_keyrings(keyrings, encryption_purpose) + .map_err(|error| match error { + ProtectionError::NotEncrypted => ProtectedError::PayloadNotEncrypted, + other => ProtectedError::Protection(other), + }) +} + +/// Return the claimed signer ID after decryption, without verifying its +/// signature. The value is untrusted and may only select the key history that +/// is then bound to the same signer ID during the subsequent open. +pub fn protected_claimed_signer_id( + frame: &CommunicationValue, + keyrings: &[&Keyring], + encryption_purpose: ProtectionPurpose, +) -> Result { + validate_protected_frame(frame)?; + let decrypted = decrypt_protected_payload(frame, keyrings, encryption_purpose)?; + let signed = decrypted + .as_signed() + .ok_or(ProtectedError::PayloadNotSigned)?; + Ok(signed.signer_id) +} + +/// Open a direct protected message using a resolver for trusted signer keys. +/// The resolver receives a claimed, unverified signer ID only as a lookup key. +pub fn open_protected_with( + frame: &CommunicationValue, + keyrings: &[&Keyring], + expected_signer_id: Option, + resolve_signer_keys: F, + expected_receiver_id: Option, + signature_purpose: ProtectionPurpose, + encryption_purpose: ProtectionPurpose, + policy: ProtectionPolicy, + replay_guard: Option<&mut dyn ReplayGuard>, +) -> Result +where + F: FnOnce(u64) -> Option>, +{ + validate_protected_frame(frame)?; + let type_map = frame.type_map().cloned().unwrap_or_else(TypeMap::latest); + let decrypted = decrypt_protected_payload(frame, keyrings, encryption_purpose)?; + let signed = decrypted + .as_signed() + .ok_or(ProtectedError::PayloadNotSigned)?; + if let Some(expected_signer_id) = expected_signer_id + && signed.signer_id != expected_signer_id + { + return Err(ProtectionError::SignerIdMismatch { + expected: expected_signer_id, + actual: signed.signer_id, + } + .into()); + } + let signer_keys = resolve_signer_keys(signed.signer_id) + .ok_or(ProtectionError::SignerKeyNotFound(signed.signer_id))?; + open_decrypted_protected( + frame, + type_map, + signed, + &signer_keys, + expected_receiver_id, + signature_purpose, + policy, + replay_guard, + ) +} + +/// Open a direct protected message against already resolved trusted signer +/// keys. The signer ID is mandatory so a key history cannot be applied to a +/// different claimed identity. +pub fn open_protected_with_keys( + frame: &CommunicationValue, + keyrings: &[&Keyring], + expected_signer_id: u64, + signer_public_keys: &[PublicKeyBundle], + expected_receiver_id: Option, + signature_purpose: ProtectionPurpose, + encryption_purpose: ProtectionPurpose, + policy: ProtectionPolicy, + replay_guard: Option<&mut dyn ReplayGuard>, +) -> Result { + let type_map = validate_protected_frame(frame)?; + let decrypted = decrypt_protected_payload(frame, keyrings, encryption_purpose)?; + let signed = decrypted + .as_signed() + .ok_or(ProtectedError::PayloadNotSigned)?; + if signed.signer_id != expected_signer_id { + return Err(ProtectionError::SignerIdMismatch { + expected: expected_signer_id, + actual: signed.signer_id, + } + .into()); + } + open_decrypted_protected( + frame, + type_map, + signed, + signer_public_keys, + expected_receiver_id, + signature_purpose, + policy, + replay_guard, + ) +} + +/// Open a direct protected message when the expected signer and one trusted +/// public key are already known. +pub fn open_protected( + frame: &CommunicationValue, + keyring: &Keyring, + expected_signer_id: u64, + signer_public_key: &PublicKeyBundle, + expected_receiver_id: Option, + signature_purpose: ProtectionPurpose, + encryption_purpose: ProtectionPurpose, + policy: ProtectionPolicy, + replay_guard: Option<&mut dyn ReplayGuard>, +) -> Result { + open_protected_with_keys( + frame, + std::slice::from_ref(&keyring), + expected_signer_id, + std::slice::from_ref(&signer_public_key), + expected_receiver_id, + signature_purpose, + encryption_purpose, + policy, + replay_guard, + ) +} + +fn open_decrypted_protected( + frame: &CommunicationValue, + type_map: TypeMap, + signed: &crate::SignedValue, + signer_public_keys: &[PublicKeyBundle], + expected_receiver_id: Option, + signature_purpose: ProtectionPurpose, + policy: ProtectionPolicy, + mut replay_guard: Option<&mut dyn ReplayGuard>, +) -> Result { + let matched_signer_key_index = signed.verify_with_key_history_index( + signed.signer_id, + signer_public_keys, + signature_purpose, + policy, + )?; + let receiver_id = frame.receiver().ok_or(ProtectedError::MissingReceiver)?; + if expected_receiver_id.is_some_and(|expected| expected != receiver_id) { + return Err(ProtectedError::ExpectedReceiverMismatch); + } + if frame + .sender() + .is_some_and(|sender| sender != signed.signer_id) + { + return Err(ProtectedError::SenderMismatch); + } + let envelope = signed + .value + .as_container() + .ok_or(ProtectedError::MissingEnvelope)?; + let envelope = DataValue::Container(envelope); + let version = protected_version(&envelope, &type_map)?; + if version != CURRENT_PROTECTED_VERSION { + return Err(ProtectedError::UnsupportedProtectedVersion(version)); + } + let message_type = string_field(&envelope, DataType::MessageType, &type_map)?; + let application_type = validate_application_message_type(&message_type, &type_map)?; + if frame.get_comm_type_enum() != Some(application_type) { + return Err(ProtectedError::MessageTypeMismatch); + } + let final_recipient_id = u64::try_from(unsigned_field( + &envelope, + DataType::FinalRecipientId, + &type_map, + )?) + .map_err(|_| ProtectedError::InvalidLayout("final recipient ID is out of range"))?; + if final_recipient_id != receiver_id { + return Err(ProtectedError::FinalRecipientMismatch); + } + let message_id = string_field(&envelope, DataType::MessageId, &type_map)?; + let created_at = u64::try_from(unsigned_field(&envelope, DataType::CreatedAt, &type_map)?) + .map_err(|_| ProtectedError::InvalidLayout("created-at value is out of range"))?; + let content = field(&envelope, DataType::Content, &type_map)?.clone(); + + if let Some(guard) = replay_guard.as_mut() + && !guard.accept(signed.signer_id, &message_id, created_at)? + { + return Err(ProtectedError::Replay); + } + + Ok(VerifiedProtectedMessage { + protected_version: version, + signer_id: signed.signer_id, + final_recipient_id, + message_id, + created_at, + message_type, + content, + matched_signer_key_index, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use mtp_crypto::{Ed25519Signer, Keyring}; + use mtp_type_map::{DataType, DataTypeId}; + + const SIGNATURE_PURPOSE: ProtectionPurpose = ProtectionPurpose(0x40); + const ENCRYPTION_PURPOSE: ProtectionPurpose = ProtectionPurpose(0x41); + + #[derive(Default)] + struct RecordingReplayGuard { + created_at: Option, + accepted: bool, + } + + impl ReplayGuard for RecordingReplayGuard { + fn accept( + &mut self, + _signer_id: u64, + _message_id: &str, + created_at: u64, + ) -> Result { + self.created_at = Some(created_at); + if self.accepted { + Ok(false) + } else { + self.accepted = true; + Ok(true) + } + } + } + + fn protected_field(data_type: DataType, type_map: &TypeMap) -> DataTypeId { + data_type + .try_to_id(type_map) + .expect("protected type mapping") + } + + fn envelope( + type_map: &TypeMap, + version: Option, + message_type: &str, + final_recipient_id: u64, + message_id: &str, + created_at: u64, + content: DataValue, + ) -> DataValue { + let mut fields = Vec::new(); + if let Some(version) = version { + fields.push(( + protected_field(DataType::ProtectedVersion, type_map), + DataValue::UnsignedNumber(version), + )); + } + fields.extend([ + ( + protected_field(DataType::MessageType, type_map), + DataValue::Str(message_type.into()), + ), + ( + protected_field(DataType::FinalRecipientId, type_map), + DataValue::UnsignedNumber(final_recipient_id as u128), + ), + ( + protected_field(DataType::MessageId, type_map), + DataValue::Str(message_id.into()), + ), + ( + protected_field(DataType::CreatedAt, type_map), + DataValue::UnsignedNumber(created_at as u128), + ), + (protected_field(DataType::Content, type_map), content), + ]); + DataValue::Container(fields) + } + + fn encrypted_frame( + envelope: DataValue, + signer_keyring: &Keyring, + signer_id: u64, + recipient_keyring: &Keyring, + receiver_id: Option, + sender_id: Option, + ) -> CommunicationValue { + let type_map = TypeMap::latest(); + let application_type = CommunicationType::from_name("ProtectedMessage") + .expect("ProtectedMessage communication type"); + let signer = Ed25519Signer::new(&signer_keyring.sig_cl_secret_key).expect("Ed25519 signer"); + let signed = envelope + .sign(signer_id, SIGNATURE_PURPOSE, &signer) + .expect("protected envelope signing"); + let encrypted = signed + .encrypt_for(&[recipient_keyring.public_key_bundle()], ENCRYPTION_PURPOSE) + .expect("protected envelope encryption"); + let mut frame = CommunicationValue::new_with_type_map(application_type, &type_map) + .with_payload(encrypted); + if let Some(receiver_id) = receiver_id { + frame = frame.with_receiver(receiver_id); + } + if let Some(sender_id) = sender_id { + frame = frame.with_sender(sender_id); + } + frame + } + + fn valid_frame( + signer_keyring: &Keyring, + recipient_keyring: &Keyring, + content: DataValue, + ) -> CommunicationValue { + ProtectedMessageBuilder::new( + "ProtectedMessage", + content, + 7, + 42, + &Ed25519Signer::new(&signer_keyring.sig_cl_secret_key).expect("Ed25519 signer"), + SIGNATURE_PURPOSE, + ENCRYPTION_PURPOSE, + ) + .message_id("protected-test") + .created_at(1_700_000_000_000) + .recipients(vec![recipient_keyring.public_key_bundle()]) + .build() + .expect("protected frame") + } + + #[test] + fn protected_message_round_trips_and_rejects_replay() { + let type_map = TypeMap::latest(); + let Some(application_type) = CommunicationType::from_name("ProtectedMessage") else { + return; + }; + if application_type.try_to_id(&type_map).is_none() { + return; + } + + let sender = Keyring::generate(); + let recipient = Keyring::generate(); + let signer = Ed25519Signer::new(&sender.sig_cl_secret_key).expect("Ed25519 signer"); + let frame = ProtectedMessageBuilder::new( + "ProtectedMessage", + DataValue::Str("hello".into()), + 7, + 42, + &signer, + SIGNATURE_PURPOSE, + ENCRYPTION_PURPOSE, + ) + .message_id("protected-test") + .created_at(1_700_000_000_000) + .recipients(vec![recipient.public_key_bundle()]) + .type_map(&type_map) + .build() + .expect("protected frame"); + + let mut guard = RecordingReplayGuard::default(); + let opened = open_protected( + &frame, + &recipient, + 7, + &sender.public_key_bundle(), + Some(42), + SIGNATURE_PURPOSE, + ENCRYPTION_PURPOSE, + ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + Some(&mut guard), + ) + .expect("protected message should open"); + assert_eq!(opened.message_type, "ProtectedMessage"); + assert_eq!(opened.message_id, "protected-test"); + assert_eq!(guard.created_at, Some(1_700_000_000_000)); + assert_eq!(opened.content, DataValue::Str("hello".into())); + assert!(matches!( + open_protected( + &frame, + &recipient, + 7, + &sender.public_key_bundle(), + Some(42), + SIGNATURE_PURPOSE, + ENCRYPTION_PURPOSE, + ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + Some(&mut guard), + ), + Err(ProtectedError::Replay) + )); + } + + #[test] + fn builder_owns_outer_sender_and_frame_id() { + let sender = Keyring::generate(); + let recipient = Keyring::generate(); + let signer = Ed25519Signer::new(&sender.sig_cl_secret_key).expect("Ed25519 signer"); + let frame = ProtectedMessageBuilder::new( + "ProtectedMessage", + DataValue::Str("hello".into()), + 7, + 42, + &signer, + SIGNATURE_PURPOSE, + ENCRYPTION_PURPOSE, + ) + .message_id("outer-fields") + .created_at(123) + .frame_id(99) + .expose_sender(true) + .recipients(vec![recipient.public_key_bundle()]) + .build() + .expect("protected frame"); + + assert_eq!(frame.id(), Some(99)); + assert_eq!(frame.sender(), Some(7)); + assert_eq!(frame.receiver(), Some(42)); + open_protected( + &frame, + &recipient, + 7, + &sender.public_key_bundle(), + Some(42), + SIGNATURE_PURPOSE, + ENCRYPTION_PURPOSE, + ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + None, + ) + .expect("outer fields should verify"); + } + + #[test] + fn protected_version_is_required_and_versioned() { + let sender = Keyring::generate(); + let recipient = Keyring::generate(); + let type_map = TypeMap::latest(); + let missing = encrypted_frame( + envelope( + &type_map, + None, + "ProtectedMessage", + 42, + "missing-version", + 123, + DataValue::Str("hello".into()), + ), + &sender, + 7, + &recipient, + Some(42), + None, + ); + assert!(matches!( + open_protected( + &missing, + &recipient, + 7, + &sender.public_key_bundle(), + Some(42), + SIGNATURE_PURPOSE, + ENCRYPTION_PURPOSE, + ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + None, + ), + Err(ProtectedError::MissingProtectedVersion) + )); + + let unsupported = encrypted_frame( + envelope( + &type_map, + Some((CURRENT_PROTECTED_VERSION + 1) as u128), + "ProtectedMessage", + 42, + "unsupported-version", + 123, + DataValue::Str("hello".into()), + ), + &sender, + 7, + &recipient, + Some(42), + None, + ); + assert!(matches!( + open_protected( + &unsupported, + &recipient, + 7, + &sender.public_key_bundle(), + Some(42), + SIGNATURE_PURPOSE, + ENCRYPTION_PURPOSE, + ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + None, + ), + Err(ProtectedError::UnsupportedProtectedVersion(2)) + )); + } + + #[test] + fn protected_builder_rejects_reserved_application_types() { + let sender = Keyring::generate(); + let recipient = Keyring::generate(); + let signer = Ed25519Signer::new(&sender.sig_cl_secret_key).expect("Ed25519 signer"); + assert!(matches!( + ProtectedMessageBuilder::new( + "Ping", + DataValue::Null, + 7, + 42, + &signer, + SIGNATURE_PURPOSE, + ENCRYPTION_PURPOSE, + ) + .message_id("reserved") + .created_at(123) + .recipients(vec![recipient.public_key_bundle()]) + .build(), + Err(ProtectedError::ReservedApplicationType(type_name)) if type_name == "Ping" + )); + } + + #[test] + fn protected_opening_rejects_type_and_receiver_tampering() { + let sender = Keyring::generate(); + let recipient = Keyring::generate(); + let frame = valid_frame(&sender, &recipient, DataValue::Str("hello".into())); + let alternate_type = CommunicationType::from_name("AlternateMessage"); + let Some(alternate_type) = alternate_type else { + return; + }; + let type_map = TypeMap::latest(); + let payload = frame.payload().clone(); + let changed_type = CommunicationValue::new_with_type_map(alternate_type, &type_map) + .with_receiver(42) + .with_payload(payload.clone()); + assert!(matches!( + open_protected( + &changed_type, + &recipient, + 7, + &sender.public_key_bundle(), + None, + SIGNATURE_PURPOSE, + ENCRYPTION_PURPOSE, + ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + None, + ), + Err(ProtectedError::MessageTypeMismatch) + )); + + let changed_receiver = frame.clone().with_receiver(43); + assert!(matches!( + open_protected( + &changed_receiver, + &recipient, + 7, + &sender.public_key_bundle(), + None, + SIGNATURE_PURPOSE, + ENCRYPTION_PURPOSE, + ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + None, + ), + Err(ProtectedError::FinalRecipientMismatch) + )); + + let signed_recipient_mismatch = encrypted_frame( + envelope( + &type_map, + Some(CURRENT_PROTECTED_VERSION as u128), + "ProtectedMessage", + 43, + "signed-recipient-mismatch", + 123, + DataValue::Str("hello".into()), + ), + &sender, + 7, + &recipient, + Some(42), + None, + ); + assert!(matches!( + open_protected( + &signed_recipient_mismatch, + &recipient, + 7, + &sender.public_key_bundle(), + None, + SIGNATURE_PURPOSE, + ENCRYPTION_PURPOSE, + ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + None, + ), + Err(ProtectedError::FinalRecipientMismatch) + )); + + assert!(matches!( + open_protected( + &frame, + &recipient, + 7, + &sender.public_key_bundle(), + Some(43), + SIGNATURE_PURPOSE, + ENCRYPTION_PURPOSE, + ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + None, + ), + Err(ProtectedError::ExpectedReceiverMismatch) + )); + } + + #[test] + fn protected_opening_validates_exposed_sender() { + let sender = Keyring::generate(); + let recipient = Keyring::generate(); + let signer = Ed25519Signer::new(&sender.sig_cl_secret_key).expect("Ed25519 signer"); + let frame = ProtectedMessageBuilder::new( + "ProtectedMessage", + DataValue::Str("hello".into()), + 7, + 42, + &signer, + SIGNATURE_PURPOSE, + ENCRYPTION_PURPOSE, + ) + .message_id("sender-check") + .created_at(123) + .expose_sender(true) + .recipients(vec![recipient.public_key_bundle()]) + .build() + .expect("protected frame"); + open_protected( + &frame, + &recipient, + 7, + &sender.public_key_bundle(), + Some(42), + SIGNATURE_PURPOSE, + ENCRYPTION_PURPOSE, + ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + None, + ) + .expect("matching exposed sender"); + assert!(matches!( + open_protected( + &frame.with_sender(8), + &recipient, + 7, + &sender.public_key_bundle(), + Some(42), + SIGNATURE_PURPOSE, + ENCRYPTION_PURPOSE, + ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + None, + ), + Err(ProtectedError::SenderMismatch) + )); + } + + #[test] + fn protected_opening_rejects_unsigned_and_unencrypted_payloads() { + let sender = Keyring::generate(); + let recipient = Keyring::generate(); + let type_map = TypeMap::latest(); + let application_type = CommunicationType::from_name("ProtectedMessage") + .expect("ProtectedMessage communication type"); + let type_map_frame = |payload: DataValue| { + CommunicationValue::new_with_type_map(application_type, &type_map) + .with_receiver(42) + .with_payload(payload) + }; + let signer = Ed25519Signer::new(&sender.sig_cl_secret_key).expect("Ed25519 signer"); + let envelope = envelope( + &type_map, + Some(CURRENT_PROTECTED_VERSION as u128), + "ProtectedMessage", + 42, + "payload-shape", + 123, + DataValue::Str("hello".into()), + ); + let signed = envelope + .clone() + .sign(7, SIGNATURE_PURPOSE, &signer) + .expect("signing"); + assert!(matches!( + open_protected( + &type_map_frame(signed), + &recipient, + 7, + &sender.public_key_bundle(), + Some(42), + SIGNATURE_PURPOSE, + ENCRYPTION_PURPOSE, + ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + None, + ), + Err(ProtectedError::PayloadNotEncrypted) + )); + let encrypted_unsigned = DataValue::Str("not signed".into()) + .encrypt_for(&[recipient.public_key_bundle()], ENCRYPTION_PURPOSE) + .expect("encryption"); + assert!(matches!( + open_protected( + &type_map_frame(encrypted_unsigned), + &recipient, + 7, + &sender.public_key_bundle(), + Some(42), + SIGNATURE_PURPOSE, + ENCRYPTION_PURPOSE, + ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + None, + ), + Err(ProtectedError::PayloadNotSigned) + )); + } + + #[test] + fn protected_opening_checks_expected_signer_before_resolving_keys() { + let sender = Keyring::generate(); + let recipient = Keyring::generate(); + let frame = valid_frame(&sender, &recipient, DataValue::Str("hello".into())); + let mut resolver_calls = 0; + let result = open_protected_with( + &frame, + &[&recipient], + Some(99), + |_| { + resolver_calls += 1; + Some(vec![sender.public_key_bundle()]) + }, + Some(42), + SIGNATURE_PURPOSE, + ENCRYPTION_PURPOSE, + ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + None, + ); + assert!(matches!( + result, + Err(ProtectedError::Protection( + ProtectionError::SignerIdMismatch { + expected: 99, + actual: 7, + } + )) + )); + assert_eq!(resolver_calls, 0); + } + + #[test] + fn protected_opening_accepts_signer_and_recipient_key_history() { + let old_sender = Keyring::generate(); + let current_sender = Keyring::generate(); + let current_recipient = Keyring::generate(); + let old_recipient = Keyring::generate(); + let signer = Ed25519Signer::new(&old_sender.sig_cl_secret_key).expect("Ed25519 signer"); + let frame = ProtectedMessageBuilder::new( + "ProtectedMessage", + DataValue::Str("rotated".into()), + 7, + 42, + &signer, + SIGNATURE_PURPOSE, + ENCRYPTION_PURPOSE, + ) + .message_id("history") + .created_at(123) + .recipients(vec![old_recipient.public_key_bundle()]) + .build() + .expect("protected frame"); + + let opened = open_protected_with_keys( + &frame, + &[¤t_recipient, &old_recipient], + 7, + &[ + current_sender.public_key_bundle(), + old_sender.public_key_bundle(), + ], + Some(42), + SIGNATURE_PURPOSE, + ENCRYPTION_PURPOSE, + ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + None, + ) + .expect("key history should open"); + assert_eq!(opened.matched_signer_key_index, 1); + assert_eq!(opened.content, DataValue::Str("rotated".into())); + } + + #[test] + fn protected_opening_accepts_arbitrary_application_values() { + let sender = Keyring::generate(); + let recipient = Keyring::generate(); + let frame = valid_frame( + &sender, + &recipient, + DataValue::Array(vec![ + DataValue::Str("value".into()), + DataValue::UnsignedNumber(7), + DataValue::Bytes(vec![1, 2, 3]), + ]), + ); + let opened = open_protected( + &frame, + &recipient, + 7, + &sender.public_key_bundle(), + Some(42), + SIGNATURE_PURPOSE, + ENCRYPTION_PURPOSE, + ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + None, + ) + .expect("arbitrary application value should open"); + assert_eq!( + opened.content, + DataValue::Array(vec![ + DataValue::Str("value".into()), + DataValue::UnsignedNumber(7), + DataValue::Bytes(vec![1, 2, 3]), + ]) + ); + } +} diff --git a/codec/src/relay.rs b/codec/src/relay.rs new file mode 100644 index 0000000..4e4d6c6 --- /dev/null +++ b/codec/src/relay.rs @@ -0,0 +1,1280 @@ +// Relay-specific opening helpers. +// +// The generic [`DataValue`] protection operations remain the primitive API. +// These helpers add the protocol boundary needed by relay participants: +// metadata can be authenticated and returned with an opaque content value, +// while content opening is a separate operation that requires the final +// recipient identity. + +#![cfg(feature = "crypto")] + +use mtp_crypto::{Keyring, PublicKeyBundle, SignatureScheme}; +use mtp_type_map::{CommunicationType, DataType, TypeMap}; + +use crate::{ + CommunicationValue, DataValue, MtpProtectionPurpose, ProtectionError, ProtectionPolicy, + ReplayError, ReplayGuard, +}; + +/// The relay metadata schema emitted by [`SealedRelayBuilder`]. +pub const CURRENT_RELAY_VERSION: u64 = 1; + +#[derive(Debug, thiserror::Error)] +pub enum RelayError { + #[error("value is not a Relay communication frame")] + NotRelay, + #[error("sealed relay frame must not expose an outer sender")] + OuterSenderPresent, + #[error("sealed relay frame must contain an explicit next-hop receiver")] + MissingNextHop, + #[error("relay frame has an invalid protected layout: {0}")] + InvalidLayout(&'static str), + #[error("relay frame does not declare a relay version")] + MissingRelayVersion, + #[error("unsupported relay version {0}")] + UnsupportedRelayVersion(u64), + #[error("relay content is addressed to a different final recipient")] + NotFinalRecipient, + #[error("relay message was already accepted")] + Replay, + #[error("relay application message type is reserved: {0}")] + ReservedApplicationType(String), + #[error("protection error: {0}")] + Protection(#[from] ProtectionError), + #[error("replay guard error: {0}")] + ReplayGuard(#[from] ReplayError), +} + +/// Metadata authenticated by the signer and decryptable by metadata +/// recipients. +/// +/// `encrypted_content` is intentionally kept as an opaque `DataValue` so a +/// metadata-only relay participant can store or forward it without possessing +/// a content key. +#[derive(Debug, Clone)] +pub struct VerifiedRelayMetadata { + relay_version: u64, + signer_id: u64, + final_recipient_id: u64, + message_id: String, + created_at: u64, + metadata: Option, + encrypted_content: DataValue, + type_map: TypeMap, + matched_signer_key_index: usize, + // There is intentionally no public constructor. This marker documents + // that the fields originate from a successful authenticated open. + _verified: VerifiedMarker, +} + +#[derive(Debug, Clone, Copy)] +struct VerifiedMarker; + +impl VerifiedRelayMetadata { + /// Return the authenticated MTP relay metadata schema version. + pub fn relay_version(&self) -> u64 { + self.relay_version + } + + pub fn signer_id(&self) -> u64 { + self.signer_id + } + + pub fn final_recipient_id(&self) -> u64 { + self.final_recipient_id + } + + pub fn message_id(&self) -> &str { + &self.message_id + } + + /// Return the authenticated creation time as Unix epoch milliseconds. + pub fn created_at(&self) -> u64 { + self.created_at + } + + /// Return the authenticated application metadata without interpreting it. + pub fn metadata(&self) -> Option<&DataValue> { + self.metadata.as_ref() + } + + /// Return the authenticated content envelope for forwarding. The value + /// remains opaque to metadata-only relay participants. + pub fn encrypted_content(&self) -> &DataValue { + &self.encrypted_content + } + + /// Return the index of the trusted signing key that verified the + /// authenticated metadata. + pub fn matched_signer_key_index(&self) -> usize { + self.matched_signer_key_index + } +} + +/// Content opened and authenticated for the final recipient. +#[derive(Debug, Clone, PartialEq)] +pub struct VerifiedRelayContent { + pub signer_id: u64, + pub final_recipient_id: u64, + pub message_type: String, + pub content: DataValue, +} + +/// Native sealed-relay builder shared by non-WASM applications. +/// +/// The browser SDK and this builder intentionally produce the same reserved +/// metadata layout. Routing recipients are supplied separately from content +/// recipients so a metadata-only relay participant can open metadata without +/// receiving content keys. +pub struct SealedRelayBuilder<'a> { + message_type: String, + content: DataValue, + signer_id: u64, + final_recipient_id: u64, + next_hop_id: u64, + message_id: Option, + created_at: Option, + metadata: Option, + signer: &'a dyn SignatureScheme, + metadata_recipients: Vec, + content_recipients: Vec, + type_map: Option, +} + +impl<'a> SealedRelayBuilder<'a> { + pub fn new( + message_type: impl Into, + content: DataValue, + signer_id: u64, + final_recipient_id: u64, + next_hop_id: u64, + signer: &'a dyn SignatureScheme, + ) -> Self { + Self { + message_type: message_type.into(), + content, + signer_id, + final_recipient_id, + next_hop_id, + message_id: None, + created_at: None, + metadata: None, + signer, + metadata_recipients: Vec::new(), + content_recipients: Vec::new(), + type_map: None, + } + } + + pub fn message_id(mut self, value: impl Into) -> Self { + self.message_id = Some(value.into()); + self + } + + pub fn metadata(mut self, value: DataValue) -> Self { + self.metadata = Some(value); + self + } + + /// Set `CreatedAt` as Unix epoch milliseconds. + pub fn created_at(mut self, value: u64) -> Self { + self.created_at = Some(value); + self + } + + pub fn metadata_recipients(mut self, recipients: Vec) -> Self { + self.metadata_recipients = recipients; + self + } + + pub fn content_recipients(mut self, recipients: Vec) -> Self { + self.content_recipients = recipients; + self + } + + /// Build the reserved relay fields against a negotiated type map. The + /// default is the current map, but native callers handling an older + /// negotiated frame should pass that map explicitly. + pub fn type_map(mut self, type_map: &TypeMap) -> Self { + self.type_map = Some(type_map.clone()); + self + } + + pub fn build(self) -> Result { + let message_id = self.message_id.ok_or(RelayError::InvalidLayout( + "relay builder requires a message ID", + ))?; + let created_at = self.created_at.ok_or(RelayError::InvalidLayout( + "relay builder requires a creation timestamp", + ))?; + if self.message_type.is_empty() || message_id.is_empty() { + return Err(RelayError::InvalidLayout( + "relay builder identifiers must be non-empty", + )); + } + if self.metadata_recipients.is_empty() || self.content_recipients.is_empty() { + return Err(RelayError::InvalidLayout( + "relay builder requires metadata and content recipients", + )); + } + + let type_map = self.type_map.unwrap_or_else(TypeMap::latest); + validate_application_message_type(&self.message_type, &type_map)?; + let message_type_id = relay_field(DataType::MessageType, &type_map)?; + let content_id = relay_field(DataType::Content, &type_map)?; + let message_id_id = relay_field(DataType::MessageId, &type_map)?; + let final_recipient_id = relay_field(DataType::FinalRecipientId, &type_map)?; + let created_at_id = relay_field(DataType::CreatedAt, &type_map)?; + let metadata_id = relay_field(DataType::Metadata, &type_map)?; + let relay_version_id = relay_field(DataType::RelayVersion, &type_map)?; + + let content = DataValue::Container(vec![ + (message_type_id, DataValue::Str(self.message_type)), + (content_id, self.content), + ]); + let signed_content = content.sign( + self.signer_id, + MtpProtectionPurpose::RelayContentSignature.into(), + self.signer, + )?; + let encrypted_content = signed_content.encrypt_for( + &self.content_recipients, + MtpProtectionPurpose::RelayContentEncryption.into(), + )?; + let mut metadata_fields = vec![ + ( + relay_version_id, + DataValue::UnsignedNumber(CURRENT_RELAY_VERSION as u128), + ), + (message_id_id, DataValue::Str(message_id)), + ( + final_recipient_id, + DataValue::UnsignedNumber(self.final_recipient_id as u128), + ), + (created_at_id, DataValue::UnsignedNumber(created_at as u128)), + (content_id, encrypted_content), + ]; + if let Some(application_metadata) = self.metadata { + metadata_fields.push((metadata_id, application_metadata)); + } + let metadata = DataValue::Container(metadata_fields); + let signed_metadata = metadata.sign( + self.signer_id, + MtpProtectionPurpose::RelayMetadataSignature.into(), + self.signer, + )?; + let encrypted_metadata = signed_metadata.encrypt_for( + &self.metadata_recipients, + MtpProtectionPurpose::RelayMetadataEncryption.into(), + )?; + Ok( + CommunicationValue::new_with_type_map(CommunicationType::Relay, &type_map) + .without_sender() + .with_receiver(self.next_hop_id) + .with_payload(encrypted_metadata), + ) + } +} + +fn relay_field( + data_type: DataType, + type_map: &TypeMap, +) -> Result { + data_type + .try_to_id(type_map) + .ok_or(RelayError::InvalidLayout( + "reserved relay type is unavailable", + )) +} + +fn validate_application_message_type( + message_type: &str, + type_map: &TypeMap, +) -> Result<(), RelayError> { + let communication_type = CommunicationType::from_name(message_type) + .ok_or(RelayError::InvalidLayout("relay message type is unknown"))?; + let communication_id = + communication_type + .try_to_id(type_map) + .ok_or(RelayError::InvalidLayout( + "relay message type is unavailable", + ))?; + if communication_id.is_reserved() { + return Err(RelayError::ReservedApplicationType(message_type.to_owned())); + } + Ok(()) +} + +fn field<'a>( + value: &'a DataValue, + data_type: DataType, + type_map: &TypeMap, +) -> Result<&'a DataValue, RelayError> { + value + .get_field(relay_field(data_type, type_map)?) + .ok_or(RelayError::InvalidLayout("required relay field is missing")) +} + +fn string_field( + value: &DataValue, + data_type: DataType, + type_map: &TypeMap, +) -> Result { + field(value, data_type, type_map)? + .as_string() + .filter(|value| !value.is_empty()) + .ok_or(RelayError::InvalidLayout( + "relay field is not a non-empty string", + )) +} + +fn optional_metadata_field( + value: &DataValue, + type_map: &TypeMap, +) -> Result, RelayError> { + Ok(value + .get_field(relay_field(DataType::Metadata, type_map)?) + .cloned()) +} + +fn unsigned_field( + value: &DataValue, + data_type: DataType, + type_map: &TypeMap, +) -> Result { + field(value, data_type, type_map)? + .as_unsigned_number() + .ok_or(RelayError::InvalidLayout("relay field is not unsigned")) +} + +#[derive(Debug)] +struct RelayMetadataV1 { + final_recipient_id: u64, + message_id: String, + created_at: u64, + metadata: Option, + encrypted_content: DataValue, +} + +fn relay_version(value: &DataValue, type_map: &TypeMap) -> Result { + let version_id = relay_field(DataType::RelayVersion, type_map)?; + let version = value + .get_field(version_id) + .ok_or(RelayError::MissingRelayVersion)? + .as_unsigned_number() + .ok_or(RelayError::InvalidLayout("relay version is not unsigned"))?; + u64::try_from(version).map_err(|_| RelayError::InvalidLayout("relay version is out of range")) +} + +fn parse_relay_v1(value: &DataValue, type_map: &TypeMap) -> Result { + let final_recipient_id = + u64::try_from(unsigned_field(value, DataType::FinalRecipientId, type_map)?) + .map_err(|_| RelayError::InvalidLayout("final recipient ID is out of range"))?; + let created_at = u64::try_from(unsigned_field(value, DataType::CreatedAt, type_map)?) + .map_err(|_| RelayError::InvalidLayout("created-at value is out of range"))?; + let encrypted_content = field(value, DataType::Content, type_map)?.clone(); + if encrypted_content.as_encrypted().is_none() { + return Err(RelayError::InvalidLayout("content is not encrypted")); + } + Ok(RelayMetadataV1 { + final_recipient_id, + message_id: string_field(value, DataType::MessageId, type_map)?, + created_at, + metadata: optional_metadata_field(value, type_map)?, + encrypted_content, + }) +} + +/// Decrypt and verify relay metadata, without opening its content. +/// +/// `expected_signer_id` is required when the caller already knows the sender. +/// For sealed-sender operation use [`open_relay_metadata_with`] and resolve a +/// trusted key by the claimed, unverified signer ID. The ID is authenticated +/// only after the returned key history verifies the signature. +pub fn open_relay_metadata( + frame: &CommunicationValue, + keyring: &Keyring, + expected_signer_id: u64, + signer_public_key: &PublicKeyBundle, + policy: ProtectionPolicy, +) -> Result { + open_relay_metadata_with( + frame, + std::slice::from_ref(&keyring), + Some(expected_signer_id), + |_| Some(vec![signer_public_key.clone()]), + policy, + None, + ) +} + +fn validate_relay_frame(frame: &CommunicationValue) -> Result<(), RelayError> { + if !frame.is_type(CommunicationType::Relay) { + return Err(RelayError::NotRelay); + } + if frame.sender().is_some() { + return Err(RelayError::OuterSenderPresent); + } + if frame.receiver().is_none() { + return Err(RelayError::MissingNextHop); + } + Ok(()) +} + +/// Return the claimed signer ID from relay metadata without verifying its +/// signature or interpreting the versioned relay schema. The result is +/// untrusted and may only select the key history that is then bound to the +/// same signer ID during [`open_relay_metadata_with_keys`]. +pub fn relay_metadata_claimed_signer_id( + frame: &CommunicationValue, + keyrings: &[&Keyring], +) -> Result { + validate_relay_frame(frame)?; + + let decrypted = frame.payload().decrypt_with_keyrings( + keyrings, + MtpProtectionPurpose::RelayMetadataEncryption.into(), + )?; + let signed = decrypted + .as_signed() + .ok_or(RelayError::InvalidLayout("metadata is not signed"))?; + Ok(signed.signer_id) +} + +/// Decrypt and verify relay metadata against an already resolved signing-key +/// history. All relay version and field interpretation remains in the native +/// codec rather than being duplicated by language bindings. +pub fn open_relay_metadata_with_keys( + frame: &CommunicationValue, + keyrings: &[&Keyring], + expected_signer_id: u64, + signer_public_keys: &[PublicKeyBundle], + policy: ProtectionPolicy, +) -> Result { + let signer_public_keys = signer_public_keys.to_vec(); + open_relay_metadata_with( + frame, + keyrings, + Some(expected_signer_id), + move |_| Some(signer_public_keys.clone()), + policy, + None, + ) +} + +/// Decrypt and verify relay metadata using recipient-key history and a +/// signer-key resolver. The resolver receives a claimed, unverified signer +/// ID used only as a trusted-key lookup key. The ID becomes authenticated +/// only after signature verification. This is the native counterpart of the +/// browser relay API and supports sealed sender plus signing-key rotation. +pub fn open_relay_metadata_with( + frame: &CommunicationValue, + keyrings: &[&Keyring], + expected_signer_id: Option, + resolve_signer_keys: F, + policy: ProtectionPolicy, + mut replay_guard: Option<&mut dyn ReplayGuard>, +) -> Result +where + F: Fn(u64) -> Option>, +{ + validate_relay_frame(frame)?; + + let type_map = frame.type_map().cloned().unwrap_or_else(TypeMap::latest); + let decrypted = frame.payload().decrypt_with_keyrings( + keyrings, + MtpProtectionPurpose::RelayMetadataEncryption.into(), + )?; + let signed = decrypted + .as_signed() + .ok_or(RelayError::InvalidLayout("metadata is not signed"))?; + if let Some(expected_signer_id) = expected_signer_id + && signed.signer_id != expected_signer_id + { + return Err(ProtectionError::SignerIdMismatch { + expected: expected_signer_id, + actual: signed.signer_id, + } + .into()); + } + let signer_keys = resolve_signer_keys(signed.signer_id) + .ok_or(ProtectionError::SignerKeyNotFound(signed.signer_id))?; + let matched_signer_key_index = signed.verify_with_key_history_index( + signed.signer_id, + &signer_keys, + MtpProtectionPurpose::RelayMetadataSignature.into(), + policy, + )?; + let metadata = signed + .value + .as_container() + .ok_or(RelayError::InvalidLayout("metadata is not a container"))?; + let metadata = DataValue::Container(metadata); + let relay_version = relay_version(&metadata, &type_map)?; + let parsed = match relay_version { + 1 => parse_relay_v1(&metadata, &type_map)?, + other => return Err(RelayError::UnsupportedRelayVersion(other)), + }; + + let result = VerifiedRelayMetadata { + relay_version, + signer_id: signed.signer_id, + final_recipient_id: parsed.final_recipient_id, + message_id: parsed.message_id, + created_at: parsed.created_at, + metadata: parsed.metadata, + encrypted_content: parsed.encrypted_content, + type_map, + matched_signer_key_index, + _verified: VerifiedMarker, + }; + if let Some(guard) = replay_guard.as_mut() + && !guard.accept(result.signer_id, &result.message_id, result.created_at)? + { + return Err(RelayError::Replay); + } + Ok(result) +} + +/// Open and verify content after metadata has been authenticated. +pub fn open_relay_content( + metadata: &VerifiedRelayMetadata, + keyring: &Keyring, + signer_public_key: &PublicKeyBundle, + expected_recipient_id: u64, + policy: ProtectionPolicy, +) -> Result { + open_relay_content_with_keys( + metadata, + keyring, + std::slice::from_ref(signer_public_key), + expected_recipient_id, + policy, + ) +} + +/// Open relay content against trusted signing-key history for the metadata's +/// authenticated signer ID. +pub fn open_relay_content_with_keys( + metadata: &VerifiedRelayMetadata, + keyring: &Keyring, + signer_public_keys: &[PublicKeyBundle], + expected_recipient_id: u64, + policy: ProtectionPolicy, +) -> Result { + open_relay_content_with_keyrings( + metadata, + std::slice::from_ref(&keyring), + signer_public_keys, + Some(expected_recipient_id), + policy, + ) +} + +/// Open relay content against recipient-key history and trusted signing-key +/// history. The expected final recipient is optional for callers that only +/// have decryption material and do not have a local identity ID. +pub fn open_relay_content_with_keyrings( + metadata: &VerifiedRelayMetadata, + keyrings: &[&Keyring], + signer_public_keys: &[PublicKeyBundle], + expected_recipient_id: Option, + policy: ProtectionPolicy, +) -> Result { + if expected_recipient_id.is_some_and(|id| metadata.final_recipient_id != id) { + return Err(RelayError::NotFinalRecipient); + } + + let type_map = &metadata.type_map; + let decrypted = metadata.encrypted_content.decrypt_with_keyrings( + keyrings, + MtpProtectionPurpose::RelayContentEncryption.into(), + )?; + let signed = decrypted + .as_signed() + .ok_or(RelayError::InvalidLayout("content is not signed"))?; + if signed.signer_id != metadata.signer_id { + return Err(RelayError::InvalidLayout( + "metadata and content signer IDs differ", + )); + } + // Content is a separately signed value and must not inherit a weaker + // metadata policy. + signed.verify_with_key_history( + metadata.signer_id, + signer_public_keys, + MtpProtectionPurpose::RelayContentSignature.into(), + policy, + )?; + let content = signed + .value + .as_container() + .ok_or(RelayError::InvalidLayout("content is not a container"))?; + let content = DataValue::Container(content); + let message_type = string_field(&content, DataType::MessageType, type_map)?; + validate_application_message_type(&message_type, type_map)?; + Ok(VerifiedRelayContent { + signer_id: signed.signer_id, + final_recipient_id: metadata.final_recipient_id, + message_type, + content: field(&content, DataType::Content, type_map)?.clone(), + }) +} + +/// Change only the clear next-hop routing field of a sealed relay frame. +/// The authenticated encrypted payload is cloned byte-for-byte, so a relay +/// cannot alter the final recipient or message metadata while forwarding. +pub fn forward_relay_frame( + frame: &CommunicationValue, + next_hop_receiver_id: u64, +) -> Result { + validate_relay_frame(frame)?; + Ok(frame.clone().with_receiver(next_hop_receiver_id)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::InMemoryReplayGuard; + use mtp_crypto::{Ed25519Signer, Keyring}; + use mtp_type_map::DataTypeId; + + fn ed_signer(keyring: &Keyring) -> Ed25519Signer { + Ed25519Signer::new(&keyring.sig_cl_secret_key).expect("Ed25519 signer") + } + + fn relay_fixture_created_at_millis() -> u64 { + include_str!("../../fixtures/relay-created-at-ms.txt") + .trim() + .parse() + .expect("relay timestamp fixture must be an unsigned integer") + } + + fn relay_frame_with_version( + version: Option, + include_v1_fields: bool, + sender: &Keyring, + recipient: &Keyring, + ) -> CommunicationValue { + let signer = ed_signer(sender); + let type_map = TypeMap::latest(); + let mut fields = Vec::new(); + if let Some(version) = version { + fields.push(( + relay_field(DataType::RelayVersion, &type_map).expect("RelayVersion mapping"), + DataValue::UnsignedNumber(version as u128), + )); + } + if include_v1_fields { + let content = DataValue::Container(vec![ + ( + relay_field(DataType::MessageType, &type_map).expect("MessageType mapping"), + DataValue::Str("ProtectedMessage".into()), + ), + ( + relay_field(DataType::Content, &type_map).expect("Content mapping"), + DataValue::Null, + ), + ]) + .sign( + 7, + MtpProtectionPurpose::RelayContentSignature.into(), + &signer, + ) + .expect("sign content") + .encrypt_for( + &[recipient.public_key_bundle()], + MtpProtectionPurpose::RelayContentEncryption.into(), + ) + .expect("encrypt content"); + fields.extend([ + ( + relay_field(DataType::MessageId, &type_map).expect("MessageId mapping"), + DataValue::Str("version-test".into()), + ), + ( + relay_field(DataType::FinalRecipientId, &type_map) + .expect("FinalRecipientId mapping"), + DataValue::UnsignedNumber(42), + ), + ( + relay_field(DataType::CreatedAt, &type_map).expect("CreatedAt mapping"), + DataValue::UnsignedNumber(123), + ), + ( + relay_field(DataType::Metadata, &type_map).expect("Metadata mapping"), + DataValue::Null, + ), + ( + relay_field(DataType::Content, &type_map).expect("Content mapping"), + content, + ), + ]); + } + let payload = DataValue::Container(fields) + .sign( + 7, + MtpProtectionPurpose::RelayMetadataSignature.into(), + &signer, + ) + .expect("sign metadata") + .encrypt_for( + &[recipient.public_key_bundle()], + MtpProtectionPurpose::RelayMetadataEncryption.into(), + ) + .expect("encrypt metadata"); + CommunicationValue::new_with_type_map(CommunicationType::Relay, &type_map) + .without_sender() + .with_receiver(9) + .with_payload(payload) + } + + #[test] + fn relay_version_one_round_trips() { + let sender = Keyring::generate(); + let recipient = Keyring::generate(); + let frame = + relay_frame_with_version(Some(CURRENT_RELAY_VERSION), true, &sender, &recipient); + let metadata = open_relay_metadata( + &frame, + &recipient, + 7, + &sender.public_key_bundle(), + ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + ) + .expect("version 1 metadata"); + assert_eq!(metadata.relay_version(), CURRENT_RELAY_VERSION); + } + + #[test] + fn missing_relay_version_is_rejected() { + let sender = Keyring::generate(); + let recipient = Keyring::generate(); + let frame = relay_frame_with_version(None, true, &sender, &recipient); + let result = open_relay_metadata( + &frame, + &recipient, + 7, + &sender.public_key_bundle(), + ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + ); + assert!(matches!(result, Err(RelayError::MissingRelayVersion))); + } + + #[test] + fn unknown_relay_version_is_rejected_before_v1_fields_are_parsed() { + let sender = Keyring::generate(); + let recipient = Keyring::generate(); + let frame = relay_frame_with_version(Some(99), false, &sender, &recipient); + let result = open_relay_metadata( + &frame, + &recipient, + 7, + &sender.public_key_bundle(), + ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + ); + assert!(matches!( + result, + Err(RelayError::UnsupportedRelayVersion(99)) + )); + } + + #[test] + fn relay_fixture_encodes_created_at_as_exact_milliseconds() { + let sender = Keyring::generate(); + let recipient = Keyring::generate(); + let signer = ed_signer(&sender); + let created_at = relay_fixture_created_at_millis(); + let frame = SealedRelayBuilder::new( + "ProtectedMessage", + DataValue::Str("fixture content".into()), + 11, + 42, + 42, + &signer, + ) + .message_id("relay-created-at-ms-fixture") + .created_at(created_at) + .metadata_recipients(vec![recipient.public_key_bundle()]) + .content_recipients(vec![recipient.public_key_bundle()]) + .build() + .expect("relay frame"); + + let type_map = frame.type_map().cloned().unwrap_or_else(TypeMap::latest); + let decrypted = frame + .payload() + .decrypt( + &recipient, + MtpProtectionPurpose::RelayMetadataEncryption.into(), + ) + .expect("metadata decryption"); + let signed = decrypted.as_signed().expect("signed metadata"); + let created_at_id = DataType::CreatedAt + .try_to_id(&type_map) + .expect("CreatedAt mapping"); + + assert_eq!( + signed.value.get_field(created_at_id), + Some(&DataValue::UnsignedNumber(created_at as u128)) + ); + let verified = open_relay_metadata( + &frame, + &recipient, + 11, + &sender.public_key_bundle(), + ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + ) + .expect("verified metadata"); + assert_eq!(verified.created_at(), created_at); + } + + #[test] + fn relay_metadata_policy_and_replay_guard_are_receiver_controls() { + let sender = Keyring::generate(); + let metadata_recipient = Keyring::generate(); + let final_recipient = Keyring::generate(); + let signer = ed_signer(&sender); + let application_metadata = DataValue::Container(vec![ + (DataTypeId(32), DataValue::Str("opaque-field".into())), + (DataTypeId(33), DataValue::UnsignedNumber(7)), + ]); + let frame = SealedRelayBuilder::new( + "ProtectedMessage", + DataValue::Str("hello".into()), + 7, + 42, + 9, + &signer, + ) + .message_id("message-1") + .created_at(123) + .metadata(application_metadata.clone()) + .metadata_recipients(vec![ + metadata_recipient.public_key_bundle(), + final_recipient.public_key_bundle(), + ]) + .content_recipients(vec![final_recipient.public_key_bundle()]) + .build() + .expect("relay frame"); + let frame = + CommunicationValue::from_bytes(&frame.to_bytes().expect("relay frame encoding")) + .expect("relay frame decoding"); + + assert_eq!(frame.sender(), None); + assert_eq!(frame.receiver(), Some(9)); + + let policy = ProtectionPolicy::from(crate::SignaturePolicy::Ed25519); + let mut guard = InMemoryReplayGuard::default(); + let metadata = open_relay_metadata_with( + &frame, + &[&metadata_recipient], + None, + |signer_id| (signer_id == 7).then(|| vec![sender.public_key_bundle()]), + policy, + Some(&mut guard), + ) + .expect("metadata"); + assert_eq!(metadata.signer_id(), 7); + assert_eq!(metadata.relay_version(), CURRENT_RELAY_VERSION); + assert_eq!(metadata.final_recipient_id(), 42); + assert_eq!(metadata.message_id(), "message-1"); + assert_eq!(metadata.created_at(), 123); + assert_eq!(metadata.metadata(), Some(&application_metadata)); + + // A final recipient may be included in the metadata recipient set and + // therefore open both authenticated layers directly. + let final_metadata = open_relay_metadata( + &frame, + &final_recipient, + 7, + &sender.public_key_bundle(), + policy, + ) + .expect("final recipient metadata"); + assert_eq!(final_metadata.metadata(), Some(&application_metadata)); + let final_content = open_relay_content( + &final_metadata, + &final_recipient, + &sender.public_key_bundle(), + 42, + policy, + ) + .expect("final recipient content"); + assert_eq!(final_content.content, DataValue::Str("hello".into())); + + assert!(matches!( + open_relay_metadata( + &frame, + &metadata_recipient, + 8, + &sender.public_key_bundle(), + policy, + ), + Err(RelayError::Protection(ProtectionError::SignerIdMismatch { + expected: 8, + actual: 7, + })) + )); + + let wrong_signer = Keyring::generate(); + assert!(matches!( + open_relay_metadata( + &frame, + &metadata_recipient, + 7, + &wrong_signer.public_key_bundle(), + policy, + ), + Err(RelayError::Protection(_)) + )); + + assert!(matches!( + open_relay_metadata( + &frame.clone().with_sender(99), + &metadata_recipient, + 7, + &sender.public_key_bundle(), + policy, + ), + Err(RelayError::OuterSenderPresent) + )); + + assert!( + open_relay_content( + &metadata, + &metadata_recipient, + &sender.public_key_bundle(), + 42, + policy, + ) + .is_err() + ); + + let content = open_relay_content( + &metadata, + &final_recipient, + &sender.public_key_bundle(), + 42, + policy, + ) + .expect("content"); + assert_eq!(content.signer_id, 7); + assert_eq!(content.final_recipient_id, 42); + assert_eq!(content.message_type, "ProtectedMessage"); + assert_eq!(content.content, DataValue::Str("hello".into())); + assert!(matches!( + open_relay_content( + &metadata, + &final_recipient, + &sender.public_key_bundle(), + 43, + policy, + ), + Err(RelayError::NotFinalRecipient) + )); + + let replay = open_relay_metadata_with( + &frame, + &[&metadata_recipient], + None, + |signer_id| (signer_id == 7).then(|| vec![sender.public_key_bundle()]), + policy, + Some(&mut guard), + ); + assert!(matches!(replay, Err(RelayError::Replay))); + } + + #[test] + fn relay_metadata_preserves_generic_values_and_absence() { + let sender = Keyring::generate(); + let recipient = Keyring::generate(); + let signer = ed_signer(&sender); + let values = vec![ + DataValue::Null, + DataValue::Bool(true), + DataValue::Str("scalar metadata".into()), + DataValue::Bytes(vec![1, 2, 3]), + DataValue::Array(vec![DataValue::UnsignedNumber(7), DataValue::BoolFalse]), + DataValue::Container(vec![(DataTypeId(32), DataValue::Str("typed".into()))]), + ]; + + for (index, value) in values.into_iter().enumerate() { + let frame = + SealedRelayBuilder::new("ProtectedMessage", DataValue::Null, 7, 42, 9, &signer) + .message_id(format!("metadata-{index}")) + .created_at(123) + .metadata(value.clone()) + .metadata_recipients(vec![recipient.public_key_bundle()]) + .content_recipients(vec![recipient.public_key_bundle()]) + .build() + .expect("relay frame"); + let opened = open_relay_metadata( + &frame, + &recipient, + 7, + &sender.public_key_bundle(), + ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + ) + .expect("relay metadata"); + assert_eq!(opened.metadata(), Some(&value)); + } + + let absent = + SealedRelayBuilder::new("ProtectedMessage", DataValue::Null, 7, 42, 9, &signer) + .message_id("metadata-absent") + .created_at(123) + .metadata_recipients(vec![recipient.public_key_bundle()]) + .content_recipients(vec![recipient.public_key_bundle()]) + .build() + .expect("relay frame"); + let opened = open_relay_metadata( + &absent, + &recipient, + 7, + &sender.public_key_bundle(), + ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + ) + .expect("relay metadata"); + assert_eq!(opened.metadata(), None); + } + + #[test] + fn relay_keyring_boundary_helpers_open_verified_handles() { + let sender = Keyring::generate(); + let recipient = Keyring::generate(); + let signer = ed_signer(&sender); + let frame = SealedRelayBuilder::new( + "ProtectedMessage", + DataValue::Str("hello".into()), + 7, + 42, + 9, + &signer, + ) + .message_id("boundary-message") + .created_at(123) + .metadata_recipients(vec![recipient.public_key_bundle()]) + .content_recipients(vec![recipient.public_key_bundle()]) + .build() + .expect("relay frame"); + + assert_eq!( + relay_metadata_claimed_signer_id(&frame, &[&recipient]).expect("signer ID"), + 7 + ); + let metadata = open_relay_metadata_with_keys( + &frame, + &[&recipient], + 7, + &[sender.public_key_bundle()], + ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + ) + .expect("relay metadata"); + let content = open_relay_content_with_keyrings( + &metadata, + &[&recipient], + &[sender.public_key_bundle()], + Some(42), + ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + ) + .expect("relay content"); + + assert_eq!(content.signer_id, 7); + assert_eq!(content.final_recipient_id, 42); + assert_eq!(content.message_type, "ProtectedMessage"); + assert_eq!(content.content, DataValue::Str("hello".into())); + } + + #[test] + fn relay_content_opening_accepts_previous_recipient_key_history() { + let sender = Keyring::generate(); + let metadata_recipient = Keyring::generate(); + let current_content_recipient = Keyring::generate(); + let previous_content_recipient = Keyring::generate(); + let signer = ed_signer(&sender); + let frame = SealedRelayBuilder::new( + "ProtectedMessage", + DataValue::Str("opened with a previous recipient key".into()), + 7, + 42, + 9, + &signer, + ) + .message_id("recipient-rotation-1") + .created_at(123) + .metadata_recipients(vec![metadata_recipient.public_key_bundle()]) + .content_recipients(vec![previous_content_recipient.public_key_bundle()]) + .build() + .expect("relay frame"); + let policy = ProtectionPolicy::from(crate::SignaturePolicy::Ed25519); + + let metadata = open_relay_metadata_with( + &frame, + &[&metadata_recipient], + Some(7), + |signer_id| (signer_id == 7).then(|| vec![sender.public_key_bundle()]), + policy, + None, + ) + .expect("relay metadata"); + + let content = open_relay_content_with_keyrings( + &metadata, + &[¤t_content_recipient, &previous_content_recipient], + &[sender.public_key_bundle()], + Some(42), + policy, + ) + .expect("previous content recipient key should decrypt"); + + assert_eq!(content.signer_id, 7); + assert_eq!(content.final_recipient_id, 42); + assert_eq!(content.message_type, "ProtectedMessage"); + assert_eq!( + content.content, + DataValue::Str("opened with a previous recipient key".into()) + ); + } + + #[test] + fn dual_policy_rejects_a_valid_ed25519_relay() { + let sender = Keyring::generate(); + let recipient = Keyring::generate(); + let signer = ed_signer(&sender); + let frame = SealedRelayBuilder::new("ProtectedMessage", DataValue::Null, 7, 42, 9, &signer) + .message_id("message-2") + .created_at(123) + .metadata_recipients(vec![recipient.public_key_bundle()]) + .content_recipients(vec![recipient.public_key_bundle()]) + .build() + .expect("relay frame"); + + let result = open_relay_metadata( + &frame, + &recipient, + 7, + &sender.public_key_bundle(), + ProtectionPolicy::from(crate::SignaturePolicy::Dual), + ); + assert!(matches!( + result, + Err(RelayError::Protection( + ProtectionError::SignaturePolicyMismatch { .. } + )) + )); + } + + #[test] + fn forwarding_changes_only_the_outer_next_hop() { + let sender = Keyring::generate(); + let recipient = Keyring::generate(); + let signer = ed_signer(&sender); + let frame = SealedRelayBuilder::new("ProtectedMessage", DataValue::Null, 7, 42, 9, &signer) + .message_id("message-3") + .created_at(123) + .metadata_recipients(vec![recipient.public_key_bundle()]) + .content_recipients(vec![recipient.public_key_bundle()]) + .build() + .expect("relay frame"); + let forwarded = forward_relay_frame(&frame, 10).expect("forward"); + assert_eq!(frame.payload(), forwarded.payload()); + assert_eq!( + frame.payload().to_bytes().expect("original payload bytes"), + forwarded + .payload() + .to_bytes() + .expect("forwarded payload bytes") + ); + assert_eq!(forwarded.receiver(), Some(10)); + assert_eq!(forwarded.sender(), None); + } + + #[test] + fn relay_signer_key_rotation_accepts_previous_key_history() { + let old_signer_keyring = Keyring::generate(); + let current_signer_keyring = Keyring::generate(); + let recipient = Keyring::generate(); + let old_signer = ed_signer(&old_signer_keyring); + let old_signer_public = old_signer_keyring.public_key_bundle(); + let current_signer_public = current_signer_keyring.public_key_bundle(); + let frame = SealedRelayBuilder::new( + "ProtectedMessage", + DataValue::Str("signed with the previous key".into()), + 77, + 42, + 9, + &old_signer, + ) + .message_id("rotation-1") + .created_at(456) + .metadata(DataValue::Str("rotation metadata".into())) + .metadata_recipients(vec![recipient.public_key_bundle()]) + .content_recipients(vec![recipient.public_key_bundle()]) + .build() + .expect("relay frame"); + let policy = ProtectionPolicy::from(crate::SignaturePolicy::Ed25519); + + let metadata = open_relay_metadata_with( + &frame, + &[&recipient], + Some(77), + |signer_id| { + (signer_id == 77) + .then(|| vec![current_signer_public.clone(), old_signer_public.clone()]) + }, + policy, + None, + ) + .expect("metadata signed by a previous key should verify"); + assert_eq!(metadata.matched_signer_key_index(), 1); + let content = open_relay_content_with_keys( + &metadata, + &recipient, + &[current_signer_public, old_signer_public], + 42, + policy, + ) + .expect("content signed by a previous key should verify"); + assert_eq!(content.message_type, "ProtectedMessage"); + assert_eq!( + content.content, + DataValue::Str("signed with the previous key".into()) + ); + } + + #[test] + fn builder_preserves_the_negotiated_type_map_context() { + let sender = Keyring::generate(); + let recipient = Keyring::generate(); + let signer = ed_signer(&sender); + let type_map = TypeMap::latest(); + let frame = SealedRelayBuilder::new("ProtectedMessage", DataValue::Null, 7, 42, 9, &signer) + .message_id("message-4") + .created_at(123) + .metadata_recipients(vec![recipient.public_key_bundle()]) + .content_recipients(vec![recipient.public_key_bundle()]) + .type_map(&type_map) + .build() + .expect("relay frame"); + + assert_eq!(frame.type_map(), Some(&type_map)); + } + + #[test] + fn builder_rejects_reserved_application_message_types() { + let sender = Keyring::generate(); + let recipient = Keyring::generate(); + let signer = ed_signer(&sender); + let result = SealedRelayBuilder::new("Ping", DataValue::Null, 7, 42, 9, &signer) + .message_id("reserved-message") + .created_at(123) + .metadata_recipients(vec![recipient.public_key_bundle()]) + .content_recipients(vec![recipient.public_key_bundle()]) + .build(); + + assert!(matches!( + result, + Err(RelayError::ReservedApplicationType(type_name)) if type_name == "Ping" + )); + } +} diff --git a/common/src/lib.rs b/common/src/lib.rs index f4279d2..c6d4c94 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -1,5 +1,32 @@ use thiserror::Error; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// Errors returned when the system clock cannot be represented as MTP time. +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +pub enum TimeError { + #[error("system clock is before the Unix epoch")] + BeforeUnixEpoch, + #[error("Unix epoch milliseconds exceed the u64 range")] + OutOfRange, +} + +fn duration_to_unix_time_millis(duration: Duration) -> Result { + u64::try_from(duration.as_millis()).map_err(|_| TimeError::OutOfRange) +} + +/// Return the current Unix time in milliseconds. +/// +/// MTP protocol fields that use `CreatedAt` store this value as an unsigned +/// integer. The conversion is centralized here so native writers do not +/// accidentally use seconds. +pub fn unix_time_millis() -> Result { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| TimeError::BeforeUnixEpoch)?; + duration_to_unix_time_millis(duration) +} + #[derive(Clone, Debug, Error, PartialEq, Eq)] pub enum CodecError { #[error("Unknown version")] @@ -25,6 +52,24 @@ pub enum CodecError { mod tests { use super::*; + #[test] + fn unix_time_millis_preserves_subsecond_precision() { + let duration = Duration::new(1_786_449_600, 123_000_000); + assert_eq!( + duration_to_unix_time_millis(duration), + Ok(1_786_449_600_123) + ); + } + + #[test] + fn unix_time_millis_rejects_values_outside_u64() { + let duration = Duration::new(u64::MAX, 0); + assert_eq!( + duration_to_unix_time_millis(duration), + Err(TimeError::OutOfRange) + ); + } + #[test] fn test_codec_error_display() { let e = CodecError::InvalidEncoding; diff --git a/crypto/Cargo.toml b/crypto/Cargo.toml index 3d24e12..11e78f0 100644 --- a/crypto/Cargo.toml +++ b/crypto/Cargo.toml @@ -16,9 +16,9 @@ ed25519-dalek = { version = "3.0", optional = true, features = [ hkdf = { version = "0.13", optional = true } sha2 = { version = "0.11", optional = true } zeroize = { version = "1.9", features = ["derive"] } -thiserror = "2" -base64 = "0.23" -rand_core = { version = "0.10.1" } +thiserror = "1" +base64 = "0.22" +rand_core = { version = "0.6", features = ["getrandom"] } rand = "0.10.2" getrandom = "0.4.3" mlkem-tls = { version = "0.2", optional = true } diff --git a/crypto/src/aead.rs b/crypto/src/aead.rs index 6f1dbed..af8b942 100644 --- a/crypto/src/aead.rs +++ b/crypto/src/aead.rs @@ -6,6 +6,15 @@ use zeroize::Zeroizing; #[cfg(any(feature = "chacha20poly1305", feature = "aes-gcm"))] use getrandom::fill; +/// Authentication-tag length shared by the supported AEAD constructions. +pub const AUTH_TAG_LEN: usize = 16; + +/// Nonce length stored at the front of an XChaCha20-Poly1305 output. +pub const XCHACHA20POLY1305_NONCE_LEN: usize = 24; + +/// Nonce length stored at the front of an AES-256-GCM output. +pub const AES256GCM_NONCE_LEN: usize = 12; + pub trait AeadEncrypt { fn encrypt(&self, plaintext: &[u8], aad: &[u8]) -> Result, CryptoError>; } @@ -27,12 +36,12 @@ fn prepend_nonce(nonce: &[u8], ciphertext: &mut Vec) -> Vec { } #[cfg(feature = "chacha20poly1305")] -pub struct ChaCha20Poly1305 { +pub struct XChaCha20Poly1305 { key: Zeroizing<[u8; 32]>, } #[cfg(feature = "chacha20poly1305")] -impl ChaCha20Poly1305 { +impl XChaCha20Poly1305 { pub fn new(key: [u8; 32]) -> Self { Self { key: Zeroizing::new(key), @@ -41,7 +50,7 @@ impl ChaCha20Poly1305 { } #[cfg(feature = "chacha20poly1305")] -impl AeadEncrypt for ChaCha20Poly1305 { +impl AeadEncrypt for XChaCha20Poly1305 { fn encrypt(&self, plaintext: &[u8], aad: &[u8]) -> Result, CryptoError> { use chacha20poly1305::XChaCha20Poly1305; use chacha20poly1305::XNonce; @@ -50,7 +59,7 @@ impl AeadEncrypt for ChaCha20Poly1305 { let key = chacha20poly1305::Key::from_slice(self.key.as_ref()); let cipher = XChaCha20Poly1305::new(key); - let mut nonce = [0u8; 24]; + let mut nonce = [0u8; XCHACHA20POLY1305_NONCE_LEN]; fill(&mut nonce).map_err(|_| CryptoError::EncryptionFailed)?; let nonce_ref = XNonce::from_slice(&nonce); @@ -68,17 +77,17 @@ impl AeadEncrypt for ChaCha20Poly1305 { } #[cfg(feature = "chacha20poly1305")] -impl AeadDecrypt for ChaCha20Poly1305 { +impl AeadDecrypt for XChaCha20Poly1305 { fn decrypt(&self, ciphertext: &[u8], aad: &[u8]) -> Result, CryptoError> { use chacha20poly1305::XChaCha20Poly1305; use chacha20poly1305::XNonce; use chacha20poly1305::aead::{Aead, KeyInit, Payload}; - if ciphertext.len() < 24 { + if ciphertext.len() < XCHACHA20POLY1305_NONCE_LEN + AUTH_TAG_LEN { return Err(CryptoError::InvalidNonceLength); } - let (nonce, ct) = ciphertext.split_at(24); + let (nonce, ct) = ciphertext.split_at(XCHACHA20POLY1305_NONCE_LEN); let key = chacha20poly1305::Key::from_slice(self.key.as_ref()); let cipher = XChaCha20Poly1305::new(key); let nonce_ref = XNonce::from_slice(nonce); @@ -92,12 +101,17 @@ impl AeadDecrypt for ChaCha20Poly1305 { } #[cfg(feature = "chacha20poly1305")] -impl AeadCipher for ChaCha20Poly1305 { +impl AeadCipher for XChaCha20Poly1305 { fn key_size() -> usize { 32 } } +/// Compatibility alias for the original public name. The implementation is +/// XChaCha20-Poly1305, including its 24-byte nonce format. +#[cfg(feature = "chacha20poly1305")] +pub type ChaCha20Poly1305 = XChaCha20Poly1305; + #[cfg(feature = "aes-gcm")] pub struct Aes256Gcm { key: Zeroizing<[u8; 32]>, @@ -122,7 +136,7 @@ impl AeadEncrypt for Aes256Gcm { let key = aes_gcm::Key::::from_slice(self.key.as_ref()); let cipher = AesGcmInner::new(key); - let mut nonce = [0u8; 12]; + let mut nonce = [0u8; AES256GCM_NONCE_LEN]; fill(&mut nonce).map_err(|_| CryptoError::EncryptionFailed)?; let nonce_ref = Nonce::from_slice(&nonce); @@ -146,11 +160,11 @@ impl AeadDecrypt for Aes256Gcm { use aes_gcm::Nonce; use aes_gcm::aead::{Aead, KeyInit, Payload}; - if ciphertext.len() < 12 { + if ciphertext.len() < AES256GCM_NONCE_LEN + AUTH_TAG_LEN { return Err(CryptoError::InvalidNonceLength); } - let (nonce, ct) = ciphertext.split_at(12); + let (nonce, ct) = ciphertext.split_at(AES256GCM_NONCE_LEN); let key = aes_gcm::Key::::from_slice(self.key.as_ref()); let cipher = AesGcmInner::new(key); let nonce_ref = Nonce::from_slice(nonce); diff --git a/crypto/src/enc.rs b/crypto/src/enc.rs index e03e716..2ba0215 100644 --- a/crypto/src/enc.rs +++ b/crypto/src/enc.rs @@ -1,19 +1,15 @@ #[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] use crate::error::CryptoError; -#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] -use crate::kdf::derive_encryption_key; -#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] +#[cfg(feature = "mlkem-tls")] use crate::kem::HybridKem; -#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] -use crate::keypair::{Keyring, PublicKeyBundle}; /* - * Algorithm selector for encrypted containers. + * Algorithm selector for encrypted values. * * Mirrors `SigAlgorithm` for signatures: a single marking byte identifies the * key-encapsulation mechanism and the AEAD used to seal a container. The byte - * is stored as the first byte of every encrypted blob so the decryptor can pick + * is stored as the first byte of every encrypted envelope so the decryptor can pick * the matching algorithm (and the matching keypair from a `Keyring`) without * any out-of-band agreement. * @@ -34,7 +30,7 @@ impl EncryptionType { pub const ML_KEM_CHACHA20POLY1305: u8 = 0x01; pub const ML_KEM_AES256_GCM: u8 = 0x02; - /// The marking byte written at the front of an encrypted blob. + /// The marking byte written at the front of an encrypted envelope. pub const fn to_byte(self) -> u8 { match self { Self::MlKemChaCha20Poly1305 => Self::ML_KEM_CHACHA20POLY1305, @@ -50,6 +46,50 @@ impl EncryptionType { _ => None, } } + + /// Size of the content-encryption key wrapped for each recipient. + pub const CONTENT_ENCRYPTION_KEY_LEN: usize = 32; + + /// The fixed-size ciphertext emitted by the KEM selected by this suite. + pub const fn kem_ciphertext_len(self) -> usize { + match self { + Self::MlKemChaCha20Poly1305 | Self::MlKemAes256Gcm => { + #[cfg(feature = "mlkem-tls")] + { + HybridKem::ciphertext_len() + } + #[cfg(not(feature = "mlkem-tls"))] + { + 0 + } + } + } + } + + /// Bytes the selected AEAD prepends/appends to an encrypted payload. + pub const fn aead_overhead(self) -> usize { + match self { + Self::MlKemChaCha20Poly1305 => { + crate::aead::XCHACHA20POLY1305_NONCE_LEN + crate::aead::AUTH_TAG_LEN + } + Self::MlKemAes256Gcm => crate::aead::AES256GCM_NONCE_LEN + crate::aead::AUTH_TAG_LEN, + } + } + + /// Total output length for an encrypted plaintext of `plaintext_len` bytes. + pub const fn encrypted_len(self, plaintext_len: usize) -> usize { + plaintext_len.saturating_add(self.aead_overhead()) + } + + /// Minimum valid AEAD output length for this suite. + pub const fn minimum_ciphertext_len(self) -> usize { + self.encrypted_len(0) + } + + /// The size of a wrapped 32-byte content key for this suite. + pub const fn wrapped_key_len(self) -> usize { + self.encrypted_len(Self::CONTENT_ENCRYPTION_KEY_LEN) + } } /* @@ -59,7 +99,7 @@ impl EncryptionType { */ #[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] #[allow(unused_variables)] -fn aead_seal( +pub fn seal_with_key( enc_type: EncryptionType, key: [u8; 32], plaintext: &[u8], @@ -70,7 +110,7 @@ fn aead_seal( match enc_type { #[cfg(feature = "chacha20poly1305")] EncryptionType::MlKemChaCha20Poly1305 => { - crate::aead::ChaCha20Poly1305::new(key).encrypt(plaintext, aad) + crate::aead::XChaCha20Poly1305::new(key).encrypt(plaintext, aad) } #[cfg(feature = "aes-gcm")] EncryptionType::MlKemAes256Gcm => crate::aead::Aes256Gcm::new(key).encrypt(plaintext, aad), @@ -86,7 +126,7 @@ fn aead_seal( */ #[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] #[allow(unused_variables)] -fn aead_open( +pub fn open_with_key( enc_type: EncryptionType, key: [u8; 32], ciphertext: &[u8], @@ -97,7 +137,7 @@ fn aead_open( match enc_type { #[cfg(feature = "chacha20poly1305")] EncryptionType::MlKemChaCha20Poly1305 => { - crate::aead::ChaCha20Poly1305::new(key).decrypt(ciphertext, aad) + crate::aead::XChaCha20Poly1305::new(key).decrypt(ciphertext, aad) } #[cfg(feature = "aes-gcm")] EncryptionType::MlKemAes256Gcm => crate::aead::Aes256Gcm::new(key).decrypt(ciphertext, aad), @@ -106,76 +146,51 @@ fn aead_open( } } -#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] -const ENC_KDF_SALT: &[u8] = b"mtp-container-enc"; -#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] -const ENC_KDF_CONTEXT: &[u8] = b"single-recipient"; - -/* - * Encrypt `plaintext` for a single recipient, selecting the algorithm with - * `enc_type` and the recipient's KEM public key from `recipient`. - * - * The returned, self-describing blob is laid out as: - * [1 byte EncryptionType] [2 bytes u16 kem_ct_len] [kem_ciphertext] [aead_payload] - * where `aead_payload` is the AEAD output (nonce + ciphertext + tag). The AEAD - * key is derived from the KEM shared secret via HKDF, so no separate content key - * is transmitted. - * - * Requires the `mlkem-tls` and `hkdf` features, plus the AEAD feature backing - * `enc_type`. - */ -#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] -pub fn encrypt_for( - enc_type: EncryptionType, - recipient: &PublicKeyBundle, - plaintext: &[u8], - aad: &[u8], -) -> Result, CryptoError> { - let enc = HybridKem::encapsulate(&recipient.kem_public_key)?; - let key = derive_encryption_key(&enc.shared_secret, ENC_KDF_SALT, ENC_KDF_CONTEXT)?; - let aead_payload = aead_seal(enc_type, key, plaintext, aad)?; - - let kem_ct = enc.ciphertext; - let mut out = Vec::with_capacity(1 + 2 + kem_ct.len() + aead_payload.len()); - out.push(enc_type.to_byte()); - out.extend_from_slice(&(kem_ct.len() as u16).to_be_bytes()); - out.extend_from_slice(&kem_ct); - out.extend_from_slice(&aead_payload); - Ok(out) -} - -/* - * Decrypt a blob produced by [`encrypt_for`] using `keyring`. - * - * The leading byte selects the `EncryptionType` (and thus which keypair to use - * from the keyring); for the current ML-KEM variants that is `kem_secret_key`. - * Returns `DecryptionFailed` on any malformed input or authentication failure. - * - * Requires the `mlkem-tls` and `hkdf` features, plus the AEAD feature backing - * the blob's algorithm. - */ -#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] -pub fn decrypt_with(blob: &[u8], keyring: &Keyring, aad: &[u8]) -> Result, CryptoError> { - if blob.len() < 3 { - return Err(CryptoError::DecryptionFailed); - } - let enc_type = EncryptionType::from_byte(blob[0]).ok_or(CryptoError::DecryptionFailed)?; - let kem_ct_len = u16::from_be_bytes([blob[1], blob[2]]) as usize; - let kem_end = 3usize - .checked_add(kem_ct_len) - .ok_or(CryptoError::DecryptionFailed)?; - let kem_ct = blob.get(3..kem_end).ok_or(CryptoError::DecryptionFailed)?; - let aead_payload = blob.get(kem_end..).ok_or(CryptoError::DecryptionFailed)?; - - let shared_secret = HybridKem::decapsulate(&keyring.kem_secret_key, kem_ct)?; - let key = derive_encryption_key(&shared_secret, ENC_KDF_SALT, ENC_KDF_CONTEXT)?; - aead_open(enc_type, key, aead_payload, aad) -} - #[cfg(test)] mod tests { use super::*; + #[cfg(feature = "mlkem-tls")] + #[test] + fn envelope_parser_uses_suite_dependent_fixed_widths() { + use crate::helper::{MultiEncryptedMessage, RecipientEntry}; + + let suites = [ + EncryptionType::MlKemChaCha20Poly1305, + EncryptionType::MlKemAes256Gcm, + ]; + assert_ne!(suites[0].wrapped_key_len(), suites[1].wrapped_key_len()); + + for (index, suite) in suites.into_iter().enumerate() { + let marker = u8::try_from(index).unwrap(); + let message = MultiEncryptedMessage { + encryption_type: suite, + purpose: 0xA5, + recipients: vec![RecipientEntry { + kem_ciphertext: vec![0x10 + marker; suite.kem_ciphertext_len()], + encrypted_key: vec![0x20 + marker; suite.wrapped_key_len()], + }], + ciphertext: vec![0x30 + marker; suite.minimum_ciphertext_len() + 3], + }; + let encoded = message.to_bytes().expect("synthetic envelope is valid"); + let kem_end = 4 + suite.kem_ciphertext_len(); + let wrapped_end = kem_end + suite.wrapped_key_len(); + + assert_eq!(&encoded[..4], &[suite.to_byte(), 0xA5, 0, 1]); + assert_eq!(&encoded[4..kem_end], message.recipients[0].kem_ciphertext); + assert_eq!( + &encoded[kem_end..wrapped_end], + message.recipients[0].encrypted_key + ); + assert_eq!(&encoded[wrapped_end..], message.ciphertext); + assert_eq!( + MultiEncryptedMessage::from_bytes(&encoded) + .expect("suite-specific envelope should parse"), + message + ); + } + } + #[test] fn encryption_type_byte_roundtrip() { for t in [ @@ -188,59 +203,19 @@ mod tests { assert_eq!(EncryptionType::from_byte(0xFF), None); } - #[cfg(all(feature = "mlkem-tls", feature = "hkdf", feature = "chacha20poly1305"))] #[test] - fn encrypt_for_roundtrip() -> Result<(), CryptoError> { - let kr = Keyring::generate(); - let blob = encrypt_for( - EncryptionType::MlKemChaCha20Poly1305, - &kr.public_key_bundle(), - b"secret payload", - b"aad", - )?; - assert_eq!(blob[0], EncryptionType::ML_KEM_CHACHA20POLY1305); - - let pt = decrypt_with(&blob, &kr, b"aad")?; - assert_eq!(pt, b"secret payload"); - Ok(()) - } - - #[cfg(all(feature = "mlkem-tls", feature = "hkdf", feature = "chacha20poly1305"))] - #[test] - fn decrypt_with_wrong_keyring_fails() -> Result<(), CryptoError> { - let kr = Keyring::generate(); - let other = Keyring::generate(); - let blob = encrypt_for( - EncryptionType::MlKemChaCha20Poly1305, - &kr.public_key_bundle(), - b"secret", - b"aad", - )?; - assert!(decrypt_with(&blob, &other, b"aad").is_err()); - Ok(()) - } - - #[cfg(all(feature = "mlkem-tls", feature = "hkdf", feature = "chacha20poly1305"))] - #[test] - fn decrypt_with_wrong_aad_fails() -> Result<(), CryptoError> { - let kr = Keyring::generate(); - let blob = encrypt_for( - EncryptionType::MlKemChaCha20Poly1305, - &kr.public_key_bundle(), - b"secret", - b"right", - )?; - assert!(decrypt_with(&blob, &kr, b"wrong").is_err()); - Ok(()) - } - - #[cfg(all(feature = "mlkem-tls", feature = "hkdf", feature = "chacha20poly1305"))] - #[test] - fn decrypt_with_malformed_fails() { - let kr = Keyring::generate(); - assert!(decrypt_with(b"", &kr, b"").is_err()); - assert!(decrypt_with(&[0x01, 0x00], &kr, b"").is_err()); - // Unknown algorithm byte. - assert!(decrypt_with(&[0x7F, 0x00, 0x00], &kr, b"").is_err()); + fn suite_lengths_are_derived_from_the_selected_primitives() { + assert_eq!( + EncryptionType::MlKemChaCha20Poly1305.wrapped_key_len(), + EncryptionType::CONTENT_ENCRYPTION_KEY_LEN + + crate::aead::XCHACHA20POLY1305_NONCE_LEN + + crate::aead::AUTH_TAG_LEN + ); + assert_eq!( + EncryptionType::MlKemAes256Gcm.wrapped_key_len(), + EncryptionType::CONTENT_ENCRYPTION_KEY_LEN + + crate::aead::AES256GCM_NONCE_LEN + + crate::aead::AUTH_TAG_LEN + ); } } diff --git a/crypto/src/error.rs b/crypto/src/error.rs index 0d97c44..3e70a5f 100644 --- a/crypto/src/error.rs +++ b/crypto/src/error.rs @@ -6,8 +6,16 @@ pub enum CryptoError { EncryptionFailed, #[error("decryption failed")] DecryptionFailed, + #[error("malformed encryption envelope")] + MalformedEnvelope, + #[error("no encryption recipients")] + NoRecipients, + #[error("no matching encryption recipient")] + NoMatchingRecipient, #[error("invalid key length")] InvalidKeyLength, + #[error("public and private key material do not match")] + InvalidKeyMaterial, #[error("invalid nonce length")] InvalidNonceLength, #[error("invalid signature")] diff --git a/crypto/src/helper.rs b/crypto/src/helper.rs index b89ce37..b8a0e8f 100644 --- a/crypto/src/helper.rs +++ b/crypto/src/helper.rs @@ -1,208 +1,328 @@ +// Canonical multi-recipient encryption envelopes. + +use crate::enc::EncryptionType; use crate::error::CryptoError; -#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))] -use crate::aead::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305}; -#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))] +#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] +use crate::enc::{open_with_key, seal_with_key}; +#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] use crate::kdf::derive_encryption_key; -#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))] +#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] use crate::kem::HybridKem; -#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))] +#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] use crate::keypair::{Keyring, PublicKeyBundle}; -#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))] +#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] use rand::Rng; -#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))] +#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] use zeroize::Zeroizing; +pub const ENCRYPT_DOMAIN: &[u8] = b"MTP-DATA-ENC-1"; +pub const KEY_WRAP_DOMAIN: &[u8] = b"MTP-DATA-WRAP-1"; +/// Operational cap for recipient entries accepted in one envelope. +/// +/// The wire count remains a `u16` for format stability, but decapsulation is +/// intentionally bounded because each entry can require a KEM operation. +pub const MAX_RECIPIENTS: usize = 64; + +#[derive(Debug, Clone, PartialEq, Eq)] pub struct RecipientEntry { pub kem_ciphertext: Vec, pub encrypted_key: Vec, } -/* - * A payload encrypted for multiple recipients. - * - * Any recipient who possesses the corresponding `KemPrivateKey` can decrypt the message. - */ +/// The envelope body used by `DataValue::Encrypted`. +#[derive(Debug, Clone, PartialEq, Eq)] pub struct MultiEncryptedMessage { + pub encryption_type: EncryptionType, + pub purpose: u8, pub recipients: Vec, - pub nonce: [u8; 24], + /// The AEAD output, including its nonce as defined by the selected suite. pub ciphertext: Vec, } impl MultiEncryptedMessage { - /* - * Serialize into a compact byte vector. - * - * Format: - * - `num_recipients: u16` - * - for each recipient: - * - `kem_ct_len: u16` | `kem_ciphertext` - * - `ek_len: u16` | `encrypted_key` - * - `nonce: 24 bytes` - * - `ciphertext` (remaining) - */ - pub fn to_bytes(&self) -> Vec { + /// Serialize the envelope body without redundant per-recipient lengths. + pub fn to_bytes(&self) -> Result, CryptoError> { + let kem_len = self.encryption_type.kem_ciphertext_len(); + let wrapped_len = self.encryption_type.wrapped_key_len(); + let count = + u16::try_from(self.recipients.len()).map_err(|_| CryptoError::EncryptionFailed)?; + if self.recipients.is_empty() + || self.recipients.len() > MAX_RECIPIENTS + || self.ciphertext.len() < self.encryption_type.minimum_ciphertext_len() + || self + .recipients + .iter() + .any(|r| r.kem_ciphertext.len() != kem_len || r.encrypted_key.len() != wrapped_len) + { + return Err(CryptoError::MalformedEnvelope); + } + let mut out = Vec::new(); - out.extend_from_slice(&(self.recipients.len() as u16).to_be_bytes()); - for r in &self.recipients { - out.extend_from_slice(&(r.kem_ciphertext.len() as u16).to_be_bytes()); - out.extend_from_slice(&r.kem_ciphertext); - out.extend_from_slice(&(r.encrypted_key.len() as u16).to_be_bytes()); - out.extend_from_slice(&r.encrypted_key); + out.push(self.encryption_type.to_byte()); + out.push(self.purpose); + out.extend_from_slice(&count.to_be_bytes()); + for recipient in &self.recipients { + out.extend_from_slice(&recipient.kem_ciphertext); + out.extend_from_slice(&recipient.encrypted_key); } - out.extend_from_slice(&self.nonce); out.extend_from_slice(&self.ciphertext); - out + Ok(out) } - /// Deserialize from bytes produced by `to_bytes`. + /// Parse the canonical envelope body. pub fn from_bytes(bytes: &[u8]) -> Result { - let mut offset = 0; - let read_u16 = |off: &mut usize| -> Result { - let slice = bytes - .get(*off..*off + 2) - .ok_or(CryptoError::DecryptionFailed)?; - let arr: [u8; 2] = slice - .try_into() - .map_err(|_| CryptoError::DecryptionFailed)?; - *off += 2; - Ok(u16::from_be_bytes(arr)) - }; - - let num = read_u16(&mut offset)? as usize; - let mut recipients = Vec::with_capacity(num); - for _ in 0..num { - let klen = read_u16(&mut offset)? as usize; - let kem_ct = bytes - .get(offset..offset + klen) - .ok_or(CryptoError::DecryptionFailed)? - .to_vec(); - offset += klen; - - let elen = read_u16(&mut offset)? as usize; - let enc_key = bytes - .get(offset..offset + elen) - .ok_or(CryptoError::DecryptionFailed)? - .to_vec(); - offset += elen; - - recipients.push(RecipientEntry { - kem_ciphertext: kem_ct, - encrypted_key: enc_key, - }); + if bytes.len() < 4 { + return Err(CryptoError::MalformedEnvelope); + } + let encryption_type = + EncryptionType::from_byte(bytes[0]).ok_or(CryptoError::UnknownAlgorithm)?; + let purpose = bytes[1]; + let count = u16::from_be_bytes([bytes[2], bytes[3]]) as usize; + if count == 0 || count > MAX_RECIPIENTS { + return Err(CryptoError::MalformedEnvelope); + } + let entry_len = encryption_type.kem_ciphertext_len() + encryption_type.wrapped_key_len(); + let entries_len = count + .checked_mul(entry_len) + .ok_or(CryptoError::MalformedEnvelope)?; + let start = 4usize; + let end = start + .checked_add(entries_len) + .ok_or(CryptoError::MalformedEnvelope)?; + let ciphertext_len = bytes + .len() + .checked_sub(end) + .ok_or(CryptoError::MalformedEnvelope)?; + if ciphertext_len < encryption_type.minimum_ciphertext_len() { + return Err(CryptoError::MalformedEnvelope); } - let nonce: [u8; 24] = bytes - .get(offset..offset + 24) - .ok_or(CryptoError::DecryptionFailed)? - .try_into() - .map_err(|_| CryptoError::DecryptionFailed)?; - offset += 24; - - let ciphertext = bytes - .get(offset..) - .ok_or(CryptoError::DecryptionFailed)? - .to_vec(); + let mut offset = start; + let mut recipients = Vec::with_capacity(count); + for _ in 0..count { + let kem_end = offset + encryption_type.kem_ciphertext_len(); + let wrapped_end = kem_end + encryption_type.wrapped_key_len(); + recipients.push(RecipientEntry { + kem_ciphertext: bytes[offset..kem_end].to_vec(), + encrypted_key: bytes[kem_end..wrapped_end].to_vec(), + }); + offset = wrapped_end; + } Ok(Self { + encryption_type, + purpose, recipients, - nonce, - ciphertext, + ciphertext: bytes[offset..].to_vec(), }) } } -/* - * Encrypt `plaintext` for every recipient in `entities`. - * - * Internally generates a fresh content-encryption key, encrypts the payload - * with ChaCha20-Poly1305, then KEM-encapsulates and wraps the key for each - * recipient. The returned `MultiEncryptedMessage` can be decrypted by any - * entity whose keyring contains the corresponding private KEM key. - * - * Requires the `pqc` and `chacha20poly1305` features. - */ -#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))] -pub fn encrypt_multi( +#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] +fn wrap_aad(encryption_type: EncryptionType, purpose: u8, kem_ciphertext: &[u8]) -> Vec { + let mut aad = Vec::with_capacity(KEY_WRAP_DOMAIN.len() + 2 + kem_ciphertext.len()); + aad.extend_from_slice(KEY_WRAP_DOMAIN); + aad.push(encryption_type.to_byte()); + aad.push(purpose); + aad.extend_from_slice(kem_ciphertext); + aad +} + +#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] +fn payload_aad(message: &MultiEncryptedMessage) -> Result, CryptoError> { + let count = + u16::try_from(message.recipients.len()).map_err(|_| CryptoError::MalformedEnvelope)?; + let mut aad = Vec::new(); + aad.extend_from_slice(ENCRYPT_DOMAIN); + aad.push(message.encryption_type.to_byte()); + aad.push(message.purpose); + aad.extend_from_slice(&count.to_be_bytes()); + for recipient in &message.recipients { + aad.extend_from_slice(&recipient.kem_ciphertext); + aad.extend_from_slice(&recipient.encrypted_key); + } + Ok(aad) +} + +/// Encrypt a value for one or more recipients using the canonical envelope. +#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] +pub fn encrypt_multi_for( + encryption_type: EncryptionType, + purpose: u8, plaintext: &[u8], - aad: &[u8], entities: &[PublicKeyBundle], ) -> Result { + if entities.is_empty() { + return Err(CryptoError::NoRecipients); + } + if entities.len() > MAX_RECIPIENTS { + return Err(CryptoError::EncryptionFailed); + } + let mut cek = Zeroizing::new([0u8; 32]); rand::rng().fill_bytes(cek.as_mut()); - let cipher = ChaCha20Poly1305::new(*cek); - let encrypted_payload = cipher.encrypt(plaintext, aad)?; - - let nonce: [u8; 24] = encrypted_payload[..24] - .try_into() - .map_err(|_| CryptoError::EncryptionFailed)?; - let ciphertext = encrypted_payload[24..].to_vec(); - let mut recipients = Vec::with_capacity(entities.len()); for entity in entities { let enc = HybridKem::encapsulate(&entity.kem_public_key)?; let wrap_key = Zeroizing::new(derive_encryption_key( &enc.shared_secret, - b"mtp-multi-key-wrap", - b"multi-recipient", + KEY_WRAP_DOMAIN, + &[encryption_type.to_byte(), purpose], )?); - - let wrap_cipher = ChaCha20Poly1305::new(*wrap_key); - let encrypted_key = wrap_cipher.encrypt(cek.as_ref(), b"")?; - + let aad = wrap_aad(encryption_type, purpose, &enc.ciphertext); + let encrypted_key = seal_with_key(encryption_type, *wrap_key, cek.as_ref(), &aad)?; recipients.push(RecipientEntry { kem_ciphertext: enc.ciphertext, encrypted_key, }); } - Ok(MultiEncryptedMessage { + let mut message = MultiEncryptedMessage { + encryption_type, + purpose, recipients, - nonce, - ciphertext, - }) + ciphertext: Vec::new(), + }; + let aad = payload_aad(&message)?; + message.ciphertext = seal_with_key(encryption_type, *cek, plaintext, &aad)?; + Ok(message) } -/* - * Decrypt a `MultiEncryptedMessage` using the recipient's `Keyring`. - * - * Tries each `RecipientEntry` until one succeeds with the given keyring's - * KEM secret key. Returns the original plaintext. - */ -#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))] -pub fn decrypt_multi( - msg: &MultiEncryptedMessage, - aad: &[u8], +/// Decrypt a canonical envelope for a recipient in `keyring`. +#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] +pub fn decrypt_multi_for( + message: &MultiEncryptedMessage, + purpose: u8, keyring: &Keyring, ) -> Result, CryptoError> { - for entry in &msg.recipients { - let ss = match HybridKem::decapsulate(&keyring.kem_secret_key, &entry.kem_ciphertext) { - Ok(s) => s, - Err(_) => continue, - }; + if message.recipients.is_empty() + || message.recipients.len() > MAX_RECIPIENTS + || message.purpose != purpose + || message.ciphertext.len() < message.encryption_type.minimum_ciphertext_len() + || message.recipients.iter().any(|recipient| { + recipient.kem_ciphertext.len() != message.encryption_type.kem_ciphertext_len() + || recipient.encrypted_key.len() != message.encryption_type.wrapped_key_len() + }) + { + return Err(CryptoError::MalformedEnvelope); + } + + let payload_aad = payload_aad(message)?; + for entry in &message.recipients { + let shared_secret = + match HybridKem::decapsulate(&keyring.kem_secret_key, &entry.kem_ciphertext) { + Ok(secret) => secret, + Err(_) => continue, + }; let wrap_key = Zeroizing::new(derive_encryption_key( - &ss, - b"mtp-multi-key-wrap", - b"multi-recipient", + &shared_secret, + KEY_WRAP_DOMAIN, + &[message.encryption_type.to_byte(), purpose], )?); - let wrap_cipher = ChaCha20Poly1305::new(*wrap_key); - let cek = match wrap_cipher.decrypt(&entry.encrypted_key, b"") { - Ok(k) => Zeroizing::new(k), + let aad = wrap_aad(message.encryption_type, purpose, &entry.kem_ciphertext); + let cek = match open_with_key( + message.encryption_type, + *wrap_key, + &entry.encrypted_key, + &aad, + ) { + Ok(key) => key, Err(_) => continue, }; - let cek_arr = Zeroizing::new( - cek.as_slice() - .try_into() - .map_err(|_| CryptoError::DecryptionFailed)?, + let cek: [u8; 32] = cek.try_into().map_err(|_| CryptoError::DecryptionFailed)?; + return open_with_key( + message.encryption_type, + cek, + &message.ciphertext, + &payload_aad, + ); + } + + Err(CryptoError::NoMatchingRecipient) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recipient_count_is_operationally_bounded() { + assert!(matches!( + MultiEncryptedMessage::from_bytes(&[EncryptionType::ML_KEM_CHACHA20POLY1305, 0, 0, 65]), + Err(CryptoError::MalformedEnvelope) + )); + } + + #[cfg(all(feature = "mlkem-tls", feature = "hkdf", feature = "chacha20poly1305"))] + #[test] + fn authenticated_envelope_fields_reject_tampering() -> Result<(), CryptoError> { + let recipient_a = Keyring::generate(); + let recipient_b = Keyring::generate(); + let message = encrypt_multi_for( + EncryptionType::MlKemChaCha20Poly1305, + 7, + b"authenticated payload", + &[ + recipient_a.public_key_bundle(), + recipient_b.public_key_bundle(), + ], + )?; + assert_eq!( + decrypt_multi_for(&message, message.purpose, &recipient_a)?, + b"authenticated payload" ); - let mut full_ct = Vec::with_capacity(24 + msg.ciphertext.len()); - full_ct.extend_from_slice(&msg.nonce); - full_ct.extend_from_slice(&msg.ciphertext); + let mut wrong_purpose = message.clone(); + wrong_purpose.purpose ^= 1; + assert!( + decrypt_multi_for(&wrong_purpose, wrong_purpose.purpose, &recipient_a).is_err(), + "mutating the encryption purpose must invalidate the envelope" + ); - let data_cipher = ChaCha20Poly1305::new(*cek_arr); - return data_cipher.decrypt(&full_ct, aad); + let mut wrong_recipient_table = message.clone(); + wrong_recipient_table.recipients[1].encrypted_key[0] ^= 1; + assert!( + decrypt_multi_for(&wrong_recipient_table, message.purpose, &recipient_a).is_err(), + "mutating another recipient's table entry must invalidate the payload" + ); + + let mut wrong_ciphertext = message; + let last = wrong_ciphertext.ciphertext.len() - 1; + wrong_ciphertext.ciphertext[last] ^= 1; + assert!( + decrypt_multi_for(&wrong_ciphertext, wrong_ciphertext.purpose, &recipient_a).is_err(), + "mutating the ciphertext must invalidate the envelope" + ); + Ok(()) + } + + #[cfg(feature = "mlkem-tls")] + #[test] + fn rejects_envelopes_without_a_complete_aead_payload() { + let encryption_type = EncryptionType::MlKemChaCha20Poly1305; + let message = MultiEncryptedMessage { + encryption_type, + purpose: 1, + recipients: vec![RecipientEntry { + kem_ciphertext: vec![0; encryption_type.kem_ciphertext_len()], + encrypted_key: vec![0; encryption_type.wrapped_key_len()], + }], + ciphertext: vec![0; encryption_type.minimum_ciphertext_len() - 1], + }; + assert!(matches!( + message.to_bytes(), + Err(CryptoError::MalformedEnvelope) + )); + + let mut encoded = vec![encryption_type.to_byte(), 1, 0, 1]; + encoded.extend_from_slice(&vec![0; encryption_type.kem_ciphertext_len()]); + encoded.extend_from_slice(&vec![0; encryption_type.wrapped_key_len()]); + encoded.extend_from_slice(&vec![0; encryption_type.minimum_ciphertext_len() - 1]); + assert!(matches!( + MultiEncryptedMessage::from_bytes(&encoded), + Err(CryptoError::MalformedEnvelope) + )); } - Err(CryptoError::DecryptionFailed) } diff --git a/crypto/src/kem.rs b/crypto/src/kem.rs index b4dcd7e..6995000 100644 --- a/crypto/src/kem.rs +++ b/crypto/src/kem.rs @@ -12,11 +12,13 @@ pub struct HybridKem; #[cfg(feature = "mlkem-tls")] impl HybridKem { + /// Fixed wire size of the KEM ciphertext used by MTP envelopes. + pub const fn ciphertext_len() -> usize { + mlkem_tls::X25519MlKem768::CIPHERTEXT_SIZE + } + pub fn generate_keypair() -> (KemPrivateKey, KemPublicKey) { - /* Obviously: cannot find module or crate rand_core06 in this scope - use of unresolved module or unlinked crate rand_core06 (rustc E0433) */ - let (ek, dk) = - mlkem_tls::X25519MlKem768::keygen(&mut chacha20poly1305::aead::rand_core::OsRng); + let (ek, dk) = mlkem_tls::X25519MlKem768::keygen(&mut rand_core::OsRng); ( KemPrivateKey::new(dk.as_bytes().to_vec()), KemPublicKey::new(ek.as_bytes().to_vec()), @@ -26,10 +28,7 @@ impl HybridKem { pub fn encapsulate(recipient_pk: &KemPublicKey) -> Result { let ek = mlkem_tls::EncapsKey768::try_from(recipient_pk.as_bytes()) .map_err(|_| CryptoError::KemEncapsulationFailed)?; - let (ct, ss) = mlkem_tls::X25519MlKem768::encapsulate( - &ek, - &mut chacha20poly1305::aead::rand_core::OsRng, - ); + let (ct, ss) = mlkem_tls::X25519MlKem768::encapsulate(&ek, &mut rand_core::OsRng); Ok(Encapsulated { ciphertext: ct.as_bytes().to_vec(), shared_secret: Zeroizing::new(ss.as_bytes().to_vec()), diff --git a/crypto/src/keypair.rs b/crypto/src/keypair.rs index 61c5b44..33ab456 100644 --- a/crypto/src/keypair.rs +++ b/crypto/src/keypair.rs @@ -237,7 +237,91 @@ impl Keyring { } } - pub fn to_bytes(&self) -> Zeroizing> { + /// Validate the material required to produce classical signatures. This + /// intentionally permits a browser role-specific keyring without KEM or + /// PQ fields. + pub fn validate_ed25519_signing(&self) -> Result<(), crate::error::CryptoError> { + use crate::error::CryptoError; + if self.sig_cl_secret_key.as_bytes().len() != 32 + || self.sig_cl_public_key.as_bytes().len() != SIG_CL_PUBLIC_KEY_LEN + { + return Err(CryptoError::InvalidKeyLength); + } + #[cfg(feature = "ed25519-dalek")] + { + let signer = crate::sign::Ed25519Signer::new(&self.sig_cl_secret_key)?; + if signer.public_key().as_bytes() != self.sig_cl_public_key.as_bytes() { + return Err(CryptoError::InvalidKeyMaterial); + } + } + Ok(()) + } + + /// Validate material required for a hybrid Ed25519 + ML-DSA signature. + pub fn validate_dual_signing(&self) -> Result<(), crate::error::CryptoError> { + use crate::error::CryptoError; + self.validate_ed25519_signing()?; + if self.sig_pq_secret_key.as_bytes().len() != 32 + || self.sig_pq_public_key.as_bytes().len() != SIG_PQ_PUBLIC_KEY_LEN + { + return Err(CryptoError::InvalidKeyLength); + } + #[cfg(feature = "ml-dsa")] + { + let signer = + crate::sign::MlDsaSigner::new(&self.sig_pq_secret_key, &self.sig_pq_public_key)?; + if signer.public_key().as_bytes() != self.sig_pq_public_key.as_bytes() { + return Err(CryptoError::InvalidKeyMaterial); + } + } + Ok(()) + } + + /// Validate the KEM material required to decrypt envelopes addressed to + /// this keyring. This is intentionally separate from full identity + /// validation because browser and relay roles may use Ed25519-only + /// signing material while still needing a complete encryption key pair. + pub fn validate_encryption(&self) -> Result<(), crate::error::CryptoError> { + use crate::error::CryptoError; + if self.kem_public_key.as_bytes().is_empty() || self.kem_secret_key.as_bytes().is_empty() { + return Err(CryptoError::InvalidKeyLength); + } + #[cfg(feature = "mlkem-tls")] + { + let encapsulated = crate::kem::HybridKem::encapsulate(&self.kem_public_key)?; + let recovered = + crate::kem::HybridKem::decapsulate(&self.kem_secret_key, &encapsulated.ciphertext)?; + if recovered.as_slice() != encapsulated.shared_secret.as_slice() { + return Err(CryptoError::InvalidKeyMaterial); + } + } + Ok(()) + } + + /// Validate a complete identity before using it at a protocol boundary. + /// + /// `Keyring` remains permissive because browser callers may intentionally + /// hold role-specific material. Protocol paths that need encryption and + /// both signing suites should call this method explicitly. + pub fn validate_full(&self) -> Result<(), crate::error::CryptoError> { + use crate::error::CryptoError; + + self.public_key_bundle().validate()?; + self.validate_encryption()?; + if self.sig_pq_secret_key.as_bytes().len() != 32 + || self.sig_cl_secret_key.as_bytes().len() != 32 + { + return Err(CryptoError::InvalidKeyLength); + } + + #[cfg(all(feature = "mlkem-tls", feature = "ml-dsa", feature = "ed25519-dalek"))] + { + self.validate_dual_signing()?; + } + Ok(()) + } + + pub fn try_to_bytes(&self) -> Result>, crate::error::CryptoError> { let fields: &[&[u8]] = &[ self.kem_public_key.as_bytes(), self.kem_secret_key.as_bytes(), @@ -248,10 +332,17 @@ impl Keyring { ]; let mut out = Zeroizing::new(Vec::new()); for f in fields { - out.extend_from_slice(&(f.len() as u16).to_be_bytes()); + let length = + u16::try_from(f.len()).map_err(|_| crate::error::CryptoError::InvalidKeyLength)?; + out.extend_from_slice(&length.to_be_bytes()); out.extend_from_slice(f); } - out + Ok(out) + } + + pub fn to_bytes(&self) -> Zeroizing> { + self.try_to_bytes() + .expect("key material length exceeds wire limit") } pub fn from_bytes(bytes: &[u8]) -> Result { @@ -283,6 +374,13 @@ impl Keyring { sig_cl_public_key: SignaturePublicKey::new(read_key(&mut offset)?), sig_cl_secret_key: SignaturePrivateKey::new(read_key(&mut offset)?), }) + .and_then(|keyring| { + if offset == bytes.len() { + Ok(keyring) + } else { + Err(CryptoError::InvalidKeyLength) + } + }) } pub fn to_hex(&self) -> String { @@ -352,7 +450,6 @@ impl PublicKeyBundle { } } - #[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa", feature = "mlkem-tls"))] pub fn validate(&self) -> Result<(), crate::error::CryptoError> { use crate::error::CryptoError; if self.sig_cl_public_key.as_bytes().len() != SIG_CL_PUBLIC_KEY_LEN { @@ -364,25 +461,70 @@ impl PublicKeyBundle { if self.kem_public_key.as_bytes().len() != KEM_PUBLIC_KEY_LEN { return Err(CryptoError::InvalidKeyLength); } + #[cfg(feature = "ed25519-dalek")] + { + let bytes: [u8; SIG_CL_PUBLIC_KEY_LEN] = self + .sig_cl_public_key + .as_bytes() + .try_into() + .map_err(|_| CryptoError::InvalidKeyLength)?; + ed25519_dalek::VerifyingKey::from_bytes(&bytes) + .map_err(|_| CryptoError::InvalidKeyMaterial)?; + } + #[cfg(feature = "ml-dsa")] + { + let encoded = ml_dsa::EncodedVerifyingKey::::try_from( + self.sig_pq_public_key.as_bytes(), + ) + .map_err(|_| CryptoError::InvalidKeyMaterial)?; + let _ = ml_dsa::VerifyingKey::::decode(&encoded); + } + #[cfg(feature = "mlkem-tls")] + { + crate::kem::HybridKem::encapsulate(&self.kem_public_key) + .map_err(|_| CryptoError::InvalidKeyMaterial)?; + } Ok(()) } - pub fn as_bytes(&self) -> Vec { + pub fn try_as_bytes(&self) -> Result, crate::error::CryptoError> { let kem = self.kem_public_key.as_bytes(); let pq = self.sig_pq_public_key.as_bytes(); let cl = self.sig_cl_public_key.as_bytes(); - + let kem_len = + u16::try_from(kem.len()).map_err(|_| crate::error::CryptoError::InvalidKeyLength)?; + let pq_len = + u16::try_from(pq.len()).map_err(|_| crate::error::CryptoError::InvalidKeyLength)?; + let cl_len = + u16::try_from(cl.len()).map_err(|_| crate::error::CryptoError::InvalidKeyLength)?; let mut out = Vec::with_capacity(kem.len() + pq.len() + cl.len() + 6); - out.extend_from_slice(&(kem.len() as u16).to_be_bytes()); + out.extend_from_slice(&kem_len.to_be_bytes()); out.extend_from_slice(kem); - out.extend_from_slice(&(pq.len() as u16).to_be_bytes()); + out.extend_from_slice(&pq_len.to_be_bytes()); out.extend_from_slice(pq); - out.extend_from_slice(&(cl.len() as u16).to_be_bytes()); + out.extend_from_slice(&cl_len.to_be_bytes()); out.extend_from_slice(cl); - out + Ok(out) } + pub fn as_bytes(&self) -> Vec { + self.try_as_bytes() + .expect("public key bundle field exceeds wire limit") + } + + /// Parse a complete suite-compatible public bundle. pub fn from_bytes(bytes: &[u8]) -> Result { + let bundle = Self::from_bytes_unvalidated(bytes)?; + bundle.validate()?; + Ok(bundle) + } + + /// Parse the canonical field layout without requiring all suite fields. + /// + /// This is reserved for explicitly partial development material, such as + /// an Ed25519-only browser keyring. Callers that will encrypt or verify + /// cryptographic protocol values must use [`Self::from_bytes`]. + pub fn from_bytes_unvalidated(bytes: &[u8]) -> Result { use crate::error::CryptoError; let mut offset = 0; @@ -422,6 +564,11 @@ impl PublicKeyBundle { .ok_or(CryptoError::InvalidKeyLength)? .to_vec(), ); + offset += cl_len; + + if offset != bytes.len() { + return Err(CryptoError::InvalidKeyLength); + } Ok(Self { kem_public_key: kem, @@ -430,6 +577,11 @@ impl PublicKeyBundle { }) } + /// Parse a complete, suite-compatible public bundle. + pub fn from_bytes_validated(bytes: &[u8]) -> Result { + Self::from_bytes(bytes) + } + pub fn to_base64(&self) -> String { bytes_to_base64(&self.as_bytes()) } @@ -437,6 +589,10 @@ impl PublicKeyBundle { pub fn from_base64(s: &str) -> Result { Self::from_bytes(&base64_to_bytes(s)?) } + + pub fn from_base64_unvalidated(s: &str) -> Result { + Self::from_bytes_unvalidated(&base64_to_bytes(s)?) + } } impl TryFrom<&[u8]> for PublicKeyBundle { @@ -474,7 +630,7 @@ mod tests { let bundle = PublicKeyBundle::new(kem, pq, cl); let bytes = bundle.as_bytes(); - let recovered = PublicKeyBundle::from_bytes(&bytes)?; + let recovered = PublicKeyBundle::from_bytes_unvalidated(&bytes)?; assert_eq!( bundle.kem_public_key.as_bytes(), @@ -491,6 +647,24 @@ mod tests { Ok(()) } + #[cfg(all(feature = "mlkem-tls", feature = "ml-dsa", feature = "ed25519-dalek"))] + #[test] + fn full_keyring_validation_checks_key_correspondence() { + let keyring = Keyring::generate(); + assert!(keyring.validate_full().is_ok()); + assert!(keyring.validate_encryption().is_ok()); + + let mut invalid = Keyring::generate(); + invalid.sig_cl_public_key = SignaturePublicKey::new(vec![0; SIG_CL_PUBLIC_KEY_LEN]); + assert!(matches!( + invalid.validate_full(), + Err(crate::error::CryptoError::InvalidKeyMaterial) + )); + + invalid.kem_secret_key = KemPrivateKey::new(vec![0]); + assert!(invalid.validate_encryption().is_err()); + } + #[test] fn public_key_bundle_try_from_roundtrip() -> Result<(), Box> { let bundle = PublicKeyBundle::new( @@ -499,7 +673,7 @@ mod tests { SignaturePublicKey::new(vec![0xEFu8; 32]), ); let bytes: Vec = Vec::from(&bundle); - let recovered = PublicKeyBundle::try_from(bytes.as_slice())?; + let recovered = PublicKeyBundle::from_bytes_unvalidated(bytes.as_slice())?; assert_eq!(bundle.as_bytes(), recovered.as_bytes()); Ok(()) } @@ -531,6 +705,53 @@ mod tests { Ok(()) } + #[test] + fn keyring_try_to_bytes_rejects_fields_larger_than_wire_length() { + let keyring = Keyring::new( + KemPublicKey::new(vec![0u8; 65_536]), + KemPrivateKey::new(Vec::new()), + SignaturePqPublicKey::new(Vec::new()), + SignaturePqPrivateKey::new(Vec::new()), + SignaturePublicKey::new(Vec::new()), + SignaturePrivateKey::new(Vec::new()), + ); + assert!(matches!( + keyring.try_to_bytes(), + Err(crate::error::CryptoError::InvalidKeyLength) + )); + } + + #[test] + fn canonical_key_parsers_reject_trailing_bytes() { + let keyring = Keyring::new( + KemPublicKey::new(vec![1u8; 16]), + KemPrivateKey::new(vec![2u8; 16]), + SignaturePqPublicKey::new(vec![3u8; 16]), + SignaturePqPrivateKey::new(vec![4u8; 16]), + SignaturePublicKey::new(vec![5u8; 16]), + SignaturePrivateKey::new(vec![6u8; 16]), + ); + let mut keyring_bytes = keyring.to_bytes().to_vec(); + keyring_bytes.push(0xAA); + assert!(Keyring::from_bytes(&keyring_bytes).is_err()); + + let bundle = keyring.public_key_bundle(); + let mut bundle_bytes = bundle.as_bytes(); + bundle_bytes.push(0xBB); + assert!(PublicKeyBundle::from_bytes(&bundle_bytes).is_err()); + } + + #[test] + fn validated_bundle_rejects_partial_suite_keys() { + let bundle = PublicKeyBundle::new( + KemPublicKey::new(vec![1u8; 32]), + SignaturePqPublicKey::new(vec![2u8; 64]), + SignaturePublicKey::new(vec![3u8; 32]), + ); + assert!(bundle.validate().is_err()); + assert!(PublicKeyBundle::from_bytes_validated(&bundle.as_bytes()).is_err()); + } + #[test] fn keyring_try_from_roundtrip() -> Result<(), Box> { let keyring = Keyring::new( @@ -597,7 +818,7 @@ mod tests { SignaturePublicKey::new(vec![3u8; 32]), ); let b64 = bundle.to_base64(); - let recovered = PublicKeyBundle::from_base64(&b64)?; + let recovered = PublicKeyBundle::from_base64_unvalidated(&b64)?; assert_eq!(bundle.as_bytes(), recovered.as_bytes()); Ok(()) } diff --git a/crypto/src/lib.rs b/crypto/src/lib.rs index 7e67f82..afcf21b 100644 --- a/crypto/src/lib.rs +++ b/crypto/src/lib.rs @@ -43,7 +43,7 @@ pub use keypair::{ }; #[cfg(feature = "chacha20poly1305")] -pub use aead::ChaCha20Poly1305; +pub use aead::{ChaCha20Poly1305, XChaCha20Poly1305}; #[cfg(feature = "aes-gcm")] pub use aead::Aes256Gcm; @@ -55,7 +55,7 @@ pub use sign::{Ed25519Signer, SignatureScheme, verify_ed25519}; pub use sign::{MlDsaSigner, verify_ml_dsa}; #[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))] -pub use sign::{DualSignature, sign_dual}; +pub use sign::{DualSignature, DualSigner, sign_dual}; #[cfg(feature = "sha2")] pub use hash::{Sha256Hasher, sha256, sha256_double}; @@ -79,11 +79,12 @@ pub fn ensure_crypto_provider() { }); } -#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] -pub use enc::{decrypt_with, encrypt_for}; +pub use helper::{ENCRYPT_DOMAIN, KEY_WRAP_DOMAIN}; -#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))] -pub use helper::{MultiEncryptedMessage, RecipientEntry, decrypt_multi, encrypt_multi}; +#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] +pub use helper::{ + MAX_RECIPIENTS, MultiEncryptedMessage, RecipientEntry, decrypt_multi_for, encrypt_multi_for, +}; /* ================================ TESTS ================================ */ #[cfg(test)] @@ -209,6 +210,20 @@ mod tests { ); } + #[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))] + #[test] + fn dual_scheme_implements_signature_trait() { + use crate::sign::SignatureScheme; + + let (signer, _, _, _, _) = DualSigner::generate(); + let signature = signer.sign(b"msg").expect("dual signing should succeed"); + assert_eq!(signer.algorithm(), SigAlgorithm::DUAL); + signer + .verify(b"msg", &signature) + .expect("dual verification should succeed"); + assert!(signer.verify(b"wrong", &signature).is_err()); + } + #[cfg(feature = "hkdf")] #[test] fn hkdf_expand_produces_key() { @@ -343,17 +358,46 @@ mod tests { assert_eq!(enc.shared_secret, ss); } - #[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))] - #[test] - fn encrypt_multi_roundtrip() { - use crate::helper::{decrypt_multi, encrypt_multi}; + #[cfg(all( + feature = "mlkem-tls", + feature = "hkdf", + feature = "ml-dsa", + feature = "ed25519-dalek" + ))] + fn multi_envelope_roundtrip(encryption_type: EncryptionType) { + use crate::helper::{decrypt_multi_for, encrypt_multi_for}; use crate::keypair::Keyring; let kr = Keyring::generate(); let entities = vec![kr.public_key_bundle()]; let msg = b"secret data"; - let ct = encrypt_multi(msg, b"aad", &entities).expect("multi encrypt should succeed"); - let pt = decrypt_multi(&ct, b"aad", &kr).expect("multi decrypt should succeed"); + let ct = encrypt_multi_for(encryption_type, 7, msg, &entities) + .expect("multi encrypt should succeed"); + let pt = decrypt_multi_for(&ct, 7, &kr).expect("multi decrypt should succeed"); assert_eq!(pt, msg); } + + #[cfg(all( + feature = "mlkem-tls", + feature = "hkdf", + feature = "ml-dsa", + feature = "ed25519-dalek", + feature = "chacha20poly1305" + ))] + #[test] + fn chacha20_multi_envelope_roundtrip() { + multi_envelope_roundtrip(EncryptionType::MlKemChaCha20Poly1305); + } + + #[cfg(all( + feature = "mlkem-tls", + feature = "hkdf", + feature = "ml-dsa", + feature = "ed25519-dalek", + feature = "aes-gcm" + ))] + #[test] + fn aes_gcm_multi_envelope_roundtrip() { + multi_envelope_roundtrip(EncryptionType::MlKemAes256Gcm); + } } diff --git a/crypto/src/sign.rs b/crypto/src/sign.rs index 35f4e68..368c1fb 100644 --- a/crypto/src/sign.rs +++ b/crypto/src/sign.rs @@ -25,6 +25,8 @@ impl SigAlgorithm { use crate::keypair::{SignaturePqPrivateKey, SignaturePqPublicKey}; pub trait SignatureScheme { + /// The wire algorithm identifier produced by this signer. + fn algorithm(&self) -> u8; fn sign(&self, msg: &[u8]) -> Result, CryptoError>; fn verify(&self, msg: &[u8], signature: &[u8]) -> Result<(), CryptoError>; } @@ -76,6 +78,10 @@ impl Ed25519Signer { #[cfg(feature = "ed25519-dalek")] impl SignatureScheme for Ed25519Signer { + fn algorithm(&self) -> u8 { + SigAlgorithm::ED25519 + } + fn sign(&self, msg: &[u8]) -> Result, CryptoError> { use ed25519_dalek::Signer; let signature = self.secret.sign(msg).to_bytes().to_vec(); @@ -170,6 +176,10 @@ impl MlDsaSigner { #[cfg(feature = "ml-dsa")] impl SignatureScheme for MlDsaSigner { + fn algorithm(&self) -> u8 { + SigAlgorithm::ML_DSA_65 + } + fn sign(&self, msg: &[u8]) -> Result, CryptoError> { use ml_dsa::Signer; let signature = self @@ -237,6 +247,75 @@ pub fn sign_dual( Ok(DualSignature { ed25519, mldsa }) } +/// A signer that produces the canonical concatenated Ed25519 + ML-DSA-65 +/// signature represented by [`SigAlgorithm::DUAL`]. +#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))] +pub struct DualSigner { + ed25519: Ed25519Signer, + mldsa: MlDsaSigner, +} + +#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))] +impl DualSigner { + pub fn new( + ed25519_secret: &SignaturePrivateKey, + mldsa_secret: &SignaturePqPrivateKey, + mldsa_public: &SignaturePqPublicKey, + ) -> Result { + Ok(Self { + ed25519: Ed25519Signer::new(ed25519_secret)?, + mldsa: MlDsaSigner::new(mldsa_secret, mldsa_public)?, + }) + } + + pub fn generate() -> ( + Self, + SignaturePrivateKey, + SignaturePqPrivateKey, + SignaturePublicKey, + SignaturePqPublicKey, + ) { + let (ed25519, ed25519_secret, ed25519_public) = Ed25519Signer::generate(); + let (mldsa, mldsa_secret, mldsa_public) = MlDsaSigner::generate(); + ( + Self { ed25519, mldsa }, + ed25519_secret, + mldsa_secret, + ed25519_public, + mldsa_public, + ) + } +} + +#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))] +impl SignatureScheme for DualSigner { + fn algorithm(&self) -> u8 { + SigAlgorithm::DUAL + } + + fn sign(&self, msg: &[u8]) -> Result, CryptoError> { + let dual = sign_dual(self.ed25519.signing_key(), self.mldsa.signing_key(), msg)?; + let mut signature = Vec::with_capacity( + SigAlgorithm::length(SigAlgorithm::DUAL).expect("known signature algorithm length"), + ); + signature.extend_from_slice(&dual.ed25519); + signature.extend_from_slice(&dual.mldsa); + Ok(signature) + } + + fn verify(&self, msg: &[u8], signature: &[u8]) -> Result<(), CryptoError> { + let ed_len = + SigAlgorithm::length(SigAlgorithm::ED25519).expect("known signature algorithm length"); + let mldsa_len = SigAlgorithm::length(SigAlgorithm::ML_DSA_65) + .expect("known signature algorithm length"); + if signature.len() != ed_len + mldsa_len { + return Err(CryptoError::InvalidSignature); + } + verify_ed25519(&self.ed25519.public_key(), msg, &signature[..ed_len])?; + verify_ml_dsa(&self.mldsa.public_key(), msg, &signature[ed_len..]) + } +} + impl DualSignature { #[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))] pub fn verify( diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5d60133..b02fa69 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -38,12 +38,33 @@ MTP separates wire encoding, QUIC transport, connection policy, protocol negotia The top row represents application entry points. Native Rust code calls the client or host crates directly. Browser code calls the TypeScript SDK, which uses generated WASM bindings for the same codec and WebTransport session. Both clients exchange the same MTP frames with a host. -The middle row is shared protocol machinery. The type map determines numeric IDs, the codec serializes values, and transport framing places each serialized frame on a QUIC stream. This is why a type-map change must be compiled into both peers before the new message can be exchanged. +The middle row is shared protocol machinery. The type map determines numeric IDs, the codec serializes self-delimiting `DataValue` payloads, and transport framing places each serialized frame on a QUIC stream. `CommunicationValue` contains only routing metadata and one generic payload. Protection is a composable value property (`Signed` or `Encrypted`), not a transport or communication-frame mode, so the frame and transport layers never infer encryption or signature state from header flags. This is why a type-map or codec change must be compiled into both peers before the new message can be exchanged. The bottom row shows the two server entry points. `MTPHost` is a native QUIC endpoint for native MTP clients. `MTPWebServer` owns TCP HTTPS and UDP HTTP/3/WebTransport listeners on the same numeric port, reuses one `HostConfig` and router, and provides the same `accept()`-based MTP session API. Its QUIC listener still uses only the `h3` ALPN, so it cannot share its UDP address with the native MTP ALPN endpoint. Choose `MTPHost` for native clients and `MTPWebServer` for browser-facing HTTP and WebTransport. `mtp-crypto` is an optional cross-cutting layer used by authenticated native connections, WebTransport connections, and browser E2EE; TLS remains the transport security layer in both paths. +MTP exposes protection as independent capabilities rather than prescribing an +application topology: + +- A stateless protected `DataValue` composes `Signed` and + `Encrypted` in the order selected by the application. +- A direct protected frame carries a protected value under its application + communication type and routes it straight to the frame receiver. +- A sealed relay uses the reserved `Relay` communication type, an absent outer + sender, and separately protected metadata and content. Applications choose + the next hop, final recipient, and both recipient sets. +- A stateful encrypted session advances symmetric send and receive chains for + an active exchange. +- An encrypted pipe protects an ordered byte stream with transcript-bound + records and an authenticated final record; forward-secure duplex setup is an + explicit option. + +These constructions are peers. Relay is optional and is not the default path +for encrypted application messages. Use direct protected frames when no +intermediate component needs relay metadata; use sealed relay when routing or +store-and-forward topology requires a distinct metadata-access boundary. + `mtp-host` performs version negotiation and native authentication before returning an `MTPConnection`. `mtp-webserver` routes HTTP/1.1, HTTP/2, and HTTP/3 requests through one route table and surfaces WebTransport sessions through `accept()`. WebTransport MTP sessions support the same optional cryptographic authentication as native hosts when the `crypto` feature is enabled. The [native client](NATIVE-CLIENT.md), [WASM client](WASM-CLIENT.md), [native host](NATIVE-HOST.md), and [web server](NATIVE-HOST-WEB-SERVER.md) guides cover the public APIs for each boundary. The web server guide should be read as the host API for browser-facing deployments; it accepts the same `HostConfig` and authentication callbacks as the native host. diff --git a/docs/CONNECTIONS.md b/docs/CONNECTIONS.md index 34eb24a..0ccf127 100644 --- a/docs/CONNECTIONS.md +++ b/docs/CONNECTIONS.md @@ -10,7 +10,7 @@ Native clients and hosts share the same connection shape after the opening hands | `description` | Optional label sent during setup | Optional label received from the client | Optional label received from the client | | `client_id` | Confirmed or assigned ID with `crypto` | Authenticated or guest client ID with `crypto` | Authenticated or guest client ID with `crypto` | | `auth_state` | Authentication result with `crypto` | Authentication result with `crypto` | Authentication result with `crypto` | -| `request_path` | — | — | WebTransport CONNECT path (e.g. `/mtp`) | +| `request_path` | / | / | WebTransport CONNECT path (e.g. `/mtp`) | | `remote_addr` | Server `SocketAddr` when available | Peer `SocketAddr` | Peer `SocketAddr` | `WebMTPConnection`, returned by `MTPWebServer::accept()`, exposes the same members as the native host connection plus `request_path`, which contains the HTTP/3 path used for the WebTransport extended CONNECT request. diff --git a/docs/CONNECTOR.md b/docs/CONNECTOR.md index 17335ea..e560a71 100644 --- a/docs/CONNECTOR.md +++ b/docs/CONNECTOR.md @@ -124,6 +124,6 @@ See [Protocol Reference](PROTOCOL-REFERENCE.md#protocol-keepalive). ## Protocol Version Changes -Add a protocol version by adding its type-map entry and `protocol_version` to the YAML configuration, then rebuild both peers. The type-map build script generates a version-specific `TypeMap` and keeps the enum as the union of all configured type names. +Add a protocol version by adding its type-map entry and `protocol_version` to the YAML configuration, then rebuild both peers. The type-map build script generates a version-specific `TypeMap`. Native hosts built with the registry feature keep an enum union across configured versions; a browser client and its generated `mtp/type-map` declarations use only the map selected by that client's `protocol_version`, plus reserved names. For a backward-compatible change, keep existing communication and data IDs stable and add new types with the new version. For a breaking change, add a new version and register both versions on the host while clients migrate. A client compiles one protocol version; it can connect only when that version is present in the host registry. Remove an old version only after its clients no longer connect, because the host closes connections whose version is unsupported. diff --git a/docs/NATIVE-CLIENT.md b/docs/NATIVE-CLIENT.md index d2ad623..5f40f9d 100644 --- a/docs/NATIVE-CLIENT.md +++ b/docs/NATIVE-CLIENT.md @@ -18,7 +18,7 @@ let conn = MTPClient::connect( let request = CommunicationValue::new(CommunicationType::Ping).with_id(1); conn.sender.send(&request).await?; let response = conn.receive().await?; -println!("received {}", response.get_id()); +println!("received {:?}", response.id()); conn.sender.close(); ``` @@ -259,48 +259,51 @@ Sends a close frame and signals the peer. The `Sender::close()` spawns an async The complete pipe protocol, native API, browser API, lifecycle, and errors are documented in [Pipes](PIPES.md). Use the connection facade described there when the `pipes` feature is enabled. -## Appendix: Crypto Containers +## Appendix: Composable Data Protection -With the `crypto` feature, `DataValue` supports encrypted, signed, and signed+encrypted containers. Encryption uses ML-KEM to encapsulate to a recipient's KEM public key (from their `PublicKeyBundle`); only the holder of the matching `Keyring` can decrypt. Signing uses the sender's Ed25519 key. +With the `crypto` feature, any `DataValue` can be signed or encrypted. The operations return typed errors and compose by operation order. `Encrypted(Signed(Value))` keeps the signer identity inside the encrypted plaintext; `Signed(Encrypted(Value))` leaves it visible. The example uses different keyrings for the signer and recipient to make the ownership explicit. ```rust -use mtp::crypto::{EncryptionType, Ed25519Signer, SigAlgorithm}; +use mtp::codec::{ProtectionPurpose, DataTypeId, DataValue}; +use mtp::crypto::{Ed25519Signer, Keyring}; -let enc_type = EncryptionType::MlKemChaCha20Poly1305; -let signer = Ed25519Signer::new(&keyring.sig_cl_secret_key)?; - -// `recipient` is the PublicKeyBundle of whoever should be able to decrypt -// (e.g. the host's bundle, obtained out of band). - -// Encrypted container -let mut enc = DataValue::Container(vec![ - (DataTypeId(1), DataValue::Str("secret".into())), +let sender_keyring = Keyring::generate(); +let recipient_keyring = Keyring::generate(); +let signer = Ed25519Signer::new(&sender_keyring.sig_cl_secret_key)?; +let recipient = recipient_keyring.public_key_bundle(); +let sender_public_keys = sender_keyring.public_key_bundle(); +let value = DataValue::Container(vec![ + (DataTypeId(32), DataValue::Str("secret".into())), ]); -enc.encrypt_container(enc_type, &recipient, b"aad"); -// Signed container -let mut sig = DataValue::Container(vec![ - (DataTypeId(1), DataValue::Str("signed".into())), -]); -sig.sign_container(SigAlgorithm::ED25519, &signer); - -// Signed + encrypted -let mut sec = DataValue::Container(vec![ - (DataTypeId(1), DataValue::Str("both".into())), -]); -sec.sign_and_encrypt_container(SigAlgorithm::ED25519, &signer, enc_type, &recipient, b"aad"); +// The outer encrypted wrapper hides the signer metadata. +let private_signer = value.clone().sign(7, ProtectionPurpose::from(1), &signer)?; +let sealed = private_signer.encrypt_for( + std::slice::from_ref(&recipient), + ProtectionPurpose::from(2), +)?; ``` -> Note: DataTypeId(1) maps intenally to the reserved DataType::Id, uncareful work with reserved DataTypes & CommunicationTypes (0 - 31) may lead to unexpected behaviour. -> Prefer registring your own. -On the receiving side, the recipient decrypts with its own `Keyring` (each blob is self-describing: its leading byte selects the algorithm and the matching KEM key from the keyring): +Reverse the calls when the signer identity should remain visible to the recipient before opening the encrypted value: ```rust -enc.decrypt_into_container(&keyring, b"aad"); // -> Container -sig.verify_into_container(&verifier); // verifier: impl SignatureScheme -sec.decrypt_signed_encrypted_container(&keyring, b"aad"); // -> SignedContainer, then verify_into_container +let encrypted = value.encrypt_for( + std::slice::from_ref(&recipient), + ProtectionPurpose::from(2), +)?; +let public_signer = encrypted.sign(7, ProtectionPurpose::from(1), &signer)?; ``` +Opening and verification are explicit and return the inner value without mutating the wrapper: + +```rust +let signed = sealed.decrypt(&recipient_keyring, ProtectionPurpose::from(2))?; +signed.verify(7, &sender_public_keys, ProtectionPurpose::from(1))?; +let plain = signed.into_verified(7, &sender_public_keys, ProtectionPurpose::from(1))?; +``` + +For `public_signer`, call `verify` and `into_verified` before calling `decrypt`; its outer signature is available before the encrypted value is opened. + ### Policy Configuration The `Policy` struct controls transport behaviour: diff --git a/docs/NATIVE-HOST-WEB-SERVER.md b/docs/NATIVE-HOST-WEB-SERVER.md index a1758c6..36cadf6 100644 --- a/docs/NATIVE-HOST-WEB-SERVER.md +++ b/docs/NATIVE-HOST-WEB-SERVER.md @@ -124,7 +124,7 @@ let mut server = MTPWebServer::new(host_config, web).await?; while let Some(connection) = server.accept().await? { // connection: WebMTPConnection while let Ok(message) = connection.receive().await { - println!("received MTP message {}", message.get_id()); + println!("received MTP message {:?}", message.id()); } } ``` @@ -146,7 +146,7 @@ With port `0` and TCP enabled, construction binds TCP first and binds UDP to the | Policy | Behavior | |--------|----------| -| `Unauthenticated` (default) | No authentication handshake is performed. The connection has `AuthState::Unauthenticated` and a random 48-bit client ID. `guest_id_generator` is not used by this adapter. | +| `Unauthenticated` (default) | No authentication handshake is performed. The connection has `AuthState::Unauthenticated` and a random full-width `u64` client ID. `guest_id_generator` is not used by this adapter. | | `AllowAuthentication` | The server accepts the first message. If it is an `Identification` or `Register` message, a full challenge-response handshake is performed. If it is an ordinary opening message, the connection remains unauthenticated. | | `ForceAuthentication` | The server requires a valid `Identification` or `Register` message as the first frame and performs the challenge-response handshake. Any other opening message is rejected. | diff --git a/docs/NATIVE-HOST.md b/docs/NATIVE-HOST.md index c71f3ac..d6ca79b 100644 --- a/docs/NATIVE-HOST.md +++ b/docs/NATIVE-HOST.md @@ -157,10 +157,9 @@ let get_existing_client = |id: u64, _description: Option| { ### guest_id_generator -Optional callback that controls how unauthenticated connections receive their client ID. When `None` (the default), the host generates a random 48-bit ID and checks it against `get_existing_client` to avoid collisions. +Optional callback that controls how unauthenticated connections receive their client ID. When `None` (the default), the host generates a random full-width `u64` ID and checks it against `get_existing_client` to avoid collisions. -Return `Some(id)` to accept the guest with that ID, or `None` to reject the connection. The ID must fit in 48 bits (`id <= mtp_codec::MAX_WIRE_ID`); -values outside that range are rejected automatically and fall back to the built-in generator. +Return `Some(id)` to accept the guest with that full-width `u64` ID, or `None` to reject the connection. ```rust use std::sync::atomic::{AtomicU64, Ordering}; diff --git a/docs/PIPES.md b/docs/PIPES.md index c4e7ff7..51b9e7e 100644 --- a/docs/PIPES.md +++ b/docs/PIPES.md @@ -1,6 +1,14 @@ # MTP Pipes -Pipes are unidirectional QUIC streams for raw bytes. The creator sends a `PipeRequest` communication value, the peer accepts or rejects it, and the stream then carries bytes without an MTP frame around every write. +Pipes are unidirectional QUIC/WebTransport streams. The transport primitive is +byte-oriented, but raw pipe bytes are not confidential or authenticated by +MTP. The creator sends a `PipeRequest` communication value, the peer accepts +or rejects it, and an application that carries sensitive data must place the +encrypted record layer described below on top of the accepted stream. + +The request's `Description` and `PipeRequest` type remain clear transport +metadata. Do not put identities, call details, file names, or other sensitive +protocol information in them. The creator owns the writer. The accepting peer owns the reader. A writer finishes with a stream FIN or aborts with a stream reset. A reader returns EOF after FIN and reports a connection or stream error when the peer closes unexpectedly. @@ -11,6 +19,88 @@ The creator calls `create_pipe` or the corresponding SDK `createPipe` method wit The request description is application metadata. It does not grant access to the stream, authenticate the creator, or negotiate an application protocol. Use the authenticated MTP connection and the host's admission policy when a pipe carries sensitive data. +The browser SDK's `createEncryptedPipe` and `acceptEncryptedPipe` convenience +methods derive the local identity, actual pipe ID, random session ID, and +default application purpose from MTP state. Use the lower-level session +functions only when integrating a custom pipe transport. The low-level API +checks that a supplied pipe ID matches the actual pipe; it does not infer a +caller-provided sender or recipient identity. + +The convenience methods intentionally require registered client credentials +because their endpoint identity is the transport client's registered MTP +identity. An application that needs a cryptographic identity independent from +transport registration must use the lower-level session functions and provide +the endpoint IDs and key material explicitly. + +## Endpoint Encryption + +`initiate_pipe_session`/`accept_pipe_session` in the native transport, or +`initiateMTPPipeSession`/`acceptMTPPipeSession` in the browser SDK, perform the +pipe-establishment step. The initiator sends an +`Encrypted(Signed(Array<...>))` offer containing a fresh 32-byte initial chain key, +session ID, pipe ID, direction, purpose, and both endpoint IDs. The recipient +decrypts it with its keyring, resolves the expected sender bundle, verifies +the signature, and checks every expected field before returning the record +reader. The offer is bounded and separately framed from application records. + +The helpers then return `EncryptedPipeWriter`/`EncryptedPipeReader` (or their +browser equivalents) without changing the raw QUIC/WebTransport adapter. The +context contains the unique pipe/session identity, endpoint identities, +direction, and application protocol purpose. Do not derive the initial chain +key from the clear description or pipe ID alone. + +The receiver's signature verification policy is explicit and independent from +its decryption keyring. Configure `signaturePolicy` on the browser accept +helper, or use the client's `defaultSignatureVerificationPolicy`. The +initiator and responder signing `signatureSuite` remain separate from this +receive policy. Both sides default to Ed25519; choose `signatureSuite: "dual"` +and a matching `signaturePolicy: "dual"` explicitly when hybrid signatures +are required. + +Each record is encoded as: + +```text +[4-byte big-endian ciphertext length] +[1-byte record type: DATA=0, FINAL=1] +[XChaCha20-Poly1305 nonce || ciphertext || tag] +``` + +The AEAD associated data is `MTP-PIPE-E2EE-1 || purpose || direction || +transcript-hash || sequence || record length || record type`. The transcript +hash binds the session ID, pipe ID, sender, recipient, purpose, and direction. +The sequence starts at zero and advances only after successful authentication. +A missing, duplicated, reordered, or modified record causes authentication to +fail. Each record derives a one-use message key and the next chain key with +HKDF using the authenticated context and sequence number; the bootstrap key is +never used directly as an AEAD key. The record layer caps one encoded record at +16 MiB. + +`FINAL` is an authenticated empty record. A reader returns clean EOF only +after validating it; transport EOF before `FINAL` is truncation. +Authentication, framing, sequence, and I/O failures permanently poison the +encrypted reader or writer and erase its current chain key. This is a one-way +chain, not a Diffie-Hellman ratchet, so the ordinary offer does not provide +forward secrecy. + +The wrapper exposes `writeRecord`/`readRecord`. Callers that already have an +independently authenticated session may still construct it directly with a +key and context; otherwise use the establishment helpers. + +For more than two members, native `initiate_group_pipe_session` and the browser +`initiateMTPPipeSession` recipient-array form encrypt one fresh session key to +each current member. Membership changes are rekeys: create a new session ID +and offer with the new recipient set, and stop using the old record chain. A +removed member must never receive a later session key; an added member must +not receive historical records. + +When a live call needs forward secrecy, use the duplex handshake +`initiate_forward_secure_pipe_session`/`accept_forward_secure_pipe_session` or +the browser `initiateMTPForwardSecurePipeSession`/ +`acceptMTPForwardSecurePipeSession`. The responder contributes a fresh +ephemeral hybrid-KEM key, while long-term signing keys authenticate the +exchange. These helpers require a bidirectional stream and bind the handshake +transcript into the record context. + ## Accepting or Rejecting a Pipe The receiving side reads pipe requests through `receive_pipe`, the host dispatcher, or the browser pipe callback. It calls `accept` to obtain a reader or `deny` to reject the request. A rejected request completes the creator's handle with `Rejected` and no raw byte stream becomes available. @@ -20,7 +110,12 @@ Normal messages and pipe requests share the transport and must pass through the ## Closing a Pipe -The creator closes a successful pipe with `PipeWriter::finish` or the browser writer's `close`; this sends a QUIC FIN and lets the reader observe EOF. Use `abort` when the peer should discard the stream immediately; this resets the stream and the reader receives an error instead of a clean EOF. Dropping the connection closes all active pipes. +The creator closes a successful encrypted pipe with `EncryptedPipeWriter::finish` +or the browser writer's `close`; this authenticates `FINAL` and then sends a +QUIC FIN. Use `abort` when the peer should discard the stream immediately; this +resets the stream and the reader receives an error instead of a clean EOF. +Dropping the connection closes all active pipes. Raw pipe FIN is not an +authenticated application completion signal. The accepting side closes its reader by consuming it or dropping it. A reader does not send an application-level acknowledgement for EOF. If the application needs completion metadata, send an ordinary MTP message before finishing the pipe. @@ -38,23 +133,40 @@ Native applications use the pipe APIs on `MTPConnection`; browser applications u ## Native File Upload and Processing -The creator streams a file in chunks. The accepting side processes each chunk without buffering the complete file: +The creator streams a file in encrypted records. The accepting side processes +each decrypted chunk without buffering the complete file. The `session_key` +below is obtained from the authenticated pipe-establishment protocol: ```rust // Client -use tokio::io::AsyncWriteExt; +use mtp_transport::{PipeSessionParameters, initiate_pipe_session}; +use tokio::io::AsyncReadExt; let handle = conn.create_pipe("file-upload").await?; -if let Some(mut writer) = handle.wait().await? { +let pipe_id = handle.pipe_id(); +if let Some(writer) = handle.wait().await? { + let params = PipeSessionParameters::new( + format!("file-upload/{pipe_id}"), pipe_id, own_client_id, host_client_id, 0x40, 0, + )?; + let mut writer = initiate_pipe_session( + writer.into_inner(), params, &own_keyring, &host_public_bundle, + ).await?; let mut file = tokio::fs::File::open("input.bin").await?; - tokio::io::copy(&mut file, &mut writer).await?; + let mut buffer = [0u8; 64 * 1024]; + loop { + let count = file.read(&mut buffer).await?; + if count == 0 { + break; + } + writer.write_record(&buffer[..count]).await?; + } writer.finish().await?; } ``` ```rust // Host -use tokio::io::AsyncReadExt; +use mtp_transport::{PipeSessionParameters, accept_pipe_session}; while let Ok(request) = conn.receive_pipe().await { if request.description() != "file-upload" { @@ -62,16 +174,18 @@ while let Ok(request) = conn.receive_pipe().await { continue; } - let mut reader = request.accept().await?; + let pipe_id = request.id(); + let reader = request.accept().await?; + let params = PipeSessionParameters::new( + format!("file-upload/{pipe_id}"), pipe_id, client_id, own_client_id, 0x40, 0, + )?; + let mut reader = accept_pipe_session( + reader.into_inner(), ¶ms, &own_keyring, &client_public_bundle, + ).await?; let mut hasher = sha2::Sha256::new(); - let mut buffer = [0u8; 64 * 1024]; - loop { - let count = reader.read(&mut buffer).await?; - if count == 0 { - break; - } - hasher.update(&buffer[..count]); - process_chunk(&buffer[..count]).await?; + while let Some(chunk) = reader.read_record().await? { + hasher.update(&chunk); + process_chunk(&chunk).await?; } let digest = hasher.finalize(); println!("processed upload with digest {digest:x}"); diff --git a/docs/PROTOCOL-REFERENCE.md b/docs/PROTOCOL-REFERENCE.md index 9b441bb..7715828 100644 --- a/docs/PROTOCOL-REFERENCE.md +++ b/docs/PROTOCOL-REFERENCE.md @@ -17,6 +17,47 @@ The client sends an MTP `Ping` communication value with a frame ID. The host ret If automatic responses are disabled, the application must read Ping frames and send compatible Pong frames. Keepalive configuration is documented in the [native client](NATIVE-CLIENT.md) and [native host](NATIVE-HOST.md) guides. +## Relay metadata version + +Protected relay metadata declares the reserved `RelayVersion` field as an unsigned integer. Builders currently emit version `1` automatically. Receivers select the metadata schema from this field before interpreting any version-specific fields. Missing versions are unsupported legacy relays, and unknown versions are rejected. + +Relay format versions are independent of application type-map versions. A type-map version selects application-defined communication and data types. It does not select the protected relay metadata schema. + +## Relay `CreatedAt` + +The reserved `CreatedAt` field in relay metadata is an unsigned integer containing milliseconds elapsed since `1970-01-01T00:00:00Z`. It is not an ISO timestamp and it is not measured in seconds. + +For example: + +```text +2026-08-11T12:00:00.000Z + ↓ +Unix epoch milliseconds + ↓ +CreatedAt = 1786449600000 +``` + +Native relay builders and browser relay senders use this unit. Verified browser metadata exposes `createdAt` as a `bigint`; native verified metadata exposes `u64`. + +## Direct protected envelope + +The high-level direct protected API signs an MTP-owned envelope before it is +encrypted for the recipient. Its reserved fields are `ProtectedVersion`, +`MessageType`, `FinalRecipientId`, `MessageId`, `CreatedAt`, and `Content`. +Receivers verify the envelope before dispatching application content and require +the signed message type and final recipient to match the outer communication +type and receiver. If the outer sender is present, it must match the signed +signer ID. `MessageId` and `CreatedAt` are authenticated; callers can pass a +replay guard to reject a previously accepted `(signerId, MessageId)` pair. +Native and browser replay guards both receive `CreatedAt` as authenticated +metadata, but the timestamp is not part of the replay key. +Verified SDK results expose the authenticated `protectedVersion` and +`finalRecipientId` alongside the application content. + +Native applications use the same schema through `ProtectedMessageBuilder` and +`open_protected`; language bindings delegate envelope construction and opening +to this codec boundary. + ## Authentication Flow ```text @@ -41,3 +82,5 @@ Login proof binds the protocol version, client ID, host challenge, and client no ## Version Negotiation The client sends one compiled-in protocol version. The host compares it with the versions in its registry and returns the selected version in the opening response. Subsequent frames use that version's type map. An unsupported version closes the connection with `AcceptError::UnsupportedVersion`. + +The self-delimiting `DataValue` codec and the three-bit communication header begin at protocol version `3.0`. A peer offering an older codec version is rejected during version negotiation; the new decoder does not attempt legacy flag, ID, or crypto-container fallbacks. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 1107d1c..b4cc18f 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -95,9 +95,100 @@ The tags prevent a valid signature for one handshake step from being accepted as | Classical signatures | Ed25519 | Default | | Post-quantum signatures | ML-DSA-65 | Default | | KDF and hashing | HKDF-SHA-256, SHA-256 | Default | +| Password KDF for `.mk` files | Argon2id | `files` feature | | Hybrid KEM | X25519 plus ML-KEM-768 | `pqc` feature | -AEAD output stores the nonce before the authenticated ciphertext. Encrypted containers select their algorithm with a leading marking byte, derive an AEAD key from the KEM shared secret with HKDF, and authenticate caller-supplied AAD. Multi-recipient encryption wraps one content-encryption key separately for each recipient. +AEAD output stores the nonce before the authenticated ciphertext. `DataValue::Encrypted` uses one canonical multi-recipient envelope and derives a content key through authenticated KEM key wrapping. `DataValue::Signed` authenticates a domain-separated purpose, signer ID, and exact serialized inner value. MTP does not accept caller-supplied AAD as a replacement for this context. + +| Protection | Authenticated fields | +| --- | --- | +| `Signed` | `MTP-DATA-SIGN-1`, signature algorithm, purpose, signer ID, and the exact serialized inner value. | +| `Encrypted` | `MTP-DATA-ENC-1`, encryption suite, purpose, recipient count, recipient table, and the ciphertext. Each wrapped content key also authenticates `MTP-DATA-WRAP-1`, suite, purpose, and its KEM ciphertext. | + +The communication header is routing metadata, not automatically part of either +generic value wrapper's authenticated data. The high-level direct protected API +adds an MTP-owned signed envelope that binds its application type, final +recipient, message ID, creation time, and content to the outer route. Callers +using the generic protection primitives must bind any routing or message +metadata they require in their own signed value. + +Protection composition is significant: `Encrypted(Signed(Value))` hides signer metadata until decryption and is the construction used for sealed-sender payloads; `Signed(Encrypted(Value))` exposes the signer metadata while protecting the contents. A sealed-sender frame simply omits the outer communication sender, routes with its receiver field, and carries an `Encrypted(Signed(Value))` payload. There is no sealed-sender frame flag or wire type. + +### Protected Frame Visibility + +Before opening an `Encrypted(Signed(Value))` payload, a component with access to the MTP frame can read the frame length, communication type, presence flags, transport correlation ID, and next-hop receiver. Relayable application messages use the generic reserved `Relay` communication type; operation-specific names are inside the ciphertext. The outer encrypted value also reveals its encryption suite, generic relay protection purpose, recipient count, unlabeled KEM ciphertext and wrapped-key entries, and ciphertext length. Recipient entries contain no recipient IDs, although recipient count and the cryptographic entry material remain visible. + +The signer algorithm, signature purpose, signer ID, signature, and application-defined inner value are encrypted. They become available only after a recipient opens the encrypted value. The recipient must still verify the inner signature before trusting its signer ID or contents. + +Sealed sender is therefore a construction rule, not an anonymity guarantee or a separate protocol type. The frame sender is absent, the next-hop receiver remains visible for routing, and MTP does not inspect application containers to infer identities or protection flags. + +Connection authentication and protected identity are separate. For a sealed +relay sent over an authenticated connection, the host knows the connection's +registered MTP identity even though the outer relay sender is absent. The +protected signer remains hidden until a metadata recipient decrypts and +verifies the relay metadata. + +For a sealed relay sent over an unauthenticated connection, the host receives +no registered MTP identity from connection authentication. The outer relay +sender is still absent, and the protected signer is still hidden until metadata +decryption and verification. The network connection nevertheless has observable +metadata such as peer addressing, timing, sizes, and the visible frame fields +described above. Neither case provides network anonymity. + +### Relay access model and replay protection + +Relay messages separate metadata recipients from content recipients. A relay +service can receive the metadata key, verify the authenticated signer and +message identifiers, index the opaque encrypted-content value, and forward the +frame without receiving a content key. Only a content recipient can open the +content. The final recipient and application message type remain inside the +protected metadata/content structure; the outer frame exposes only the chosen +next hop. + +The receiver must consume the authenticated `(signer ID, MessageId)` pair with +a replay guard. `CreatedAt` is authenticated metadata that the guard receives +for retention or observability, but it is not part of the replay identity and +must not be used as the replay defense. The native codec exposes `ReplayGuard` +and the browser SDK exposes the matching `MTPReplayGuard` contract. Both +high-level APIs use bounded process-local guards by default for direct and +relay subscriptions. Those defaults are duplicate suppression only while an +entry remains in the fixed cache: eviction, reloads, or multiple receiver +processes can permit a previously accepted message again. Low-level relay +metadata opening remains replay-optional for callers reopening stored frames. +Use a durable guard when replay state must survive cache eviction, reloads, or +process boundaries. A guard should atomically record a new ID before +dispatching application content. Transport frame IDs must not be used for +this purpose. + +`VerifiedRelayMetadata` is an authenticated capability rather than a caller +constructed data transfer object. Rust fields are private and the browser +implementation keeps authenticated state behind a branded class. Content +opening consumes that authenticated state, so changing a message ID or +recipient in a normal object cannot make unrelated encrypted content inherit +those fields. Browser callers can call `dispose()` or `free()` on the metadata +capability for deterministic native-handle release; finalization remains a +fallback. + +### Signature policy + +Verification takes a receiver-side `SignaturePolicy`/`ProtectionPolicy`. +`AnySupported` is useful for compatibility at the low-level codec boundary, +but protocol receivers should select `Ed25519` or `Dual`. The browser SDK uses +an explicit `ed25519` default and permits an operation or client override. It +never derives receive policy from the recipient keyring. The sender's +signature suite remains a separate choice. Signature policy must be applied +independently to relay metadata, relay content, and pipe session establishment. + +### Key history and rotation + +Recipient KEM key history is tried locally without adding a stable recipient +key identifier to the visible encrypted-recipient table. Signing-key resolvers +receive a claimed, unverified signer ID only as a trusted-key lookup key; the +relay helpers authenticate that ID when they verify against the returned +history. Deployments should retain old +verification keys for at least as long as stored signed messages remain +accepted, and should make key-history lookup an authorization decision rather +than accepting any key supplied with a message. [mtp-crypto API](../crypto/), [native client](NATIVE-CLIENT.md), and [native host](NATIVE-HOST.md). @@ -112,7 +203,7 @@ The crate's feature groups are: | `wasm` | `getrandom` support for WebAssembly | | `tls` | Development certificate generation | -The main types are `Keyring`, `PublicKeyBundle`, `EncryptionType`, `HybridKem`, `ChaCha20Poly1305`, `Aes256Gcm`, `Ed25519Signer`, and `MlDsaSigner`. Hashing and KDF helpers include `sha256`, `sha256_double`, `hkdf_extract`, `hkdf_expand`, and `derive_encryption_key`. Handshake payload builders are in `mtp_crypto::auth`. +The main types are `Keyring`, `PublicKeyBundle`, `EncryptionType`, `HybridKem`, `XChaCha20Poly1305` (with the legacy `ChaCha20Poly1305` alias), `Aes256Gcm`, `Ed25519Signer`, and `MlDsaSigner`. Hashing and KDF helpers include `sha256`, `sha256_double`, `hkdf_extract`, `hkdf_expand`, and `derive_encryption_key`. Handshake payload builders are in `mtp_crypto::auth`. ## Cryptographic Review Status @@ -139,19 +230,45 @@ The browser SDK's optional E2EE session uses XChaCha20-Poly1305 with message key This is a single-chain ratchet. It has no Diffie-Hellman ratchet step and does not provide post-compromise security. Out-of-order messages can create skipped keys; the SDK accepts a receive gap of at most 100 messages and retains at most 100 skipped keys. Consumed or evicted keys are zeroed in the SDK state where the implementation owns the buffer. The session root key comes from the authenticated handshake's KEM shared secret. The initiator and responder derive separate send and receive chains. -Each message consumes one chain key, derives one message key with HKDF, and increments its counter. `sessionStorage` stores browser session state for the current origin. `encryptedDeviceSecretProvider` supplies encrypted device secret storage when sessions must survive page reloads. The provider must protect its wrapping secret outside the SDK; the SDK does not recover a lost device secret or skipped message keys. +Each message consumes one chain key, derives one message key with HKDF, and increments its counter. `sessionStorage` stores browser session state for the current origin. `encryptedSecretProvider` is an independent caller-managed encrypted-secret facility; it is not automatically used by `MTPSessionStorage` or `MTPSessionManager`. Applications that need encrypted session persistence must coordinate those stores explicitly. The provider must protect its wrapping secret outside the SDK; the SDK does not recover a lost secret or skipped message keys. + +Relay envelopes, browser session E2EE, and encrypted pipes are separate +protocols: + +| Model | State | Intended use | +| --- | --- | --- | +| `RelayEnvelope` | Stateless `Encrypted(Signed(Value))`, multi-recipient | Store-and-forward messages and routing | +| `SessionE2EE` | Stateful symmetric ratchet in `sessionStorage` | Active browser exchanges | +| `EncryptedPipeSession` | Authenticated setup plus ordered record chain | Protected streams | + +Encrypted pipes bind the pipe/session transcript, direction, purpose, sequence, +record length, and record type to each record. `FINAL` is authenticated and +unexpected EOF is reported as truncation. The ordinary signed/KEM offer is not +forward-secure; the native and browser duplex helpers use an ephemeral +authenticated KEM exchange before deriving the record chain. Group membership +changes require a new session key and recipient set. ## Key Storage `Keyring` contains three public and three private key values. Its private key fields use `ZeroizeOnDrop`, and serialized keyring output is held in a zeroizing buffer while it is constructed. Public key bundles contain only the three public values. -Applications remain responsible for storage at rest. The `files` feature writes passphrase-protected keyrings to `.mk` files and public bundles to `.mpkb` files. On Unix, keyring files are created with owner-only `0600` permissions. +Role-specific protocol boundaries should validate only the material they need: +`validate_encryption()` checks that a KEM public/private pair corresponds, while +`validate_full()` additionally requires a complete hybrid signing identity. +This keeps partial browser keyrings usable without allowing an envelope sender +to proceed with an invalid local decryption key. + +Applications remain responsible for storage at rest. The `files` feature writes passphrase-protected keyrings to `.mk` files and public bundles to `.mpkb` files. Protected `.mk` files store the Argon2id identifier, parameters, salt, and AEAD ciphertext; they do not derive their key with HKDF. On Unix, keyring files are created with owner-only `0600` permissions. Restrict those files to the owning account and protect backups. Browser applications should treat the configured credential storage as sensitive application data. ## Resource Limits and Operational Controls `Policy::default()` sets a 16 MiB application message limit and a 64 KiB handshake message limit. It also sets a 30 second read timeout, a 30 second maximum idle timeout, a receiver queue capacity of 1000, and a maximum of 128 concurrent stream tasks. Tune these values for the deployment and peer trust level. +The recursive codec applies additional defaults while parsing untrusted values: +maximum nesting depth 64, 65,536 value nodes, 16 MiB per blob or envelope, +and 64 encrypted recipients. Decrypted values are parsed with the same limits. + The host does not provide a general authentication-attempt rate limiter. Deploy authentication endpoints behind a rate-limiting proxy or add admission control through the host callbacks, including `GuestIdGenerator` where guest connections are permitted. @@ -161,3 +278,9 @@ Deploy authentication endpoints behind a rate-limiting proxy or add admission co - `AllowAuthentication` intentionally permits unauthenticated clients; it is not an authenticated-only mode. - Browser-side Rust panics cannot be recovered by JavaScript. The WASM client contains panic paths from internal `expect` calls. - The browser E2EE ratchet does not provide post-compromise security. +- The ordinary encrypted-pipe offer does not provide forward secrecy; use the + duplex handshake when recorded-call confidentiality after long-term KEM + compromise is required. +- Replay state is process-local by default for high-level subscriptions. Use a + durable replay guard when protection must survive reloads or coordinate + multiple receiver processes. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index f0d72fc..6842c6f 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -63,7 +63,7 @@ When `require_pq` is true, both Ed25519 and ML-DSA-65 keys and signatures must b `ReservedCommunicationType` means application code attempted to use a reserved wire ID. Use generated communication types instead of assigning protocol IDs manually. `MissingField` means a required typed field was not present. -`InvalidEncoding` indicates truncated, malformed, or structurally invalid bytes. `TooManyEntries` indicates that an array, container, or frame exceeds the codec's representable count or length. `CryptoFailed` indicates that signature verification or encrypted-container processing failed. The complete variant table is in [Errors](ERRORS.md). +`InvalidEncoding` indicates truncated, malformed, duplicate-field, reserved-kind, or structurally invalid bytes. `TooManyEntries` indicates that an array, container, or frame exceeds the codec's representable count or length. Protection operations return typed errors for malformed envelopes, authentication failures, invalid signatures, and missing recipients. The complete variant table is in [Errors](ERRORS.md). ## Frames and Message Limits diff --git a/docs/TYPE-MAP.md b/docs/TYPE-MAP.md index 63c2b0c..157850f 100644 --- a/docs/TYPE-MAP.md +++ b/docs/TYPE-MAP.md @@ -6,21 +6,84 @@ This file documents the Type Map & Registry configuration used by the MTP protoc Every transport frame is a four-byte big-endian length followed by one `CommunicationValue`. The length counts all bytes after the length field. +This is the only transport frame length prefix. Transports write the +`CommunicationValue` bytes directly and do not add another length before this +field. The close-frame sentinel occupies the same four-byte position. + ```text -u32 length -u16 communication_type -u8 flags -u32 id if flag 0x04 is set -u48 sender if flag 0x01 is set -u48 receiver if flag 0x02 is set -u8 signature_type if flag 0x10 is set -... signature if flag 0x10 is set, length depends on signature_type -... data container or encrypted payload +[4 bytes total length] +[2 bytes communication type] +[1 byte flags] + bit 0 = has ID + bit 1 = has sender ID + bit 2 = has receiver ID + bits 3-7 must be zero +[4 bytes ID] if bit 0 +[8 bytes sender ID] if bit 1 +[8 bytes receiver ID] if bit 2 +[DataValue payload] ``` -The flag values are `0x01` for sender, `0x02` for receiver, `0x04` for frame ID, `0x08` for encrypted data, `0x10` for a frame signature, and `0x20` for a signed encrypted container. Sender and receiver IDs are six-byte unsigned big-endian values. The `communication_type` and every container field use IDs from the negotiated `TypeMap`. +The only defined flag values are `0x01` for ID, `0x02` for sender, and `0x04` for receiver. Unknown flag bits are rejected. IDs are full-width unsigned big-endian values: the correlation ID is `u32`, while sender and receiver IDs are `u64`. Encryption and signing are properties of the `DataValue` payload, never of the frame header. -Data values begin with a one-byte kind marker. MTP assigns `0x01` and `0x02` to boolean true and false, `0x03` to signed integers, `0x04` to unsigned integers, `0x05` to floats, `0x06` to UTF-8 strings, `0x07` to bytes, `0x08` to arrays, `0x09` to containers, `0x0A` through `0x0C` to crypto containers, and `0xFF` to null. Length-prefixed values use a four-byte big-endian payload length; container and array counts use two-byte big-endian counts. +`Relay` is the reserved opaque application communication type. Relay frames +omit the outer sender, expose only the next-hop receiver and transport +correlation data, and carry the actual operation and application metadata in +their protected payload. + +## DataValue Wire Format + +Every `DataValue` begins with a one-byte kind marker. MTP assigns `0x01` and `0x02` to boolean true and false, `0x03` to signed `i128`, `0x04` to unsigned `u128`, `0x05` to `f64`, `0x06` to UTF-8 strings, `0x07` to bytes, `0x08` to arrays, `0x09` to containers, `0x0A` to `Encrypted`, `0x0B` to `Signed`, and `0xFF` to null. Kind `0x0C` is reserved and rejected. All multibyte numeric values, counts, and lengths are big-endian. + +Strings and bytes have a four-byte byte length. Arrays have a two-byte element count followed by that many self-delimiting values. The protection wrappers have the following canonical layouts. + +```text +Container + +09 +[2 bytes element count] + +repeat for each element: + [2 bytes DataTypeId] + [DataValue] +``` + +Container field IDs must be unique. Each nested value is self-delimiting, so container elements have no generic per-element payload length. + +```text +Signed + +0B +[4 bytes wrapper length] + +[1 byte signature algorithm] +[1 byte purpose] +[8 bytes signer ID] +[signature] +[DataValue] +``` + +The wrapper length counts the bytes after the length field. Signature length is determined by the signature algorithm. The signature covers `MTP-DATA-SIGN-1 || algorithm || purpose || signer ID || serialized inner value`. + +```text +Encrypted + +0A +[4 bytes envelope length] + +[1 byte encryption suite] +[1 byte purpose] +[2 bytes recipient count] + +[recipient entry] +... + +[encrypted DataValue bytes] +``` + +The envelope length counts the bytes after the length field. A recipient entry is an unlabeled fixed-size KEM ciphertext and wrapped content-encryption key; both lengths are determined by the selected suite. The encrypted bytes are the AEAD output for the complete serialized inner `DataValue`. + +Protection nesting directly represents both signer-visibility choices: `Encrypted(Signed(Container))` keeps signer metadata private, while `Signed(Encrypted(Container))` exposes it. A frame with no outer sender and an `Encrypted(Signed(Container))` payload uses sealed sender. Sealed sender adds no flag or distinct wire type. ## TypeMap & Compile-Time Type Safety @@ -43,6 +106,12 @@ export default defineConfig({ Rust and manual WASM builds can set `MTP_TYPE_MAPS` directly (see [Customizing Type Maps in Downstream Projects](#customizing-type-maps-in-downstream-projects)). +For browser builds, `protocol_version` selects the one application map compiled +into that WASM client. The Vite-generated `mtp/type-map` module contains the +reserved MTP names and the application names from that selected version only; +the selected version must be present in `type_maps`. This keeps its TypeScript +unions aligned with the client runtime. + ### Using Generated Enums After editing the config and rebuilding, `CommunicationType` and `DataType` enums are generated automatically. Use them in code: @@ -50,11 +119,17 @@ After editing the config and rebuilding, `CommunicationType` and `DataType` enum ```rust use mtp::type_map::{CommunicationType, DataType, TypeMap}; -let tm = TypeMap::v2_0(); +let tm = TypeMap::v3_0(); let id = tm.data_id_enum(DataType::SomeType).unwrap(); ``` -The enums are a **union across all versions**; every type name from every version is a variant. The version-specific `TypeMap` maps each variant to the correct wire ID for that version. For a type absent from a selected version, the lookup returns `None`. +For native builds with the `registry` feature, the enums are a **union across +all versions**; every type name from every version is a variant. The +version-specific `TypeMap` maps each variant to the correct wire ID for that +version. For a type absent from a selected version, the lookup returns `None`. +Browser-generated TypeScript unions intentionally differ: they contain only +the selected `protocol_version` plus reserved names, matching the WASM client +compiled by the Vite plugin. Encoding/decoding uses a `TypeMap` to resolve type names to wire IDs: @@ -70,23 +145,19 @@ let decoded = decode(&bytes, &tm).unwrap(); ``` ```rust -let tm_v2 = TypeMap::v2_0(); -assert!(tm_v2.data_id_enum(DataType::SomeType).is_some()); // defined in v2.0 -assert!(tm_v2.data_id_enum(DataType::ExampleType).is_none()); // NOT in v2.0 - -let tm_v1 = TypeMap::v1_0(); -assert!(tm_v1.data_id_enum(DataType::ExampleType).is_some()); // defined in v1.0 +let tm_v3 = TypeMap::v3_0(); +assert!(tm_v3.data_id_enum(DataType::SomeType).is_some()); ``` -When communicating with a peer on another version, encode only variants that map in the negotiated version. If an incoming frame names a type absent from the selected map, reject it as a protocol or type-map compatibility error; do not reinterpret its wire ID using another version's map. Keep old IDs stable, register both versions during migration, and remove a version only after its clients have moved. +When communicating with a peer on another version, encode only variants that map in the negotiated version. If an incoming frame names a type absent from the selected map, reject it as a protocol or type-map compatibility error; do not reinterpret its wire ID using another version's map. The self-delimiting codec begins at protocol version `3.0`; older versions are not codec fallbacks. ### Forward/Backward Compatibility Between Versions Because enums are a union of all types across versions, a variant might exist that has no wire mapping in the *negotiated* version: ``` -v2.0 client sends DataType::SomeType → host encodes with v2.0 TypeMap → wire ID 32 -v2.0 host receives DataType::ExampleType (from v1.0 client) → not in v2.0 TypeMap → None → Error +v3.0 client sends DataType::SomeType → host encodes with v3.0 TypeMap → wire ID 32 +v3.0 host receives an unsupported pre-v3.0 peer → version negotiation error ``` Encoding a frame with an unmapped communication or data type returns `CodecError::UnknownCommunicationType` or `CodecError::UnknownDataType`. Select a mapped variant from the compiled-in version before sending it. @@ -109,10 +180,10 @@ let registry = Registry::builtin(); let codec = VersionedCodec::new(registry); // Encode with a specific version -let bytes = codec.encode(&value, Version(2, 0)).unwrap(); +let bytes = codec.encode(&value, Version(3, 0)).unwrap(); // Decode with a specific version -let decoded = codec.decode(&bytes, Version(2, 0)).unwrap(); +let decoded = codec.decode(&bytes, Version(3, 0)).unwrap(); ``` ## Customizing Type Maps in Downstream Projects diff --git a/docs/WASM-CLIENT.md b/docs/WASM-CLIENT.md index 5ebd293..d3b4fe1 100644 --- a/docs/WASM-CLIENT.md +++ b/docs/WASM-CLIENT.md @@ -30,6 +30,11 @@ import { mtp } from "mtp/vite"; Browser apps provide their own type map. The Vite plugin runs `wasm-pack` during dev and build with `MTP_TYPE_MAPS` set, writes generated output under `node_modules/.vite/mtp/` by default, and aliases `mtp/raw` plus `mtp/type-map` to that generated output. Configuration: [Type Map](TYPE-MAP.md). +The browser build uses the map named by `protocol_version` and includes the +reserved MTP names. It does not advertise application names from other map +versions, because the generated WASM client is compiled for that one protocol +version. The selected version must exist in `type_maps`. + You do not need to publish, fork, or copy an app-specific generated WASM package. The [web client example](../example/web-client/src/main.ts) shows the entry point. Its [Vite configuration](../example/web-client/vite.config.ts) shows the generated binding integration. @@ -100,7 +105,8 @@ if (!MTPClient.isSupported()) { | `pings` | `false` | Protocol pings, or an object with `intervalMs`. | | `logger` | No-op | Receives SDK state and error events. | | `sessionStorage` | In-memory | E2EE session state storage. | -| `encryptedDeviceSecretProvider` | In-memory | Device-secret storage for E2EE. | +| `encryptedSecretProvider` | In-memory | Independent caller-managed encrypted secret storage. | +| `defaultSignatureVerificationPolicy` | `"ed25519"` | Receiver policy for protected signatures. | `wasm` selects a custom generated WASM module. `MTPClient.create` validates positive safe-integer values for the numeric limits and timeout options. @@ -112,7 +118,222 @@ The browser SDK uses WebTransport and JavaScript promises. The native client use The `storage` option supplies the credential adapter. The adapter stores the client ID and serialized keyring after registration and returns them for later connections. The SDK does not select `localStorage` or IndexedDB for an application. Treat the serialized keyring as private key material. -`sessionStorage` and `encryptedDeviceSecretProvider` are separate E2EE session stores. The latter exchanges `EncryptedDeviceSecretRecord` values through `setEncryptedDeviceSecret` and `getEncryptedDeviceSecret`; the application chooses the backing store and protects its wrapping key. +`sessionStorage` and `encryptedSecretProvider` are separate caller-managed +stores. The latter exchanges `MTPEncryptedSecretRecord` values through +`setEncryptedSecret`, `getEncryptedSecret`, and `deleteEncryptedSecret`. +`MTPSessionManager` does not automatically route session state through the +provider. If session material must be encrypted at rest, the caller must make +that coordination explicit in its `MTPSessionStorage` implementation. Secret +IDs are opaque to MTP, so a caller can map its own state to the ID while +choosing the backing store and protecting its wrapping key. + +### Direct Protected Messages + +Use `sendProtected` when the destination is the frame receiver and no +intermediate relay needs a separately encrypted metadata layer. It keeps the +application communication type on the outer frame and encrypts an MTP-owned +signed envelope for the exact recipient bundles supplied by the caller. The +envelope authenticates `ProtectedVersion`, `MessageType`, `FinalRecipientId`, +`MessageId`, `CreatedAt`, and `Content`. The opening operation checks the +authenticated type and final recipient against the outer frame. + +```typescript +await client.sendProtected("ProtectedMessage", { Content: "hello" }, { + receiverId: recipientId, + recipients: [recipientPublicKey], + signaturePurpose: 0x40, + encryptionPurpose: 0x41, + exposeSender: false, +}); +``` + +The protection purposes are application-defined domain-separation values. +`exposeSender` controls only the outer frame sender; the protected value remains +signed in either case. If `identity` is omitted, the SDK uses stored registered +credentials and rejects the operation when no usable protection identity is +available. + +An unauthenticated connection can still send a protected value when the caller +provides an explicit `identity` with the signer ID and keyring. The connection's +authentication state and the protected signer's identity are independent. + +When `signatureSuite` is omitted, protected send helpers use Ed25519 even when +the signing keyring also contains post-quantum keys. This matches the default +receiver policy. Use `signatureSuite: "dual"` together with +`signaturePolicy: "dual"` when both sides explicitly require hybrid +signatures. + +Open a direct protected frame with the recipient keyring and a resolver that +receives the claimed, unverified signer ID only as a trusted-key lookup key: + +```typescript +const message = await client.openProtected(frame, { + recipient: { + id: recipientId, + keyring: recipientKeyring, + keyringHistory: previousRecipientKeyrings, + }, + expectedReceiverId: recipientId, + expectedSignerId: signerId, + resolveSignerPublicKeys: (id) => signerDirectory.get(id) ?? [], + signaturePolicy: "dual", + signaturePurpose: 0x40, + encryptionPurpose: 0x41, + replayGuard, +}); + +console.log(message.type, message.signerId, message.messageId, message.data); +``` + +`protectedVersion`, `finalRecipientId`, `signerId`, `messageId`, and `createdAt` +are taken from the verified protected envelope. `outerSender`, when present, +must equal the authenticated signer. +Protected application data may be any supported MTP `DataValue`, including +scalar, byte, array, and container values. Direct opening uses a bounded +process-local duplicate-suppression guard by default. The bounded cache can +evict old entries, so supply a durable `replayGuard` keyed by authenticated +signer and message ID when replay protection must survive eviction, reloads, or +multiple receiver processes. The guard also receives authenticated +`createdAt` metadata, which is not part of the replay key. +`subscribeProtected` uses the same opening and verification path: + +```typescript +const unsubscribe = client.subscribeProtected( + "ProtectedMessage", + (message, frame) => handleMessage(message.data, frame), + { + recipient: { id: recipientId, keyring: recipientKeyring }, + resolveSignerPublicKeys: (id) => signerDirectory.get(id) ?? [], + signaturePolicy: "dual", + signaturePurpose: 0x40, + encryptionPurpose: 0x41, + }, +); +``` + +Each `subscribeProtected` registration owns its own bounded default replay +guard, so multiple handlers receive the same raw frame through the WASM +fan-out dispatcher. Pass the same caller-owned `replayGuard` deliberately when +several subscriptions should share replay state. + +### Sealed Relay Messages + +`sendSealedRelay` uses the reserved opaque `Relay` communication type. Its +inner message type must be an application communication type, not an MTP +control type. The outer frame contains no sender and exposes only the next-hop +receiver. The +signed relay metadata contains the generic `signerId`, `finalRecipientId`, +`messageId`, `createdAt`, application `metadata`, and an opaque encrypted +content value. `createdAt` is generated as Unix epoch milliseconds. For +example, `2026-08-11T12:00:00.000Z` is `1786449600000`. + +```typescript +const data = { Content: "hello" }; + +await client.sendSealedRelay("ProtectedMessage", data, { + finalRecipientId, + nextHopId, + metadataRecipients: [ + relayPublicKey, + recipientPublicKey, + ], + contentRecipients: [ + recipientPublicKey, + ], + metadata: { + ExampleMetadata: "routing context", + }, +}); + +client.subscribeSealedRelay( + "ProtectedMessage", + (message, frame) => handleMessage(message.data, frame), + { + recipient: { + id: finalRecipientId, + keyring: recipientKeyring, + }, + expectedSignerId: signerId, + resolveSignerPublicKeys: () => [senderPublicKey], + }, +); +``` + +The caller supplies the exact metadata and content recipient sets; the SDK +does not infer application topology. Set `signaturePolicy: "dual"` to require +hybrid signatures explicitly, and install a durable `replayGuard` so a valid +`(signerId, messageId)` is dispatched only once. + +Each sealed-relay or metadata subscription likewise gets an independent +bounded default guard. This preserves fan-out when multiple handlers inspect +the same outer `Relay` frame; an explicitly supplied guard is shared by the +subscriptions that receive it. + +Applications choose between direct protected delivery and sealed relay based +on topology and metadata-access requirements. Prefer `sendProtected` for a +direct destination. Use `sendSealedRelay` when a next hop must route or store a +message and the application needs metadata recipients to differ from content +recipients. Neither construction requires connection authentication, although +the host can associate an authenticated connection with its registered MTP +identity. + +For metadata-only access, call `openRelayMetadata` or subscribe with +`subscribeRelayMetadata`. These operations authenticate the metadata and +expose `encryptedContent` for forwarding without attempting content +decryption. A final recipient calls `openRelayContent` after metadata +verification; the returned `MTPVerifiedRelayContent` includes the application +type and data plus `signerId`, `finalRecipientId`, `messageId`, `createdAt`, +and generic metadata fields. These are authenticated protected identities, not +the clear outer sender and next-hop receiver. +Relay content inherits the authenticated metadata's `signaturePolicy` when no +content override is supplied. A different content policy is rejected so the +two relay layers cannot be verified under conflicting rules. +Metadata passed to a `subscribeRelayMetadata` handler is callback-scoped and is +disposed after the handler resolves. Do not retain it for a later +`openRelayContent` call; use `openRelayMetadata` directly when a longer-lived +verified capability is needed, and call `dispose()` when finished. +When signer key history is used, `signerPublicKeys` exposes the trusted +candidates, `matchedSignerKeyIndex` identifies the key that verified the +metadata, and `matchedSignerPublicKey` returns that exact bundle. + +Protected receive operations accept an optional `recipient` decryption +identity. Its `keyring` controls decryption and its optional `id` is used only +for final-recipient validation. The identity is independent from connection +authentication. Metadata opening does not require the identity ID to match the +clear next-hop receiver, so a forwarded frame can be opened by a metadata +recipient or final recipient with the appropriate keyring. When `recipient` is +omitted, stored registered credentials remain the convenience fallback. + +To open values encrypted for a rotated recipient, provide `keyringHistory` on +the decryption identity. The current `keyring` is tried first, followed by +history entries from newest to oldest. Exact duplicate byte sequences are +removed without changing the caller's input arrays. An empty current keyring +or an empty history entry is rejected. + +Generic MTP `DataValue` inputs accept `bigint` for exact integer values. An +integral JavaScript `number` outside the safe-integer range is rejected, so it +cannot silently become an imprecise float. Use `bigint` for large signed or +unsigned integers. + +For streams, prefer `createEncryptedPipe` and `acceptEncryptedPipe`; they bind +the actual pipe ID and local identity automatically. The lower-level +`initiateMTPPipeSession` API also accepts multiple recipient bundles for a +group bootstrap. Group membership changes require a fresh session ID and +recipient set. Live calls that need forward secrecy can use the exported +duplex `initiateMTPForwardSecurePipeSession` and +`acceptMTPForwardSecurePipeSession` helpers. + +The convenience pipe methods intentionally require registered client +credentials because they use the connection's registered identity as the +endpoint identity. Use the lower-level session functions when transport +authentication and cryptographic endpoint identity must remain independent. + +Receive-side signature policy is independent from the recipient keyring. Use +`signaturePolicy` on protected receive and encrypted-pipe accept operations, +or configure `defaultSignatureVerificationPolicy` on the client. The sender's +`signatureSuite` selects how local values are signed and is a separate choice. +Both sender and receiver default to Ed25519; `dual` is always an explicit +choice on each side. ### Native and Browser Certificate Checks @@ -262,7 +483,10 @@ Use `pings: true` for the default interval. ## Pipes -Pipes are raw binary streams over QUIC. A pipe starts with a lightweight `PipeRequest` handshake frame, then the stream carries raw bytes with zero per-frame overhead. Pipes are unidirectional; the peer that initiates the pipe writes, and the peer that accepts it reads. +Pipes are byte-oriented streams over WebTransport. The `PipeRequest` type and +description are clear transport metadata; raw stream bytes are not protected +by MTP. For sensitive calls, files, or application streams, wrap the accepted +pipe with `MTPEncryptedPipeWriter` or `MTPEncryptedPipeReader`. ### Outgoing Pipes @@ -284,6 +508,41 @@ await writer.close(); `writer.close()` sends a QUIC stream FIN. `writer.abort()` resets the stream abruptly. Each `write` resolves when the chunk has been handed to the transport; it does not wait for the peer to consume it. +### Encrypted Pipe Records + +`initiateMTPPipeSession` and `acceptMTPPipeSession` perform the signed/KEM +protected pipe-session offer and return the encrypted record wrapper. The +offer binds the session ID, pipe ID, endpoint IDs, direction, and purpose. Do +not derive the initial chain key from the clear description or pipe ID alone. + +```typescript +import { + initiateMTPPipeSession, +} from "mtp"; + +const encryptedWriter = await initiateMTPPipeSession( + writer, + { + sessionId: new TextEncoder().encode(`file-transfer/${writer.pipeId}`), + pipeId: writer.pipeId, + senderId: ownClientId, + recipientId: hostClientId, + purpose: 0x40, + direction: 0, + }, + ownKeyring, + hostPublicKeyBundle, +); +await encryptedWriter.writeRecord(chunk); +await encryptedWriter.close(); +``` + +`writeRecord` and `readRecord` use XChaCha20-Poly1305 with ordered sequence +numbers bound to the session context. Each record advances an HKDF chain and +uses a one-use message key. Record insertion, removal, reordering, or +modification fails authentication. The wrapper is intentionally separate from +the raw `PipeWriter`/`PipeReader` transport primitives. + The handle and writer expose `pipeId` and `description`: ```typescript @@ -330,8 +589,8 @@ console.log(reader.pipeId, reader.description); 1. The initiator calls `createPipe(description)`; the SDK sends a `PipeRequest` frame with a random `pipeId` and the description. 2. The receiver's `setOnPipeRequest` callback fires with `{ pipeId, description }`. -3. The receiver calls `acceptPipe(pipeId)`; the SDK sends a `PipeResponse` with `Accepted = true` and opens a new unidirectional stream for raw data. -4. The initiator's `handle.wait()` resolves with a `PipeWriter` bound to that stream. +3. The receiver calls `acceptPipe(pipeId)`; the SDK sends a `PipeResponse` with `Accepted = true` and opens a new unidirectional stream for byte transport. +4. The initiator's `handle.wait()` resolves with a `PipeWriter` bound to that stream. Sensitive applications then perform their signed/encrypted session-key setup and construct an encrypted record wrapper. 5. If the receiver calls `denyPipe(pipeId)`, `handle.wait()` resolves with `null`. Pipes share the same WebTransport session as message frames; they do not need a separate connection. @@ -417,9 +676,13 @@ Raw crypto and key helpers include: - `keyring_generate()` - `keyring_from_ed25519(secretKey, publicKey)` - `WasmKeyring.from_bytes(bytes)` and `keyring.to_bytes()` +- `keyring.validate_encryption()` for envelope decryption roles +- `keyring.validate_full()` for complete hybrid identities - `WasmPublicKeyBundle.from_bytes(bytes)` and `bundle.to_bytes()` - `WasmEd25519Signer` - `WasmChaCha20Poly1305` +- `sign_data_value_with_keyring` and `verify_data_value_with_policy` (both require an explicit signature suite), plus `encrypt_data_value`, `encrypt_data_value_for_recipients`, and `decrypt_data_value` +- `parse_data_value` and `encode_data_value` - `wasm_sha256`, `wasm_sha256_double`, `wasm_hkdf_expand`, and `wasm_derive_encryption_key` Raw authenticated login and registration map directly to the Rust WASM layer: @@ -463,4 +726,4 @@ A `WasmClient` manages one active WebTransport session. Create a new instance fo ### State Management -A `WasmClient` owns one active WebTransport session. Create a separate client for each independent connection. Call `free()` or `[Symbol.dispose]()` on raw WASM objects when the application no longer needs them. SDK session and device secret persistence are documented in [Security](SECURITY.md#browser-end-to-end-encryption). +A `WasmClient` owns one active WebTransport session. Create a separate client for each independent connection. Call `free()` or `[Symbol.dispose]()` on raw WASM objects when the application no longer needs them. SDK session and encrypted secret persistence are documented in [Security](SECURITY.md#browser-end-to-end-encryption). diff --git a/example-type-maps.yaml b/example-type-maps.yaml index ec04c5c..ae7c4ba 100644 --- a/example-type-maps.yaml +++ b/example-type-maps.yaml @@ -1,3 +1,7 @@ +################################################################################# +# This is an example, overwrite it for your project to register your own types. # +################################################################################# + # The version a Client should use protocol_version: "0.0" @@ -26,6 +30,7 @@ protocol_version: "0.0" # BadGateway: 20 # ServiceUnavailable: 21 # GatewayTimeout: 22 +# Relay: 26 # PipeRequest: 23 # PipeResponse: 24 # PipeAbort: 25 @@ -45,16 +50,29 @@ protocol_version: "0.0" # ErrorParsing: 11 # ErrorMessage: 12 # Accepted: 13, +# RequirePq: 14 +# MessageId: 15 +# FinalRecipientId: 18 +# CreatedAt: 21 +# MessageType: 22 +# Content: 23 +# Metadata: 24 +# RelayVersion: 25 +# ProtectedVersion: 26 # # Types absent from a protocol version cannot be encoded for that version. type_maps: "0.0": # Protocol version 0.0 CommunicationTypes: + ProtectedMessage: 32 + AlternateMessage: 33 DataTypes: ExampleType: 32 "1.0": CommunicationTypes: + ProtectedMessage: 32 + AlternateMessage: 33 DataTypes: # If a v0.0 client connects # - the server can't use "AnotherType" @@ -64,6 +82,8 @@ type_maps: SomeType: 34 "2.0": CommunicationTypes: + ProtectedMessage: 32 + AlternateMessage: 33 DataTypes: # If a v0.0 client connects # - the server can't use "AnotherType" diff --git a/example/Cargo.lock b/example/Cargo.lock index 4506a30..65f10cb 100644 --- a/example/Cargo.lock +++ b/example/Cargo.lock @@ -12,6 +12,18 @@ dependencies = [ "generic-array", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "asn1-rs" version = "0.7.2" @@ -131,6 +143,15 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -433,6 +454,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -1269,6 +1291,7 @@ dependencies = [ "mtp-crypto", "mtp-type-map", "rand 0.10.2", + "thiserror 2.0.18", ] [[package]] @@ -1293,7 +1316,7 @@ dependencies = [ "ml-dsa", "mlkem-tls", "rand 0.10.2", - "rand_core 0.10.1", + "rand_core 0.6.4", "rcgen", "rustls", "serde", @@ -1308,6 +1331,7 @@ dependencies = [ name = "mtp-files" version = "0.2.0" dependencies = [ + "argon2", "mtp-crypto", "rand 0.10.2", "thiserror 1.0.69", @@ -1336,6 +1360,7 @@ dependencies = [ "mtp-codec", "mtp-common", "mtp-crypto", + "rand 0.10.2", "rcgen", "rustls", "rustls-native-certs", @@ -1343,6 +1368,7 @@ dependencies = [ "tokio", "tracing", "wtransport", + "zeroize", ] [[package]] @@ -1490,6 +1516,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pem" version = "3.0.6" diff --git a/example/client/Cargo.toml b/example/client/Cargo.toml index 38fe2ad..41a3604 100644 --- a/example/client/Cargo.toml +++ b/example/client/Cargo.toml @@ -8,7 +8,7 @@ name = "client" path = "src/main.rs" [dependencies] -mtp = { version = "0.2.0", path = "../../", features = ["client", "crypto", "files", "pipes"] } +mtp = { version = "0.2.0", path = "../../", features = ["client", "crypto", "files", "pipes", "raw"] } tokio = { version = "1", features = ["full"] } rand = "0.10.1" tracing-subscriber = "0.3.23" diff --git a/example/client/src/auth.rs b/example/client/src/auth.rs index cd947d7..90334ef 100644 --- a/example/client/src/auth.rs +++ b/example/client/src/auth.rs @@ -3,9 +3,7 @@ use std::time::{Duration, Instant}; use tokio::fs; use mtp::client::{ClientConfig, MTPClient, MTPConnection}; -use mtp::crypto::{ - Ed25519Signer, KemPrivateKey, KemPublicKey, Keyring, MlDsaSigner, PublicKeyBundle, -}; +use mtp::crypto::{Keyring, PublicKeyBundle}; use mtp::files::{load_keyring_raw, save_keyring_raw}; pub async fn connect_or_register( @@ -40,17 +38,8 @@ pub async fn connect_or_register( println!("No existing keys found: registering new client"); - /* The client authenticates with signatures only, so the KEM slot is empty. */ - let (_ed_signer, sig_sk, sig_pk) = Ed25519Signer::generate(); - let (_pq_signer, sig_pq_sk, sig_pq_pk) = MlDsaSigner::generate(); - let keyring = Keyring::new( - KemPublicKey::new(vec![]), - KemPrivateKey::new(vec![]), - sig_pq_pk, - sig_pq_sk, - sig_pk, - sig_sk, - ); + /* Registration publishes a complete MTP identity for later protection. */ + let keyring = Keyring::generate(); let reg_started = Instant::now(); let conn = MTPClient::auth_register(config, &keyring, &host_public_key).await?; @@ -63,3 +52,19 @@ pub async fn connect_or_register( Ok((conn, keyring, "register".into(), reg_duration)) } + +/// Open a guest transport even when the caller already owns registered +/// credentials. The credentials stay with the caller for protected signing. +pub async fn connect_unauthenticated( + config: ClientConfig, +) -> Result> { + let conn = MTPClient::connect(config).await?; + if conn.auth_state != mtp::client::AuthState::Unauthenticated { + return Err("guest connection did not report Unauthenticated state".into()); + } + println!( + "Opened unauthenticated transport with host-assigned guest ID {}", + conn.client_id + ); + Ok(conn) +} diff --git a/example/client/src/main.rs b/example/client/src/main.rs index c7358c6..06e1e9b 100644 --- a/example/client/src/main.rs +++ b/example/client/src/main.rs @@ -2,12 +2,13 @@ mod auth; mod metrics; mod messages; mod pipes; +mod protected; use std::fs; use std::path::Path; use std::time::Duration; -use mtp::client::ClientConfig; +use mtp::client::{AuthState, ClientConfig}; use mtp::files::load_public_key_bundle; fn dev_cert_path() -> String { @@ -43,7 +44,7 @@ async fn main() -> Result<(), Box> { println!("Connecting to 127.0.0.1:8080 ..."); let config = ClientConfig::new("https://127.0.0.1:8080") - .with_pinned_pem(cert_pem) + .with_pinned_pem(cert_pem.clone()) .with_description("MTP example client"); let server_bundle = host_public_key.clone(); @@ -62,9 +63,57 @@ async fn main() -> Result<(), Box> { let mut builder = metrics::SessionBuilder::new(&auth_method, auth_duration); + if conn.auth_state != AuthState::Authenticated { + return Err("authenticated example connection did not report Authenticated state".into()); + } + println!( + "Receive connection A: authenticated client {}", + conn.client_id + ); + + let unauthenticated_config = ClientConfig::new("https://127.0.0.1:8080") + .with_pinned_pem(cert_pem.clone()) + .with_description("MTP example unauthenticated sender"); + let unauthenticated_conn = auth::connect_unauthenticated(unauthenticated_config).await?; + println!( + "Send connection B: unauthenticated guest transport ID {}", + unauthenticated_conn.client_id + ); + + let direct_roundtrip = protected::send_direct_protected( + &unauthenticated_conn, + conn.client_id, + &keyring, + &server_bundle, + ) + .await?; + println!( + "Protected signer {} was accepted through unauthenticated connection B", + conn.client_id + ); + + let relay_roundtrip = protected::send_sealed_relay( + &unauthenticated_conn, + conn.client_id, + &keyring, + &server_bundle, + ) + .await?; + println!( + "Sealed relay round-trip completed in {:.3}ms", + relay_roundtrip.as_secs_f64() * 1000.0 + ); + + unauthenticated_conn.sender.close().await; + let roundtrip = messages::send_and_receive(&conn, &keyring, &server_bundle).await?; builder.set_message_roundtrip(roundtrip); + println!( + "Direct protected round-trip: {:.3}ms", + direct_roundtrip.as_secs_f64() * 1000.0 + ); + println!("\n--- Pipe demo ---"); let pipe_results = pipes::run_pipe_demo(&conn, 1).await?; for result in &pipe_results { diff --git a/example/client/src/messages.rs b/example/client/src/messages.rs index 90da68f..f5da0d1 100644 --- a/example/client/src/messages.rs +++ b/example/client/src/messages.rs @@ -1,8 +1,9 @@ use std::time::{Duration, Instant}; use mtp::client::MTPConnection; +use mtp::codec::ProtectionPurpose; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; -use mtp::crypto::{Ed25519Signer, EncryptionType, Keyring, PublicKeyBundle, SigAlgorithm}; +use mtp::crypto::{Ed25519Signer, Keyring, PublicKeyBundle}; use mtp::type_map::TypeMap; pub fn build_demo_message( @@ -11,7 +12,6 @@ pub fn build_demo_message( server_bundle: &PublicKeyBundle, ) -> Result> { // Encrypt to the server's KEM public key; the server decrypts with its keyring. - let enc_type = EncryptionType::MlKemChaCha20Poly1305; let signer = Ed25519Signer::new(&keyring.sig_cl_secret_key)?; let tm = TypeMap::latest(); @@ -26,15 +26,16 @@ pub fn build_demo_message( (version_id, DataValue::Str("secret inner data".into())), (id_id, DataValue::UnsignedNumber(42)), ]); - let mut dv_enc = inner_enc; - dv_enc.encrypt_container(enc_type, server_bundle, b"demo-aad"); + let dv_enc = inner_enc.encrypt_for( + std::slice::from_ref(server_bundle), + ProtectionPurpose::from(1), + )?; let inner_sig = DataValue::Container(vec![ (version_id, DataValue::Str("signed by client".into())), (id_id, DataValue::UnsignedNumber(99)), ]); - let mut dv_sig = inner_sig; - dv_sig.sign_container(SigAlgorithm::ED25519, &signer); + let dv_sig = inner_sig.sign(client_id, ProtectionPurpose::from(2), &signer)?; let inner_sec = DataValue::Container(vec![ ( @@ -43,18 +44,16 @@ pub fn build_demo_message( ), (id_id, DataValue::UnsignedNumber(7)), ]); - let mut dv_sec = inner_sec; - dv_sec.sign_and_encrypt_container( - SigAlgorithm::ED25519, - &signer, - enc_type, - server_bundle, - b"demo-aad", - ); + let dv_sec = inner_sec + .sign(client_id, ProtectionPurpose::from(3), &signer)? + .encrypt_for( + std::slice::from_ref(server_bundle), + ProtectionPurpose::from(4), + )?; let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? - .as_secs(); + .as_millis(); let msg = CommunicationValue::new(CommunicationType::Ping) .add_typed_default( @@ -101,7 +100,10 @@ pub async fn send_and_receive( Ok(resp) => { let roundtrip = start.elapsed(); println!("Received: {resp}"); - println!("Message round-trip: {:.3}ms", roundtrip.as_secs_f64() * 1000.0); + println!( + "Message round-trip: {:.3}ms", + roundtrip.as_secs_f64() * 1000.0 + ); Ok(roundtrip) } Err(e) => { diff --git a/example/client/src/metrics.rs b/example/client/src/metrics.rs index 59c7e98..fcc039a 100644 --- a/example/client/src/metrics.rs +++ b/example/client/src/metrics.rs @@ -1,3 +1,4 @@ +use mtp::common::unix_time_millis; use serde::{Deserialize, Serialize}; use std::path::Path; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -8,6 +9,9 @@ fn now_epoch_secs() -> u64 { .unwrap_or_default() .as_secs() } +fn now_epoch_millis() -> u64 { + unix_time_millis().unwrap_or_default() +} fn generate_session_id() -> String { let ts = now_epoch_secs(); @@ -197,7 +201,7 @@ impl SessionBuilder { pub fn new(auth_method: &str, auth_duration: Duration) -> Self { Self { session_id: generate_session_id(), - timestamp: now_epoch_secs(), + timestamp: now_epoch_millis(), auth_method: auth_method.to_string(), auth_duration_ms: auth_duration.as_secs_f64() * 1000.0, error: None, @@ -219,11 +223,7 @@ impl SessionBuilder { } pub fn build(self) -> ClientSessionRecord { - let total_pipe_bytes: u64 = self - .pipe_results - .iter() - .map(|r| r.size as u64) - .sum(); + let total_pipe_bytes: u64 = self.pipe_results.iter().map(|r| r.size as u64).sum(); let overall_pipe_avg_total_ms = if self.pipe_results.is_empty() { 0.0 @@ -235,7 +235,10 @@ impl SessionBuilder { let overall_pipe_avg_data_ms = if self.pipe_results.is_empty() { 0.0 } else { - self.pipe_results.iter().map(|r| r.data_only_ms).sum::() + self.pipe_results + .iter() + .map(|r| r.data_only_ms) + .sum::() / self.pipe_results.len() as f64 }; diff --git a/example/client/src/protected.rs b/example/client/src/protected.rs new file mode 100644 index 0000000..50b7925 --- /dev/null +++ b/example/client/src/protected.rs @@ -0,0 +1,198 @@ +use std::time::{Duration, Instant}; + +use mtp::client::MTPConnection; +use mtp::codec::{ + CommunicationType, DataType, DataValue, ProtectionPolicy, ProtectedMessageBuilder, + ProtectionPurpose, SealedRelayBuilder, SignaturePolicy, TypeMap, open_relay_content, + open_relay_metadata, +}; +use mtp::common::unix_time_millis; +use mtp::crypto::{Ed25519Signer, Keyring, PublicKeyBundle}; + +/// The direct protected example sends to the host as the destination MTP ID. +pub const DIRECT_DESTINATION_ID: u64 = 1; + +/// The example host acts as the metadata relay and uses this stable MTP ID. +pub const METADATA_RELAY_ID: u64 = 1; + +/// This keyring represents a final recipient independently of the transport +/// identity used by the example client. +pub const FINAL_RECIPIENT_ID: u64 = 7_002; + +const DIRECT_SIGNATURE_PURPOSE: u8 = 0x40; +const DIRECT_ENCRYPTION_PURPOSE: u8 = 0x41; +const RELAY_SIGNATURE_POLICY: ProtectionPolicy = ProtectionPolicy { + signature: SignaturePolicy::Ed25519, +}; + +fn type_id( + data_type: DataType, + type_map: &TypeMap, +) -> Result> { + data_type + .try_to_id(type_map) + .ok_or_else(|| format!("missing example data type mapping for {data_type}").into()) +} + +fn application_value(text: &str, number: u128) -> Result> { + let type_map = TypeMap::latest(); + Ok(DataValue::Container(vec![ + ( + type_id(DataType::ExampleText, &type_map)?, + DataValue::Str(text.to_owned()), + ), + ( + type_id(DataType::ExampleNumber, &type_map)?, + DataValue::UnsignedNumber(number), + ), + ])) +} + +fn relay_metadata() -> Result> { + let type_map = TypeMap::latest(); + Ok(DataValue::Container(vec![ + ( + type_id(DataType::ExampleRole, &type_map)?, + DataValue::Str("metadata relay".into()), + ), + ( + type_id(DataType::ExampleMetadata, &type_map)?, + DataValue::Str("application metadata remains authenticated and opaque to MTP".into()), + ), + ])) +} + +/// Send an application value directly to the host without constructing a +/// Relay frame. The outer sender is deliberately absent so the example also +/// demonstrates that the protected signer is independent of transport auth. +pub async fn send_direct_protected( + conn: &MTPConnection, + signer_id: u64, + signer_keyring: &Keyring, + recipient_public_key: &PublicKeyBundle, +) -> Result> { + let signer = Ed25519Signer::new(&signer_keyring.sig_cl_secret_key)?; + let created_at = unix_time_millis()?; + let message_id = format!( + "example-direct-{created_at}-{}", + rand::random::() + ); + let content = application_value("direct protected delivery", 40)?; + let frame = ProtectedMessageBuilder::new( + "ProtectedMessage", + content, + signer_id, + DIRECT_DESTINATION_ID, + &signer, + ProtectionPurpose::from(DIRECT_SIGNATURE_PURPOSE), + ProtectionPurpose::from(DIRECT_ENCRYPTION_PURPOSE), + ) + .message_id(message_id) + .created_at(created_at) + .recipients(vec![recipient_public_key.clone()]) + .type_map(&TypeMap::latest()) + .build()?; + + println!( + "Sending direct protected frame: type=ProtectedMessage receiver={} outer_sender=absent signer={signer_id}", + DIRECT_DESTINATION_ID + ); + let started = Instant::now(); + conn.sender.send(&frame).await?; + let response = conn.receive().await?; + if !response.is_type(CommunicationType::Pong) { + return Err(format!("direct protected response was not Pong: {response}").into()); + } + let elapsed = started.elapsed(); + println!( + "Direct protected value verified and acknowledged in {:.3}ms", + elapsed.as_secs_f64() * 1000.0 + ); + Ok(elapsed) +} + +/// Send a sealed relay through the host, which can open metadata but cannot +/// decrypt the content. The final recipient is represented by a separate +/// keyring so the example does not conflate relay and content access. +pub async fn send_sealed_relay( + conn: &MTPConnection, + signer_id: u64, + signer_keyring: &Keyring, + metadata_relay_public_key: &PublicKeyBundle, +) -> Result> { + let type_map = TypeMap::latest(); + let final_recipient_keyring = Keyring::generate(); + let final_recipient_public_key = final_recipient_keyring.public_key_bundle(); + let signer = Ed25519Signer::new(&signer_keyring.sig_cl_secret_key)?; + let created_at = unix_time_millis()?; + let message_id = format!("example-relay-{created_at}-{}", rand::random::()); + let frame = SealedRelayBuilder::new( + "ProtectedMessage", + application_value("sealed relay delivery", 41)?, + signer_id, + FINAL_RECIPIENT_ID, + METADATA_RELAY_ID, + &signer, + ) + .message_id(message_id) + .created_at(created_at) + .metadata(relay_metadata()?) + .metadata_recipients(vec![ + metadata_relay_public_key.clone(), + final_recipient_public_key.clone(), + ]) + .content_recipients(vec![final_recipient_public_key]) + .type_map(&type_map) + .build()?; + + println!( + "Sending sealed relay: next_hop={} final_recipient={} metadata_recipients=2 content_recipients=1", + METADATA_RELAY_ID, FINAL_RECIPIENT_ID + ); + let started = Instant::now(); + conn.sender.send(&frame).await?; + let forwarded = conn.receive().await?; + if !forwarded.is_type(CommunicationType::Relay) { + return Err(format!("relay response was not Relay: {forwarded}").into()); + } + if forwarded.sender().is_some() || forwarded.receiver() != Some(FINAL_RECIPIENT_ID) { + return Err("relay forwarding changed the sealed-sender boundary".into()); + } + + let metadata = open_relay_metadata( + &forwarded, + &final_recipient_keyring, + signer_id, + &signer_keyring.public_key_bundle(), + RELAY_SIGNATURE_POLICY, + )?; + let application_metadata = metadata + .metadata() + .ok_or("forwarded relay metadata was missing")?; + let content = open_relay_content( + &metadata, + &final_recipient_keyring, + &signer_keyring.public_key_bundle(), + FINAL_RECIPIENT_ID, + RELAY_SIGNATURE_POLICY, + )?; + if content.message_type != "ProtectedMessage" { + return Err(format!("unexpected relay message type: {}", content.message_type).into()); + } + let expected_metadata = relay_metadata()?; + if application_metadata != &expected_metadata { + return Err("relay application metadata changed during forwarding".into()); + } + let expected_content = application_value("sealed relay delivery", 41)?; + if content.content != expected_content { + return Err("relay application content changed during forwarding".into()); + } + + let elapsed = started.elapsed(); + println!( + "Final recipient opened authenticated metadata and content in {:.3}ms (message_id={})", + elapsed.as_secs_f64() * 1000.0, + metadata.message_id() + ); + Ok(elapsed) +} diff --git a/example/keygen/Cargo.toml b/example/keygen/Cargo.toml index f2ecad2..6ed92b1 100644 --- a/example/keygen/Cargo.toml +++ b/example/keygen/Cargo.toml @@ -4,4 +4,4 @@ version = "0.2.0" edition = "2024" [dependencies] -mtp = { version = "0.2.0", path = "../../", features = ["files"] } +mtp = { version = "0.2.0", path = "../../", features = ["files", "raw"] } diff --git a/example/server/Cargo.toml b/example/server/Cargo.toml index c4c3f1f..03cead1 100644 --- a/example/server/Cargo.toml +++ b/example/server/Cargo.toml @@ -8,7 +8,7 @@ name = "server" path = "src/main.rs" [dependencies] -mtp = { version = "0.2.0", path = "../../", features = ["crypto", "tls", "web-server", "files", "pipes"] } +mtp = { version = "0.2.0", path = "../../", features = ["crypto", "tls", "web-server", "files", "pipes", "raw"] } tokio = { version = "1", features = ["full"] } http = "1" serde_json = { version = "1" } diff --git a/example/server/src/handlers.rs b/example/server/src/handlers.rs index 3c08ffa..65dfedd 100644 --- a/example/server/src/handlers.rs +++ b/example/server/src/handlers.rs @@ -1,23 +1,208 @@ -use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataTypeId, DataValue, TypeMap}; -use mtp::crypto::{CryptoError, Keyring, SignaturePublicKey, SignatureScheme, verify_ed25519}; +use std::collections::HashMap; -struct Ed25519Verifier(SignaturePublicKey); +use mtp::codec::{ + CommunicationType, CommunicationValue, DataType, DataTypeId, DataValue, InMemoryReplayGuard, + ProtectionPolicy, ProtectionPurpose, SignaturePolicy, TypeMap, + forward_relay_frame, open_protected_with, open_relay_content, open_relay_metadata_with, +}; +use mtp::crypto::{Keyring, PublicKeyBundle}; -impl SignatureScheme for Ed25519Verifier { - fn sign(&self, _msg: &[u8]) -> Result, CryptoError> { - Err(CryptoError::SigningFailed) +const DIRECT_DESTINATION_ID: u64 = 1; +const METADATA_RELAY_ID: u64 = 1; +const FINAL_RECIPIENT_ID: u64 = 7_002; +const DIRECT_SIGNATURE_PURPOSE: u8 = 0x40; +const DIRECT_ENCRYPTION_PURPOSE: u8 = 0x41; +const SIGNATURE_POLICY: ProtectionPolicy = ProtectionPolicy { + signature: SignaturePolicy::Ed25519, +}; + +fn resolve_signer_key( + signer_id: u64, + registered_clients: &HashMap, +) -> Option { + registered_clients.get(&signer_id).cloned() +} + +fn pong(tm: &TypeMap, data: impl Into) -> Result { + let desc_id = DataTypeId( + tm.data_id_enum(DataType::Description) + .ok_or("missing Description type mapping")?, + ); + let ts_id = DataTypeId( + tm.data_id_enum(DataType::Timestamp) + .ok_or("missing Timestamp type mapping")?, + ); + let data_id = DataTypeId( + tm.data_id_enum(DataType::Data) + .ok_or("missing Data type mapping")?, + ); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| e.to_string())? + .as_millis(); + + CommunicationValue::from_comm(CommunicationType::Pong, tm) + .add_data(desc_id, DataValue::Str("MTP example response".into())) + .map_err(|e| e.to_string())? + .add_data(ts_id, DataValue::UnsignedNumber(now as u128)) + .map_err(|e| e.to_string())? + .add_data(data_id, DataValue::Str(data.into())) + .map_err(|e| e.to_string()) +} + +fn process_direct_protected( + msg: &CommunicationValue, + tm: &TypeMap, + client_pk: Option<&PublicKeyBundle>, + registered_clients: &HashMap, + host_keyring: &Keyring, + accepted_messages: &mut InMemoryReplayGuard, +) -> Result { + if msg.receiver() != Some(DIRECT_DESTINATION_ID) { + return Err(format!( + "direct protected frame was addressed to {:?}, expected destination {DIRECT_DESTINATION_ID}", + msg.receiver() + )); } - fn verify(&self, msg: &[u8], signature: &[u8]) -> Result<(), CryptoError> { - verify_ed25519(&self.0, msg, signature) + + let opened = open_protected_with( + msg, + std::slice::from_ref(&host_keyring), + None, + |signer_id| resolve_signer_key(signer_id, registered_clients).map(|key| vec![key]), + Some(DIRECT_DESTINATION_ID), + ProtectionPurpose::from(DIRECT_SIGNATURE_PURPOSE), + ProtectionPurpose::from(DIRECT_ENCRYPTION_PURPOSE), + SIGNATURE_POLICY, + Some(accepted_messages), + ) + .map_err(|e| format!("direct protected message could not be authenticated: {e}"))?; + let signer_id = opened.signer_id; + let message_id = opened.message_id; + let value = opened + .content + .as_container() + .ok_or("direct protected application value is not a container")?; + + let text_id = DataTypeId( + tm.data_id_enum(DataType::ExampleText) + .ok_or("missing ExampleText type mapping")?, + ); + let number_id = DataTypeId( + tm.data_id_enum(DataType::ExampleNumber) + .ok_or("missing ExampleNumber type mapping")?, + ); + let text = value + .iter() + .find(|(id, _)| *id == text_id) + .and_then(|(_, value)| value.as_str()) + .ok_or("direct protected value is missing ExampleText")?; + let number = value + .iter() + .find(|(id, _)| *id == number_id) + .and_then(|(_, value)| value.as_unsigned_number()) + .ok_or("direct protected value is missing ExampleNumber")?; + + println!( + " Direct protected message: signer={signer_id}, message_id={message_id}, transport_key_available={}, ExampleText={text:?}, ExampleNumber={number}", + client_pk.is_some() + ); + if client_pk.is_none() { + println!( + " Protected signer was verified from the registered key map; transport is unauthenticated" + ); } + pong( + tm, + format!("direct protected value verified for signer {signer_id}"), + ) +} + +fn process_sealed_relay( + msg: &CommunicationValue, + registered_clients: &HashMap, + host_keyring: &Keyring, + accepted_messages: &mut InMemoryReplayGuard, +) -> Result { + if msg.receiver() != Some(METADATA_RELAY_ID) { + return Err(format!( + "sealed relay next hop was {:?}, expected metadata relay {METADATA_RELAY_ID}", + msg.receiver() + )); + } + + let metadata = open_relay_metadata_with( + msg, + std::slice::from_ref(&host_keyring), + None, + |signer_id| { + resolve_signer_key(signer_id, registered_clients).map(|key| vec![key]) + }, + SIGNATURE_POLICY, + Some(accepted_messages), + ) + .map_err(|e| format!("metadata relay could not authenticate metadata: {e}"))?; + println!( + " Metadata relay opened message_id={} signer={} final_recipient={} metadata={:?}", + metadata.message_id(), + metadata.signer_id(), + metadata.final_recipient_id(), + metadata.metadata() + ); + println!( + " Metadata relay retained opaque encrypted content ({} bytes)", + metadata + .encrypted_content() + .to_bytes() + .map_err(|e| format!("opaque content serialization failed: {e}"))? + .len() + ); + + let content_result = open_relay_content( + &metadata, + host_keyring, + &resolve_signer_key(metadata.signer_id(), registered_clients) + .ok_or("metadata signer key disappeared")?, + FINAL_RECIPIENT_ID, + SIGNATURE_POLICY, + ); + if content_result.is_ok() { + return Err("metadata relay unexpectedly decrypted final-recipient content".into()); + } + println!(" Metadata relay cannot decrypt final-recipient content (expected)"); + + forward_relay_frame(msg, metadata.final_recipient_id()) + .map_err(|e| format!("metadata relay forwarding failed: {e}")) } pub fn process_and_respond( msg: &CommunicationValue, tm: &TypeMap, client_pk: Option<&mtp::crypto::PublicKeyBundle>, + registered_clients: &HashMap, host_keyring: &Keyring, + accepted_direct_messages: &mut InMemoryReplayGuard, + accepted_relay_messages: &mut InMemoryReplayGuard, ) -> Result { + if msg.is_type(CommunicationType::ProtectedMessage) { + return process_direct_protected( + msg, + tm, + client_pk, + registered_clients, + host_keyring, + accepted_direct_messages, + ); + } + if msg.is_type(CommunicationType::Relay) { + return process_sealed_relay( + msg, + registered_clients, + host_keyring, + accepted_relay_messages, + ); + } + let desc_id = DataTypeId( tm.data_id_enum(DataType::Description) .ok_or("missing Description type mapping")?, @@ -59,13 +244,34 @@ pub fn process_and_respond( .ok_or("missing SecurePayload type mapping")?, ); - let description = msg.get_data(DataType::Description); - let timestamp = msg.get_data(DataType::Timestamp); - let data = msg.get_data(DataType::Data); - let flags = msg.get_data(DataType::Flags); - let value = msg.get_data(DataType::Value); - let binary = msg.get_data(DataType::BinaryData); - let items = msg.get_data(DataType::Items); + let description = msg + .get_data(DataType::Description) + .cloned() + .unwrap_or(DataValue::Null); + let timestamp = msg + .get_data(DataType::Timestamp) + .cloned() + .unwrap_or(DataValue::Null); + let data = msg + .get_data(DataType::Data) + .cloned() + .unwrap_or(DataValue::Null); + let flags = msg + .get_data(DataType::Flags) + .cloned() + .unwrap_or(DataValue::Null); + let value = msg + .get_data(DataType::Value) + .cloned() + .unwrap_or(DataValue::Null); + let binary = msg + .get_data(DataType::BinaryData) + .cloned() + .unwrap_or(DataValue::Null); + let items = msg + .get_data(DataType::Items) + .cloned() + .unwrap_or(DataValue::Null); println!( " Description: {}", @@ -82,13 +288,8 @@ pub fn process_and_respond( let mut sig_status = String::from("SignedPayload: not present"); let mut secure_status = String::from("SecurePayload: not present"); - let enc = msg.get_data(DataType::EncryptedPayload); - if matches!(enc, DataValue::EncryptedContainer(_)) { - let mut dv = enc.clone(); - if dv - .decrypt_into_container(host_keyring, b"demo-aad") - .is_some() - { + if let Some(enc @ DataValue::Encrypted(_)) = msg.get_data(DataType::EncryptedPayload) { + if let Ok(dv) = enc.decrypt(host_keyring, mtp::codec::ProtectionPurpose::from(1)) { if let Some(entries) = dv.as_container() { println!(" Decrypted EncryptedPayload: {:?}", entries); enc_status = format!("EncryptedPayload decrypted OK ({} entries)", entries.len()); @@ -99,13 +300,19 @@ pub fn process_and_respond( } } - let sig = msg.get_data(DataType::SignedPayload); - if matches!(sig, DataValue::SignedContainer(_)) { + if let Some(sig @ DataValue::Signed(_)) = msg.get_data(DataType::SignedPayload) { if let Some(pk_bundle) = client_pk { - let verifier = Ed25519Verifier(pk_bundle.sig_cl_public_key.clone()); - let mut dv = sig.clone(); - if dv.verify_into_container(&verifier).is_some() { - if let Some(entries) = dv.as_container() { + let signer_id = sig.as_signed().map(|signed| signed.signer_id); + if let Some(signer_id) = signer_id + && sig + .verify(signer_id, pk_bundle, mtp::codec::ProtectionPurpose::from(2)) + .is_ok() + { + let dv = sig + .clone() + .into_verified(signer_id, pk_bundle, mtp::codec::ProtectionPurpose::from(2)) + .ok(); + if let Some(entries) = dv.and_then(|value| value.as_container()) { println!(" Verified SignedPayload: {:?}", entries); sig_status = format!("SignedPayload verified OK ({} entries)", entries.len()); } @@ -119,17 +326,23 @@ pub fn process_and_respond( } } - let secure = msg.get_data(DataType::SecurePayload); - if matches!(secure, DataValue::SignedEncryptedContainer(_)) { + if let Some(secure @ DataValue::Encrypted(_)) = msg.get_data(DataType::SecurePayload) { if let Some(pk_bundle) = client_pk { - let verifier = Ed25519Verifier(pk_bundle.sig_cl_public_key.clone()); - let mut dv = secure.clone(); - if dv - .decrypt_signed_encrypted_container(host_keyring, b"demo-aad") - .is_some() - && dv.verify_into_container(&verifier).is_some() + if let Ok(opened) = secure.decrypt(host_keyring, mtp::codec::ProtectionPurpose::from(4)) + && let Some(signed) = opened.as_signed() + && opened + .verify( + signed.signer_id, + pk_bundle, + mtp::codec::ProtectionPurpose::from(3), + ) + .is_ok() { - if let Some(entries) = dv.as_container() { + let signer_id = signed.signer_id; + let dv = opened + .into_verified(signer_id, pk_bundle, mtp::codec::ProtectionPurpose::from(3)) + .ok(); + if let Some(entries) = dv.and_then(|value| value.as_container()) { println!(" Verified SecurePayload: {:?}", entries); secure_status = format!( "SecurePayload decrypted+verified OK ({} entries)", @@ -149,11 +362,13 @@ pub fn process_and_respond( let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_err(|e| e.to_string())? - .as_secs(); + .as_millis(); - Ok(CommunicationValue::from_comm(CommunicationType::Pong, tm) - .add_data(desc_id, description.clone()) + let response = CommunicationValue::from_comm(CommunicationType::Pong, tm) + .add_data(desc_id, description) + .map_err(|e| e.to_string())? .add_data(ts_id, DataValue::UnsignedNumber(now as u128)) + .map_err(|e| e.to_string())? .add_data( data_id, DataValue::Str(format!( @@ -161,8 +376,14 @@ pub fn process_and_respond( enc_status, sig_status, secure_status )), ) - .add_data(flags_id, flags.clone()) - .add_data(value_id, value.clone()) - .add_data(bin_id, binary.clone()) - .add_data(items_id, items.clone())) + .map_err(|e| e.to_string())? + .add_data(flags_id, flags) + .map_err(|e| e.to_string())? + .add_data(value_id, value) + .map_err(|e| e.to_string())? + .add_data(bin_id, binary) + .map_err(|e| e.to_string())? + .add_data(items_id, items) + .map_err(|e| e.to_string())?; + Ok(response) } diff --git a/example/server/src/main.rs b/example/server/src/main.rs index 596c42d..9854d2f 100644 --- a/example/server/src/main.rs +++ b/example/server/src/main.rs @@ -6,7 +6,7 @@ mod tls; #[path = "web-server.rs"] mod web_server; -use mtp::host::HostConfig; +use mtp::host::{AuthenticationPolicy, AuthState, HostConfig}; use mtp::type_map::TypeMap; use std::future::Future; use std::path::Path; @@ -136,7 +136,8 @@ async fn main() -> Result<(), Box> { host_keyring, Box::new(get_existing_client), Box::new(complete_register), - ); + ) + .with_authentication_policy(AuthenticationPolicy::AllowAuthentication); let mut host = mtp::webserver::MTPWebServer::new(config, web_server::config()?).await?; println!("Server listening on https://{}", host.local_addr()); @@ -158,9 +159,16 @@ async fn main() -> Result<(), Box> { }; let decrypt_keyring = Arc::clone(&decrypt_keyring); let metrics = Arc::clone(&metrics); + let registered_clients = Arc::clone(&clients); metrics.record_connection_version(&conn.version.to_string()); tokio::spawn(async move { let desc = conn.description.as_deref().unwrap_or("(no description)"); + let connection_state = match &conn.auth_state { + AuthState::Authenticated => "authenticated client", + AuthState::Unauthenticated => "unauthenticated client", + AuthState::Pending => "pending client", + AuthState::Failed => "failed client", + }; println!( "\n--- New connection (version {}, remote: {}, description: {desc}) ---", conn.version, @@ -168,7 +176,7 @@ async fn main() -> Result<(), Box> { .map(|addr| addr.to_string()) .unwrap_or_else(|| "unknown".into()) ); - println!("Client ID: {}", conn.client_id); + println!("Connection state: {connection_state}; MTP ID: {}", conn.client_id); let mut session = metrics.start_session(conn.client_id, desc.to_string()); @@ -177,6 +185,8 @@ async fn main() -> Result<(), Box> { println!("Waiting for messages / pipe requests ..."); let mut pipe_open = true; let mut message_open = true; + let mut accepted_direct_messages = mtp::codec::InMemoryReplayGuard::default(); + let mut accepted_relay_messages = mtp::codec::InMemoryReplayGuard::default(); let mut exit_reason = "normal".to_string(); while pipe_open || message_open { @@ -215,11 +225,18 @@ async fn main() -> Result<(), Box> { Ok(message) => { println!("Received: {message}"); let msg_start = std::time::Instant::now(); + let registered_clients = registered_clients + .lock() + .map(|clients| clients.clone()) + .unwrap_or_default(); let result = handlers::process_and_respond( &message, tm, conn.client_public_key.as_ref(), + ®istered_clients, &decrypt_keyring, + &mut accepted_direct_messages, + &mut accepted_relay_messages, ); let latency = msg_start.elapsed(); let ok = result.is_ok(); diff --git a/example/server/src/metrics.rs b/example/server/src/metrics.rs index 314a891..10dcd62 100644 --- a/example/server/src/metrics.rs +++ b/example/server/src/metrics.rs @@ -331,7 +331,7 @@ impl ServerMetrics { } // --------------------------------------------------------------------------- -// Session handle — local accumulators, no mutex contention during connection +// Session handle, local accumulators, no mutex contention during connection // --------------------------------------------------------------------------- pub struct SessionHandle<'a> { diff --git a/example/type-maps.yaml b/example/type-maps.yaml index 6e411e4..0f96f2e 100644 --- a/example/type-maps.yaml +++ b/example/type-maps.yaml @@ -1,29 +1,13 @@ -protocol_version: "1.0" +protocol_version: "3.0" type_maps: - "0.0": + "3.0": CommunicationTypes: + ProtectedMessage: 32 + AlternateMessage: 33 DataTypes: - "1.0": - CommunicationTypes: - CommunicationType: 32 - DataTypes: - Data: 32 Flags: 33 - Value: 34 - BinaryData: 35 - Items: 36 - EncryptedPayload: 37 - SignedPayload: 38 - SecurePayload: 39 - CommunicationType: 40 - DataType: 41 - "2.0": - CommunicationTypes: - CommunicationType: 32 - DataTypes: Data: 34 - Flags: 33 Value: 35 BinaryData: 36 Items: 37 @@ -32,3 +16,7 @@ type_maps: SecurePayload: 40 CommunicationType: 41 DataType: 42 + ExampleText: 43 + ExampleNumber: 44 + ExampleRole: 45 + ExampleMetadata: 46 diff --git a/example/web-client/index.html b/example/web-client/index.html index f2fadda..db6008a 100644 --- a/example/web-client/index.html +++ b/example/web-client/index.html @@ -67,6 +67,9 @@ Use new credentials + diff --git a/example/web-client/src/main.ts b/example/web-client/src/main.ts index d9316a6..ccdfe52 100644 --- a/example/web-client/src/main.ts +++ b/example/web-client/src/main.ts @@ -20,6 +20,9 @@ const GENERATE_KEYPAIR = document.getElementById( "generate-keypair", ) as HTMLButtonElement; const CONNECT = document.getElementById("connect") as HTMLButtonElement; +const CONNECT_UNAUTHENTICATED = document.getElementById( + "connect-unauthenticated", +) as HTMLButtonElement; const CLEAR_KEYS = document.getElementById("clear-keys") as HTMLButtonElement; const STREAM_MIC = document.getElementById("stream-mic") as HTMLButtonElement; const STOP_MIC = document.getElementById("stop-mic") as HTMLButtonElement; @@ -32,7 +35,6 @@ const HOST_PUBLIC_KEY_KEY = "mtp-web-client-host-public-key"; type SavedKeys = { clientId: string | null; keyring?: number[]; - keyringBytes?: number[]; hostPublicKey?: number[]; }; @@ -290,7 +292,7 @@ function loadKeys() { const data = JSON.parse(raw) as SavedKeys; clientId = data.clientId ? BigInt(data.clientId) : null; - const keyringLength = (data.keyring ?? data.keyringBytes ?? []).length; + const keyringLength = (data.keyring ?? []).length; CLIENT_CREDENTIALS.value = renderStructured({ clientId: data.clientId, keyringBytes: keyringLength, @@ -352,6 +354,7 @@ async function initWasm() { const supported = MTPClient.isSupported(); log(`WASM loaded. WebTransport supported: ${supported}`); CONNECT.disabled = !supported; + CONNECT_UNAUTHENTICATED.disabled = !supported; } async function createClient() { @@ -448,6 +451,39 @@ async function connect() { } } +async function connectUnauthenticated() { + STATUS.textContent = ""; + PIPE_STATUS.textContent = ""; + + if (!MTPClient.isSupported()) { + log("WebTransport is not supported in this browser.", "error"); + return; + } + saveHostPublicKey(); + + try { + const client = await createClient(); + activeClient = client; + const storedIdentity = client.credentials?.clientId; + await client.connectUnauthenticated(); + + clientId = storedIdentity ?? clientId; + loadKeys(); + log( + `Connected over an unauthenticated transport (guest connection). Stored protection identity ${storedIdentity == null ? "not registered" : `${storedIdentity} retained`}.`, + ); + log( + "The explicit connectUnauthenticated() path did not delete or replace stored credentials.", + "state", + ); + STREAM_MIC.disabled = false; + log("\nPipe demo ready. Click 'Stream Microphone' to start.", "pipe"); + updateMetrics(); + } catch (error) { + log(`[error] ${error}`, "error"); + } +} + async function startMicStreaming() { if (!activeClient) { pipeLog("No active client connection.", "error"); @@ -719,6 +755,13 @@ CONNECT.addEventListener("click", () => { }); }); +CONNECT_UNAUTHENTICATED.addEventListener("click", () => { + connectUnauthenticated().catch((e) => { + log(`Unauthenticated connection failed: ${e}`, "error"); + console.error(e); + }); +}); + CLEAR_KEYS.addEventListener("click", () => { clientId = null; CLIENT_CREDENTIALS.value = ""; diff --git a/files/Cargo.toml b/files/Cargo.toml index 4dcecde..80d577d 100644 --- a/files/Cargo.toml +++ b/files/Cargo.toml @@ -7,7 +7,12 @@ edition = "2024" # Only the plain key types (`Keyring`, `PublicKeyBundle`, `CryptoError`) are # needed here; those are always compiled, so no crypto features are required. mtp-crypto = { version = "0.2.0", path = "../crypto", default-features = false, features = ["chacha20poly1305", "hkdf"] } +argon2 = "0.5" rand = "0.10.2" thiserror = "2" zeroize = "1.9" + +[features] +# Plain private-key files are only needed by migration tooling and tests. +raw = [] diff --git a/files/src/lib.rs b/files/src/lib.rs index 2fb1610..9d6e1af 100644 --- a/files/src/lib.rs +++ b/files/src/lib.rs @@ -13,7 +13,7 @@ use std::io; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; -use mtp_crypto::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305, derive_encryption_key}; +use mtp_crypto::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305}; use rand::RngExt; use thiserror::Error; use zeroize::Zeroizing; @@ -29,11 +29,15 @@ pub const BUNDLE_EXTENSION: &str = "mpkb"; const KEYRING_MAGIC: [u8; 4] = *b"MTMK"; /* Methanium Keyring */ const BUNDLE_MAGIC: [u8; 4] = *b"MPKB"; /* Methanium Public Key Bundle */ const RAW_FORMAT_VERSION: u8 = 1; -const PROTECTED_FORMAT_VERSION: u8 = 2; +const PROTECTED_FORMAT_VERSION: u8 = 3; const BUNDLE_FORMAT_VERSION: u8 = 1; const HEADER_LEN: usize = 4 + 1; const SALT_LEN: usize = 32; -const KEYRING_KDF_CONTEXT: &[u8] = b"mtp-keyring-at-rest-v2"; +const KDF_ID_ARGON2ID: u8 = 1; +const ARGON2_MEMORY_KIB: u32 = 19 * 1024; +const ARGON2_ITERATIONS: u32 = 2; +const ARGON2_LANES: u32 = 1; +const PROTECTED_PARAMS_LEN: usize = 1 + 4 + 4 + 4 + SALT_LEN; #[derive(Error, Debug)] pub enum FileError { @@ -145,7 +149,39 @@ fn write_secret_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { Ok(()) } -/// Save a keyring encrypted with XChaCha20-Poly1305 under an HKDF-derived key. +fn derive_key( + passphrase: &[u8], + salt: &[u8], + memory_kib: u32, + iterations: u32, + lanes: u32, +) -> Result, FileError> { + if salt.len() != SALT_LEN + || !(8 * 1024..=256 * 1024).contains(&memory_kib) + || !(1..=10).contains(&iterations) + || !(1..=8).contains(&lanes) + { + return Err(FileError::Crypto(CryptoError::KdfError)); + } + let params = argon2::Params::new(memory_kib, iterations, lanes, Some(32)) + .map_err(|_| FileError::Crypto(CryptoError::KdfError))?; + let argon = argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params); + let mut key = Zeroizing::new([0u8; 32]); + argon + .hash_password_into(passphrase, salt, key.as_mut()) + .map_err(|_| FileError::Crypto(CryptoError::KdfError))?; + Ok(key) +} + +fn protected_header_aad(parameters: &[u8]) -> Vec { + let mut aad = Vec::with_capacity(HEADER_LEN + parameters.len()); + aad.extend_from_slice(&KEYRING_MAGIC); + aad.push(PROTECTED_FORMAT_VERSION); + aad.extend_from_slice(parameters); + aad +} + +/// Save a keyring encrypted with XChaCha20-Poly1305 under Argon2id. pub fn save_keyring( keyring: &Keyring, path: impl AsRef, @@ -156,16 +192,24 @@ pub fn save_keyring( } let mut salt = [0u8; SALT_LEN]; rand::rng().fill(&mut salt); - let key = Zeroizing::new(derive_encryption_key( + let key = derive_key( passphrase, &salt, - KEYRING_KDF_CONTEXT, - )?); + ARGON2_MEMORY_KIB, + ARGON2_ITERATIONS, + ARGON2_LANES, + )?; + let mut parameters = Vec::with_capacity(PROTECTED_PARAMS_LEN); + parameters.push(KDF_ID_ARGON2ID); + parameters.extend_from_slice(&ARGON2_MEMORY_KIB.to_be_bytes()); + parameters.extend_from_slice(&ARGON2_ITERATIONS.to_be_bytes()); + parameters.extend_from_slice(&ARGON2_LANES.to_be_bytes()); + parameters.extend_from_slice(&salt); let cipher = ChaCha20Poly1305::new(*key); let plaintext = keyring.to_bytes(); - let encrypted = cipher.encrypt(&plaintext, &KEYRING_MAGIC)?; - let mut payload = Vec::with_capacity(SALT_LEN + encrypted.len()); - payload.extend_from_slice(&salt); + let encrypted = cipher.encrypt(&plaintext, &protected_header_aad(¶meters))?; + let mut payload = Vec::with_capacity(PROTECTED_PARAMS_LEN + encrypted.len()); + payload.extend_from_slice(¶meters); payload.extend_from_slice(&encrypted); let bytes = encode(KEYRING_MAGIC, PROTECTED_FORMAT_VERSION, &payload); write_secret_atomic(path.as_ref(), &bytes)?; @@ -187,23 +231,33 @@ pub fn load_keyring(path: impl AsRef, passphrase: &[u8]) -> Result) -> Result<(), FileError> { let payload = keyring.to_bytes(); let bytes = Zeroizing::new(encode(KEYRING_MAGIC, RAW_FORMAT_VERSION, &payload)); @@ -212,6 +266,7 @@ pub fn save_keyring_raw(keyring: &Keyring, path: impl AsRef) -> Result<(), } /// Explicitly load the legacy plaintext format for tests and development. +#[cfg(any(test, feature = "raw"))] pub fn load_keyring_raw(path: impl AsRef) -> Result { let bytes = Zeroizing::new(fs::read(path)?); let (version, payload) = decode(&bytes, KEYRING_MAGIC, "keyring")?; @@ -245,15 +300,16 @@ pub fn load_public_key_bundle(path: impl AsRef) -> Result Keyring { Keyring::new( - KemPublicKey::new(vec![1u8; 32]), + KemPublicKey::new(vec![1u8; KEM_PUBLIC_KEY_LEN]), KemPrivateKey::new(vec![2u8; 32]), - SignaturePqPublicKey::new(vec![3u8; 64]), + SignaturePqPublicKey::new(vec![3u8; SIG_PQ_PUBLIC_KEY_LEN]), SignaturePqPrivateKey::new(vec![4u8; 64]), - SignaturePublicKey::new(vec![5u8; 32]), + SignaturePublicKey::new(vec![5u8; SIG_CL_PUBLIC_KEY_LEN]), SignaturePrivateKey::new(vec![6u8; 32]), ) } @@ -377,4 +433,21 @@ mod tests { let _ = fs::remove_file(&path); Ok(()) } + + #[test] + fn protected_header_parameters_are_authenticated() -> Result<(), Box> { + let path = temp_path(KEYRING_EXTENSION); + save_keyring(&sample_keyring(), &path, b"passphrase")?; + let mut stored = fs::read(&path)?; + // The iteration count begins after the file header, KDF identifier, + // and memory parameter: MTMK || version || KDF || memory. + stored[5 + 1 + 4 + 3] ^= 1; + fs::write(&path, stored)?; + assert!(matches!( + load_keyring(&path, b"passphrase"), + Err(FileError::Crypto(CryptoError::DecryptionFailed)) + )); + let _ = fs::remove_file(&path); + Ok(()) + } } diff --git a/flake.nix b/flake.nix index a449e49..aef6e83 100644 --- a/flake.nix +++ b/flake.nix @@ -40,7 +40,7 @@ name = "mtp-clippy"; runtimeInputs = [rustToolchain]; text = '' - export MTP_TYPE_MAPS="''${MTP_TYPE_MAPS:-$PWD/example-type-maps.yaml}" + export MTP_TYPE_MAPS="''${MTP_TYPE_MAPS:-$PWD/example/type-maps.yaml}" cargo clippy --workspace --exclude mtp-wasm --all-targets --all-features -- -D warnings -W unreachable-pub ''; }; @@ -57,7 +57,7 @@ name = "mtp-build-all"; runtimeInputs = [rustToolchain pkgs.cargo-deny pkgs.wasm-pack pkgs.pnpm pkgs.coreutils clippyCheck macheteCheck]; text = '' - export MTP_TYPE_MAPS="''${MTP_TYPE_MAPS:-$PWD/example-type-maps.yaml}" + export MTP_TYPE_MAPS="''${MTP_TYPE_MAPS:-$PWD/example/type-maps.yaml}" timeout 60s pnpm install --frozen-lockfile cargo fmt --all --check @@ -69,6 +69,11 @@ mtp-machete pnpm run dup pnpm run build + RUSTFLAGS="--cfg web_sys_unstable_apis" wasm-pack test --node wasm + pnpm run test:e2e + pnpm run test:secrets + pnpm run test:types + pnpm run check:boundary pnpm --filter mtp-web-client run build ''; }; @@ -96,7 +101,7 @@ openssl ]; - MTP_TYPE_MAPS = "${toString ./example-type-maps.yaml}"; + MTP_TYPE_MAPS = "${toString ./example/type-maps.yaml}"; shellHook = '' repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" diff --git a/host/src/config.rs b/host/src/config.rs index fc27d1d..2335437 100644 --- a/host/src/config.rs +++ b/host/src/config.rs @@ -1,8 +1,14 @@ use std::net::IpAddr; +#[cfg(feature = "crypto")] +use std::collections::HashMap; +#[cfg(feature = "crypto")] +use std::collections::HashSet; #[cfg(feature = "crypto")] use std::pin::Pin; #[cfg(feature = "crypto")] +use std::sync::{Arc, Mutex}; +#[cfg(feature = "crypto")] use tokio::time::Duration; pub use mtp_transport::Policy; @@ -26,18 +32,23 @@ pub type GetExistingClient = Box< /// Callback that assigns a guest (unauthenticated) client ID. /// -/// Return `Some(id)` to accept the guest with the given ID, or `None` to reject -/// the connection. The returned ID must fit in 48 bits -/// (`id <= mtp_codec::MAX_WIRE_ID`); values outside that range are rejected -/// automatically. +/// Return `Some(id)` to accept the guest with the given full-width `u64` ID, or +/// `None` to reject the connection. /// /// When set to `None` on `HostConfig`, the built-in generator produces a random -/// 48-bit ID that avoids collisions with registered clients. +/// full-width non-zero ID that avoids collisions with registered clients and +/// currently connected guests. #[cfg(feature = "crypto")] pub type GuestIdGenerator = Box Pin> + Send>> + Send + Sync>; #[cfg(feature = "crypto")] +/// Callback that commits a new registration and returns its non-zero ID. +/// +/// The host serializes registration commits and remembers successful identity +/// assignments for the lifetime of the host. Applications that need retry +/// recovery across a host restart should also configure [`FindRegisteredClient`] +/// to look up the public identity in persistent storage. pub type CompleteRegister = Box< dyn Fn( mtp_crypto::PublicKeyBundle, @@ -47,6 +58,22 @@ pub type CompleteRegister = Box< + Sync, >; +/// Callback that recovers an existing registration by its public identity. +/// +/// Returning an ID makes a registration retry idempotent: the host can send +/// the same final response when the original response was lost after the +/// application committed the registration. Returning `None` asks the host to +/// invoke [`CompleteRegister`] for a new registration. +#[cfg(feature = "crypto")] +pub type FindRegisteredClient = Box< + dyn Fn( + mtp_crypto::PublicKeyBundle, + Option, + ) -> Pin> + Send>> + + Send + + Sync, +>; + #[cfg(feature = "crypto")] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AuthenticationPolicy { @@ -75,9 +102,17 @@ pub struct HostConfig { #[cfg(feature = "crypto")] pub get_existing_client: GetExistingClient, #[cfg(feature = "crypto")] + pub(crate) active_guest_ids: Arc>>, + #[cfg(feature = "crypto")] + pub(crate) registration_ids: Arc, u64>>>, + #[cfg(feature = "crypto")] + pub(crate) registration_lock: Arc>, + #[cfg(feature = "crypto")] pub guest_id_generator: Option, #[cfg(feature = "crypto")] pub complete_register: CompleteRegister, + #[cfg(feature = "crypto")] + pub find_registered_client: Option, } impl HostConfig { @@ -107,9 +142,17 @@ impl HostConfig { #[cfg(feature = "crypto")] get_existing_client: Box::new(|_, _| Box::pin(async { None })), #[cfg(feature = "crypto")] + active_guest_ids: Arc::new(Mutex::new(HashSet::new())), + #[cfg(feature = "crypto")] + registration_ids: Arc::new(Mutex::new(HashMap::new())), + #[cfg(feature = "crypto")] + registration_lock: Arc::new(tokio::sync::Mutex::new(())), + #[cfg(feature = "crypto")] guest_id_generator: None, #[cfg(feature = "crypto")] complete_register: Box::new(|_, _| Box::pin(async { 0 })), + #[cfg(feature = "crypto")] + find_registered_client: None, } } @@ -160,4 +203,11 @@ impl HostConfig { self.guest_id_generator = Some(generator); self } + + /// Configure the lookup used to make registration retries idempotent. + #[cfg(feature = "crypto")] + pub fn with_registration_lookup(mut self, lookup: FindRegisteredClient) -> Self { + self.find_registered_client = Some(lookup); + self + } } diff --git a/host/src/connection.rs b/host/src/connection.rs index 453588b..20cc76d 100644 --- a/host/src/connection.rs +++ b/host/src/connection.rs @@ -77,12 +77,30 @@ pub struct MTPConnection< pub(crate) _pipe_stream: std::marker::PhantomData

, pub description: Option, pub(crate) _dispatcher_task: tokio::task::JoinHandle<()>, + /// Keeps an outer server admission permit alive for this MTP session. + /// Native hosts leave it empty; WebTransport hosts use it to make the + /// configured connection limit cover the session lifetime. + pub(crate) _connection_guard: Option, #[cfg(feature = "crypto")] pub auth_state: crate::error::AuthState, #[cfg(feature = "crypto")] pub client_id: u64, #[cfg(feature = "crypto")] pub client_public_key: Option, + #[cfg(feature = "crypto")] + pub(crate) guest_id_lease: Option, +} + +impl MTPConnection { + /// Keep an outer server admission permit until this connection is dropped. + pub fn set_connection_guard(&mut self, guard: tokio::sync::OwnedSemaphorePermit) { + self._connection_guard = Some(guard); + } + + #[cfg(feature = "crypto")] + pub fn set_guest_id_lease(&mut self, lease: Option) { + self.guest_id_lease = lease; + } } #[cfg(feature = "pipes")] @@ -133,6 +151,7 @@ where pending_creations: Mutex::new(std::collections::HashMap::new()), pending_pipes: Mutex::new(std::collections::HashMap::new()), policy, + type_map: codec.type_map().clone(), }); let task = tokio::spawn(run_dispatcher( receiver.clone(), @@ -153,12 +172,15 @@ where pipe_dispatcher: dispatcher, description, _dispatcher_task: task, + _connection_guard: None, #[cfg(feature = "crypto")] auth_state: crate::error::AuthState::Unauthenticated, #[cfg(feature = "crypto")] client_id: random_client_id(), #[cfg(feature = "crypto")] client_public_key: None, + #[cfg(feature = "crypto")] + guest_id_lease: None, } } @@ -182,6 +204,7 @@ where pending_creations: Mutex::new(std::collections::HashMap::new()), pending_pipes: Mutex::new(std::collections::HashMap::new()), policy, + type_map: codec.type_map().clone(), }); let task = tokio::spawn(run_dispatcher( receiver.clone(), @@ -202,12 +225,15 @@ where pipe_dispatcher: dispatcher, description, _dispatcher_task: task, + _connection_guard: None, #[cfg(feature = "crypto")] auth_state: crate::error::AuthState::Unauthenticated, #[cfg(feature = "crypto")] client_id: random_client_id(), #[cfg(feature = "crypto")] client_public_key: None, + #[cfg(feature = "crypto")] + guest_id_lease: None, } } } @@ -252,12 +278,15 @@ impl MTPConnection { description, _pipe_stream: std::marker::PhantomData, _dispatcher_task: tokio::spawn(async {}), + _connection_guard: None, #[cfg(feature = "crypto")] auth_state: crate::error::AuthState::Unauthenticated, #[cfg(feature = "crypto")] client_id: random_client_id(), #[cfg(feature = "crypto")] client_public_key: None, + #[cfg(feature = "crypto")] + guest_id_lease: None, } } } @@ -293,21 +322,33 @@ where &self, description: &str, ) -> Result, mtp_common::PipeError> { - let pipe_id = rand::random::(); let (response_tx, response_rx) = tokio::sync::oneshot::channel(); - self.pipe_dispatcher - .pending_creations - .lock() - .await - .insert(pipe_id, response_tx); + let pipe_id = { + let mut pending = self.pipe_dispatcher.pending_creations.lock().await; + let pipe_id = loop { + let candidate = rand::random::(); + if candidate != 0 && !pending.contains_key(&candidate) { + break candidate; + } + }; + pending.insert(pipe_id, response_tx); + pipe_id + }; - let request = CommunicationValue::new(CommunicationType::PipeRequest) - .with_id(pipe_id) - .add_typed_default(DataType::Description, DataValue::Str(description.into())); - self.sender - .send_pipe_message(&request) - .await - .map_err(mtp_common::PipeError::from)?; + let request = CommunicationValue::new_with_type_map( + CommunicationType::PipeRequest, + self.codec.type_map(), + ) + .with_id(pipe_id) + .add_typed_default(DataType::Description, DataValue::Str(description.into())); + if let Err(error) = self.sender.send_pipe_message(&request).await { + self.pipe_dispatcher + .pending_creations + .lock() + .await + .remove(&pipe_id); + return Err(mtp_common::PipeError::from(error)); + } Ok(crate::pipe::PipeHandle { pipe_id, diff --git a/host/src/engine.rs b/host/src/engine.rs index 7d6464e..078c2f1 100644 --- a/host/src/engine.rs +++ b/host/src/engine.rs @@ -7,11 +7,15 @@ use crate::config::HostConfig; use crate::error::AcceptError; use mtp_codec::{ - CommunicationType, CommunicationValue, DataType, DataValue, Version, + CommunicationType, CommunicationValue, DataType, DataValue, TypeMap, Version, registry::{Registry, VersionedCodec}, }; use mtp_common::{CommunicationError, RejectionReason}; +#[cfg(feature = "crypto")] +use std::collections::HashSet; use std::sync::Arc; +#[cfg(feature = "crypto")] +use std::sync::Mutex; /// Trait for sending handshake messages during the opening exchange. /// @@ -24,6 +28,9 @@ pub trait HandshakeSender: Send + Sync { fn finish_stream( &self, ) -> impl std::future::Future> + Send; + fn set_type_map(&self, _type_map: &TypeMap) -> impl std::future::Future + Send { + async {} + } fn close(&self); } @@ -34,6 +41,37 @@ pub trait HandshakeReceiver: Send + Sync { fn receive( &self, ) -> impl std::future::Future> + Send; + + /// Bind subsequently decoded frames to the negotiated type map. + /// + /// The opening frame must be decoded with the transport's bootstrap map so + /// that it can reveal the version. Once negotiation succeeds, all later + /// frames—including the remainder of the authentication exchange—must use + /// the negotiated map rather than whichever map happens to be latest at + /// compile time. + fn set_type_map(&self, _type_map: &TypeMap) -> impl std::future::Future + Send { + async {} + } +} + +/// A non-zero guest ID reserved for the lifetime of a connected session. +/// +/// The lease is moved into the resulting `MTPConnection`, so dropping that +/// connection releases the ID for a later guest session. +#[cfg(feature = "crypto")] +#[derive(Debug)] +pub struct GuestIdLease { + active_ids: Arc>>, + id: u64, +} + +#[cfg(feature = "crypto")] +impl Drop for GuestIdLease { + fn drop(&mut self) { + if let Ok(mut active_ids) = self.active_ids.lock() { + active_ids.remove(&self.id); + } + } } /// The result of a successful handshake, containing everything needed to @@ -49,6 +87,8 @@ pub struct HandshakeResult { pub client_id: u64, #[cfg(feature = "crypto")] pub client_public_key: Option, + #[cfg(feature = "crypto")] + pub guest_id_lease: Option, } /// Transport-independent handshake state machine. @@ -90,13 +130,56 @@ impl HandshakeEngine { ) -> Result { #[cfg(feature = "crypto")] { - let timeout = self.config.auth_timeout; - tokio::time::timeout(timeout, self.accept_inner(sender, receiver)) - .await - .unwrap_or(Err(AcceptError::AuthenticationTimedOut)) + self.accept_until( + sender, + receiver, + tokio::time::Instant::now() + self.config.auth_timeout, + ) + .await } #[cfg(not(feature = "crypto"))] - self.accept_inner(sender, receiver).await + { + let result = self.accept_inner(sender, receiver).await; + if result.is_err() { + sender.close(); + } + result + } + } + + /// Run the crypto handshake until an absolute deadline. + /// + /// WebTransport authentication may wait for a shared semaphore before it + /// reaches this engine. Passing the deadline through keeps that queueing + /// time from silently granting the handshake another full timeout. + #[cfg(feature = "crypto")] + pub async fn accept_until( + &self, + sender: &S, + receiver: &R, + deadline: tokio::time::Instant, + ) -> Result { + match tokio::time::timeout_at(deadline, self.accept_inner(sender, receiver)).await { + Ok(result) => { + if result.is_err() { + sender.close(); + } + result + } + Err(_) => { + let error = AcceptError::AuthenticationTimedOut; + send_rejection_generic( + sender, + RejectionReason::AuthenticationFailed { + detail: error.to_string(), + }, + None, + ) + .await; + sender.close(); + Err(error) + } + } } async fn accept_inner( @@ -104,16 +187,17 @@ impl HandshakeEngine { sender: &S, receiver: &R, ) -> Result { - let first_msg = receiver.receive().await.map_err(AcceptError::Receive)?; + let mut first_msg = receiver.receive().await.map_err(AcceptError::Receive)?; let version_str = match first_msg.get_data(DataType::Version) { - DataValue::Str(s) => s.clone(), + Some(DataValue::Str(s)) => s.clone(), _ => { send_rejection_generic( sender, RejectionReason::AuthenticationFailed { detail: "opening message omitted a valid protocol version".into(), }, + None, ) .await; sender.close(); @@ -128,6 +212,7 @@ impl HandshakeEngine { RejectionReason::AuthenticationFailed { detail: "opening message omitted a valid protocol version".into(), }, + None, ) .await; sender.close(); @@ -150,6 +235,7 @@ impl HandshakeEngine { .map(|v| v.to_string()) .collect(), }, + None, ) .await; sender.close(); @@ -160,8 +246,12 @@ impl HandshakeEngine { let codec = VersionedCodec::for_version(self.registry.clone(), negotiated.clone()) .ok_or_else(|| AcceptError::UnsupportedVersion(negotiated.clone()))?; + sender.set_type_map(codec.type_map()).await; + receiver.set_type_map(codec.type_map()).await; + first_msg.set_type_map(codec.type_map()); + let description = match first_msg.get_data(DataType::Description) { - DataValue::Str(s) => Some(s.clone()), + Some(DataValue::Str(s)) => Some(s.clone()), _ => None, }; @@ -209,9 +299,28 @@ impl HandshakeEngine { #[cfg(not(feature = "crypto"))] { - let _ = sender; + let authentication_requested = Some(first_msg.get_type()) + == CommunicationType::Register.try_to_id(codec.type_map()) + || first_msg.get_data(DataType::PublicKeys).is_some(); + if authentication_requested { + let error = AcceptError::AuthenticationFailed( + "authentication is unavailable on this non-crypto host".into(), + ); + send_rejection_generic( + sender, + RejectionReason::AuthenticationFailed { + detail: error.to_string(), + }, + Some(codec.type_map()), + ) + .await; + sender.close(); + return Err(error); + } let _ = receiver; - let _ = first_msg; + send_accepted_generic(sender, &negotiated, codec.type_map(), Some(0)) + .await + .map_err(AcceptError::Send)?; Ok(HandshakeResult { negotiated_version: negotiated, codec, @@ -229,15 +338,21 @@ impl HandshakeEngine { codec: VersionedCodec, description: Option, ) -> Result { - let tm = mtp_codec::TypeMap::latest(); + let tm = codec.type_map(); - // Reject Register frames on unauthenticated hosts - if Some(first_msg.get_type()) == CommunicationType::Register.try_to_id(&tm) { + // Reject explicit authentication attempts on unauthenticated hosts. + // Authenticated clients include PublicKeys in Identification as an + // intent marker; this avoids acknowledging the opening as a guest + // connection and leaving the client waiting for a Challenge. + if Some(first_msg.get_type()) == CommunicationType::Register.try_to_id(&tm) + || first_msg.get_data(DataType::PublicKeys).is_some() + { send_rejection_generic( sender, RejectionReason::AuthenticationFailed { detail: "authentication not allowed on this host".into(), }, + Some(tm), ) .await; sender.close(); @@ -246,8 +361,15 @@ impl HandshakeEngine { )); } - let guest_id = self.assign_guest_id().await?; - send_accepted_generic(sender, &negotiated, Some(guest_id)) + let guest_id_lease = match self.assign_guest_id().await { + Ok(lease) => lease, + Err(error) => { + reject_error_generic(sender, &error, tm).await; + return Err(error); + } + }; + let guest_id = guest_id_lease.id; + send_accepted_generic(sender, &negotiated, tm, Some(guest_id)) .await .map_err(AcceptError::Send)?; @@ -258,6 +380,7 @@ impl HandshakeEngine { auth_state: crate::error::AuthState::Unauthenticated, client_id: guest_id, client_public_key: None, + guest_id_lease: Some(guest_id_lease), }) } @@ -274,11 +397,17 @@ impl HandshakeEngine { version_str: &str, client_version: Version, ) -> Result { - let tm = mtp_codec::TypeMap::latest(); + let tm = codec.type_map(); // Register frames always go through full authentication if Some(first_msg.get_type()) == CommunicationType::Register.try_to_id(&tm) { - let bundle = extract_register_bundle(&first_msg)?; + let bundle = match extract_register_bundle(&first_msg) { + Ok(bundle) => bundle, + Err(error) => { + reject_error_generic(sender, &error, tm).await; + return Err(error); + } + }; let pk_bytes = bundle.as_bytes(); return self .complete_auth_handshake( @@ -298,7 +427,7 @@ impl HandshakeEngine { // Identification: try lookup, fall back to guest if Some(first_msg.get_type()) == CommunicationType::Identification.try_to_id(&tm) { let cid = match first_msg.get_data(DataType::Id) { - DataValue::UnsignedNumber(n) => *n as u64, + Some(DataValue::UnsignedNumber(n)) => u64::try_from(*n).unwrap_or(0), _ => 0, }; @@ -321,9 +450,26 @@ impl HandshakeEngine { .await; } + // Unknown or zero ID: an Identification carrying PublicKeys is an + // explicit authentication attempt, not a guest connection. + if first_msg.get_data(DataType::PublicKeys).is_some() { + let error = AcceptError::AuthenticationFailed( + "unknown authenticated client identity".into(), + ); + reject_error_generic(sender, &error, tm).await; + return Err(error); + } + // Unknown or zero ID: fall back to guest - let guest_id = self.assign_guest_id().await?; - send_accepted_generic(sender, &negotiated, Some(guest_id)) + let guest_id_lease = match self.assign_guest_id().await { + Ok(lease) => lease, + Err(error) => { + reject_error_generic(sender, &error, tm).await; + return Err(error); + } + }; + let guest_id = guest_id_lease.id; + send_accepted_generic(sender, &negotiated, tm, Some(guest_id)) .await .map_err(AcceptError::Send)?; return Ok(HandshakeResult { @@ -333,13 +479,13 @@ impl HandshakeEngine { auth_state: crate::error::AuthState::Unauthenticated, client_id: guest_id, client_public_key: None, + guest_id_lease: Some(guest_id_lease), }); } - sender.close(); - Err(AcceptError::AuthenticationFailed( - "unexpected message type".into(), - )) + let error = AcceptError::AuthenticationFailed("unexpected message type".into()); + reject_error_generic(sender, &error, tm).await; + Err(error) } #[cfg(feature = "crypto")] @@ -355,30 +501,39 @@ impl HandshakeEngine { version_str: &str, client_version: Version, ) -> Result { - let tm = mtp_codec::TypeMap::latest(); + let tm = codec.type_map(); let (flow, response_type) = if Some(first_msg.get_type()) == CommunicationType::Identification.try_to_id(&tm) { let cid = match first_msg.get_data(DataType::Id) { - DataValue::UnsignedNumber(n) => *n as u64, + Some(DataValue::UnsignedNumber(n)) => match u64::try_from(*n) { + Ok(id) => id, + Err(_) => { + let error = + AcceptError::AuthenticationFailed("client id is out of range".into()); + reject_error_generic(sender, &error, tm).await; + return Err(error); + } + }, _ => { - sender.close(); - return Err(AcceptError::AuthenticationFailed( - "missing client id".into(), - )); + let error = AcceptError::AuthenticationFailed("missing client id".into()); + reject_error_generic(sender, &error, tm).await; + return Err(error); } }; let bundle = match (self.config.get_existing_client)(cid, description.clone()).await { Some(b) => b, None => { - let rejection = - CommunicationValue::new(CommunicationType::IdentificationResponse) - .add_typed_default(DataType::Connected, DataValue::BoolFalse) - .add_typed_default( - DataType::ErrorMessage, - DataValue::Str("unknown client id".into()), - ); + let rejection = CommunicationValue::new_with_type_map( + CommunicationType::IdentificationResponse, + tm, + ) + .add_typed_default(DataType::Connected, DataValue::BoolFalse) + .add_typed_default( + DataType::ErrorMessage, + DataValue::Str("unknown client id".into()), + ); let _ = sender.send(&rejection).await; sender.close(); return Err(AcceptError::AuthenticationFailed( @@ -391,17 +546,23 @@ impl HandshakeEngine { CommunicationType::IdentificationResponse, ) } else if Some(first_msg.get_type()) == CommunicationType::Register.try_to_id(&tm) { - let bundle = extract_register_bundle(&first_msg)?; + let bundle = match extract_register_bundle(&first_msg) { + Ok(bundle) => bundle, + Err(error) => { + reject_error_generic(sender, &error, tm).await; + return Err(error); + } + }; let pk_bytes = bundle.as_bytes(); ( Flow::Register { bundle, pk_bytes }, CommunicationType::RegisterResponse, ) } else { - sender.close(); - return Err(AcceptError::AuthenticationFailed( - "unexpected authentication message".into(), - )); + let error = + AcceptError::AuthenticationFailed("unexpected authentication message".into()); + reject_error_generic(sender, &error, tm).await; + return Err(error); }; self.complete_auth_handshake( @@ -434,7 +595,7 @@ impl HandshakeEngine { ) -> Result { use mtp_crypto::{Ed25519Signer, MlDsaSigner, SignatureScheme, auth, verify_ed25519}; - let tm = mtp_codec::TypeMap::latest(); + let tm = codec.type_map(); // PQ preflight: host requiring PQ must have a PQ key let pq_enabled = !self @@ -457,6 +618,7 @@ impl HandshakeEngine { RejectionReason::AuthenticationFailed { detail: "host requires PQ authentication but has no PQ signing key".into(), }, + Some(tm), ) .await; sender.close(); @@ -468,11 +630,17 @@ impl HandshakeEngine { // Initialize host signers let host_pq_signer = if pq_enabled { Some(Arc::new( - MlDsaSigner::new( + match MlDsaSigner::new( &self.config.host_keyring.sig_pq_secret_key, &self.config.host_keyring.sig_pq_public_key, - ) - .map_err(|e| AcceptError::AuthenticationFailed(e.to_string()))?, + ) { + Ok(signer) => signer, + Err(error) => { + let error = AcceptError::AuthenticationFailed(error.to_string()); + reject_error_generic(sender, &error, tm).await; + return Err(error); + } + }, )) } else { None @@ -505,14 +673,21 @@ impl HandshakeEngine { let server_challenge: u128 = rand::random(); let (chal_sig, chal_pq_sig) = - host_sign(auth::challenge_payload(challenge_id, server_challenge)).await?; + match host_sign(auth::challenge_payload(challenge_id, server_challenge)).await { + Ok(signatures) => signatures, + Err(error) => { + reject_error_generic(sender, &error, tm).await; + return Err(error); + } + }; - let mut challenge_msg = CommunicationValue::new(CommunicationType::Challenge) - .add_typed_default( - DataType::ServerNonce, - DataValue::UnsignedNumber(server_challenge), - ) - .add_typed_default(DataType::Signature, DataValue::Bytes(chal_sig)); + let mut challenge_msg = + CommunicationValue::new_with_type_map(CommunicationType::Challenge, tm) + .add_typed_default( + DataType::ServerNonce, + DataValue::UnsignedNumber(server_challenge), + ) + .add_typed_default(DataType::Signature, DataValue::Bytes(chal_sig)); challenge_msg = challenge_msg.add_typed_default( DataType::RequirePq, if self.config.require_pq { @@ -536,31 +711,28 @@ impl HandshakeEngine { AcceptError::Receive(e) })?; if Some(proof.get_type()) != CommunicationType::ChallengeResponse.try_to_id(&tm) { - sender.close(); - return Err(AcceptError::AuthenticationFailed( - "missing challenge response".into(), - )); + let error = AcceptError::AuthenticationFailed("missing challenge response".into()); + reject_error_generic(sender, &error, tm).await; + return Err(error); } let client_nonce = match proof.get_data(DataType::ClientNonce) { - DataValue::UnsignedNumber(n) => *n, + Some(DataValue::UnsignedNumber(n)) => *n, _ => { - sender.close(); - return Err(AcceptError::AuthenticationFailed( - "missing client nonce".into(), - )); + let error = AcceptError::AuthenticationFailed("missing client nonce".into()); + reject_error_generic(sender, &error, tm).await; + return Err(error); } }; let sig_bytes = match proof.get_data(DataType::Signature) { - DataValue::Bytes(b) => b.clone(), + Some(DataValue::Bytes(b)) => b.clone(), _ => { - sender.close(); - return Err(AcceptError::AuthenticationFailed( - "missing challenge signature".into(), - )); + let error = AcceptError::AuthenticationFailed("missing challenge signature".into()); + reject_error_generic(sender, &error, tm).await; + return Err(error); } }; let pq_sig_bytes: Vec = match proof.get_data(DataType::PqSignature) { - DataValue::Bytes(b) => b.clone(), + Some(DataValue::Bytes(b)) => b.clone(), _ => vec![], }; @@ -601,6 +773,7 @@ impl HandshakeEngine { RejectionReason::AuthenticationFailed { detail: "client proof signature invalid".into(), }, + Some(tm), ) .await; sender.close(); @@ -613,21 +786,66 @@ impl HandshakeEngine { let (assigned_id, client_bundle) = match flow { Flow::Login { id, bundle } => (id, bundle), Flow::Register { bundle, .. } => { - let new_id = - (self.config.complete_register)(bundle.clone(), description.clone()).await; + let _registration_guard = self.config.registration_lock.lock().await; + let identity = bundle.as_bytes(); + let cached_id = self + .config + .registration_ids + .lock() + .ok() + .and_then(|registrations| registrations.get(&identity).copied()); + let new_id = if let Some(id) = cached_id { + id + } else if let Some(lookup) = &self.config.find_registered_client { + match lookup(bundle.clone(), description.clone()).await { + Some(id) => id, + None => { + (self.config.complete_register)(bundle.clone(), description.clone()) + .await + } + } + } else { + (self.config.complete_register)(bundle.clone(), description.clone()).await + }; + if new_id != 0 + && let Ok(mut registrations) = self.config.registration_ids.lock() + { + registrations.insert(identity, new_id); + } + if new_id == 0 { + let error = AcceptError::AuthenticationFailed( + "registration callback returned reserved client id 0".into(), + ); + reject_error_generic(sender, &error, tm).await; + return Err(error); + } (new_id, bundle) } }; + if assigned_id == 0 { + let error = AcceptError::AuthenticationFailed( + "client id 0 is reserved for no authenticated identity".into(), + ); + reject_error_generic(sender, &error, tm).await; + return Err(error); + } // Sign and send final response - let (host_sig, host_pq_sig) = host_sign(auth::host_final_payload( + let (host_sig, host_pq_sig) = match host_sign(auth::host_final_payload( assigned_id, client_nonce, server_challenge, )) - .await?; + .await + { + Ok(signatures) => signatures, + Err(error) => { + reject_error_generic(sender, &error, tm).await; + return Err(error); + } + }; - let mut response = CommunicationValue::new(response_type) + let mut response = CommunicationValue::new_with_type_map(response_type, tm) .add_typed_default(DataType::Connected, DataValue::BoolTrue) .add_typed_default(DataType::Id, DataValue::UnsignedNumber(assigned_id as u128)) .add_typed_default( @@ -658,6 +876,7 @@ impl HandshakeEngine { auth_state: crate::error::AuthState::Authenticated, client_id: assigned_id, client_public_key: Some(client_bundle), + guest_id_lease: None, }) } } @@ -682,36 +901,52 @@ enum Flow { impl HandshakeEngine { const GUEST_ID_MAX_RETRIES: u32 = 100; - async fn assign_guest_id(&self) -> Result { + async fn assign_guest_id(&self) -> Result { if let Some(ref generator) = self.config.guest_id_generator { let id = generator().await.ok_or_else(|| { AcceptError::AuthenticationFailed( "guest id generator rejected the connection".into(), ) })?; - if id > mtp_codec::MAX_WIRE_ID { + if id == 0 { return Err(AcceptError::AuthenticationFailed( - "guest id exceeds wire limit".into(), + "guest id 0 is reserved for no authenticated identity".into(), )); } - if (self.config.get_existing_client)(id, None).await.is_none() { - return Ok(id); + if let Some(lease) = self.try_reserve_guest_id(id).await? { + return Ok(lease); } } self.random_guest_id().await } - async fn random_guest_id(&self) -> Result { + async fn random_guest_id(&self) -> Result { for _ in 0..Self::GUEST_ID_MAX_RETRIES { - let id = rand::random::() & mtp_codec::MAX_WIRE_ID; - if (self.config.get_existing_client)(id, None).await.is_none() { - return Ok(id); + let id = rand::random::(); + if let Some(lease) = self.try_reserve_guest_id(id).await? { + return Ok(lease); } } Err(AcceptError::AuthenticationFailed( "failed to allocate a unique guest id after retries".into(), )) } + + async fn try_reserve_guest_id(&self, id: u64) -> Result, AcceptError> { + if id == 0 || (self.config.get_existing_client)(id, None).await.is_some() { + return Ok(None); + } + let mut active_ids = self.config.active_guest_ids.lock().map_err(|_| { + AcceptError::AuthenticationFailed("guest ID registry is poisoned".into()) + })?; + if !active_ids.insert(id) { + return Ok(None); + } + Ok(Some(GuestIdLease { + active_ids: Arc::clone(&self.config.active_guest_ids), + id, + })) + } } // --------------------------------------------------------------------------- @@ -723,7 +958,7 @@ fn extract_register_bundle( msg: &CommunicationValue, ) -> Result { match msg.get_data(DataType::PublicKeys) { - DataValue::Bytes(b) => mtp_crypto::PublicKeyBundle::from_bytes(b) + Some(DataValue::Bytes(b)) => mtp_crypto::PublicKeyBundle::from_bytes(b) .map_err(|_| AcceptError::AuthenticationFailed("invalid public key bundle".into())), _ => Err(AcceptError::AuthenticationFailed( "missing public keys".into(), @@ -731,32 +966,58 @@ fn extract_register_bundle( } } -async fn send_rejection_generic(sender: &S, reason: RejectionReason) { +async fn send_rejection_generic( + sender: &S, + reason: RejectionReason, + type_map: Option<&TypeMap>, +) { + let type_map = type_map.cloned().unwrap_or_else(TypeMap::latest); let response = match &reason { RejectionReason::BadVersion { supported_versions } => { - CommunicationValue::new(CommunicationType::ErrorBadVersion) + CommunicationValue::new_with_type_map(CommunicationType::ErrorBadVersion, &type_map) .add_typed_default( DataType::Version, DataValue::Str(supported_versions.join(",")), ) .add_typed_default(DataType::ErrorMessage, DataValue::Str(reason.to_string())) } - _ => CommunicationValue::new(CommunicationType::IdentificationResponse) - .add_typed_default(DataType::Connected, DataValue::BoolFalse) - .add_typed_default(DataType::ErrorMessage, DataValue::Str(reason.to_string())), + _ => CommunicationValue::new_with_type_map( + CommunicationType::IdentificationResponse, + &type_map, + ) + .add_typed_default(DataType::Connected, DataValue::BoolFalse) + .add_typed_default(DataType::ErrorMessage, DataValue::Str(reason.to_string())), }; let _ = sender.send(&response).await; } #[cfg(feature = "crypto")] +async fn reject_error_generic( + sender: &S, + error: &AcceptError, + type_map: &TypeMap, +) { + send_rejection_generic( + sender, + RejectionReason::AuthenticationFailed { + detail: error.to_string(), + }, + Some(type_map), + ) + .await; + sender.close(); +} + async fn send_accepted_generic( sender: &S, version: &Version, + type_map: &TypeMap, assigned_id: Option, ) -> Result<(), CommunicationError> { - let mut response = CommunicationValue::new(CommunicationType::IdentificationResponse) - .add_typed_default(DataType::Connected, DataValue::BoolTrue) - .add_typed_default(DataType::Version, DataValue::Str(version.to_string())); + let mut response = + CommunicationValue::new_with_type_map(CommunicationType::IdentificationResponse, type_map) + .add_typed_default(DataType::Connected, DataValue::BoolTrue) + .add_typed_default(DataType::Version, DataValue::Str(version.to_string())); if let Some(id) = assigned_id { response = response.add_typed_default(DataType::Id, DataValue::UnsignedNumber(id as u128)); } @@ -780,6 +1041,9 @@ impl HandshakeSender for mtp_transport::Sender { ) -> impl std::future::Future> + Send { mtp_transport::Sender::finish_stream(self) } + fn set_type_map(&self, type_map: &TypeMap) -> impl std::future::Future + Send { + async move { self.set_type_map(type_map).await } + } fn close(&self) { let sender = self.clone(); tokio::spawn(async move { sender.close().await }); @@ -793,6 +1057,10 @@ impl HandshakeReceiver for mtp_transport::Receiver { { mtp_transport::Receiver::receive(self) } + + fn set_type_map(&self, type_map: &TypeMap) -> impl std::future::Future + Send { + async move { self.set_type_map(type_map).await } + } } impl HandshakeSender for mtp_transport::GenericSender { @@ -807,6 +1075,9 @@ impl HandshakeSender for mtp_transport::G ) -> impl std::future::Future> + Send { mtp_transport::GenericSender::finish_stream(self) } + fn set_type_map(&self, type_map: &TypeMap) -> impl std::future::Future + Send { + async move { self.set_type_map(type_map).await } + } fn close(&self) { mtp_transport::GenericSender::close(self); } @@ -821,4 +1092,68 @@ impl HandshakeReceiver { mtp_transport::GenericReceiver::receive(self) } + + fn set_type_map(&self, type_map: &TypeMap) -> impl std::future::Future + Send { + async move { self.set_type_map(type_map).await } + } +} + +#[cfg(all(test, feature = "crypto"))] +mod tests { + use super::*; + use std::sync::Mutex; + + #[tokio::test] + async fn guest_generator_accepts_full_width_id_after_collision_check() + -> Result<(), Box> { + let lookups = Arc::new(Mutex::new(Vec::new())); + let recorded_lookups = Arc::clone(&lookups); + + let mut config = HostConfig::new("127.0.0.1".parse()?, 4433, Vec::new(), Vec::new()) + .with_guest_id_generator(Box::new(|| Box::pin(async { Some(u64::MAX) }))); + config.get_existing_client = Box::new(move |id, description| { + let recorded_lookups = Arc::clone(&recorded_lookups); + Box::pin(async move { + recorded_lookups + .lock() + .expect("guest ID lookup mutex should not be poisoned") + .push((id, description)); + None + }) + }); + + let engine = HandshakeEngine::new(Registry::builtin(), Arc::new(config)); + + assert_eq!(engine.assign_guest_id().await?.id, u64::MAX); + assert_eq!( + *lookups + .lock() + .expect("guest ID lookup mutex should not be poisoned"), + vec![(u64::MAX, None)] + ); + Ok(()) + } + + #[tokio::test] + async fn active_guest_ids_are_unique_and_released() -> Result<(), Box> { + let config = HostConfig::new("127.0.0.1".parse()?, 4433, Vec::new(), Vec::new()); + let engine = HandshakeEngine::new(Registry::builtin(), Arc::new(config)); + + let first = engine.try_reserve_guest_id(1).await?.unwrap(); + assert!(engine.try_reserve_guest_id(1).await?.is_none()); + + drop(first); + assert!(engine.try_reserve_guest_id(1).await?.is_some()); + Ok(()) + } + + #[tokio::test] + async fn zero_is_rejected_as_a_guest_id() -> Result<(), Box> { + let config = HostConfig::new("127.0.0.1".parse()?, 4433, Vec::new(), Vec::new()) + .with_guest_id_generator(Box::new(|| Box::pin(async { Some(0) }))); + let engine = HandshakeEngine::new(Registry::builtin(), Arc::new(config)); + + assert!(engine.assign_guest_id().await.is_err()); + Ok(()) + } } diff --git a/host/src/error.rs b/host/src/error.rs index 7a7873e..b7a8c4f 100644 --- a/host/src/error.rs +++ b/host/src/error.rs @@ -7,14 +7,13 @@ use mtp_codec::{CommunicationValue, DataType, DataValue}; #[cfg(feature = "crypto")] pub(crate) fn random_client_id() -> u64 { - rand::random::() & mtp_codec::MAX_WIRE_ID + rand::random::() } #[cfg(test)] pub(crate) fn extract_version(msg: &CommunicationValue) -> Option { - let value = msg.get_data(DataType::Version); - match value { - DataValue::Str(s) => Version::parse(s.as_str()), + match msg.get_data(DataType::Version) { + Some(DataValue::Str(s)) => Version::parse(s.as_str()), _ => None, } } diff --git a/host/src/handshake.rs b/host/src/handshake.rs index 32a23fb..ae4c211 100644 --- a/host/src/handshake.rs +++ b/host/src/handshake.rs @@ -164,6 +164,8 @@ impl HandshakeContext { let remote_addr = sender.handle().remote_addr(); receiver.set_max_message_size(self.config.policy.max_message_size); #[cfg(feature = "pipes")] + let type_map = result.codec.type_map().clone(); + #[cfg(feature = "pipes")] { if self.config.send_pongs { receiver.respond_to_pings(sender.clone()); @@ -177,6 +179,7 @@ impl HandshakeContext { pending_creations: tokio::sync::Mutex::new(std::collections::HashMap::new()), pending_pipes: tokio::sync::Mutex::new(std::collections::HashMap::new()), policy: Arc::new(self.config.policy), + type_map: type_map.clone(), }); let dispatcher_clone = dispatcher.clone(); @@ -202,9 +205,11 @@ impl HandshakeContext { pipe_dispatcher: dispatcher, description: result.description, _dispatcher_task: task, + _connection_guard: None, auth_state: result.auth_state, client_id: result.client_id, client_public_key: result.client_public_key, + guest_id_lease: result.guest_id_lease, } } @@ -226,9 +231,11 @@ impl HandshakeContext { _pipe_stream: std::marker::PhantomData, description: result.description, _dispatcher_task: task, + _connection_guard: None, auth_state: result.auth_state, client_id: result.client_id, client_public_key: result.client_public_key, + guest_id_lease: result.guest_id_lease, } } } @@ -246,6 +253,8 @@ impl HandshakeContext { let remote_addr = sender.handle().remote_addr(); receiver.set_max_message_size(self.config.policy.max_message_size); #[cfg(feature = "pipes")] + let type_map = codec.type_map().clone(); + #[cfg(feature = "pipes")] { if self.config.send_pongs { receiver.respond_to_pings(sender.clone()); @@ -259,6 +268,7 @@ impl HandshakeContext { pending_creations: tokio::sync::Mutex::new(std::collections::HashMap::new()), pending_pipes: tokio::sync::Mutex::new(std::collections::HashMap::new()), policy: Arc::new(self.config.policy), + type_map, }); let dispatcher_clone = dispatcher.clone(); @@ -284,6 +294,7 @@ impl HandshakeContext { pipe_dispatcher: dispatcher, description, _dispatcher_task: task, + _connection_guard: None, } } @@ -305,6 +316,7 @@ impl HandshakeContext { _pipe_stream: std::marker::PhantomData, description, _dispatcher_task: task, + _connection_guard: None, } } } diff --git a/host/src/lib.rs b/host/src/lib.rs index c071db2..fe5e7bf 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -28,7 +28,10 @@ pub use pipe::PipeRequest; pub use mtp_codec::registry::Registry; #[cfg(feature = "crypto")] -pub use config::{AuthenticationPolicy, CompleteRegister, GetExistingClient, GuestIdGenerator}; +pub use config::{ + AuthenticationPolicy, CompleteRegister, FindRegisteredClient, GetExistingClient, + GuestIdGenerator, +}; #[cfg(feature = "crypto")] pub use error::AuthState; @@ -51,9 +54,9 @@ mod tests { fn version_extraction() { let tm = mtp_codec::TypeMap::latest(); let msg = mtp_codec::CommunicationValue::from_comm(CommunicationType::Identification, &tm) - .add_typed(DataType::Version, &tm, DataValue::Str("2.0".to_string())); + .add_typed(DataType::Version, &tm, DataValue::Str("3.0".to_string())); let version = error::extract_version(&msg); - assert_eq!(version, Some(mtp_codec::Version(2, 0))); + assert_eq!(version, Some(mtp_codec::Version(3, 0))); } #[test] @@ -92,7 +95,7 @@ mod tests { #[tokio::test] async fn alternative_transports_use_the_shared_connection_type() { let registry = Registry::builtin(); - let version = mtp_codec::Version(1, 0); + let version = mtp_codec::Version(3, 0); let codec = VersionedCodec::for_version(registry, version.clone()).unwrap(); let connection: MTPConnection = MTPConnection::from_transport_parts( diff --git a/host/src/pipe.rs b/host/src/pipe.rs index ed3c623..6985d97 100644 --- a/host/src/pipe.rs +++ b/host/src/pipe.rs @@ -1,4 +1,4 @@ -use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue}; +use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue, TypeMap}; use mtp_common::{CommunicationError, PipeError}; use mtp_transport::{PipeReader, PipeWriter, Policy, TransportEvent}; use std::collections::HashMap; @@ -152,24 +152,49 @@ where .await .insert(self.pipe_id, pipe_tx); - let response = CommunicationValue::new(CommunicationType::PipeResponse) - .with_id(self.pipe_id) - .add_typed_default(DataType::Accepted, DataValue::BoolTrue); - self.sender - .send_pipe_message(&response) - .await - .map_err(PipeError::from)?; + let response = CommunicationValue::new_with_type_map( + CommunicationType::PipeResponse, + &self.dispatcher.type_map, + ) + .with_id(self.pipe_id) + .add_typed_default(DataType::Accepted, DataValue::BoolTrue); + if let Err(error) = self.sender.send_pipe_message(&response).await { + self.dispatcher + .pending_pipes + .lock() + .await + .remove(&self.pipe_id); + return Err(PipeError::from(error)); + } - tokio::time::timeout(self.dispatcher.policy.read_timeout, pipe_rx) - .await - .map_err(|_| PipeError::HandshakeTimeout)? - .map_err(|_| PipeError::StreamClosed) + match tokio::time::timeout(self.dispatcher.policy.read_timeout, pipe_rx).await { + Ok(Ok(reader)) => Ok(reader), + Ok(Err(_)) => { + self.dispatcher + .pending_pipes + .lock() + .await + .remove(&self.pipe_id); + Err(PipeError::StreamClosed) + } + Err(_) => { + self.dispatcher + .pending_pipes + .lock() + .await + .remove(&self.pipe_id); + Err(PipeError::HandshakeTimeout) + } + } } pub async fn deny(self) -> Result<(), PipeError> { - let response = CommunicationValue::new(CommunicationType::PipeResponse) - .with_id(self.pipe_id) - .add_typed_default(DataType::Accepted, DataValue::BoolFalse); + let response = CommunicationValue::new_with_type_map( + CommunicationType::PipeResponse, + &self.dispatcher.type_map, + ) + .with_id(self.pipe_id) + .add_typed_default(DataType::Accepted, DataValue::BoolFalse); self.sender .send_pipe_message(&response) .await @@ -182,6 +207,7 @@ pub(crate) struct PipeDispatcher

{ Mutex>>>, pub(crate) pending_pipes: Mutex>>>, pub(crate) policy: Arc, + pub(crate) type_map: TypeMap, } pub(crate) async fn run_dispatcher( @@ -195,15 +221,21 @@ pub(crate) async fn run_dispatcher( R: PipeReceiver

, P: tokio::io::AsyncRead + Send + Unpin + 'static, { - let pipe_req_type = CommunicationType::PipeRequest.try_to_id(&mtp_codec::TypeMap::latest()); - let pipe_resp_type = CommunicationType::PipeResponse.try_to_id(&mtp_codec::TypeMap::latest()); - loop { match receiver.receive_pipe_event().await { Ok(TransportEvent::Message(message)) => { - if Some(message.get_type()) == pipe_req_type { + if message.is_type(CommunicationType::PipeRequest) { + let Some(pipe_id) = message.id().filter(|id| *id != 0) else { + let error = CommunicationError::Other( + "PipeRequest frame must contain a non-zero id".into(), + ); + if app_tx.send(Err(error)).await.is_err() { + break; + } + continue; + }; let request = PipeRequest { - pipe_id: message.get_id(), + pipe_id, description: message .get_str(DataType::Description) .unwrap_or("") @@ -214,14 +246,36 @@ pub(crate) async fn run_dispatcher( let _ = pipe_req_tx.send(request).await; continue; } - if Some(message.get_type()) == pipe_resp_type { + if message.is_type(CommunicationType::PipeResponse) { + let Some(pipe_id) = message.id().filter(|id| *id != 0) else { + let error = CommunicationError::Other( + "PipeResponse frame must contain a non-zero id".into(), + ); + if app_tx.send(Err(error)).await.is_err() { + break; + } + continue; + }; let mut pending = dispatcher.pending_creations.lock().await; - if let Some(reply) = pending.remove(&message.get_id()) { + if let Some(reply) = pending.remove(&pipe_id) { let _ = reply.send(Ok(message.get_bool(DataType::Accepted).unwrap_or(false))); } continue; } + if !matches!(message.id(), Some(id) if id != 0) + && message + .get_type_name() + .is_some_and(|name| name.ends_with("Response")) + { + let error = CommunicationError::Other( + "response frame must contain a non-zero id".into(), + ); + if app_tx.send(Err(error)).await.is_err() { + break; + } + continue; + } if app_tx.send(Ok(message)).await.is_err() { break; } diff --git a/mtp-webserver/src/h3.rs b/mtp-webserver/src/h3.rs index eb3539c..c09379c 100644 --- a/mtp-webserver/src/h3.rs +++ b/mtp-webserver/src/h3.rs @@ -67,7 +67,10 @@ pub(crate) async fn run_driver( let host_config = host_config.clone(); let auth_semaphore = auth_semaphore.clone(); connection_tasks.spawn(async move { - let _permit = permit; + // The permit normally lives for this HTTP/3 connection. + // For an MTP session it is moved into the resulting + // connection so the limit covers the session lifetime. + let mut connection_permit = Some(permit); let connect_start = std::time::Instant::now(); let connection = match incoming.await { Ok(connection) => connection, @@ -149,27 +152,41 @@ pub(crate) async fn run_driver( remote_addr, )); let mtp_tx = mtp_tx.clone(); + let mtp_queue_permit = match mtp_tx.clone().try_reserve_owned() { + Ok(permit) => permit, + Err(_) => { + tracing::debug!( + "rejecting MTP session because the application queue is full" + ); + connection.close( + quinn::VarInt::from_u32(0), + b"mtp application queue is full", + ); + return; + } + }; let auth_semaphore = auth_semaphore.clone(); let host_config = host_config.clone(); + let connection_guard = connection_permit.take(); let connection = connection.clone(); + let close_connection = connection.clone(); tokio::spawn(async move { - let result = - accept_web_connection(session, mtp_path, connection, send_pongs, policy, host_config, auth_semaphore) - .await; - match mtp_tx.try_send(result) { - Ok(()) => {} - Err(tokio::sync::mpsc::error::TrySendError::Full(result)) => { - tracing::warn!("MTP connection backlog is full; dropping connection"); - if let Ok(connection) = result { - connection.sender.close(); - } - } - Err(tokio::sync::mpsc::error::TrySendError::Closed(result)) => { - if let Ok(connection) = result { - connection.sender.close(); - } - } + let result = accept_web_connection( + session, + mtp_path, + connection, + send_pongs, + policy, + host_config, + auth_semaphore, + connection_guard, + ) + .await; + if result.is_err() { + close_connection + .close(quinn::VarInt::from_u32(0), b"mtp handshake failed"); } + mtp_queue_permit.send(result); }); return; } diff --git a/mtp-webserver/src/transport.rs b/mtp-webserver/src/transport.rs index f96aa88..3834f64 100644 --- a/mtp-webserver/src/transport.rs +++ b/mtp-webserver/src/transport.rs @@ -1,8 +1,5 @@ use bytes::Bytes; -use mtp_codec::{ - DataType, DataValue, Version, - registry::{Registry, VersionedCodec}, -}; +use mtp_codec::registry::Registry; use mtp_common::CommunicationError; use mtp_host::AcceptError; use mtp_host::HostConfig; @@ -11,14 +8,9 @@ use mtp_transport::{ TransportSendStream, }; use std::sync::Arc; -#[cfg(feature = "crypto")] -use std::time::Instant; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tracing::error; -#[cfg(feature = "crypto")] -const GUEST_ID_MAX_RETRIES: u32 = 100; - type Session = h3_webtransport::server::WebTransportSession; type H3SendStream = h3_webtransport::stream::SendStream, Bytes>; type H3RecvStream = h3_webtransport::stream::RecvStream; @@ -228,38 +220,6 @@ pub type WebMtpReceiver = GenericReceiver; pub type WebMTPConnection = mtp_host::MTPConnection; -#[cfg(feature = "crypto")] -impl H3TransportConnection { - /// Assign a unique guest ID, using the configured generator if present. - async fn assign_guest_id(host_config: &HostConfig) -> Result { - if let Some(ref generator) = host_config.guest_id_generator { - let id = generator().await.ok_or_else(|| { - AcceptError::AuthenticationFailed( - "guest id generator rejected the connection".into(), - ) - })?; - if id > mtp_codec::MAX_WIRE_ID { - return Err(AcceptError::AuthenticationFailed( - "guest id exceeds wire limit".into(), - )); - } - if (host_config.get_existing_client)(id, None).await.is_none() { - return Ok(id); - } - } - // Fall back to random ID with collision check - for _ in 0..GUEST_ID_MAX_RETRIES { - let id = rand::random::() & mtp_codec::MAX_WIRE_ID; - if (host_config.get_existing_client)(id, None).await.is_none() { - return Ok(id); - } - } - Err(AcceptError::AuthenticationFailed( - "failed to allocate a unique guest id after retries".into(), - )) - } -} - pub(crate) async fn accept_web_connection( session: Arc, path: String, @@ -267,25 +227,45 @@ pub(crate) async fn accept_web_connection( send_pongs: bool, policy: Policy, host_config: Arc, - _auth_semaphore: Arc, + #[allow(unused_variables)] auth_semaphore: Arc, + connection_guard: Option, ) -> Result { #[cfg(feature = "crypto")] { - let permit = _auth_semaphore.clone().acquire_owned().await.map_err(|_| { - AcceptError::AuthenticationFailed("authentication service stopped".into()) - })?; - let result = tokio::time::timeout( - host_config.auth_timeout, - accept_web_connection_inner(session, path, quinn, send_pongs, policy, host_config), + let deadline = tokio::time::Instant::now() + host_config.auth_timeout; + let permit = tokio::time::timeout_at(deadline, auth_semaphore.clone().acquire_owned()) + .await + .map_err(|_| AcceptError::AuthenticationTimedOut)? + .map_err(|_| { + AcceptError::AuthenticationFailed("authentication service stopped".into()) + })?; + let result = accept_web_connection_inner( + session, + path, + quinn, + send_pongs, + policy, + host_config, + Some(deadline), + connection_guard, ) - .await - .unwrap_or(Err(AcceptError::AuthenticationTimedOut)); + .await; drop(permit); result } #[cfg(not(feature = "crypto"))] - accept_web_connection_inner(session, path, quinn, send_pongs, policy, host_config).await + accept_web_connection_inner( + session, + path, + quinn, + send_pongs, + policy, + host_config, + None, + connection_guard, + ) + .await } async fn accept_web_connection_inner( @@ -294,434 +274,74 @@ async fn accept_web_connection_inner( quinn: quinn::Connection, send_pongs: bool, policy: Policy, - _host_config: Arc, + host_config: Arc, + #[allow(unused_variables)] deadline: Option, + connection_guard: Option, ) -> Result { - #[cfg(feature = "crypto")] - let auth_handshake_started = Instant::now(); let max_message_size = policy.max_message_size; let transport = H3TransportConnection::new(session, quinn); let remote_addr = transport.remote_addr(); let policy = Arc::new(policy); - let receiver = WebMtpReceiver::new(transport.clone(), policy.clone()); + let sender = WebMtpSender::new(transport.clone(), policy.clone()); + let receiver = WebMtpReceiver::new(transport, policy.clone()); - let first = receiver.receive().await.map_err(AcceptError::Receive)?; - let version = match first.get_data(DataType::Version) { - DataValue::Str(value) => Version::parse(value).ok_or(AcceptError::MissingVersion)?, - _ => return Err(AcceptError::MissingVersion), - }; - let registry = Registry::builtin(); - let negotiated = registry - .negotiate(std::slice::from_ref(&version)) - .ok_or_else(|| AcceptError::UnsupportedVersion(version.clone()))?; - let codec = VersionedCodec::for_version(registry, negotiated.clone()) - .ok_or_else(|| AcceptError::UnsupportedVersion(negotiated.clone()))?; - let description = match first.get_data(DataType::Description) { - DataValue::Str(value) => Some(value.clone()), - _ => None, - }; - let sender = WebMtpSender::new(transport, policy.clone()); - if send_pongs { - receiver.respond_to_pings(sender.clone()).await; - } + let engine = mtp_host::HandshakeEngine::new(Registry::builtin(), host_config); + #[cfg(feature = "crypto")] + let result = engine + .accept_until( + &sender, + &receiver, + deadline.expect("crypto WebTransport handshakes have a deadline"), + ) + .await?; + #[cfg(not(feature = "crypto"))] + let result = engine.accept(&sender, &receiver).await?; + + let version = result.negotiated_version.clone(); + let codec = result.codec.clone(); + let description = result.description.clone(); #[cfg(feature = "pipes")] let connection: WebMTPConnection = mtp_host::MTPConnection::from_transport_parts_with_policy( - negotiated, + version, codec, sender, receiver, path, - description.clone(), + description, Some(remote_addr), policy, ); #[cfg(not(feature = "pipes"))] let connection: WebMTPConnection = mtp_host::MTPConnection::from_transport_parts_with_remote_addr( - negotiated, + version, codec, sender, receiver, path, - description.clone(), + description, Some(remote_addr), ); - #[cfg(not(feature = "crypto"))] - { - connection.receiver.set_max_message_size(max_message_size); - Ok(connection) - } - #[cfg(feature = "crypto")] + let mut connection = connection; + #[cfg(feature = "crypto")] { - use mtp_codec::{CommunicationType, DataType, DataValue}; - use mtp_crypto::{ - Ed25519Signer, MlDsaSigner, PublicKeyBundle, SignatureScheme, auth, verify_ed25519, - }; - - let tm = mtp_codec::TypeMap::latest(); - let is_allow_auth = matches!( - _host_config.authentication_policy, - mtp_host::AuthenticationPolicy::AllowAuthentication - ); - let is_force_auth = matches!( - _host_config.authentication_policy, - mtp_host::AuthenticationPolicy::ForceAuthentication - ); - - // Unauthenticated: send accepted response with guest ID (or ID 0) - if !is_allow_auth && !is_force_auth { - let response = - mtp_codec::CommunicationValue::new(CommunicationType::IdentificationResponse) - .add_typed_default(DataType::Connected, DataValue::BoolTrue) - .add_typed_default( - DataType::Version, - DataValue::Str(connection.version.to_string()), - ) - .add_typed_default(DataType::Id, DataValue::UnsignedNumber(0)); - connection - .sender - .send(&response) - .await - .map_err(AcceptError::Send)?; - connection - .sender - .finish_stream() - .await - .map_err(AcceptError::Send)?; - connection.receiver.set_max_message_size(max_message_size); - return Ok(connection); - } - - // AllowAuthentication / ForceAuthentication: perform authentication - let client_lookup_started = Instant::now(); - let first_type = first.get_type(); - - let id_type = CommunicationType::Identification.try_to_id(&tm); - let reg_type = CommunicationType::Register.try_to_id(&tm); - let first_type_opt = Some(first_type); - - let (client_id, client_bundle, response_type, is_guest) = if is_allow_auth - && first_type_opt == id_type - { - // AllowAuthentication Identification: try lookup, fall back to guest - let id = match first.get_data(DataType::Id) { - DataValue::UnsignedNumber(value) => *value as u64, - _ => 0, - }; - if id > 0 { - if let Some(bundle) = - (_host_config.get_existing_client)(id, description.clone()).await - { - ( - id, - Some(bundle), - CommunicationType::IdentificationResponse, - false, - ) - } else { - // Unknown client: fall back to guest - let guest_id = H3TransportConnection::assign_guest_id(&_host_config).await?; - ( - guest_id, - None, - CommunicationType::IdentificationResponse, - true, - ) - } - } else { - // ID zero or missing: fall back to guest - let guest_id = H3TransportConnection::assign_guest_id(&_host_config).await?; - ( - guest_id, - None, - CommunicationType::IdentificationResponse, - true, - ) - } - } else if first_type_opt == reg_type { - // Registration: always authenticate (both AllowAuth and ForceAuth) - let bundle = match first.get_data(DataType::PublicKeys) { - DataValue::Bytes(bytes) => PublicKeyBundle::from_bytes(bytes).map_err(|_| { - AcceptError::AuthenticationFailed("invalid public key bundle".into()) - })?, - _ => { - return Err(AcceptError::AuthenticationFailed( - "missing public keys".into(), - )); - } - }; - (0, Some(bundle), CommunicationType::RegisterResponse, false) - } else if first_type_opt == id_type { - // ForceAuthentication Identification: require lookup - let id = match first.get_data(DataType::Id) { - DataValue::UnsignedNumber(value) => *value as u64, - _ => { - return Err(AcceptError::AuthenticationFailed( - "missing client id".into(), - )); - } - }; - let bundle = (_host_config.get_existing_client)(id, description.clone()) - .await - .ok_or_else(|| AcceptError::AuthenticationFailed("unknown client id".into()))?; - ( - id, - Some(bundle), - CommunicationType::IdentificationResponse, - false, - ) - } else { - return Err(AcceptError::AuthenticationFailed( - "unexpected authentication message".into(), - )); - }; - tracing::debug!(elapsed = ?client_lookup_started.elapsed(), is_guest, "web authentication handshake: identify client"); - - // Guest path: skip challenge/response, send accepted with guest ID - if is_guest { - let guest_id = client_id; - let response = - mtp_codec::CommunicationValue::new(CommunicationType::IdentificationResponse) - .add_typed_default(DataType::Connected, DataValue::BoolTrue) - .add_typed_default( - DataType::Version, - DataValue::Str(connection.version.to_string()), - ) - .add_typed_default(DataType::Id, DataValue::UnsignedNumber(guest_id as u128)); - connection - .sender - .send(&response) - .await - .map_err(AcceptError::Send)?; - connection - .sender - .finish_stream() - .await - .map_err(AcceptError::Send)?; - connection.receiver.set_max_message_size(max_message_size); - connection.auth_state = mtp_host::AuthState::Unauthenticated; - connection.client_id = guest_id; - return Ok(connection); - } - - let client_bundle = client_bundle.unwrap(); - - // PQ preflight: if host requires PQ, it must have a PQ key - let pq_enabled = !_host_config - .host_keyring - .sig_pq_secret_key - .as_bytes() - .is_empty(); - if _host_config.require_pq - && (!pq_enabled - || _host_config - .host_keyring - .sig_pq_public_key - .as_bytes() - .is_empty()) - { - return Err(AcceptError::AuthenticationFailed( - "host requires PQ authentication but has no PQ signing key".into(), - )); - } - - let signer_init_started = Instant::now(); - let host_pq_signer = if pq_enabled { - Some(Arc::new( - MlDsaSigner::new( - &_host_config.host_keyring.sig_pq_secret_key, - &_host_config.host_keyring.sig_pq_public_key, - ) - .map_err(|e| AcceptError::AuthenticationFailed(e.to_string()))?, - )) - } else { - None - }; - tracing::debug!(elapsed = ?signer_init_started.elapsed(), "web authentication handshake: signer initialization"); - - let server_challenge: u128 = rand::random(); - let host_sign = |payload: Vec| { - let host_config = _host_config.clone(); - let pq_signer = host_pq_signer.clone(); - async move { - let signer = Ed25519Signer::new(&host_config.host_keyring.sig_cl_secret_key) - .map_err(|e| AcceptError::AuthenticationFailed(e.to_string()))?; - if let Some(pq_signer) = pq_signer { - mtp_crypto::sign_parallel::sign_dual_parallel_shared_pq( - signer, pq_signer, payload, - ) - .await - .map_err(|e| AcceptError::AuthenticationFailed(e.to_string())) - } else { - let sig = signer - .sign(&payload) - .map_err(|e| AcceptError::AuthenticationFailed(e.to_string()))?; - Ok((sig, Vec::new())) - } - } - }; - let sign_challenge_started = Instant::now(); - let (sig, pq_sig) = host_sign(auth::challenge_payload(client_id, server_challenge)).await?; - tracing::debug!(elapsed = ?sign_challenge_started.elapsed(), "web authentication handshake: sign challenge"); - let mut challenge = mtp_codec::CommunicationValue::new(CommunicationType::Challenge) - .add_typed_default( - DataType::ServerNonce, - DataValue::UnsignedNumber(server_challenge), - ) - .add_typed_default(DataType::Signature, DataValue::Bytes(sig)) - .add_typed_default( - DataType::RequirePq, - if _host_config.require_pq { - DataValue::BoolTrue - } else { - DataValue::BoolFalse - }, - ); - if pq_enabled { - challenge = - challenge.add_typed_default(DataType::PqSignature, DataValue::Bytes(pq_sig)); - } - let send_challenge_started = Instant::now(); - connection - .sender - .send(&challenge) - .await - .map_err(AcceptError::Send)?; - tracing::debug!(elapsed = ?send_challenge_started.elapsed(), "web authentication handshake: send challenge"); - let receive_proof_started = Instant::now(); - let proof = { - #[cfg(feature = "pipes")] - { - connection.receive().await.map_err(AcceptError::Receive)? - } - #[cfg(not(feature = "pipes"))] - { - let mut proof = connection - .receiver - .receive() - .await - .map_err(AcceptError::Receive)?; - proof.set_type_map(connection.codec.type_map()); - proof - } - }; - tracing::debug!(elapsed = ?receive_proof_started.elapsed(), "web authentication handshake: receive client proof"); - if Some(proof.get_type()) != CommunicationType::ChallengeResponse.try_to_id(&tm) { - return Err(AcceptError::AuthenticationFailed( - "missing challenge response".into(), - )); - } - let nonce = match proof.get_data(DataType::ClientNonce) { - DataValue::UnsignedNumber(n) => n.to_owned(), - _ => { - return Err(AcceptError::AuthenticationFailed( - "missing client nonce".into(), - )); - } - }; - let signature = match proof.get_data(DataType::Signature) { - DataValue::Bytes(bytes) => bytes, - _ => { - return Err(AcceptError::AuthenticationFailed( - "missing challenge signature".into(), - )); - } - }; - let pq_signature = match proof.get_data(DataType::PqSignature) { - DataValue::Bytes(bytes) => bytes.as_slice(), - _ => &[], - }; - let payload = if first.get_type() == CommunicationType::Register.try_to_id(&tm).unwrap() { - auth::register_proof_payload( - &version.to_string(), - &client_bundle.as_bytes(), - server_challenge, - nonce, - ) - } else { - auth::login_proof_payload(&version.to_string(), client_id, server_challenge, nonce) - }; - - // Verify client proof: classical is always required; PQ is verified - // when supplied (even if not required), matching native behavior. - let has_client_pq_key = !client_bundle.sig_pq_public_key.as_bytes().is_empty(); - let verify_proof_started = Instant::now(); - let proof_ok = if pq_signature.is_empty() { - !_host_config.require_pq - && verify_ed25519(&client_bundle.sig_cl_public_key, &payload, signature).is_ok() - } else if has_client_pq_key { - mtp_crypto::sign_parallel::verify_dual_parallel( - client_bundle.sig_cl_public_key.clone(), - client_bundle.sig_pq_public_key.clone(), - payload, - signature.to_vec(), - pq_signature.to_vec(), - ) - .await - .is_ok() - } else { - false - }; - tracing::debug!(elapsed = ?verify_proof_started.elapsed(), "web authentication handshake: verify client proof"); - - if !proof_ok { - let rejection = - mtp_codec::CommunicationValue::new(CommunicationType::IdentificationResponse) - .add_typed_default(DataType::Connected, DataValue::BoolFalse) - .add_typed_default( - DataType::ErrorMessage, - DataValue::Str("client proof signature invalid".into()), - ); - let _ = connection.sender.send(&rejection).await; - connection.sender.close(); - return Err(AcceptError::AuthenticationFailed( - "client proof signature invalid".into(), - )); - } - - let register_started = Instant::now(); - let assigned_id = if response_type == CommunicationType::RegisterResponse { - (_host_config.complete_register)(client_bundle.clone(), description.clone()).await - } else { - client_id - }; - tracing::debug!(elapsed = ?register_started.elapsed(), "web authentication handshake: registration callback"); - let sign_final_started = Instant::now(); - let (final_sig, final_pq) = host_sign(auth::host_final_payload( - assigned_id, - nonce, - server_challenge, - )) - .await?; - tracing::debug!(elapsed = ?sign_final_started.elapsed(), "web authentication handshake: sign final response"); - let mut response = mtp_codec::CommunicationValue::new(response_type) - .add_typed_default(DataType::Connected, DataValue::BoolTrue) - .add_typed_default(DataType::Id, DataValue::UnsignedNumber(assigned_id as u128)) - .add_typed_default(DataType::ClientNonce, DataValue::UnsignedNumber(nonce)) - .add_typed_default(DataType::Signature, DataValue::Bytes(final_sig)) - .add_typed_default(DataType::Version, DataValue::Str(version.to_string())); - if pq_enabled { - response = - response.add_typed_default(DataType::PqSignature, DataValue::Bytes(final_pq)); - } - let send_final_started = Instant::now(); - connection - .sender - .send(&response) - .await - .map_err(AcceptError::Send)?; - connection - .sender - .finish_stream() - .await - .map_err(AcceptError::Send)?; - tracing::debug!(elapsed = ?send_final_started.elapsed(), "web authentication handshake: send final response"); - tracing::debug!(elapsed = ?auth_handshake_started.elapsed(), "web authentication handshake: complete"); - connection.receiver.set_max_message_size(max_message_size); - connection.auth_state = mtp_host::AuthState::Authenticated; - connection.client_id = assigned_id; - connection.client_public_key = Some(client_bundle); - Ok(connection) + connection.auth_state = result.auth_state; + connection.client_id = result.client_id; + connection.client_public_key = result.client_public_key; + connection.set_guest_id_lease(result.guest_id_lease); } + if let Some(connection_guard) = connection_guard { + connection.set_connection_guard(connection_guard); + } + + if send_pongs { + connection + .receiver + .respond_to_pings(connection.sender.clone()) + .await; + } + connection.receiver.set_max_message_size(max_message_size); + Ok(connection) } diff --git a/package.json b/package.json index 2348a44..82fe6b5 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "crypto/src/", "type-map/Cargo.toml", "type-map/build.rs", + "type-map/reserved.json", "type-map/src/", "wasm/.cargo/", "wasm/Cargo.toml", @@ -51,7 +52,13 @@ "example": "pnpm install && pnpm run build:all && nix develop .#autoStart", "build": "MTP_TYPE_MAPS=$PWD/example-type-maps.yaml RUSTFLAGS='--cfg web_sys_unstable_apis' wasm-pack build wasm --target web --out-dir pkg --release && tsc", "build:all": "nix run .#build-all", - "dup": "jscpd --pattern '**/*.{rs,ts}' --ignore 'target/**' --ignore 'wasm/pkg/**' --ignore '.git/**' --min-lines 8 --min-tokens 80 --threshold 4 --reporters console --noTips ." + "dup": "jscpd --pattern '**/*.{rs,ts}' --ignore 'target/**' --ignore 'wasm/pkg/**' --ignore '.git/**' --min-lines 8 --min-tokens 80 --threshold 4 --reporters console --noTips .", + "test:e2e": "tsc && node test/e2ee.mjs", + "test:secrets": "tsc && node --test --test-isolation=none test/encrypted-secret.mjs", + "test:types": "tsc -p tsconfig.type-tests.json --noEmit", + "test:vite": "tsc && node test/vite-type-map.mjs", + "test": "pnpm run test:e2e && pnpm run test:secrets && pnpm run test:types && pnpm run test:vite", + "check:boundary": "node scripts/check-mtp-boundary.mjs" }, "devDependencies": { "@types/node": "^26.0.1", diff --git a/src/sdk/encrypted-device-secret.ts b/src/sdk/encrypted-device-secret.ts deleted file mode 100644 index bc4587d..0000000 --- a/src/sdk/encrypted-device-secret.ts +++ /dev/null @@ -1,74 +0,0 @@ -export interface EncryptedDeviceSecretRecord { - userId: string; - deviceId: string; - secretId: string; - version: number; - encryptedSecret: Uint8Array; - wrappingPublicKeyId?: string; - wrappingScheme: string; - createdAt: number; - updatedAt: number; -} - -export interface MTPEncryptedDeviceSecretProvider { - setEncryptedDeviceSecret(record: EncryptedDeviceSecretRecord): Promise; - getEncryptedDeviceSecret(query: { - userId: string; - deviceId?: string; - secretId?: string; - }): Promise; -} - -function keyFor(record: Pick): string { - return `${record.userId}\0${record.deviceId}\0${record.secretId}`; -} - -function cloneRecord(record: EncryptedDeviceSecretRecord): EncryptedDeviceSecretRecord { - return { - ...record, - encryptedSecret: new Uint8Array(record.encryptedSecret), - }; -} - -function validateEncryptedRecord(record: EncryptedDeviceSecretRecord): void { - if (!record.userId || !record.deviceId || !record.secretId) { - throw new Error("encrypted device secret requires userId, deviceId, and secretId"); - } - if (!(record.encryptedSecret instanceof Uint8Array) || record.encryptedSecret.length === 0) { - throw new Error("encrypted device secret requires non-empty encryptedSecret bytes"); - } - if (!record.wrappingScheme) { - throw new Error("encrypted device secret requires wrappingScheme"); - } -} - -export class InMemoryEncryptedDeviceSecretProvider implements MTPEncryptedDeviceSecretProvider { - private store = new Map(); - - async setEncryptedDeviceSecret(record: EncryptedDeviceSecretRecord): Promise { - validateEncryptedRecord(record); - const now = Date.now(); - this.store.set(keyFor(record), cloneRecord({ ...record, updatedAt: record.updatedAt || now })); - } - - async getEncryptedDeviceSecret(query: { - userId: string; - deviceId?: string; - secretId?: string; - }): Promise { - if (!query.userId) { - throw new Error("userId is required"); - } - if (query.deviceId && query.secretId) { - const found = this.store.get(`${query.userId}\0${query.deviceId}\0${query.secretId}`); - return found ? cloneRecord(found) : null; - } - for (const record of this.store.values()) { - if (record.userId !== query.userId) continue; - if (query.deviceId && record.deviceId !== query.deviceId) continue; - if (query.secretId && record.secretId !== query.secretId) continue; - return cloneRecord(record); - } - return null; - } -} diff --git a/src/sdk/encrypted-message.ts b/src/sdk/encrypted-message.ts index e3c7686..60449c7 100644 --- a/src/sdk/encrypted-message.ts +++ b/src/sdk/encrypted-message.ts @@ -14,8 +14,8 @@ const HEADER_FIXED_LEN = 1 + 1 + 8 + 8 + 4 + 2 + 4; export interface ParsedEncryptedMessage { version: 1; flags: number; - senderClientId: bigint; - recipientClientId: bigint; + senderId: bigint; + recipientId: bigint; messageNumber: number; kemCiphertext?: Uint8Array; ciphertext: Uint8Array; @@ -28,8 +28,8 @@ export interface ParsedEncryptedMessage { export interface EncryptedMessageHeader { version: 1; flags: number; - senderClientId: bigint; - recipientClientId: bigint; + senderId: bigint; + recipientId: bigint; messageNumber: number; kemCiphertext?: Uint8Array; } @@ -105,8 +105,8 @@ export function serializeEncryptedMessage( return concatBytes([ new Uint8Array([normalized.version]), new Uint8Array([normalized.flags]), - writeU64BE(normalized.senderClientId), - writeU64BE(normalized.recipientClientId), + writeU64BE(normalized.senderId), + writeU64BE(normalized.recipientId), writeU32BE(normalized.messageNumber), new Uint8Array([ (kemCiphertext.length >>> 8) & 0xff, @@ -131,9 +131,9 @@ export function parseEncryptedMessage( const version = bytes[offset++]; const flags = bytes[offset++]; - const senderClientId = readU64BE(bytes, offset); + const senderId = readU64BE(bytes, offset); offset += 8; - const recipientClientId = readU64BE(bytes, offset); + const recipientId = readU64BE(bytes, offset); offset += 8; const messageNumber = ((bytes[offset] << 24) | @@ -176,8 +176,8 @@ export function parseEncryptedMessage( const parsed: ParsedEncryptedMessage = { version: version as 1, flags, - senderClientId, - recipientClientId, + senderId, + recipientId, messageNumber, kemCiphertext, ciphertext, @@ -185,8 +185,8 @@ export function parseEncryptedMessage( parsed.header = { version: parsed.version, flags: parsed.flags, - senderClientId: parsed.senderClientId, - recipientClientId: parsed.recipientClientId, + senderId: parsed.senderId, + recipientId: parsed.recipientId, messageNumber: parsed.messageNumber, kemCiphertext: parsed.kemCiphertext, }; @@ -199,8 +199,8 @@ function buildAAD(header: EncryptedMessageHeader): Uint8Array { return concatBytes([ new Uint8Array([header.version]), new Uint8Array([header.flags]), - writeU64BE(header.senderClientId), - writeU64BE(header.recipientClientId), + writeU64BE(header.senderId), + writeU64BE(header.recipientId), writeU32BE(header.messageNumber), ]); } @@ -227,8 +227,8 @@ export async function encryptPayload(args: { const header: EncryptedMessageHeader = { version: 1, flags: args.kemCiphertext ? FLAG_INIT : 0, - senderClientId: args.session.ownClientId, - recipientClientId: args.session.peerClientId, + senderId: args.session.localId, + recipientId: args.session.remoteId, messageNumber: args.session.sendCount, kemCiphertext: args.kemCiphertext, }; @@ -257,19 +257,18 @@ export async function encryptPayload(args: { export async function decryptPayload(args: { payload: Uint8Array; session: MTPSessionState; - expectedRecipientClientId?: bigint; + expectedRecipientId?: bigint; aad?: Uint8Array; }): Promise<{ plaintext: Uint8Array; session: MTPSessionState; }> { const parsed = parseEncryptedMessage(args.payload); - const expectedRecipientClientId = - args.expectedRecipientClientId ?? args.session.ownClientId; - if (parsed.recipientClientId !== expectedRecipientClientId) { + const expectedRecipientId = args.expectedRecipientId ?? args.session.localId; + if (parsed.recipientId !== expectedRecipientId) { throw new Error("Encrypted message recipient mismatch"); } - if (parsed.senderClientId !== args.session.peerClientId) { + if (parsed.senderId !== args.session.remoteId) { throw new Error("Encrypted message sender mismatch"); } @@ -322,8 +321,8 @@ export async function decryptPayload(args: { const header: EncryptedMessageHeader = { version: parsed.version, flags: parsed.flags, - senderClientId: parsed.senderClientId, - recipientClientId: parsed.recipientClientId, + senderId: parsed.senderId, + recipientId: parsed.recipientId, messageNumber: parsed.messageNumber, kemCiphertext: parsed.kemCiphertext, }; diff --git a/src/sdk/encrypted-pipe.ts b/src/sdk/encrypted-pipe.ts new file mode 100644 index 0000000..b616654 --- /dev/null +++ b/src/sdk/encrypted-pipe.ts @@ -0,0 +1,1523 @@ +import * as bindings from "mtp/raw"; +import type { + MTPBytesInput, + MTPPipeReader, + MTPPipeWriter, + MTPProtectionSignatureSuite, +} from "./index.js"; +import { + MTPSignatureVerificationError, + resolveSignatureVerificationPolicy, + signatureVerificationFailure, + verifyDataValueWithPolicy, +} from "./signature-policy.js"; +import type { MTPSignatureVerificationPolicy } from "./signature-policy.js"; +import { concatBytes, utf8Encode, writeU64BE } from "./utils.js"; + +const PIPE_E2EE_DOMAIN = utf8Encode("MTP-PIPE-E2EE-1"); +const PIPE_RECORD_KDF_DOMAIN = utf8Encode("MTP-PIPE-E2EE-1/KEY"); +const PIPE_TRANSCRIPT_DOMAIN = utf8Encode("MTP-PIPE-TRANSCRIPT-1"); +const PIPE_RECORD_MESSAGE_LABEL = utf8Encode("/message"); +const PIPE_RECORD_NEXT_LABEL = utf8Encode("/next"); +const XCHACHA_OVERHEAD = 24 + 16; +const MAX_SESSION_ID = 1024; +export const MAX_ENCRYPTED_PIPE_RECORD = 16 * 1024 * 1024; +export const MAX_PIPE_SESSION_OFFER = 64 * 1024; +const PIPE_SESSION_OFFER_DOMAIN = "MTP-PIPE-SESSION-1"; +const FS_INIT_DOMAIN = "MTP-PIPE-FS-INIT-1"; +const FS_RESPONSE_DOMAIN = "MTP-PIPE-FS-RESPONSE-1"; +const FS_FINISH_DOMAIN = "MTP-PIPE-FS-FINISH-1"; +const FS_ROOT_INFO = utf8Encode("MTP-PIPE-FS-ROOT-1"); +const RECORD_TYPE_DATA = 0; +const RECORD_TYPE_FINAL = 1; +const MAX_PIPE_BUFFER = MAX_ENCRYPTED_PIPE_RECORD + 5; +// Session setup may leave one encrypted record in the same transport chunk +// after an offer. Bound that carry-over buffer before concatenating attacker- +// controlled chunks, just as the record reader bounds its input buffer. +const MAX_SESSION_BUFFER = MAX_PIPE_BUFFER + MAX_PIPE_SESSION_OFFER + 4; +const KEM_PUBLIC_KEY_LEN = 1216; +const SIG_PQ_PUBLIC_KEY_LEN = 1952; +const SIG_CL_PUBLIC_KEY_LEN = 32; + +export function pipeSessionSignaturePurpose(): number { + return bindings.mtp_pipe_session_signature_purpose(); +} + +export function pipeSessionEncryptionPurpose(): number { + return bindings.mtp_pipe_session_encryption_purpose(); +} + +/** + * Validate a purpose supplied for application pipe records. MTP-owned + * purpose bytes come from the WASM protocol registry so browser callers do + * not have to duplicate the numeric allocation. + */ +export function validateApplicationProtectionPurpose(purpose: number): number { + if (!Number.isInteger(purpose) || purpose < 0 || purpose > 0xff) { + throw new MTPEncryptedPipeError("context", "purpose must be a u8"); + } + const reserved = new Set([ + bindings.mtp_relay_metadata_encryption_purpose(), + bindings.mtp_relay_content_signature_purpose(), + bindings.mtp_relay_content_encryption_purpose(), + bindings.mtp_relay_metadata_signature_purpose(), + bindings.mtp_pipe_session_signature_purpose(), + bindings.mtp_pipe_session_encryption_purpose(), + ]); + if (reserved.has(purpose)) { + throw new MTPEncryptedPipeError( + "context", + "purpose is reserved for an MTP protocol operation", + ); + } + return purpose; +} + +export class MTPEncryptedPipeError extends Error { + readonly code: + | "context" + | "record-length" + | "sequence" + | "truncated" + | "authentication" + | "io" + | "setup" + | "state"; + + constructor( + code: MTPEncryptedPipeError["code"], + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = "MTPEncryptedPipeError"; + this.code = code; + } +} + +export class MTPPipeProtectionContext { + readonly sessionId: Uint8Array; + readonly purpose: number; + readonly direction: number; + readonly transcriptHash: Uint8Array; + + constructor( + sessionId: Uint8Array, + purpose: number, + direction: number, + transcriptHash?: Uint8Array, + ) { + if ( + !(sessionId instanceof Uint8Array) || + sessionId.length === 0 || + sessionId.length > MAX_SESSION_ID + ) { + throw new MTPEncryptedPipeError( + "context", + "sessionId must contain between 1 and 1024 bytes", + ); + } + validateApplicationProtectionPurpose(purpose); + if (!Number.isInteger(direction) || direction < 0 || direction > 0xff) { + throw new MTPEncryptedPipeError("context", "direction must be a u8"); + } + this.sessionId = sessionId.slice(); + this.purpose = purpose; + this.direction = direction; + if (transcriptHash != null) { + if ( + !(transcriptHash instanceof Uint8Array) || + transcriptHash.length !== 32 + ) { + throw new MTPEncryptedPipeError( + "context", + "transcriptHash must be 32 bytes", + ); + } + this.transcriptHash = transcriptHash.slice(); + } else { + this.transcriptHash = baseTranscriptHash( + this.sessionId, + purpose, + direction, + ); + } + } +} + +function u32(value: number): Uint8Array { + const result = new Uint8Array(4); + new DataView(result.buffer).setUint32(0, value, false); + return result; +} + +function sessionTranscriptHash(params: MTPPipeSessionParameters): Uint8Array { + return bindings.wasm_sha256( + concatBytes([ + PIPE_TRANSCRIPT_DOMAIN, + u32(params.sessionId.length), + params.sessionId, + u32(params.pipeId), + writeU64BE(params.senderId), + writeU64BE(params.recipientId), + new Uint8Array([params.purpose, params.direction]), + ]), + ); +} + +function baseTranscriptHash( + sessionId: Uint8Array, + purpose: number, + direction: number, +): Uint8Array { + return bindings.wasm_sha256( + concatBytes([ + PIPE_TRANSCRIPT_DOMAIN, + u32(sessionId.length), + sessionId, + new Uint8Array([purpose, direction]), + ]), + ); +} + +function recordLength(plaintextLength: number): number { + const length = plaintextLength + XCHACHA_OVERHEAD; + if ( + !Number.isSafeInteger(length) || + length < XCHACHA_OVERHEAD || + length > MAX_ENCRYPTED_PIPE_RECORD || + length > 0xffff_ffff + ) { + throw new MTPEncryptedPipeError( + "record-length", + `invalid encrypted pipe record length: ${length}`, + ); + } + return length; +} + +function aad( + context: MTPPipeProtectionContext, + sequence: bigint, + encodedLength: number, + recordType: number, +): Uint8Array { + return concatBytes([ + PIPE_E2EE_DOMAIN, + new Uint8Array([context.purpose, context.direction]), + context.transcriptHash, + writeU64BE(sequence), + u32(encodedLength), + new Uint8Array([recordType]), + ]); +} + +function keyBytes(key: Uint8Array): Uint8Array { + if (!(key instanceof Uint8Array) || key.length !== 32) { + throw new MTPEncryptedPipeError( + "context", + "pipe session key must be 32 bytes", + ); + } + return key.slice(); +} + +function recordKeyInfo( + context: MTPPipeProtectionContext, + sequence: bigint, + label: Uint8Array, +): Uint8Array { + return concatBytes([ + PIPE_RECORD_KDF_DOMAIN, + new Uint8Array([context.purpose, context.direction]), + context.transcriptHash, + writeU64BE(sequence), + label, + ]); +} + +function deriveRecordKeys( + chainKey: Uint8Array, + context: MTPPipeProtectionContext, + sequence: bigint, +): { messageKey: Uint8Array; nextChainKey: Uint8Array } { + try { + return { + messageKey: bindings.wasm_hkdf_expand( + chainKey, + context.transcriptHash, + recordKeyInfo(context, sequence, PIPE_RECORD_MESSAGE_LABEL), + 32, + ), + nextChainKey: bindings.wasm_hkdf_expand( + chainKey, + context.transcriptHash, + recordKeyInfo(context, sequence, PIPE_RECORD_NEXT_LABEL), + 32, + ), + }; + } catch (error) { + throw new MTPEncryptedPipeError( + "authentication", + "encrypted pipe record key derivation failed", + { cause: error }, + ); + } +} + +export interface MTPWritablePipe { + write(data: Uint8Array): Promise; + close(): Promise; + abort(): void; +} + +export interface MTPReadablePipe { + read(): Promise; +} + +/** Advanced duplex transport required by the forward-secure handshake. */ +export interface MTPDuplexPipe extends MTPWritablePipe, MTPReadablePipe { + readonly pipeId?: number; +} + +export interface MTPPipeSessionParameters { + sessionId: Uint8Array; + pipeId: number; + senderId: bigint; + recipientId: bigint; + purpose: number; + direction: number; +} + +/** Session fields the receiver can know before decrypting the offer. */ +export type MTPPipeSessionExpectation = Omit< + MTPPipeSessionParameters, + "sessionId" +>; + +function contextForSessionParameters( + params: MTPPipeSessionParameters, +): MTPPipeProtectionContext { + return new MTPPipeProtectionContext( + params.sessionId, + params.purpose, + params.direction, + sessionTranscriptHash(params), + ); +} + +function sessionError(message: string, cause?: unknown): MTPEncryptedPipeError { + return new MTPEncryptedPipeError("setup", message, { cause }); +} + +function bytesInput(value: MTPBytesInput, name: string): Uint8Array { + if (value instanceof Uint8Array) return value.slice(); + if (Array.isArray(value)) return new Uint8Array(value); + throw sessionError(`${name} must be a Uint8Array or number[]`); +} + +function publicKeyBundleInputs(value: Uint8Array): void { + let offset = 0; + const lengths: number[] = []; + for (let index = 0; index < 3; index += 1) { + if (offset + 2 > value.length) + throw sessionError("public key bundle is truncated"); + const length = new DataView( + value.buffer, + value.byteOffset, + value.byteLength, + ).getUint16(offset, false); + offset += 2; + if (offset + length > value.length) { + throw sessionError("public key bundle is truncated"); + } + lengths.push(length); + offset += length; + } + if ( + offset !== value.length || + lengths[0] !== KEM_PUBLIC_KEY_LEN || + lengths[1] !== SIG_PQ_PUBLIC_KEY_LEN || + lengths[2] !== SIG_CL_PUBLIC_KEY_LEN + ) { + throw sessionError("public key bundle contains invalid suite key lengths"); + } +} + +function recipientBundleInputs( + value: MTPBytesInput | MTPBytesInput[], +): Uint8Array[] { + // A number[] is one serialized bundle; an array whose first element is a + // byte array is the multi-recipient form. + if (value instanceof Uint8Array) return [value.slice()]; + if ( + Array.isArray(value) && + (value.length === 0 || typeof value[0] === "number") + ) { + return [bytesInput(value as MTPBytesInput, "recipientPublicKey")]; + } + if (!Array.isArray(value)) { + throw sessionError( + "recipientPublicKey must be bytes or an array of bundles", + ); + } + const result = value.map((entry, index) => + bytesInput(entry, `recipientPublicKeys[${index}]`), + ); + if (result.length === 0) { + throw sessionError("at least one recipient public key is required"); + } + return result; +} + +function validateSessionParameters( + params: MTPPipeSessionParameters, +): MTPPipeSessionParameters { + if ( + !(params.sessionId instanceof Uint8Array) || + params.sessionId.length === 0 || + params.sessionId.length > MAX_SESSION_ID + ) { + throw sessionError("sessionId must contain between 1 and 1024 bytes"); + } + if ( + !Number.isInteger(params.pipeId) || + params.pipeId <= 0 || + params.pipeId > 0xffff_ffff + ) { + throw sessionError("pipeId must be a non-zero u32"); + } + if (params.senderId < 0n || params.senderId > 0xffff_ffff_ffff_ffffn) { + throw sessionError("senderId must be a u64"); + } + if (params.recipientId < 0n || params.recipientId > 0xffff_ffff_ffff_ffffn) { + throw sessionError("recipientId must be a u64"); + } + validateApplicationProtectionPurpose(params.purpose); + if ( + !Number.isInteger(params.direction) || + params.direction < 0 || + params.direction > 0xff + ) { + throw sessionError("direction must be a u8"); + } + return { ...params, sessionId: params.sessionId.slice() }; +} + +function serializedKeyring(keyring: MTPBytesInput): { + bytes: Uint8Array; + hasPqSigningKey: boolean; +} { + const bytes = bytesInput(keyring, "keyring"); + let offset = 0; + const fields: Uint8Array[] = []; + for (let index = 0; index < 6; index += 1) { + if (offset + 2 > bytes.length) throw sessionError("keyring is truncated"); + const length = new DataView( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ).getUint16(offset, false); + offset += 2; + if (offset + length > bytes.length) + throw sessionError("keyring is truncated"); + fields.push(bytes.slice(offset, offset + length)); + offset += length; + } + if (offset !== bytes.length || fields[5].length !== 32) { + throw sessionError("keyring does not contain a valid Ed25519 secret key"); + } + const hasPqPublicKey = fields[2].length > 0; + const hasPqSecretKey = fields[3].length > 0; + return { + bytes, + hasPqSigningKey: hasPqPublicKey && hasPqSecretKey, + }; +} + +function selectedSignatureSuite( + keyring: ReturnType, + requested: MTPProtectionSignatureSuite | undefined, +): MTPProtectionSignatureSuite { + const suite = requested ?? "ed25519"; + if (suite !== "dual" && suite !== "ed25519") { + throw sessionError("signatureSuite must be 'dual' or 'ed25519'"); + } + if (suite === "dual" && !keyring.hasPqSigningKey) { + throw sessionError( + "dual protected signatures require a complete ML-DSA key pair; choose 'ed25519' for a partial keyring", + ); + } + return suite; +} + +function asBigInt(value: unknown, name: string): bigint { + try { + if (typeof value === "bigint") return value; + if (typeof value === "number" && Number.isSafeInteger(value)) + return BigInt(value); + if (typeof value === "string" && value.length > 0) return BigInt(value); + } catch (error) { + throw sessionError(`${name} is not an integer`, error); + } + throw sessionError(`${name} is not an integer`); +} + +function asBytes(value: unknown, name: string): Uint8Array { + if (value instanceof Uint8Array) return value; + throw sessionError(`${name} is not binary`); +} + +function appendChunk(buffer: Uint8Array, chunk: Uint8Array): Uint8Array { + if ( + buffer.length > MAX_SESSION_BUFFER || + chunk.length > MAX_SESSION_BUFFER - buffer.length + ) { + throw sessionError("encrypted pipe session input buffer is too large"); + } + const combined = new Uint8Array(buffer.length + chunk.length); + combined.set(buffer); + combined.set(chunk, buffer.length); + return combined; +} + +async function readSessionOffer(reader: MTPReadablePipe): Promise<{ + offer: Uint8Array; + remainder: Uint8Array; +}> { + let buffer: Uint8Array = new Uint8Array(0); + const ensure = async (length: number): Promise => { + while (buffer.length < length) { + const chunk = await reader.read(); + if (chunk == null) + throw sessionError("pipe ended before session setup completed"); + if (!(chunk instanceof Uint8Array)) + throw sessionError("pipe reader returned non-byte data"); + if (chunk.length > 0) buffer = appendChunk(buffer, chunk); + } + }; + + await ensure(4); + const offerLength = new DataView( + buffer.buffer, + buffer.byteOffset, + buffer.byteLength, + ).getUint32(0, false); + if (offerLength === 0 || offerLength > MAX_PIPE_SESSION_OFFER) { + throw sessionError(`invalid pipe session offer length: ${offerLength}`); + } + await ensure(4 + offerLength); + return { + offer: buffer.slice(4, 4 + offerLength), + remainder: buffer.slice(4 + offerLength), + }; +} + +class MTPSessionOfferReader { + private buffered: Uint8Array = new Uint8Array(0); + + constructor(private readonly reader: MTPReadablePipe) {} + + async read(): Promise { + const result = await readSessionOfferWithBuffer(this.reader, this.buffered); + this.buffered = result.remainder; + return result.offer; + } + + remainder(): Uint8Array { + return this.buffered.slice(); + } +} + +async function readSessionOfferWithBuffer( + reader: MTPReadablePipe, + initialBuffer: Uint8Array, +): Promise<{ offer: Uint8Array; remainder: Uint8Array }> { + let buffer: Uint8Array = initialBuffer.slice(); + const ensure = async (length: number): Promise => { + while (buffer.length < length) { + const chunk = await reader.read(); + if (chunk == null) + throw sessionError("pipe ended before session setup completed"); + if (!(chunk instanceof Uint8Array)) { + throw sessionError("pipe reader returned non-byte data"); + } + if (chunk.length > 0) buffer = appendChunk(buffer, chunk); + } + }; + + await ensure(4); + const offerLength = new DataView( + buffer.buffer, + buffer.byteOffset, + buffer.byteLength, + ).getUint32(0, false); + if (offerLength === 0 || offerLength > MAX_PIPE_SESSION_OFFER) { + throw sessionError(`invalid pipe session offer length: ${offerLength}`); + } + await ensure(4 + offerLength); + return { + offer: buffer.slice(4, 4 + offerLength), + remainder: buffer.slice(4 + offerLength), + }; +} + +async function writeSessionOffer( + writer: MTPWritablePipe, + offer: Uint8Array, +): Promise { + if (offer.length === 0 || offer.length > MAX_PIPE_SESSION_OFFER) { + throw sessionError(`invalid pipe session offer length: ${offer.length}`); + } + const prefix = new Uint8Array(4); + new DataView(prefix.buffer).setUint32(0, offer.length, false); + await writer.write(concatBytes([prefix, offer])); +} + +function handshakeHash(parts: readonly Uint8Array[]): Uint8Array { + return bindings.wasm_sha256( + concatBytes(parts.flatMap((part) => [u32(part.length), part])), + ); +} + +function fsCommonFields(params: MTPPipeSessionParameters): unknown[] { + return [ + params.sessionId, + BigInt(params.pipeId), + params.senderId, + params.recipientId, + BigInt(params.purpose), + BigInt(params.direction), + ]; +} + +function fsInitValue( + params: MTPPipeSessionParameters, + nonce: Uint8Array, +): unknown[] { + return [FS_INIT_DOMAIN, ...fsCommonFields(params), nonce]; +} + +function fsResponseValue( + params: MTPPipeSessionParameters, + initHash: Uint8Array, + ephemeralPublicKey: Uint8Array, +): unknown[] { + return [ + FS_RESPONSE_DOMAIN, + ...fsCommonFields(params), + initHash, + ephemeralPublicKey, + ]; +} + +function fsFinishValue( + params: MTPPipeSessionParameters, + responseHash: Uint8Array, + ciphertext: Uint8Array, +): unknown[] { + return [ + FS_FINISH_DOMAIN, + ...fsCommonFields(params), + responseHash, + ciphertext, + ]; +} + +function signHandshakeValue( + value: unknown, + signerId: bigint, + keyring: ReturnType, + suite: MTPProtectionSignatureSuite, +): Uint8Array { + return bindings.sign_data_value_with_keyring( + bindings.encode_data_value(value), + signerId, + pipeSessionSignaturePurpose(), + keyring.bytes, + suite === "dual" + ? bindings.mtp_protection_signature_suite_dual() + : bindings.mtp_protection_signature_suite_ed25519(), + ); +} + +function verifiedHandshakeValue( + encoded: Uint8Array, + expectedSignerId: bigint, + senderPublicKey: Uint8Array, + policy: MTPSignatureVerificationPolicy, +): unknown[] { + return verifiedHandshakeValueWithKeys( + encoded, + expectedSignerId, + [senderPublicKey], + policy, + ); +} + +function verifiedHandshakeValueWithKeys( + encoded: Uint8Array, + expectedSignerId: bigint, + senderPublicKeys: Uint8Array[], + policy: MTPSignatureVerificationPolicy, +): unknown[] { + const errors: unknown[] = []; + let verified = false; + for (const senderPublicKey of senderPublicKeys) { + try { + verifyDataValueWithPolicy( + encoded, + senderPublicKey, + expectedSignerId, + pipeSessionSignaturePurpose(), + policy, + ); + verified = true; + break; + } catch (error) { + errors.push(error); + } + } + if (!verified) throw signatureVerificationFailure(errors, expectedSignerId); + const parsed = bindings.parse_data_value(encoded); + if ( + parsed === null || + typeof parsed !== "object" || + Array.isArray(parsed) || + (parsed as Record).kind !== "signed" + ) { + throw sessionError("forward-secure handshake value is not signed"); + } + const fields = (parsed as { value?: unknown }).value; + if (!Array.isArray(fields)) { + throw sessionError("forward-secure handshake value is not an array"); + } + return fields; +} + +function validateFsCommon( + fields: unknown[], + expected: MTPPipeSessionParameters, + domain: string, + length: number, +): void { + if (fields.length !== length || fields[0] !== domain) { + throw sessionError("forward-secure handshake domain or length mismatch"); + } + const sessionId = asBytes(fields[1], "sessionId"); + if ( + sessionId.length !== expected.sessionId.length || + sessionId.some((byte, index) => byte !== expected.sessionId[index]) + ) { + throw sessionError("forward-secure handshake session mismatch"); + } + if (asBigInt(fields[2], "pipeId") !== BigInt(expected.pipeId)) { + throw sessionError("forward-secure handshake pipe mismatch"); + } + if (asBigInt(fields[3], "senderId") !== expected.senderId) { + throw sessionError("forward-secure handshake sender mismatch"); + } + if (asBigInt(fields[4], "recipientId") !== expected.recipientId) { + throw sessionError("forward-secure handshake recipient mismatch"); + } + if (asBigInt(fields[5], "purpose") !== BigInt(expected.purpose)) { + throw sessionError("forward-secure handshake purpose mismatch"); + } + if (asBigInt(fields[6], "direction") !== BigInt(expected.direction)) { + throw sessionError("forward-secure handshake direction mismatch"); + } +} + +function forwardSecureContext( + params: MTPPipeSessionParameters, + handshakeTranscript: Uint8Array, +): MTPPipeProtectionContext { + return new MTPPipeProtectionContext( + params.sessionId, + params.purpose, + params.direction, + bindings.wasm_sha256( + concatBytes([ + PIPE_TRANSCRIPT_DOMAIN, + sessionTranscriptHash(params), + handshakeTranscript, + ]), + ), + ); +} + +function validateOfferFields( + signedValue: unknown, + expected: MTPPipeSessionParameters, +): Uint8Array { + if ( + signedValue === null || + typeof signedValue !== "object" || + Array.isArray(signedValue) || + (signedValue as Record).kind !== "signed" + ) { + throw sessionError("pipe session offer is not signed"); + } + const fields = (signedValue as { value?: unknown }).value; + if (!Array.isArray(fields) || fields.length !== 8) { + throw sessionError("pipe session offer has invalid fields"); + } + if (fields[0] !== PIPE_SESSION_OFFER_DOMAIN) { + throw sessionError("pipe session offer domain mismatch"); + } + if (asBytes(fields[1], "sessionId").length !== expected.sessionId.length) { + throw sessionError("pipe session offer session mismatch"); + } + const sessionId = asBytes(fields[1], "sessionId"); + if (sessionId.some((byte, index) => byte !== expected.sessionId[index])) { + throw sessionError("pipe session offer session mismatch"); + } + if (asBigInt(fields[2], "pipeId") !== BigInt(expected.pipeId)) { + throw sessionError("pipe session offer pipe mismatch"); + } + if (asBigInt(fields[3], "senderId") !== expected.senderId) { + throw sessionError("pipe session offer sender mismatch"); + } + if (asBigInt(fields[4], "recipientId") !== expected.recipientId) { + throw sessionError("pipe session offer recipient mismatch"); + } + if (asBigInt(fields[5], "purpose") !== BigInt(expected.purpose)) { + throw sessionError("pipe session offer purpose mismatch"); + } + if (asBigInt(fields[6], "direction") !== BigInt(expected.direction)) { + throw sessionError("pipe session offer direction mismatch"); + } + const key = asBytes(fields[7], "session key"); + if (key.length !== 32) + throw sessionError("pipe session offer key is not 32 bytes"); + return key.slice(); +} + +function senderBundleInputs( + value: MTPBytesInput | MTPBytesInput[], +): Uint8Array[] { + const bundles = recipientBundleInputs(value); + bundles.forEach((bundle) => publicKeyBundleInputs(bundle)); + return bundles; +} + +function verifyPipeSessionValue( + signed: Uint8Array, + senderBundles: Uint8Array[], + expectedSignerId: bigint, + policy: MTPSignatureVerificationPolicy, +): void { + const errors: unknown[] = []; + for (const senderBundle of senderBundles) { + try { + verifyDataValueWithPolicy( + signed, + senderBundle, + expectedSignerId, + pipeSessionSignaturePurpose(), + policy, + ); + return; + } catch (error) { + errors.push(error); + } + } + throw signatureVerificationFailure(errors, expectedSignerId); +} + +function sessionIdFromOffer(parsed: unknown): Uint8Array { + if ( + parsed === null || + typeof parsed !== "object" || + Array.isArray(parsed) || + (parsed as Record).kind !== "signed" + ) { + throw sessionError("pipe session offer is not signed"); + } + const fields = (parsed as { value?: unknown }).value; + if (!Array.isArray(fields) || fields.length !== 8) { + throw sessionError("pipe session offer has invalid fields"); + } + const sessionId = asBytes(fields[1], "sessionId"); + if (sessionId.length === 0 || sessionId.length > MAX_SESSION_ID) { + throw sessionError("pipe session offer session ID is invalid"); + } + return sessionId.slice(); +} + +/** Send a signed/KEM-protected pipe session offer and return its record writer. */ +export async function initiateMTPPipeSession( + writer: MTPPipeWriter & MTPWritablePipe, + params: MTPPipeSessionParameters, + senderKeyring: MTPBytesInput, + recipientPublicKey: MTPBytesInput | MTPBytesInput[], + signatureSuite?: MTPProtectionSignatureSuite, +): Promise { + const checked = validateSessionParameters(params); + if (writer.pipeId != null && writer.pipeId !== checked.pipeId) { + throw sessionError("pipeId does not match the actual writer pipe"); + } + const recipientBundles = recipientBundleInputs(recipientPublicKey); + const keyring = serializedKeyring(senderKeyring); + const suite = selectedSignatureSuite(keyring, signatureSuite); + const key = new Uint8Array(32); + globalThis.crypto.getRandomValues(key); + const payload = bindings.encode_data_value([ + PIPE_SESSION_OFFER_DOMAIN, + checked.sessionId, + BigInt(checked.pipeId), + checked.senderId, + checked.recipientId, + checked.purpose, + checked.direction, + key, + ]); + const signed = bindings.sign_data_value_with_keyring( + payload, + checked.senderId, + pipeSessionSignaturePurpose(), + keyring.bytes, + suite === "dual" + ? bindings.mtp_protection_signature_suite_dual() + : bindings.mtp_protection_signature_suite_ed25519(), + ); + const encrypted = bindings.encrypt_data_value_for_recipients( + signed, + recipientBundles, + pipeSessionEncryptionPurpose(), + ); + if (encrypted.length > MAX_PIPE_SESSION_OFFER) { + throw sessionError(`pipe session offer is too large: ${encrypted.length}`); + } + const prefix = new Uint8Array(4); + new DataView(prefix.buffer).setUint32(0, encrypted.length, false); + try { + await writer.write(concatBytes([prefix, encrypted])); + return new MTPEncryptedPipeWriter( + writer, + key, + contextForSessionParameters(checked), + ); + } catch (error) { + key.fill(0); + throw sessionError("failed to write pipe session offer", error); + } finally { + key.fill(0); + } +} + +/** Read and verify a pipe session offer, then return its record reader. */ +export async function acceptMTPPipeSession( + reader: MTPPipeReader & MTPReadablePipe, + params: MTPPipeSessionParameters, + recipientKeyring: MTPBytesInput, + senderPublicKey: MTPBytesInput | MTPBytesInput[], + signaturePolicy?: MTPSignatureVerificationPolicy, +): Promise { + const checked = validateSessionParameters(params); + if (reader.pipeId != null && reader.pipeId !== checked.pipeId) { + throw sessionError("pipeId does not match the actual reader pipe"); + } + const { offer, remainder } = await readSessionOffer(reader); + const signed = bindings.decrypt_data_value( + offer, + bytesInput(recipientKeyring, "recipientKeyring"), + pipeSessionEncryptionPurpose(), + ); + const senderBundles = senderBundleInputs(senderPublicKey); + const policy = resolveSignatureVerificationPolicy(signaturePolicy); + verifyPipeSessionValue(signed, senderBundles, checked.senderId, policy); + const parsed = bindings.parse_data_value(signed); + const key = validateOfferFields(parsed, checked); + const result = new MTPEncryptedPipeReader( + reader, + key, + contextForSessionParameters(checked), + remainder, + ); + key.fill(0); + return result; +} + +/** + * Accept a pipe session without making the caller copy the sender's random + * session ID out of band. The ID is learned only after recipient decryption + * and signature verification, then all record context uses that ID. + */ +export async function acceptMTPPipeSessionAuto( + reader: MTPPipeReader & MTPReadablePipe, + expected: MTPPipeSessionExpectation, + recipientKeyring: MTPBytesInput, + senderPublicKey: MTPBytesInput | MTPBytesInput[], + signaturePolicy?: MTPSignatureVerificationPolicy, +): Promise { + if (reader.pipeId != null && reader.pipeId !== expected.pipeId) { + throw sessionError("pipeId does not match the actual reader pipe"); + } + const { offer, remainder } = await readSessionOffer(reader); + const signed = bindings.decrypt_data_value( + offer, + bytesInput(recipientKeyring, "recipientKeyring"), + pipeSessionEncryptionPurpose(), + ); + const senderBundles = senderBundleInputs(senderPublicKey); + const policy = resolveSignatureVerificationPolicy(signaturePolicy); + verifyPipeSessionValue(signed, senderBundles, expected.senderId, policy); + const parsed = bindings.parse_data_value(signed); + const sessionId = sessionIdFromOffer(parsed); + const checked = validateSessionParameters({ ...expected, sessionId }); + const key = validateOfferFields(parsed, checked); + const result = new MTPEncryptedPipeReader( + reader, + key, + contextForSessionParameters(checked), + remainder, + ); + key.fill(0); + return result; +} + +/** + * Establish a forward-secure encrypted pipe over a bidirectional transport. + * + * The responder contributes a fresh ephemeral hybrid-KEM key. Long-term + * identity keys authenticate the three-message exchange, but are not used to + * encrypt the resulting record chain, so later compromise of a long-term KEM + * key does not recover recorded sessions. + */ +export async function initiateMTPForwardSecurePipeSession( + stream: MTPDuplexPipe, + params: MTPPipeSessionParameters, + senderKeyring: MTPBytesInput, + recipientPublicKey: MTPBytesInput, + signatureSuite?: MTPProtectionSignatureSuite, + signaturePolicy?: MTPSignatureVerificationPolicy, +): Promise { + const checked = validateSessionParameters(params); + if (stream.pipeId != null && stream.pipeId !== checked.pipeId) { + throw sessionError("pipeId does not match the actual duplex pipe"); + } + const recipientBundle = bytesInput(recipientPublicKey, "recipientPublicKey"); + publicKeyBundleInputs(recipientBundle); + const keyring = serializedKeyring(senderKeyring); + const suite = selectedSignatureSuite(keyring, signatureSuite); + const policy = resolveSignatureVerificationPolicy(signaturePolicy); + const nonce = new Uint8Array(32); + globalThis.crypto.getRandomValues(nonce); + const initBytes = signHandshakeValue( + fsInitValue(checked, nonce), + checked.senderId, + keyring, + suite, + ); + await writeSessionOffer(stream, initBytes); + + const offerReader = new MTPSessionOfferReader(stream); + const responseBytes = await offerReader.read(); + const responseFields = verifiedHandshakeValue( + responseBytes, + checked.recipientId, + recipientBundle, + policy, + ); + validateFsCommon(responseFields, checked, FS_RESPONSE_DOMAIN, 9); + const initHash = handshakeHash([initBytes]); + const receivedInitHash = asBytes(responseFields[7], "initHash"); + if ( + receivedInitHash.length !== initHash.length || + receivedInitHash.some((byte, index) => byte !== initHash[index]) + ) { + throw sessionError("forward-secure handshake init transcript mismatch"); + } + const ephemeralPublicKey = asBytes(responseFields[8], "ephemeralPublicKey"); + let encapsulated: + ReturnType | undefined; + try { + encapsulated = bindings.wasm_kem_encapsulate(ephemeralPublicKey); + const ciphertext = encapsulated.ciphertext; + const finishBytes = signHandshakeValue( + fsFinishValue(checked, handshakeHash([responseBytes]), ciphertext), + checked.senderId, + keyring, + suite, + ); + await writeSessionOffer(stream, finishBytes); + const handshakeTranscript = handshakeHash([ + initBytes, + responseBytes, + finishBytes, + ]); + const chainKey = encapsulated.shared_secret; + const recordKey = bindings.wasm_hkdf_expand( + chainKey, + handshakeTranscript, + FS_ROOT_INFO, + 32, + ); + try { + return new MTPEncryptedPipeWriter( + stream, + recordKey, + forwardSecureContext(checked, handshakeTranscript), + ); + } finally { + chainKey.fill(0); + recordKey.fill(0); + } + } catch (error) { + if (error instanceof MTPSignatureVerificationError) throw error; + throw sessionError("forward-secure pipe handshake failed", error); + } finally { + encapsulated?.free(); + nonce.fill(0); + } +} + +/** + * Accept the forward-secure handshake. The session ID is learned from the + * authenticated initiator message; the remaining endpoint and pipe fields + * are supplied as the pre-decryption expectation. + */ +export async function acceptMTPForwardSecurePipeSession( + stream: MTPDuplexPipe, + expected: MTPPipeSessionExpectation, + recipientKeyring: MTPBytesInput, + senderPublicKey: MTPBytesInput | MTPBytesInput[], + signatureSuite?: MTPProtectionSignatureSuite, + signaturePolicy?: MTPSignatureVerificationPolicy, +): Promise { + if (stream.pipeId != null && stream.pipeId !== expected.pipeId) { + throw sessionError("pipeId does not match the actual duplex pipe"); + } + const recipientKeys = serializedKeyring(recipientKeyring); + const suite = selectedSignatureSuite(recipientKeys, signatureSuite); + const policy = resolveSignatureVerificationPolicy(signaturePolicy); + const senderBundles = senderBundleInputs(senderPublicKey); + const offerReader = new MTPSessionOfferReader(stream); + const initBytes = await offerReader.read(); + const initFields = verifiedHandshakeValueWithKeys( + initBytes, + expected.senderId, + senderBundles, + policy, + ); + if (initFields.length !== 8 || initFields[0] !== FS_INIT_DOMAIN) { + throw sessionError("forward-secure init message is malformed"); + } + const sessionId = asBytes(initFields[1], "sessionId"); + const checked = validateSessionParameters({ ...expected, sessionId }); + validateFsCommon(initFields, checked, FS_INIT_DOMAIN, 8); + const nonce = asBytes(initFields[7], "nonce"); + if (nonce.length !== 32) + throw sessionError("forward-secure nonce is not 32 bytes"); + + const ephemeral = bindings.wasm_kem_generate_keypair(); + try { + const responseBytes = signHandshakeValue( + fsResponseValue( + checked, + handshakeHash([initBytes]), + ephemeral.public_key, + ), + checked.recipientId, + recipientKeys, + suite, + ); + await writeSessionOffer(stream, responseBytes); + const finishBytes = await offerReader.read(); + const finishFields = verifiedHandshakeValueWithKeys( + finishBytes, + checked.senderId, + senderBundles, + policy, + ); + validateFsCommon(finishFields, checked, FS_FINISH_DOMAIN, 9); + const responseHash = handshakeHash([responseBytes]); + const receivedResponseHash = asBytes(finishFields[7], "responseHash"); + if ( + receivedResponseHash.length !== responseHash.length || + receivedResponseHash.some((byte, index) => byte !== responseHash[index]) + ) { + throw sessionError( + "forward-secure handshake response transcript mismatch", + ); + } + const sharedSecret = bindings.wasm_kem_decapsulate( + ephemeral.secret_key, + asBytes(finishFields[8], "ciphertext"), + ); + const handshakeTranscript = handshakeHash([ + initBytes, + responseBytes, + finishBytes, + ]); + const recordKey = bindings.wasm_hkdf_expand( + sharedSecret, + handshakeTranscript, + FS_ROOT_INFO, + 32, + ); + try { + return new MTPEncryptedPipeReader( + stream, + recordKey, + forwardSecureContext(checked, handshakeTranscript), + offerReader.remainder(), + ); + } finally { + sharedSecret.fill(0); + recordKey.fill(0); + } + } catch (error) { + if (error instanceof MTPSignatureVerificationError) throw error; + throw sessionError("forward-secure pipe handshake failed", error); + } finally { + ephemeral.free(); + } +} + +/** Encrypts ordered records on top of a negotiated MTP pipe. */ +export class MTPEncryptedPipeWriter { + readonly pipeId?: number; + private chainKey: Uint8Array; + private readonly context: MTPPipeProtectionContext; + private sequence = 0n; + private writeChain: Promise = Promise.resolve(); + private state: "open" | "finalized" | "failed" = "open"; + + constructor( + private readonly writer: MTPWritablePipe, + key: Uint8Array, + context: MTPPipeProtectionContext, + ) { + this.chainKey = keyBytes(key); + this.context = context; + this.pipeId = (writer as MTPPipeWriter).pipeId; + } + + get sequenceNumber(): bigint { + return this.sequence; + } + + writeRecord(plaintext: Uint8Array): Promise { + if (!(plaintext instanceof Uint8Array)) { + return Promise.reject(new TypeError("pipe record must be a Uint8Array")); + } + if (this.state !== "open") { + return Promise.reject( + new MTPEncryptedPipeError( + "state", + "encrypted pipe is no longer writable", + ), + ); + } + const input = plaintext.slice(); + const operation = this.writeChain.then(() => + this.writeRecordInternal(input), + ); + this.writeChain = operation + .catch((error) => { + this.poison(); + throw error; + }) + .catch(() => undefined); + return operation; + } + + private async writeRecordInternal( + plaintext: Uint8Array, + recordType = RECORD_TYPE_DATA, + ): Promise { + if (this.state !== "open") { + throw new MTPEncryptedPipeError( + "state", + "encrypted pipe is no longer writable", + ); + } + if (this.sequence === 0xffff_ffff_ffff_ffffn) { + throw new MTPEncryptedPipeError( + "sequence", + "encrypted pipe sequence exhausted", + ); + } + const encodedLength = recordLength(plaintext.length); + const { messageKey, nextChainKey } = deriveRecordKeys( + this.chainKey, + this.context, + this.sequence, + ); + let committed = false; + let ciphertext: Uint8Array; + try { + try { + const cipher = new bindings.WasmChaCha20Poly1305(messageKey); + try { + ciphertext = cipher.encrypt( + plaintext, + aad(this.context, this.sequence, encodedLength, recordType), + ); + } finally { + cipher.free(); + } + } catch (error) { + throw new MTPEncryptedPipeError( + "authentication", + "encrypted pipe record encryption failed", + { cause: error }, + ); + } + if (ciphertext.length !== encodedLength) { + throw new MTPEncryptedPipeError( + "record-length", + `encrypted pipe cipher returned ${ciphertext.length} bytes, expected ${encodedLength}`, + ); + } + const prefix = new Uint8Array(4); + new DataView(prefix.buffer).setUint32(0, encodedLength, false); + try { + await this.writer.write( + concatBytes([prefix, new Uint8Array([recordType]), ciphertext]), + ); + } catch (error) { + throw new MTPEncryptedPipeError("io", "encrypted pipe write failed", { + cause: error, + }); + } + this.chainKey.fill(0); + this.chainKey = nextChainKey; + this.sequence += 1n; + committed = true; + } finally { + messageKey.fill(0); + if (!committed) nextChainKey.fill(0); + } + } + + async close(): Promise { + if (this.state !== "open") { + throw new MTPEncryptedPipeError( + "state", + "encrypted pipe is no longer open", + ); + } + const operation = this.writeChain.then(async () => { + await this.writeRecordInternal(new Uint8Array(0), RECORD_TYPE_FINAL); + this.state = "finalized"; + try { + await this.writer.close(); + } catch (error) { + throw new MTPEncryptedPipeError("io", "encrypted pipe close failed", { + cause: error, + }); + } + }); + this.writeChain = operation + .catch((error) => { + this.poison(); + throw error; + }) + .catch(() => undefined); + await operation; + } + + abort(): void { + this.poison(); + this.writer.abort(); + } + + private poison(): void { + this.chainKey.fill(0); + this.state = "failed"; + } +} + +/** Reads and authenticates ordered records on top of a negotiated MTP pipe. */ +export class MTPEncryptedPipeReader { + private chainKey: Uint8Array; + private readonly context: MTPPipeProtectionContext; + private sequence = 0n; + private buffered = new Uint8Array(0); + private ended = false; + private readChain: Promise = Promise.resolve(); + private state: "open" | "finalized" | "failed" = "open"; + + constructor( + private readonly reader: MTPReadablePipe, + key: Uint8Array, + context: MTPPipeProtectionContext, + initialBuffer: Uint8Array = new Uint8Array(0), + ) { + this.chainKey = keyBytes(key); + this.context = context; + this.buffered = initialBuffer.slice(); + } + + get sequenceNumber(): bigint { + return this.sequence; + } + + private append(chunk: Uint8Array): void { + if (this.buffered.length + chunk.length > MAX_PIPE_BUFFER) { + throw new MTPEncryptedPipeError( + "record-length", + "encrypted pipe input buffer is too large", + ); + } + const combined = new Uint8Array(this.buffered.length + chunk.length); + combined.set(this.buffered); + combined.set(chunk, this.buffered.length); + this.buffered = combined; + } + + private async ensure(length: number): Promise { + while (this.buffered.length < length && !this.ended) { + let chunk: Uint8Array | null; + try { + chunk = await this.reader.read(); + } catch (error) { + throw new MTPEncryptedPipeError("io", "encrypted pipe read failed", { + cause: error, + }); + } + if (chunk == null) { + this.ended = true; + break; + } + if (!(chunk instanceof Uint8Array)) { + throw new MTPEncryptedPipeError( + "io", + "pipe reader returned a non-byte chunk", + ); + } + if (chunk.length > 0) this.append(chunk); + } + return this.buffered.length >= length; + } + + readRecord(): Promise { + if (this.state === "finalized") return Promise.resolve(null); + if (this.state === "failed") { + return Promise.reject( + new MTPEncryptedPipeError( + "state", + "encrypted pipe is no longer readable", + ), + ); + } + const operation = this.readChain.then(() => this.readRecordInternal()); + this.readChain = operation + .catch((error) => { + this.poison(); + throw error; + }) + .then( + () => undefined, + () => undefined, + ); + return operation; + } + + private async readRecordInternal(): Promise { + if (this.state !== "open") { + if (this.state === "finalized") return null; + throw new MTPEncryptedPipeError( + "state", + "encrypted pipe is no longer readable", + ); + } + if (this.sequence === 0xffff_ffff_ffff_ffffn) { + throw new MTPEncryptedPipeError( + "sequence", + "encrypted pipe sequence exhausted", + ); + } + if (!(await this.ensure(4))) { + throw new MTPEncryptedPipeError( + "truncated", + "encrypted pipe ended without an authenticated final record", + ); + } + const encodedLength = new DataView( + this.buffered.buffer, + this.buffered.byteOffset, + this.buffered.byteLength, + ).getUint32(0, false); + if ( + encodedLength < XCHACHA_OVERHEAD || + encodedLength > MAX_ENCRYPTED_PIPE_RECORD + ) { + throw new MTPEncryptedPipeError( + "record-length", + `invalid encrypted pipe record length: ${encodedLength}`, + ); + } + const totalLength = 5 + encodedLength; + if (!(await this.ensure(totalLength))) { + throw new MTPEncryptedPipeError( + "truncated", + "truncated encrypted pipe record", + ); + } + const recordType = this.buffered[4]; + if (recordType !== RECORD_TYPE_DATA && recordType !== RECORD_TYPE_FINAL) { + throw new MTPEncryptedPipeError( + "record-length", + `invalid encrypted pipe record type: ${recordType}`, + ); + } + const ciphertext = this.buffered.slice(5, totalLength); + this.buffered = this.buffered.slice(totalLength); + const { messageKey, nextChainKey } = deriveRecordKeys( + this.chainKey, + this.context, + this.sequence, + ); + let committed = false; + let plaintext: Uint8Array; + try { + try { + const cipher = new bindings.WasmChaCha20Poly1305(messageKey); + try { + plaintext = cipher.decrypt( + ciphertext, + aad(this.context, this.sequence, encodedLength, recordType), + ); + } finally { + cipher.free(); + } + } catch (error) { + throw new MTPEncryptedPipeError( + "authentication", + "encrypted pipe record authentication failed", + { cause: error }, + ); + } + this.chainKey.fill(0); + this.chainKey = nextChainKey; + this.sequence += 1n; + committed = true; + if (recordType === RECORD_TYPE_FINAL) { + if (plaintext.length !== 0) { + throw new MTPEncryptedPipeError( + "record-length", + "encrypted pipe final record must be empty", + ); + } + this.state = "finalized"; + return null; + } + return plaintext; + } finally { + messageKey.fill(0); + if (!committed) nextChainKey.fill(0); + } + } + + private poison(): void { + this.chainKey.fill(0); + this.state = "failed"; + } +} + +export type MTPEncryptedPipeWriterSource = MTPPipeWriter & MTPWritablePipe; +export type MTPEncryptedPipeReaderSource = MTPPipeReader & MTPReadablePipe; diff --git a/src/sdk/encrypted-secret.ts b/src/sdk/encrypted-secret.ts new file mode 100644 index 0000000..2c8f1ec --- /dev/null +++ b/src/sdk/encrypted-secret.ts @@ -0,0 +1,90 @@ +/** + * Encrypted secret material persisted for MTP cryptographic facilities. + * + * `id` is an opaque, MTP-owned or caller-derived identifier. The provider + * does not interpret it or infer an identity hierarchy from it. + */ +export interface MTPEncryptedSecretRecord { + id: string; + encryptedSecret: Uint8Array; + formatVersion: number; + wrappingScheme: string; + wrappingKeyId?: string; + createdAt: number; + updatedAt: number; +} + +export interface MTPEncryptedSecretProvider { + get(id: string): Promise; + set(record: MTPEncryptedSecretRecord): Promise; + delete(id: string): Promise; +} + +function requireId(id: string): string { + if (typeof id !== "string" || id.length === 0) { + throw new TypeError("encrypted secret id must be a non-empty string"); + } + return id; +} + +function validateTimestamp(value: number, name: string): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new TypeError(`${name} must be a non-negative safe integer`); + } +} + +function cloneRecord(record: MTPEncryptedSecretRecord): MTPEncryptedSecretRecord { + return { + ...record, + encryptedSecret: new Uint8Array(record.encryptedSecret), + }; +} + +function validateRecord(record: MTPEncryptedSecretRecord): void { + requireId(record.id); + if ( + !(record.encryptedSecret instanceof Uint8Array) || + record.encryptedSecret.length === 0 + ) { + throw new TypeError( + "encrypted secret requires non-empty encryptedSecret bytes", + ); + } + if (!Number.isSafeInteger(record.formatVersion) || record.formatVersion < 0) { + throw new TypeError( + "encrypted secret formatVersion must be a non-negative safe integer", + ); + } + if (typeof record.wrappingScheme !== "string" || !record.wrappingScheme) { + throw new TypeError("encrypted secret requires wrappingScheme"); + } + validateTimestamp(record.createdAt, "encrypted secret createdAt"); + validateTimestamp(record.updatedAt, "encrypted secret updatedAt"); + if ( + record.wrappingKeyId !== undefined && + (typeof record.wrappingKeyId !== "string" || !record.wrappingKeyId) + ) { + throw new TypeError("encrypted secret wrappingKeyId must be non-empty"); + } +} + +/** A small reference implementation for callers that need local persistence. */ +export class InMemoryEncryptedSecretProvider + implements MTPEncryptedSecretProvider +{ + private store = new Map(); + + async get(id: string): Promise { + const record = this.store.get(requireId(id)); + return record ? cloneRecord(record) : null; + } + + async set(record: MTPEncryptedSecretRecord): Promise { + validateRecord(record); + this.store.set(record.id, cloneRecord(record)); + } + + async delete(id: string): Promise { + this.store.delete(requireId(id)); + } +} diff --git a/src/sdk/index.ts b/src/sdk/index.ts index 9123c18..82325c8 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -6,27 +6,45 @@ import initWasm, { keyring_generate, } from "mtp/raw"; import * as bindings from "mtp/raw"; -import { utf8Encode } from "./utils.js"; +import { unixTimeMillis, utf8Encode } from "./utils.js"; import type * as RawBindings from "../raw/index"; import type { MTPCommunicationType } from "../type-map/index"; +import { RESERVED_COMMUNICATION_TYPE_IDS } from "../type-map/reserved.js"; import type { MTPSessionStorage, MTPSessionState } from "./session"; -import { - MTPSessionManager, - getConversationId, - deriveSessionKeys, -} from "./session.js"; +import { MTPSessionManager } from "./session.js"; import type { - EncryptedDeviceSecretRecord, - MTPEncryptedDeviceSecretProvider, -} from "./encrypted-device-secret"; -import { InMemoryEncryptedDeviceSecretProvider } from "./encrypted-device-secret.js"; + MTPEncryptedSecretRecord, + MTPEncryptedSecretProvider, +} from "./encrypted-secret"; +import { InMemoryEncryptedSecretProvider } from "./encrypted-secret.js"; import { InMemorySessionStorage } from "./session.js"; import { - parseEncryptedMessage, - encryptPayload, - decryptPayload, - FLAG_INIT, -} from "./encrypted-message.js"; + acceptMTPPipeSession, + acceptMTPPipeSessionAuto, + acceptMTPForwardSecurePipeSession, + initiateMTPForwardSecurePipeSession, + initiateMTPPipeSession, + MTPEncryptedPipeReader, + MTPEncryptedPipeWriter, + validateApplicationProtectionPurpose, +} from "./encrypted-pipe.js"; +import { + DEFAULT_SIGNATURE_VERIFICATION_POLICY, + MTPSignatureVerificationError, + resolveSignatureVerificationPolicy, + signatureVerificationPolicyValue, + signerKeysUnavailable, +} from "./signature-policy.js"; +import type { MTPSignatureVerificationPolicy } from "./signature-policy.js"; +export type { + MTPSignatureVerificationErrorCode, + MTPSignatureVerificationPolicy, +} from "./signature-policy.js"; +export { + DEFAULT_SIGNATURE_VERIFICATION_POLICY, + MTPSignatureVerificationError, + resolveSignatureVerificationPolicy, +} from "./signature-policy.js"; export type StorageValue = string | null; @@ -84,7 +102,9 @@ export interface MTPCrypto { sha256(data: Uint8Array): Uint8Array; sha256Double(data: Uint8Array): Uint8Array; keyringToKeys(keyring: string | MTPBytesInput): MTPKeyringKeys; - publicKeyBundleToKeys(publicKeyBundle: string | MTPBytesInput): MTPPublicKeyBundleKeys; + publicKeyBundleToKeys( + publicKeyBundle: string | MTPBytesInput, + ): MTPPublicKeyBundleKeys; encrypt(key: Uint8Array, input: Uint8Array): Promise; decrypt(key: Uint8Array, input: Uint8Array): Promise; encryptText(key: Uint8Array, plaintext: string): Promise; @@ -227,16 +247,12 @@ export const codec: MTPCodec = { export interface MTPCredentials { clientId: bigint | string | number | null; keyring: MTPBytesInput; - /** @deprecated Use keyring. Kept as a migration alias for existing callers. */ - keyringBytes?: MTPBytesInput; hostPublicKey?: MTPBytesInput | string; } export interface MTPClientCredentials { clientId: bigint | null; keyring: Uint8Array; - /** @deprecated Use keyring. Kept as a migration alias for existing callers. */ - keyringBytes: Uint8Array; hostPublicKey?: Uint8Array; } @@ -275,17 +291,540 @@ export interface MTPClientOptions { }; logger?: (event: MTPLogEvent) => void; sessionStorage?: MTPSessionStorage; - encryptedDeviceSecretProvider?: MTPEncryptedDeviceSecretProvider; + /** + * Independent caller-managed encrypted-secret storage. Session state is not + * routed through this provider automatically. + */ + encryptedSecretProvider?: MTPEncryptedSecretProvider; + /** Default receiver policy for protected signatures. */ + defaultSignatureVerificationPolicy?: MTPSignatureVerificationPolicy; } export type Unsubscribe = () => void; -export interface MTPSendOptions { +export interface MTPFrameIdOptions { id?: number; +} + +export interface MTPAddressedFrameOptions extends MTPFrameIdOptions { sender?: bigint | number; receiver?: bigint | number; } +export interface MTPSendOptions extends MTPAddressedFrameOptions {} + +export interface MTPProtectionIdentity { + signerId: bigint | number | string; + keyring: string | MTPBytesInput; +} + +/** + * Key material used to open protected MTP values. + * + * This identity is independent from transport authentication. Its optional + * ID is used only for structural destination checks when a receive operation + * supports one. + */ +export interface MTPDecryptionIdentity { + id?: bigint | number | string; + keyring: string | MTPBytesInput; + /** + * Previously used recipient keyrings, ordered newest to oldest. The + * current keyring is always attempted first. + */ + keyringHistory?: Array; +} + +/** Decoded value returned by the MTP DataValue codec. */ +export type MTPDataValue = RawBindings.ParsedDataValue; + +/** JavaScript values accepted by the MTP DataValue encoder. */ +export type MTPDataValueInput = + | null + | boolean + | number + | bigint + | string + | Uint8Array + | MTPDataValueInput[] + | { [key: string]: MTPDataValueInput }; + +/** Public-key material trusted for one protected signer identity. */ +export type MTPResolvedSignerKeys = Array; + +/** + * Resolve trusted public-key bundles for a claimed, unverified signer ID. + * The ID is used only as a trusted-key lookup key and becomes authenticated + * after the native protected codec verifies the signature. + */ +export type MTPSignerKeyResolver = ( + signerId: bigint, +) => MTPResolvedSignerKeys | Promise; + +export interface MTPRelayPlan { + nextHopId: bigint | number | string; + finalRecipientId: bigint | number | string; + metadataRecipients: Array; + contentRecipients: Array; + metadata?: MTPDataValueInput; +} + +export interface MTPSendProtectedOptions extends MTPFrameIdOptions { + receiverId: bigint | number | string; + identity?: MTPProtectionIdentity; + recipients: Array; + signaturePurpose: number; + encryptionPurpose: number; + signatureSuite?: MTPProtectionSignatureSuite; + exposeSender?: boolean; +} + +export interface MTPSendSealedRelayOptions extends MTPRelayPlan { + identity?: MTPProtectionIdentity; + signatureSuite?: MTPProtectionSignatureSuite; +} + +export interface MTPRelayVerificationOptions { + /** Key material used for protected opening, independent from transport auth. */ + recipient?: MTPDecryptionIdentity; + /** Require the protected signer to be this MTP identity. */ + expectedSignerId?: bigint | number | string; + /** Resolve trusted keys for a claimed, unverified signer lookup ID. */ + resolveSignerPublicKeys?: MTPSignerKeyResolver; + /** Receiver policy applied to relay metadata and content signatures. */ + signaturePolicy?: MTPSignatureVerificationPolicy; +} + +export interface MTPOpenRelayMetadataOptions extends MTPRelayVerificationOptions { + /** Consume one authenticated relay ID from a caller-owned store. */ + replayGuard?: MTPReplayGuard; +} + +export interface MTPOpenRelayContentOptions extends MTPRelayVerificationOptions { + /** Validate the authenticated final recipient when supplied. */ + expectedFinalRecipientId?: bigint | number | string; +} + +export interface MTPOpenProtectedOptions { + recipient?: MTPDecryptionIdentity; + + expectedSignerId?: bigint | number | string; + expectedReceiverId?: bigint | number | string; + + resolveSignerPublicKeys: MTPSignerKeyResolver; + + signaturePolicy?: MTPSignatureVerificationPolicy; + + signaturePurpose: number; + encryptionPurpose: number; + + /** Override the default process-local replay guard for durable storage. */ + replayGuard?: MTPReplayGuard; +} + +export interface MTPVerifiedProtectedMessage { + type: string; + + /** Authenticated direct-message envelope schema version. */ + protectedVersion: number; + + signerId: bigint; + + /** Authenticated destination from the protected envelope. */ + finalRecipientId: bigint; + + /** Authenticated application message identifier. */ + messageId: string; + + /** Authenticated Unix epoch timestamp in milliseconds. */ + createdAt: bigint; + + receiver?: bigint; + outerSender?: bigint; + + data: T; +} + +export type MTPProtectedFrameInput = ParsedFrame | MTPBytesInput; + +/** Options shared by the sealed-relay subscription APIs. */ +export interface MTPEncryptedSubscriptionOptions + extends MTPOpenRelayContentOptions { + /** Consume authenticated relay IDs while dispatching the subscription. */ + replayGuard?: MTPReplayGuard; +} + +export type MTPProtectionSignatureSuite = "ed25519" | "dual"; + +export interface MTPReplayGuard { + /** + * Return true and atomically record the pair when it has not been seen. + * `createdAt` is authenticated metadata for retention/observability; the + * replay identity is only `(signerId, messageId)`. + */ + accept( + signerId: bigint, + messageId: string, + createdAt: bigint, + ): boolean | Promise; +} + +/** + * Bounded process-local duplicate-suppression guard used by high-level direct + * protected and relay receives when the caller does not provide durable + * storage. Once the fixed cache is full, the oldest entry is evicted and may + * be accepted again; use a durable `MTPReplayGuard` for security-sensitive + * replay protection that must survive cache eviction, reloads, or multiple + * receiver processes. + */ +export class InMemoryReplayGuard implements MTPReplayGuard { + #accepted = new Set(); + readonly #capacity = 10_000; + + accept(signerId: bigint, messageId: string, _createdAt: bigint): boolean { + const key = `${signerId}:${messageId}`; + if (this.#accepted.has(key)) return false; + this.#accepted.add(key); + if (this.#accepted.size > this.#capacity) { + const oldest = this.#accepted.values().next().value; + if (oldest !== undefined) this.#accepted.delete(oldest); + } + return true; + } +} + +export class MTPReplayError extends Error { + readonly signerId: bigint; + readonly messageId: string; + + constructor(signerId: bigint, messageId: string) { + super(`message ${messageId} from signer ${signerId} was already accepted`); + this.name = "MTPReplayError"; + this.signerId = signerId; + this.messageId = messageId; + } +} + +export class MTPMissingRelayVersionError extends Error { + constructor() { + super("relay frame does not declare a relay version"); + this.name = "MTPMissingRelayVersionError"; + } +} + +export class MTPUnsupportedRelayVersionError extends Error { + readonly relayVersion: bigint; + + constructor(relayVersion: bigint) { + super(`unsupported relay version ${relayVersion}`); + this.name = "MTPUnsupportedRelayVersionError"; + this.relayVersion = relayVersion; + } +} + +export class MTPMissingProtectedVersionError extends Error { + constructor() { + super("protected message does not declare a protected version"); + this.name = "MTPMissingProtectedVersionError"; + } +} + +export class MTPUnsupportedProtectedVersionError extends Error { + readonly protectedVersion: bigint; + + constructor(protectedVersion: bigint) { + super(`unsupported protected message version ${protectedVersion}`); + this.name = "MTPUnsupportedProtectedVersionError"; + this.protectedVersion = protectedVersion; + } +} + +function relayOpeningError(error: unknown, signerId?: bigint): Error { + if (error !== null && typeof error === "object") { + const structured = error as { + code?: unknown; + relayVersion?: unknown; + }; + if (typeof structured.code === "string") { + switch (structured.code) { + case "missing-relay-version": + return new MTPMissingRelayVersionError(); + case "unsupported-relay-version": + if ( + typeof structured.relayVersion === "bigint" || + typeof structured.relayVersion === "number" || + typeof structured.relayVersion === "string" + ) { + return new MTPUnsupportedRelayVersionError( + inputU64(structured.relayVersion, "relayVersion"), + ); + } + break; + case "no-matching-recipient": + return new Error( + "Unable to decrypt protected value with supplied recipient keyrings", + ); + case "not-final-recipient": + return new Error( + "relay content is addressed to a different final recipient", + ); + case "reserved-application-type": + return new Error( + "relay application message type is reserved for MTP control", + ); + case "signature-policy-mismatch": + return new MTPSignatureVerificationError( + "policy-rejected", + signerId, + ); + case "unsupported-signature-suite": + return new MTPSignatureVerificationError( + "unsupported-suite", + signerId, + ); + case "invalid-signature": + return new MTPSignatureVerificationError( + "invalid-signature", + signerId, + ); + case "signer-id-mismatch": + return new Error("relay signer ID mismatch"); + case "purpose-mismatch": + return new Error("relay protection purpose mismatch"); + case "signer-key-not-found": + return signerKeysUnavailable(signerId); + case "replay": + return new Error("relay message was already accepted"); + } + } + } + return error instanceof Error ? error : new Error(String(error)); +} + +function protectedOpeningError(error: unknown, signerId?: bigint): Error { + if (error !== null && typeof error === "object") { + const structured = error as { + code?: unknown; + protectedVersion?: unknown; + }; + if (typeof structured.code === "string") { + switch (structured.code) { + case "missing-protected-version": + return new MTPMissingProtectedVersionError(); + case "unsupported-protected-version": + if ( + typeof structured.protectedVersion === "bigint" || + typeof structured.protectedVersion === "number" || + typeof structured.protectedVersion === "string" + ) { + return new MTPUnsupportedProtectedVersionError( + inputU64(structured.protectedVersion, "protectedVersion"), + ); + } + break; + case "no-matching-recipient": + return new Error( + "Unable to decrypt protected value with supplied recipient keyrings", + ); + case "reserved-application-type": + return new Error( + "MTP control communication types cannot be used as application content", + ); + case "signature-policy-mismatch": + return new MTPSignatureVerificationError( + "policy-rejected", + signerId, + ); + case "unsupported-signature-suite": + return new MTPSignatureVerificationError( + "unsupported-suite", + signerId, + ); + case "invalid-signature": + return new MTPSignatureVerificationError( + "invalid-signature", + signerId, + ); + case "signer-id-mismatch": + return new Error("protected signer ID mismatch"); + case "receiver-id-mismatch": + return new Error("protected frame receiver ID mismatch"); + case "message-type-mismatch": + return new Error( + "protected message type does not match outer routing", + ); + case "final-recipient-mismatch": + return new Error( + "protected final recipient does not match outer routing receiver", + ); + case "sender-id-mismatch": + return new Error( + "protected frame sender does not match authenticated signer", + ); + case "signer-key-not-found": + return signerKeysUnavailable(signerId); + case "replay": + return new Error("protected message was already accepted"); + } + } + } + return error instanceof Error ? error : new Error(String(error)); +} + +interface MTPRelayMetadataState { + frame: ParsedFrame; + native: RawBindings.WasmVerifiedRelayMetadata; + relayVersion: number; + signerId: bigint; + finalRecipientId: bigint; + messageId: string; + createdAt: bigint; + hasMetadata: boolean; + metadata?: MTPDataValue; + encryptedContent: Uint8Array; + signerPublicKeys: Uint8Array[]; + matchedSignerKeyIndex: number; + signaturePolicy: MTPSignatureVerificationPolicy; + disposed: boolean; + finalizerToken: object; +} + +const relayMetadataState = new WeakMap< + MTPVerifiedRelayMetadata, + MTPRelayMetadataState +>(); +const relayMetadataFinalizer = new FinalizationRegistry< + RawBindings.WasmVerifiedRelayMetadata +>((native) => { + try { + native.free(); + } catch { + // The WASM instance may already have been torn down during page unload. + } +}); +const RELAY_METADATA_TOKEN = Symbol("mtp-authenticated-relay-metadata"); + +/** + * Authenticated relay metadata produced only by `openRelayMetadata()`. + * + * The internal state is intentionally kept out of the public structural type + * so `openRelayContent()` cannot be fed a caller-fabricated "verified" + * object. Getters return immutable primitives or defensive byte copies. + */ +export class MTPVerifiedRelayMetadata { + constructor( + token: typeof RELAY_METADATA_TOKEN, + state: MTPRelayMetadataState, + ) { + if (token !== RELAY_METADATA_TOKEN) { + throw new Error( + "relay metadata must be created by authenticated opening", + ); + } + relayMetadataState.set(this, state); + } + + private get state(): MTPRelayMetadataState { + const state = relayMetadataState.get(this); + if (!state) + throw new Error("relay metadata authentication state is missing"); + if (state.disposed) throw new Error("relay metadata has been disposed"); + return state; + } + + /** Release the native verified metadata handle immediately. */ + dispose(): void { + const state = relayMetadataState.get(this); + if (!state || state.disposed) return; + state.disposed = true; + relayMetadataFinalizer.unregister(state.finalizerToken); + try { + state.native.free(); + } catch { + // The WASM instance may already have been torn down during page unload. + } + } + + /** Alias for callers that use the WASM resource naming convention. */ + free(): void { + this.dispose(); + } + + [Symbol.dispose](): void { + this.dispose(); + } + + get frame(): ParsedFrame { + return cloneParsedFrame(this.state.frame); + } + + get signerId(): bigint { + return this.state.signerId; + } + + /** Authenticated MTP relay metadata schema version. */ + get relayVersion(): number { + return this.state.relayVersion; + } + + get finalRecipientId(): bigint { + return this.state.finalRecipientId; + } + + get messageId(): string { + return this.state.messageId; + } + + /** Unix epoch milliseconds from the authenticated relay metadata. */ + get createdAt(): bigint { + return this.state.createdAt; + } + + get metadata(): MTPDataValue | undefined { + return this.state.hasMetadata + ? (cloneParsedValue(this.state.metadata) as MTPDataValue) + : undefined; + } + + get encryptedContent(): Uint8Array { + return this.state.encryptedContent.slice(); + } + + /** Trusted public-key history used for this verification. */ + get signerPublicKeys(): Uint8Array[] { + return this.state.signerPublicKeys.map((bundle) => bundle.slice()); + } + + /** Index of the trusted key that authenticated this metadata. */ + get matchedSignerKeyIndex(): number { + return this.state.matchedSignerKeyIndex; + } + + /** The exact trusted public-key bundle that authenticated this metadata. */ + get matchedSignerPublicKey(): Uint8Array { + const key = this.state.signerPublicKeys[this.state.matchedSignerKeyIndex]; + if (!key) { + throw new Error("relay verification matched an unavailable signer key"); + } + return key.slice(); + } + + get signaturePolicy(): MTPSignatureVerificationPolicy { + return this.state.signaturePolicy; + } +} + +export interface MTPVerifiedRelayContent { + type: string; + data: MTPDataValue; + signerId: bigint; + finalRecipientId: bigint; + messageId: string; + /** Unix epoch milliseconds from the authenticated relay metadata. */ + createdAt: bigint; + metadata?: MTPDataValue; +} + export interface MTPRequestOptions extends MTPSendOptions { responseType?: MTPCommunicationType; timeoutMs?: number; @@ -315,12 +854,28 @@ export interface MTPOutgoingPipeHandle { wait(): Promise; } -type InternalCredentials = Omit< - MTPCredentials, - "clientId" | "keyring" | "hostPublicKey" -> & { +export interface MTPCreateEncryptedPipeOptions { + recipientId: bigint | number | string; + recipientPublicKey?: string | MTPBytesInput; + recipientPublicKeys?: Array; + description?: string; + purpose?: number; + direction?: number; + signatureSuite?: MTPProtectionSignatureSuite; +} + +export interface MTPAcceptEncryptedPipeOptions { + senderId: bigint | number | string; + senderPublicKey?: string | MTPBytesInput; + senderPublicKeys?: Array; + purpose?: number; + direction?: number; + signaturePolicy?: MTPSignatureVerificationPolicy; +} + +type InternalCredentials = { clientId: bigint | null; - keyringBytes: Uint8Array; + keyring: Uint8Array; hostPublicKey?: Uint8Array; }; @@ -339,7 +894,175 @@ function createMessageId(): string { ); } -function emit(logger: MTPClientOptions["logger"] | undefined, event: MTPLogEvent): void { +function protectionSignatureSuiteValue( + suite: MTPProtectionSignatureSuite, +): number { + return suite === "dual" + ? bindings.mtp_protection_signature_suite_dual() + : bindings.mtp_protection_signature_suite_ed25519(); +} + +function effectiveProtectionSignatureSuite( + keyring: Uint8Array, + requested?: MTPProtectionSignatureSuite, +): MTPProtectionSignatureSuite { + const keys = keyringToKeys(keyring); + const hasPqPublicKey = keys.sigPqPublicKey.length > 0; + const hasPqSecretKey = keys.sigPqSecretKey.length > 0; + const suite = requested ?? "ed25519"; + if (suite !== "ed25519" && suite !== "dual") { + throw new Error("signatureSuite must be 'ed25519' or 'dual'"); + } + if (suite === "dual" && !(hasPqPublicKey && hasPqSecretKey)) { + throw new Error( + "dual protected signatures require a complete ML-DSA key pair; choose 'ed25519' for a partial keyring", + ); + } + return suite; +} + +interface ResolvedProtectionIdentity { + signerId: bigint; + keyring: Uint8Array; +} + +interface ResolvedDecryptionIdentity { + id?: bigint; + keyrings: Uint8Array[]; +} + +interface SignerResolutionOptions { + expectedSignerId?: bigint | number | string; + resolveSignerPublicKeys?: MTPSignerKeyResolver; +} + +function sameBytes(left: Uint8Array, right: Uint8Array): boolean { + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) return false; + } + return true; +} + +/** + * Normalize one recipient identity into current-first, duplicate-free keyring + * bytes. The returned arrays are owned by the SDK and never alias caller + * input. + */ +function normalizeDecryptionKeyrings( + identity: MTPDecryptionIdentity, +): Uint8Array[] { + const current = normalizeBytes(identity.keyring, "recipient.keyring"); + if (current.length === 0) { + throw new Error("recipient.keyring must not be empty"); + } + + if ( + identity.keyringHistory !== undefined && + !Array.isArray(identity.keyringHistory) + ) { + throw new TypeError("recipient.keyringHistory must be an array"); + } + + const keyrings: Uint8Array[] = []; + const add = (value: string | MTPBytesInput, name: string): void => { + const bytes = normalizeBytes(value, name); + if (bytes.length === 0) { + throw new Error(`${name} must not be empty`); + } + if (!keyrings.some((existing) => sameBytes(existing, bytes))) { + keyrings.push(bytes.slice()); + } + }; + + add(current, "recipient.keyring"); + for (const [index, history] of ( + identity.keyringHistory ?? [] + ).entries()) { + add(history, `recipient.keyringHistory[${index}]`); + } + + if (keyrings.length === 0) { + throw new Error("recipient must contain at least one keyring"); + } + return keyrings; +} + +function normalizeRecipientBundles( + recipients: Array, + name: string, +): Uint8Array[] { + if (!Array.isArray(recipients) || recipients.length === 0) { + throw new TypeError(`${name} must contain at least one public key bundle`); + } + + return recipients.map((value, index) => { + const bundle = normalizeBytes(value, `${name}[${index}]`); + publicKeyBundleToKeys(bundle); + return bundle.slice(); + }); +} + +function resolveProtectionIdentity( + explicit: MTPProtectionIdentity | undefined, + stored: InternalCredentials | null, +): ResolvedProtectionIdentity { + if (explicit) { + return { + signerId: inputU64(explicit.signerId, "identity.signerId"), + keyring: normalizeBytes(explicit.keyring, "identity.keyring").slice(), + }; + } + + if (stored?.clientId != null && stored.keyring.length > 0) { + return { + signerId: stored.clientId, + keyring: stored.keyring.slice(), + }; + } + + throw new Error( + "protected send requires an explicit protection identity or stored registered credentials", + ); +} + +function resolveDecryptionIdentity( + explicit: MTPDecryptionIdentity | undefined, + stored: InternalCredentials | null, +): ResolvedDecryptionIdentity { + if (explicit) { + return { + id: + explicit.id == null + ? undefined + : inputU64(explicit.id, "recipient.id"), + keyrings: normalizeDecryptionKeyrings(explicit), + }; + } + + if (stored?.clientId != null && stored.keyring.length > 0) { + return { + id: stored.clientId, + keyrings: normalizeDecryptionKeyrings({ + id: stored.clientId, + keyring: stored.keyring, + }), + }; + } + + throw new Error( + "protected receive requires an explicit decryption identity or stored registered credentials", + ); +} + +const KEM_PUBLIC_KEY_LEN = 1216; +const SIG_PQ_PUBLIC_KEY_LEN = 1952; +const SIG_CL_PUBLIC_KEY_LEN = 32; + +function emit( + logger: MTPClientOptions["logger"] | undefined, + event: MTPLogEvent, +): void { if (typeof logger === "function") { logger(event); } @@ -363,8 +1086,214 @@ function isErrorType(type: string): boolean { ); } -function errorMessage(frame: Pick | null | undefined): string { - const data = frame?.data ?? {}; +function parsedDataObject( + data: ParsedFrame["data"] | null | undefined, +): Record { + if ( + data === null || + typeof data !== "object" || + Array.isArray(data) || + data instanceof Uint8Array + ) { + return {}; + } + + const object = data as Record; + if (object.kind === "encrypted" || object.kind === "signed") { + return {}; + } + return object; +} + +function parseProtectedFrame(frame: MTPProtectedFrameInput): ParsedFrame { + if (isBytes(frame)) { + return bindings.parse_frame(bytesFrom(frame, "frame")); + } + if ( + frame === null || + typeof frame !== "object" || + typeof frame.type !== "string" + ) { + throw new TypeError("frame must be a parsed MTP frame or serialized bytes"); + } + if (frame.raw instanceof Uint8Array) { + return bindings.parse_frame(frame.raw); + } + return frame; +} + +function assertKnownCommunicationType(frame: ParsedFrame): void { + if (!frame.type || /^[0-9]+$/.test(frame.type)) { + throw new Error(`Unknown communication type: ${frame.type || "unknown"}`); + } + try { + bindings.build_frame(frame.type, null, {}); + } catch (error) { + throw new Error(`Unknown communication type: ${frame.type}`, { + cause: error, + }); + } +} + +function protectedFrameBytes(frame: ParsedFrame): Uint8Array { + if (frame.raw instanceof Uint8Array) return frame.raw.slice(); + const data = + frame.data !== null && + typeof frame.data === "object" && + !Array.isArray(frame.data) && + !(frame.data instanceof Uint8Array) + ? (frame.data as Record) + : null; + const encoded = data?.encoded; + if (data?.kind !== "encrypted" || !(encoded instanceof Uint8Array)) { + throw new Error("protected frame payload is not encrypted"); + } + return bindings.build_frame_with_payload(frame.type, encoded, { + id: frame.id, + ...(frame.sender == null ? {} : { sender: frame.sender }), + ...(frame.receiver == null ? {} : { receiver: frame.receiver }), + }); +} + +function assertApplicationCommunicationType(type: string): string { + if (typeof type !== "string" || !type.trim() || /^[0-9]+$/.test(type)) { + throw new Error(`Unknown communication type: ${type || "unknown"}`); + } + if (Object.prototype.hasOwnProperty.call(RESERVED_COMMUNICATION_TYPE_IDS, type)) { + throw new Error( + `MTP control communication type ${type} cannot be used as application content`, + ); + } + try { + bindings.build_frame(type, null, {}); + } catch (error) { + throw new Error(`Unknown communication type: ${type}`, { cause: error }); + } + return type; +} + +function cloneParsedValue(value: unknown): unknown { + if (value instanceof Uint8Array) return value.slice(); + if (Array.isArray(value)) return value.map(cloneParsedValue); + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [ + key, + cloneParsedValue(entry), + ]), + ); + } + return value; +} + +function cloneParsedFrame(frame: ParsedFrame): ParsedFrame { + return cloneParsedValue(frame) as ParsedFrame; +} + +function encodeMTPDataValue(value: unknown): Uint8Array { + const ancestors = new WeakSet(); + const validate = (candidate: unknown): void => { + if ( + candidate === null || + typeof candidate === "boolean" || + typeof candidate === "string" || + typeof candidate === "bigint" || + candidate instanceof Uint8Array + ) { + return; + } + if (typeof candidate === "number") { + if (Number.isInteger(candidate) && !Number.isSafeInteger(candidate)) { + throw new TypeError( + "unsafe integral MTP DataValue inputs must use bigint", + ); + } + return; + } + if (typeof candidate !== "object") { + throw new TypeError(`unsupported MTP DataValue input: ${typeof candidate}`); + } + const object = candidate as object; + if (ancestors.has(object)) { + throw new TypeError("MTP DataValue input must not be cyclic"); + } + if ( + !Array.isArray(candidate) && + Object.getPrototypeOf(candidate) !== Object.prototype && + Object.getPrototypeOf(candidate) !== null + ) { + throw new TypeError("MTP DataValue containers must be plain objects"); + } + ancestors.add(object); + const entries = Array.isArray(candidate) + ? candidate + : Object.values(candidate as Record); + for (const entry of entries) validate(entry); + ancestors.delete(object); + }; + + validate(value); + return bindings.encode_data_value(value); +} + +function valueAsBigInt(value: unknown, name: string): bigint { + try { + if (typeof value === "bigint") return value; + if (typeof value === "number" && Number.isSafeInteger(value)) { + return BigInt(value); + } + if (typeof value === "string" && value.length > 0) { + return BigInt(value); + } + } catch { + // Normalize all malformed protected metadata to one caller-facing error. + } + throw new Error(`protected metadata field ${name} is not an integer`); +} + +function valueAsUnsignedBigInt(value: unknown, name: string): bigint { + const result = valueAsBigInt(value, name); + if (result < 0n) { + throw new Error(`protected metadata field ${name} must be unsigned`); + } + return result; +} + +function valueAsU64BigInt(value: unknown, name: string): bigint { + const result = valueAsUnsignedBigInt(value, name); + if (result > 0xffff_ffff_ffff_ffffn) { + throw new Error(`protected metadata field ${name} is outside u64 range`); + } + return result; +} + +function inputU64(value: bigint | number | string, name: string): bigint { + if (typeof value === "number" && !Number.isSafeInteger(value)) { + throw new RangeError( + `${name} must be a safe integer number, bigint, or integer string`, + ); + } + let result: bigint; + try { + result = BigInt(value); + } catch (error) { + throw new RangeError(`${name} must be an integer`, { cause: error }); + } + if (result < 0n || result > 0xffff_ffff_ffff_ffffn) { + throw new RangeError(`${name} must be a u64`); + } + return result; +} + +function valueAsString(value: unknown, name: string): string { + if (typeof value === "string" && value.length > 0) return value; + throw new Error(`protected metadata field ${name} is not a non-empty string`); +} + +function errorMessage( + frame: Pick | null | undefined, +): string { + const data = parsedDataObject(frame?.data); return String( data.ErrorMessage ?? data.Error ?? @@ -373,17 +1302,27 @@ function errorMessage(frame: Pick | null | undefin ); } -async function storageGet(storage: MTPCredentialStorage | undefined, key: string): Promise { +async function storageGet( + storage: MTPCredentialStorage | undefined, + key: string, +): Promise { return storage ? await storage.getItem(key) : null; } -async function storageSet(storage: MTPCredentialStorage | undefined, key: string, value: string): Promise { +async function storageSet( + storage: MTPCredentialStorage | undefined, + key: string, + value: string, +): Promise { if (storage) { await storage.setItem(key, value); } } -async function storageRemove(storage: MTPCredentialStorage | undefined, key: string): Promise { +async function storageRemove( + storage: MTPCredentialStorage | undefined, + key: string, +): Promise { if (storage) { await storage.removeItem(key); } @@ -551,14 +1490,19 @@ export function secretKeyFromString(secret: string): Uint8Array { ); } -function normalizeBytes(value: string | MTPBytesInput, name: string): Uint8Array { +function normalizeBytes( + value: string | MTPBytesInput, + name: string, +): Uint8Array { if (typeof value === "string") { return bytesFromString(value, name); } return bytesFrom(value, name); } -function normalizeCredentials(value: MTPCredentials | string | null): MTPCredentials | null { +function normalizeCredentials( + value: MTPCredentials | string | null, +): MTPCredentials | null { if (!value) { return null; } @@ -570,11 +1514,13 @@ function normalizeCredentials(value: MTPCredentials | string | null): MTPCredent return value; } -function toBigInt(value: bigint | string | number | null | undefined): bigint | null { +function toBigInt( + value: bigint | string | number | null | undefined, +): bigint | null { if (value == null || value === "") { return null; } - return typeof value === "bigint" ? value : BigInt(value); + return inputU64(value, "clientId"); } function generateKeyringBytes() { @@ -593,14 +1539,20 @@ export function keyringToKeys(keyring: string | MTPBytesInput): MTPKeyringKeys { let offset = 0; const readKey = () => { + if (offset + 2 > bytes.length) { + throw new TypeError("keyring is truncated"); + } const len = (bytes[offset] << 8) | bytes[offset + 1]; offset += 2; + if (offset + len > bytes.length) { + throw new TypeError("keyring is truncated"); + } const key = bytes.slice(offset, offset + len); offset += len; return key; }; - return { + const result = { kemPublicKey: readKey(), kemSecretKey: readKey(), sigPqPublicKey: readKey(), @@ -608,16 +1560,24 @@ export function keyringToKeys(keyring: string | MTPBytesInput): MTPKeyringKeys { sigClPublicKey: readKey(), sigClSecretKey: readKey(), }; + if (offset !== bytes.length) { + throw new TypeError("keyring has trailing data"); + } + return result; } -export function publicKeyBundleToKeys(publicKeyBundle: string | MTPBytesInput): MTPPublicKeyBundleKeys { +export function publicKeyBundleToKeys( + publicKeyBundle: string | MTPBytesInput, +): MTPPublicKeyBundleKeys { const bytes = typeof publicKeyBundle === "string" ? bytesFromString(publicKeyBundle, "publicKeyBundle") : bytesFrom(publicKeyBundle, "publicKeyBundle"); if (bytes.length < 6) { - throw new TypeError("public key bundle data is too short to contain 3 keys"); + throw new TypeError( + "public key bundle data is too short to contain 3 keys", + ); } let offset = 0; @@ -645,13 +1605,21 @@ export function publicKeyBundleToKeys(publicKeyBundle: string | MTPBytesInput): throw new TypeError("public key bundle has trailing data"); } + if ( + result.kemPublicKey.length !== KEM_PUBLIC_KEY_LEN || + result.sigPqPublicKey.length !== SIG_PQ_PUBLIC_KEY_LEN || + result.sigClPublicKey.length !== SIG_CL_PUBLIC_KEY_LEN + ) { + throw new TypeError("public key bundle contains invalid suite key lengths"); + } + return result; } function serializeCredentials(credentials) { return JSON.stringify({ clientId: credentials.clientId?.toString() ?? null, - keyring: Array.from(credentials.keyringBytes ?? []), + keyring: Array.from(credentials.keyring ?? []), hostPublicKey: credentials.hostPublicKey ? Array.from(credentials.hostPublicKey) : undefined, @@ -664,14 +1632,14 @@ function deserializeCredentials(credentials) { return null; } - const keyring = normalized.keyring ?? normalized.keyringBytes; + const keyring = normalized.keyring; if (!isBytes(keyring)) { throw new TypeError("credentials.keyring must be a Uint8Array or number[]"); } return { clientId: toBigInt(normalized.clientId), - keyringBytes: bytesFrom(keyring, "credentials.keyring"), + keyring: bytesFrom(keyring, "credentials.keyring"), hostPublicKey: normalized.hostPublicKey == null ? undefined @@ -685,8 +1653,7 @@ function publicCredentials(credentials) { } return { clientId: credentials.clientId, - keyring: credentials.keyringBytes, - keyringBytes: credentials.keyringBytes, + keyring: credentials.keyring, hostPublicKey: credentials.hostPublicKey, }; } @@ -701,6 +1668,10 @@ function validateOptions(options) { if (options.descriptor != null && typeof options.descriptor !== "string") { throw new TypeError("descriptor must be a string"); } + resolveSignatureVerificationPolicy( + undefined, + options.defaultSignatureVerificationPolicy, + ); if (options.storage) { for (const method of ["getItem", "setItem", "removeItem"]) { if (typeof options.storage[method] !== "function") { @@ -730,21 +1701,32 @@ function validateOptions(options) { } } -async function withTimeout(promise, timeoutMs, message) { +async function withTimeout(promise, timeoutMs, message, cancel?: () => void) { if (!timeoutMs) { return await promise; } let timeoutId; + let timedOut = false; try { return await Promise.race([ promise, new Promise((_resolve, reject) => { - timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs); + timeoutId = setTimeout(() => { + timedOut = true; + cancel?.(); + reject(new Error(message)); + }, timeoutMs); }), ]); } finally { clearTimeout(timeoutId); + // A JS Promise.race cannot cancel the losing WASM future. Wait for the + // raw attempt to observe disconnect() before its borrowed wasm arguments + // are released by the caller's finally block. + if (timedOut) { + await promise.catch(() => undefined); + } } } @@ -754,13 +1736,15 @@ export class MTPClient { #credentials: InternalCredentials | null; #options: NormalizedMTPClientOptions; + readonly #protectedReplayGuard = new InMemoryReplayGuard(); readonly raw: MTPRaw; readonly crypto = MTPClient.crypto; readonly codec = MTPClient.codec; readonly sessionManager: MTPSessionManager; - readonly encryptedDeviceSecretProvider: MTPEncryptedDeviceSecretProvider; + /** Independent encrypted-secret storage selected by the caller. */ + readonly encryptedSecretProvider: MTPEncryptedSecretProvider; private constructor( options: NormalizedMTPClientOptions, @@ -769,9 +1753,8 @@ export class MTPClient { this.#options = options; this.#credentials = deserializeCredentials(options.credentials); this.raw = { client, bindings }; - this.encryptedDeviceSecretProvider = - options.encryptedDeviceSecretProvider ?? - new InMemoryEncryptedDeviceSecretProvider(); + this.encryptedSecretProvider = + options.encryptedSecretProvider ?? new InMemoryEncryptedSecretProvider(); this.sessionManager = new MTPSessionManager( options.sessionStorage ?? new InMemorySessionStorage(), ); @@ -815,7 +1798,7 @@ export class MTPClient { if (!sdk.#credentials) { sdk.#credentials = { clientId: null, - keyringBytes: generateKeyringBytes(), + keyring: generateKeyringBytes(), hostPublicKey: normalizedOptions.hostPublicKey, }; } else if ( @@ -853,6 +1836,13 @@ export class MTPClient { return publicCredentials(this.#credentials); } + get defaultSignatureVerificationPolicy(): MTPSignatureVerificationPolicy { + return resolveSignatureVerificationPolicy( + undefined, + this.#options.defaultSignatureVerificationPolicy, + ); + } + get state(): RawBindings.ConnectionState { return this.raw.client.state; } @@ -893,14 +1883,22 @@ export class MTPClient { return; } + await this.connectUnauthenticated(); + } + + async connectUnauthenticated(): Promise { const config = this.#connectionConfig(); try { await withTimeout( this.raw.client.connect(config), this.#options.authTimeoutMs, "connection timed out", + () => this.raw.client.disconnect(), ); - this.#startPings(0n); + const clientId = ( + this.raw.client as RawBindings.WasmClient & { readonly client_id: bigint } + ).client_id; + this.#startPings(clientId); } finally { config.free(); } @@ -922,7 +1920,7 @@ export class MTPClient { ); } if ( - !this.#credentials?.keyringBytes?.length || + !this.#credentials?.keyring?.length || this.#credentials.clientId == null ) { throw new Error( @@ -936,11 +1934,12 @@ export class MTPClient { this.raw.client.auth_connect( config, this.#options.hostPublicKey, - this.#credentials.keyringBytes, + this.#credentials.keyring, this.#credentials.clientId, ), this.#options.authTimeoutMs, "authentication timed out", + () => this.raw.client.disconnect(), ); this.#credentials = { ...this.#credentials, clientId }; await this.#persistCredentials(); @@ -955,10 +1954,10 @@ export class MTPClient { if (!this.#options.hostPublicKey) { throw new Error("MTPClient.register requires hostPublicKey"); } - if (!this.#credentials?.keyringBytes?.length) { + if (!this.#credentials?.keyring?.length) { this.#credentials = { clientId: null, - keyringBytes: generateKeyringBytes(), + keyring: generateKeyringBytes(), hostPublicKey: this.#options.hostPublicKey, }; } @@ -969,10 +1968,11 @@ export class MTPClient { this.raw.client.auth_register( config, this.#options.hostPublicKey, - this.#credentials.keyringBytes, + this.#credentials.keyring, ), this.#options.authTimeoutMs, "authentication timed out", + () => this.raw.client.disconnect(), ); this.#credentials = { ...this.#credentials, clientId }; await this.#persistCredentials(); @@ -1069,6 +2069,24 @@ export class MTPClient { await this.raw.client.send(message); } + /** Re-route an opaque sealed relay payload without opening it. */ + forwardRelayFrame( + frame: Uint8Array | ParsedFrame, + nextHopId: bigint | number | string, + ): Uint8Array { + const raw = frame instanceof Uint8Array ? frame : frame.raw; + const nextHop = inputU64(nextHopId, "nextHopId"); + return bindings.forward_encrypted_relay_frame(raw, nextHop); + } + + /** Forward an opaque sealed relay frame through this client connection. */ + async forwardRelay( + frame: Uint8Array | ParsedFrame, + nextHopId: bigint | number | string, + ): Promise { + await this.send(this.forwardRelayFrame(frame, nextHopId)); + } + async request( message: Uint8Array, data?: never, @@ -1084,7 +2102,8 @@ export class MTPClient { data?: Record, options: MTPRequestOptions = {}, ): Promise { - const timeoutMs = options.timeoutMs ?? this.#options.requestTimeoutMs ?? 30_000; + const timeoutMs = + options.timeoutMs ?? this.#options.requestTimeoutMs ?? 30_000; if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) { throw new TypeError("request timeoutMs must be a positive safe integer"); } @@ -1116,10 +2135,13 @@ export class MTPClient { direction: "send", }); } - return await withTimeout( - this.raw.client.request(frame, options.responseType ?? null, timeoutMs), + // The WASM client owns request expiry and its late-response tombstones. + // Keeping a second Promise timer here can reject the SDK call while the + // protocol request is still allowed to complete successfully. + return await this.raw.client.request( + frame, + options.responseType ?? null, timeoutMs, - `request timed out after ${timeoutMs}ms`, ); } @@ -1157,290 +2179,459 @@ export class MTPClient { } #getKemPublicKey(): Uint8Array { - if (!this.#credentials?.keyringBytes?.length) { + if (!this.#credentials?.keyring?.length) { throw new Error("No keyring available"); } - const keys = keyringToKeys(this.#credentials.keyringBytes); + const keys = keyringToKeys(this.#credentials.keyring); return keys.kemPublicKey; } #getKemSecretKey(): Uint8Array { - if (!this.#credentials?.keyringBytes?.length) { + if (!this.#credentials?.keyring?.length) { throw new Error("No keyring available"); } - const keys = keyringToKeys(this.#credentials.keyringBytes); + const keys = keyringToKeys(this.#credentials.keyring); return keys.kemSecretKey; } - async sendEncrypted( - type: number | string, - data: Record, - options: MTPSendOptions & { - recipientClientId: bigint | number | string; - recipientPublicKey: string | MTPBytesInput; - senderUserId?: string; - recipientUserId?: string; - recipientDeviceId?: string; - }, - ): Promise { - const ownId = this.#credentials?.clientId; - if (ownId == null) { - throw new Error("Client not registered"); + #getPublicKeyBundleBytes(): Uint8Array { + if (!this.#credentials?.keyring?.length) { + throw new Error("No keyring available"); } - if (options.recipientClientId == null) { - throw new Error("recipientClientId is required"); - } - const recipientClientId = BigInt(options.recipientClientId); - - const plaintext = this.raw.bindings.build_frame(type as string, data, { - sender: ownId, - receiver: recipientClientId, - ...options, - }); - - let session = await this.sessionManager.getSession( - ownId, - recipientClientId, + const keyring = bindings.WasmKeyring.from_bytes( + this.#credentials.keyring, ); - let kemCiphertext: Uint8Array | undefined; - - if (!session) { - if (options.recipientPublicKey == null) { - throw new Error("recipientPublicKey is required for new encrypted sessions"); + try { + const bundle = keyring.public_key_bundle(); + try { + return bundle.to_bytes(); + } finally { + bundle.free(); } - const recipientPublicKey = publicKeyBundleToKeys( - options.recipientPublicKey, - ); - const enc = bindings.wasm_kem_encapsulate( - recipientPublicKey.kemPublicKey, - ); - kemCiphertext = enc.ciphertext; - const conversationId = getConversationId(ownId, recipientClientId); - - session = await this.sessionManager.createSession({ - ownClientId: ownId, - peerClientId: recipientClientId, - peerPublicKey: recipientPublicKey.kemPublicKey, - sharedSecret: enc.shared_secret, - role: "initiator", - transcriptContext: { - senderUserId: (options as { senderUserId?: string }).senderUserId, - senderClientId: ownId, - recipientUserId: options.recipientUserId, - recipientClientId, - recipientPublicKey: recipientPublicKey.kemPublicKey, - kemCiphertext, - conversationId, - }, - }); - enc.shared_secret.fill(0); - } - - const { payload, session: newSession } = await encryptPayload({ - plaintext, - session, - kemCiphertext, - }); - - await this.sessionManager.saveSession(newSession); - - const messageId = createMessageId(); - const createdAt = Date.now(); - const senderUserId = (options as { senderUserId?: string }).senderUserId; - const frame = this.raw.bindings.build_frame( - "EncryptedMessage", - { - MessageId: messageId, - ConversationId: session.conversationId, - SenderClientId: ownId, - RecipientClientId: recipientClientId, - SenderUserId: senderUserId, - RecipientUserId: options.recipientUserId, - CreatedAt: createdAt, - EncryptionVersion: 1, - EncryptedPayload: payload, - }, - { - sender: ownId, - receiver: recipientClientId, - }, - ); - await this.raw.client.send(frame); - - if (senderUserId && options.recipientUserId) { - const ownKemPublicKey = this.#getKemPublicKey(); - const archiveEnc = bindings.wasm_kem_encapsulate(ownKemPublicKey); - const archiveSession = await this.sessionManager.createSession({ - ownClientId: ownId, - peerClientId: ownId, - peerPublicKey: ownKemPublicKey, - sharedSecret: archiveEnc.shared_secret, - role: "initiator", - transcriptContext: { - senderUserId, - senderClientId: ownId, - recipientUserId: options.recipientUserId, - recipientClientId: ownId, - recipientPublicKey: ownKemPublicKey, - kemCiphertext: archiveEnc.ciphertext, - conversationId: `archive:${session.conversationId}:${messageId}`, - }, - }); - archiveEnc.shared_secret.fill(0); - const { payload: archivePayload } = await encryptPayload({ - plaintext, - session: archiveSession, - kemCiphertext: archiveEnc.ciphertext, - }); - const archiveFrame = this.raw.bindings.build_frame( - "EncryptedMessage", - { - MessageId: `${messageId}:sender`, - ConversationId: session.conversationId, - SenderClientId: ownId, - RecipientClientId: ownId, - SenderUserId: senderUserId, - RecipientUserId: options.recipientUserId, - CreatedAt: createdAt, - EncryptionVersion: 1, - EncryptedPayload: archivePayload, - }, - { - sender: ownId, - receiver: ownId, - }, - ); - await this.raw.client.send(archiveFrame); + } finally { + keyring.free(); } } - subscribeEncrypted( - type: number | string, - handler: (data: unknown, meta: ParsedFrame) => void | Promise, - ): Unsubscribe; - subscribeEncrypted( - handler: (data: { - type: string; - data: Record; - sender?: bigint; - receiver?: bigint; - }) => void | Promise, - ): Unsubscribe; - subscribeEncrypted( - typeOrHandler: - | number - | string - | ((data: { - type: string; - data: Record; - sender?: bigint; - receiver?: bigint; - }) => void | Promise), - maybeHandler?: (data: unknown, meta: ParsedFrame) => void | Promise, - ): Unsubscribe { - const expectedInnerType = - typeof typeOrHandler === "function" ? null : String(typeOrHandler); - const legacyHandler = - typeof typeOrHandler === "function" ? typeOrHandler : null; - const sub = this.raw.client.subscribe( - "EncryptedMessage", - async (frame: ParsedFrame) => { - const raw = frame.data?.["EncryptedPayload"]; - if (!raw) return; + #resolveDecryptionIdentity( + explicit?: MTPDecryptionIdentity, + ): ResolvedDecryptionIdentity { + return resolveDecryptionIdentity(explicit, this.#credentials); + } - let payloadBytes: Uint8Array; - if (raw instanceof Uint8Array) { - payloadBytes = raw; - } else if (Array.isArray(raw)) { - payloadBytes = new Uint8Array(raw); - } else { - return; - } + #assertExpectedSignerId( + signerId: bigint, + expectedSignerId: bigint | number | string | undefined, + ): void { + if ( + expectedSignerId != null && + inputU64(expectedSignerId, "expectedSignerId") !== signerId + ) { + throw new Error("protected signer ID mismatch"); + } + } + async #resolveSignerPublicKeys( + signerId: bigint, + options: SignerResolutionOptions, + ): Promise { + if (options.resolveSignerPublicKeys) { + let resolved: MTPResolvedSignerKeys; + try { + resolved = await options.resolveSignerPublicKeys(signerId); + } catch { + throw signerKeysUnavailable(signerId); + } + if (!Array.isArray(resolved) || resolved.length === 0) { + throw signerKeysUnavailable(signerId); + } + return resolved.map((value, index) => { try { - const parsed = parseEncryptedMessage(payloadBytes); + const bytes = normalizeBytes(value, `senderPublicKeys[${index}]`); + publicKeyBundleToKeys(bytes); + return bytes; + } catch { + throw signerKeysUnavailable(signerId); + } + }); + } + if (this.#credentials?.clientId === signerId) { + try { + const bytes = this.#getPublicKeyBundleBytes(); + publicKeyBundleToKeys(bytes); + return [bytes]; + } catch { + throw signerKeysUnavailable(signerId); + } + } + return null; + } - const ownId = this.#credentials?.clientId; - if (ownId == null) return; - if (parsed.recipientClientId !== ownId) return; + /** + * Decrypt and verify only relay metadata. The content remains opaque so a + * metadata-only relay participant can index or forward it without possessing + * a content-recipient key. + */ + async openRelayMetadata( + frame: ParsedFrame, + options: MTPOpenRelayMetadataOptions = {}, + ): Promise { + // The resolver is asynchronous. Keep an immutable byte snapshot so the + // signer claim and the later native verification refer to one frame. + const frameBytes = frame.raw.slice(); + const frameSnapshot = bindings.parse_frame(frameBytes); + const signaturePolicy = resolveSignatureVerificationPolicy( + options.signaturePolicy, + this.#options.defaultSignatureVerificationPolicy, + ); + const recipient = this.#resolveDecryptionIdentity(options.recipient); + let signerId: bigint; + try { + signerId = BigInt( + bindings.relay_metadata_claimed_signer_id( + frameBytes, + recipient.keyrings, + ), + ); + } catch (error) { + throw relayOpeningError(error); + } + this.#assertExpectedSignerId(signerId, options.expectedSignerId); + const signerBundles = await this.#resolveSignerPublicKeys( + signerId, + options, + ); + if (!signerBundles) { + throw signerKeysUnavailable(signerId); + } - const peerClientId = parsed.senderClientId; - let session = await this.sessionManager.getSession( - ownId, - peerClientId, + let native: RawBindings.WasmVerifiedRelayMetadata | undefined; + let ownershipTransferred = false; + try { + native = bindings.open_relay_metadata_with_keyrings( + frameBytes, + recipient.keyrings, + signerId, + signerBundles, + signatureVerificationPolicyValue(signaturePolicy), + ); + } catch (error) { + throw relayOpeningError(error, signerId); + } + + try { + if (!native) throw new Error("relay metadata opening returned no handle"); + const metadataBytes = native.metadata(); + const hasApplicationMetadata = metadataBytes != null; + const applicationMetadata = hasApplicationMetadata + ? (cloneParsedValue( + bindings.parse_data_value(metadataBytes), + ) as MTPDataValue) + : undefined; + const nativeSignerId = BigInt(native.signer_id()); + const messageId = native.message_id(); + const createdAt = BigInt(native.created_at()); + if (options.replayGuard) { + const accepted = await options.replayGuard.accept( + nativeSignerId, + messageId, + createdAt, + ); + if (!accepted) throw new MTPReplayError(nativeSignerId, messageId); + } + + const matchedSignerKeyIndex = Number(native.matched_signer_key_index()); + if ( + !Number.isSafeInteger(matchedSignerKeyIndex) || + matchedSignerKeyIndex < 0 || + matchedSignerKeyIndex >= signerBundles.length + ) { + throw new Error("relay verification returned an invalid key index"); + } + + const verified = new MTPVerifiedRelayMetadata(RELAY_METADATA_TOKEN, { + frame: frameSnapshot, + native, + relayVersion: Number(native.relay_version()), + signerId: nativeSignerId, + finalRecipientId: BigInt(native.final_recipient_id()), + messageId, + createdAt, + hasMetadata: hasApplicationMetadata, + metadata: applicationMetadata, + encryptedContent: native.encrypted_content().slice(), + signerPublicKeys: signerBundles.map((bundle) => bundle.slice()), + matchedSignerKeyIndex, + signaturePolicy, + disposed: false, + finalizerToken: {}, + }); + // Register only after the JS wrapper owns the native handle. The + // finally block below handles every failure before this transfer. + const state = relayMetadataState.get(verified); + if (!state) throw new Error("relay metadata state was not initialized"); + relayMetadataFinalizer.register(verified, native, state.finalizerToken); + ownershipTransferred = true; + return verified; + } finally { + if (!ownershipTransferred) { + try { + native?.free(); + } catch { + // Preserve the original opening or conversion failure. + } + } + } + } + + /** Open and verify content after relay metadata has been authenticated. */ + async openRelayContent( + metadata: MTPVerifiedRelayMetadata, + options: MTPOpenRelayContentOptions = {}, + ): Promise { + const recipient = this.#resolveDecryptionIdentity(options.recipient); + const state = relayMetadataState.get(metadata); + if (!state) { + throw new Error( + "relay metadata was not produced by authenticated opening", + ); + } + if (state.disposed) { + throw new Error("relay metadata has been disposed"); + } + const expectedFinalRecipientId = + options.expectedFinalRecipientId == null + ? recipient.id + : inputU64( + options.expectedFinalRecipientId, + "expectedFinalRecipientId", ); + // Content belongs to the authenticated metadata operation. Inherit its + // policy when no content override is supplied so a client default cannot + // split the two relay verification layers. + const signaturePolicy = resolveSignatureVerificationPolicy( + options.signaturePolicy, + state.signaturePolicy, + ); + if (signaturePolicy !== state.signaturePolicy) { + throw new MTPSignatureVerificationError( + "policy-rejected", + state.signerId, + ); + } - if (!session) { - if (!(parsed.flags & FLAG_INIT) || !parsed.kemCiphertext) { - return; - } + let signerBundles: Uint8Array[] = state.signerPublicKeys.map((bundle) => + bundle.slice(), + ); + this.#assertExpectedSignerId(state.signerId, options.expectedSignerId); + if (options.resolveSignerPublicKeys != null) { + const resolved = await this.#resolveSignerPublicKeys( + state.signerId, + options, + ); + if (state.disposed) { + throw new Error("relay metadata has been disposed"); + } + if (!resolved) { + throw signerKeysUnavailable(state.signerId); + } + signerBundles = resolved; + } - const ownKemSecret = this.#getKemSecretKey(); - const sharedSecret = bindings.wasm_kem_decapsulate( - ownKemSecret, - parsed.kemCiphertext, - ); + // A caller may dispose the capability while asynchronous signer-key + // resolution is in flight. Never hand a freed native handle back to + // wasm-bindgen after that await, even if the resolver returned no keys. + if (state.disposed) { + throw new Error("relay metadata has been disposed"); + } - session = await this.sessionManager.createSession({ - ownClientId: ownId, - peerClientId, - peerPublicKey: new Uint8Array(0), - sharedSecret, - role: "receiver", - transcriptContext: { - senderUserId: String( - frame.data?.["SenderUserId"] ?? - frame.data?.["senderUserId"] ?? - "", - ), - senderClientId: peerClientId, - recipientUserId: String( - frame.data?.["RecipientUserId"] ?? - frame.data?.["recipientUserId"] ?? - "", - ), - recipientClientId: ownId, - recipientPublicKey: this.#getKemPublicKey(), - kemCiphertext: parsed.kemCiphertext, - conversationId: getConversationId(peerClientId, ownId), - }, - }); - sharedSecret.fill(0); - } + let nativeContent: RawBindings.WasmVerifiedRelayContent; + try { + nativeContent = bindings.open_relay_content_with_keyrings( + state.native, + recipient.keyrings, + signerBundles, + expectedFinalRecipientId == null + ? null + : BigInt(expectedFinalRecipientId), + signatureVerificationPolicyValue(signaturePolicy), + ); + } catch (error) { + throw relayOpeningError(error, state.signerId); + } + try { + return this.#formatRelayContent(nativeContent, state); + } finally { + nativeContent.free(); + } + } - const { plaintext, session: newSession } = await decryptPayload({ - payload: payloadBytes, - session, - expectedRecipientClientId: ownId, - }); + #formatRelayContent( + nativeContent: RawBindings.WasmVerifiedRelayContent, + state: MTPRelayMetadataState, + ): MTPVerifiedRelayContent { + const data = bindings.parse_data_value(nativeContent.content()) as MTPDataValue; - await this.sessionManager.saveSession(newSession); + return { + type: nativeContent.message_type(), + data: cloneParsedValue(data) as MTPDataValue, + signerId: BigInt(nativeContent.signer_id()), + finalRecipientId: BigInt(nativeContent.final_recipient_id()), + messageId: state.messageId, + createdAt: state.createdAt, + metadata: + state.hasMetadata + ? (cloneParsedValue(state.metadata) as MTPDataValue) + : undefined, + }; + } - let parsedFrame: ParsedFrame; - try { - parsedFrame = this.raw.bindings.parse_frame(plaintext); - } catch { - return; - } + async #openProtectedFrame( + frameInput: MTPProtectedFrameInput, + options: MTPOpenProtectedOptions, + ): Promise<{ + frame: ParsedFrame; + message: MTPVerifiedProtectedMessage; + }> { + if (!options || typeof options !== "object") { + throw new TypeError("openProtected requires an options object"); + } + if (typeof options.resolveSignerPublicKeys !== "function") { + throw new TypeError("openProtected requires resolveSignerPublicKeys"); + } - if (expectedInnerType && parsedFrame.type !== expectedInnerType) { - return; - } - if (legacyHandler) { - await legacyHandler({ - type: parsedFrame.type, - data: parsedFrame.data, - sender: parsedFrame.sender, - receiver: parsedFrame.receiver, - }); - } else if (maybeHandler) { - await maybeHandler(parsedFrame.data, parsedFrame); - } - } catch (e) { + // Detach parsed-object inputs before awaiting signer-key resolution. This + // keeps the authenticated payload and routing fields bound to one + // snapshot even when a caller reuses or mutates its frame object. + const frame = cloneParsedFrame(parseProtectedFrame(frameInput)); + assertKnownCommunicationType(frame); + const frameBytes = protectedFrameBytes(frame); + validateApplicationProtectionPurpose(options.signaturePurpose); + validateApplicationProtectionPurpose(options.encryptionPurpose); + + const recipient = this.#resolveDecryptionIdentity(options.recipient); + const signaturePolicy = resolveSignatureVerificationPolicy( + options.signaturePolicy, + this.#options.defaultSignatureVerificationPolicy, + ); + const expectedReceiverId = + options.expectedReceiverId == null + ? recipient.id + : inputU64(options.expectedReceiverId, "expectedReceiverId"); + let signerId: bigint; + try { + signerId = BigInt( + bindings.protected_claimed_signer_id( + frameBytes, + recipient.keyrings, + options.encryptionPurpose, + ), + ); + } catch (error) { + throw protectedOpeningError(error); + } + this.#assertExpectedSignerId(signerId, options.expectedSignerId); + const signerBundles = await this.#resolveSignerPublicKeys(signerId, options); + if (!signerBundles) { + throw signerKeysUnavailable(signerId); + } + + let native: RawBindings.WasmVerifiedProtectedMessage; + try { + native = bindings.open_protected_with_keyrings( + frameBytes, + recipient.keyrings, + signerId, + signerBundles, + expectedReceiverId == null ? null : expectedReceiverId, + options.signaturePurpose, + options.encryptionPurpose, + signatureVerificationPolicyValue(signaturePolicy), + ); + } catch (error) { + throw protectedOpeningError(error, signerId); + } + + const replayGuard = options.replayGuard ?? this.#protectedReplayGuard; + const nativeSignerId = BigInt(native.signer_id()); + const messageId = native.message_id(); + const createdAt = BigInt(native.created_at()); + try { + const accepted = await replayGuard.accept( + nativeSignerId, + messageId, + createdAt, + ); + if (!accepted) throw new MTPReplayError(nativeSignerId, messageId); + + const data = bindings.parse_data_value(native.content()) as MTPDataValue; + const receiver = frame.receiver; + const outerSender = frame.sender; + return { + frame, + message: { + type: native.message_type(), + protectedVersion: Number(native.protected_version()), + signerId: nativeSignerId, + finalRecipientId: BigInt(native.final_recipient_id()), + messageId, + createdAt, + ...(receiver == null ? {} : { receiver }), + ...(outerSender == null ? {} : { outerSender }), + data: cloneParsedValue(data) as MTPDataValue, + }, + }; + } finally { + native.free(); + } + } + + async openProtected( + frame: MTPProtectedFrameInput, + options: MTPOpenProtectedOptions, + ): Promise> { + const opened = await this.#openProtectedFrame(frame, options); + return opened.message as MTPVerifiedProtectedMessage; + } + + subscribeProtected( + type: MTPCommunicationType, + handler: ( + message: MTPVerifiedProtectedMessage, + frame: ParsedFrame, + ) => void | Promise, + options: MTPOpenProtectedOptions, + ): Unsubscribe { + if (typeof type !== "string" || !type) { + throw new TypeError( + "protected subscription type must be a non-empty string", + ); + } + const applicationType = assertApplicationCommunicationType(type); + if (typeof handler !== "function") { + throw new TypeError("protected handler must be a function"); + } + const replayGuard = options.replayGuard ?? new InMemoryReplayGuard(); + const subscriptionOptions = { ...options, replayGuard }; + + const sub = this.raw.client.subscribe( + applicationType, + async (frame: ParsedFrame) => { + try { + const opened = await this.#openProtectedFrame( + frame, + subscriptionOptions, + ); + if (opened.message.type !== applicationType) return; + await handler( + opened.message as MTPVerifiedProtectedMessage, + opened.frame, + ); + } catch (error) { emit(this.#options.logger, { hint: "error", type: "E2EE", - error: String(e), + error: String(error), direction: "recv", }); } @@ -1450,123 +2641,252 @@ export class MTPClient { return () => this.raw.client.unsubscribe(sub); } - async decryptEncryptedRecord( - frameData: Record, - ): Promise { - const raw = frameData["EncryptedPayload"]; - if (!raw) throw new Error("EncryptedPayload is required"); - - const payloadBytes = - raw instanceof Uint8Array - ? raw - : Array.isArray(raw) - ? new Uint8Array(raw) - : bytesFrom(raw as MTPBytesInput, "EncryptedPayload"); - - const parsed = parseEncryptedMessage(payloadBytes); - const ownId = this.#credentials?.clientId; - if (ownId == null) throw new Error("Client not registered"); - if (parsed.recipientClientId !== ownId) { - throw new Error("Encrypted message recipient mismatch"); - } - - const peerClientId = parsed.senderClientId; - let session = await this.sessionManager.getSession(ownId, peerClientId); - - const isSenderArchive = - parsed.senderClientId === ownId && parsed.recipientClientId === ownId; - - if (isSenderArchive && (parsed.flags & FLAG_INIT) && parsed.kemCiphertext) { - const ownKemSecret = this.#getKemSecretKey(); - const sharedSecret = bindings.wasm_kem_decapsulate( - ownKemSecret, - parsed.kemCiphertext, - ); - const archiveMessageId = String( - frameData["MessageId"] ?? frameData["messageId"] ?? "", - ).replace(/:sender$/, ""); - session = await this.sessionManager.createSession({ - ownClientId: ownId, - peerClientId: ownId, - peerPublicKey: this.#getKemPublicKey(), - sharedSecret, - role: "receiver", - transcriptContext: { - senderUserId: String( - frameData["SenderUserId"] ?? frameData["senderUserId"] ?? "", - ), - senderClientId: ownId, - recipientUserId: String( - frameData["RecipientUserId"] ?? frameData["recipientUserId"] ?? "", - ), - recipientClientId: ownId, - recipientPublicKey: this.#getKemPublicKey(), - kemCiphertext: parsed.kemCiphertext, - conversationId: `archive:${String(frameData["ConversationId"] ?? frameData["conversationId"] ?? "")}:${archiveMessageId}`, - }, - }); - sharedSecret.fill(0); - } else if (!session) { - if (!(parsed.flags & FLAG_INIT) || !parsed.kemCiphertext) { - throw new Error("No session for non-init encrypted message"); - } - const ownKemSecret = this.#getKemSecretKey(); - const sharedSecret = bindings.wasm_kem_decapsulate( - ownKemSecret, - parsed.kemCiphertext, - ); - - session = await this.sessionManager.createSession({ - ownClientId: ownId, - peerClientId, - peerPublicKey: new Uint8Array(0), - sharedSecret, - role: "receiver", - transcriptContext: { - senderUserId: String( - frameData["SenderUserId"] ?? frameData["senderUserId"] ?? "", - ), - senderClientId: peerClientId, - recipientUserId: String( - frameData["RecipientUserId"] ?? frameData["recipientUserId"] ?? "", - ), - recipientClientId: ownId, - recipientPublicKey: this.#getKemPublicKey(), - kemCiphertext: parsed.kemCiphertext, - conversationId: getConversationId(peerClientId, ownId), - }, - }); - sharedSecret.fill(0); - } - - const { plaintext, session: newSession } = await decryptPayload({ - payload: payloadBytes, - session, - expectedRecipientClientId: ownId, - }); - if (!isSenderArchive) { - await this.sessionManager.saveSession(newSession); - } - return this.raw.bindings.parse_frame(plaintext); - } - - async setEncryptedDeviceSecret( - record: EncryptedDeviceSecretRecord, + async sendProtected( + type: MTPCommunicationType, + data: MTPDataValueInput, + options: MTPSendProtectedOptions, ): Promise { - await this.encryptedDeviceSecretProvider.setEncryptedDeviceSecret(record); + const messageType = assertApplicationCommunicationType(type); + const identity = resolveProtectionIdentity( + options.identity, + this.#credentials, + ); + const receiverId = inputU64(options.receiverId, "receiverId"); + const recipients = normalizeRecipientBundles(options.recipients, "recipients"); + const messageId = createMessageId(); + const createdAt = unixTimeMillis(); + validateApplicationProtectionPurpose(options.signaturePurpose); + validateApplicationProtectionPurpose(options.encryptionPurpose); + const signatureSuite = effectiveProtectionSignatureSuite( + identity.keyring, + options.signatureSuite, + ); + const encodedContent = encodeMTPDataValue(data); + let frame: Uint8Array; + try { + frame = bindings.build_protected_frame_with_keyring( + messageType, + encodedContent, + identity.signerId, + receiverId, + messageId, + createdAt, + options.signaturePurpose, + options.encryptionPurpose, + identity.keyring, + protectionSignatureSuiteValue(signatureSuite), + options.id ?? null, + options.exposeSender ?? false, + recipients, + ); + } catch (error) { + throw protectedOpeningError(error, identity.signerId); + } + await this.send(frame); } - async getEncryptedDeviceSecret(query: { - userId: string; - deviceId?: string; - secretId?: string; - }): Promise { - return this.encryptedDeviceSecretProvider.getEncryptedDeviceSecret(query); + async sendSealedRelay( + type: MTPCommunicationType, + data: MTPDataValueInput, + options: MTPSendSealedRelayOptions, + ): Promise { + const messageType = assertApplicationCommunicationType(type); + const identity = resolveProtectionIdentity( + options.identity, + this.#credentials, + ); + const finalRecipientId = inputU64( + options.finalRecipientId, + "finalRecipientId", + ); + const nextHopId = inputU64(options.nextHopId, "nextHopId"); + const metadataRecipients = normalizeRecipientBundles( + options.metadataRecipients, + "metadataRecipients", + ); + const contentRecipients = normalizeRecipientBundles( + options.contentRecipients, + "contentRecipients", + ); + const signatureSuite = effectiveProtectionSignatureSuite( + identity.keyring, + options.signatureSuite, + ); + const frame = bindings.build_encrypted_relay_frame_with_keyring( + messageType, + data, + identity.signerId, + finalRecipientId, + nextHopId, + createMessageId(), + unixTimeMillis(), + options.metadata === undefined + ? undefined + : encodeMTPDataValue(options.metadata), + identity.keyring, + protectionSignatureSuiteValue(signatureSuite), + metadataRecipients, + contentRecipients, + ); + await this.send(frame); } - setOnPipeRequest( - handler: ((request: MTPPipeRequest) => void) | null, - ): void { + subscribeSealedRelay( + type: MTPCommunicationType, + handler: ( + content: MTPVerifiedRelayContent, + frame: ParsedFrame, + ) => void | Promise, + options?: MTPEncryptedSubscriptionOptions, + ): Unsubscribe; + subscribeSealedRelay( + handler: ( + content: MTPVerifiedRelayContent, + frame: ParsedFrame, + ) => void | Promise, + options?: MTPEncryptedSubscriptionOptions, + ): Unsubscribe; + subscribeSealedRelay( + typeOrHandler: + | MTPCommunicationType + | (( + content: MTPVerifiedRelayContent, + frame: ParsedFrame, + ) => void | Promise), + maybeHandlerOrOptions?: + | (( + content: MTPVerifiedRelayContent, + frame: ParsedFrame, + ) => void | Promise) + | MTPEncryptedSubscriptionOptions, + maybeOptions?: MTPEncryptedSubscriptionOptions, + ): Unsubscribe { + const expectedInnerType = + typeof typeOrHandler === "function" ? null : typeOrHandler; + if (expectedInnerType != null) { + assertApplicationCommunicationType(expectedInnerType); + } + const handler = + typeof typeOrHandler === "function" + ? typeOrHandler + : typeof maybeHandlerOrOptions === "function" + ? maybeHandlerOrOptions + : null; + if (!handler) { + throw new TypeError("sealed relay handler must be a function"); + } + const options: MTPEncryptedSubscriptionOptions = + typeof typeOrHandler === "function" + ? typeof maybeHandlerOrOptions === "function" || + maybeHandlerOrOptions == null + ? {} + : maybeHandlerOrOptions + : (maybeOptions ?? {}); + const replayGuard = options.replayGuard ?? new InMemoryReplayGuard(); + const subscriptionOptions = { ...options, replayGuard }; + const sub = this.raw.client.subscribe( + "Relay", + async (frame: ParsedFrame) => { + let metadata: MTPVerifiedRelayMetadata | undefined; + try { + metadata = await this.openRelayMetadata(frame, subscriptionOptions); + const opened = await this.openRelayContent( + metadata, + subscriptionOptions, + ); + if ( + !opened || + (expectedInnerType && opened.type !== expectedInnerType) + ) { + return; + } + const parsedFrame: ParsedFrame = { + ...frame, + type: opened.type, + data: opened.data as ParsedFrame["data"], + }; + await handler(opened, parsedFrame); + } catch (e) { + emit(this.#options.logger, { + hint: "error", + type: "E2EE", + error: String(e), + direction: "recv", + }); + } finally { + metadata?.dispose(); + } + }, + ); + + return () => this.raw.client.unsubscribe(sub); + } + + /** + * Subscribe to verified relay metadata without attempting content + * decryption. The metadata capability is callback-scoped: it is disposed + * after the handler resolves, so do not retain it for later content opening. + * Call `openRelayMetadata()` directly when a longer-lived capability is + * required and dispose it when finished. + */ + subscribeRelayMetadata( + handler: ( + metadata: MTPVerifiedRelayMetadata, + frame: ParsedFrame, + ) => void | Promise, + options: MTPOpenRelayMetadataOptions = {}, + ): Unsubscribe { + if (typeof handler !== "function") { + throw new TypeError("relay metadata handler must be a function"); + } + const replayGuard = options.replayGuard ?? new InMemoryReplayGuard(); + const subscriptionOptions = { ...options, replayGuard }; + const sub = this.raw.client.subscribe( + "Relay", + async (frame: ParsedFrame) => { + let metadata: MTPVerifiedRelayMetadata | undefined; + try { + metadata = await this.openRelayMetadata(frame, subscriptionOptions); + await handler(metadata, frame); + } catch (e) { + emit(this.#options.logger, { + hint: "error", + type: "E2EE", + error: String(e), + direction: "recv", + }); + } finally { + metadata?.dispose(); + } + }, + ); + return () => this.raw.client.unsubscribe(sub); + } + + /** Explicit alias for callers that want to emphasize encrypted metadata. */ + subscribeEncryptedMetadata( + handler: ( + metadata: MTPVerifiedRelayMetadata, + frame: ParsedFrame, + ) => void | Promise, + options: MTPOpenRelayMetadataOptions = {}, + ): Unsubscribe { + return this.subscribeRelayMetadata(handler, options); + } + + async setEncryptedSecret(record: MTPEncryptedSecretRecord): Promise { + await this.encryptedSecretProvider.set(record); + } + + async getEncryptedSecret(id: string): Promise { + return this.encryptedSecretProvider.get(id); + } + + async deleteEncryptedSecret(id: string): Promise { + await this.encryptedSecretProvider.delete(id); + } + + setOnPipeRequest(handler: ((request: MTPPipeRequest) => void) | null): void { if (handler == null) { this.raw.client.set_on_pipe_request(null); return; @@ -1588,9 +2908,8 @@ export class MTPClient { if (typeof description !== "string") { throw new TypeError("description must be a string"); } - const handle: WasmPipeHandle = await this.raw.client.create_pipe( - description, - ); + const handle: WasmPipeHandle = + await this.raw.client.create_pipe(description); const sdk = this; return { pipeId: handle.pipeId, @@ -1611,6 +2930,71 @@ export class MTPClient { }; } + /** + * Create and negotiate an encrypted pipe with the actual MTP pipe ID and + * local identity bound automatically. A recipient array creates a group + * bootstrap; membership changes should create a fresh session with the new + * array. + */ + async createEncryptedPipe( + options: MTPCreateEncryptedPipeOptions, + ): Promise { + const credentials = this.#credentials; + if (!credentials || credentials.clientId == null) { + throw new Error("Client not registered"); + } + const recipientId = inputU64(options.recipientId, "recipientId"); + if ( + options.recipientPublicKey != null && + options.recipientPublicKeys != null + ) { + throw new Error( + "provide recipientPublicKey or recipientPublicKeys, not both", + ); + } + const rawRecipients = + options.recipientPublicKeys ?? + (options.recipientPublicKey != null ? [options.recipientPublicKey] : []); + if (rawRecipients.length === 0) { + throw new Error("at least one recipient public key is required"); + } + const recipients = rawRecipients.map((value, index) => + normalizeBytes(value, `recipientPublicKeys[${index}]`), + ); + recipients.forEach((value) => publicKeyBundleToKeys(value)); + const purpose = options.purpose ?? 0x40; + const direction = options.direction ?? 0; + validateApplicationProtectionPurpose(purpose); + if (!Number.isInteger(direction) || direction < 0 || direction > 0xff) { + throw new RangeError("direction must be a u8"); + } + + const handle = await this.createPipe( + options.description ?? "encrypted pipe", + ); + const writer = await handle.wait(); + if (!writer) return null; + if (writer.pipeId !== handle.pipeId) { + throw new Error("created pipe ID does not match the accepted writer"); + } + const sessionId = new Uint8Array(32); + globalThis.crypto.getRandomValues(sessionId); + return initiateMTPPipeSession( + writer, + { + sessionId, + pipeId: writer.pipeId, + senderId: credentials.clientId, + recipientId, + purpose, + direction, + }, + credentials.keyring, + recipients, + options.signatureSuite, + ); + } + async acceptPipe(pipeId: number): Promise { if (typeof pipeId !== "number" || !Number.isFinite(pipeId)) { throw new TypeError("pipeId must be a finite number"); @@ -1625,6 +3009,64 @@ export class MTPClient { return reader as unknown as MTPPipeReader; } + /** Accept a pipe and learn its authenticated session ID from the offer. */ + async acceptEncryptedPipe( + request: MTPPipeRequest, + options: MTPAcceptEncryptedPipeOptions, + ): Promise { + const credentials = this.#credentials; + if (!credentials || credentials.clientId == null) { + throw new Error("Client not registered"); + } + if (!Number.isSafeInteger(request.pipeId) || request.pipeId <= 0) { + throw new TypeError("request.pipeId must be a non-zero safe integer"); + } + const senderId = inputU64(options.senderId, "senderId"); + const purpose = options.purpose ?? 0x40; + const direction = options.direction ?? 0; + validateApplicationProtectionPurpose(purpose); + if (!Number.isInteger(direction) || direction < 0 || direction > 0xff) { + throw new RangeError("direction must be a u8"); + } + if ( + options.senderPublicKeys != null && + options.senderPublicKeys.length === 0 + ) { + throw new Error("senderPublicKeys must contain at least one key"); + } + const singleSenderPublicKey = options.senderPublicKey; + if (options.senderPublicKeys == null && singleSenderPublicKey == null) { + throw new Error("senderPublicKey or senderPublicKeys is required"); + } + const senderPublicKey = + options.senderPublicKeys != null + ? options.senderPublicKeys.map((value, index) => + normalizeBytes(value, `senderPublicKeys[${index}]`), + ) + : normalizeBytes(singleSenderPublicKey!, "senderPublicKey"); + const reader = await this.acceptPipe(request.pipeId); + if (reader.pipeId !== request.pipeId) { + throw new Error("accepted pipe ID does not match the requested pipe"); + } + const signaturePolicy = resolveSignatureVerificationPolicy( + options.signaturePolicy, + this.#options.defaultSignatureVerificationPolicy, + ); + return acceptMTPPipeSessionAuto( + reader, + { + pipeId: reader.pipeId, + senderId, + recipientId: credentials.clientId, + purpose, + direction, + }, + credentials.keyring, + senderPublicKey, + signaturePolicy, + ); + } + async denyPipe(pipeId: number): Promise { if (typeof pipeId !== "number" || !Number.isFinite(pipeId)) { throw new TypeError("pipeId must be a finite number"); @@ -1651,16 +3093,42 @@ export type { MTPSessionState, MTPSessionStorage, MTPSessionTranscriptContext, + SkippedMessageKey, } from "./session"; export { MTPSessionManager, InMemorySessionStorage, - getConversationId, + derivePeerSessionId, deriveSessionKeys, buildSessionTranscript, } from "./session.js"; export { MTPRatchet } from "./ratchet.js"; export type { RatchetStep } from "./ratchet.js"; +export { + MTPEncryptedPipeError, + MTPEncryptedPipeReader, + MTPEncryptedPipeWriter, + MTPPipeProtectionContext, + MAX_ENCRYPTED_PIPE_RECORD, + pipeSessionSignaturePurpose, + pipeSessionEncryptionPurpose, + validateApplicationProtectionPurpose, + MAX_PIPE_SESSION_OFFER, + initiateMTPPipeSession, + acceptMTPPipeSession, + acceptMTPPipeSessionAuto, + initiateMTPForwardSecurePipeSession, + acceptMTPForwardSecurePipeSession, +} from "./encrypted-pipe.js"; +export type { + MTPReadablePipe, + MTPWritablePipe, + MTPPipeSessionParameters, + MTPPipeSessionExpectation, + MTPDuplexPipe, + MTPEncryptedPipeReaderSource, + MTPEncryptedPipeWriterSource, +} from "./encrypted-pipe.js"; export { serializeEncryptedMessage, parseEncryptedMessage, @@ -1675,7 +3143,7 @@ export type { SerializedEncryptedMessage, } from "./encrypted-message"; export type { - EncryptedDeviceSecretRecord, - MTPEncryptedDeviceSecretProvider, -} from "./encrypted-device-secret"; -export { InMemoryEncryptedDeviceSecretProvider } from "./encrypted-device-secret.js"; + MTPEncryptedSecretRecord, + MTPEncryptedSecretProvider, +} from "./encrypted-secret"; +export { InMemoryEncryptedSecretProvider } from "./encrypted-secret.js"; diff --git a/src/sdk/session.ts b/src/sdk/session.ts index 9c4f098..1abca71 100644 --- a/src/sdk/session.ts +++ b/src/sdk/session.ts @@ -4,23 +4,29 @@ import { concatBytes, utf8Encode, writeU64BE } from "./utils.js"; export const HKDF_SALT_ROOT = "mtp-e2ee-v1-root"; const HKDF_INITIATOR_SEND = "mtp-e2ee-v1-initiator-send"; const HKDF_INITIATOR_RECV = "mtp-e2ee-v1-initiator-recv"; +const SESSION_TRANSCRIPT_DOMAIN = "mtp-e2ee-session-transcript-v1"; export interface MTPSessionTranscriptContext { - senderUserId?: string; - senderClientId: bigint; - recipientUserId?: string; - recipientClientId: bigint; + /** Application-selected identity for this stateful MTP session. */ + sessionId: string; + /** MTP identity that initiated key establishment. */ + initiatorId: bigint; + /** MTP identity that receives the key-establishment message. */ + recipientId: bigint; + /** Public key used by the recipient for this key establishment. */ recipientPublicKey: Uint8Array; + /** KEM ciphertext used by the key establishment. */ kemCiphertext: Uint8Array; - conversationId: string; + /** Opaque, application-owned context included by hash only. */ + applicationContext?: Uint8Array; } export interface MTPSessionState { version: 1; - conversationId: string; - ownClientId: bigint; - peerClientId: bigint; - peerPublicKey: Uint8Array; + sessionId: string; + localId: bigint; + remoteId: bigint; + remotePublicKey: Uint8Array; sendChainKey: Uint8Array; recvChainKey: Uint8Array; sendCount: number; @@ -37,9 +43,26 @@ export interface SkippedMessageKey { } export interface MTPSessionStorage { - getSession(conversationId: string): Promise; + getSession(sessionId: string): Promise; setSession(state: MTPSessionState): Promise; - deleteSession(conversationId: string): Promise; + deleteSession(sessionId: string): Promise; +} + +function requireSessionId(sessionId: string): string { + if (typeof sessionId !== "string" || sessionId.length === 0) { + throw new TypeError("sessionId must be a non-empty string"); + } + return sessionId; +} + +function requireMtpId(id: bigint, name: string): bigint { + if (typeof id !== "bigint") { + throw new TypeError(`${name} must be a bigint`); + } + // Validate the range once at the API boundary. The returned value is still + // the original bigint so callers do not observe a representation change. + writeU64BE(id); + return id; } export class InMemorySessionStorage implements MTPSessionStorage { @@ -48,7 +71,7 @@ export class InMemorySessionStorage implements MTPSessionStorage { private cloneSession(state: MTPSessionState): MTPSessionState { return { ...state, - peerPublicKey: state.peerPublicKey.slice(), + remotePublicKey: state.remotePublicKey.slice(), sendChainKey: state.sendChainKey.slice(), recvChainKey: state.recvChainKey.slice(), skippedMessageKeys: (state.skippedMessageKeys ?? []).map((skipped) => ({ @@ -64,26 +87,31 @@ export class InMemorySessionStorage implements MTPSessionStorage { for (const skipped of state.skippedMessageKeys ?? []) skipped.key.fill(0); } - async getSession(conversationId: string): Promise { - const state = this.store.get(conversationId); + async getSession(sessionId: string): Promise { + const state = this.store.get(requireSessionId(sessionId)); return state ? this.cloneSession(state) : null; } async setSession(state: MTPSessionState): Promise { + const sessionId = requireSessionId(state.sessionId); const replacement = this.cloneSession(state); - const previous = this.store.get(state.conversationId); + const previous = this.store.get(sessionId); if (previous) this.zeroizeSession(previous); - this.store.set(state.conversationId, replacement); + this.store.set(sessionId, replacement); } - async deleteSession(conversationId: string): Promise { - const previous = this.store.get(conversationId); + async deleteSession(sessionId: string): Promise { + const key = requireSessionId(sessionId); + const previous = this.store.get(key); if (previous) this.zeroizeSession(previous); - this.store.delete(conversationId); + this.store.delete(key); } } function writeU32BE(value: number): Uint8Array { + if (!Number.isSafeInteger(value) || value < 0 || value > 0xffff_ffff) { + throw new Error("u32 value out of range"); + } return new Uint8Array([ (value >>> 24) & 0xff, (value >>> 16) & 0xff, @@ -102,21 +130,34 @@ function transcriptField(label: string, value: Uint8Array): Uint8Array { ]); } +/** + * Build a length-delimited transcript for one MTP session. + * + * Application context is intentionally hashed as an opaque byte string. MTP + * therefore provides domain separation without parsing or naming any fields + * owned by the consuming application. + */ export function buildSessionTranscript( args: MTPSessionTranscriptContext, ): Uint8Array { + const sessionId = requireSessionId(args.sessionId); + const initiatorId = requireMtpId(args.initiatorId, "initiatorId"); + const recipientId = requireMtpId(args.recipientId, "recipientId"); const recipientPublicKeyHash = bindings.wasm_sha256(args.recipientPublicKey); const kemHash = bindings.wasm_sha256(args.kemCiphertext); + const applicationContextHash = bindings.wasm_sha256( + args.applicationContext ?? new Uint8Array(0), + ); + return concatBytes([ - transcriptField("domain", utf8Encode("mtp-e2ee-session-transcript-v1")), + transcriptField("domain", utf8Encode(SESSION_TRANSCRIPT_DOMAIN)), transcriptField("version", utf8Encode("1")), - transcriptField("senderUserId", utf8Encode(args.senderUserId ?? "")), - transcriptField("senderClientId", writeU64BE(args.senderClientId)), - transcriptField("recipientUserId", utf8Encode(args.recipientUserId ?? "")), - transcriptField("recipientClientId", writeU64BE(args.recipientClientId)), + transcriptField("sessionId", utf8Encode(sessionId)), + transcriptField("initiatorId", writeU64BE(initiatorId)), + transcriptField("recipientId", writeU64BE(recipientId)), transcriptField("recipientPublicKeyHash", recipientPublicKeyHash), transcriptField("kemCiphertextHash", kemHash), - transcriptField("conversationId", utf8Encode(args.conversationId)), + transcriptField("applicationContextHash", applicationContextHash), ]); } @@ -150,62 +191,65 @@ export async function deriveSessionKeys( return { root, initiatorSend, initiatorRecv }; } -export function getConversationId( - ownClientId: bigint, - peerClientId: bigint, +/** + * Derive a stable ID for the unordered pair of MTP identities. + * + * This helper is only a convenience. Session storage and the manager accept + * caller-selected IDs directly, so applications can keep multiple sessions + * between the same pair of identities. + */ +export function derivePeerSessionId( + localId: bigint, + remoteId: bigint, ): string { - const ids = [ownClientId, peerClientId].sort((a, b) => - a < b ? -1 : a > b ? 1 : 0, - ); + const ids = [ + requireMtpId(localId, "localId"), + requireMtpId(remoteId, "remoteId"), + ].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); return `${ids[0].toString(16)}:${ids[1].toString(16)}`; } export class MTPSessionManager { constructor(private storage: MTPSessionStorage) {} - getConversationId( - ownClientId: bigint, - peerClientId: bigint, - ): Promise { - return Promise.resolve(getConversationId(ownClientId, peerClientId)); - } - - async getSession( - ownClientId: bigint, - peerClientId: bigint, - ): Promise { - return this.storage.getSession( - getConversationId(ownClientId, peerClientId), - ); + getSession(sessionId: string): Promise { + return this.storage.getSession(requireSessionId(sessionId)); } async saveSession(state: MTPSessionState): Promise { - await this.storage.setSession({ ...state, updatedAt: Date.now() }); + await this.storage.setSession({ + ...state, + updatedAt: Date.now(), + }); } - async deleteSession( - ownClientId: bigint, - peerClientId: bigint, - ): Promise { - await this.storage.deleteSession( - getConversationId(ownClientId, peerClientId), - ); + async deleteSession(sessionId: string): Promise { + await this.storage.deleteSession(requireSessionId(sessionId)); } async createSession(args: { - ownClientId: bigint; - peerClientId: bigint; - peerPublicKey: Uint8Array; + sessionId: string; + localId: bigint; + remoteId: bigint; + remotePublicKey: Uint8Array; sharedSecret: Uint8Array; role: "initiator" | "receiver"; transcript?: Uint8Array; transcriptContext?: MTPSessionTranscriptContext; }): Promise { - const transcript = - args.transcript ?? - (args.transcriptContext + const sessionId = requireSessionId(args.sessionId); + const localId = requireMtpId(args.localId, "localId"); + const remoteId = requireMtpId(args.remoteId, "remoteId"); + const transcript = args.transcript + ? args.transcript.slice() + : args.transcriptContext ? buildSessionTranscript(args.transcriptContext) - : undefined); + : null; + if (!transcript || transcript.length === 0) { + throw new Error( + "session creation requires a non-empty authenticated transcript or transcriptContext", + ); + } const { root, initiatorSend, initiatorRecv } = await deriveSessionKeys( args.sharedSecret, transcript, @@ -213,10 +257,10 @@ export class MTPSessionManager { const now = Date.now(); const state: MTPSessionState = { version: 1, - conversationId: getConversationId(args.ownClientId, args.peerClientId), - ownClientId: args.ownClientId, - peerClientId: args.peerClientId, - peerPublicKey: args.peerPublicKey, + sessionId, + localId, + remoteId, + remotePublicKey: args.remotePublicKey.slice(), sendChainKey: args.role === "initiator" ? initiatorSend : initiatorRecv, recvChainKey: args.role === "initiator" ? initiatorRecv : initiatorSend, sendCount: 0, diff --git a/src/sdk/signature-policy.ts b/src/sdk/signature-policy.ts new file mode 100644 index 0000000..a9778a7 --- /dev/null +++ b/src/sdk/signature-policy.ts @@ -0,0 +1,170 @@ +import * as bindings from "mtp/raw"; + +export type MTPSignatureVerificationPolicy = + | "ed25519" + | "dual" + | "any-supported"; + +/** + * The SDK default is deliberately fixed. Senders also default to the + * interoperable Ed25519 suite; dual signatures require an explicit sender + * suite and receiver policy. + */ +export const DEFAULT_SIGNATURE_VERIFICATION_POLICY: MTPSignatureVerificationPolicy = + "ed25519"; + +export type MTPSignatureVerificationErrorCode = + | "unsupported-suite" + | "policy-rejected" + | "invalid-signature" + | "signer-keys-unavailable"; + +const POLICY_NAMES: Record< + MTPSignatureVerificationErrorCode, + string +> = { + "unsupported-suite": "unsupported signature suite", + "policy-rejected": "signature rejected by policy", + "invalid-signature": "signature cryptographically invalid", + "signer-keys-unavailable": "signer public keys unavailable", +}; + +/** Caller-facing signature verification failure without cryptographic detail. */ +export class MTPSignatureVerificationError extends Error { + readonly code: MTPSignatureVerificationErrorCode; + readonly signerId?: bigint; + + constructor( + code: MTPSignatureVerificationErrorCode, + signerId?: bigint, + ) { + super( + signerId == null + ? POLICY_NAMES[code] + : `${POLICY_NAMES[code]} for signer ${signerId}`, + ); + this.name = "MTPSignatureVerificationError"; + this.code = code; + this.signerId = signerId; + } +} + +function validPolicy( + value: unknown, +): value is MTPSignatureVerificationPolicy { + return ( + value === "ed25519" || + value === "dual" || + value === "any-supported" + ); +} + +/** + * Resolve receiver policy in operation, client, library order. + */ +export function resolveSignatureVerificationPolicy( + operationPolicy: MTPSignatureVerificationPolicy | undefined, + clientDefaultPolicy?: MTPSignatureVerificationPolicy, +): MTPSignatureVerificationPolicy { + if (operationPolicy != null && !validPolicy(operationPolicy)) { + throw new TypeError( + "signaturePolicy must be 'ed25519', 'dual', or 'any-supported'", + ); + } + if (clientDefaultPolicy != null && !validPolicy(clientDefaultPolicy)) { + throw new TypeError( + "defaultSignatureVerificationPolicy must be 'ed25519', 'dual', or 'any-supported'", + ); + } + return ( + operationPolicy ?? + clientDefaultPolicy ?? + DEFAULT_SIGNATURE_VERIFICATION_POLICY + ); +} + +/** Convert the SDK policy into the raw WASM verifier's policy value. */ +export function signatureVerificationPolicyValue( + policy: MTPSignatureVerificationPolicy, +): number { + switch (policy) { + case "ed25519": + return bindings.mtp_protection_signature_suite_ed25519(); + case "dual": + return bindings.mtp_protection_signature_suite_dual(); + case "any-supported": + return 0; + } +} + +/** Verify one protected value and sanitize raw WASM failure details. */ +export function verifyDataValueWithPolicy( + value: Uint8Array, + publicKeyBundle: Uint8Array, + expectedSignerId: bigint, + expectedPurpose: number, + policy: MTPSignatureVerificationPolicy, +): void { + try { + bindings.verify_data_value_with_policy( + value, + publicKeyBundle, + expectedSignerId, + expectedPurpose, + signatureVerificationPolicyValue(policy), + ); + } catch (error) { + throw classifySignatureVerificationFailure(error, expectedSignerId); + } +} + +function rawErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** Classify a raw verifier failure without returning its cryptographic cause. */ +export function classifySignatureVerificationFailure( + error: unknown, + signerId?: bigint, +): MTPSignatureVerificationError { + const message = rawErrorMessage(error).toLowerCase(); + if (message.includes("unknown") && message.includes("signature suite")) { + return new MTPSignatureVerificationError("unsupported-suite", signerId); + } + if (message.includes("policy")) { + return new MTPSignatureVerificationError("policy-rejected", signerId); + } + return new MTPSignatureVerificationError("invalid-signature", signerId); +} + +/** Select the most useful sanitized error after trying key history. */ +export function signatureVerificationFailure( + errors: readonly unknown[], + signerId?: bigint, +): MTPSignatureVerificationError { + const classified = errors.map((error) => + error instanceof MTPSignatureVerificationError + ? error + : classifySignatureVerificationFailure(error, signerId), + ); + const preferredCode = [ + "unsupported-suite", + "policy-rejected", + "invalid-signature", + ].find((code) => + classified.some((error) => error.code === code), + ) as MTPSignatureVerificationErrorCode | undefined; + return new MTPSignatureVerificationError( + preferredCode ?? "invalid-signature", + signerId, + ); +} + +export function signerKeysUnavailable( + signerId?: bigint, +): MTPSignatureVerificationError { + return new MTPSignatureVerificationError( + "signer-keys-unavailable", + signerId, + ); +} diff --git a/src/sdk/utils.ts b/src/sdk/utils.ts index 1014771..14c02d1 100644 --- a/src/sdk/utils.ts +++ b/src/sdk/utils.ts @@ -13,6 +13,11 @@ export function utf8Encode(text: string): Uint8Array { return bytes.subarray(0, len); } +/** Return the current Unix time in milliseconds for MTP protocol fields. */ +export function unixTimeMillis(): bigint { + return BigInt(Date.now()); +} + export function writeU64BE(value: bigint): Uint8Array { if (value < 0n || value > 0xffff_ffff_ffff_ffffn) throw new Error("u64 value out of range"); const out = new Uint8Array(8); diff --git a/src/type-map/reserved.ts b/src/type-map/reserved.ts index 297739e..6b5d26a 100644 --- a/src/type-map/reserved.ts +++ b/src/type-map/reserved.ts @@ -1,16 +1,13 @@ -export const RESERVED_COMMUNICATION_TYPES = [ - "Identification", "IdentificationResponse", "Register", "RegisterResponse", - "Challenge", "ChallengeResponse", "Ping", "Pong", "Disconnect", "Redirect", - "Shutdown", "Error", "ErrorParsing", "ErrorBadVersion", "BadRequest", - "Unauthorized", "Forbidden", "NotFound", "TooManyRequests", "InternalServerError", - "BadGateway", "ServiceUnavailable", "GatewayTimeout", "PipeRequest", "PipeResponse", - "PipeAbort", -] as const; +import reserved from "../../type-map/reserved.json" with { type: "json" }; -export const RESERVED_DATA_TYPES = [ - "Version", "Id", "ClientNonce", "ServerNonce", "PublicKeys", "Signature", - "PqSignature", "Description", "Connected", "Timestamp", "Error", "ErrorParsing", - "ErrorMessage", "Accepted", "RequirePq", -] as const; - -export const FIRST_USER_TYPE_ID = 32; +export const FIRST_USER_TYPE_ID = reserved.firstUserTypeId; +export const RESERVED_COMMUNICATION_TYPES = reserved.communication.map( + ({ name }) => name, +); +export const RESERVED_DATA_TYPES = reserved.data.map(({ name }) => name); +export const RESERVED_COMMUNICATION_TYPE_IDS = Object.fromEntries( + reserved.communication.map(({ name, id }) => [name, id]), +); +export const RESERVED_DATA_TYPE_IDS = Object.fromEntries( + reserved.data.map(({ name, id }) => [name, id]), +); diff --git a/src/vite/index.ts b/src/vite/index.ts index f8f34b5..03d31c5 100644 --- a/src/vite/index.ts +++ b/src/vite/index.ts @@ -61,7 +61,7 @@ function devServerPath(root: string, filePath: string) { return `/${relativePath.split(path.sep).join("/")}`; } -async function hashPackageInputs() { +export async function hashPackageInputs(root = packageRoot) { const hash = crypto.createHash("sha256"); const inputs = [ "wasm/Cargo.toml", @@ -74,11 +74,12 @@ async function hashPackageInputs() { "crypto/src", "type-map/Cargo.toml", "type-map/build.rs", + "type-map/reserved.json", "type-map/src", ]; async function addPath(relativePath) { - const absolutePath = path.join(packageRoot, relativePath); + const absolutePath = path.join(root, relativePath); const stat = await fs.stat(absolutePath).catch(() => null); if (!stat) { return; @@ -109,7 +110,7 @@ function quoteList(values: string[]): string { : values.map((value) => JSON.stringify(value)).join(" | "); } -function parseTypeMapYaml(source: string, filePath: string) { +export function parseTypeMapYaml(source: string, filePath: string) { const document = YAML.parseDocument(source, { prettyErrors: false }); if (document.errors.length) { const error = document.errors[0]; @@ -120,27 +121,80 @@ function parseTypeMapYaml(source: string, filePath: string) { throw new Error(`${filePath}:${line}: ${error.message}`); } const root = document.toJS() as { - type_maps?: Record< - string, - { - CommunicationTypes?: Record; - DataTypes?: Record; - } - >; + protocol_version?: unknown; + type_maps?: unknown; }; + if ( + root === null || + typeof root !== "object" || + Array.isArray(root) || + typeof root.protocol_version !== "string" || + !/^\d+\.\d+$/.test(root.protocol_version) + ) { + throw new Error( + `${filePath}: protocol_version must be a string matching '.'`, + ); + } + if ( + root.type_maps === null || + typeof root.type_maps !== "object" || + Array.isArray(root.type_maps) + ) { + throw new Error(`${filePath}: type_maps must be a mapping`); + } + const typeMaps = root.type_maps as Record; + if (!Object.prototype.hasOwnProperty.call(typeMaps, root.protocol_version)) { + throw new Error( + `${filePath}: protocol_version '${root.protocol_version}' is not defined in type_maps`, + ); + } + + const reservedCommunicationTypes = new Set(RESERVED_COMMUNICATION_TYPES); + const reservedDataTypes = new Set(RESERVED_DATA_TYPES); const communicationTypes = new Set(RESERVED_COMMUNICATION_TYPES); const dataTypes = new Set(RESERVED_DATA_TYPES); - for (const [version, map] of Object.entries(root.type_maps ?? {})) { + for (const [version, rawMap] of Object.entries(typeMaps)) { if (!/^\d+\.\d+$/.test(version)) throw new Error(`${filePath}: unparseable type-map version '${version}'`); - for (const [section, target] of [ - ["CommunicationTypes", communicationTypes], - ["DataTypes", dataTypes], - ] as const) { + if ( + rawMap === null || + typeof rawMap !== "object" || + Array.isArray(rawMap) + ) { + throw new Error(`${filePath}: ${version} must be a mapping`); + } + const map = rawMap as { + CommunicationTypes?: unknown; + DataTypes?: unknown; + }; + for (const { section, reserved, selected } of [ + { + section: "CommunicationTypes", + reserved: reservedCommunicationTypes, + selected: communicationTypes, + }, + { + section: "DataTypes", + reserved: reservedDataTypes, + selected: dataTypes, + }, + ]) { + const sectionValue = map[section as "CommunicationTypes" | "DataTypes"]; + if ( + sectionValue !== undefined && + (sectionValue === null || + typeof sectionValue !== "object" || + Array.isArray(sectionValue)) + ) { + throw new Error(`${filePath}: ${version}.${section} must be a mapping`); + } const ids = new Map(); - for (const [name, value] of Object.entries( - map[section as "CommunicationTypes" | "DataTypes"] ?? {}, - )) { + for (const [name, value] of Object.entries(sectionValue ?? {})) { + if (reserved.has(name)) { + throw new Error( + `${filePath}: ${version}.${section}.${name} uses a reserved type name`, + ); + } if (!Number.isInteger(value) || (value as number) < FIRST_USER_TYPE_ID) throw new Error( `${filePath}: ${version}.${section}.${name} must use an integer id >= ${FIRST_USER_TYPE_ID}`, @@ -152,7 +206,9 @@ function parseTypeMapYaml(source: string, filePath: string) { `${filePath}: duplicate type id ${id} in ${section} (${previous} and ${name})`, ); ids.set(id, name); - target.add(name); + if (version === root.protocol_version) { + selected.add(name); + } } } } @@ -162,7 +218,7 @@ function parseTypeMapYaml(source: string, filePath: string) { }; } -function generateTypeMapModule(metadata: { +export function generateTypeMapModule(metadata: { communicationTypes: string[]; dataTypes: string[]; }) { @@ -171,7 +227,7 @@ function generateTypeMapModule(metadata: { return { js, dts }; } -async function writeTypeMapModule(outDir, typeMapsPath) { +export async function writeTypeMapModule(outDir, typeMapsPath) { const source = await fs.readFile(typeMapsPath, "utf8").catch((error) => { throw new Error( `Failed to read type map '${typeMapsPath}': ${error.message}`, @@ -186,7 +242,7 @@ async function writeTypeMapModule(outDir, typeMapsPath) { return source; } -async function copyWasmBuildInputs(buildRoot) { +export async function copyWasmBuildInputs(buildRoot) { const inputs = [ "Cargo.lock", "wasm", @@ -416,18 +472,19 @@ export function mtp(options: MTPVitePluginOptions): VitePlugin { }); } - const sourceWatchDirs = [ + const sourceWatchPaths = [ "wasm/src", "common/src", "codec/src", "crypto/src", "type-map/src", + "type-map/reserved.json", ].map((rel) => path.join(packageRoot, rel)); server.watcher.add(state.typeMapsPath); - for (const dir of sourceWatchDirs) { - if (await pathExists(dir)) { - server.watcher.add(dir); + for (const sourcePath of sourceWatchPaths) { + if (await pathExists(sourcePath)) { + server.watcher.add(sourcePath); } } @@ -435,8 +492,10 @@ export function mtp(options: MTPVitePluginOptions): VitePlugin { const scheduleRebuild = (changedPath: string) => { const resolved = path.resolve(changedPath); const isTypeMap = resolved === state.typeMapsPath; - const isSource = sourceWatchDirs.some((dir) => - resolved.startsWith(`${dir}${path.sep}`), + const isSource = sourceWatchPaths.some( + (sourcePath) => + resolved === sourcePath || + resolved.startsWith(`${sourcePath}${path.sep}`), ); if (!isTypeMap && !isSource) { return; diff --git a/test/e2ee.mjs b/test/e2ee.mjs index 3e11177..3a34e3b 100644 --- a/test/e2ee.mjs +++ b/test/e2ee.mjs @@ -11,6 +11,15 @@ const wasmBytes = fs.readFileSync(wasmPath); const wasmModule = new WebAssembly.Module(wasmBytes); initSync(wasmModule); +const relayFixtureCreatedAtMillis = BigInt( + fs + .readFileSync( + path.resolve(__dirname, "../fixtures/relay-created-at-ms.txt"), + "utf8", + ) + .trim(), +); + const sdk = await import("../dist/sdk/index.js"); const { MTPRatchet } = await import("../dist/sdk/ratchet.js"); const { @@ -25,11 +34,96 @@ const { MTPSessionManager, InMemorySessionStorage, deriveSessionKeys, - getConversationId, + derivePeerSessionId, + buildSessionTranscript, } = await import("../dist/sdk/session.js"); const bindings = sdk.raw; +class MemoryEndpoint { + constructor(pipeId) { + this.pipeId = pipeId; + this.queue = []; + this.waiters = []; + this.peer = null; + this.closed = false; + } + + write(data) { + if (this.closed || !this.peer || this.peer.closed) { + return Promise.reject(new Error("memory pipe is closed")); + } + const chunk = data.slice(); + const waiter = this.peer.waiters.shift(); + if (waiter) waiter(chunk); + else this.peer.queue.push(chunk); + return Promise.resolve(); + } + + read() { + if (this.queue.length > 0) return Promise.resolve(this.queue.shift()); + if (this.closed) return Promise.resolve(null); + return new Promise((resolve) => this.waiters.push(resolve)); + } + + close() { + this.closed = true; + for (const resolve of this.waiters.splice(0)) resolve(null); + if (this.peer) { + this.peer.closed = true; + for (const resolve of this.peer.waiters.splice(0)) resolve(null); + } + return Promise.resolve(); + } + + abort() { + this.closed = true; + for (const resolve of this.waiters.splice(0)) resolve(null); + if (this.peer) { + this.peer.closed = true; + for (const resolve of this.peer.waiters.splice(0)) resolve(null); + } + } +} + +function memoryDuplexPair(pipeId = 77) { + const left = new MemoryEndpoint(pipeId); + const right = new MemoryEndpoint(pipeId); + left.peer = right; + right.peer = left; + return [left, right]; +} + +function publicBundle(keyring) { + const keys = sdk.crypto.keyringToKeys(keyring); + return concat( + new Uint8Array([keys.kemPublicKey.length >> 8, keys.kemPublicKey.length & 0xff]), + keys.kemPublicKey, + new Uint8Array([keys.sigPqPublicKey.length >> 8, keys.sigPqPublicKey.length & 0xff]), + keys.sigPqPublicKey, + new Uint8Array([keys.sigClPublicKey.length >> 8, keys.sigClPublicKey.length & 0xff]), + keys.sigClPublicKey, + ); +} + +function ed25519OnlyEncryptionKeyring(keyring) { + const keys = sdk.crypto.keyringToKeys(keyring); + const fields = [ + keys.kemPublicKey, + keys.kemSecretKey, + new Uint8Array(0), + new Uint8Array(0), + keys.sigClPublicKey, + keys.sigClSecretKey, + ]; + return concat( + ...fields.flatMap((field) => [ + new Uint8Array([field.length >> 8, field.length & 0xff]), + field, + ]), + ); +} + function concat(...arrays) { const totalLen = arrays.reduce((sum, a) => sum + a.length, 0); const result = new Uint8Array(totalLen); @@ -46,6 +140,7 @@ function setupSessions(sharedSecret, aliceId = 1n, bobId = 2n) { const aliceManager = new MTPSessionManager(aliceStorage); const bobStorage = new InMemorySessionStorage(); const bobManager = new MTPSessionManager(bobStorage); + const sessionId = derivePeerSessionId(aliceId, bobId); return { aliceManager, @@ -53,23 +148,28 @@ function setupSessions(sharedSecret, aliceId = 1n, bobId = 2n) { bobManager, bobStorage, async initSessions() { + const transcript = new Uint8Array([0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e]); const { initiatorSend, initiatorRecv } = await deriveSessionKeys( sharedSecret, - new Uint8Array(0), + transcript, ); const aliceSession = await aliceManager.createSession({ - ownClientId: aliceId, - peerClientId: bobId, - peerPublicKey: new Uint8Array(32), + sessionId, + localId: aliceId, + remoteId: bobId, + remotePublicKey: new Uint8Array(32), sharedSecret, role: "initiator", + transcript, }); const bobSession = await bobManager.createSession({ - ownClientId: bobId, - peerClientId: aliceId, - peerPublicKey: new Uint8Array(32), + sessionId, + localId: bobId, + remoteId: aliceId, + remotePublicKey: new Uint8Array(32), sharedSecret, role: "receiver", + transcript, }); return { aliceSession, bobSession, initiatorSend, initiatorRecv }; }, @@ -123,6 +223,44 @@ await describe("E2EE Session Derivation", async () => { }); }); +await describe("MTP Session Transcript", async () => { + const base = { + sessionId: "session-a", + initiatorId: 1n, + recipientId: 2n, + recipientPublicKey: new Uint8Array([1, 2, 3]), + kemCiphertext: new Uint8Array([4, 5, 6]), + }; + + await it("binds generic session identity and MTP key-establishment values", () => { + const transcript = buildSessionTranscript(base); + assert.notDeepEqual( + transcript, + buildSessionTranscript({ ...base, sessionId: "session-b" }), + ); + assert.notDeepEqual( + transcript, + buildSessionTranscript({ ...base, initiatorId: 3n }), + ); + assert.notDeepEqual( + transcript, + buildSessionTranscript({ + ...base, + recipientPublicKey: new Uint8Array([1, 2, 4]), + }), + ); + }); + + await it("hashes opaque application context into the transcript", () => { + const withoutContext = buildSessionTranscript(base); + const withContext = buildSessionTranscript({ + ...base, + applicationContext: new Uint8Array([0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74]), + }); + assert.notDeepEqual(withoutContext, withContext); + }); +}); + await describe("E2EE Ratchet", async () => { await it("Repeated sends produce different message keys", async () => { const chainKey = sdk.crypto.sha256(new Uint8Array([42])); @@ -159,8 +297,8 @@ await describe("E2EE Serialization", async () => { header: { version: 1, flags: 0, - senderClientId: 0x1234567890abcdefn, - recipientClientId: 0xfedcba0987654321n, + senderId: 0x1234567890abcdefn, + recipientId: 0xfedcba0987654321n, messageNumber: 42, }, aeadPayload: new Uint8Array([1, 2, 3, 4, 5]), @@ -169,8 +307,8 @@ await describe("E2EE Serialization", async () => { const parsed = parseEncryptedMessage(bytes); assert.equal(parsed.header.version, 1); assert.equal(parsed.header.flags, 0); - assert.equal(parsed.header.senderClientId, msg.header.senderClientId); - assert.equal(parsed.header.recipientClientId, msg.header.recipientClientId); + assert.equal(parsed.header.senderId, msg.header.senderId); + assert.equal(parsed.header.recipientId, msg.header.recipientId); assert.equal(parsed.header.messageNumber, 42); assert.equal(parsed.header.kemCiphertext, undefined); assert.deepEqual(parsed.aeadPayload, msg.aeadPayload); @@ -181,8 +319,8 @@ await describe("E2EE Serialization", async () => { header: { version: 1, flags: FLAG_INIT, - senderClientId: 1n, - recipientClientId: 2n, + senderId: 1n, + recipientId: 2n, messageNumber: 0, kemCiphertext: new Uint8Array([0xde, 0xad, 0xbe, 0xef]), }, @@ -199,8 +337,8 @@ await describe("E2EE Serialization", async () => { header: { version: 1, flags: 0, - senderClientId: 0xaaaabbbbccccddddn, - recipientClientId: 0xffff000011112222n, + senderId: 0xaaaabbbbccccddddn, + recipientId: 0xffff000011112222n, messageNumber: 65535, }, aeadPayload: new Uint8Array(100).fill(0x42), @@ -226,8 +364,8 @@ await describe("E2EE Serialization", async () => { header: { version: 1, flags: 0, - senderClientId: 0n, - recipientClientId: 0n, + senderId: 0n, + recipientId: 0n, messageNumber: 0, }, aeadPayload: new Uint8Array([1]), @@ -242,8 +380,8 @@ await describe("E2EE Serialization", async () => { header: { version: 1, flags: 0, - senderClientId: 0n, - recipientClientId: 0n, + senderId: 0n, + recipientId: 0n, messageNumber: 0, }, aeadPayload: new Uint8Array([1]), @@ -402,38 +540,1458 @@ await describe("E2EE Public Key Bundle", async () => { }); }); +await describe("Protected-value policy", async () => { + await it("enforces the receiver-selected signature suite", () => { + const keyring = sdk.crypto.generateKeyring(); + const bundle = publicBundle(keyring); + const value = bindings.encode_data_value("policy-check"); + const edSigned = bindings.sign_data_value_with_keyring( + value, + 7n, + 0x40, + keyring, + bindings.mtp_protection_signature_suite_ed25519(), + ); + assert.doesNotThrow(() => + bindings.verify_data_value_with_policy( + edSigned, + bundle, + 7n, + 0x40, + bindings.mtp_protection_signature_suite_ed25519(), + ), + ); + assert.throws(() => + bindings.verify_data_value_with_policy( + edSigned, + bundle, + 7n, + 0x40, + bindings.mtp_protection_signature_suite_dual(), + ), + ); + + const dualSigned = bindings.sign_data_value_with_keyring( + value, + 7n, + 0x40, + keyring, + bindings.mtp_protection_signature_suite_dual(), + ); + assert.doesNotThrow(() => + bindings.verify_data_value_with_policy( + dualSigned, + bundle, + 7n, + 0x40, + bindings.mtp_protection_signature_suite_dual(), + ), + ); + }); +}); + +await describe("Relay recipient separation", async () => { + await it("lets a metadata recipient open metadata but not content", async () => { + const senderKeyring = sdk.crypto.generateKeyring(); + const metadataKeyring = sdk.crypto.generateKeyring(); + const finalKeyring = sdk.crypto.generateKeyring(); + const metadataRecipients = [ + publicBundle(metadataKeyring), + publicBundle(finalKeyring), + ]; + const contentRecipients = [publicBundle(finalKeyring)]; + + const frameBytes = bindings.build_encrypted_relay_frame_with_keyring( + "ProtectedMessage", + { ExampleType: "hello" }, + 11n, + 42n, + 7n, + "message-1", + 123n, + bindings.encode_data_value({ ExampleType: "metadata" }), + senderKeyring, + bindings.mtp_protection_signature_suite_dual(), + metadataRecipients, + contentRecipients, + ); + const frame = bindings.parse_frame(frameBytes); + assert.equal(frame.type, "Relay"); + assert.equal(frame.sender, undefined); + assert.equal(frame.receiver, 7n); + + const metadataBytes = frame.data.encoded; + const openedMetadata = bindings.decrypt_data_value( + metadataBytes, + metadataKeyring, + bindings.mtp_relay_metadata_encryption_purpose(), + ); + const metadata = bindings.parse_data_value(openedMetadata); + assert.equal(metadata.kind, "signed"); + assert.equal(BigInt(metadata.value.RelayVersion), 1n); + assert.equal(BigInt(metadata.value.FinalRecipientId), 42n); + assert.equal(metadata.value.Metadata.ExampleType, "metadata"); + + const metadataClient = await sdk.MTPClient.create({ + url: "https://example.invalid", + credentials: { clientId: 7n, keyring: metadataKeyring }, + pings: false, + }); + const verifiedMetadata = await metadataClient.openRelayMetadata(frame, { + expectedSignerId: 11n, + resolveSignerPublicKeys: () => [publicBundle(senderKeyring)], + signaturePolicy: "dual", + }); + assert.equal(verifiedMetadata.relayVersion, 1); + assert.equal(verifiedMetadata.finalRecipientId, 42n); + assert.deepEqual(verifiedMetadata.metadata, { + ExampleType: "metadata", + }); + await assert.rejects( + () => metadataClient.openRelayContent(verifiedMetadata), + /different final recipient/, + ); + + const encryptedContent = metadata.value.Content.encoded; + assert.throws(() => + bindings.decrypt_data_value( + encryptedContent, + metadataKeyring, + bindings.mtp_relay_content_encryption_purpose(), + ), + ); + + const forwardedBytes = bindings.forward_encrypted_relay_frame(frameBytes, 42n); + const forwarded = bindings.parse_frame(forwardedBytes); + assert.deepEqual(forwarded.data.encoded, frame.data.encoded); + const finalMetadata = bindings.parse_data_value( + bindings.decrypt_data_value( + forwarded.data.encoded, + finalKeyring, + bindings.mtp_relay_metadata_encryption_purpose(), + ), + ); + assert.equal(BigInt(finalMetadata.value.RelayVersion), 1n); + const content = bindings.parse_data_value( + bindings.decrypt_data_value( + finalMetadata.value.Content.encoded, + finalKeyring, + bindings.mtp_relay_content_encryption_purpose(), + ), + ); + assert.equal(content.kind, "signed"); + assert.equal(content.value.MessageType, "ProtectedMessage"); + assert.equal(content.value.Content.ExampleType, "hello"); + }); + + await it("opens forwarded relay data with explicit identities", async () => { + const senderKeyring = sdk.crypto.generateKeyring(); + const metadataKeyring = sdk.crypto.generateKeyring(); + const finalKeyring = sdk.crypto.generateKeyring(); + const frameBytes = bindings.build_encrypted_relay_frame_with_keyring( + "ProtectedMessage", + { ExampleType: "explicit-identity" }, + 11n, + 42n, + 7n, + "message-explicit-identity", + 456n, + bindings.encode_data_value({ ExampleType: "forwarded" }), + senderKeyring, + bindings.mtp_protection_signature_suite_dual(), + [publicBundle(metadataKeyring), publicBundle(finalKeyring)], + [publicBundle(finalKeyring)], + ); + const forwarded = bindings.parse_frame( + bindings.forward_encrypted_relay_frame(frameBytes, 99n), + ); + const verification = { + expectedSignerId: 11n, + resolveSignerPublicKeys: () => [publicBundle(senderKeyring)], + signaturePolicy: "dual", + }; + + const metadataClient = await sdk.MTPClient.create({ + url: "https://example.invalid", + pings: false, + }); + const metadata = await metadataClient.openRelayMetadata(forwarded, { + ...verification, + recipient: { keyring: metadataKeyring }, + }); + assert.equal(metadata.finalRecipientId, 42n); + assert.deepEqual(metadata.metadata, { ExampleType: "forwarded" }); + + const wrongKeyring = sdk.crypto.generateKeyring(); + await assert.rejects( + () => + metadataClient.openRelayMetadata(forwarded, { + ...verification, + recipient: { keyring: wrongKeyring }, + }), + ); + + const registeredClient = await sdk.MTPClient.create({ + url: "https://example.invalid", + credentials: { clientId: 7n, keyring: metadataKeyring }, + pings: false, + }); + const finalIdentity = { id: 42n, keyring: finalKeyring }; + const finalMetadata = await registeredClient.openRelayMetadata( + forwarded, + { + ...verification, + recipient: finalIdentity, + }, + ); + const content = await registeredClient.openRelayContent(finalMetadata, { + ...verification, + recipient: finalIdentity, + }); + assert.equal(content.signerId, 11n); + assert.equal(content.finalRecipientId, 42n); + assert.equal(content.data.ExampleType, "explicit-identity"); + + await assert.rejects( + () => + registeredClient.openRelayContent(finalMetadata, { + ...verification, + recipient: finalIdentity, + expectedFinalRecipientId: 99n, + }), + /different final recipient/, + ); + }); +}); + +await describe("Relay recipient key history", async () => { + function verificationOptions(recipient) { + return { + recipient, + expectedSignerId: 11n, + resolveSignerPublicKeys: () => [publicBundle(senderKeyring)], + signaturePolicy: "dual", + }; + } + + let senderKeyring; + + await it("uses the current keyring and accepts an empty history", async () => { + senderKeyring = sdk.crypto.generateKeyring(); + const recipientKeyring = sdk.crypto.generateKeyring(); + const frame = bindings.parse_frame( + bindings.build_encrypted_relay_frame_with_keyring( + "ProtectedMessage", + { ExampleType: "current-key" }, + 11n, + 42n, + 42n, + "message-current-key", + 1n, + null, + senderKeyring, + bindings.mtp_protection_signature_suite_dual(), + [publicBundle(recipientKeyring)], + [publicBundle(recipientKeyring)], + ), + ); + const client = await sdk.MTPClient.create({ + url: "https://example.invalid", + pings: false, + }); + const options = verificationOptions({ + id: 42n, + keyring: recipientKeyring, + keyringHistory: [], + }); + + const metadata = await client.openRelayMetadata(frame, options); + const content = await client.openRelayContent(metadata, options); + assert.equal(content.data.ExampleType, "current-key"); + }); + + await it( + "tries rotated metadata and content recipients newest to oldest without duplicates", + async () => { + senderKeyring = sdk.crypto.generateKeyring(); + const currentKeyring = sdk.crypto.generateKeyring(); + const newerPreviousKeyring = sdk.crypto.generateKeyring(); + const previousKeyring = sdk.crypto.generateKeyring(); + const history = [ + newerPreviousKeyring, + previousKeyring, + previousKeyring, + ]; + const historySnapshot = history.map((keyring) => keyring.slice()); + const frame = bindings.parse_frame( + bindings.build_encrypted_relay_frame_with_keyring( + "ProtectedMessage", + { ExampleType: "rotated-key" }, + 11n, + 42n, + 7n, + "message-rotated-key", + 2n, + bindings.encode_data_value({ ExampleType: "rotated-metadata" }), + senderKeyring, + bindings.mtp_protection_signature_suite_dual(), + [publicBundle(previousKeyring)], + [publicBundle(previousKeyring)], + ), + ); + const client = await sdk.MTPClient.create({ + url: "https://example.invalid", + pings: false, + }); + const options = verificationOptions({ + id: 42n, + keyring: currentKeyring, + keyringHistory: history, + }); + + const metadata = await client.openRelayMetadata(frame, options); + const content = await client.openRelayContent(metadata, options); + assert.equal(content.data.ExampleType, "rotated-key"); + assert.equal(metadata.matchedSignerKeyIndex, 0); + assert.equal(metadata.signerPublicKeys.length, 1); + assert.deepEqual( + metadata.matchedSignerPublicKey, + publicBundle(senderKeyring), + ); + assert.deepEqual(history, historySnapshot); + }, + ); + + await it("does not expose individual key failures when all keyrings fail", async () => { + senderKeyring = sdk.crypto.generateKeyring(); + const recipientKeyring = sdk.crypto.generateKeyring(); + const wrongCurrentKeyring = sdk.crypto.generateKeyring(); + const wrongPreviousKeyring = sdk.crypto.generateKeyring(); + const frame = bindings.parse_frame( + bindings.build_encrypted_relay_frame_with_keyring( + "ProtectedMessage", + { ExampleType: "wrong-key" }, + 11n, + 42n, + 42n, + "message-wrong-key", + 3n, + null, + senderKeyring, + bindings.mtp_protection_signature_suite_dual(), + [publicBundle(recipientKeyring)], + [publicBundle(recipientKeyring)], + ), + ); + const client = await sdk.MTPClient.create({ + url: "https://example.invalid", + pings: false, + }); + + await assert.rejects( + () => + client.openRelayMetadata(frame, { + ...verificationOptions({ + keyring: wrongCurrentKeyring, + keyringHistory: [wrongPreviousKeyring], + }), + }), + (error) => + error.message === + "Unable to decrypt protected value with supplied recipient keyrings", + ); + }); +}); + +await describe("Protected send APIs", async () => { + function captureSend(client) { + const frames = []; + client.raw.client.send = async (frame) => { + frames.push(frame.slice()); + }; + return frames; + } + + await it("round trips with interoperable Ed25519 defaults", async () => { + const signerKeyring = sdk.crypto.generateKeyring(); + const recipientKeyring = sdk.crypto.generateKeyring(); + const client = await sdk.MTPClient.create({ + url: "https://example.invalid", + pings: false, + }); + const frames = captureSend(client); + const signerPublicKey = publicBundle(signerKeyring); + const recipientPublicKey = publicBundle(recipientKeyring); + + await client.sendProtected( + "ProtectedMessage", + { ExampleType: "default-direct" }, + { + receiverId: 22n, + identity: { signerId: 11n, keyring: signerKeyring }, + recipients: [recipientPublicKey], + signaturePurpose: 0x40, + encryptionPurpose: 0x41, + }, + ); + const directFrame = sdk.codec.decode(frames.shift()); + const direct = await client.openProtected(directFrame, { + recipient: { id: 22n, keyring: recipientKeyring }, + expectedSignerId: 11n, + expectedReceiverId: 22n, + resolveSignerPublicKeys: () => [signerPublicKey], + signaturePurpose: 0x40, + encryptionPurpose: 0x41, + }); + assert.equal(direct.data.ExampleType, "default-direct"); + + await client.sendSealedRelay( + "ProtectedMessage", + { ExampleType: "default-relay" }, + { + finalRecipientId: 22n, + nextHopId: 7n, + identity: { signerId: 11n, keyring: signerKeyring }, + metadataRecipients: [recipientPublicKey], + contentRecipients: [recipientPublicKey], + metadata: { ExampleType: "default-metadata" }, + }, + ); + const relayFrame = sdk.codec.decode(frames.shift()); + const relayOptions = { + recipient: { id: 22n, keyring: recipientKeyring }, + expectedSignerId: 11n, + resolveSignerPublicKeys: () => [signerPublicKey], + }; + const metadata = await client.openRelayMetadata(relayFrame, relayOptions); + const content = await client.openRelayContent(metadata, relayOptions); + assert.equal(metadata.metadata.ExampleType, "default-metadata"); + assert.equal(content.data.ExampleType, "default-relay"); + metadata.dispose(); + }); + + await it("builds direct protected frames without a Relay hop", async () => { + const signerKeyring = sdk.crypto.generateKeyring(); + const recipientKeyring = sdk.crypto.generateKeyring(); + const client = await sdk.MTPClient.create({ + url: "https://example.invalid", + pings: false, + }); + const frames = captureSend(client); + + await client.sendProtected( + "ProtectedMessage", + { ExampleType: "direct" }, + { + receiverId: 22n, + id: 123, + identity: { signerId: 11n, keyring: signerKeyring }, + recipients: [publicBundle(recipientKeyring)], + signaturePurpose: 0x40, + encryptionPurpose: 0x41, + signatureSuite: "dual", + exposeSender: true, + }, + ); + + assert.equal(frames.length, 1); + const frame = bindings.parse_frame(frames[0]); + assert.equal(frame.type, "ProtectedMessage"); + assert.equal(frame.id, 123); + assert.equal(frame.sender, 11n); + assert.equal(frame.receiver, 22n); + assert.equal(frame.data.kind, "encrypted"); + + const signedBytes = bindings.decrypt_data_value( + frame.data.encoded, + recipientKeyring, + 0x41, + ); + const signed = bindings.parse_data_value(signedBytes); + assert.equal(signed.kind, "signed"); + assert.equal(signed.signerId, 11n); + assert.equal(signed.value.Content.ExampleType, "direct"); + assert.equal(signed.value.MessageType, "ProtectedMessage"); + assert.equal(BigInt(signed.value.FinalRecipientId), 22n); + assert.equal(typeof signed.value.MessageId, "string"); + assert.equal(BigInt(signed.value.CreatedAt) > 0n, true); + bindings.verify_data_value_with_policy( + signedBytes, + publicBundle(signerKeyring), + 11n, + 0x40, + bindings.mtp_protection_signature_suite_dual(), + ); + + await assert.rejects( + () => + client.sendProtected("Ping", { ExampleType: "reserved" }, { + receiverId: 22n, + identity: { signerId: 11n, keyring: signerKeyring }, + recipients: [publicBundle(recipientKeyring)], + signaturePurpose: 0x40, + encryptionPurpose: 0x41, + }), + /control communication type/, + ); + }); + + await it("builds sealed relay frames from exact generic recipient sets", async () => { + const signerKeyring = sdk.crypto.generateKeyring(); + const metadataKeyring = sdk.crypto.generateKeyring(); + const contentKeyring = sdk.crypto.generateKeyring(); + const client = await sdk.MTPClient.create({ + url: "https://example.invalid", + pings: false, + }); + const frames = captureSend(client); + + await client.sendSealedRelay( + "ProtectedMessage", + { ExampleType: "content" }, + { + finalRecipientId: 42n, + nextHopId: 7n, + identity: { signerId: 11n, keyring: signerKeyring }, + metadataRecipients: [publicBundle(metadataKeyring)], + contentRecipients: [publicBundle(contentKeyring)], + metadata: { ExampleType: "metadata" }, + signatureSuite: "dual", + }, + ); + + assert.equal(frames.length, 1); + const frame = bindings.parse_frame(frames[0]); + assert.equal(frame.type, "Relay"); + assert.equal(frame.sender, undefined); + assert.equal(frame.receiver, 7n); + + const metadataBytes = bindings.decrypt_data_value( + frame.data.encoded, + metadataKeyring, + bindings.mtp_relay_metadata_encryption_purpose(), + ); + const metadata = bindings.parse_data_value(metadataBytes); + assert.equal(metadata.kind, "signed"); + assert.equal(metadata.signerId, 11n); + assert.equal(BigInt(metadata.value.FinalRecipientId), 42n); + assert.equal(metadata.value.Metadata.ExampleType, "metadata"); + assert.equal(metadata.value.Content.kind, "encrypted"); + + assert.throws(() => + bindings.decrypt_data_value( + metadata.value.Content.encoded, + metadataKeyring, + bindings.mtp_relay_content_encryption_purpose(), + ), + ); + const contentBytes = bindings.decrypt_data_value( + metadata.value.Content.encoded, + contentKeyring, + bindings.mtp_relay_content_encryption_purpose(), + ); + const content = bindings.parse_data_value(contentBytes); + assert.equal(content.kind, "signed"); + assert.equal(content.value.Content.ExampleType, "content"); + + await assert.rejects( + () => + client.sendSealedRelay("Relay", { ExampleType: "reserved" }, { + finalRecipientId: 42n, + nextHopId: 7n, + identity: { signerId: 11n, keyring: signerKeyring }, + metadataRecipients: [publicBundle(metadataKeyring)], + contentRecipients: [publicBundle(contentKeyring)], + }), + /control communication type/, + ); + }); + + await it("round trips generic relay content and metadata presence", async () => { + const signerKeyring = sdk.crypto.generateKeyring(); + const recipientKeyring = sdk.crypto.generateKeyring(); + const client = await sdk.MTPClient.create({ + url: "https://example.invalid", + pings: false, + }); + const frames = captureSend(client); + const openOptions = { + recipient: { id: 42n, keyring: recipientKeyring }, + expectedSignerId: 11n, + resolveSignerPublicKeys: () => [publicBundle(signerKeyring)], + signaturePolicy: "dual", + }; + + const sendAndOpen = async (data, metadata, includeMetadata) => { + const options = { + finalRecipientId: 42n, + nextHopId: 42n, + identity: { signerId: 11n, keyring: signerKeyring }, + metadataRecipients: [publicBundle(recipientKeyring)], + contentRecipients: [publicBundle(recipientKeyring)], + signatureSuite: "dual", + ...(includeMetadata ? { metadata } : {}), + }; + await client.sendSealedRelay("ProtectedMessage", data, options); + const frame = sdk.codec.decode(frames.pop()); + const verifiedMetadata = await client.openRelayMetadata( + frame, + openOptions, + ); + const content = await client.openRelayContent( + verifiedMetadata, + openOptions, + ); + return { verifiedMetadata, content }; + }; + + const explicitNull = await sendAndOpen( + new Uint8Array([1, 2, 3]), + null, + true, + ); + assert.equal(explicitNull.verifiedMetadata.metadata, null); + assert.deepEqual(explicitNull.content.data, new Uint8Array([1, 2, 3])); + + const absent = await sendAndOpen( + ["array", 7, false], + undefined, + false, + ); + assert.equal(absent.verifiedMetadata.metadata, undefined); + assert.deepEqual(absent.content.data, ["array", 7, false]); + + const scalar = await sendAndOpen("scalar content", "scalar metadata", true); + assert.equal(scalar.verifiedMetadata.metadata, "scalar metadata"); + assert.equal(scalar.content.data, "scalar content"); + }); + + await it("opens an unauthenticated connection without discarding credentials", async () => { + const keyring = sdk.crypto.generateKeyring(); + const client = await sdk.MTPClient.create({ + url: "https://example.invalid", + hostPublicKey: new Uint8Array([1]), + credentials: { clientId: 99n, keyring }, + pings: false, + }); + let connectCalls = 0; + let authCalls = 0; + client.raw.client.connect = async () => { + connectCalls += 1; + }; + client.raw.client.auth_connect = async () => { + authCalls += 1; + return 99n; + }; + + await client.connectUnauthenticated(); + + assert.equal(connectCalls, 1); + assert.equal(authCalls, 0); + assert.equal(client.credentials.clientId, 99n); + }); + + await it("opens an authenticated connection when transport credentials are available", async () => { + const transportKeyring = sdk.crypto.generateKeyring(); + const client = await sdk.MTPClient.create({ + url: "https://example.invalid", + hostPublicKey: new Uint8Array([1]), + credentials: { clientId: 99n, keyring: transportKeyring }, + pings: false, + }); + let connectCalls = 0; + let authCalls = 0; + client.raw.client.connect = async () => { + connectCalls += 1; + }; + client.raw.client.auth_connect = async ( + _config, + hostPublicKey, + keyring, + clientId, + ) => { + authCalls += 1; + assert.deepEqual(hostPublicKey, new Uint8Array([1])); + assert.deepEqual(keyring, transportKeyring); + assert.equal(clientId, 99n); + return 99n; + }; + + await client.connect(); + + assert.equal(connectCalls, 0); + assert.equal(authCalls, 1); + assert.equal(client.credentials.clientId, 99n); + }); + + await it("keeps a protected signer independent from transport authentication", async () => { + const transportKeyring = sdk.crypto.generateKeyring(); + const signerKeyring = sdk.crypto.generateKeyring(); + const recipientKeyring = sdk.crypto.generateKeyring(); + const client = await sdk.MTPClient.create({ + url: "https://example.invalid", + hostPublicKey: new Uint8Array([1]), + credentials: { clientId: 99n, keyring: transportKeyring }, + pings: false, + }); + client.raw.client.auth_connect = async () => 99n; + await client.connect(); + const frames = captureSend(client); + + await client.sendProtected( + "ProtectedMessage", + { ExampleType: "transport-independent" }, + { + receiverId: 22n, + identity: { signerId: 11n, keyring: signerKeyring }, + recipients: [publicBundle(recipientKeyring)], + signaturePurpose: 0x40, + encryptionPurpose: 0x41, + signatureSuite: "dual", + }, + ); + + const frame = bindings.parse_frame(frames[0]); + assert.equal(client.credentials.clientId, 99n); + assert.equal(frame.sender, undefined); + const signedBytes = bindings.decrypt_data_value( + frame.data.encoded, + recipientKeyring, + 0x41, + ); + const signed = bindings.parse_data_value(signedBytes); + assert.equal(signed.kind, "signed"); + assert.equal(signed.signerId, 11n); + assert.equal(signed.value.Content.ExampleType, "transport-independent"); + }); +}); + +await describe("Protected receive APIs", async () => { + async function buildDirectFrame({ + signerKeyring, + recipientKeyring, + data = { ExampleType: "direct-receive" }, + exposeSender = true, + receiverId = 22n, + signaturePurpose = 0x40, + encryptionPurpose = 0x41, + signatureSuite = "dual", + }) { + const client = await sdk.MTPClient.create({ + url: "https://example.invalid", + pings: false, + }); + const frames = []; + client.raw.client.send = async (frame) => frames.push(frame.slice()); + await client.sendProtected( + "ProtectedMessage", + data, + { + receiverId, + identity: { signerId: 11n, keyring: signerKeyring }, + recipients: [publicBundle(recipientKeyring)], + signaturePurpose, + encryptionPurpose, + signatureSuite, + exposeSender, + }, + ); + return frames[0]; + } + + function openOptions(signerKeyring, recipientKeyring, overrides = {}) { + return { + recipient: { id: 22n, keyring: recipientKeyring }, + expectedSignerId: 11n, + expectedReceiverId: 22n, + resolveSignerPublicKeys: (signerId) => { + assert.equal(signerId, 11n); + return [publicBundle(signerKeyring)]; + }, + signaturePolicy: "dual", + signaturePurpose: 0x40, + encryptionPurpose: 0x41, + ...overrides, + }; + } + + await it("opens exposed and hidden outer senders", async () => { + const signerKeyring = sdk.crypto.generateKeyring(); + const recipientKeyring = sdk.crypto.generateKeyring(); + const client = await sdk.MTPClient.create({ + url: "https://example.invalid", + pings: false, + }); + + const exposed = await client.openProtected( + await buildDirectFrame({ signerKeyring, recipientKeyring }), + openOptions(signerKeyring, recipientKeyring), + ); + assert.equal(exposed.type, "ProtectedMessage"); + assert.equal(exposed.protectedVersion, 1); + assert.equal(exposed.signerId, 11n); + assert.equal(exposed.finalRecipientId, 22n); + assert.equal(exposed.receiver, 22n); + assert.equal(exposed.outerSender, 11n); + assert.equal(typeof exposed.messageId, "string"); + assert.equal(typeof exposed.createdAt, "bigint"); + assert.deepEqual(exposed.data, { + ExampleType: "direct-receive", + }); + + const hidden = await client.openProtected( + await buildDirectFrame({ + signerKeyring, + recipientKeyring, + exposeSender: false, + }), + openOptions(signerKeyring, recipientKeyring), + ); + assert.equal(hidden.outerSender, undefined); + assert.equal(hidden.signerId, 11n); + }); + + await it("authenticates direct routing fields and rejects direct replays", async () => { + const signerKeyring = sdk.crypto.generateKeyring(); + const recipientKeyring = sdk.crypto.generateKeyring(); + const client = await sdk.MTPClient.create({ + url: "https://example.invalid", + pings: false, + }); + const frame = await buildDirectFrame({ signerKeyring, recipientKeyring }); + + const changedType = frame.slice(); + changedType[4] = 0; + changedType[5] = 33; + await assert.rejects( + () => + client.openProtected( + changedType, + openOptions(signerKeyring, recipientKeyring), + ), + /protected message type does not match outer routing/, + ); + + const parsed = bindings.parse_frame(frame); + const changedSender = bindings.build_frame_with_payload( + "ProtectedMessage", + parsed.data.encoded, + { receiver: 22n, sender: 12n }, + ); + await assert.rejects( + () => + client.openProtected( + changedSender, + openOptions(signerKeyring, recipientKeyring), + ), + /protected frame sender does not match authenticated signer/, + ); + + const changedReceiver = await buildDirectFrame({ + signerKeyring, + recipientKeyring, + exposeSender: false, + }); + // The frame has an ID and receiver but no sender, so the receiver's final + // byte is at offset 18 in the MTP wire header. + changedReceiver[18] ^= 1; + await assert.rejects( + () => + client.openProtected( + changedReceiver, + openOptions(signerKeyring, recipientKeyring, { + recipient: { keyring: recipientKeyring }, + expectedReceiverId: undefined, + }), + ), + /protected final recipient does not match outer routing receiver/, + ); + + const replayOptions = openOptions(signerKeyring, recipientKeyring); + await client.openProtected(frame, replayOptions); + await assert.rejects( + () => client.openProtected(frame, replayOptions), + (error) => error instanceof sdk.MTPReplayError, + ); + }); + + await it("snapshots parsed direct frames before async signer resolution", async () => { + const signerKeyring = sdk.crypto.generateKeyring(); + const recipientKeyring = sdk.crypto.generateKeyring(); + const serialized = await buildDirectFrame({ + signerKeyring, + recipientKeyring, + }); + const parsed = bindings.parse_frame(serialized); + delete parsed.raw; + + let releaseKeys; + const keysReady = new Promise((resolve) => { + releaseKeys = resolve; + }); + const client = await sdk.MTPClient.create({ + url: "https://example.invalid", + pings: false, + }); + const opening = client.openProtected(parsed, { + recipient: { id: 22n, keyring: recipientKeyring }, + resolveSignerPublicKeys: async () => { + await keysReady; + return [publicBundle(signerKeyring)]; + }, + signaturePolicy: "dual", + signaturePurpose: 0x40, + encryptionPurpose: 0x41, + }); + + parsed.receiver = 23n; + releaseKeys(); + const message = await opening; + assert.equal(message.receiver, 22n); + }); + + await it("validates the expected signer and outer receiver", async () => { + const signerKeyring = sdk.crypto.generateKeyring(); + const recipientKeyring = sdk.crypto.generateKeyring(); + const frame = await buildDirectFrame({ signerKeyring, recipientKeyring }); + const client = await sdk.MTPClient.create({ + url: "https://example.invalid", + pings: false, + }); + + let resolverCalls = 0; + await assert.rejects( + () => + client.openProtected( + frame, + openOptions(signerKeyring, recipientKeyring, { + expectedSignerId: 99n, + resolveSignerPublicKeys: () => { + resolverCalls += 1; + return [publicBundle(signerKeyring)]; + }, + }), + ), + /protected signer ID mismatch/, + ); + assert.equal( + resolverCalls, + 0, + "expected signer mismatch must precede signer-key resolution", + ); + await assert.rejects( + () => + client.openProtected( + frame, + openOptions(signerKeyring, recipientKeyring, { + expectedReceiverId: 23n, + }), + ), + /protected frame receiver ID mismatch/, + ); + }); + + await it("uses current and historical recipient keyrings", async () => { + const signerKeyring = sdk.crypto.generateKeyring(); + const currentRecipientKeyring = sdk.crypto.generateKeyring(); + const historicalRecipientKeyring = sdk.crypto.generateKeyring(); + const client = await sdk.MTPClient.create({ + url: "https://example.invalid", + pings: false, + }); + + const currentFrame = await buildDirectFrame({ + signerKeyring, + recipientKeyring: currentRecipientKeyring, + }); + const current = await client.openProtected( + currentFrame, + openOptions(signerKeyring, currentRecipientKeyring), + ); + assert.equal(current.data.ExampleType, "direct-receive"); + + const historicalFrame = await buildDirectFrame({ + signerKeyring, + recipientKeyring: historicalRecipientKeyring, + }); + const historical = await client.openProtected( + historicalFrame, + openOptions(signerKeyring, currentRecipientKeyring, { + recipient: { + id: 22n, + keyring: currentRecipientKeyring, + keyringHistory: [historicalRecipientKeyring], + }, + }), + ); + assert.equal(historical.data.ExampleType, "direct-receive"); + + await assert.rejects( + () => + client.openProtected( + historicalFrame, + openOptions(signerKeyring, sdk.crypto.generateKeyring()), + ), + /Unable to decrypt protected value with supplied recipient keyrings/, + ); + }); + + await it("round trips non-container application DataValues", async () => { + const signerKeyring = sdk.crypto.generateKeyring(); + const recipientKeyring = sdk.crypto.generateKeyring(); + const client = await sdk.MTPClient.create({ + url: "https://example.invalid", + pings: false, + }); + const options = openOptions(signerKeyring, recipientKeyring); + + const stringMessage = await client.openProtected( + await buildDirectFrame({ + signerKeyring, + recipientKeyring, + data: "direct string", + }), + options, + ); + assert.equal(stringMessage.data, "direct string"); + + const arrayMessage = await client.openProtected( + await buildDirectFrame({ + signerKeyring, + recipientKeyring, + data: ["direct array", 7, false], + }), + options, + ); + assert.deepEqual(arrayMessage.data, ["direct array", 7, false]); + + const bytesMessage = await client.openProtected( + await buildDirectFrame({ + signerKeyring, + recipientKeyring, + data: new Uint8Array([3, 1, 4]), + }), + options, + ); + assert.deepEqual(bytesMessage.data, new Uint8Array([3, 1, 4])); + + const largeIntegerMessage = await client.openProtected( + await buildDirectFrame({ + signerKeyring, + recipientKeyring, + data: 9_007_199_254_740_992n, + }), + options, + ); + assert.equal(largeIntegerMessage.data, 9_007_199_254_740_992n); + + const safeIntegerMessage = await client.openProtected( + await buildDirectFrame({ + signerKeyring, + recipientKeyring, + data: 9_007_199_254_740_991, + }), + options, + ); + assert.equal(safeIntegerMessage.data, 9_007_199_254_740_991); + + const largeSignedMessage = await client.openProtected( + await buildDirectFrame({ + signerKeyring, + recipientKeyring, + data: -9_007_199_254_740_992n, + }), + options, + ); + assert.equal(largeSignedMessage.data, -9_007_199_254_740_992n); + + const largeUnsignedMessage = await client.openProtected( + await buildDirectFrame({ + signerKeyring, + recipientKeyring, + data: 18_446_744_073_709_551_615n, + }), + options, + ); + assert.equal(largeUnsignedMessage.data, 18_446_744_073_709_551_615n); + + await assert.rejects( + () => + client.sendProtected( + "ProtectedMessage", + 9_007_199_254_740_992, + { + receiverId: 22n, + identity: { signerId: 11n, keyring: signerKeyring }, + recipients: [publicBundle(recipientKeyring)], + signaturePurpose: 0x40, + encryptionPurpose: 0x41, + signatureSuite: "dual", + }, + ), + /unsafe integral MTP DataValue inputs must use bigint/, + ); + }); + + await it("requires the configured purposes and verifies the protected signature", async () => { + const signerKeyring = sdk.crypto.generateKeyring(); + const recipientKeyring = sdk.crypto.generateKeyring(); + const client = await sdk.MTPClient.create({ + url: "https://example.invalid", + pings: false, + }); + const frame = await buildDirectFrame({ signerKeyring, recipientKeyring }); + + await assert.rejects( + () => + client.openProtected( + frame, + openOptions(signerKeyring, recipientKeyring, { + signaturePurpose: 0x42, + }), + ), + ); + await assert.rejects( + () => + client.openProtected( + frame, + openOptions(signerKeyring, recipientKeyring, { + encryptionPurpose: 0x42, + }), + ), + ); + + const parsed = bindings.parse_frame(frame); + const signedBytes = bindings.decrypt_data_value( + parsed.data.encoded, + recipientKeyring, + 0x41, + ); + const tamperedSigned = signedBytes.slice(); + tamperedSigned[1 + 4 + 1 + 1 + 8] ^= 0xff; + const tamperedPayload = bindings.encrypt_data_value_for_recipients( + tamperedSigned, + [publicBundle(recipientKeyring)], + 0x41, + ); + const tamperedFrame = bindings.build_frame_with_payload( + "ProtectedMessage", + tamperedPayload, + { receiver: 22n, sender: 11n }, + ); + await assert.rejects(() => + client.openProtected( + tamperedFrame, + openOptions(signerKeyring, recipientKeyring), + ), + ); + }); + + await it("resolves policy independently from recipient signing capabilities", async () => { + const signerKeyring = sdk.crypto.generateKeyring(); + const recipientKeyring = sdk.crypto.generateKeyring(); + const edOnlyRecipientKeyring = ed25519OnlyEncryptionKeyring(recipientKeyring); + const client = await sdk.MTPClient.create({ + url: "https://example.invalid", + defaultSignatureVerificationPolicy: "dual", + pings: false, + }); + const dualFrame = await buildDirectFrame({ + signerKeyring, + recipientKeyring, + signatureSuite: "dual", + }); + const defaultOpened = await client.openProtected( + dualFrame, + openOptions(signerKeyring, edOnlyRecipientKeyring, { + signaturePolicy: undefined, + }), + ); + assert.equal(defaultOpened.data.ExampleType, "direct-receive"); + + const edFrame = await buildDirectFrame({ + signerKeyring, + recipientKeyring, + signatureSuite: "ed25519", + }); + const overridden = await client.openProtected( + edFrame, + openOptions(signerKeyring, edOnlyRecipientKeyring, { + signaturePolicy: "ed25519", + }), + ); + assert.equal(overridden.data.ExampleType, "direct-receive"); + + await assert.rejects( + () => + client.openProtected( + edFrame, + openOptions(signerKeyring, edOnlyRecipientKeyring, { + signaturePolicy: undefined, + }), + ), + (error) => + error instanceof sdk.MTPSignatureVerificationError && + error.code === "policy-rejected", + ); + + const libraryDefaultClient = await sdk.MTPClient.create({ + url: "https://example.invalid", + pings: false, + }); + const libraryDefault = await libraryDefaultClient.openProtected( + edFrame, + openOptions(signerKeyring, edOnlyRecipientKeyring, { + signaturePolicy: undefined, + }), + ); + assert.equal(libraryDefaultClient.defaultSignatureVerificationPolicy, "ed25519"); + assert.equal(libraryDefault.data.ExampleType, "direct-receive"); + }); + + await it("reports unavailable signer keys and invalid signatures separately", async () => { + const signerKeyring = sdk.crypto.generateKeyring(); + const recipientKeyring = sdk.crypto.generateKeyring(); + const client = await sdk.MTPClient.create({ + url: "https://example.invalid", + pings: false, + }); + const frame = await buildDirectFrame({ signerKeyring, recipientKeyring }); + + await assert.rejects( + () => + client.openProtected( + frame, + openOptions(signerKeyring, recipientKeyring, { + resolveSignerPublicKeys: () => [], + }), + ), + (error) => + error instanceof sdk.MTPSignatureVerificationError && + error.code === "signer-keys-unavailable", + ); + + await assert.rejects( + () => + client.openProtected( + frame, + openOptions(signerKeyring, recipientKeyring, { + resolveSignerPublicKeys: async () => { + throw new Error("signer key store unavailable"); + }, + }), + ), + (error) => + error instanceof sdk.MTPSignatureVerificationError && + error.code === "signer-keys-unavailable", + ); + + const parsed = bindings.parse_frame(frame); + const signedBytes = bindings.decrypt_data_value( + parsed.data.encoded, + recipientKeyring, + 0x41, + ); + const tamperedSigned = signedBytes.slice(); + tamperedSigned[1 + 4 + 1 + 1 + 8] ^= 0xff; + const tamperedPayload = bindings.encrypt_data_value_for_recipients( + tamperedSigned, + [publicBundle(recipientKeyring)], + 0x41, + ); + const tamperedFrame = bindings.build_frame_with_payload( + "ProtectedMessage", + tamperedPayload, + { receiver: 22n, sender: 11n }, + ); + await assert.rejects( + () => + client.openProtected( + tamperedFrame, + openOptions(signerKeyring, recipientKeyring), + ), + (error) => + error instanceof sdk.MTPSignatureVerificationError && + error.code === "invalid-signature", + ); + }); + + await it("rejects an unresolved communication type", async () => { + const signerKeyring = sdk.crypto.generateKeyring(); + const recipientKeyring = sdk.crypto.generateKeyring(); + const frame = await buildDirectFrame({ signerKeyring, recipientKeyring }); + const unknownTypeFrame = frame.slice(); + unknownTypeFrame[4] = 0; + unknownTypeFrame[5] = 0xff; + const client = await sdk.MTPClient.create({ + url: "https://example.invalid", + pings: false, + }); + + await assert.rejects( + () => + client.openProtected( + unknownTypeFrame, + openOptions(signerKeyring, recipientKeyring), + ), + /Unknown communication type/, + ); + }); + + await it("uses the shared opening path for subscriptions", async () => { + const signerKeyring = sdk.crypto.generateKeyring(); + const recipientKeyring = sdk.crypto.generateKeyring(); + const frame = bindings.parse_frame( + await buildDirectFrame({ signerKeyring, recipientKeyring }), + ); + const client = await sdk.MTPClient.create({ + url: "https://example.invalid", + pings: false, + }); + const originalSubscribe = client.raw.client.subscribe; + const originalUnsubscribe = client.raw.client.unsubscribe; + let subscribedType; + let subscribedHandler; + let unsubscribedId; + client.raw.client.subscribe = (type, handler) => { + subscribedType = type; + subscribedHandler = handler; + return 23; + }; + client.raw.client.unsubscribe = (id) => { + unsubscribedId = id; + return true; + }; + + try { + const received = []; + const unsubscribe = client.subscribeProtected( + "ProtectedMessage", + (message, receivedFrame) => received.push({ message, receivedFrame }), + openOptions(signerKeyring, recipientKeyring), + ); + assert.equal(subscribedType, "ProtectedMessage"); + await subscribedHandler(frame); + assert.equal(received.length, 1); + assert.equal(received[0].message.signerId, 11n); + assert.equal(received[0].receivedFrame.type, "ProtectedMessage"); + unsubscribe(); + assert.equal(unsubscribedId, 23); + } finally { + client.raw.client.subscribe = originalSubscribe; + client.raw.client.unsubscribe = originalUnsubscribe; + } + }); + + await it("keeps protected subscription replay guards independent", async () => { + const signerKeyring = sdk.crypto.generateKeyring(); + const recipientKeyring = sdk.crypto.generateKeyring(); + const frame = bindings.parse_frame( + await buildDirectFrame({ + signerKeyring, + recipientKeyring, + signatureSuite: "dual", + }), + ); + const client = await sdk.MTPClient.create({ + url: "https://example.invalid", + pings: false, + }); + const originalSubscribe = client.raw.client.subscribe; + const originalUnsubscribe = client.raw.client.unsubscribe; + const subscriptions = []; + client.raw.client.subscribe = (type, handler) => { + const id = subscriptions.length + 1; + subscriptions.push({ type, handler, id }); + return id; + }; + client.raw.client.unsubscribe = () => true; + + try { + const received = [0, 0]; + const options = openOptions(signerKeyring, recipientKeyring); + client.subscribeProtected( + "ProtectedMessage", + () => { + received[0] += 1; + }, + options, + ); + client.subscribeProtected( + "ProtectedMessage", + () => { + received[1] += 1; + }, + options, + ); + + assert.equal(subscriptions.length, 2); + await subscriptions[0].handler(frame); + await subscriptions[1].handler(frame); + assert.deepEqual(received, [1, 1]); + } finally { + client.raw.client.subscribe = originalSubscribe; + client.raw.client.unsubscribe = originalUnsubscribe; + } + }); +}); + await describe("E2EE Session Manager", async () => { - await it("getConversationId is consistent regardless of order", () => { - const id1 = getConversationId(5n, 10n); - const id2 = getConversationId(10n, 5n); + await it("derivePeerSessionId is consistent regardless of order", () => { + const id1 = derivePeerSessionId(5n, 10n); + const id2 = derivePeerSessionId(10n, 5n); assert.equal(id1, id2); }); - await it("MTPSessionManager creates, retrieves, and deletes sessions", async () => { + await it("stores independent sessions by explicit session ID", async () => { const ss = sdk.crypto.sha256(new Uint8Array([1, 2, 3])); const storage = new InMemorySessionStorage(); const manager = new MTPSessionManager(storage); + const sessionId = "independent-session"; - assert.equal(await manager.getSession(1n, 2n), null); + assert.equal(await manager.getSession(sessionId), null); const session = await manager.createSession({ - ownClientId: 1n, - peerClientId: 2n, - peerPublicKey: new Uint8Array(32), + sessionId, + localId: 1n, + remoteId: 2n, + remotePublicKey: new Uint8Array(32), sharedSecret: ss, role: "initiator", + transcriptContext: { + sessionId, + initiatorId: 1n, + recipientId: 2n, + recipientPublicKey: new Uint8Array(32), + kemCiphertext: new Uint8Array([1]), + applicationContext: new Uint8Array([2]), + }, }); assert.equal(session.version, 1); assert.equal(session.sendCount, 0); assert.equal(session.recvCount, 0); await manager.saveSession(session); - const retrieved = await manager.getSession(1n, 2n); + const retrieved = await manager.getSession(sessionId); assert.notEqual(retrieved, null); - assert.equal(retrieved.conversationId, session.conversationId); + assert.equal(retrieved.sessionId, session.sessionId); - await manager.deleteSession(1n, 2n); - assert.equal(await manager.getSession(1n, 2n), null); + const otherSession = await manager.createSession({ + sessionId: "another-session", + localId: 1n, + remoteId: 2n, + remotePublicKey: new Uint8Array(32), + sharedSecret: ss, + role: "initiator", + transcript: new Uint8Array([2]), + }); + await manager.saveSession(otherSession); + assert.notEqual(await manager.getSession(sessionId), null); + assert.notEqual(await manager.getSession(otherSession.sessionId), null); + + await manager.deleteSession(sessionId); + assert.equal(await manager.getSession(sessionId), null); + assert.notEqual(await manager.getSession(otherSession.sessionId), null); }); }); @@ -543,3 +2101,696 @@ await describe("E2EE Tamper Detection", async () => { ); }); }); + +await describe("Encrypted Pipe", async () => { + await it("rejects MTP-reserved application purposes", () => { + assert.throws( + () => new sdk.MTPPipeProtectionContext(new Uint8Array([1]), 0x30, 0), + /reserved/, + ); + }); + + await it("authenticates records and requires a final record", async () => { + const [writerPipe, readerPipe] = memoryDuplexPair(); + const context = new sdk.MTPPipeProtectionContext( + new Uint8Array([1, 2, 3]), + 0x40, + 0, + ); + const writer = new sdk.MTPEncryptedPipeWriter( + writerPipe, + new Uint8Array(32).fill(7), + context, + ); + const reader = new sdk.MTPEncryptedPipeReader( + readerPipe, + new Uint8Array(32).fill(7), + context, + ); + + await writer.writeRecord(new Uint8Array([1, 2, 3])); + assert.deepEqual(await reader.readRecord(), new Uint8Array([1, 2, 3])); + await writer.close(); + assert.equal(await reader.readRecord(), null); + assert.equal(await reader.readRecord(), null); + }); + + await it("poisons the reader after a truncated stream", async () => { + const [writerPipe, readerPipe] = memoryDuplexPair(); + const context = new sdk.MTPPipeProtectionContext( + new Uint8Array([4, 5, 6]), + 0x40, + 0, + ); + const writer = new sdk.MTPEncryptedPipeWriter( + writerPipe, + new Uint8Array(32).fill(8), + context, + ); + const reader = new sdk.MTPEncryptedPipeReader( + readerPipe, + new Uint8Array(32).fill(8), + context, + ); + await writer.writeRecord(new Uint8Array([9])); + writerPipe.abort(); + assert.deepEqual(await reader.readRecord(), new Uint8Array([9])); + await assert.rejects(() => reader.readRecord(), /final|truncated/i); + await assert.rejects(() => reader.readRecord(), /state|readable/i); + }); + + await it("poisons the writer after a transport write failure", async () => { + const [writerPipe] = memoryDuplexPair(); + const context = new sdk.MTPPipeProtectionContext( + new Uint8Array([7, 8, 9]), + 0x40, + 0, + ); + const writer = new sdk.MTPEncryptedPipeWriter( + writerPipe, + new Uint8Array(32).fill(6), + context, + ); + writerPipe.abort(); + await assert.rejects(() => writer.writeRecord(new Uint8Array([1])), /write|closed/i); + await assert.rejects(() => writer.writeRecord(new Uint8Array([2])), /state|writable/i); + }); + + await it("establishes a forward-secure authenticated duplex session", async () => { + const senderKeyring = sdk.crypto.generateKeyring(); + const currentSenderKeyring = sdk.crypto.generateKeyring(); + const recipientKeyring = sdk.crypto.generateKeyring(); + const senderBundle = publicBundle(senderKeyring); + const currentSenderBundle = publicBundle(currentSenderKeyring); + const recipientBundle = publicBundle(recipientKeyring); + const [initiatorPipe, responderPipe] = memoryDuplexPair(91); + const sessionId = new Uint8Array(32).fill(0x42); + const params = { + sessionId, + pipeId: 91, + senderId: 11n, + recipientId: 22n, + purpose: 0x40, + direction: 0, + }; + + const responder = sdk.acceptMTPForwardSecurePipeSession( + responderPipe, + { + pipeId: params.pipeId, + senderId: params.senderId, + recipientId: params.recipientId, + purpose: params.purpose, + direction: params.direction, + }, + recipientKeyring, + [currentSenderBundle, senderBundle], + "dual", + "dual", + ); + const initiator = await sdk.initiateMTPForwardSecurePipeSession( + initiatorPipe, + params, + senderKeyring, + recipientBundle, + "dual", + "dual", + ); + const receiver = await responder; + await initiator.writeRecord(new Uint8Array([0xaa, 0xbb])); + assert.deepEqual(await receiver.readRecord(), new Uint8Array([0xaa, 0xbb])); + await initiator.close(); + assert.equal(await receiver.readRecord(), null); + }); + + await it("uses interoperable Ed25519 defaults for pipe handshakes", async () => { + const senderKeyring = sdk.crypto.generateKeyring(); + const recipientKeyring = sdk.crypto.generateKeyring(); + const [initiatorPipe, responderPipe] = memoryDuplexPair(93); + const params = { + sessionId: new Uint8Array([9, 3]), + pipeId: 93, + senderId: 11n, + recipientId: 22n, + purpose: 0x40, + direction: 0, + }; + + const responder = sdk.acceptMTPForwardSecurePipeSession( + responderPipe, + params, + recipientKeyring, + publicBundle(senderKeyring), + ); + const initiator = await sdk.initiateMTPForwardSecurePipeSession( + initiatorPipe, + params, + senderKeyring, + publicBundle(recipientKeyring), + ); + const receiver = await responder; + await initiator.writeRecord(new Uint8Array([0x55])); + assert.deepEqual(await receiver.readRecord(), new Uint8Array([0x55])); + await initiator.close(); + assert.equal(await receiver.readRecord(), null); + }); + + await it("accepts a dual sender with an Ed25519-only recipient keyring", async () => { + const senderKeyring = sdk.crypto.generateKeyring(); + const recipientKeyring = sdk.crypto.generateKeyring(); + const recipientEncryptionKeyring = ed25519OnlyEncryptionKeyring( + recipientKeyring, + ); + const [writerPipe, readerPipe] = memoryDuplexPair(92); + const params = { + sessionId: new Uint8Array([9, 2]), + pipeId: 92, + senderId: 11n, + recipientId: 22n, + purpose: 0x40, + direction: 0, + }; + const readerPromise = sdk.acceptMTPPipeSession( + readerPipe, + params, + recipientEncryptionKeyring, + publicBundle(senderKeyring), + "dual", + ); + const writer = await sdk.initiateMTPPipeSession( + writerPipe, + params, + senderKeyring, + publicBundle(recipientKeyring), + "dual", + ); + const reader = await readerPromise; + await writer.writeRecord(new Uint8Array([0x11])); + assert.deepEqual(await reader.readRecord(), new Uint8Array([0x11])); + }); +}); + +await describe("Relay API invariants", async () => { + await it("exposes stable codes for raw relay operation errors", () => { + const ping = bindings.build_ping_frame( + 7n, + "not-a-relay", + 1n, + new Uint8Array(), + ); + assert.throws( + () => bindings.forward_encrypted_relay_frame(ping, 9n), + (error) => error?.code === "not-relay", + ); + + assert.throws( + () => bindings.forward_encrypted_relay_frame(new Uint8Array([0xff]), 9n), + (error) => error?.code === "invalid-frame", + ); + }); + + await it("uses the client default for metadata and content independently of recipient PQ keys", async () => { + const senderKeyring = sdk.crypto.generateKeyring(); + const recipientKeyring = sdk.crypto.generateKeyring(); + const frame = sdk.codec.decode( + bindings.build_encrypted_relay_frame_with_keyring( + "ProtectedMessage", + { ExampleType: "default-policy" }, + 11n, + 42n, + 42n, + "message-default-policy", + 456n, + null, + senderKeyring, + bindings.mtp_protection_signature_suite_ed25519(), + [publicBundle(recipientKeyring)], + [publicBundle(recipientKeyring)], + ), + ); + const client = await sdk.MTPClient.create({ + url: "https://example.invalid", + defaultSignatureVerificationPolicy: "ed25519", + pings: false, + }); + const options = { + recipient: { id: 42n, keyring: recipientKeyring }, + expectedSignerId: 11n, + resolveSignerPublicKeys: () => [publicBundle(senderKeyring)], + }; + const metadata = await client.openRelayMetadata(frame, options); + assert.equal(metadata.signaturePolicy, "ed25519"); + const content = await client.openRelayContent(metadata, options); + assert.equal(content.data.ExampleType, "default-policy"); + }); + + await it("inherits the authenticated metadata policy for relay content", async () => { + const senderKeyring = sdk.crypto.generateKeyring(); + const recipientKeyring = sdk.crypto.generateKeyring(); + const frame = sdk.codec.decode( + bindings.build_encrypted_relay_frame_with_keyring( + "ProtectedMessage", + { ExampleType: "inherited-policy" }, + 11n, + 42n, + 42n, + "message-inherited-policy", + 456n, + null, + senderKeyring, + bindings.mtp_protection_signature_suite_dual(), + [publicBundle(recipientKeyring)], + [publicBundle(recipientKeyring)], + ), + ); + const client = await sdk.MTPClient.create({ + url: "https://example.invalid", + defaultSignatureVerificationPolicy: "ed25519", + pings: false, + }); + const metadataOptions = { + recipient: { id: 42n, keyring: recipientKeyring }, + expectedSignerId: 11n, + resolveSignerPublicKeys: () => [publicBundle(senderKeyring)], + signaturePolicy: "dual", + }; + const metadata = await client.openRelayMetadata(frame, metadataOptions); + const content = await client.openRelayContent(metadata, { + recipient: metadataOptions.recipient, + expectedSignerId: metadataOptions.expectedSignerId, + resolveSignerPublicKeys: metadataOptions.resolveSignerPublicKeys, + }); + assert.equal(content.data.ExampleType, "inherited-policy"); + }); + + await it("reports the shared relay CreatedAt fixture as bigint", async () => { + const senderKeyring = sdk.crypto.generateKeyring(); + const recipientKeyring = sdk.crypto.generateKeyring(); + const frame = sdk.codec.decode( + bindings.build_encrypted_relay_frame_with_keyring( + "ProtectedMessage", + { ExampleType: "generic" }, + 11n, + 42n, + 42n, + "message-generic", + relayFixtureCreatedAtMillis, + bindings.encode_data_value({ ExampleType: "opaque-to-content-only-relays" }), + senderKeyring, + bindings.mtp_protection_signature_suite_dual(), + [publicBundle(recipientKeyring)], + [publicBundle(recipientKeyring)], + ), + ); + const client = await sdk.MTPClient.create({ + url: "https://example.invalid", + pings: false, + }); + const options = { + recipient: { id: 42n, keyring: recipientKeyring }, + expectedSignerId: 11n, + resolveSignerPublicKeys: () => [publicBundle(senderKeyring)], + signaturePolicy: "dual", + }; + + const metadata = await client.openRelayMetadata(frame, options); + assert.ok(metadata); + assert.equal(metadata.signerId, 11n); + assert.equal(metadata.finalRecipientId, 42n); + assert.equal(metadata.messageId, "message-generic"); + assert.equal(metadata.createdAt, relayFixtureCreatedAtMillis); + assert.deepEqual(metadata.metadata, { + ExampleType: "opaque-to-content-only-relays", + }); + + const content = await client.openRelayContent(metadata, options); + assert.deepEqual(content, { + type: "ProtectedMessage", + data: { ExampleType: "generic" }, + signerId: 11n, + finalRecipientId: 42n, + messageId: "message-generic", + createdAt: relayFixtureCreatedAtMillis, + metadata: { ExampleType: "opaque-to-content-only-relays" }, + }); + }); + + await it("preserves scalar metadata and byte content values", async () => { + const senderKeyring = sdk.crypto.generateKeyring(); + const recipientKeyring = sdk.crypto.generateKeyring(); + const frame = sdk.codec.decode( + bindings.build_encrypted_relay_frame_with_keyring( + "ProtectedMessage", + new Uint8Array([0xde, 0xad, 0xbe, 0xef]), + 11n, + 42n, + 42n, + "message-scalar-values", + 456n, + bindings.encode_data_value("opaque metadata"), + senderKeyring, + bindings.mtp_protection_signature_suite_dual(), + [publicBundle(recipientKeyring)], + [publicBundle(recipientKeyring)], + ), + ); + const client = await sdk.MTPClient.create({ + url: "https://example.invalid", + pings: false, + }); + const options = { + recipient: { id: 42n, keyring: recipientKeyring }, + expectedSignerId: 11n, + resolveSignerPublicKeys: () => [publicBundle(senderKeyring)], + signaturePolicy: "dual", + }; + + const metadata = await client.openRelayMetadata(frame, options); + assert.equal(metadata.metadata, "opaque metadata"); + const content = await client.openRelayContent(metadata, options); + assert.deepEqual(content.data, new Uint8Array([0xde, 0xad, 0xbe, 0xef])); + }); + + await it("subscribes through the explicit sealed-relay API", async () => { + const senderKeyring = sdk.crypto.generateKeyring(); + const recipientKeyring = sdk.crypto.generateKeyring(); + const frame = sdk.codec.decode( + bindings.build_encrypted_relay_frame_with_keyring( + "ProtectedMessage", + { ExampleType: "subscription" }, + 11n, + 42n, + 42n, + "message-subscription", + 789n, + bindings.encode_data_value({ ExampleType: "subscription" }), + senderKeyring, + bindings.mtp_protection_signature_suite_dual(), + [publicBundle(recipientKeyring)], + [publicBundle(recipientKeyring)], + ), + ); + const client = await sdk.MTPClient.create({ + url: "https://example.invalid", + pings: false, + }); + const originalSubscribe = client.raw.client.subscribe; + const originalUnsubscribe = client.raw.client.unsubscribe; + let subscribedType; + let subscribedHandler; + let unsubscribedId; + client.raw.client.subscribe = (type, handler) => { + subscribedType = type; + subscribedHandler = handler; + return 17; + }; + client.raw.client.unsubscribe = (id) => { + unsubscribedId = id; + }; + + try { + const received = []; + const unsubscribe = client.subscribeSealedRelay( + "ProtectedMessage", + (content, parsedFrame) => { + received.push({ content, parsedFrame }); + }, + { + recipient: { id: 42n, keyring: recipientKeyring }, + expectedSignerId: 11n, + resolveSignerPublicKeys: () => [publicBundle(senderKeyring)], + signaturePolicy: "dual", + }, + ); + + assert.equal(subscribedType, "Relay"); + assert.equal(typeof subscribedHandler, "function"); + await subscribedHandler(frame); + assert.equal(received.length, 1); + assert.equal(received[0].content.messageId, "message-subscription"); + assert.deepEqual(received[0].content.metadata, { + ExampleType: "subscription", + }); + assert.equal(received[0].parsedFrame.type, "ProtectedMessage"); + assert.deepEqual(received[0].parsedFrame.data, { + ExampleType: "subscription", + }); + await subscribedHandler(frame); + assert.equal(received.length, 1); + + unsubscribe(); + assert.equal(unsubscribedId, 17); + } finally { + client.raw.client.subscribe = originalSubscribe; + client.raw.client.unsubscribe = originalUnsubscribe; + } + }); + + await it("keeps relay subscription replay guards independent", async () => { + const senderKeyring = sdk.crypto.generateKeyring(); + const recipientKeyring = sdk.crypto.generateKeyring(); + const frame = sdk.codec.decode( + bindings.build_encrypted_relay_frame_with_keyring( + "ProtectedMessage", + { ExampleType: "fanout" }, + 11n, + 42n, + 42n, + "message-relay-fanout", + 789n, + bindings.encode_data_value({ ExampleType: "fanout-metadata" }), + senderKeyring, + bindings.mtp_protection_signature_suite_dual(), + [publicBundle(recipientKeyring)], + [publicBundle(recipientKeyring)], + ), + ); + const client = await sdk.MTPClient.create({ + url: "https://example.invalid", + pings: false, + }); + const originalSubscribe = client.raw.client.subscribe; + const originalUnsubscribe = client.raw.client.unsubscribe; + const subscriptions = []; + client.raw.client.subscribe = (type, handler) => { + const id = subscriptions.length + 1; + subscriptions.push({ type, handler, id }); + return id; + }; + client.raw.client.unsubscribe = () => true; + + try { + const options = { + recipient: { id: 42n, keyring: recipientKeyring }, + expectedSignerId: 11n, + resolveSignerPublicKeys: () => [publicBundle(senderKeyring)], + signaturePolicy: "dual", + }; + let mismatched = 0; + let matched = 0; + let metadataOnly = 0; + client.subscribeSealedRelay( + "AlternateMessage", + () => { + mismatched += 1; + }, + options, + ); + client.subscribeSealedRelay( + "ProtectedMessage", + () => { + matched += 1; + }, + options, + ); + client.subscribeRelayMetadata( + () => { + metadataOnly += 1; + }, + options, + ); + + const relaySubscriptions = subscriptions.filter( + ({ type }) => type === "Relay", + ); + assert.equal(relaySubscriptions.length, 3); + for (const subscription of relaySubscriptions) { + await subscription.handler(frame); + } + assert.equal(mismatched, 0); + assert.equal(matched, 1); + assert.equal(metadataOnly, 1); + } finally { + client.raw.client.subscribe = originalSubscribe; + client.raw.client.unsubscribe = originalUnsubscribe; + } + }); + + await it("consumes authenticated relay IDs through the caller's replay guard", async () => { + const senderKeyring = sdk.crypto.generateKeyring(); + const recipientKeyring = sdk.crypto.generateKeyring(); + const frame = sdk.codec.decode( + bindings.build_encrypted_relay_frame_with_keyring( + "ProtectedMessage", + { ExampleType: "replay" }, + 11n, + 42n, + 42n, + "message-replay", + 123n, + null, + senderKeyring, + bindings.mtp_protection_signature_suite_dual(), + [publicBundle(recipientKeyring)], + [publicBundle(recipientKeyring)], + ), + ); + const client = await sdk.MTPClient.create({ + url: "https://example.invalid", + credentials: { clientId: 42n, keyring: recipientKeyring }, + pings: false, + }); + const accepted = new Set(); + const replayGuard = { + accept(signerId, messageId) { + const key = `${signerId}:${messageId}`; + if (accepted.has(key)) return false; + accepted.add(key); + return true; + }, + }; + const options = { + expectedSignerId: 11n, + resolveSignerPublicKeys: () => [publicBundle(senderKeyring)], + signaturePolicy: "dual", + replayGuard, + }; + const metadata = await client.openRelayMetadata(frame, options); + assert.equal(metadata.messageId, "message-replay"); + await assert.rejects( + () => client.openRelayMetadata(frame, options), + (error) => error instanceof sdk.MTPReplayError, + ); + metadata.dispose(); + metadata.free(); + await assert.rejects( + () => client.openRelayContent(metadata, options), + /disposed/, + ); + }); + + await it("disposes metadata after relay metadata subscription handlers complete", async () => { + const senderKeyring = sdk.crypto.generateKeyring(); + const recipientKeyring = sdk.crypto.generateKeyring(); + const frame = sdk.codec.decode( + bindings.build_encrypted_relay_frame_with_keyring( + "ProtectedMessage", + { ExampleType: "metadata-subscription" }, + 11n, + 42n, + 42n, + "message-metadata-subscription", + 123n, + null, + senderKeyring, + bindings.mtp_protection_signature_suite_dual(), + [publicBundle(recipientKeyring)], + [publicBundle(recipientKeyring)], + ), + ); + const client = await sdk.MTPClient.create({ + url: "https://example.invalid", + pings: false, + }); + const originalSubscribe = client.raw.client.subscribe; + let subscribedHandler; + client.raw.client.subscribe = (_type, handler) => { + subscribedHandler = handler; + return 19; + }; + + try { + let receivedMetadata; + client.subscribeRelayMetadata( + (metadata) => { + receivedMetadata = metadata; + assert.equal(metadata.messageId, "message-metadata-subscription"); + }, + { + recipient: { id: 42n, keyring: recipientKeyring }, + expectedSignerId: 11n, + resolveSignerPublicKeys: () => [publicBundle(senderKeyring)], + signaturePolicy: "dual", + }, + ); + + await subscribedHandler(frame); + assert.throws(() => receivedMetadata.messageId, /disposed/); + } finally { + client.raw.client.subscribe = originalSubscribe; + } + }); + + await it("rejects relay content opening when metadata is disposed during resolution", async () => { + const senderKeyring = sdk.crypto.generateKeyring(); + const recipientKeyring = sdk.crypto.generateKeyring(); + const frame = sdk.codec.decode( + bindings.build_encrypted_relay_frame_with_keyring( + "ProtectedMessage", + { ExampleType: "dispose-race" }, + 11n, + 42n, + 42n, + "message-dispose-race", + 123n, + null, + senderKeyring, + bindings.mtp_protection_signature_suite_dual(), + [publicBundle(recipientKeyring)], + [publicBundle(recipientKeyring)], + ), + ); + const client = await sdk.MTPClient.create({ + url: "https://example.invalid", + pings: false, + }); + const signerPublicKey = publicBundle(senderKeyring); + const metadata = await client.openRelayMetadata(frame, { + recipient: { id: 42n, keyring: recipientKeyring }, + expectedSignerId: 11n, + resolveSignerPublicKeys: () => [signerPublicKey], + signaturePolicy: "dual", + }); + + let signalResolverStarted; + let releaseResolver; + const resolverStarted = new Promise((resolve) => { + signalResolverStarted = resolve; + }); + const resolverRelease = new Promise((resolve) => { + releaseResolver = resolve; + }); + const opening = client.openRelayContent(metadata, { + recipient: { id: 42n, keyring: recipientKeyring }, + expectedSignerId: 11n, + resolveSignerPublicKeys: async () => { + signalResolverStarted(); + await resolverRelease; + return [signerPublicKey]; + }, + signaturePolicy: "dual", + }); + + await resolverStarted; + metadata.dispose(); + releaseResolver(); + await assert.rejects(opening, /relay metadata has been disposed/); + }); + + await it("does not permit callers to construct verified metadata", () => { + assert.throws( + () => new sdk.MTPVerifiedRelayMetadata(Symbol(), {}), + /authenticated opening/, + ); + }); +}); diff --git a/test/encrypted-secret.mjs b/test/encrypted-secret.mjs new file mode 100644 index 0000000..85291ef --- /dev/null +++ b/test/encrypted-secret.mjs @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { test } from "node:test"; +import { InMemoryEncryptedSecretProvider } from "../dist/sdk/encrypted-secret.js"; + +function record(id, bytes, updatedAt = 2) { + return { + id, + encryptedSecret: new Uint8Array(bytes), + formatVersion: 1, + wrappingScheme: "test-wrap-v1", + wrappingKeyId: "wrapping-key-1", + createdAt: 1, + updatedAt, + }; +} + +test("encrypted secret provider stores, reads, replaces, and deletes", async () => { + const provider = new InMemoryEncryptedSecretProvider(); + const initial = record("session-secret:alpha", [1, 2, 3]); + + await provider.set(initial); + initial.encryptedSecret[0] = 99; + + assert.deepEqual(await provider.get("session-secret:alpha"), record( + "session-secret:alpha", + [1, 2, 3], + )); + + const replacement = record("session-secret:alpha", [4, 5], 3); + await provider.set(replacement); + assert.deepEqual( + await provider.get("session-secret:alpha"), + replacement, + ); + + const fetched = await provider.get("session-secret:alpha"); + fetched.encryptedSecret[0] = 88; + assert.deepEqual( + await provider.get("session-secret:alpha"), + replacement, + ); + + await provider.delete("session-secret:alpha"); + assert.equal(await provider.get("session-secret:alpha"), null); + await provider.delete("session-secret:missing"); +}); + +test("encrypted secret provider isolates unrelated opaque IDs", async () => { + const provider = new InMemoryEncryptedSecretProvider(); + const session = record("session-secret:one", [1]); + const pipe = record("pipe-secret:one", [2]); + + await provider.set(session); + await provider.set(pipe); + + assert.deepEqual(await provider.get(session.id), session); + assert.deepEqual(await provider.get(pipe.id), pipe); + assert.equal(await provider.get("identity-secret:one"), null); +}); + +test("encrypted secret provider has no application identity hierarchy", async () => { + const source = await readFile( + new URL("../src/sdk/encrypted-secret.ts", import.meta.url), + "utf8", + ); + + const forbiddenFields = [ + ["user", "Id"], + ["device", "Id"], + ["chat", "Id"], + ["conversation", "Id"], + ].map(([prefix, suffix]) => `${prefix}${suffix}`); + for (const field of forbiddenFields) { + assert.doesNotMatch(source, new RegExp(`\\b${field}\\b`)); + } +}); + +test("encrypted secret provider validates IDs and records", async () => { + const provider = new InMemoryEncryptedSecretProvider(); + + await assert.rejects(() => provider.get(""), /non-empty string/); + await assert.rejects(() => provider.delete(""), /non-empty string/); + await assert.rejects( + () => provider.set(record("", [1])), + /non-empty string/, + ); + await assert.rejects( + () => provider.set({ + ...record("invalid", [1]), + encryptedSecret: new Uint8Array(), + }), + /non-empty encryptedSecret bytes/, + ); +}); diff --git a/test/task8-options.type-test.ts b/test/task8-options.type-test.ts new file mode 100644 index 0000000..a1b6ace --- /dev/null +++ b/test/task8-options.type-test.ts @@ -0,0 +1,78 @@ +import type { + MTPDataValueInput, + MTPClientOptions, + MTPOpenRelayMetadataOptions, + MTPOpenRelayContentOptions, + MTPClient, + MTPSendProtectedOptions, + MTPSendSealedRelayOptions, +} from "../src/sdk/index.js"; + +const recipients = [new Uint8Array([1])]; + +const clientOptions: MTPClientOptions = { + url: "https://example.invalid", + credentials: { + clientId: 1, + keyring: recipients[0], + // @ts-expect-error keyringBytes was removed from the stable credential API + keyringBytes: recipients[0], + }, +}; + +const protectedOptions: MTPSendProtectedOptions = { + receiverId: 2, + recipients, + signaturePurpose: 32, + encryptionPurpose: 33, + // @ts-expect-error sendProtected has only receiverId + receiver: 3, +}; + +const relayOptions: MTPSendSealedRelayOptions = { + finalRecipientId: 2, + nextHopId: 3, + metadataRecipients: recipients, + contentRecipients: recipients, + // @ts-expect-error sealed relay frames never expose an outer sender + sender: 1, +}; + +const metadataValues: MTPDataValueInput[] = [ + null, + true, + 42, + 42n, + "metadata", + new Uint8Array([1, 2]), + ["nested", false], + { Metadata: "typed container" }, +]; + +const relayReceiveOptions: MTPOpenRelayMetadataOptions = { + resolveSignerPublicKeys: () => recipients, +}; + +const contentReceiveOptions: MTPOpenRelayContentOptions = { + // @ts-expect-error replayGuard belongs to metadata opening or subscriptions + replayGuard: { accept: () => true }, +}; + +declare const client: MTPClient; +void client.sendProtected("ProtectedMessage", "scalar protected content", protectedOptions); +void client.sendSealedRelay("ProtectedMessage", new Uint8Array([1, 2, 3]), relayOptions); + +// @ts-expect-error sealed relay subscriptions accept application type names, not numeric IDs +client.subscribeSealedRelay(32, () => {}); + +// @ts-expect-error relay receive uses the key-history resolver API +relayReceiveOptions.senderPublicKey = recipients[0]; +// @ts-expect-error relay receive selects a verification policy, not a signature suite +relayReceiveOptions.signatureSuite = "dual"; + +void protectedOptions; +void relayOptions; +void metadataValues; +void relayReceiveOptions; +void contentReceiveOptions; +void clientOptions; diff --git a/test/vite-type-map.mjs b/test/vite-type-map.mjs new file mode 100644 index 0000000..5492f76 --- /dev/null +++ b/test/vite-type-map.mjs @@ -0,0 +1,243 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { + chmod, + mkdir, + mkdtemp, + readdir, + readFile, + rename, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { existsSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { + copyWasmBuildInputs, + generateTypeMapModule, + hashPackageInputs, + parseTypeMapYaml, +} from "../dist/vite/index.js"; + +const run = promisify(execFile); +const repositoryRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); + +const repeatedNames = ` +protocol_version: "2.0" +type_maps: + "1.0": + CommunicationTypes: + Message: 32 + LegacyMessage: 33 + DataTypes: + Payload: 32 + LegacyPayload: 33 + "2.0": + CommunicationTypes: + Message: 35 + CurrentMessage: 36 + DataTypes: + Payload: 35 + CurrentPayload: 36 +`; + +const selected = parseTypeMapYaml(repeatedNames, "repeated.yaml"); +assert.ok(selected.communicationTypes.includes("Message")); +assert.ok(selected.communicationTypes.includes("CurrentMessage")); +assert.ok(!selected.communicationTypes.includes("LegacyMessage")); +assert.ok(selected.dataTypes.includes("Payload")); +assert.ok(selected.dataTypes.includes("CurrentPayload")); +assert.ok(!selected.dataTypes.includes("LegacyPayload")); +assert.ok(selected.communicationTypes.includes("Ping")); +assert.ok(selected.dataTypes.includes("Version")); + +const generated = generateTypeMapModule(selected); +assert.match(generated.dts, /"Message"/); +assert.match(generated.dts, /"CurrentMessage"/); +assert.doesNotMatch(generated.dts, /LegacyMessage/); +assert.doesNotMatch(generated.dts, /LegacyPayload/); + +assert.throws( + () => + parseTypeMapYaml( + `protocol_version: "1.0"\ntype_maps:\n "1.0":\n CommunicationTypes:\n Ping: 32\n`, + "reserved.yaml", + ), + /uses a reserved type name/, +); +assert.throws( + () => + parseTypeMapYaml( + `protocol_version: "2.0"\ntype_maps:\n "1.0": {}\n`, + "missing-version.yaml", + ), + /is not defined in type_maps/, +); +assert.throws( + () => parseTypeMapYaml(`protocol_version: "1.0"\ntype_maps: {}`, "empty.yaml"), + /is not defined in type_maps/, +); +assert.throws( + () => + parseTypeMapYaml( + `protocol_version: "latest"\ntype_maps:\n "latest": {}\n`, + "invalid-version.yaml", + ), + /protocol_version must be a string/, +); + +const temporaryBuildRoot = await mkdtemp(path.join(os.tmpdir(), "mtp-vite-inputs-")); +try { + await copyWasmBuildInputs(temporaryBuildRoot); + for (const requiredInput of [ + "Cargo.lock", + "wasm/Cargo.toml", + "wasm/src/lib.rs", + "common/Cargo.toml", + "codec/Cargo.toml", + "crypto/Cargo.toml", + "type-map/Cargo.toml", + "type-map/build.rs", + "type-map/reserved.json", + ]) { + assert.equal( + existsSync(path.join(temporaryBuildRoot, requiredInput)), + true, + `temporary WASM build input is missing: ${requiredInput}`, + ); + } + assert.equal( + await readFile(path.join(temporaryBuildRoot, "type-map/reserved.json"), "utf8"), + await readFile(path.join(repositoryRoot, "type-map/reserved.json"), "utf8"), + ); + assert.match( + await readFile(path.join(temporaryBuildRoot, "type-map/build.rs"), "utf8"), + /include_str!\("reserved\.json"\)/, + ); +} finally { + await rm(temporaryBuildRoot, { recursive: true, force: true }); +} + +const hashTestRoot = await mkdtemp(path.join(os.tmpdir(), "mtp-vite-hash-")); +try { + await mkdir(path.join(hashTestRoot, "type-map"), { recursive: true }); + const manifestPath = path.join(hashTestRoot, "type-map/reserved.json"); + await writeFile(manifestPath, "{\"version\":1}"); + const firstHash = await hashPackageInputs(hashTestRoot); + await writeFile(manifestPath, "{\"version\":2}"); + const secondHash = await hashPackageInputs(hashTestRoot); + assert.notEqual(firstHash, secondHash); +} finally { + await rm(hashTestRoot, { recursive: true, force: true }); +} + +const viteCandidates = [ + path.join(repositoryRoot, "example/web-client/node_modules/.bin/vite"), + path.join(repositoryRoot, "node_modules/.bin/vite"), +]; +const viteBin = viteCandidates.find((candidate) => existsSync(candidate)); +if (!viteBin) { + console.warn("Skipping packed Vite smoke test: Vite is not installed"); +} else { + const temporaryPackageRoot = await mkdtemp(path.join(os.tmpdir(), "mtp-vite-package-")); + try { + await run( + "npm", + ["pack", "--pack-destination", temporaryPackageRoot], + { + cwd: repositoryRoot, + env: { + ...process.env, + npm_config_cache: path.join(temporaryPackageRoot, "npm-cache"), + }, + }, + ); + const packageName = (await readdir(temporaryPackageRoot)).find((entry) => + entry.endsWith(".tgz"), + ); + assert.ok(packageName, "npm pack did not report a tarball"); + + const extractedRoot = path.join(temporaryPackageRoot, "extracted"); + const appRoot = path.join(temporaryPackageRoot, "app"); + await mkdir(extractedRoot, { recursive: true }); + await run("tar", ["-xzf", path.join(temporaryPackageRoot, packageName), "-C", extractedRoot]); + await mkdir(path.join(appRoot, "node_modules"), { recursive: true }); + await rename(path.join(extractedRoot, "package"), path.join(appRoot, "node_modules/mtp")); + await symlink( + path.join(repositoryRoot, "node_modules/yaml"), + path.join(appRoot, "node_modules/yaml"), + "dir", + ); + await symlink( + path.join(path.dirname(path.dirname(viteBin)), "vite"), + path.join(appRoot, "node_modules/vite"), + "dir", + ); + + await writeFile( + path.join(appRoot, "package.json"), + JSON.stringify({ type: "module", private: true }), + ); + await writeFile( + path.join(appRoot, "index.html"), + '', + ); + await writeFile( + path.join(appRoot, "main.js"), + 'import { communicationTypes } from "mtp/type-map";\n' + + 'if (!communicationTypes.includes("CurrentMessage") || communicationTypes.includes("LegacyMessage")) throw new Error("wrong browser type-map selection");\n', + ); + await writeFile( + path.join(appRoot, "type-maps.yaml"), + repeatedNames, + ); + await writeFile( + path.join(appRoot, "vite.config.mjs"), + 'import { defineConfig } from "vite";\n' + + 'import { mtp } from "mtp/vite";\n' + + 'export default defineConfig({ plugins: [mtp({ typeMaps: "./type-maps.yaml", release: false })] });\n', + ); + + const fakeBin = path.join(temporaryPackageRoot, "bin"); + const fakeWasmPack = path.join(fakeBin, "wasm-pack"); + await mkdir(fakeBin, { recursive: true }); + await writeFile( + fakeWasmPack, + `#!/usr/bin/env node +const fs = require("node:fs"); +const path = require("node:path"); +const args = process.argv.slice(2); +const outIndex = args.indexOf("--out-dir"); +if (outIndex < 0) throw new Error("fake wasm-pack did not receive --out-dir"); +if (!fs.existsSync(path.join(process.cwd(), "type-map", "reserved.json"))) { + throw new Error("temporary wasm build is missing type-map/reserved.json"); +} +const outDir = args[outIndex + 1]; +fs.mkdirSync(outDir, { recursive: true }); +fs.writeFileSync(path.join(outDir, "mtp_wasm.js"), "export default function init() {}\\n"); +fs.writeFileSync(path.join(outDir, "mtp_wasm_bg.wasm"), Buffer.from([0, 97, 115, 109, 1, 0, 0, 0])); +`, + ); + await chmod(fakeWasmPack, 0o755); + + await run(viteBin, ["build", "--config", "vite.config.mjs"], { + cwd: appRoot, + env: { + ...process.env, + PATH: `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`, + }, + }); + assert.equal(existsSync(path.join(appRoot, "dist/index.html")), true); + } finally { + await rm(temporaryPackageRoot, { recursive: true, force: true }); + } +} + +console.log("Vite type-map tests passed"); diff --git a/transport/Cargo.toml b/transport/Cargo.toml index bc227ff..872929e 100644 --- a/transport/Cargo.toml +++ b/transport/Cargo.toml @@ -19,6 +19,8 @@ rcgen = "0.14" tracing = "0.1" async-trait = "0.1" sha2 = "0.11" +rand = "0.10.2" +zeroize = "1.9" [dev-dependencies] @@ -30,7 +32,7 @@ required-features = ["host", "insecure-tls"] # Enables hosting a MTP server host = [] -pipes = ["mtp-codec/pipes"] +pipes = ["mtp-codec/pipes", "mtp-codec/crypto"] # Compiles the insecure certificate verifier (NoopCertVerifier). # Even with this feature enabled, the verifier requires the environment diff --git a/transport/src/connection.rs b/transport/src/connection.rs index 9d59af4..7f0b5d6 100644 --- a/transport/src/connection.rs +++ b/transport/src/connection.rs @@ -1,7 +1,7 @@ use crate::ConnectionHandle; #[cfg(feature = "pipes")] use crate::pipe::PipeReader; -use mtp_codec::CommunicationValue; +use mtp_codec::{CommunicationValue, DecodeLimits, TypeMap}; use mtp_common::CommunicationError; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; @@ -148,6 +148,7 @@ pub struct Sender { handle: Arc, connection: Connection, policy: Arc, + type_map: Arc>, } impl Sender { @@ -158,9 +159,15 @@ impl Sender { handle, connection, policy, + type_map: Arc::new(RwLock::new(TypeMap::latest())), } } + /// Bind control frames created by this sender to the negotiated protocol map. + pub async fn set_type_map(&self, type_map: &TypeMap) { + *self.type_map.write().await = type_map.clone(); + } + #[instrument(skip(stream, data, policy), level = "trace")] async fn write_frame( stream: &mut wtransport::SendStream, @@ -174,9 +181,7 @@ impl Sender { return Err(CommunicationError::MessageTooLarge); } - let len_bytes = (bytes.len() as u32).to_be_bytes(); let write_result = async { - stream.write_all(&len_bytes).await?; stream.write_all(&bytes).await?; Ok::<(), wtransport::error::StreamWriteError>(()) }; @@ -458,12 +463,16 @@ impl Sender { let mut stream = Self::open_uni_stream(&self.connection, &self.policy).await?; - let request = CommunicationValue::new(mtp_codec::CommunicationType::PipeRequest) - .with_id(pipe_id) - .add_typed_default( - mtp_codec::DataType::Description, - mtp_codec::DataValue::Str(description.to_string()), - ); + let type_map = self.type_map.read().await.clone(); + let request = CommunicationValue::new_with_type_map( + mtp_codec::CommunicationType::PipeRequest, + &type_map, + ) + .with_id(pipe_id) + .add_typed_default( + mtp_codec::DataType::Description, + mtp_codec::DataValue::Str(description.to_string()), + ); Self::write_frame(&mut stream, &request, &self.policy).await?; @@ -595,6 +604,7 @@ struct ReceiverInner { ping_control: Arc>, queue_notify: Arc, max_message_size: Arc, + type_map: Arc>, } impl Clone for Receiver { @@ -624,6 +634,7 @@ impl Receiver { Self::new_with_max_message_size(connection, handle, policy.clone(), policy.max_message_size) } + #[cfg(feature = "host")] pub(crate) fn new_for_handshake( connection: Connection, handle: Arc, @@ -661,6 +672,8 @@ impl Receiver { let accept_queue_notify = queue_notify.clone(); let max_message_size = Arc::new(AtomicU64::new(initial_max_message_size)); let accept_max_message_size = max_message_size.clone(); + let type_map = Arc::new(RwLock::new(TypeMap::latest())); + let accept_type_map = type_map.clone(); let stream_limit = Arc::new(Semaphore::new(policy.max_concurrent_stream_tasks.max(1))); let accept_stream_limit = stream_limit.clone(); debug!( @@ -728,6 +741,7 @@ impl Receiver { let stream_policy = accept_policy.clone(); let stream_ping_control = accept_ping_control.clone(); let stream_max_message_size = accept_max_message_size.clone(); + let stream_type_map = accept_type_map.clone(); tokio::spawn(async move { let _permit = permit; @@ -748,18 +762,25 @@ impl Receiver { let frame_limit = stream_max_message_size.load(Ordering::Relaxed); match Self::read_one_frame(&mut s, &stream_policy, frame_limit).await { - Ok(ReceivedFrame::Message(msg)) => { + Ok(ReceivedFrame::Message(mut msg)) => { + let negotiated_type_map = + stream_type_map.read().await.clone(); + msg.set_type_map(&negotiated_type_map); frame_count += 1; #[cfg(feature = "pipes")] { - let pipe_request_type = - mtp_codec::CommunicationType::PipeRequest - .try_to_id(&mtp_codec::TypeMap::latest()); - if Some(msg.get_type()) == pipe_request_type + if msg.is_type(mtp_codec::CommunicationType::PipeRequest) && frame_count == 1 { - let pipe_id = msg.get_id(); + let Some(pipe_id) = msg.id().filter(|id| *id != 0) else { + let error = CommunicationError::Other( + "PipeRequest frame must contain a non-zero id".into(), + ); + let _ = msg_tx_stream.send(Err(error.clone())).await; + stream_handle.close(Some(error)); + break; + }; let description = msg .get_str(mtp_codec::DataType::Description) .unwrap_or("") @@ -784,18 +805,14 @@ impl Receiver { } } - let ping_type = mtp_codec::CommunicationType::Ping - .try_to_id(&mtp_codec::TypeMap::latest()); - let pong_type = mtp_codec::CommunicationType::Pong - .try_to_id(&mtp_codec::TypeMap::latest()); let control = { let control = stream_ping_control.read().await; - if Some(msg.get_type()) == ping_type { + if msg.is_type(mtp_codec::CommunicationType::Ping) { control .pong_sender .clone() .map(|sender| (Some(sender), None)) - } else if Some(msg.get_type()) == pong_type { + } else if msg.is_type(mtp_codec::CommunicationType::Pong) { control .pong_observer .clone() @@ -806,9 +823,16 @@ impl Receiver { }; if let Some((Some(sender), _)) = control { - let mut pong = CommunicationValue::new(mtp_codec::CommunicationType::Pong) - .with_id(msg.get_id()); - if let Some(timestamp) = msg.get_data_opt(mtp_codec::DataType::Timestamp) { + let mut pong = CommunicationValue::new_with_type_map( + mtp_codec::CommunicationType::Pong, + &negotiated_type_map, + ); + if let Some(id) = msg.id() { + pong = pong.with_id(id); + } else { + pong = pong.without_id(); + } + if let Some(timestamp) = msg.get_data(mtp_codec::DataType::Timestamp) { pong = pong.add_typed_default( mtp_codec::DataType::Timestamp, timestamp.clone(), @@ -917,6 +941,7 @@ impl Receiver { ping_control, queue_notify, max_message_size, + type_map, }), } } @@ -928,6 +953,11 @@ impl Receiver { .store(max_message_size, Ordering::Relaxed); } + /// Bind subsequently decoded frames to the negotiated protocol version. + pub async fn set_type_map(&self, type_map: &TypeMap) { + *self.inner.type_map.write().await = type_map.clone(); + } + /* Respond to reserved Ping frames without exposing them to application I/O. */ pub fn respond_to_pings(&self, sender: Sender) { if let Ok(mut control) = self.inner.ping_control.try_write() { @@ -982,18 +1012,21 @@ impl Receiver { return Ok(ReceivedFrame::ClosedByPeer); } - let len_usize = len as usize; - if len as u64 > max_message_size { + let body_len = len as usize; + let frame_len = body_len + .checked_add(4) + .ok_or(CommunicationError::MessageTooLarge)?; + if frame_len as u64 > max_message_size { return Err(CommunicationError::MessageTooLarge); } // Grow in bounded chunks instead of trusting the peer's length prefix // enough to allocate the complete frame up front. let mut buf = Vec::new(); - buf.try_reserve(len_usize.min(16 * 1024)) + buf.try_reserve(body_len.min(16 * 1024)) .map_err(|_| CommunicationError::MessageTooLarge)?; - while buf.len() < len_usize { - let chunk_len = (len_usize - buf.len()).min(16 * 1024); + while buf.len() < body_len { + let chunk_len = (body_len - buf.len()).min(16 * 1024); let mut chunk = [0u8; 16 * 1024]; match timeout( policy.read_timeout, @@ -1008,7 +1041,7 @@ impl Receiver { } Ok(Err(StreamReadExactError::FinishedEarly(n))) => { warn!( - "[Receiver] body read ended early ({}/{len_usize} bytes): stream closed by peer", + "[Receiver] body read ended early ({}/{body_len} bytes): stream closed by peer", buf.len() + n ); return Err(CommunicationError::StreamError); @@ -1024,14 +1057,20 @@ impl Receiver { return Err(CommunicationError::StreamError); } Err(_) => { - warn!("[Receiver] body read timed out (len={len_usize})"); + warn!("[Receiver] body read timed out (len={body_len})"); return Err(CommunicationError::StreamError); } } } - let message = CommunicationValue::from_bytes(&buf) - .map_err(|_| CommunicationError::ParseCommunicationValue)?; + let mut frame = Vec::with_capacity(frame_len); + frame.extend_from_slice(&len_buf); + frame.extend_from_slice(&buf); + let message = CommunicationValue::from_bytes_with_limits( + &frame, + DecodeLimits::for_transport_message_size(max_message_size), + ) + .map_err(|_| CommunicationError::ParseCommunicationValue)?; Ok(ReceivedFrame::Message(message)) } diff --git a/transport/src/encrypted_pipe.rs b/transport/src/encrypted_pipe.rs new file mode 100644 index 0000000..d4bd7fd --- /dev/null +++ b/transport/src/encrypted_pipe.rs @@ -0,0 +1,1512 @@ +//! Endpoint-to-endpoint authenticated encryption for MTP pipes. +//! +//! Pipe negotiation and QUIC/WebTransport remain transport primitives. This +//! module adds the application-facing record layer that callers can place on +//! top of an accepted [`PipeWriter`] or [`PipeReader`], plus an explicit +//! signed/KEM session-offer helper. The raw stream adapter does not infer +//! application identities or derive keys from clear pipe metadata. + +use mtp_codec::{ + DataValue, DecodeLimits, MtpProtectionPurpose, ProtectionError, ProtectionPolicy, + ProtectionPurpose, +}; +use mtp_crypto::{ + AeadDecrypt, AeadEncrypt, DualSigner, Ed25519Signer, KemPublicKey, Keyring, PublicKeyBundle, + SignatureScheme, XChaCha20Poly1305, +}; +use rand::RngExt; +use std::fmt; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use zeroize::{Zeroize, Zeroizing}; + +const PIPE_E2EE_DOMAIN: &[u8] = b"MTP-PIPE-E2EE-1"; +const PIPE_RECORD_KDF_DOMAIN: &[u8] = b"MTP-PIPE-E2EE-1/KEY"; +const PIPE_TRANSCRIPT_DOMAIN: &[u8] = b"MTP-PIPE-TRANSCRIPT-1"; +const PIPE_RECORD_MESSAGE_LABEL: &[u8] = b"/message"; +const PIPE_RECORD_NEXT_LABEL: &[u8] = b"/next"; +const SESSION_ID_MAX_LEN: usize = 1024; +const RECORD_LENGTH_BYTES: usize = 4; +const RECORD_TYPE_BYTES: usize = 1; +const RECORD_TYPE_DATA: u8 = 0; +const RECORD_TYPE_FINAL: u8 = 1; +const XCHACHA_OVERHEAD: usize = + mtp_crypto::aead::XCHACHA20POLY1305_NONCE_LEN + mtp_crypto::aead::AUTH_TAG_LEN; + +/// Maximum encoded ciphertext size of one encrypted pipe record. +pub const MAX_ENCRYPTED_PIPE_RECORD: usize = 16 * 1024 * 1024; + +/// Purpose authenticated by the signed session-key offer. +pub const PIPE_SESSION_SIGNATURE_PURPOSE: u8 = MtpProtectionPurpose::PipeSessionSignature.value(); +/// Generic purpose authenticated by the encrypted session-key offer. +pub const PIPE_SESSION_ENCRYPTION_PURPOSE: u8 = MtpProtectionPurpose::PipeSessionEncryption.value(); +/// Maximum serialized size of a session-key offer. +pub const MAX_PIPE_SESSION_OFFER: usize = 64 * 1024; +const PIPE_SESSION_OFFER_DOMAIN: &str = "MTP-PIPE-SESSION-1"; +const FS_INIT_DOMAIN: &str = "MTP-PIPE-FS-INIT-1"; +const FS_RESPONSE_DOMAIN: &str = "MTP-PIPE-FS-RESPONSE-1"; +const FS_FINISH_DOMAIN: &str = "MTP-PIPE-FS-FINISH-1"; +const FS_ROOT_INFO: &[u8] = b"MTP-PIPE-FS-ROOT-1"; + +/// The context authenticated by every encrypted pipe record. +#[derive(Clone, PartialEq, Eq)] +pub struct PipeProtectionContext { + session_id: Vec, + purpose: u8, + direction: u8, + transcript_hash: [u8; 32], +} + +impl fmt::Debug for PipeProtectionContext { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PipeProtectionContext") + .field("session_id_len", &self.session_id.len()) + .field("purpose", &self.purpose) + .field("direction", &self.direction) + .field("transcript_hash", &"[REDACTED]") + .finish() + } +} + +impl PipeProtectionContext { + /// Create a context shared by both endpoints of one logical pipe stream. + /// + /// `session_id` must identify the authenticated pipe/session and should + /// include both endpoint identities and the pipe identity. `direction` + /// is a protocol-defined value that must be identical at both endpoints; + /// use different values for the two directions of a bidirectional design. + pub fn new( + session_id: impl AsRef<[u8]>, + purpose: u8, + direction: u8, + ) -> Result { + let session_id = session_id.as_ref(); + if session_id.is_empty() || session_id.len() > SESSION_ID_MAX_LEN { + return Err(EncryptedPipeError::InvalidContext); + } + if MtpProtectionPurpose::is_reserved(purpose) { + return Err(EncryptedPipeError::InvalidContext); + } + Ok(Self { + session_id: session_id.to_vec(), + purpose, + direction, + transcript_hash: base_transcript_hash(session_id, purpose, direction), + }) + } + + fn from_parameters(parameters: &PipeSessionParameters) -> Self { + Self { + session_id: parameters.session_id.clone(), + purpose: parameters.purpose, + direction: parameters.direction, + transcript_hash: parameters.transcript_hash(), + } + } + + pub fn session_id(&self) -> &[u8] { + &self.session_id + } + + pub fn purpose(&self) -> u8 { + self.purpose + } + + pub fn direction(&self) -> u8 { + self.direction + } + + pub fn transcript_hash(&self) -> &[u8; 32] { + &self.transcript_hash + } +} + +/// Endpoint and stream metadata that a pipe-session key must bind. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PipeSessionParameters { + session_id: Vec, + pipe_id: u32, + sender_id: u64, + recipient_id: u64, + purpose: u8, + direction: u8, +} + +impl PipeSessionParameters { + pub fn new( + session_id: impl AsRef<[u8]>, + pipe_id: u32, + sender_id: u64, + recipient_id: u64, + purpose: u8, + direction: u8, + ) -> Result { + if pipe_id == 0 { + return Err(PipeSessionError::InvalidParameters( + "pipe id must be non-zero", + )); + } + let session_id = session_id.as_ref().to_vec(); + PipeProtectionContext::new(&session_id, purpose, direction) + .map_err(|_| PipeSessionError::InvalidParameters("invalid session id"))?; + Ok(Self { + session_id, + pipe_id, + sender_id, + recipient_id, + purpose, + direction, + }) + } + + pub fn session_id(&self) -> &[u8] { + &self.session_id + } + + pub fn pipe_id(&self) -> u32 { + self.pipe_id + } + + pub fn sender_id(&self) -> u64 { + self.sender_id + } + + pub fn recipient_id(&self) -> u64 { + self.recipient_id + } + + pub fn purpose(&self) -> u8 { + self.purpose + } + + pub fn direction(&self) -> u8 { + self.direction + } + + fn context(&self) -> PipeProtectionContext { + PipeProtectionContext::from_parameters(self) + } + + fn transcript_hash(&self) -> [u8; 32] { + let mut transcript = Vec::with_capacity(64 + self.session_id.len()); + transcript.extend_from_slice(PIPE_TRANSCRIPT_DOMAIN); + append_transcript_field(&mut transcript, &self.session_id); + transcript.extend_from_slice(&self.pipe_id.to_be_bytes()); + transcript.extend_from_slice(&self.sender_id.to_be_bytes()); + transcript.extend_from_slice(&self.recipient_id.to_be_bytes()); + transcript.push(self.purpose); + transcript.push(self.direction); + mtp_crypto::sha256(&transcript) + } +} + +fn append_transcript_field(out: &mut Vec, value: &[u8]) { + out.extend_from_slice(&(value.len() as u32).to_be_bytes()); + out.extend_from_slice(value); +} + +fn base_transcript_hash(session_id: &[u8], purpose: u8, direction: u8) -> [u8; 32] { + let mut transcript = Vec::with_capacity(32 + session_id.len()); + transcript.extend_from_slice(PIPE_TRANSCRIPT_DOMAIN); + append_transcript_field(&mut transcript, session_id); + transcript.push(purpose); + transcript.push(direction); + mtp_crypto::sha256(&transcript) +} + +/// Errors returned while establishing an encrypted pipe session. +#[derive(Debug)] +pub enum PipeSessionError { + InvalidParameters(&'static str), + InvalidOffer, + OfferTooLarge(usize), + UnexpectedEof, + Io(std::io::Error), + Codec(mtp_common::CodecError), + Protection(ProtectionError), + Crypto(mtp_crypto::CryptoError), +} + +impl fmt::Display for PipeSessionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidParameters(message) => { + write!(f, "invalid pipe session parameters: {message}") + } + Self::InvalidOffer => f.write_str("invalid pipe session offer"), + Self::OfferTooLarge(length) => { + write!(f, "pipe session offer is too large: {length} bytes") + } + Self::UnexpectedEof => f.write_str("truncated pipe session offer"), + Self::Io(error) => write!(f, "pipe session I/O error: {error}"), + Self::Codec(error) => write!(f, "pipe session codec error: {error}"), + Self::Protection(error) => write!(f, "pipe session protection error: {error}"), + Self::Crypto(error) => write!(f, "pipe session crypto error: {error}"), + } + } +} + +impl std::error::Error for PipeSessionError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(error) => Some(error), + Self::Codec(error) => Some(error), + Self::Protection(error) => Some(error), + Self::Crypto(error) => Some(error), + _ => None, + } + } +} + +impl From for PipeSessionError { + fn from(error: std::io::Error) -> Self { + Self::Io(error) + } +} + +impl From for PipeSessionError { + fn from(error: mtp_common::CodecError) -> Self { + Self::Codec(error) + } +} + +impl From for PipeSessionError { + fn from(error: ProtectionError) -> Self { + Self::Protection(error) + } +} + +impl From for PipeSessionError { + fn from(error: mtp_crypto::CryptoError) -> Self { + Self::Crypto(error) + } +} + +fn session_offer_value(params: &PipeSessionParameters, key: [u8; 32]) -> DataValue { + DataValue::Array(vec![ + DataValue::Str(PIPE_SESSION_OFFER_DOMAIN.to_owned()), + DataValue::Bytes(params.session_id.clone()), + DataValue::UnsignedNumber(params.pipe_id as u128), + DataValue::UnsignedNumber(params.sender_id as u128), + DataValue::UnsignedNumber(params.recipient_id as u128), + DataValue::UnsignedNumber(params.purpose as u128), + DataValue::UnsignedNumber(params.direction as u128), + DataValue::Bytes(key.to_vec()), + ]) +} + +fn fs_common_fields(params: &PipeSessionParameters) -> Vec { + vec![ + DataValue::Bytes(params.session_id.clone()), + DataValue::UnsignedNumber(params.pipe_id as u128), + DataValue::UnsignedNumber(params.sender_id as u128), + DataValue::UnsignedNumber(params.recipient_id as u128), + DataValue::UnsignedNumber(params.purpose as u128), + DataValue::UnsignedNumber(params.direction as u128), + ] +} + +fn fs_init_value(params: &PipeSessionParameters, nonce: [u8; 32]) -> DataValue { + let mut fields = vec![DataValue::Str(FS_INIT_DOMAIN.to_owned())]; + fields.extend(fs_common_fields(params)); + fields.push(DataValue::Bytes(nonce.to_vec())); + DataValue::Array(fields) +} + +fn fs_response_value( + params: &PipeSessionParameters, + init_hash: [u8; 32], + ephemeral_public_key: &[u8], +) -> DataValue { + let mut fields = vec![DataValue::Str(FS_RESPONSE_DOMAIN.to_owned())]; + fields.extend(fs_common_fields(params)); + fields.push(DataValue::Bytes(init_hash.to_vec())); + fields.push(DataValue::Bytes(ephemeral_public_key.to_vec())); + DataValue::Array(fields) +} + +fn fs_finish_value( + params: &PipeSessionParameters, + response_hash: [u8; 32], + ciphertext: &[u8], +) -> DataValue { + let mut fields = vec![DataValue::Str(FS_FINISH_DOMAIN.to_owned())]; + fields.extend(fs_common_fields(params)); + fields.push(DataValue::Bytes(response_hash.to_vec())); + fields.push(DataValue::Bytes(ciphertext.to_vec())); + DataValue::Array(fields) +} + +fn validate_fs_common( + fields: &[DataValue], + expected: &PipeSessionParameters, + expected_domain: &str, + expected_len: usize, +) -> Result<(), PipeSessionError> { + if fields.len() != expected_len + || fields.first().and_then(DataValue::as_str) != Some(expected_domain) + || offer_field(fields, 1)?.as_bytes_slice() != Some(expected.session_id()) + || u32::try_from(unsigned_field(fields, 2)?).ok() != Some(expected.pipe_id) + || u64::try_from(unsigned_field(fields, 3)?).ok() != Some(expected.sender_id) + || u64::try_from(unsigned_field(fields, 4)?).ok() != Some(expected.recipient_id) + || u8::try_from(unsigned_field(fields, 5)?).ok() != Some(expected.purpose) + || u8::try_from(unsigned_field(fields, 6)?).ok() != Some(expected.direction) + { + return Err(PipeSessionError::InvalidOffer); + } + Ok(()) +} + +fn derive_forward_secure_chain_key( + shared_secret: &[u8], + handshake_transcript: &[u8; 32], +) -> Result<[u8; 32], PipeSessionError> { + let key = mtp_crypto::hkdf_expand(shared_secret, handshake_transcript, FS_ROOT_INFO, 32)?; + key.try_into().map_err(|_| PipeSessionError::InvalidOffer) +} + +fn forward_secure_context( + params: &PipeSessionParameters, + handshake_transcript: &[u8; 32], +) -> PipeProtectionContext { + let mut transcript = Vec::with_capacity(PIPE_TRANSCRIPT_DOMAIN.len() + 64); + transcript.extend_from_slice(PIPE_TRANSCRIPT_DOMAIN); + transcript.extend_from_slice(¶ms.transcript_hash()); + transcript.extend_from_slice(handshake_transcript); + let hash: [u8; 32] = mtp_crypto::sha256(&transcript); + PipeProtectionContext { + session_id: params.session_id.clone(), + purpose: params.purpose, + direction: params.direction, + transcript_hash: hash, + } +} + +enum PipeSigner { + Ed25519(Ed25519Signer), + Dual(DualSigner), +} + +impl SignatureScheme for PipeSigner { + fn algorithm(&self) -> u8 { + match self { + Self::Ed25519(signer) => signer.algorithm(), + Self::Dual(signer) => signer.algorithm(), + } + } + + fn sign(&self, message: &[u8]) -> Result, mtp_crypto::CryptoError> { + match self { + Self::Ed25519(signer) => signer.sign(message), + Self::Dual(signer) => signer.sign(message), + } + } + + fn verify(&self, message: &[u8], signature: &[u8]) -> Result<(), mtp_crypto::CryptoError> { + match self { + Self::Ed25519(signer) => signer.verify(message, signature), + Self::Dual(signer) => signer.verify(message, signature), + } + } +} + +fn pipe_signer_for_keyring(sender_keyring: &Keyring) -> Result { + match ( + sender_keyring.sig_pq_secret_key.as_bytes().is_empty(), + sender_keyring.sig_pq_public_key.as_bytes().is_empty(), + ) { + (true, true) => { + sender_keyring.validate_ed25519_signing()?; + Ok(PipeSigner::Ed25519(Ed25519Signer::new( + &sender_keyring.sig_cl_secret_key, + )?)) + } + (false, false) => { + sender_keyring.validate_dual_signing()?; + Ok(PipeSigner::Dual(DualSigner::new( + &sender_keyring.sig_cl_secret_key, + &sender_keyring.sig_pq_secret_key, + &sender_keyring.sig_pq_public_key, + )?)) + } + _ => Err(PipeSessionError::InvalidParameters( + "incomplete ML-DSA key pair", + )), + } +} + +fn pipe_signature_policy(keyring: &Keyring) -> Result { + match ( + keyring.sig_pq_secret_key.as_bytes().is_empty(), + keyring.sig_pq_public_key.as_bytes().is_empty(), + ) { + (true, true) => Ok(ProtectionPolicy::from(mtp_codec::SignaturePolicy::Ed25519)), + (false, false) => Ok(ProtectionPolicy::from(mtp_codec::SignaturePolicy::Dual)), + _ => Err(PipeSessionError::InvalidParameters( + "incomplete ML-DSA key pair", + )), + } +} + +fn build_session_offer( + params: &PipeSessionParameters, + sender_keyring: &Keyring, + recipient_public_keys: &[PublicKeyBundle], + key: [u8; 32], +) -> Result, PipeSessionError> { + if recipient_public_keys.is_empty() { + return Err(PipeSessionError::InvalidParameters( + "at least one pipe-session recipient is required", + )); + } + for recipient_public_key in recipient_public_keys { + recipient_public_key.validate()?; + } + let signer = pipe_signer_for_keyring(sender_keyring)?; + let signed = session_offer_value(params, key).sign( + params.sender_id, + ProtectionPurpose::from(PIPE_SESSION_SIGNATURE_PURPOSE), + &signer, + )?; + let encrypted = signed.encrypt_for( + recipient_public_keys, + ProtectionPurpose::from(PIPE_SESSION_ENCRYPTION_PURPOSE), + )?; + let offer = encrypted.to_bytes()?; + if offer.len() > MAX_PIPE_SESSION_OFFER { + return Err(PipeSessionError::OfferTooLarge(offer.len())); + } + Ok(offer) +} + +async fn write_session_offer( + stream: &mut S, + offer: &[u8], +) -> Result<(), PipeSessionError> { + let length = + u32::try_from(offer.len()).map_err(|_| PipeSessionError::OfferTooLarge(offer.len()))?; + stream.write_all(&length.to_be_bytes()).await?; + stream.write_all(offer).await?; + stream.flush().await?; + Ok(()) +} + +async fn read_session_offer( + stream: &mut R, +) -> Result, PipeSessionError> { + let mut length_bytes = [0u8; 4]; + stream + .read_exact(&mut length_bytes) + .await + .map_err(|error| { + if error.kind() == std::io::ErrorKind::UnexpectedEof { + PipeSessionError::UnexpectedEof + } else { + PipeSessionError::Io(error) + } + })?; + let length = u32::from_be_bytes(length_bytes) as usize; + if length == 0 || length > MAX_PIPE_SESSION_OFFER { + return Err(PipeSessionError::OfferTooLarge(length)); + } + let mut offer = vec![0u8; length]; + stream.read_exact(&mut offer).await.map_err(|error| { + if error.kind() == std::io::ErrorKind::UnexpectedEof { + PipeSessionError::UnexpectedEof + } else { + PipeSessionError::Io(error) + } + })?; + Ok(offer) +} + +fn offer_field(fields: &[DataValue], index: usize) -> Result<&DataValue, PipeSessionError> { + fields.get(index).ok_or(PipeSessionError::InvalidOffer) +} + +fn unsigned_field(fields: &[DataValue], index: usize) -> Result { + offer_field(fields, index)? + .as_unsigned_number() + .ok_or(PipeSessionError::InvalidOffer) +} + +fn validate_session_offer( + decrypted: DataValue, + expected: &PipeSessionParameters, + sender_public_key: &PublicKeyBundle, + policy: ProtectionPolicy, +) -> Result<[u8; 32], PipeSessionError> { + validate_session_offer_with_keys( + decrypted, + expected, + std::slice::from_ref(sender_public_key), + policy, + ) +} + +fn validate_session_offer_with_keys( + decrypted: DataValue, + expected: &PipeSessionParameters, + sender_public_keys: &[PublicKeyBundle], + policy: ProtectionPolicy, +) -> Result<[u8; 32], PipeSessionError> { + let signed = decrypted + .as_signed() + .ok_or(PipeSessionError::InvalidOffer)?; + if signed.signer_id != expected.sender_id { + return Err(PipeSessionError::Protection( + ProtectionError::SignerIdMismatch { + expected: expected.sender_id, + actual: signed.signer_id, + }, + )); + } + for sender_public_key in sender_public_keys { + sender_public_key.validate()?; + } + signed.verify_with_key_history( + expected.sender_id, + sender_public_keys, + ProtectionPurpose::from(PIPE_SESSION_SIGNATURE_PURPOSE), + policy, + )?; + + let fields = signed + .value + .as_array_slice() + .ok_or(PipeSessionError::InvalidOffer)?; + if fields.len() != 8 + || offer_field(fields, 0)?.as_str() != Some(PIPE_SESSION_OFFER_DOMAIN) + || offer_field(fields, 1)?.as_bytes_slice() != Some(expected.session_id()) + || u32::try_from(unsigned_field(fields, 2)?).ok() != Some(expected.pipe_id) + || u64::try_from(unsigned_field(fields, 3)?).ok() != Some(expected.sender_id) + || u64::try_from(unsigned_field(fields, 4)?).ok() != Some(expected.recipient_id) + || u8::try_from(unsigned_field(fields, 5)?).ok() != Some(expected.purpose) + || u8::try_from(unsigned_field(fields, 6)?).ok() != Some(expected.direction) + { + return Err(PipeSessionError::InvalidOffer); + } + let key = offer_field(fields, 7)? + .as_bytes_slice() + .ok_or(PipeSessionError::InvalidOffer)?; + key.try_into().map_err(|_| PipeSessionError::InvalidOffer) +} + +/// Establish an encrypted writer by sending a signed, recipient-encrypted +/// session-key offer over the raw pipe, then return the authenticated record +/// layer for subsequent bytes. +pub async fn initiate_pipe_session( + stream: S, + params: PipeSessionParameters, + sender_keyring: &Keyring, + recipient_public_key: &PublicKeyBundle, +) -> Result, PipeSessionError> { + let recipients = [recipient_public_key.clone()]; + initiate_group_pipe_session(stream, params, sender_keyring, &recipients).await +} + +/// Establish a pipe session for a group by encrypting one fresh session key +/// to every current member. Membership changes must create a fresh session +/// offer with the new recipient set; do not reuse the old record key for a +/// newly added member or continue sending it to a removed member. +pub async fn initiate_group_pipe_session( + mut stream: S, + params: PipeSessionParameters, + sender_keyring: &Keyring, + recipient_public_keys: &[PublicKeyBundle], +) -> Result, PipeSessionError> { + let mut key = [0u8; 32]; + rand::rng().fill(&mut key); + let offer = build_session_offer(¶ms, sender_keyring, recipient_public_keys, key)?; + write_session_offer(&mut stream, &offer).await?; + Ok(EncryptedPipeWriter::new(stream, key, params.context())) +} + +/// Accept and authenticate a signed, recipient-encrypted session-key offer, +/// then return the record layer for subsequent bytes. +pub async fn accept_pipe_session( + stream: R, + expected: &PipeSessionParameters, + recipient_keyring: &Keyring, + sender_public_key: &PublicKeyBundle, +) -> Result, PipeSessionError> { + let policy = pipe_signature_policy(recipient_keyring)?; + accept_pipe_session_with_policy( + stream, + expected, + recipient_keyring, + sender_public_key, + policy, + ) + .await +} + +/// Policy-aware counterpart to [`accept_pipe_session`]. +pub async fn accept_pipe_session_with_policy( + mut stream: R, + expected: &PipeSessionParameters, + recipient_keyring: &Keyring, + sender_public_key: &PublicKeyBundle, + policy: ProtectionPolicy, +) -> Result, PipeSessionError> { + let offer = read_session_offer(&mut stream).await?; + let encrypted = DataValue::from_bytes_with_limits( + &offer, + DecodeLimits { + max_blob_size: MAX_PIPE_SESSION_OFFER, + ..DecodeLimits::default() + }, + ) + .ok_or(PipeSessionError::InvalidOffer)?; + let signed = encrypted.decrypt( + recipient_keyring, + ProtectionPurpose::from(PIPE_SESSION_ENCRYPTION_PURPOSE), + )?; + let key = validate_session_offer(signed, expected, sender_public_key, policy)?; + Ok(EncryptedPipeReader::new(stream, key, expected.context())) +} + +/// Accept a pipe session against a trusted signing-key history. Historical +/// keys are local resolver state and never become visible in the offer. +pub async fn accept_pipe_session_with_key_history( + mut stream: R, + expected: &PipeSessionParameters, + recipient_keyring: &Keyring, + sender_public_keys: &[PublicKeyBundle], + policy: ProtectionPolicy, +) -> Result, PipeSessionError> { + if sender_public_keys.is_empty() { + return Err(PipeSessionError::InvalidParameters( + "at least one sender verification key is required", + )); + } + let offer = read_session_offer(&mut stream).await?; + let encrypted = DataValue::from_bytes_with_limits( + &offer, + DecodeLimits { + max_blob_size: MAX_PIPE_SESSION_OFFER, + ..DecodeLimits::default() + }, + ) + .ok_or(PipeSessionError::InvalidOffer)?; + let signed = encrypted.decrypt( + recipient_keyring, + ProtectionPurpose::from(PIPE_SESSION_ENCRYPTION_PURPOSE), + )?; + let key = validate_session_offer_with_keys(signed, expected, sender_public_keys, policy)?; + Ok(EncryptedPipeReader::new(stream, key, expected.context())) +} + +fn sign_forward_secure_value( + value: DataValue, + signer_id: u64, + keyring: &Keyring, +) -> Result, PipeSessionError> { + let signer = pipe_signer_for_keyring(keyring)?; + value + .sign( + signer_id, + ProtectionPurpose::from(PIPE_SESSION_SIGNATURE_PURPOSE), + &signer, + )? + .to_bytes() + .map_err(PipeSessionError::Codec) +} + +fn verify_forward_secure_value( + bytes: &[u8], + expected_signer_id: u64, + signer_public_key: &PublicKeyBundle, + policy: ProtectionPolicy, +) -> Result { + verify_forward_secure_value_with_keys( + bytes, + expected_signer_id, + std::slice::from_ref(signer_public_key), + policy, + ) +} + +fn verify_forward_secure_value_with_keys( + bytes: &[u8], + expected_signer_id: u64, + signer_public_keys: &[PublicKeyBundle], + policy: ProtectionPolicy, +) -> Result { + if signer_public_keys.is_empty() { + return Err(PipeSessionError::InvalidParameters( + "at least one sender verification key is required", + )); + } + let value = DataValue::from_bytes_with_limits( + bytes, + DecodeLimits { + max_blob_size: MAX_PIPE_SESSION_OFFER, + ..DecodeLimits::default() + }, + ) + .ok_or(PipeSessionError::InvalidOffer)?; + for signer_public_key in signer_public_keys { + signer_public_key.validate()?; + } + let signed = value.as_signed().ok_or(PipeSessionError::InvalidOffer)?; + signed.verify_with_key_history( + expected_signer_id, + signer_public_keys, + ProtectionPurpose::from(PIPE_SESSION_SIGNATURE_PURPOSE), + policy, + )?; + Ok((*signed.value).clone()) +} + +fn handshake_hash(parts: &[&[u8]]) -> [u8; 32] { + let total = parts.iter().map(|part| part.len()).sum(); + let mut transcript = Vec::with_capacity(total); + for part in parts { + append_transcript_field(&mut transcript, part); + } + mtp_crypto::sha256(&transcript) +} + +/// Forward-secret duplex handshake. +/// +/// Unlike the one-way session offer, this API requires a bidirectional stream: +/// the responder contributes an ephemeral KEM key, the initiator encapsulates +/// to it, and both sides derive record keys from the authenticated transcript. +/// Long-term KEM keys are not used, so later compromise of those keys cannot +/// recover recorded sessions. Long-term signing keys still authenticate the +/// exchange. +pub async fn initiate_forward_secure_pipe_session( + mut stream: S, + params: PipeSessionParameters, + sender_keyring: &Keyring, + recipient_public_key: &PublicKeyBundle, + policy: ProtectionPolicy, +) -> Result, PipeSessionError> { + recipient_public_key.validate()?; + let mut nonce = [0u8; 32]; + rand::rng().fill(&mut nonce); + let init_bytes = sign_forward_secure_value( + fs_init_value(¶ms, nonce), + params.sender_id, + sender_keyring, + )?; + write_session_offer(&mut stream, &init_bytes).await?; + + let response_bytes = read_session_offer(&mut stream).await?; + let response = verify_forward_secure_value( + &response_bytes, + params.recipient_id, + recipient_public_key, + policy, + )?; + let response_fields = response + .as_array_slice() + .ok_or(PipeSessionError::InvalidOffer)?; + validate_fs_common(response_fields, ¶ms, FS_RESPONSE_DOMAIN, 9)?; + let init_hash = handshake_hash(&[&init_bytes]); + if response_fields[7].as_bytes_slice() != Some(init_hash.as_slice()) { + return Err(PipeSessionError::InvalidOffer); + } + let ephemeral_public = response_fields[8] + .as_bytes_slice() + .ok_or(PipeSessionError::InvalidOffer)?; + let encapsulated = + mtp_crypto::HybridKem::encapsulate(&KemPublicKey::new(ephemeral_public.to_vec()))?; + let finish_bytes = sign_forward_secure_value( + fs_finish_value( + ¶ms, + handshake_hash(&[&response_bytes]), + &encapsulated.ciphertext, + ), + params.sender_id, + sender_keyring, + )?; + write_session_offer(&mut stream, &finish_bytes).await?; + + let transcript = handshake_hash(&[&init_bytes, &response_bytes, &finish_bytes]); + let chain_key = derive_forward_secure_chain_key(&encapsulated.shared_secret, &transcript)?; + Ok(EncryptedPipeWriter::new( + stream, + chain_key, + forward_secure_context(¶ms, &transcript), + )) +} + +/// Responder side of [`initiate_forward_secure_pipe_session`]. +pub async fn accept_forward_secure_pipe_session( + stream: S, + expected: &PipeSessionParameters, + recipient_keyring: &Keyring, + sender_public_key: &PublicKeyBundle, + policy: ProtectionPolicy, +) -> Result, PipeSessionError> { + accept_forward_secure_pipe_session_with_key_history( + stream, + expected, + recipient_keyring, + std::slice::from_ref(sender_public_key), + policy, + ) + .await +} + +/// Responder side of the forward-secure handshake with a local signing-key +/// history. Historical public keys remain local resolver state and are never +/// included in the handshake. +pub async fn accept_forward_secure_pipe_session_with_key_history< + S: AsyncRead + AsyncWrite + Unpin, +>( + mut stream: S, + expected: &PipeSessionParameters, + recipient_keyring: &Keyring, + sender_public_keys: &[PublicKeyBundle], + policy: ProtectionPolicy, +) -> Result, PipeSessionError> { + let init_bytes = read_session_offer(&mut stream).await?; + let init = verify_forward_secure_value_with_keys( + &init_bytes, + expected.sender_id, + sender_public_keys, + policy, + )?; + let init_fields = init + .as_array_slice() + .ok_or(PipeSessionError::InvalidOffer)?; + validate_fs_common(init_fields, expected, FS_INIT_DOMAIN, 8)?; + let nonce = init_fields[7] + .as_bytes_slice() + .ok_or(PipeSessionError::InvalidOffer)?; + if nonce.len() != 32 { + return Err(PipeSessionError::InvalidOffer); + } + + let (ephemeral_secret, ephemeral_public) = mtp_crypto::HybridKem::generate_keypair(); + let response_bytes = sign_forward_secure_value( + fs_response_value( + expected, + handshake_hash(&[&init_bytes]), + ephemeral_public.as_bytes(), + ), + expected.recipient_id, + recipient_keyring, + )?; + write_session_offer(&mut stream, &response_bytes).await?; + + let finish_bytes = read_session_offer(&mut stream).await?; + let finish = verify_forward_secure_value_with_keys( + &finish_bytes, + expected.sender_id, + sender_public_keys, + policy, + )?; + let finish_fields = finish + .as_array_slice() + .ok_or(PipeSessionError::InvalidOffer)?; + validate_fs_common(finish_fields, expected, FS_FINISH_DOMAIN, 9)?; + let response_hash = handshake_hash(&[&response_bytes]); + if finish_fields[7].as_bytes_slice() != Some(response_hash.as_slice()) { + return Err(PipeSessionError::InvalidOffer); + } + let ciphertext = finish_fields[8] + .as_bytes_slice() + .ok_or(PipeSessionError::InvalidOffer)?; + let shared_secret = mtp_crypto::HybridKem::decapsulate(&ephemeral_secret, ciphertext)?; + let transcript = handshake_hash(&[&init_bytes, &response_bytes, &finish_bytes]); + let chain_key = derive_forward_secure_chain_key(&shared_secret, &transcript)?; + Ok(EncryptedPipeReader::new( + stream, + chain_key, + forward_secure_context(expected, &transcript), + )) +} + +/// Errors produced by the encrypted pipe record layer. +#[derive(Debug)] +pub enum EncryptedPipeError { + InvalidContext, + InvalidRecordLength(usize), + InvalidRecordType(u8), + InvalidState, + SequenceExhausted, + FinalRecordRequired, + UnexpectedEof, + Io(std::io::Error), + Crypto(mtp_crypto::CryptoError), +} + +impl fmt::Display for EncryptedPipeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidContext => write!(f, "invalid encrypted pipe context"), + Self::InvalidRecordLength(length) => { + write!(f, "invalid encrypted pipe record length: {length}") + } + Self::InvalidRecordType(record_type) => { + write!(f, "invalid encrypted pipe record type: {record_type}") + } + Self::InvalidState => f.write_str("encrypted pipe is no longer usable"), + Self::SequenceExhausted => write!(f, "encrypted pipe sequence exhausted"), + Self::FinalRecordRequired => f.write_str("encrypted pipe ended without a final record"), + Self::UnexpectedEof => write!(f, "truncated encrypted pipe record"), + Self::Io(error) => write!(f, "encrypted pipe I/O error: {error}"), + Self::Crypto(error) => write!(f, "encrypted pipe authentication failed: {error}"), + } + } +} + +impl std::error::Error for EncryptedPipeError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(error) => Some(error), + Self::Crypto(error) => Some(error), + _ => None, + } + } +} + +impl From for EncryptedPipeError { + fn from(error: std::io::Error) -> Self { + Self::Io(error) + } +} + +impl From for EncryptedPipeError { + fn from(error: mtp_crypto::CryptoError) -> Self { + Self::Crypto(error) + } +} + +fn checked_record_length(plaintext_len: usize) -> Result { + let length = plaintext_len + .checked_add(XCHACHA_OVERHEAD) + .ok_or(EncryptedPipeError::InvalidRecordLength(usize::MAX))?; + if length > MAX_ENCRYPTED_PIPE_RECORD || length > u32::MAX as usize { + return Err(EncryptedPipeError::InvalidRecordLength(length)); + } + Ok(length) +} + +fn record_aad( + context: &PipeProtectionContext, + sequence: u64, + record_len: usize, + record_type: u8, +) -> Vec { + let mut aad = Vec::with_capacity(PIPE_E2EE_DOMAIN.len() + 2 + 32 + 8 + 4); + aad.extend_from_slice(PIPE_E2EE_DOMAIN); + aad.push(context.purpose); + aad.push(context.direction); + aad.extend_from_slice(context.transcript_hash()); + aad.extend_from_slice(&sequence.to_be_bytes()); + aad.extend_from_slice(&(record_len as u32).to_be_bytes()); + aad.push(record_type); + aad +} + +fn record_key_info(context: &PipeProtectionContext, sequence: u64, label: &[u8]) -> Vec { + let mut info = Vec::with_capacity(PIPE_RECORD_KDF_DOMAIN.len() + 2 + 32 + 8 + label.len()); + info.extend_from_slice(PIPE_RECORD_KDF_DOMAIN); + info.push(context.purpose); + info.push(context.direction); + info.extend_from_slice(context.transcript_hash()); + info.extend_from_slice(&sequence.to_be_bytes()); + info.extend_from_slice(label); + info +} + +fn derive_record_keys( + chain_key: &[u8; 32], + context: &PipeProtectionContext, + sequence: u64, +) -> Result<([u8; 32], [u8; 32]), EncryptedPipeError> { + let message_key = mtp_crypto::hkdf_expand( + chain_key, + context.transcript_hash(), + &record_key_info(context, sequence, PIPE_RECORD_MESSAGE_LABEL), + 32, + )?; + let next_chain_key = mtp_crypto::hkdf_expand( + chain_key, + context.transcript_hash(), + &record_key_info(context, sequence, PIPE_RECORD_NEXT_LABEL), + 32, + )?; + Ok(( + message_key + .try_into() + .map_err(|_| EncryptedPipeError::InvalidContext)?, + next_chain_key + .try_into() + .map_err(|_| EncryptedPipeError::InvalidContext)?, + )) +} + +/// Writer for ordered, authenticated encrypted pipe records. +pub struct EncryptedPipeWriter { + stream: S, + chain_key: Zeroizing<[u8; 32]>, + context: PipeProtectionContext, + sequence: u64, + state: PipeStreamState, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PipeStreamState { + Open, + Finalized, + Failed, +} + +impl EncryptedPipeWriter { + pub fn new(stream: S, key: [u8; 32], context: PipeProtectionContext) -> Self { + Self { + stream, + chain_key: Zeroizing::new(key), + context, + sequence: 0, + state: PipeStreamState::Open, + } + } + + pub fn sequence(&self) -> u64 { + self.sequence + } + + pub fn into_inner(self) -> S { + self.stream + } +} + +impl EncryptedPipeWriter { + /// Encrypt and append one record. Record boundaries are preserved by the + /// four-byte length prefix and are authenticated as associated data. + pub async fn write_record(&mut self, plaintext: &[u8]) -> Result<(), EncryptedPipeError> { + if self.state != PipeStreamState::Open { + return Err(EncryptedPipeError::InvalidState); + } + let result = self.write_record_inner(plaintext, RECORD_TYPE_DATA).await; + if result.is_err() { + self.poison(); + } + result + } + + async fn write_record_inner( + &mut self, + plaintext: &[u8], + record_type: u8, + ) -> Result<(), EncryptedPipeError> { + let sequence = self.sequence; + if sequence == u64::MAX { + return Err(EncryptedPipeError::SequenceExhausted); + } + let record_len = checked_record_length(plaintext.len())?; + let aad = record_aad(&self.context, sequence, record_len, record_type); + let (message_key, next_chain_key) = + derive_record_keys(&self.chain_key, &self.context, sequence)?; + let next_chain_key = Zeroizing::new(next_chain_key); + let cipher = XChaCha20Poly1305::new(message_key); + let ciphertext = cipher.encrypt(plaintext, &aad)?; + if ciphertext.len() != record_len { + return Err(EncryptedPipeError::InvalidRecordLength(ciphertext.len())); + } + + self.stream + .write_all(&(record_len as u32).to_be_bytes()) + .await?; + self.stream.write_all(&[record_type]).await?; + self.stream.write_all(&ciphertext).await?; + self.stream.flush().await?; + self.chain_key = next_chain_key; + self.sequence = sequence + .checked_add(1) + .ok_or(EncryptedPipeError::SequenceExhausted)?; + Ok(()) + } + + fn poison(&mut self) { + self.chain_key.zeroize(); + self.state = PipeStreamState::Failed; + } + + /// Authenticate stream completion with a final empty record before + /// closing the underlying transport. + pub async fn finish(mut self) -> Result<(), EncryptedPipeError> { + if self.state != PipeStreamState::Open { + return Err(EncryptedPipeError::InvalidState); + } + if let Err(error) = self.write_record_inner(&[], RECORD_TYPE_FINAL).await { + self.poison(); + return Err(error); + } + self.state = PipeStreamState::Finalized; + if let Err(error) = self.stream.shutdown().await { + self.poison(); + return Err(error.into()); + } + Ok(()) + } +} + +/// Reader for ordered, authenticated encrypted pipe records. +pub struct EncryptedPipeReader { + stream: R, + chain_key: Zeroizing<[u8; 32]>, + context: PipeProtectionContext, + sequence: u64, + state: PipeStreamState, +} + +impl EncryptedPipeReader { + pub fn new(stream: R, key: [u8; 32], context: PipeProtectionContext) -> Self { + Self { + stream, + chain_key: Zeroizing::new(key), + context, + sequence: 0, + state: PipeStreamState::Open, + } + } + + pub fn sequence(&self) -> u64 { + self.sequence + } + + pub fn into_inner(self) -> R { + self.stream + } +} + +impl EncryptedPipeReader { + /// Read and authenticate the next record. `None` is returned only after a + /// valid authenticated final record; transport EOF alone is truncation. + pub async fn read_record(&mut self) -> Result>, EncryptedPipeError> { + if self.state == PipeStreamState::Finalized { + return Ok(None); + } + if self.state == PipeStreamState::Failed { + return Err(EncryptedPipeError::InvalidState); + } + let result = self.read_record_inner().await; + if result.is_err() { + self.poison(); + } + result + } + + async fn read_record_inner(&mut self) -> Result>, EncryptedPipeError> { + if self.sequence == u64::MAX { + return Err(EncryptedPipeError::SequenceExhausted); + } + let mut prefix = [0u8; RECORD_LENGTH_BYTES]; + self.stream.read_exact(&mut prefix).await.map_err(|error| { + if error.kind() == std::io::ErrorKind::UnexpectedEof { + EncryptedPipeError::FinalRecordRequired + } else { + EncryptedPipeError::Io(error) + } + })?; + + let record_len = u32::from_be_bytes(prefix) as usize; + if !(XCHACHA_OVERHEAD..=MAX_ENCRYPTED_PIPE_RECORD).contains(&record_len) { + return Err(EncryptedPipeError::InvalidRecordLength(record_len)); + } + let mut record_type = [0u8; RECORD_TYPE_BYTES]; + self.stream + .read_exact(&mut record_type) + .await + .map_err(|error| { + if error.kind() == std::io::ErrorKind::UnexpectedEof { + EncryptedPipeError::UnexpectedEof + } else { + EncryptedPipeError::Io(error) + } + })?; + if !matches!(record_type[0], RECORD_TYPE_DATA | RECORD_TYPE_FINAL) { + return Err(EncryptedPipeError::InvalidRecordType(record_type[0])); + } + + let mut ciphertext = vec![0u8; record_len]; + self.stream + .read_exact(&mut ciphertext) + .await + .map_err(|error| { + if error.kind() == std::io::ErrorKind::UnexpectedEof { + EncryptedPipeError::UnexpectedEof + } else { + EncryptedPipeError::Io(error) + } + })?; + let sequence = self.sequence; + let aad = record_aad(&self.context, sequence, record_len, record_type[0]); + let (message_key, next_chain_key) = + derive_record_keys(&self.chain_key, &self.context, sequence)?; + let next_chain_key = Zeroizing::new(next_chain_key); + let cipher = XChaCha20Poly1305::new(message_key); + let plaintext = cipher.decrypt(&ciphertext, &aad)?; + self.chain_key = next_chain_key; + self.sequence = sequence + .checked_add(1) + .ok_or(EncryptedPipeError::SequenceExhausted)?; + if record_type[0] == RECORD_TYPE_FINAL { + if !plaintext.is_empty() { + return Err(EncryptedPipeError::InvalidRecordLength(plaintext.len())); + } + self.state = PipeStreamState::Finalized; + return Ok(None); + } + Ok(Some(plaintext)) + } + + fn poison(&mut self) { + self.chain_key.zeroize(); + self.state = PipeStreamState::Failed; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::duplex; + + #[test] + fn application_pipe_context_rejects_mtp_purposes() { + assert!(matches!( + PipeProtectionContext::new(b"application", PIPE_SESSION_SIGNATURE_PURPOSE, 0), + Err(EncryptedPipeError::InvalidContext) + )); + } + + #[tokio::test] + async fn records_roundtrip_and_bind_context() { + let (left, right) = duplex(4096); + let context = + PipeProtectionContext::new(b"pipe-session/client/peer", 0x41, 0).expect("context"); + let writer_context = context.clone(); + let reader_context = context.clone(); + let writer = tokio::spawn(async move { + let mut writer = EncryptedPipeWriter::new(left, [7u8; 32], writer_context); + writer.write_record(b"first").await.expect("first record"); + writer.write_record(b"second").await.expect("second record"); + writer.finish().await.expect("finish"); + }); + + let mut reader = EncryptedPipeReader::new(right, [7u8; 32], reader_context); + assert_eq!( + reader.read_record().await.expect("read").as_deref(), + Some(b"first".as_slice()) + ); + assert_eq!( + reader.read_record().await.expect("read").as_deref(), + Some(b"second".as_slice()) + ); + assert!(reader.read_record().await.expect("eof").is_none()); + writer.await.expect("writer task"); + } + + #[tokio::test] + async fn wrong_context_fails_authentication() { + let (left, right) = duplex(4096); + let writer_context = PipeProtectionContext::new(b"session-a", 1, 0).expect("context"); + let reader_context = PipeProtectionContext::new(b"session-b", 1, 0).expect("context"); + let writer = tokio::spawn(async move { + let mut writer = EncryptedPipeWriter::new(left, [9u8; 32], writer_context); + writer.write_record(b"secret").await.expect("write"); + }); + let mut reader = EncryptedPipeReader::new(right, [9u8; 32], reader_context); + assert!(matches!( + reader.read_record().await, + Err(EncryptedPipeError::Crypto(_)) + )); + assert!(matches!( + reader.read_record().await, + Err(EncryptedPipeError::InvalidState) + )); + writer.await.expect("writer task"); + } + + #[tokio::test] + async fn transport_eof_without_final_record_is_truncation() { + let (left, right) = duplex(4096); + let context = PipeProtectionContext::new(b"session", 0x40, 0).expect("context"); + let mut writer = EncryptedPipeWriter::new(left, [3u8; 32], context.clone()); + writer.write_record(b"not finished").await.expect("record"); + let stream = writer.into_inner(); + drop(stream); + + let mut reader = EncryptedPipeReader::new(right, [3u8; 32], context); + assert_eq!( + reader.read_record().await.expect("record").as_deref(), + Some(b"not finished".as_slice()) + ); + assert!(matches!( + reader.read_record().await, + Err(EncryptedPipeError::FinalRecordRequired) + )); + assert!(matches!( + reader.read_record().await, + Err(EncryptedPipeError::InvalidState) + )); + } + + #[test] + fn record_key_schedule_is_context_and_chain_bound() { + let context = PipeProtectionContext::new(b"session-a", 1, 0).expect("context"); + let first = derive_record_keys(&[7u8; 32], &context, 0).expect("first keys"); + let second = derive_record_keys(&first.1, &context, 1).expect("second keys"); + let repeated = derive_record_keys(&[7u8; 32], &context, 1).expect("repeated keys"); + let other_context = PipeProtectionContext::new(b"session-b", 1, 0).expect("context"); + let other = derive_record_keys(&first.1, &other_context, 1).expect("other keys"); + + assert_ne!(first.0, second.0); + assert_ne!(second.0, repeated.0); + assert_ne!(second.0, other.0); + assert_ne!(second.1, other.1); + } + + #[tokio::test] + async fn signed_session_offer_establishes_the_record_layer() { + let sender = Keyring::generate(); + let recipient = Keyring::generate(); + let sender_public = sender.public_key_bundle(); + let recipient_public = recipient.public_key_bundle(); + let params = PipeSessionParameters::new(b"session/client/peer/pipe-7", 7, 41, 99, 0x40, 0) + .expect("parameters"); + let writer_params = params.clone(); + let (left, right) = duplex(128 * 1024); + let writer_task = tokio::spawn(async move { + let mut writer = initiate_pipe_session(left, writer_params, &sender, &recipient_public) + .await + .expect("session offer"); + writer + .write_record(b"authenticated pipe data") + .await + .expect("record"); + writer.finish().await.expect("finish"); + }); + + let mut reader = accept_pipe_session(right, ¶ms, &recipient, &sender_public) + .await + .expect("session accept"); + assert_eq!( + reader.read_record().await.expect("record").as_deref(), + Some(b"authenticated pipe data".as_slice()) + ); + assert!(reader.read_record().await.expect("eof").is_none()); + writer_task.await.expect("writer task"); + } + + #[tokio::test] + async fn session_offer_accepts_a_trusted_historical_signing_key() { + let historical_sender = Keyring::generate(); + let current_sender = Keyring::generate(); + let recipient = Keyring::generate(); + let recipient_public = recipient.public_key_bundle(); + let historical_public = historical_sender.public_key_bundle(); + let current_public = current_sender.public_key_bundle(); + let params = PipeSessionParameters::new(b"historical-session", 8, 41, 99, 0x40, 0) + .expect("parameters"); + let writer_params = params.clone(); + let (left, right) = duplex(128 * 1024); + let writer_task = tokio::spawn(async move { + initiate_pipe_session(left, writer_params, &historical_sender, &recipient_public).await + }); + let reader = accept_pipe_session_with_key_history( + right, + ¶ms, + &recipient, + &[current_public, historical_public], + ProtectionPolicy::from(mtp_codec::SignaturePolicy::Dual), + ) + .await + .expect("historical session offer"); + let writer = writer_task.await.expect("writer task").expect("writer"); + drop(writer); + // The successful setup is the assertion; no application record is + // needed to prove that the historical signature key was selected. + assert_eq!(reader.sequence(), 0); + } + + #[tokio::test] + async fn forward_secure_duplex_handshake_accepts_signing_key_history() { + let historical_sender = Keyring::generate(); + let current_sender = Keyring::generate(); + let recipient = Keyring::generate(); + let historical_public = historical_sender.public_key_bundle(); + let current_public = current_sender.public_key_bundle(); + let recipient_public = recipient.public_key_bundle(); + let params = PipeSessionParameters::new(b"forward-secure-session", 17, 41, 99, 0x40, 0) + .expect("parameters"); + let responder_params = params.clone(); + let (left, right) = duplex(256 * 1024); + let responder = tokio::spawn(async move { + accept_forward_secure_pipe_session_with_key_history( + right, + &responder_params, + &recipient, + &[current_public, historical_public], + ProtectionPolicy::from(mtp_codec::SignaturePolicy::Dual), + ) + .await + }); + let mut writer = initiate_forward_secure_pipe_session( + left, + params, + &historical_sender, + &recipient_public, + ProtectionPolicy::from(mtp_codec::SignaturePolicy::Dual), + ) + .await + .expect("forward-secure initiator"); + writer + .write_record(b"forward secret") + .await + .expect("record"); + writer.finish().await.expect("finish"); + let mut reader = responder.await.expect("responder task").expect("reader"); + assert_eq!( + reader.read_record().await.expect("record").as_deref(), + Some(b"forward secret".as_slice()) + ); + assert!(reader.read_record().await.expect("final").is_none()); + } + + #[test] + fn group_session_offer_is_decryptable_by_each_current_member_only() { + let sender = Keyring::generate(); + let first = Keyring::generate(); + let second = Keyring::generate(); + let outsider = Keyring::generate(); + let params = + PipeSessionParameters::new(b"group-session", 11, 41, 99, 0x40, 0).expect("parameters"); + let key = [8u8; 32]; + let offer = build_session_offer( + ¶ms, + &sender, + &[first.public_key_bundle(), second.public_key_bundle()], + key, + ) + .expect("offer"); + let encrypted = DataValue::from_bytes(&offer).expect("encrypted offer"); + for member in [&first, &second] { + let opened = encrypted + .decrypt( + member, + ProtectionPurpose::from(PIPE_SESSION_ENCRYPTION_PURPOSE), + ) + .expect("member decrypt"); + let fields = opened + .as_signed() + .and_then(|value| value.value.as_array_slice()) + .expect("signed fields"); + assert_eq!(fields[7].as_bytes_slice(), Some(key.as_slice())); + } + assert!(matches!( + encrypted.decrypt( + &outsider, + ProtectionPurpose::from(PIPE_SESSION_ENCRYPTION_PURPOSE), + ), + Err(ProtectionError::NoMatchingRecipient) + )); + } +} diff --git a/transport/src/framing.rs b/transport/src/framing.rs index c179f6e..42e6831 100644 --- a/transport/src/framing.rs +++ b/transport/src/framing.rs @@ -2,7 +2,11 @@ use crate::{Policy, TransportSendStream}; use mtp_codec::CommunicationValue; use mtp_common::CommunicationError; -/// Writes the canonical length-prefixed MTP frame used by every transport. +/// Writes the canonical self-framed MTP value used by every transport. +/// +/// `CommunicationValue` already begins with the four-byte body length. The +/// transport writes that representation directly so a frame does not carry a +/// redundant outer length prefix. pub(crate) async fn write_frame( stream: &mut S, value: &CommunicationValue, @@ -14,8 +18,70 @@ pub(crate) async fn write_frame( { return Err(CommunicationError::MessageTooLarge); } - stream - .write_all(&(bytes.len() as u32).to_be_bytes()) - .await?; stream.write_all(&bytes).await } + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use mtp_codec::{CommunicationType, DataValue}; + use std::pin::Pin; + use std::task::{Context, Poll}; + use tokio::io::AsyncWrite; + + #[derive(Default)] + struct BufferStream { + bytes: Vec, + } + + impl AsyncWrite for BufferStream { + fn poll_write( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + self.bytes.extend_from_slice(buf); + Poll::Ready(Ok(buf.len())) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + } + + #[async_trait] + impl TransportSendStream for BufferStream { + async fn write_all(&mut self, buf: &[u8]) -> Result<(), CommunicationError> { + self.bytes.extend_from_slice(buf); + Ok(()) + } + + async fn finish(&mut self) -> Result<(), CommunicationError> { + Ok(()) + } + } + + #[tokio::test] + async fn framing_preserves_a_generic_payload() { + let payload = DataValue::Array(vec![ + DataValue::Str("payload".into()), + DataValue::Bytes(vec![1, 2, 3]), + ]); + let frame = CommunicationValue::new(CommunicationType::Pong).with_payload(payload.clone()); + let mut stream = BufferStream::default(); + + write_frame(&mut stream, &frame, &Policy::default()) + .await + .unwrap(); + + let body_len = u32::from_be_bytes(stream.bytes[..4].try_into().unwrap()) as usize; + assert_eq!(body_len, stream.bytes.len() - 4); + let decoded = CommunicationValue::from_bytes(&stream.bytes).unwrap(); + assert_eq!(decoded.into_payload(), payload); + } +} diff --git a/transport/src/generic_connection.rs b/transport/src/generic_connection.rs index 9222d27..94fdd3f 100644 --- a/transport/src/generic_connection.rs +++ b/transport/src/generic_connection.rs @@ -7,7 +7,7 @@ use crate::{ Policy, TransportConnection, TransportRecvStream, TransportSendStream, framing::write_frame, }; -use mtp_codec::CommunicationValue; +use mtp_codec::{CommunicationValue, DecodeLimits, TypeMap}; use mtp_common::CommunicationError; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; @@ -22,6 +22,7 @@ pub struct GenericSender { policy: Arc, persistent: Arc>>, send_lock: Arc>, + type_map: Arc>, } impl Clone for GenericSender { @@ -31,6 +32,7 @@ impl Clone for GenericSender { policy: self.policy.clone(), persistent: self.persistent.clone(), send_lock: self.send_lock.clone(), + type_map: self.type_map.clone(), } } } @@ -42,9 +44,15 @@ impl GenericSender { policy, persistent: Arc::new(Mutex::new(None)), send_lock: Arc::new(Mutex::new(())), + type_map: Arc::new(RwLock::new(TypeMap::latest())), } } + /// Bind control frames created by this sender to the negotiated protocol map. + pub async fn set_type_map(&self, type_map: &TypeMap) { + *self.type_map.write().await = type_map.clone(); + } + async fn open(&self) -> Result { timeout(self.policy.open_stream_timeout, self.connection.open_uni()) .await @@ -112,12 +120,16 @@ impl GenericSender { let mut stream = self.open().await?; - let request = CommunicationValue::new(mtp_codec::CommunicationType::PipeRequest) - .with_id(pipe_id) - .add_typed_default( - mtp_codec::DataType::Description, - mtp_codec::DataValue::Str(description.to_string()), - ); + let type_map = self.type_map.read().await.clone(); + let request = CommunicationValue::new_with_type_map( + mtp_codec::CommunicationType::PipeRequest, + &type_map, + ) + .with_id(pipe_id) + .add_typed_default( + mtp_codec::DataType::Description, + mtp_codec::DataValue::Str(description.to_string()), + ); timeout( self.policy.write_timeout, @@ -166,6 +178,7 @@ pub struct GenericReceiver { connection: C, ping_sender: Arc>>>, max_message_size: Arc, + type_map: Arc>, _accept_task: Arc>, } @@ -178,6 +191,7 @@ impl Clone for GenericReceiver { connection: self.connection.clone(), ping_sender: self.ping_sender.clone(), max_message_size: self.max_message_size.clone(), + type_map: self.type_map.clone(), _accept_task: self._accept_task.clone(), } } @@ -206,6 +220,8 @@ impl GenericReceiver { let task_connection = connection.clone(); let task_policy = policy.clone(); let task_max_message_size = max_message_size.clone(); + let type_map = Arc::new(RwLock::new(TypeMap::latest())); + let task_type_map = type_map.clone(); let task_accept_task_tx = tx.clone(); #[cfg(feature = "pipes")] let task_accept_task_pipe_tx = pipe_tx.clone(); @@ -260,6 +276,7 @@ impl GenericReceiver { let max_message_size = task_max_message_size.clone(); let ping_sender = task_ping_sender.clone(); let connection = task_connection.clone(); + let type_map = task_type_map.clone(); tokio::spawn(async move { let _permit = permit; let mut stream = stream; @@ -298,13 +315,22 @@ impl GenericReceiver { break; } let frame_limit = max_message_size.load(Ordering::Relaxed); - if len as u64 > frame_limit { - tracing::warn!(len, "MTP receive stream frame is too large"); + let body_len = len as usize; + let frame_len = match body_len.checked_add(4) { + Some(frame_len) => frame_len, + None => { + let _ = tx.send(Err(CommunicationError::MessageTooLarge)).await; + connection.close(policy.application_close_code, b"frame too large"); + break; + } + }; + if frame_len as u64 > frame_limit { + tracing::warn!(frame_len, "MTP receive stream frame is too large"); let _ = tx.send(Err(CommunicationError::MessageTooLarge)).await; connection.close(policy.application_close_code, b"frame too large"); break; } - let target_len = len as usize; + let target_len = body_len; let mut body = Vec::new(); if body.try_reserve(target_len.min(16 * 1024)).is_err() { tracing::warn!( @@ -343,7 +369,13 @@ impl GenericReceiver { break; } frames += 1; - let message = match CommunicationValue::from_bytes(&body) { + let mut frame = Vec::with_capacity(frame_len); + frame.extend_from_slice(&len.to_be_bytes()); + frame.extend_from_slice(&body); + let mut message = match CommunicationValue::from_bytes_with_limits( + &frame, + DecodeLimits::for_transport_message_size(frame_limit), + ) { Ok(message) => message, Err(_) => { tracing::warn!("MTP receive stream contained an invalid frame"); @@ -354,13 +386,25 @@ impl GenericReceiver { break; } }; + let negotiated_type_map = type_map.read().await.clone(); + message.set_type_map(&negotiated_type_map); #[cfg(feature = "pipes")] { - let pipe_request_type = mtp_codec::CommunicationType::PipeRequest - .try_to_id(&mtp_codec::TypeMap::latest()); - if Some(message.get_type()) == pipe_request_type && frames == 1 { - let pipe_id = message.get_id(); + if message.is_type(mtp_codec::CommunicationType::PipeRequest) + && frames == 1 + { + let Some(pipe_id) = message.id().filter(|id| *id != 0) else { + let error = CommunicationError::Other( + "PipeRequest frame must contain a non-zero id".into(), + ); + let _ = tx.send(Err(error.clone())).await; + connection.close( + policy.application_close_code, + b"pipe request missing id", + ); + break; + }; let description = message .get_str(mtp_codec::DataType::Description) .unwrap_or("") @@ -383,11 +427,17 @@ impl GenericReceiver { if message.is_type(mtp_codec::CommunicationType::Ping) { if let Some(sender) = ping_sender.read().await.clone() { - let mut pong = - CommunicationValue::new(mtp_codec::CommunicationType::Pong) - .with_id(message.get_id()); + let mut pong = CommunicationValue::new_with_type_map( + mtp_codec::CommunicationType::Pong, + &negotiated_type_map, + ); + if let Some(id) = message.id() { + pong = pong.with_id(id); + } else { + pong = pong.without_id(); + } if let Some(timestamp) = - message.get_data_opt(mtp_codec::DataType::Timestamp) + message.get_data(mtp_codec::DataType::Timestamp) { pong = pong.add_typed_default( mtp_codec::DataType::Timestamp, @@ -412,6 +462,7 @@ impl GenericReceiver { connection, ping_sender, max_message_size, + type_map, _accept_task: Arc::new(accept_task), } } @@ -424,6 +475,11 @@ impl GenericReceiver { self.max_message_size .store(max_message_size, Ordering::Relaxed); } + + /// Bind subsequently decoded frames to the negotiated protocol version. + pub async fn set_type_map(&self, type_map: &TypeMap) { + *self.type_map.write().await = type_map.clone(); + } pub async fn receive(&self) -> Result { self.incoming .lock() diff --git a/transport/src/lib.rs b/transport/src/lib.rs index 3a7f9ac..6df5311 100644 --- a/transport/src/lib.rs +++ b/transport/src/lib.rs @@ -6,6 +6,8 @@ pub mod generic_connection; pub mod pinning; pub mod transport_traits; +#[cfg(feature = "pipes")] +pub mod encrypted_pipe; #[cfg(feature = "pipes")] pub mod pipe; @@ -15,6 +17,15 @@ pub use generic_connection::{GenericReceiver, GenericSender}; #[cfg(feature = "pipes")] pub use connection::TransportEvent; #[cfg(feature = "pipes")] +pub use encrypted_pipe::{ + EncryptedPipeError, EncryptedPipeReader, EncryptedPipeWriter, MAX_ENCRYPTED_PIPE_RECORD, + MAX_PIPE_SESSION_OFFER, PIPE_SESSION_ENCRYPTION_PURPOSE, PIPE_SESSION_SIGNATURE_PURPOSE, + PipeProtectionContext, PipeSessionError, PipeSessionParameters, + accept_forward_secure_pipe_session, accept_forward_secure_pipe_session_with_key_history, + accept_pipe_session, accept_pipe_session_with_key_history, accept_pipe_session_with_policy, + initiate_forward_secure_pipe_session, initiate_group_pipe_session, initiate_pipe_session, +}; +#[cfg(feature = "pipes")] pub use pipe::{PipeReader, PipeWriter}; pub use client::{ClientConfig, connect, connect_with_config}; diff --git a/transport/src/pipe.rs b/transport/src/pipe.rs index 9cd5b3d..9c17f71 100644 --- a/transport/src/pipe.rs +++ b/transport/src/pipe.rs @@ -22,6 +22,10 @@ impl PipeWriter { } impl PipeWriter { + pub fn into_inner(self) -> S { + self.stream + } + pub async fn finish_async(mut self) -> Result<(), mtp_common::CommunicationError> { tokio::io::AsyncWriteExt::shutdown(&mut self) .await @@ -58,6 +62,10 @@ pub struct PipeReader { } impl PipeReader { + pub fn into_inner(self) -> R { + self.stream + } + pub fn description(&self) -> &str { &self.description } diff --git a/transport/tests/integration.rs b/transport/tests/integration.rs index 16d42de..7028528 100644 --- a/transport/tests/integration.rs +++ b/transport/tests/integration.rs @@ -50,12 +50,14 @@ async fn connected_pair() } fn numbered_message(comm_type: CommunicationType, value: u128, tm: &TypeMap) -> CommunicationValue { - CommunicationValue::new(comm_type).add_data( - DataType::PqSignature - .try_to_id(tm) - .expect("test type must be mapped"), - DataValue::UnsignedNumber(value), - ) + CommunicationValue::new(comm_type) + .add_data( + DataType::PqSignature + .try_to_id(tm) + .expect("test type must be mapped"), + DataValue::UnsignedNumber(value), + ) + .expect("numbered message must have a container payload") } fn assert_numbered_message( @@ -69,8 +71,8 @@ fn assert_numbered_message( comm_type.try_to_id(tm).expect("test type must be mapped") ); assert_eq!( - message.get_data(DataType::PqSignature).clone(), - DataValue::UnsignedNumber(value) + message.get_data(DataType::PqSignature), + Some(&DataValue::UnsignedNumber(value)) ); } @@ -138,6 +140,25 @@ async fn test_send_receive_roundtrip() -> Result<(), Box> Ok(()) } +#[tokio::test] +async fn test_generic_payload_roundtrip() -> Result<(), Box> { + let (_h, client_tx, _client_rx, host_tx, host_rx) = connected_pair().await?; + let payload = DataValue::Array(vec![ + DataValue::Str("generic payload".into()), + DataValue::Bytes(vec![1, 2, 3]), + ]); + let message = + CommunicationValue::new(CommunicationType::BadRequest).with_payload(payload.clone()); + + client_tx.send(&message).await?; + let received = host_rx.receive().await?; + + assert_eq!(received.into_payload(), payload); + client_tx.close().await; + host_tx.close().await; + Ok(()) +} + #[tokio::test] async fn test_concurrent_messages() -> Result<(), Box> { let (_h, client_tx, _client_rx, _host_tx, host_rx) = connected_pair().await?; diff --git a/tsconfig.json b/tsconfig.json index ea7887a..be2b7f0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,6 +19,7 @@ "skipLibCheck": true, "isolatedModules": true, "verbatimModuleSyntax": true, + "resolveJsonModule": true, }, "include": [ "src/raw/**/*.ts", diff --git a/tsconfig.type-tests.json b/tsconfig.type-tests.json new file mode 100644 index 0000000..5325ad8 --- /dev/null +++ b/tsconfig.type-tests.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": ["src/**/*.ts", "test/**/*.type-test.ts"] +} diff --git a/type-map/build.rs b/type-map/build.rs index a7dbe27..21f81ff 100755 --- a/type-map/build.rs +++ b/type-map/build.rs @@ -2,9 +2,9 @@ use serde::Deserialize; use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Write; use std::path::{Path, PathBuf}; +use std::sync::OnceLock; const DEFAULT_TYPE_MAPS_PATH: &str = "./type-maps.yaml"; -const FIRST_USER_TYPE_ID: u16 = 32; #[derive(Deserialize)] struct Config { @@ -22,186 +22,54 @@ struct TypeMapConfig { data_types: BTreeMap, } -struct ReservedEntry { - name: &'static str, +#[derive(Deserialize)] +struct ReservedManifest { + #[serde(rename = "firstUserTypeId")] + first_user_type_id: u16, + communication: Vec, + data: Vec, +} + +#[derive(Deserialize)] +struct ManifestEntry { + name: String, id: u16, } -const RESERVED_COMM_TYPES: &[ReservedEntry] = &[ - ReservedEntry { - name: "Identification", - id: 0, - }, - ReservedEntry { - name: "IdentificationResponse", - id: 1, - }, - ReservedEntry { - name: "Register", - id: 2, - }, - ReservedEntry { - name: "RegisterResponse", - id: 3, - }, - ReservedEntry { - name: "Challenge", - id: 4, - }, - ReservedEntry { - name: "ChallengeResponse", - id: 5, - }, - ReservedEntry { - name: "Ping", - id: 6, - }, - ReservedEntry { - name: "Pong", - id: 7, - }, - ReservedEntry { - name: "Disconnect", - id: 8, - }, - ReservedEntry { - name: "Redirect", - id: 9, - }, - ReservedEntry { - name: "Shutdown", - id: 10, - }, - ReservedEntry { - name: "Error", - id: 11, - }, - ReservedEntry { - name: "ErrorParsing", - id: 12, - }, - ReservedEntry { - name: "ErrorBadVersion", - id: 13, - }, - ReservedEntry { - name: "BadRequest", - id: 14, - }, - ReservedEntry { - name: "Unauthorized", - id: 15, - }, - ReservedEntry { - name: "Forbidden", - id: 16, - }, - ReservedEntry { - name: "NotFound", - id: 17, - }, - ReservedEntry { - name: "TooManyRequests", - id: 18, - }, - ReservedEntry { - name: "InternalServerError", - id: 19, - }, - ReservedEntry { - name: "BadGateway", - id: 20, - }, - ReservedEntry { - name: "ServiceUnavailable", - id: 21, - }, - ReservedEntry { - name: "GatewayTimeout", - id: 22, - }, - #[cfg(feature = "pipes")] - ReservedEntry { - name: "PipeRequest", - id: 23, - }, - #[cfg(feature = "pipes")] - ReservedEntry { - name: "PipeResponse", - id: 24, - }, - #[cfg(feature = "pipes")] - ReservedEntry { - name: "PipeAbort", - id: 25, - }, -]; +fn reserved_manifest() -> &'static ReservedManifest { + static MANIFEST: OnceLock = OnceLock::new(); + MANIFEST.get_or_init(|| { + serde_yaml::from_str(include_str!("reserved.json")) + .expect("type-map/reserved.json must be valid JSON/YAML") + }) +} -const RESERVED_DATA_TYPES: &[ReservedEntry] = &[ - ReservedEntry { - name: "Version", - id: 0, - }, - ReservedEntry { name: "Id", id: 1 }, - ReservedEntry { - name: "ClientNonce", - id: 2, - }, - ReservedEntry { - name: "ServerNonce", - id: 3, - }, - ReservedEntry { - name: "PublicKeys", - id: 4, - }, - ReservedEntry { - name: "Signature", - id: 5, - }, - ReservedEntry { - name: "PqSignature", - id: 6, - }, - ReservedEntry { - name: "Description", - id: 7, - }, - ReservedEntry { - name: "Connected", - id: 8, - }, - ReservedEntry { - name: "Timestamp", - id: 9, - }, - ReservedEntry { - name: "Error", - id: 10, - }, - ReservedEntry { - name: "ErrorParsing", - id: 11, - }, - ReservedEntry { - name: "ErrorMessage", - id: 12, - }, - ReservedEntry { - name: "Accepted", - id: 13, - }, - ReservedEntry { - name: "RequirePq", - id: 14, - }, -]; +fn first_user_type_id() -> u16 { + reserved_manifest().first_user_type_id +} + +fn all_reserved_comm_types() -> &'static [ManifestEntry] { + &reserved_manifest().communication +} + +fn generated_reserved_comm_types() -> Vec<&'static ManifestEntry> { + all_reserved_comm_types() + .iter() + .filter(|entry| cfg!(feature = "pipes") || !entry.name.starts_with("Pipe")) + .collect() +} + +fn all_reserved_data_types() -> &'static [ManifestEntry] { + &reserved_manifest().data +} fn main() { let out = PathBuf::from(std::env::var("OUT_DIR").unwrap()); let multi_version = std::env::var("CARGO_FEATURE_REGISTRY").is_ok(); println!("cargo:rerun-if-env-changed=MTP_TYPE_MAPS"); + println!("cargo:rerun-if-changed=reserved.json"); + validate_reserved_manifest(); let loaded = match std::env::var_os("MTP_TYPE_MAPS") { Some(config_path) => load_config(&PathBuf::from(config_path)), @@ -231,6 +99,35 @@ fn main() { std::fs::write(out.join("types.rs"), code).unwrap(); } +fn validate_reserved_manifest() { + assert!(first_user_type_id() > 0); + for (category, entries) in [ + ("communication", all_reserved_comm_types()), + ("data", all_reserved_data_types()), + ] { + let mut names = BTreeSet::new(); + let mut ids = BTreeSet::new(); + for entry in entries { + assert!( + entry.id < first_user_type_id(), + "reserved {category} type {} has a user-range ID {}", + entry.name, + entry.id + ); + assert!( + names.insert(entry.name.as_str()), + "duplicate reserved {category} type name {}", + entry.name + ); + assert!( + ids.insert(entry.id), + "duplicate reserved {category} type ID {}", + entry.id + ); + } + } +} + struct LoadedConfig { config: Config, path: Option, @@ -292,8 +189,15 @@ fn validate_config(loaded: &LoadedConfig) { version, "CommunicationTypes", &type_map.communication_types, + all_reserved_comm_types(), + ); + validate_ids( + loaded, + version, + "DataTypes", + &type_map.data_types, + all_reserved_data_types(), ); - validate_ids(loaded, version, "DataTypes", &type_map.data_types); } } @@ -319,16 +223,28 @@ fn validate_ids( version: &str, category: &str, entries: &BTreeMap, + reserved: &[ManifestEntry], ) { let mut names_by_id = BTreeMap::new(); for (name, id) in entries { - if *id < FIRST_USER_TYPE_ID { + if reserved.iter().any(|entry| entry.name == *name) { validation_error( loaded, &["type_maps", version, category], name, format!( - "{category}.{name} in type-map version {version} uses reserved id {id}; user ids must be {FIRST_USER_TYPE_ID} or greater" + "{category}.{name} in type-map version {version} uses reserved name {name:?}" + ), + ); + } + if *id < first_user_type_id() { + validation_error( + loaded, + &["type_maps", version, category], + name, + format!( + "{category}.{name} in type-map version {version} uses reserved id {id}; user ids must be {} or greater", + first_user_type_id() ), ); } @@ -540,7 +456,7 @@ fn generate_comm_type_enum(out: &mut String, user_names: &BTreeSet<&str>) { writeln!(out, "#[allow(clippy::enum_variant_names)]").unwrap(); writeln!(out, "pub enum CommunicationType {{").unwrap(); - for entry in RESERVED_COMM_TYPES { + for entry in generated_reserved_comm_types() { writeln!(out, " {},", entry.name).unwrap(); } for name in user_names { @@ -554,7 +470,7 @@ fn generate_comm_type_enum(out: &mut String, user_names: &BTreeSet<&str>) { writeln!(out, " pub fn name(self) -> &'static str {{").unwrap(); writeln!(out, " match self {{").unwrap(); - for entry in RESERVED_COMM_TYPES { + for entry in generated_reserved_comm_types() { writeln!( out, " CommunicationType::{} => \"{}\",", @@ -580,7 +496,7 @@ fn generate_comm_type_enum(out: &mut String, user_names: &BTreeSet<&str>) { writeln!(out, " pub fn from_name(s: &str) -> Option {{").unwrap(); writeln!(out, " match s {{").unwrap(); - for entry in RESERVED_COMM_TYPES { + for entry in generated_reserved_comm_types() { writeln!( out, " \"{}\" => Some(CommunicationType::{}),", @@ -625,7 +541,7 @@ fn generate_data_type_enum(out: &mut String, user_names: &BTreeSet<&str>) { writeln!(out, "#[allow(clippy::enum_variant_names)]").unwrap(); writeln!(out, "pub enum DataType {{").unwrap(); - for entry in RESERVED_DATA_TYPES { + for entry in all_reserved_data_types() { writeln!(out, " {},", entry.name).unwrap(); } for name in user_names { @@ -639,7 +555,7 @@ fn generate_data_type_enum(out: &mut String, user_names: &BTreeSet<&str>) { writeln!(out, " pub fn name(self) -> &'static str {{").unwrap(); writeln!(out, " match self {{").unwrap(); - for entry in RESERVED_DATA_TYPES { + for entry in all_reserved_data_types() { writeln!( out, " DataType::{} => \"{}\",", @@ -660,7 +576,7 @@ fn generate_data_type_enum(out: &mut String, user_names: &BTreeSet<&str>) { writeln!(out, " pub fn from_name(s: &str) -> Option {{").unwrap(); writeln!(out, " match s {{").unwrap(); - for entry in RESERVED_DATA_TYPES { + for entry in all_reserved_data_types() { writeln!( out, " \"{}\" => Some(DataType::{}),", @@ -733,7 +649,7 @@ fn generate_lookup_methods( major, minor ) .unwrap(); - for entry in RESERVED_COMM_TYPES { + for entry in generated_reserved_comm_types() { writeln!( out, " CommunicationType::{} => Some({}),", @@ -772,7 +688,7 @@ fn generate_lookup_methods( major, minor ) .unwrap(); - for entry in RESERVED_DATA_TYPES { + for entry in all_reserved_data_types() { writeln!( out, " DataType::{} => Some({}),", @@ -806,7 +722,7 @@ fn generate_lookup_methods( major, minor ) .unwrap(); - for entry in RESERVED_COMM_TYPES { + for entry in generated_reserved_comm_types() { writeln!( out, " {} => Some(CommunicationType::{}),", @@ -844,7 +760,7 @@ fn generate_lookup_methods( major, minor ) .unwrap(); - for entry in RESERVED_DATA_TYPES { + for entry in all_reserved_data_types() { writeln!( out, " {} => Some(DataType::{}),", @@ -877,7 +793,7 @@ fn generate_single_version_lookup(out: &mut String, config: &Config) { .unwrap(); writeln!(out, " match self.version {{").unwrap(); writeln!(out, " PROTOCOL_VERSION => match ct {{").unwrap(); - for entry in RESERVED_COMM_TYPES { + for entry in generated_reserved_comm_types() { writeln!( out, " CommunicationType::{} => Some({}),", @@ -910,7 +826,7 @@ fn generate_single_version_lookup(out: &mut String, config: &Config) { .unwrap(); writeln!(out, " match self.version {{").unwrap(); writeln!(out, " PROTOCOL_VERSION => match dt {{").unwrap(); - for entry in RESERVED_DATA_TYPES { + for entry in all_reserved_data_types() { writeln!( out, " DataType::{} => Some({}),", @@ -938,7 +854,7 @@ fn generate_single_version_lookup(out: &mut String, config: &Config) { .unwrap(); writeln!(out, " match self.version {{").unwrap(); writeln!(out, " PROTOCOL_VERSION => match id {{").unwrap(); - for entry in RESERVED_COMM_TYPES { + for entry in generated_reserved_comm_types() { writeln!( out, " {} => Some(CommunicationType::{}),", @@ -970,7 +886,7 @@ fn generate_single_version_lookup(out: &mut String, config: &Config) { .unwrap(); writeln!(out, " match self.version {{").unwrap(); writeln!(out, " PROTOCOL_VERSION => match id {{").unwrap(); - for entry in RESERVED_DATA_TYPES { + for entry in all_reserved_data_types() { writeln!( out, " {} => Some(DataType::{}),", @@ -1077,7 +993,7 @@ fn generate_all_types_methods( let tm_cfg = &config.type_maps[version_key]; write!(out, " Version({}, {}) => &[", major, minor).unwrap(); let mut first = true; - for entry in RESERVED_COMM_TYPES { + for entry in generated_reserved_comm_types() { if !first { write!(out, ", ").unwrap(); } @@ -1108,7 +1024,7 @@ fn generate_all_types_methods( let tm_cfg = &config.type_maps[version_key]; write!(out, " Version({}, {}) => &[", major, minor).unwrap(); let mut first = true; - for entry in RESERVED_DATA_TYPES { + for entry in all_reserved_data_types() { if !first { write!(out, ", ").unwrap(); } @@ -1144,7 +1060,7 @@ fn generate_all_types_methods_single(out: &mut String, config: &Config) { writeln!(out, " match self.version {{").unwrap(); write!(out, " PROTOCOL_VERSION => &[").unwrap(); let mut first = true; - for entry in RESERVED_COMM_TYPES { + for entry in generated_reserved_comm_types() { if !first { write!(out, ", ").unwrap(); } @@ -1174,7 +1090,7 @@ fn generate_all_types_methods_single(out: &mut String, config: &Config) { writeln!(out, " match self.version {{").unwrap(); write!(out, " PROTOCOL_VERSION => &[").unwrap(); let mut first = true; - for entry in RESERVED_DATA_TYPES { + for entry in all_reserved_data_types() { if !first { write!(out, ", ").unwrap(); } diff --git a/type-map/reserved.json b/type-map/reserved.json new file mode 100644 index 0000000..cb49f66 --- /dev/null +++ b/type-map/reserved.json @@ -0,0 +1,57 @@ +{ + "firstUserTypeId": 32, + "communication": [ + { "name": "Identification", "id": 0 }, + { "name": "IdentificationResponse", "id": 1 }, + { "name": "Register", "id": 2 }, + { "name": "RegisterResponse", "id": 3 }, + { "name": "Challenge", "id": 4 }, + { "name": "ChallengeResponse", "id": 5 }, + { "name": "Ping", "id": 6 }, + { "name": "Pong", "id": 7 }, + { "name": "Disconnect", "id": 8 }, + { "name": "Redirect", "id": 9 }, + { "name": "Shutdown", "id": 10 }, + { "name": "Error", "id": 11 }, + { "name": "ErrorParsing", "id": 12 }, + { "name": "ErrorBadVersion", "id": 13 }, + { "name": "BadRequest", "id": 14 }, + { "name": "Unauthorized", "id": 15 }, + { "name": "Forbidden", "id": 16 }, + { "name": "NotFound", "id": 17 }, + { "name": "TooManyRequests", "id": 18 }, + { "name": "InternalServerError", "id": 19 }, + { "name": "BadGateway", "id": 20 }, + { "name": "ServiceUnavailable", "id": 21 }, + { "name": "GatewayTimeout", "id": 22 }, + { "name": "PipeRequest", "id": 23 }, + { "name": "PipeResponse", "id": 24 }, + { "name": "PipeAbort", "id": 25 }, + { "name": "Relay", "id": 26 } + ], + "data": [ + { "name": "Version", "id": 0 }, + { "name": "Id", "id": 1 }, + { "name": "ClientNonce", "id": 2 }, + { "name": "ServerNonce", "id": 3 }, + { "name": "PublicKeys", "id": 4 }, + { "name": "Signature", "id": 5 }, + { "name": "PqSignature", "id": 6 }, + { "name": "Description", "id": 7 }, + { "name": "Connected", "id": 8 }, + { "name": "Timestamp", "id": 9 }, + { "name": "Error", "id": 10 }, + { "name": "ErrorParsing", "id": 11 }, + { "name": "ErrorMessage", "id": 12 }, + { "name": "Accepted", "id": 13 }, + { "name": "RequirePq", "id": 14 }, + { "name": "MessageId", "id": 15 }, + { "name": "FinalRecipientId", "id": 18 }, + { "name": "CreatedAt", "id": 21 }, + { "name": "MessageType", "id": 22 }, + { "name": "Content", "id": 23 }, + { "name": "Metadata", "id": 24 }, + { "name": "RelayVersion", "id": 25 }, + { "name": "ProtectedVersion", "id": 26 } + ] +} diff --git a/type-map/src/lib.rs b/type-map/src/lib.rs index 97eea5f..3691c3b 100644 --- a/type-map/src/lib.rs +++ b/type-map/src/lib.rs @@ -92,6 +92,51 @@ impl TypeMap { include!(concat!(env!("OUT_DIR"), "/types.rs")); +#[cfg(test)] +mod tests { + use super::{DataType, TypeMap}; + + #[test] + fn current_relay_reserved_fields_use_generic_layout() { + let type_map = TypeMap::latest(); + + assert_eq!( + DataType::MessageId.try_to_id(&type_map).map(|id| id.0), + Some(15) + ); + assert_eq!( + DataType::FinalRecipientId + .try_to_id(&type_map) + .map(|id| id.0), + Some(18) + ); + assert_eq!( + DataType::CreatedAt.try_to_id(&type_map).map(|id| id.0), + Some(21) + ); + assert_eq!( + DataType::MessageType.try_to_id(&type_map).map(|id| id.0), + Some(22) + ); + assert_eq!( + DataType::Content.try_to_id(&type_map).map(|id| id.0), + Some(23) + ); + assert_eq!( + DataType::Metadata.try_to_id(&type_map).map(|id| id.0), + Some(24) + ); + assert_eq!( + DataType::RelayVersion.try_to_id(&type_map).map(|id| id.0), + Some(25) + ); + + for tombstoned_id in [16, 17, 19, 20] { + assert_eq!(type_map.data_type_name(tombstoned_id), None); + } + } +} + /* ============================= REGISTRY ============================= */ #[cfg(feature = "registry")] pub use registry::*; @@ -169,9 +214,17 @@ mod registry { use super::*; #[test] - fn builtin_contains_versions() { + fn builtin_registers_only_the_current_codec_version() { let r = Registry::builtin(); - assert!(r.supports(&Version(0, 0))); + assert!(r.supports(&Version(3, 0))); + for removed_version in [Version(0, 0), Version(1, 0), Version(2, 0)] { + assert!(!r.supports(&removed_version)); + assert_eq!(r.negotiate(&[removed_version]), None); + } + assert_eq!( + r.negotiate(&[Version(3, 0), Version(2, 0)]), + Some(Version(3, 0)) + ); } #[test] diff --git a/wasm/Cargo.toml b/wasm/Cargo.toml index 55b0bb3..ba20429 100644 --- a/wasm/Cargo.toml +++ b/wasm/Cargo.toml @@ -26,7 +26,7 @@ getrandom-v04 = { package = "getrandom", version = "0.4.3", features = ["wasm_js mtp-common = { version = "0.2.0", path = "../common" } mtp-type-map = { version = "0.2.0", path = "../type-map" } -mtp-codec = { version = "0.2.0", path = "../codec", features = ["crypto", "pipes"] } +mtp-codec = { version = "0.2.0", path = "../codec", features = ["crypto", "pipes", "registry"] } mtp-crypto = { version = "0.2.0", path = "../crypto", features = ["wasm"] } zeroize = "1.9" wasm-bindgen-test = "0.3.76" diff --git a/wasm/src/auth.rs b/wasm/src/auth.rs index cbea60f..e2e8127 100644 --- a/wasm/src/auth.rs +++ b/wasm/src/auth.rs @@ -38,15 +38,15 @@ pub(crate) fn verify_host_challenge( require_pq: bool, ) -> Result<(), JsValue> { let sig = match challenge.get_data(DataType::Signature) { - DataValue::Bytes(b) => b.clone(), + Some(DataValue::Bytes(b)) => b.clone(), _ => return Err(js_error("missing host challenge signature")), }; let pq_sig = match challenge.get_data(DataType::PqSignature) { - DataValue::Bytes(b) => b.clone(), + Some(DataValue::Bytes(b)) => b.clone(), _ => vec![], }; - let host_requires_pq = challenge.get_data(DataType::RequirePq) == &DataValue::BoolTrue; + let host_requires_pq = challenge.get_data(DataType::RequirePq) == Some(&DataValue::BoolTrue); if host_requires_pq && host_pk.sig_pq_public_key.as_bytes().is_empty() { return Err(js_error( "host requires post-quantum authentication but its PQ public key is absent", @@ -77,15 +77,15 @@ pub(crate) fn verify_host_final( server_challenge: u128, require_pq: bool, ) -> Result<(), JsValue> { - if *resp.get_data(DataType::ClientNonce) != DataValue::UnsignedNumber(client_nonce) { + if resp.get_data(DataType::ClientNonce) != Some(&DataValue::UnsignedNumber(client_nonce)) { return Err(js_error("nonce mismatch")); } let host_sig = match resp.get_data(DataType::Signature) { - DataValue::Bytes(b) => b.clone(), + Some(DataValue::Bytes(b)) => b.clone(), _ => return Err(js_error("missing host signature")), }; let host_pq_sig = match resp.get_data(DataType::PqSignature) { - DataValue::Bytes(b) => b.clone(), + Some(DataValue::Bytes(b)) => b.clone(), _ => vec![], }; if require_pq && host_pq_sig.is_empty() { @@ -114,6 +114,7 @@ pub(crate) fn signed_challenge_response_bytes( keyring: &mtp_crypto::Keyring, proof_payload: &[u8], client_nonce: u128, + type_map: &mtp_codec::TypeMap, ) -> Result, JsValue> { use mtp_crypto::SignatureScheme; @@ -123,12 +124,13 @@ pub(crate) fn signed_challenge_response_bytes( .sign(proof_payload) .map_err(|e| js_error(format!("signature failed: {}", e)))?; - let mut proof = CommunicationValue::new(CommunicationType::ChallengeResponse) - .add_typed_default( - DataType::ClientNonce, - DataValue::UnsignedNumber(client_nonce), - ) - .add_typed_default(DataType::Signature, DataValue::Bytes(signature)); + let mut proof = + CommunicationValue::new_with_type_map(CommunicationType::ChallengeResponse, type_map) + .add_typed_default( + DataType::ClientNonce, + DataValue::UnsignedNumber(client_nonce), + ) + .add_typed_default(DataType::Signature, DataValue::Bytes(signature)); if !keyring.sig_pq_secret_key.as_bytes().is_empty() { let pq_signer = diff --git a/wasm/src/client.rs b/wasm/src/client.rs index 3b9525b..58f8c4c 100644 --- a/wasm/src/client.rs +++ b/wasm/src/client.rs @@ -21,7 +21,13 @@ struct PingTimer { closure: Closure, } +struct PendingPing { + generation: u32, + sent_at: f64, +} + const DEFAULT_REQUEST_TIMEOUT_MS: u32 = 30_000; +const MAX_SAFE_JS_INTEGER: f64 = 9_007_199_254_740_991.0; fn frame_property(frame: &JsValue, key: &str) -> Option { js_sys::Reflect::get(frame, &JsValue::from_str(key)) @@ -32,7 +38,10 @@ fn frame_property(frame: &JsValue, key: &str) -> Option { fn frame_id(frame: &JsValue) -> Option { frame_property(frame, "id") .and_then(|value| value.as_f64()) - .map(|value| value as u32) + .filter(|value| { + value.is_finite() && value.fract() == 0.0 && (0.0..=u32::MAX as f64).contains(value) + }) + .and_then(|value| u32::try_from(value as u64).ok()) } fn frame_type(frame: &JsValue) -> Option { @@ -41,24 +50,43 @@ fn frame_type(frame: &JsValue) -> Option { fn route_incoming_frame( frame: &JsValue, + generation: u32, on_message: &js_sys::Function, subscriptions: &Rc>>, pending_requests: &Rc>>, - pending_pings: &Rc>>, + expired_requests: &Rc>>, + pending_pings: &Rc>>, ping_ms: &Rc>>, ) { let message_type = frame_type(frame); - if message_type.as_deref() == Some("Pong") { - if let Some(sent_at) = frame_id(frame).and_then(|id| pending_pings.borrow_mut().remove(&id)) - { + if message_type.as_deref() == Some("Pong") + && let Some(ping_id) = frame_id(frame) + { + let sent_at = pending_pings + .borrow() + .get(&ping_id) + .filter(|ping| ping.generation == generation) + .map(|ping| ping.sent_at); + if let Some(sent_at) = sent_at { + pending_pings.borrow_mut().remove(&ping_id); ping_ms.set(Some(js_sys::Date::now() - sent_at)); return; } } if let Some(request_id) = frame_id(frame) { - let pending = pending_requests.borrow_mut().remove(&request_id); + let pending = { + let mut requests = pending_requests.borrow_mut(); + if requests + .get(&request_id) + .is_some_and(|request| request.generation == generation) + { + requests.remove(&request_id) + } else { + None + } + }; if let Some(pending) = pending { let type_matches = pending .response_type @@ -78,6 +106,9 @@ fn route_incoming_frame( } return; } + if client_pipe::consume_expired_request(expired_requests, request_id) { + return; + } } let _ = on_message.call1(&JsValue::NULL, frame); @@ -177,6 +208,7 @@ pub enum ConnectionState { #[wasm_bindgen] pub struct WasmClient { transport: Rc>>, + attempt_transport: Rc>>, connection_generation: Rc>, state: Rc>, pending_state_callbacks: Rc>>, @@ -186,11 +218,13 @@ pub struct WasmClient { subscriptions: Rc>>, next_subscription_id: Rc>, pending_requests: Rc>>, + expired_requests: Rc>>, ping_timer: Rc>>, - pending_pings: Rc>>, + pending_pings: Rc>>, ping_ms: Rc>>, - pending_pipe_creations: Rc>>>>, - pending_pipes: Rc>>>, + pending_pipe_creations: client_pipe::PendingPipeCreations, + pending_pipes: client_pipe::PendingPipes, + connection_client_id: Rc>, on_pipe_request: Rc>>, } @@ -215,6 +249,7 @@ impl WasmClient { }) as Box); Self { transport: Rc::new(RefCell::new(None)), + attempt_transport: Rc::new(RefCell::new(None)), connection_generation: Rc::new(Cell::new(0)), state: Rc::new(Cell::new(ConnectionState::Disconnected)), pending_state_callbacks, @@ -224,11 +259,13 @@ impl WasmClient { subscriptions: Rc::new(RefCell::new(HashMap::new())), next_subscription_id: Rc::new(Cell::new(1)), pending_requests: Rc::new(RefCell::new(HashMap::new())), + expired_requests: Rc::new(RefCell::new(HashMap::new())), ping_timer: Rc::new(RefCell::new(None)), pending_pings: Rc::new(RefCell::new(HashMap::new())), ping_ms: Rc::new(Cell::new(None)), pending_pipe_creations: Rc::new(RefCell::new(HashMap::new())), pending_pipes: Rc::new(RefCell::new(HashMap::new())), + connection_client_id: Rc::new(Cell::new(0)), on_pipe_request: Rc::new(RefCell::new(None)), } } @@ -248,68 +285,124 @@ impl WasmClient { self.ping_ms.get() } + #[wasm_bindgen(getter)] + pub fn client_id(&self) -> u64 { + self.connection_client_id.get() + } + #[wasm_bindgen] pub async fn connect(&self, config: &ConnectionConfig) -> Result<(), JsValue> { let generation = self.begin_connection(); - let transport = WasmTransport::connect( + let transport = match WasmTransport::connect( &config.url, config.server_certificate_hashes.clone(), config.max_message_size, ) - .await?; + .await + { + Ok(transport) => transport, + Err(error) => { + self.set_state_if_current(generation, ConnectionState::Disconnected); + return Err(error); + } + }; + if !self.install_attempt_transport(&transport, generation) { + return Err(js_error("connection attempt superseded")); + } - let version_str = format!("{}", PROTOCOL_VERSION); - let mut ident = CommunicationValue::new(CommunicationType::Identification) + let result = async { + let version_str = format!("{}", PROTOCOL_VERSION); + let opening_codec = mtp_codec::registry::VersionedCodec::for_version( + mtp_codec::registry::Registry::builtin(), + PROTOCOL_VERSION, + ) + .ok_or_else(|| js_error("client protocol version is not registered"))?; + transport.set_type_map(opening_codec.type_map()); + let mut ident = CommunicationValue::new_with_type_map( + CommunicationType::Identification, + opening_codec.type_map(), + ) .add_typed_default(DataType::Version, DataValue::Str(version_str)) .add_typed_default( DataType::Id, DataValue::UnsignedNumber(config.client_id as u128), ); - if let Some(desc) = &config.description { - ident = ident.add_typed_default(DataType::Description, DataValue::Str(desc.clone())); - } - let ident_bytes = ident - .to_bytes() - .map_err(|e| js_error(format!("encode failed: {}", e)))?; - transport.send_frame(&ident_bytes).await?; - - let outcome_bytes = transport.read_one_frame().await?; - let outcome = CommunicationValue::from_bytes(&outcome_bytes) - .map_err(|e| js_error(format!("parse handshake outcome: {e}")))?; - let tm = mtp_codec::TypeMap::latest(); - if Some(outcome.get_type()) == CommunicationType::ErrorBadVersion.try_to_id(&tm) { - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(js_error( - outcome - .get_str(DataType::ErrorMessage) - .unwrap_or("host does not support this protocol version"), - )); - } - let expected = CommunicationType::IdentificationResponse - .try_to_id(&tm) - .ok_or_else(|| js_error("IdentificationResponse is absent from the type map"))?; - if outcome.get_type() != expected - || outcome.get_data(DataType::Connected) != &DataValue::BoolTrue - { - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(js_error( - outcome - .get_str(DataType::ErrorMessage) - .unwrap_or("host rejected the connection"), - )); - } - match outcome.get_data(DataType::Version) { - DataValue::Str(version) if mtp_codec::Version::parse(version).is_some() => {} - _ => { - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(js_error("host omitted a valid negotiated protocol version")); + if let Some(desc) = &config.description { + ident = + ident.add_typed_default(DataType::Description, DataValue::Str(desc.clone())); } - } + let ident_bytes = ident + .to_bytes() + .map_err(|e| js_error(format!("encode failed: {}", e)))?; + transport.send_frame(&ident_bytes).await?; - if !self.start_receive_loop(transport, generation) { - return Err(js_error("connection attempt superseded")); + let outcome_bytes = transport.read_one_frame().await?; + let outcome = + CommunicationValue::from_bytes_with(&outcome_bytes, opening_codec.type_map()) + .map_err(|e| js_error(format!("parse handshake outcome: {e}")))?; + if Some(outcome.get_type()) + == CommunicationType::ErrorBadVersion.try_to_id(opening_codec.type_map()) + { + self.set_state_if_current(generation, ConnectionState::Disconnected); + return Err(js_error( + outcome + .get_str(DataType::ErrorMessage) + .unwrap_or("host does not support this protocol version"), + )); + } + let negotiated_version = match outcome.get_data(DataType::Version) { + Some(DataValue::Str(version)) => mtp_codec::Version::parse(version) + .ok_or_else(|| js_error("host omitted a valid negotiated protocol version"))?, + _ => return Err(js_error("host omitted a valid negotiated protocol version")), + }; + if negotiated_version != PROTOCOL_VERSION { + return Err(js_error( + "host selected a protocol version the client did not offer", + )); + } + let codec = mtp_codec::registry::VersionedCodec::for_version( + mtp_codec::registry::Registry::builtin(), + negotiated_version, + ) + .ok_or_else(|| js_error("host returned an unsupported negotiated protocol version"))?; + transport.set_type_map(codec.type_map()); + let outcome = CommunicationValue::from_bytes_with(&outcome_bytes, codec.type_map()) + .map_err(|e| js_error(format!("parse negotiated handshake outcome: {e}")))?; + let tm = codec.type_map(); + let expected = CommunicationType::IdentificationResponse + .try_to_id(&tm) + .ok_or_else(|| js_error("IdentificationResponse is absent from the type map"))?; + if outcome.get_type() != expected + || outcome.get_data(DataType::Connected) != Some(&DataValue::BoolTrue) + { + self.set_state_if_current(generation, ConnectionState::Disconnected); + return Err(js_error( + outcome + .get_str(DataType::ErrorMessage) + .unwrap_or("host rejected the connection"), + )); + } + let assigned_id = match outcome.get_data(DataType::Id) { + Some(DataValue::UnsignedNumber(id)) => { + u64::try_from(*id).map_err(|_| js_error("assigned ID is out of range"))? + } + _ => { + self.set_state_if_current(generation, ConnectionState::Disconnected); + return Err(js_error("host omitted the assigned client ID")); + } + }; + + if !self.start_receive_loop(transport.clone(), generation, assigned_id) { + return Err(js_error("connection attempt superseded")); + } + Ok(()) } - Ok(()) + .await; + if let Err(error) = &result { + self.abort_attempt(&transport, generation); + let _ = error; + } + result } #[wasm_bindgen] @@ -322,109 +415,179 @@ impl WasmClient { ) -> Result { let generation = self.begin_connection(); - let host_pk = mtp_crypto::PublicKeyBundle::from_bytes(host_public_key_bytes) - .map_err(|e| js_error(format!("invalid host public key: {}", e)))?; - let keyring = mtp_crypto::Keyring::from_bytes(keyring_bytes) - .map_err(|e| js_error(format!("invalid keyring: {}", e)))?; + let host_pk = match mtp_crypto::PublicKeyBundle::from_bytes(host_public_key_bytes) { + Ok(value) => value, + Err(error) => { + let error = js_error(format!("invalid host public key: {}", error)); + self.set_state_if_current(generation, ConnectionState::Disconnected); + return Err(error); + } + }; + let keyring = match mtp_crypto::Keyring::from_bytes(keyring_bytes) { + Ok(value) => value, + Err(error) => { + let error = js_error(format!("invalid keyring: {}", error)); + self.set_state_if_current(generation, ConnectionState::Disconnected); + return Err(error); + } + }; - let tm = mtp_codec::TypeMap::latest(); + let handshake_codec = mtp_codec::registry::VersionedCodec::for_version( + mtp_codec::registry::Registry::builtin(), + PROTOCOL_VERSION, + ) + .ok_or_else(|| js_error("client protocol version is not registered"))?; + let tm = handshake_codec.type_map().clone(); let version_str = format!("{}", PROTOCOL_VERSION); - let transport = WasmTransport::connect( + let transport = match WasmTransport::connect( &config.url, config.server_certificate_hashes.clone(), config.max_message_size, ) - .await?; - - let mut hello = CommunicationValue::new(CommunicationType::Identification) - .add_typed_default(DataType::Version, DataValue::Str(version_str.clone())) - .add_typed_default(DataType::Id, DataValue::UnsignedNumber(client_id as u128)); - if let Some(desc) = &config.description { - hello = hello.add_typed_default(DataType::Description, DataValue::Str(desc.clone())); - } - let hello_bytes = hello - .to_bytes() - .map_err(|e| js_error(format!("encode failed: {}", e)))?; - transport.send_frame(&hello_bytes).await?; - - let server_challenge = self - .read_verified_challenge( - &transport, - &tm, - &host_pk, - client_id, - "auth_connect challenge", - config.require_pq, - !keyring.sig_pq_secret_key.as_bytes().is_empty(), - generation, - ) - .await?; - - let client_nonce = auth::random_nonce()?; - - let proof_payload = mtp_crypto::auth::login_proof_payload( - &version_str, - client_id, - server_challenge, - client_nonce, - ); - let proof = auth::signed_challenge_response_bytes(&keyring, &proof_payload, client_nonce)?; - transport.send_frame(&proof).await?; - - let response = transport.read_one_frame().await?; - let resp_comm = CommunicationValue::from_bytes(&response) - .map_err(|e| js_error(format!("parse response: {}", e)))?; - let resp_type = resp_comm.get_type(); - let expected_type = CommunicationType::IdentificationResponse - .try_to_id(&tm) - .ok_or_else(|| js_error("IdentificationResponse is absent from the type map"))?; - if resp_type != expected_type { - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(auth::unexpected_response_type_error( - "auth_connect", - expected_type, - resp_type, - &response, - &resp_comm, - )); - } - - if resp_comm.get_data(DataType::Connected) != &DataValue::BoolTrue { - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(js_error( - resp_comm - .get_str(DataType::ErrorMessage) - .unwrap_or("host rejected authentication"), - )); - } - - if let Err(e) = auth::verify_host_final( - &resp_comm, - &tm, - &host_pk, - client_id, - client_nonce, - server_challenge, - config.require_pq, - ) { - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(e); - } - - let assigned_id = match resp_comm.get_data(DataType::Id) { - DataValue::UnsignedNumber(n) => *n as u64, - _ => { + .await + { + Ok(transport) => transport, + Err(error) => { self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(js_error("missing assigned ID")); + return Err(error); } }; - - if !self.start_receive_loop(transport, generation) { + transport.set_type_map(&tm); + if !self.install_attempt_transport(&transport, generation) { return Err(js_error("connection attempt superseded")); } - Ok(assigned_id) + let result = async { + let mut hello = + CommunicationValue::new_with_type_map(CommunicationType::Identification, &tm) + .add_typed_default(DataType::Version, DataValue::Str(version_str.clone())) + .add_typed_default(DataType::Id, DataValue::UnsignedNumber(client_id as u128)) + // Mark this as an authentication-capable opening so a + // non-crypto host can reject it explicitly. + .add_typed_default( + DataType::PublicKeys, + DataValue::Bytes(keyring.public_key_bundle().as_bytes()), + ); + if let Some(desc) = &config.description { + hello = + hello.add_typed_default(DataType::Description, DataValue::Str(desc.clone())); + } + let hello_bytes = hello + .to_bytes() + .map_err(|e| js_error(format!("encode failed: {}", e)))?; + transport.send_frame(&hello_bytes).await?; + + let server_challenge = self + .read_verified_challenge( + &transport, + &tm, + &host_pk, + client_id, + "auth_connect challenge", + config.require_pq, + !keyring.sig_pq_secret_key.as_bytes().is_empty(), + generation, + ) + .await?; + + let client_nonce = auth::random_nonce()?; + + let proof_payload = mtp_crypto::auth::login_proof_payload( + &version_str, + client_id, + server_challenge, + client_nonce, + ); + let proof = + auth::signed_challenge_response_bytes(&keyring, &proof_payload, client_nonce, &tm)?; + transport.send_frame(&proof).await?; + + let response = transport.read_one_frame().await?; + let resp_comm = CommunicationValue::from_bytes_with(&response, &tm) + .map_err(|e| js_error(format!("parse response: {}", e)))?; + let negotiated_version = match resp_comm.get_data(DataType::Version) { + Some(DataValue::Str(version)) => mtp_codec::Version::parse(version) + .ok_or_else(|| js_error("host returned an invalid negotiated version"))?, + _ => return Err(js_error("host omitted the negotiated version")), + }; + if negotiated_version != PROTOCOL_VERSION { + return Err(js_error( + "host selected a protocol version the client did not offer", + )); + } + let codec = mtp_codec::registry::VersionedCodec::for_version( + mtp_codec::registry::Registry::builtin(), + negotiated_version, + ) + .ok_or_else(|| js_error("host returned an unsupported negotiated version"))?; + transport.set_type_map(codec.type_map()); + let resp_comm = CommunicationValue::from_bytes_with(&response, codec.type_map()) + .map_err(|e| js_error(format!("parse negotiated response: {}", e)))?; + let tm = codec.type_map(); + let resp_type = resp_comm.get_type(); + let expected_type = CommunicationType::IdentificationResponse + .try_to_id(&tm) + .ok_or_else(|| js_error("IdentificationResponse is absent from the type map"))?; + if resp_type != expected_type { + self.set_state_if_current(generation, ConnectionState::Disconnected); + return Err(auth::unexpected_response_type_error( + "auth_connect", + expected_type, + resp_type, + &response, + &resp_comm, + )); + } + + if resp_comm.get_data(DataType::Connected) != Some(&DataValue::BoolTrue) { + self.set_state_if_current(generation, ConnectionState::Disconnected); + return Err(js_error( + resp_comm + .get_str(DataType::ErrorMessage) + .unwrap_or("host rejected authentication"), + )); + } + + if let Err(e) = auth::verify_host_final( + &resp_comm, + &tm, + &host_pk, + client_id, + client_nonce, + server_challenge, + config.require_pq, + ) { + self.set_state_if_current(generation, ConnectionState::Disconnected); + return Err(e); + } + + let assigned_id = match resp_comm.get_data(DataType::Id) { + Some(DataValue::UnsignedNumber(n)) => match u64::try_from(*n) { + Ok(id) => id, + Err(_) => { + self.set_state_if_current(generation, ConnectionState::Disconnected); + return Err(js_error("assigned ID is out of range")); + } + }, + _ => { + self.set_state_if_current(generation, ConnectionState::Disconnected); + return Err(js_error("missing assigned ID")); + } + }; + + if !self.start_receive_loop(transport.clone(), generation, assigned_id) { + return Err(js_error("connection attempt superseded")); + } + + Ok(assigned_id) + } + .await; + if let Err(error) = &result { + self.abort_attempt(&transport, generation); + let _ = error; + } + result } #[wasm_bindgen] @@ -436,115 +599,182 @@ impl WasmClient { ) -> Result { let generation = self.begin_connection(); - let host_pk = mtp_crypto::PublicKeyBundle::from_bytes(host_public_key_bytes) - .map_err(|e| js_error(format!("invalid host public key: {}", e)))?; - let keyring = mtp_crypto::Keyring::from_bytes(keyring_bytes) - .map_err(|e| js_error(format!("invalid keyring: {}", e)))?; + let host_pk = match mtp_crypto::PublicKeyBundle::from_bytes(host_public_key_bytes) { + Ok(value) => value, + Err(error) => { + let error = js_error(format!("invalid host public key: {}", error)); + self.set_state_if_current(generation, ConnectionState::Disconnected); + return Err(error); + } + }; + let keyring = match mtp_crypto::Keyring::from_bytes(keyring_bytes) { + Ok(value) => value, + Err(error) => { + let error = js_error(format!("invalid keyring: {}", error)); + self.set_state_if_current(generation, ConnectionState::Disconnected); + return Err(error); + } + }; - let tm = mtp_codec::TypeMap::latest(); + let handshake_codec = mtp_codec::registry::VersionedCodec::for_version( + mtp_codec::registry::Registry::builtin(), + PROTOCOL_VERSION, + ) + .ok_or_else(|| js_error("client protocol version is not registered"))?; + let tm = handshake_codec.type_map().clone(); let version_str = format!("{}", PROTOCOL_VERSION); let pk_bytes = keyring.public_key_bundle().as_bytes(); - let transport = WasmTransport::connect( + let transport = match WasmTransport::connect( &config.url, config.server_certificate_hashes.clone(), config.max_message_size, ) - .await?; - - let mut hello = CommunicationValue::new(CommunicationType::Register) - .add_typed_default(DataType::Version, DataValue::Str(version_str.clone())) - .add_typed_default(DataType::PublicKeys, DataValue::Bytes(pk_bytes.clone())); - if let Some(desc) = &config.description { - hello = hello.add_typed_default(DataType::Description, DataValue::Str(desc.clone())); - } - let hello_bytes = hello - .to_bytes() - .map_err(|e| js_error(format!("encode failed: {}", e)))?; - transport.send_frame(&hello_bytes).await?; - - let server_challenge = self - .read_verified_challenge( - &transport, - &tm, - &host_pk, - 0, - "auth_register challenge", - config.require_pq, - !keyring.sig_pq_secret_key.as_bytes().is_empty(), - generation, - ) - .await?; - - let client_nonce = auth::random_nonce()?; - - let proof_payload = mtp_crypto::auth::register_proof_payload( - &version_str, - &pk_bytes, - server_challenge, - client_nonce, - ); - let proof = auth::signed_challenge_response_bytes(&keyring, &proof_payload, client_nonce)?; - transport.send_frame(&proof).await?; - - let response = transport.read_one_frame().await?; - let resp_comm = CommunicationValue::from_bytes(&response) - .map_err(|e| js_error(format!("parse response: {}", e)))?; - let resp_type = resp_comm.get_type(); - let expected_type = CommunicationType::RegisterResponse - .try_to_id(&tm) - .ok_or_else(|| js_error("RegisterResponse is absent from the type map"))?; - if resp_type != expected_type { - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(auth::unexpected_response_type_error( - "auth_register", - expected_type, - resp_type, - &response, - &resp_comm, - )); - } - - if resp_comm.get_data(DataType::Connected) != &DataValue::BoolTrue { - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(js_error( - resp_comm - .get_str(DataType::ErrorMessage) - .unwrap_or("host rejected registration"), - )); - } - - let assigned_id = match resp_comm.get_data(DataType::Id) { - DataValue::UnsignedNumber(n) => *n as u64, - _ => { + .await + { + Ok(transport) => transport, + Err(error) => { self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(js_error("missing assigned ID")); + return Err(error); } }; - - if let Err(e) = auth::verify_host_final( - &resp_comm, - &tm, - &host_pk, - assigned_id, - client_nonce, - server_challenge, - config.require_pq, - ) { - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(e); - } - - if !self.start_receive_loop(transport, generation) { + transport.set_type_map(&tm); + if !self.install_attempt_transport(&transport, generation) { return Err(js_error("connection attempt superseded")); } - Ok(assigned_id) + let result = async { + let mut hello = CommunicationValue::new_with_type_map(CommunicationType::Register, &tm) + .add_typed_default(DataType::Version, DataValue::Str(version_str.clone())) + .add_typed_default(DataType::PublicKeys, DataValue::Bytes(pk_bytes.clone())); + if let Some(desc) = &config.description { + hello = + hello.add_typed_default(DataType::Description, DataValue::Str(desc.clone())); + } + let hello_bytes = hello + .to_bytes() + .map_err(|e| js_error(format!("encode failed: {}", e)))?; + transport.send_frame(&hello_bytes).await?; + + let server_challenge = self + .read_verified_challenge( + &transport, + &tm, + &host_pk, + 0, + "auth_register challenge", + config.require_pq, + !keyring.sig_pq_secret_key.as_bytes().is_empty(), + generation, + ) + .await?; + + let client_nonce = auth::random_nonce()?; + + let proof_payload = mtp_crypto::auth::register_proof_payload( + &version_str, + &pk_bytes, + server_challenge, + client_nonce, + ); + let proof = + auth::signed_challenge_response_bytes(&keyring, &proof_payload, client_nonce, &tm)?; + transport.send_frame(&proof).await?; + + let response = transport.read_one_frame().await?; + let resp_comm = CommunicationValue::from_bytes_with(&response, &tm) + .map_err(|e| js_error(format!("parse response: {}", e)))?; + let negotiated_version = match resp_comm.get_data(DataType::Version) { + Some(DataValue::Str(version)) => mtp_codec::Version::parse(version) + .ok_or_else(|| js_error("host returned an invalid negotiated version"))?, + _ => return Err(js_error("host omitted the negotiated version")), + }; + if negotiated_version != PROTOCOL_VERSION { + return Err(js_error( + "host selected a protocol version the client did not offer", + )); + } + let codec = mtp_codec::registry::VersionedCodec::for_version( + mtp_codec::registry::Registry::builtin(), + negotiated_version, + ) + .ok_or_else(|| js_error("host returned an unsupported negotiated version"))?; + transport.set_type_map(codec.type_map()); + let resp_comm = CommunicationValue::from_bytes_with(&response, codec.type_map()) + .map_err(|e| js_error(format!("parse negotiated response: {}", e)))?; + let tm = codec.type_map(); + let resp_type = resp_comm.get_type(); + let expected_type = CommunicationType::RegisterResponse + .try_to_id(&tm) + .ok_or_else(|| js_error("RegisterResponse is absent from the type map"))?; + if resp_type != expected_type { + self.set_state_if_current(generation, ConnectionState::Disconnected); + return Err(auth::unexpected_response_type_error( + "auth_register", + expected_type, + resp_type, + &response, + &resp_comm, + )); + } + + if resp_comm.get_data(DataType::Connected) != Some(&DataValue::BoolTrue) { + self.set_state_if_current(generation, ConnectionState::Disconnected); + return Err(js_error( + resp_comm + .get_str(DataType::ErrorMessage) + .unwrap_or("host rejected registration"), + )); + } + + let assigned_id = match resp_comm.get_data(DataType::Id) { + Some(DataValue::UnsignedNumber(n)) => match u64::try_from(*n) { + Ok(id) => id, + Err(_) => { + self.set_state_if_current(generation, ConnectionState::Disconnected); + return Err(js_error("assigned ID is out of range")); + } + }, + _ => { + self.set_state_if_current(generation, ConnectionState::Disconnected); + return Err(js_error("missing assigned ID")); + } + }; + + if let Err(e) = auth::verify_host_final( + &resp_comm, + &tm, + &host_pk, + assigned_id, + client_nonce, + server_challenge, + config.require_pq, + ) { + self.set_state_if_current(generation, ConnectionState::Disconnected); + return Err(e); + } + + if !self.start_receive_loop(transport.clone(), generation, assigned_id) { + return Err(js_error("connection attempt superseded")); + } + + Ok(assigned_id) + } + .await; + if let Err(error) = &result { + self.abort_attempt(&transport, generation); + let _ = error; + } + result } #[wasm_bindgen] pub async fn send(&self, frame: Vec) -> Result<(), JsValue> { - match self.transport.borrow().clone() { + if self.state.get() != ConnectionState::Connected { + return Err(js_error("not connected")); + } + let transport = self.transport.borrow().clone(); + match transport { Some(t) => t.send_frame(&frame).await, None => Err(js_error("not connected")), } @@ -557,16 +787,26 @@ impl WasmClient { response_type: Option, timeout_ms: Option, ) -> Result { - let request = CommunicationValue::from_bytes(&frame) - .map_err(|e| js_error(format!("parse request: {}", e)))?; - let request_id = request.get_id(); - if request_id == 0 { - return Err(js_error("request frame must have a non-zero id")); + if self.state.get() != ConnectionState::Connected { + return Err(js_error("not connected")); } - + let generation = self.connection_generation.get(); let Some(transport) = self.transport.borrow().clone() else { return Err(js_error("not connected")); }; + let request = CommunicationValue::from_bytes_with(&frame, &transport.type_map()) + .map_err(|e| js_error(format!("parse request: {}", e)))?; + let request_id = request + .id() + .ok_or_else(|| js_error("request frame must contain an id"))?; + if request_id == 0 { + return Err(js_error("request frame must have a non-zero id")); + } + if client_pipe::is_expired_request(&self.expired_requests, request_id) { + return Err(js_error(format!( + "request id {request_id} recently timed out; use a new request id" + ))); + } let (sender, receiver) = oneshot::channel(); let token = Rc::new(()); @@ -580,6 +820,7 @@ impl WasmClient { pending.insert( request_id, PendingRequest { + generation, token: token.clone(), response_type, sender, @@ -606,7 +847,12 @@ impl WasmClient { result }, result = timeout => { - client_pipe::remove_pending_request(&self.pending_requests, request_id, &token); + client_pipe::expire_pending_request( + &self.pending_requests, + &self.expired_requests, + request_id, + &token, + ); result?; Err(js_error(format!( "request {request_id} timed out after {timeout_ms}ms" @@ -633,41 +879,84 @@ impl WasmClient { #[wasm_bindgen] pub fn start_protocol_pings(&self, interval_ms: u32, client_id: u64) -> Result<(), JsValue> { self.stop_protocol_pings(); + if self.state.get() != ConnectionState::Connected { + return Err(js_error("not connected")); + } let Some(transport) = self.transport.borrow().clone() else { return Err(js_error("not connected")); }; - let interval_ms = interval_ms.max(1_000) as i32; + let generation = self.connection_generation.get(); + let current_generation = self.connection_generation.clone(); + let interval_ms = i32::try_from(interval_ms.max(1_000)) + .map_err(|_| js_error("ping interval is too large"))?; let on_error = self.on_error.clone(); let pending_pings = self.pending_pings.clone(); let closure = Closure::wrap(Box::new(move || { + if current_generation.get() != generation { + return; + } let transport = transport.clone(); let on_error = on_error.clone(); let pending_pings = pending_pings.clone(); + let current_generation = current_generation.clone(); wasm_bindgen_futures::spawn_local(async move { + if current_generation.get() != generation { + return; + } let sent_at = js_sys::Date::now(); - pending_pings - .borrow_mut() - .retain(|_, pending_at| sent_at - *pending_at < interval_ms as f64 * 3.0); - let timestamp = sent_at as u64; - let frame = CommunicationValue::new(CommunicationType::Ping) - .add_typed_default( - DataType::Description, - DataValue::Str("protocol ping".into()), - ) - .add_typed_default( - DataType::Timestamp, - DataValue::UnsignedNumber(timestamp as u128), - ) - .with_sender(client_id); - let ping_id = frame.get_id(); + pending_pings.borrow_mut().retain(|_, pending| { + pending.generation == generation + && sent_at - pending.sent_at < interval_ms as f64 * 3.0 + }); + let timestamp = if sent_at.is_finite() + && sent_at >= 0.0 + && sent_at <= MAX_SAFE_JS_INTEGER + && sent_at.fract() == 0.0 + { + sent_at as u64 + } else { + let _ = on_error.call1(&JsValue::NULL, &js_error("invalid clock value")); + return; + }; + let type_map = transport.type_map(); + let frame = + CommunicationValue::new_with_type_map(CommunicationType::Ping, &type_map) + .add_typed_default( + DataType::Description, + DataValue::Str("protocol ping".into()), + ) + .add_typed_default( + DataType::Timestamp, + DataValue::UnsignedNumber(timestamp as u128), + ) + .with_sender(client_id); + let Some(ping_id) = frame.id() else { + let _ = on_error.call1(&JsValue::NULL, &js_error("ping frame has no id")); + return; + }; let frame = frame .to_bytes() .map_err(|e| js_error(format!("encode ping failed: {}", e))); match frame { Ok(frame) => { - pending_pings.borrow_mut().insert(ping_id, sent_at); + if current_generation.get() != generation { + return; + } + pending_pings.borrow_mut().insert( + ping_id, + PendingPing { + generation, + sent_at, + }, + ); if let Err(error) = transport.send_frame(&frame).await { - pending_pings.borrow_mut().remove(&ping_id); + if pending_pings + .borrow() + .get(&ping_id) + .is_some_and(|ping| ping.generation == generation) + { + pending_pings.borrow_mut().remove(&ping_id); + } let _ = on_error.call1(&JsValue::NULL, &error); } } @@ -688,7 +977,13 @@ impl WasmClient { &JsValue::from_f64(interval_ms as f64), )? .as_f64() - .ok_or_else(|| js_error("setInterval did not return an id"))? as i32; + .filter(|value| { + value.is_finite() + && value.fract() == 0.0 + && (i32::MIN as f64..=i32::MAX as f64).contains(value) + }) + .and_then(|value| i32::try_from(value as i64).ok()) + .ok_or_else(|| js_error("setInterval did not return a valid id"))?; *self.ping_timer.borrow_mut() = Some(PingTimer { id, closure }); Ok(()) } @@ -717,9 +1012,14 @@ impl WasmClient { if let Some(t) = self.transport.borrow_mut().take() { t.close(); } + if let Some(t) = self.attempt_transport.borrow_mut().take() { + t.close(); + } self.subscriptions.borrow_mut().clear(); self.reject_pending_requests("disconnected"); client_pipe::reject_pending_pipe_creations(&self.pending_pipe_creations, "disconnected"); + client_pipe::reject_pending_pipes(&self.pending_pipes, "disconnected"); + self.connection_client_id.set(0); self.set_state(ConnectionState::Disconnected); } @@ -733,6 +1033,9 @@ impl WasmClient { &self, description: &str, ) -> Result { + if self.state.get() != ConnectionState::Connected { + return Err(js_error("not connected")); + } let transport = self .transport .borrow() @@ -745,23 +1048,39 @@ impl WasmClient { description, pipe_id, &self.pending_pipe_creations, + self.connection_generation.get(), + &self.connection_generation, ) .await } #[wasm_bindgen] pub async fn accept_pipe(&self, pipe_id: u32) -> Result { + if self.state.get() != ConnectionState::Connected { + return Err(js_error("not connected")); + } let transport = self .transport .borrow() .clone() .ok_or_else(|| js_error("not connected"))?; - client_pipe::wasm_accept_pipe(&transport, pipe_id, &self.pending_pipes).await + let generation = self.connection_generation.get(); + client_pipe::wasm_accept_pipe( + &transport, + pipe_id, + &self.pending_pipes, + generation, + &self.connection_generation, + ) + .await } #[wasm_bindgen] pub async fn deny_pipe(&self, pipe_id: u32) -> Result<(), JsValue> { + if self.state.get() != ConnectionState::Connected { + return Err(js_error("not connected")); + } let transport = self .transport .borrow() @@ -786,6 +1105,37 @@ impl WasmClient { } } + fn install_attempt_transport(&self, transport: &WasmTransport, generation: u32) -> bool { + if self.connection_generation.get() != generation { + transport.close(); + return false; + } + *self.attempt_transport.borrow_mut() = Some(transport.clone()); + true + } + + fn abort_attempt(&self, transport: &WasmTransport, generation: u32) { + transport.close(); + if self.connection_generation.get() != generation { + return; + } + if let Some(current) = self.attempt_transport.borrow_mut().take() { + current.close(); + } + if let Some(current) = self.transport.borrow_mut().take() { + current.close(); + } + self.stop_protocol_pings(); + self.reject_pending_requests("connection failed"); + client_pipe::reject_pending_pipe_creations( + &self.pending_pipe_creations, + "connection failed", + ); + client_pipe::reject_pending_pipes(&self.pending_pipes, "connection failed"); + self.connection_client_id.set(0); + self.set_state(ConnectionState::Disconnected); + } + fn begin_connection(&self) -> u32 { let generation = self.connection_generation.get().wrapping_add(1); self.connection_generation.set(generation); @@ -793,21 +1143,32 @@ impl WasmClient { if let Some(transport) = self.transport.borrow_mut().take() { transport.close(); } + if let Some(transport) = self.attempt_transport.borrow_mut().take() { + transport.close(); + } self.reject_pending_requests("connection replaced"); client_pipe::reject_pending_pipe_creations( &self.pending_pipe_creations, "connection replaced", ); + client_pipe::reject_pending_pipes(&self.pending_pipes, "connection replaced"); + self.connection_client_id.set(0); self.set_state(ConnectionState::Connecting); generation } - fn start_receive_loop(&self, transport: WasmTransport, generation: u32) -> bool { + fn start_receive_loop( + &self, + transport: WasmTransport, + generation: u32, + client_id: u64, + ) -> bool { if self.connection_generation.get() != generation { transport.close(); return false; } let loop_transport = transport.clone(); + self.attempt_transport.borrow_mut().take(); *self.transport.borrow_mut() = Some(transport); self.set_state(ConnectionState::Connected); @@ -821,6 +1182,8 @@ impl WasmClient { let subscriptions = self.subscriptions.clone(); let pending_requests = self.pending_requests.clone(); let loop_pending_requests = pending_requests.clone(); + let expired_requests = self.expired_requests.clone(); + let loop_expired_requests = expired_requests.clone(); let ping_timer = self.ping_timer.clone(); let pending_pings = self.pending_pings.clone(); let loop_pending_pings = pending_pings.clone(); @@ -828,16 +1191,26 @@ impl WasmClient { let loop_ping_ms = ping_ms.clone(); let pending_pipe_creations = self.pending_pipe_creations.clone(); let pending_pipes = self.pending_pipes.clone(); + let loop_pending_pipes = pending_pipes.clone(); let on_pipe_request = self.on_pipe_request.clone(); let loop_pipe_creations = pending_pipe_creations.clone(); + let loop_generation = generation; + let frame_generation = connection_generation.clone(); + let transport_for_cleanup = self.transport.clone(); + let connection_client_id = self.connection_client_id.clone(); wasm_bindgen_futures::spawn_local(async move { loop_transport .receive_loop_with_pipes( move |frame: JsValue| { + if frame_generation.get() != loop_generation { + return; + } let message_type = frame_type(&frame); if let Some(ref msg_type) = message_type { if msg_type == "PipeRequest" { - let pipe_id = frame_id(&frame).unwrap_or(0); + let Some(pipe_id) = frame_id(&frame).filter(|id| *id != 0) else { + return; + }; let description = frame_property(&frame, "data") .and_then(|data| { let desc = js_sys::Reflect::get( @@ -868,7 +1241,9 @@ impl WasmClient { } if msg_type == "PipeResponse" { - let pipe_id = frame_id(&frame).unwrap_or(0); + let Some(pipe_id) = frame_id(&frame).filter(|id| *id != 0) else { + return; + }; let accepted = frame_property(&frame, "data") .and_then(|data| { let acc = js_sys::Reflect::get( @@ -881,8 +1256,12 @@ impl WasmClient { .unwrap_or(false); let mut pending = loop_pipe_creations.borrow_mut(); - if let Some(tx) = pending.remove(&pipe_id) { - let _ = tx.send(Ok(accepted)); + if pending + .get(&pipe_id) + .is_some_and(|entry| entry.generation == loop_generation) + && let Some(entry) = pending.remove(&pipe_id) + { + let _ = entry.sender.send(Ok(accepted)); } return; } @@ -890,9 +1269,11 @@ impl WasmClient { route_incoming_frame( &frame, + loop_generation, &on_msg, &subscriptions, &loop_pending_requests, + &loop_expired_requests, &loop_pending_pings, &loop_ping_ms, ); @@ -904,9 +1285,13 @@ impl WasmClient { }, move |pipe_reader: PipeReader| { let pipe_id = pipe_reader.pipe_id(); - let mut pending = pending_pipes.borrow_mut(); - if let Some(tx) = pending.remove(&pipe_id) { - let _ = tx.send(pipe_reader); + let mut pending = loop_pending_pipes.borrow_mut(); + if pending + .get(&pipe_id) + .is_some_and(|entry| entry.generation == loop_generation) + && let Some(entry) = pending.remove(&pipe_id) + { + let _ = entry.sender.send(Ok(pipe_reader)); } }, ) @@ -914,6 +1299,9 @@ impl WasmClient { if connection_generation.get() != generation { return; } + if let Some(current_transport) = transport_for_cleanup.borrow_mut().take() { + current_transport.close(); + } set_shared_state( &state, &pending_state_callbacks, @@ -924,15 +1312,21 @@ impl WasmClient { pending_pings.borrow_mut().clear(); ping_ms.set(None); reject_pending_requests(&pending_requests, "disconnected"); + expired_requests.borrow_mut().clear(); client_pipe::reject_pending_pipe_creations(&pending_pipe_creations, "disconnected"); + client_pipe::reject_pending_pipes(&pending_pipes, "disconnected"); + connection_client_id.set(0); }); + self.connection_client_id.set(client_id); true } fn reject_pending_requests(&self, message: &str) { reject_pending_requests(&self.pending_requests, message); + self.expired_requests.borrow_mut().clear(); } + #[allow(clippy::too_many_arguments)] async fn read_verified_challenge( &self, transport: &WasmTransport, @@ -945,7 +1339,7 @@ impl WasmClient { generation: u32, ) -> Result { let challenge_bytes = transport.read_one_frame().await?; - let challenge = CommunicationValue::from_bytes(&challenge_bytes) + let challenge = CommunicationValue::from_bytes_with(&challenge_bytes, tm) .map_err(|e| js_error(format!("parse challenge: {}", e)))?; let expected = CommunicationType::Challenge .try_to_id(tm) @@ -962,14 +1356,16 @@ impl WasmClient { } let server_challenge = match challenge.get_data(DataType::ServerNonce) { - DataValue::UnsignedNumber(n) => *n, + Some(DataValue::UnsignedNumber(n)) => *n, _ => { self.set_state_if_current(generation, ConnectionState::Disconnected); return Err(js_error("missing server challenge")); } }; - if challenge.get_data(DataType::RequirePq) == &DataValue::BoolTrue && !client_has_pq_key { + if challenge.get_data(DataType::RequirePq) == Some(&DataValue::BoolTrue) + && !client_has_pq_key + { self.set_state_if_current(generation, ConnectionState::Disconnected); return Err(js_error( "host requires post-quantum authentication but the client PQ key is absent", diff --git a/wasm/src/client_pipe.rs b/wasm/src/client_pipe.rs index 99c73d0..1e0ea5a 100644 --- a/wasm/src/client_pipe.rs +++ b/wasm/src/client_pipe.rs @@ -13,11 +13,27 @@ use crate::pipe::PipeReader; use crate::transport::WasmTransport; pub(crate) struct PendingRequest { + pub(crate) generation: u32, pub(crate) token: Rc<()>, pub(crate) response_type: Option, pub(crate) sender: oneshot::Sender>, } +pub(crate) struct PendingPipeCreation { + pub(crate) generation: u32, + pub(crate) sender: oneshot::Sender>, +} +pub(crate) type PendingPipeCreations = Rc>>; +type PipeResponseReceiver = oneshot::Receiver>; +type PipeResponseCell = Rc>>; + +pub(crate) struct PendingPipe { + pub(crate) generation: u32, + pub(crate) sender: oneshot::Sender>, +} + +pub(crate) type PendingPipes = Rc>>; + pub(crate) fn remove_pending_request( pending_requests: &Rc>>, request_id: u32, @@ -32,6 +48,57 @@ pub(crate) fn remove_pending_request( } } +const EXPIRED_REQUEST_TOMBSTONE_TTL_MS: f64 = 60_000.0; +const MAX_EXPIRED_REQUEST_TOMBSTONES: usize = 1024; + +pub(crate) fn expire_pending_request( + pending_requests: &Rc>>, + expired_requests: &Rc>>, + request_id: u32, + token: &Rc<()>, +) { + let mut pending = pending_requests.borrow_mut(); + if pending + .get(&request_id) + .is_some_and(|entry| Rc::ptr_eq(&entry.token, token)) + { + pending.remove(&request_id); + drop(pending); + let now = js_sys::Date::now(); + let mut expired = expired_requests.borrow_mut(); + expired.retain(|_, expires_at| *expires_at > now); + if expired.len() >= MAX_EXPIRED_REQUEST_TOMBSTONES + && let Some(oldest) = expired + .iter() + .min_by(|(_, left), (_, right)| left.total_cmp(right)) + .map(|(id, _)| *id) + { + expired.remove(&oldest); + } + expired.insert(request_id, now + EXPIRED_REQUEST_TOMBSTONE_TTL_MS); + } +} + +pub(crate) fn consume_expired_request( + expired_requests: &Rc>>, + request_id: u32, +) -> bool { + let now = js_sys::Date::now(); + let mut expired = expired_requests.borrow_mut(); + expired.retain(|_, expires_at| *expires_at > now); + expired.remove(&request_id).is_some() +} + +pub(crate) fn is_expired_request( + expired_requests: &Rc>>, + request_id: u32, +) -> bool { + let now = js_sys::Date::now(); + let mut expired = expired_requests.borrow_mut(); + expired.retain(|_, expires_at| *expires_at > now); + expired.contains_key(&request_id) +} + #[wasm_bindgen(typescript_custom_section)] const PIPE_HANDLE_TS: &str = r#" export interface WasmPipeHandle { @@ -46,7 +113,7 @@ pub struct WasmPipeHandle { pipe_id: u32, description: String, transport: WasmTransport, - response_rx: Rc>>>>, + response_rx: PipeResponseCell, } #[wasm_bindgen] @@ -92,13 +159,17 @@ pub(crate) fn random_pipe_id() -> Result { Ok(u32::from_be_bytes(bytes)) } -pub(crate) fn reject_pending_pipe_creations( - pending: &Rc>>>>, - message: &str, -) { +pub(crate) fn reject_pending_pipe_creations(pending: &PendingPipeCreations, message: &str) { let pending = std::mem::take(&mut *pending.borrow_mut()); - for (_, tx) in pending { - let _ = tx.send(Err(js_error(message))); + for (_, entry) in pending { + let _ = entry.sender.send(Err(js_error(message))); + } +} + +pub(crate) fn reject_pending_pipes(pending: &PendingPipes, message: &str) { + let pending = std::mem::take(&mut *pending.borrow_mut()); + for (_, entry) in pending { + let _ = entry.sender.send(Err(js_error(message))); } } @@ -106,12 +177,24 @@ pub(crate) async fn wasm_create_pipe( transport: &WasmTransport, description: &str, pipe_id: u32, - pending_pipe_creations: &Rc>>>>, + pending_pipe_creations: &PendingPipeCreations, + generation: u32, + current_generation: &Rc>, ) -> Result { let (tx, rx) = oneshot::channel(); - pending_pipe_creations.borrow_mut().insert(pipe_id, tx); - - let request = CommunicationValue::new(CommunicationType::PipeRequest) + let mut pipe_id = pipe_id; + for _ in 0..128 { + let occupied = pipe_id == 0 || pending_pipe_creations.borrow().contains_key(&pipe_id); + if !occupied { + break; + } + pipe_id = random_pipe_id()?; + } + if pipe_id == 0 || pending_pipe_creations.borrow().contains_key(&pipe_id) { + return Err(js_error("could not allocate a unique pipe id")); + } + let type_map = transport.type_map(); + let request = CommunicationValue::new_with_type_map(CommunicationType::PipeRequest, &type_map) .with_id(pipe_id) .add_typed_default( DataType::Description, @@ -120,6 +203,13 @@ pub(crate) async fn wasm_create_pipe( let request_bytes = request .to_bytes() .map_err(|e| js_error(format!("encode failed: {}", e)))?; + pending_pipe_creations.borrow_mut().insert( + pipe_id, + PendingPipeCreation { + generation, + sender: tx, + }, + ); debug!( target = "mtp.wasm", pipe_id, @@ -127,7 +217,26 @@ pub(crate) async fn wasm_create_pipe( frame_len = request_bytes.len(), "sending pipe request" ); - transport.send_frame(&request_bytes).await?; + if let Err(error) = transport.send_frame(&request_bytes).await { + let mut pending = pending_pipe_creations.borrow_mut(); + if pending + .get(&pipe_id) + .is_some_and(|entry| entry.generation == generation) + { + pending.remove(&pipe_id); + } + return Err(error); + } + if current_generation.get() != generation { + let mut pending = pending_pipe_creations.borrow_mut(); + if pending + .get(&pipe_id) + .is_some_and(|entry| entry.generation == generation) + { + pending.remove(&pipe_id); + } + return Err(js_error("connection attempt superseded")); + } Ok(WasmPipeHandle { pipe_id, @@ -140,14 +249,40 @@ pub(crate) async fn wasm_create_pipe( pub(crate) async fn wasm_accept_pipe( transport: &WasmTransport, pipe_id: u32, - pending_pipes: &Rc>>>, + pending_pipes: &PendingPipes, + generation: u32, + current_generation: &Rc>, ) -> Result { - let resp = CommunicationValue::new(CommunicationType::PipeResponse) + if pipe_id == 0 { + return Err(js_error("pipe id must be non-zero")); + } + if current_generation.get() != generation { + return Err(js_error("connection attempt superseded")); + } + + let type_map = transport.type_map(); + let resp = CommunicationValue::new_with_type_map(CommunicationType::PipeResponse, &type_map) .with_id(pipe_id) .add_typed_default(DataType::Accepted, DataValue::BoolTrue); let resp_bytes = resp .to_bytes() .map_err(|e| js_error(format!("encode failed: {}", e)))?; + + let (tx, rx) = oneshot::channel(); + { + let mut pending = pending_pipes.borrow_mut(); + if pending.contains_key(&pipe_id) { + return Err(js_error(format!("pipe {pipe_id} is already pending"))); + } + pending.insert( + pipe_id, + PendingPipe { + generation, + sender: tx, + }, + ); + } + debug!( target = "mtp.wasm", pipe_id, @@ -155,17 +290,37 @@ pub(crate) async fn wasm_accept_pipe( frame_len = resp_bytes.len(), "sending pipe response" ); - transport.send_frame(&resp_bytes).await?; - - let (tx, rx) = oneshot::channel(); - pending_pipes.borrow_mut().insert(pipe_id, tx); + if let Err(error) = transport.send_frame(&resp_bytes).await { + let mut pending = pending_pipes.borrow_mut(); + if pending + .get(&pipe_id) + .is_some_and(|entry| entry.generation == generation) + { + pending.remove(&pipe_id); + } + return Err(error); + } + if current_generation.get() != generation { + let mut pending = pending_pipes.borrow_mut(); + if pending + .get(&pipe_id) + .is_some_and(|entry| entry.generation == generation) + { + pending.remove(&pipe_id); + } + return Err(js_error("connection attempt superseded")); + } rx.await - .map_err(|_| js_error("pipe closed before stream arrived")) + .map_err(|_| js_error("pipe closed before stream arrived"))? } pub(crate) async fn wasm_deny_pipe(transport: &WasmTransport, pipe_id: u32) -> Result<(), JsValue> { - let resp = CommunicationValue::new(CommunicationType::PipeResponse) + if pipe_id == 0 { + return Err(js_error("pipe id must be non-zero")); + } + let type_map = transport.type_map(); + let resp = CommunicationValue::new_with_type_map(CommunicationType::PipeResponse, &type_map) .with_id(pipe_id) .add_typed_default(DataType::Accepted, DataValue::BoolFalse); let resp_bytes = resp diff --git a/wasm/src/crypto.rs b/wasm/src/crypto.rs index f7a6aad..a96d42a 100644 --- a/wasm/src/crypto.rs +++ b/wasm/src/crypto.rs @@ -1,13 +1,67 @@ use wasm_bindgen::prelude::*; use zeroize::Zeroizing; +use mtp_codec::{ + DataValue, MtpProtectionPurpose, PROTOCOL_VERSION, ProtectionPolicy, ProtectionPurpose, + SealedRelayBuilder, SignaturePolicy, TypeMap, +}; use mtp_crypto::{ - AeadDecrypt, AeadEncrypt, ChaCha20Poly1305, Ed25519Signer, HybridKem, KemPrivateKey, - KemPublicKey, Keyring, PublicKeyBundle, SignaturePqPrivateKey, SignaturePqPublicKey, - SignaturePrivateKey, SignaturePublicKey, SignatureScheme, sha256, sha256_double, + AeadDecrypt, AeadEncrypt, DualSigner, Ed25519Signer, HybridKem, KemPrivateKey, KemPublicKey, + Keyring, PublicKeyBundle, SignaturePqPrivateKey, SignaturePqPublicKey, SignaturePrivateKey, + SignaturePublicKey, SignatureScheme, XChaCha20Poly1305, sha256, sha256_double, }; -use crate::error::js_error; +use crate::error::{from_protection_error, js_error}; +use crate::relay::{decode_frame, relay_error, structured_error}; + +fn decode_data_value(value: &[u8]) -> Result { + DataValue::from_bytes(value).ok_or_else(|| js_error("invalid DataValue")) +} + +fn decode_public_key_bundle( + bytes: &[u8], + index: Option, +) -> Result { + PublicKeyBundle::from_bytes(bytes).map_err(|e| { + let prefix = index + .map(|index| format!("recipient {index}: ")) + .unwrap_or_default(); + js_error(format!("{prefix}public bundle initialization failed: {e}")) + }) +} + +pub(crate) fn public_key_bundles_from_js(value: &JsValue) -> Result, JsValue> { + if js_sys::Uint8Array::instanceof(value) { + return Ok(vec![decode_public_key_bundle( + &js_sys::Uint8Array::new(value).to_vec(), + None, + )?]); + } + + if !js_sys::Array::is_array(value) { + return Err(js_error( + "recipient public key bundles must be a Uint8Array or an array of Uint8Arrays", + )); + } + + let array = js_sys::Array::from(value); + if array.length() == 0 { + return Err(js_error( + "at least one recipient public key bundle is required", + )); + } + + array + .iter() + .enumerate() + .map(|(index, value)| { + if !js_sys::Uint8Array::instanceof(&value) { + return Err(js_error(format!("recipient {index} must be a Uint8Array"))); + } + decode_public_key_bundle(&js_sys::Uint8Array::new(&value).to_vec(), Some(index)) + }) + .collect() +} // =========================================================================== // Keyring @@ -41,6 +95,25 @@ impl WasmKeyring { inner: self.inner.public_key_bundle(), } } + + /// Validate that all full-suite public/private components correspond. + /// Role-specific browser keyrings may intentionally fail this check. + #[wasm_bindgen] + pub fn validate_full(&self) -> Result<(), JsValue> { + self.inner + .validate_full() + .map_err(|e| js_error(format!("Keyring::validate_full: {e}"))) + } + + /// Validate the KEM public/private pair without requiring PQ signing + /// material. This is the invariant needed by envelope recipients and + /// sealed-relay clients that explicitly choose Ed25519 signatures. + #[wasm_bindgen] + pub fn validate_encryption(&self) -> Result<(), JsValue> { + self.inner + .validate_encryption() + .map_err(|e| js_error(format!("Keyring::validate_encryption: {e}"))) + } } /// Generate a full keyring with KEM, ML-DSA, and Ed25519 keys. @@ -109,6 +182,16 @@ impl WasmPublicKeyBundle { .map_err(|e| js_error(format!("PublicKeyBundle::from_bytes: {}", e)))?; Ok(Self { inner }) } + + /// Deserialise an explicitly partial bundle for development-only key + /// material. Protocol encryption and signature verification use the + /// strict `from_bytes` parser above. + #[wasm_bindgen] + pub fn from_bytes_unvalidated(bytes: &[u8]) -> Result { + let inner = PublicKeyBundle::from_bytes_unvalidated(bytes) + .map_err(|e| js_error(format!("PublicKeyBundle::from_bytes_unvalidated: {}", e)))?; + Ok(Self { inner }) + } } // =========================================================================== @@ -126,6 +209,36 @@ pub struct WasmEncapsulated { inner_ciphertext: Vec, } +/// A short-lived ephemeral hybrid-KEM keypair for the forward-secure pipe +/// handshake. The secret is zeroized when the object is freed. +#[wasm_bindgen] +pub struct WasmKemKeypair { + secret: Zeroizing>, + public: Vec, +} + +#[wasm_bindgen] +impl WasmKemKeypair { + #[wasm_bindgen(getter)] + pub fn public_key(&self) -> Vec { + self.public.clone() + } + + #[wasm_bindgen(getter)] + pub fn secret_key(&self) -> Vec { + self.secret.to_vec() + } +} + +#[wasm_bindgen] +pub fn wasm_kem_generate_keypair() -> WasmKemKeypair { + let (secret, public) = HybridKem::generate_keypair(); + WasmKemKeypair { + secret: Zeroizing::new(secret.as_bytes().to_vec()), + public: public.as_bytes().to_vec(), + } +} + #[wasm_bindgen] impl WasmEncapsulated { /// Symmetric secret derived during encapsulation. @@ -178,7 +291,7 @@ pub fn wasm_kem_decapsulate( #[wasm_bindgen] pub struct WasmChaCha20Poly1305 { - inner: ChaCha20Poly1305, + inner: XChaCha20Poly1305, } #[wasm_bindgen] @@ -192,7 +305,7 @@ impl WasmChaCha20Poly1305 { let mut k = [0u8; 32]; k.copy_from_slice(&key); Ok(Self { - inner: ChaCha20Poly1305::new(k), + inner: XChaCha20Poly1305::new(k), }) } @@ -339,6 +452,401 @@ pub fn wasm_derive_encryption_key( .map_err(|e| js_error(format!("derive_encryption_key failed: {}", e))) } +/// Signature suites accepted by high-level protected-value APIs. +pub const PROTECTION_SIGNATURE_SUITE_ED25519: u8 = 0x01; +pub const PROTECTION_SIGNATURE_SUITE_DUAL: u8 = 0x03; + +pub(crate) fn protection_policy_from_suite(suite: u8) -> Result { + let signature = match suite { + 0 => SignaturePolicy::AnySupported, + PROTECTION_SIGNATURE_SUITE_ED25519 => SignaturePolicy::Ed25519, + PROTECTION_SIGNATURE_SUITE_DUAL => SignaturePolicy::Dual, + _ => { + return Err(js_error(format!( + "unknown protection signature suite: {suite}" + ))); + } + }; + Ok(ProtectionPolicy { signature }) +} + +pub(crate) enum RelaySigner { + Ed25519(Ed25519Signer), + Dual(DualSigner), +} + +impl SignatureScheme for RelaySigner { + fn algorithm(&self) -> u8 { + match self { + Self::Ed25519(signer) => signer.algorithm(), + Self::Dual(signer) => signer.algorithm(), + } + } + + fn sign(&self, message: &[u8]) -> Result, mtp_crypto::CryptoError> { + match self { + Self::Ed25519(signer) => signer.sign(message), + Self::Dual(signer) => signer.sign(message), + } + } + + fn verify(&self, message: &[u8], signature: &[u8]) -> Result<(), mtp_crypto::CryptoError> { + match self { + Self::Ed25519(signer) => signer.verify(message, signature), + Self::Dual(signer) => signer.verify(message, signature), + } + } +} + +pub(crate) fn relay_signer_from_keyring( + keyring: &Keyring, + suite: u8, +) -> Result { + match suite { + PROTECTION_SIGNATURE_SUITE_ED25519 => { + keyring + .validate_ed25519_signing() + .map_err(|e| js_error(format!("signing key validation failed: {e}")))?; + Ed25519Signer::new(&keyring.sig_cl_secret_key) + .map(RelaySigner::Ed25519) + .map_err(|e| js_error(format!("signer initialization failed: {e}"))) + } + PROTECTION_SIGNATURE_SUITE_DUAL => { + keyring + .validate_dual_signing() + .map_err(|e| js_error(format!("dual signing key validation failed: {e}")))?; + DualSigner::new( + &keyring.sig_cl_secret_key, + &keyring.sig_pq_secret_key, + &keyring.sig_pq_public_key, + ) + .map(RelaySigner::Dual) + .map_err(|e| js_error(format!("dual signer initialization failed: {e}"))) + } + _ => Err(js_error(format!( + "unknown protection signature suite: {suite}" + ))), + } +} + +/// Sign a serialized `DataValue` using the selected suite from a serialized +/// keyring. +#[wasm_bindgen] +pub fn sign_data_value_with_keyring( + value: &[u8], + signer_id: u64, + purpose: u8, + keyring: &[u8], + signature_suite: u8, +) -> Result, JsValue> { + let value = decode_data_value(value)?; + let keyring = Keyring::from_bytes(keyring) + .map_err(|e| js_error(format!("keyring initialization failed: {e}")))?; + let signer = relay_signer_from_keyring(&keyring, signature_suite)?; + value + .sign(signer_id, ProtectionPurpose::from(purpose), &signer) + .map_err(from_protection_error)? + .to_bytes() + .map_err(|e| js_error(format!("sign failed: {e}"))) +} + +/// Verify a serialized `Signed` wrapper while enforcing the receiver's +/// required signature suite. `0` retains the legacy any-supported behavior; +/// new protocol callers should pass one of the exported suite constants. +#[wasm_bindgen] +pub fn verify_data_value_with_policy( + value: &[u8], + public_key_bundle: &[u8], + expected_signer_id: u64, + expected_purpose: u8, + signature_suite: u8, +) -> Result<(), JsValue> { + let value = decode_data_value(value)?; + let bundle = decode_public_key_bundle(public_key_bundle, None)?; + let result = if signature_suite == 0 { + value.verify( + expected_signer_id, + &bundle, + ProtectionPurpose::from(expected_purpose), + ) + } else { + value.verify_with_policy( + expected_signer_id, + &bundle, + ProtectionPurpose::from(expected_purpose), + protection_policy_from_suite(signature_suite)?, + ) + }; + result.map_err(from_protection_error) +} + +/// Encrypt a serialized `DataValue` for one recipient using the canonical +/// multi-recipient envelope. +#[wasm_bindgen] +pub fn encrypt_data_value( + value: &[u8], + recipient_public_key_bundle: &[u8], + purpose: u8, +) -> Result, JsValue> { + let value = decode_data_value(value)?; + let recipient = decode_public_key_bundle(recipient_public_key_bundle, None)?; + let encrypted = value + .encrypt_for(&[recipient], ProtectionPurpose::from(purpose)) + .map_err(from_protection_error)?; + encrypted + .to_bytes() + .map_err(|e| js_error(format!("encryption failed: {e}"))) +} + +/// Encrypt a serialized `DataValue` for one or more recipients. +/// +/// `recipient_public_key_bundles` may be a single `Uint8Array` for the common +/// case or an array of serialized public-key bundles. The array form uses the +/// same canonical envelope as native multi-recipient encryption. +#[wasm_bindgen] +pub fn encrypt_data_value_for_recipients( + value: &[u8], + recipient_public_key_bundles: JsValue, + purpose: u8, +) -> Result, JsValue> { + let value = decode_data_value(value)?; + let recipients = public_key_bundles_from_js(&recipient_public_key_bundles)?; + value + .encrypt_for(&recipients, ProtectionPurpose::from(purpose)) + .map_err(from_protection_error)? + .to_bytes() + .map_err(|e| js_error(format!("encryption failed: {e}"))) +} + +/// Decrypt a serialized `Encrypted` wrapper with a serialized keyring. +/// The expected purpose is supplied by the protocol caller, not taken from +/// the untrusted encrypted wrapper. +#[wasm_bindgen] +pub fn decrypt_data_value( + value: &[u8], + keyring: &[u8], + expected_purpose: u8, +) -> Result, JsValue> { + let value = decode_data_value(value)?; + let keyring = Keyring::from_bytes(keyring) + .map_err(|e| js_error(format!("keyring initialization failed: {e}")))?; + let opened = value + .decrypt(&keyring, ProtectionPurpose::from(expected_purpose)) + .map_err(from_protection_error)?; + opened + .to_bytes() + .map_err(|e| js_error(format!("decryption failed: {e}"))) +} + +/// Decrypt using a caller-supplied local key history. Recipient key +/// identifiers remain absent from the serialized envelope. +#[wasm_bindgen] +pub fn decrypt_data_value_with_keyrings( + value: &[u8], + keyrings: JsValue, + expected_purpose: u8, +) -> Result, JsValue> { + let value = decode_data_value(value)?; + let keyrings = keyrings_from_js(&keyrings)?; + let references: Vec<&Keyring> = keyrings.iter().collect(); + value + .decrypt_with_keyrings(&references, ProtectionPurpose::from(expected_purpose)) + .map_err(from_protection_error)? + .to_bytes() + .map_err(|e| js_error(format!("decryption failed: {e}"))) +} + +pub(crate) fn keyrings_from_js(value: &JsValue) -> Result, JsValue> { + let keyring_bytes: Vec> = if js_sys::Uint8Array::instanceof(value) { + vec![js_sys::Uint8Array::new(value).to_vec()] + } else if js_sys::Array::is_array(value) { + let array = js_sys::Array::from(value); + array + .iter() + .enumerate() + .map(|(index, value)| { + if !js_sys::Uint8Array::instanceof(&value) { + return Err(js_error(format!("keyring {index} must be a Uint8Array"))); + } + Ok(js_sys::Uint8Array::new(&value).to_vec()) + }) + .collect::>()? + } else { + return Err(js_error( + "keyrings must be a Uint8Array or an array of Uint8Arrays", + )); + }; + if keyring_bytes.is_empty() { + return Err(js_error("at least one keyring is required")); + } + keyring_bytes + .iter() + .map(|bytes| { + Keyring::from_bytes(bytes) + .map_err(|e| js_error(format!("keyring initialization failed: {e}"))) + }) + .collect() +} + +/// Protection purposes used by the generic browser relay envelope. +/// +/// The outer encryption purpose is intentionally generic: the actual +/// application operation is inside the encrypted metadata container. +pub const RELAY_METADATA_ENCRYPTION_PURPOSE: u8 = + MtpProtectionPurpose::RelayMetadataEncryption.value(); +pub const RELAY_CONTENT_SIGNATURE_PURPOSE: u8 = MtpProtectionPurpose::RelayContentSignature.value(); +pub const RELAY_CONTENT_ENCRYPTION_PURPOSE: u8 = + MtpProtectionPurpose::RelayContentEncryption.value(); +pub const RELAY_METADATA_SIGNATURE_PURPOSE: u8 = + MtpProtectionPurpose::RelayMetadataSignature.value(); + +/// Return the canonical MTP relay metadata-encryption purpose. +#[wasm_bindgen] +pub fn mtp_relay_metadata_encryption_purpose() -> u8 { + MtpProtectionPurpose::RelayMetadataEncryption.value() +} + +/// Return the canonical MTP relay content-signature purpose. +#[wasm_bindgen] +pub fn mtp_relay_content_signature_purpose() -> u8 { + MtpProtectionPurpose::RelayContentSignature.value() +} + +/// Return the canonical MTP relay content-encryption purpose. +#[wasm_bindgen] +pub fn mtp_relay_content_encryption_purpose() -> u8 { + MtpProtectionPurpose::RelayContentEncryption.value() +} + +/// Return the canonical MTP relay metadata-signature purpose. +#[wasm_bindgen] +pub fn mtp_relay_metadata_signature_purpose() -> u8 { + MtpProtectionPurpose::RelayMetadataSignature.value() +} + +/// Return the canonical MTP pipe-session signature purpose. +#[wasm_bindgen] +pub fn mtp_pipe_session_signature_purpose() -> u8 { + MtpProtectionPurpose::PipeSessionSignature.value() +} + +/// Return the canonical MTP pipe-session encryption purpose. +#[wasm_bindgen] +pub fn mtp_pipe_session_encryption_purpose() -> u8 { + MtpProtectionPurpose::PipeSessionEncryption.value() +} + +#[wasm_bindgen] +pub fn mtp_protection_signature_suite_ed25519() -> u8 { + PROTECTION_SIGNATURE_SUITE_ED25519 +} + +#[wasm_bindgen] +pub fn mtp_protection_signature_suite_dual() -> u8 { + PROTECTION_SIGNATURE_SUITE_DUAL +} + +/// Forward a sealed relay frame to another clear next hop without opening or +/// re-encoding its authenticated encrypted payload. +#[wasm_bindgen] +pub fn forward_encrypted_relay_frame( + frame: &[u8], + next_hop_receiver_id: u64, +) -> Result, JsValue> { + let frame = decode_frame(frame)?; + mtp_codec::forward_relay_frame(&frame, next_hop_receiver_id) + .map_err(relay_error)? + .to_bytes() + .map_err(|e| structured_error("invalid-frame", format!("relay frame encoding failed: {e}"))) +} + +/// Convert browser values and build a sealed relay frame through the native +/// codec builder. The builder owns the protected relay layout so native and +/// browser callers cannot silently diverge. +#[allow(clippy::too_many_arguments)] +fn build_encrypted_relay_frame_impl( + message_type: &str, + data: JsValue, + signer_id: u64, + final_recipient_id: u64, + next_hop_id: u64, + message_id: &str, + created_at: u64, + encoded_metadata: Option>, + signer: &dyn SignatureScheme, + metadata_recipient_public_key_bundles: JsValue, + content_recipient_public_key_bundles: JsValue, +) -> Result, JsValue> { + let tm = TypeMap::new(PROTOCOL_VERSION); + let application_content = crate::frame::js_to_data_value(&data, &tm)?; + let application_metadata = encoded_metadata + .as_deref() + .map(decode_data_value) + .transpose()?; + let content_recipients = public_key_bundles_from_js(&content_recipient_public_key_bundles)?; + let metadata_recipients = public_key_bundles_from_js(&metadata_recipient_public_key_bundles)?; + + let builder = SealedRelayBuilder::new( + message_type, + application_content, + signer_id, + final_recipient_id, + next_hop_id, + signer, + ) + .message_id(message_id) + .created_at(created_at) + .metadata_recipients(metadata_recipients) + .content_recipients(content_recipients) + .type_map(&tm); + let builder = match application_metadata { + Some(metadata) => builder.metadata(metadata), + None => builder, + }; + + builder + .build() + .map_err(relay_error)? + .to_bytes() + .map_err(|e| js_error(format!("relay frame encoding failed: {e}"))) +} + +/// Build a relay frame using an explicit Ed25519 or dual-signature policy. +/// `created_at` is Unix epoch milliseconds. +#[wasm_bindgen] +#[allow(clippy::too_many_arguments)] +pub fn build_encrypted_relay_frame_with_keyring( + message_type: &str, + data: JsValue, + signer_id: u64, + final_recipient_id: u64, + next_hop_id: u64, + message_id: &str, + created_at: u64, + encoded_metadata: Option>, + keyring_bytes: &[u8], + signature_suite: u8, + metadata_recipient_public_key_bundles: JsValue, + content_recipient_public_key_bundles: JsValue, +) -> Result, JsValue> { + let keyring = Keyring::from_bytes(keyring_bytes) + .map_err(|e| js_error(format!("keyring initialization failed: {e}")))?; + let signer = relay_signer_from_keyring(&keyring, signature_suite)?; + build_encrypted_relay_frame_impl( + message_type, + data, + signer_id, + final_recipient_id, + next_hop_id, + message_id, + created_at, + encoded_metadata, + &signer, + metadata_recipient_public_key_bundles, + content_recipient_public_key_bundles, + ) +} + #[cfg(test)] #[cfg(target_arch = "wasm32")] mod tests { @@ -384,7 +892,8 @@ mod tests { }; let bytes = bundle.to_bytes(); - let restored = WasmPublicKeyBundle::from_bytes(&bytes).expect("from_bytes failed"); + let restored = WasmPublicKeyBundle::from_bytes_unvalidated(&bytes) + .expect("from_bytes_unvalidated failed"); assert_eq!(restored.sig_cl_public_key(), pk); } @@ -578,4 +1087,72 @@ mod tests { wasm_derive_encryption_key(b"pass2", b"salt", b"context").expect("derive failed"); assert_ne!(key, key2); } + + // ------------------------------------------------------------------ + // DataValue protection + // ------------------------------------------------------------------ + + #[wasm_bindgen_test] + fn signed_data_value_can_be_verified_through_wasm() { + let keyring = Keyring::generate(); + let value = DataValue::Str("signed through wasm".into()) + .to_bytes() + .expect("value encoding failed"); + let keyring_bytes = keyring.to_bytes(); + let signed = sign_data_value_with_keyring( + &value, + 0xfeed_beef, + 7, + &keyring_bytes, + PROTECTION_SIGNATURE_SUITE_ED25519, + ) + .expect("sign_data_value_with_keyring failed"); + let bundle = keyring.public_key_bundle(); + + verify_data_value_with_policy( + &signed, + &bundle.as_bytes(), + 0xfeed_beef, + 7, + PROTECTION_SIGNATURE_SUITE_ED25519, + ) + .expect("verify_data_value_with_policy failed"); + let wrong_bundle = Keyring::generate().public_key_bundle(); + assert!( + verify_data_value_with_policy( + &signed, + &wrong_bundle.as_bytes(), + 0xfeed_beef, + 7, + PROTECTION_SIGNATURE_SUITE_ED25519, + ) + .is_err() + ); + } + + #[wasm_bindgen_test] + fn encrypted_data_value_can_be_opened_through_wasm() { + let keyring = Keyring::generate(); + let recipient = keyring.public_key_bundle(); + let value = DataValue::Array(vec![DataValue::BoolTrue, DataValue::UnsignedNumber(42)]) + .to_bytes() + .expect("value encoding failed"); + let encrypted = encrypt_data_value(&value, &recipient.as_bytes(), 9) + .expect("encrypt_data_value failed"); + let decrypted = decrypt_data_value(&encrypted, &keyring.to_bytes(), 9) + .expect("decrypt_data_value failed"); + + assert_eq!(decrypted, value); + + let second_keyring = Keyring::generate(); + let second_recipient = second_keyring.public_key_bundle(); + let recipients = js_sys::Array::new(); + recipients.push(&js_sys::Uint8Array::from(&recipient.as_bytes()[..])); + recipients.push(&js_sys::Uint8Array::from(&second_recipient.as_bytes()[..])); + let multi = encrypt_data_value_for_recipients(&value, recipients.into(), 9) + .expect("multi-recipient encryption failed"); + let opened_by_second = decrypt_data_value(&multi, &second_keyring.to_bytes(), 9) + .expect("second recipient could not decrypt"); + assert_eq!(opened_by_second, value); + } } diff --git a/wasm/src/error.rs b/wasm/src/error.rs index 2fb7d2a..147cbd7 100644 --- a/wasm/src/error.rs +++ b/wasm/src/error.rs @@ -16,6 +16,10 @@ pub fn from_crypto_error(e: mtp_crypto::CryptoError) -> JsValue { js_error(e.to_string()) } +pub fn from_protection_error(e: mtp_codec::ProtectionError) -> JsValue { + js_error(e.to_string()) +} + #[cfg(test)] #[cfg(target_arch = "wasm32")] mod tests { diff --git a/wasm/src/frame.rs b/wasm/src/frame.rs index 0aee98c..a81edf0 100644 --- a/wasm/src/frame.rs +++ b/wasm/src/frame.rs @@ -7,12 +7,40 @@ use crate::error::js_error; #[wasm_bindgen(typescript_custom_section)] const PARSED_FRAME_TS: &'static str = r#" +export interface ParsedEncryptedValue { + kind: "encrypted"; + encryptionType: number; + purpose: number; + recipientCount: number; + encoded: Uint8Array; +} + +export interface ParsedSignedValue { + kind: "signed"; + signatureType: number; + purpose: number; + signerId: bigint; + value: ParsedDataValue; +} + +export type ParsedDataValue = + | boolean + | number + | bigint + | string + | Uint8Array + | ParsedDataValue[] + | { [key: string]: ParsedDataValue } + | ParsedEncryptedValue + | ParsedSignedValue + | null; + export interface ParsedFrame { id?: number; type: string; sender?: bigint; receiver?: bigint; - data: Record; + data: ParsedDataValue; raw: Uint8Array; } "#; @@ -28,10 +56,10 @@ fn integer_value(value: &str) -> JsValue { { return JsValue::from_f64(number); } - JsValue::from_str(value) + JsValue::bigint_from_str(value) } -fn data_value_to_js(value: &DataValue, tm: &TypeMap) -> Result { +pub(crate) fn data_value_to_js(value: &DataValue, tm: &TypeMap) -> Result { match value { DataValue::BoolTrue => Ok(JsValue::TRUE), DataValue::BoolFalse => Ok(JsValue::FALSE), @@ -59,17 +87,57 @@ fn data_value_to_js(value: &DataValue, tm: &TypeMap) -> Result } Ok(obj.into()) } - DataValue::EncryptedContainer(bytes) - | DataValue::SignedContainer(bytes) - | DataValue::SignedEncryptedContainer(bytes) => { - Ok(js_sys::Uint8Array::from(&bytes[..]).into()) + DataValue::Encrypted(encrypted) => { + let obj = js_sys::Object::new(); + set_prop(&obj, "kind", &JsValue::from_str("encrypted"))?; + set_prop( + &obj, + "encryptionType", + &JsValue::from_f64(encrypted.encryption_type.to_byte() as f64), + )?; + set_prop( + &obj, + "purpose", + &JsValue::from_f64(encrypted.purpose as f64), + )?; + set_prop( + &obj, + "recipientCount", + &JsValue::from_f64(encrypted.recipients.len() as f64), + )?; + let encoded = value + .to_bytes() + .map_err(|e| js_error(format!("encode protected value: {e}")))?; + set_prop( + &obj, + "encoded", + &js_sys::Uint8Array::from(&encoded[..]).into(), + )?; + Ok(obj.into()) + } + DataValue::Signed(signed) => { + let obj = js_sys::Object::new(); + set_prop(&obj, "kind", &JsValue::from_str("signed"))?; + set_prop( + &obj, + "signatureType", + &JsValue::from_f64(signed.algorithm as f64), + )?; + set_prop(&obj, "purpose", &JsValue::from_f64(signed.purpose as f64))?; + set_prop( + &obj, + "signerId", + &JsValue::bigint_from_str(&signed.signer_id.to_string()), + )?; + set_prop(&obj, "value", &data_value_to_js(&signed.value, tm)?)?; + Ok(obj.into()) } DataValue::Null => Ok(JsValue::NULL), } } const MAX_SAFE_INT: f64 = 9007199254740991.0; // 2^53 - 1 -fn js_to_data_value(value: &JsValue, tm: &TypeMap) -> Result { +pub(crate) fn js_to_data_value(value: &JsValue, tm: &TypeMap) -> Result { if value.is_null() || value.is_undefined() { return Ok(DataValue::Null); } @@ -91,18 +159,13 @@ fn js_to_data_value(value: &JsValue, tm: &TypeMap) -> Result return Ok(DataValue::Array(values)); } if let Some(v) = value.as_f64() { - if let Some(v) = value.as_f64() { - if v.is_finite() - && v.fract() == 0.0 - && (-(MAX_SAFE_INT + 1.0)..=MAX_SAFE_INT).contains(&v) - { - if v >= 0.0 { - return Ok(DataValue::UnsignedNumber(v as u128)); - } else { - return Ok(DataValue::SignedNumber(v as i128)); - } + if v.is_finite() && v.fract() == 0.0 && (-(MAX_SAFE_INT + 1.0)..=MAX_SAFE_INT).contains(&v) + { + if v >= 0.0 { + return Ok(DataValue::UnsignedNumber(v as u128)); + } else { + return Ok(DataValue::SignedNumber(v as i128)); } - return Ok(DataValue::Float(v)); } return Ok(DataValue::Float(v)); } @@ -115,10 +178,16 @@ fn js_to_data_value(value: &JsValue, tm: &TypeMap) -> Result .as_string() .ok_or_else(|| js_error("failed to stringify bigint"))?; if let Some(unsigned) = as_string.strip_prefix('-') { - let n = unsigned - .parse::() + let magnitude = unsigned + .parse::() .map_err(|_| js_error("bigint out of range"))?; - return Ok(DataValue::SignedNumber(-n)); + if magnitude > (1u128 << 127) { + return Err(js_error("bigint out of range")); + } + if magnitude == (1u128 << 127) { + return Ok(DataValue::SignedNumber(i128::MIN)); + } + return Ok(DataValue::SignedNumber(-(magnitude as i128))); } let n = as_string .parse::() @@ -159,7 +228,11 @@ fn option_u32(options: &JsValue, key: &str) -> Result, JsValue> { let Some(n) = value.as_f64() else { return Err(js_error(format!("{key} must be a number"))); }; - Ok(Some(n as u32)) + if !n.is_finite() || n.fract() != 0.0 || !(0.0..=MAX_SAFE_INT).contains(&n) { + return Err(js_error(format!("{key} must be an exact integer"))); + } + let n = u32::try_from(n as u64).map_err(|_| js_error(format!("{key} out of range")))?; + Ok(Some(n)) } fn option_u64(options: &JsValue, key: &str) -> Result, JsValue> { @@ -168,6 +241,11 @@ fn option_u64(options: &JsValue, key: &str) -> Result, JsValue> { return Ok(None); } if let Some(n) = value.as_f64() { + if !n.is_finite() || n.fract() != 0.0 || !(0.0..=MAX_SAFE_INT).contains(&n) { + return Err(js_error(format!( + "{key} must be an exact integer number at most 2^53-1 or a bigint" + ))); + } return Ok(Some(n as u64)); } let type_name = value.js_typeof().as_string().unwrap_or_default(); @@ -185,18 +263,39 @@ fn option_u64(options: &JsValue, key: &str) -> Result, JsValue> { Err(js_error(format!("{key} must be a number or bigint"))) } -pub(crate) fn parse_frame_value(frame: &[u8]) -> Result { - let comm = CommunicationValue::from_bytes(frame) - .map_err(|e| js_error(format!("parse failed: {}", e)))?; - let tm = comm - .type_map() - .cloned() - .unwrap_or_else(|| TypeMap::new(PROTOCOL_VERSION)); - let obj = js_sys::Object::new(); - let data = js_sys::Object::new(); +fn apply_frame_options( + mut message: CommunicationValue, + options: &JsValue, +) -> Result { + if !options.is_null() && !options.is_undefined() { + if let Some(id) = option_u32(options, "id")? { + message = message.with_id(id); + } + if let Some(sender) = option_u64(options, "sender")? { + message = message.with_sender(sender); + } + if let Some(receiver) = option_u64(options, "receiver")? { + message = message.with_receiver(receiver); + } + } + Ok(message) +} - if comm.get_id() != 0 { - set_prop(&obj, "id", &JsValue::from_f64(comm.get_id() as f64))?; +pub(crate) fn parse_frame_value(frame: &[u8]) -> Result { + parse_frame_value_with_type_map(frame, &TypeMap::latest()) +} + +pub(crate) fn parse_frame_value_with_type_map( + frame: &[u8], + type_map: &TypeMap, +) -> Result { + let comm = CommunicationValue::from_bytes_with(frame, type_map) + .map_err(|e| js_error(format!("parse failed: {}", e)))?; + let tm = type_map; + let obj = js_sys::Object::new(); + + if let Some(id) = comm.id() { + set_prop(&obj, "id", &JsValue::from_f64(id as f64))?; } let frame_type = tm @@ -205,29 +304,22 @@ pub(crate) fn parse_frame_value(frame: &[u8]) -> Result { .unwrap_or_else(|| comm.get_type().0.to_string()); set_prop(&obj, "type", &JsValue::from_str(&frame_type))?; - if comm.get_sender() != 0 { + if let Some(sender) = comm.sender() { set_prop( &obj, "sender", - &JsValue::bigint_from_str(&comm.get_sender().to_string()), + &JsValue::bigint_from_str(&sender.to_string()), )?; } - if comm.get_receiver() != 0 { + if let Some(receiver) = comm.receiver() { set_prop( &obj, "receiver", - &JsValue::bigint_from_str(&comm.get_receiver().to_string()), + &JsValue::bigint_from_str(&receiver.to_string()), )?; } - for (key, value) in comm.data() { - let name = tm - .data_type_name(key.0) - .map(str::to_string) - .unwrap_or_else(|| key.0.to_string()); - set_prop(&data, &name, &data_value_to_js(value, &tm)?)?; - } - set_prop(&obj, "data", &data.into())?; + set_prop(&obj, "data", &data_value_to_js(comm.payload(), &tm)?)?; set_prop(&obj, "raw", &js_sys::Uint8Array::from(frame).into())?; Ok(obj.into()) @@ -266,25 +358,30 @@ pub fn parse_auth_response(response: &[u8]) -> Result { let comm = CommunicationValue::from_bytes(response) .map_err(|e| js_error(format!("parse failed: {}", e)))?; - let connected = matches!(comm.get_data(DataType::Connected), DataValue::BoolTrue); + let connected = matches!( + comm.get_data(DataType::Connected), + Some(DataValue::BoolTrue) + ); let client_nonce = match comm.get_data(DataType::ClientNonce) { - DataValue::UnsignedNumber(n) => Some(*n), + Some(DataValue::UnsignedNumber(n)) => Some(*n), _ => None, }; let assigned_id = match comm.get_data(DataType::Id) { - DataValue::UnsignedNumber(n) => Some(*n as u64), + Some(DataValue::UnsignedNumber(n)) => { + Some(u64::try_from(*n).map_err(|_| js_error("assigned ID is out of range"))?) + } _ => None, }; let timestamp = match comm.get_data(DataType::Timestamp) { - DataValue::UnsignedNumber(n) => Some(*n), + Some(DataValue::UnsignedNumber(n)) => Some(*n), _ => None, }; let signature = match comm.get_data(DataType::Signature) { - DataValue::Bytes(b) => Some(b.clone()), + Some(DataValue::Bytes(b)) => Some(b.clone()), _ => None, }; @@ -332,6 +429,25 @@ pub fn parse_frame(frame: &[u8]) -> Result { parse_frame_value(frame) } +/// Parse a standalone serialized `DataValue` into the same structured form +/// used for frame payloads. Protected values remain opaque until the caller +/// explicitly opens and verifies them. +#[wasm_bindgen(unchecked_return_type = "ParsedDataValue")] +pub fn parse_data_value(value: &[u8]) -> Result { + let value = DataValue::from_bytes(value).ok_or_else(|| js_error("invalid DataValue"))?; + let tm = TypeMap::new(PROTOCOL_VERSION); + data_value_to_js(&value, &tm) +} + +/// Encode one standalone `DataValue` using the negotiated/current type map. +#[wasm_bindgen] +pub fn encode_data_value(value: JsValue) -> Result, JsValue> { + let tm = TypeMap::new(PROTOCOL_VERSION); + js_to_data_value(&value, &tm)? + .to_bytes() + .map_err(|e| js_error(format!("encode data value failed: {e}"))) +} + /// Build a typed MTP frame using generated communication/data type names. #[wasm_bindgen] pub fn build_frame( @@ -342,19 +458,7 @@ pub fn build_frame( let comm_type = CommunicationType::from_name(message_type) .ok_or_else(|| js_error(format!("unknown communication type: {message_type}")))?; let tm = TypeMap::new(PROTOCOL_VERSION); - let mut msg = CommunicationValue::new(comm_type); - - if !options.is_null() && !options.is_undefined() { - if let Some(id) = option_u32(&options, "id")? { - msg = msg.with_id(id); - } - if let Some(sender) = option_u64(&options, "sender")? { - msg = msg.with_sender(sender); - } - if let Some(receiver) = option_u64(&options, "receiver")? { - msg = msg.with_receiver(receiver); - } - } + let mut msg = apply_frame_options(CommunicationValue::new(comm_type), &options)?; if data.is_object() && !js_sys::Uint8Array::instanceof(&data) && !js_sys::Array::is_array(&data) { @@ -373,7 +477,9 @@ pub fn build_frame( tm.version )) })?; - msg = msg.add_data(id, js_to_data_value(&value, &tm)?); + msg = msg + .add_data(id, js_to_data_value(&value, &tm)?) + .map_err(|e| js_error(format!("add data failed: {e}")))?; } } else if !data.is_null() && !data.is_undefined() { return Err(js_error( @@ -385,10 +491,37 @@ pub fn build_frame( .map_err(|e| js_error(format!("encode failed: {}", e))) } +/// Build a typed MTP frame around a complete serialized `DataValue` payload. +/// +/// Unlike [`build_frame`], this does not interpret the payload as a clear data +/// container. It can therefore carry any value supported by the codec, +/// including signed and encrypted protection wrappers. +#[wasm_bindgen] +pub fn build_frame_with_payload( + message_type: &str, + serialized_payload: &[u8], + options: JsValue, +) -> Result, JsValue> { + let comm_type = CommunicationType::from_name(message_type) + .ok_or_else(|| js_error(format!("unknown communication type: {message_type}")))?; + let payload = DataValue::from_bytes(serialized_payload) + .ok_or_else(|| js_error("invalid serialized DataValue payload"))?; + let message = + apply_frame_options(CommunicationValue::new(comm_type), &options)?.with_payload(payload); + + message + .to_bytes() + .map_err(|e| js_error(format!("encode failed: {e}"))) +} + #[cfg(test)] #[cfg(target_arch = "wasm32")] mod tests { use super::*; + use mtp_codec::ProtectionPurpose; + use mtp_crypto::{ + Ed25519Signer, HybridKem, PublicKeyBundle, SignaturePqPublicKey, SignaturePublicKey, + }; use wasm_bindgen_test::*; #[wasm_bindgen_test] @@ -401,14 +534,14 @@ mod tests { cv.get_type(), CommunicationType::Ping.try_to_id(&tm).unwrap() ); - assert_eq!(cv.get_sender(), 42); + assert_eq!(cv.sender(), Some(42)); assert_eq!( cv.get_data(DataType::Description), - &DataValue::Str("test-ping".into()) + Some(&DataValue::Str("test-ping".into())) ); assert_eq!( cv.get_data(DataType::Timestamp), - &DataValue::UnsignedNumber(1234567890) + Some(&DataValue::UnsignedNumber(1234567890)) ); } @@ -423,18 +556,18 @@ mod tests { cv.get_type(), CommunicationType::Ping.try_to_id(&tm).unwrap() ); - assert_eq!(cv.get_sender(), 99); + assert_eq!(cv.sender(), Some(99)); assert_eq!( cv.get_data(DataType::Description), - &DataValue::Str("with-data".into()) + Some(&DataValue::Str("with-data".into())) ); assert_eq!( cv.get_data(DataType::Timestamp), - &DataValue::UnsignedNumber(555) + Some(&DataValue::UnsignedNumber(555)) ); assert_eq!( cv.get_data(DataType::Id), - &DataValue::Bytes(payload.to_vec()) + Some(&DataValue::Bytes(payload.to_vec())) ); } @@ -442,7 +575,273 @@ mod tests { fn build_ping_frame_client_id_zero() { let bytes = build_ping_frame(0, "zero-id", 0, &[]).expect("encode failed"); let cv = CommunicationValue::from_bytes(&bytes).expect("decode failed"); - assert_eq!(cv.get_sender(), 0); + assert_eq!(cv.sender(), Some(0)); + } + + #[wasm_bindgen_test] + fn parse_frame_preserves_a_generic_payload() { + let bytes = CommunicationValue::new(CommunicationType::Pong) + .with_payload(DataValue::Bytes(vec![1, 2, 3])) + .to_bytes() + .expect("encode failed"); + + let parsed = parse_frame_value(&bytes).expect("parse failed"); + let data = js_sys::Reflect::get(&parsed, &JsValue::from_str("data")) + .expect("data should be present"); + assert_eq!(js_sys::Uint8Array::new(&data).to_vec(), vec![1, 2, 3]); + } + + #[wasm_bindgen_test] + fn integer_data_values_round_trip_without_losing_numeric_type() { + let tm = TypeMap::latest(); + let cases = [ + (DataValue::UnsignedNumber(9_007_199_254_740_991), "number"), + (DataValue::UnsignedNumber(9_007_199_254_740_992), "bigint"), + (DataValue::SignedNumber(-9_007_199_254_740_992), "bigint"), + (DataValue::SignedNumber(i128::MIN), "bigint"), + (DataValue::UnsignedNumber(u128::from(u64::MAX)), "bigint"), + ]; + + for (original, expected_type) in cases { + let javascript = data_value_to_js(&original, &tm).expect("decode value"); + assert_eq!( + javascript.js_typeof().as_string().as_deref(), + Some(expected_type) + ); + assert_eq!( + js_to_data_value(&javascript, &tm).expect("encode value"), + original + ); + } + } + + #[wasm_bindgen_test] + fn parse_frame_preserves_signed_value_structure() { + let (signer, _secret_key, _public_key) = Ed25519Signer::generate(); + let signed = DataValue::Str("signed payload".into()) + .sign(0xfeed_beef, ProtectionPurpose::from(7), &signer) + .expect("signing failed"); + let bytes = CommunicationValue::new(CommunicationType::Pong) + .with_payload(signed) + .to_bytes() + .expect("encode failed"); + + let parsed = parse_frame_value(&bytes).expect("parse failed"); + let data = js_sys::Reflect::get(&parsed, &"data".into()).expect("data should be present"); + assert_eq!( + js_sys::Reflect::get(&data, &"kind".into()) + .expect("kind should be present") + .as_string() + .as_deref(), + Some("signed") + ); + assert_eq!( + js_sys::Reflect::get(&data, &"purpose".into()) + .expect("purpose should be present") + .as_f64(), + Some(7.0) + ); + let signer_id = js_sys::Reflect::get(&data, &"signerId".into()) + .expect("signerId should be present") + .unchecked_into::() + .to_string(10) + .expect("signerId should stringify") + .as_string(); + assert_eq!(signer_id.as_deref(), Some("4276993775")); + assert_eq!( + js_sys::Reflect::get(&data, &"value".into()) + .expect("value should be present") + .as_string() + .as_deref(), + Some("signed payload") + ); + } + + #[wasm_bindgen_test] + fn parse_frame_keeps_encrypted_contents_private() { + let (_secret_key, public_key) = HybridKem::generate_keypair(); + let recipient = PublicKeyBundle::new( + public_key, + SignaturePqPublicKey::new(Vec::new()), + SignaturePublicKey::new(Vec::new()), + ); + let encrypted = DataValue::Str("secret payload".into()) + .encrypt_for(&[recipient], ProtectionPurpose::from(9)) + .expect("encryption failed"); + let encoded = encrypted.to_bytes().expect("encode protected value failed"); + let bytes = CommunicationValue::new(CommunicationType::Pong) + .with_payload(encrypted) + .to_bytes() + .expect("encode failed"); + + let parsed = parse_frame_value(&bytes).expect("parse failed"); + let data = js_sys::Reflect::get(&parsed, &"data".into()).expect("data should be present"); + assert_eq!( + js_sys::Reflect::get(&data, &"kind".into()) + .expect("kind should be present") + .as_string() + .as_deref(), + Some("encrypted") + ); + assert_eq!( + js_sys::Reflect::get(&data, &"recipientCount".into()) + .expect("recipientCount should be present") + .as_f64(), + Some(1.0) + ); + assert!(!js_sys::Reflect::has(&data, &"value".into()).unwrap_or(false)); + assert_eq!( + js_sys::Reflect::get(&data, &"encoded".into()) + .expect("encoded should be present") + .unchecked_into::() + .to_vec(), + encoded + ); + } + + #[wasm_bindgen_test] + fn parse_frame_preserves_signed_encrypted_composition() { + let (signer, _secret_key, _public_key) = Ed25519Signer::generate(); + let (_kem_secret_key, kem_public_key) = HybridKem::generate_keypair(); + let recipient = PublicKeyBundle::new( + kem_public_key, + SignaturePqPublicKey::new(Vec::new()), + SignaturePublicKey::new(Vec::new()), + ); + let encrypted = DataValue::Container(vec![( + mtp_type_map::DataTypeId(32), + DataValue::Str("secret payload".into()), + )]) + .encrypt_for(&[recipient], ProtectionPurpose::from(9)) + .expect("encryption failed"); + let encrypted_bytes = encrypted.to_bytes().expect("encrypted value should encode"); + let signed = encrypted + .sign(0x0102_0304_0506_0708, ProtectionPurpose::from(7), &signer) + .expect("signing failed"); + let frame = CommunicationValue::new(CommunicationType::Pong) + .with_payload(signed) + .to_bytes() + .expect("frame should encode"); + + let parsed = parse_frame_value(&frame).expect("frame should parse"); + let signed = js_sys::Reflect::get(&parsed, &"data".into()) + .expect("signed payload should be present"); + assert_eq!( + js_sys::Reflect::get(&signed, &"kind".into()) + .expect("signed kind should be present") + .as_string() + .as_deref(), + Some("signed") + ); + let encrypted = js_sys::Reflect::get(&signed, &"value".into()) + .expect("encrypted inner value should be present"); + assert_eq!( + js_sys::Reflect::get(&encrypted, &"kind".into()) + .expect("encrypted kind should be present") + .as_string() + .as_deref(), + Some("encrypted") + ); + assert_eq!( + js_sys::Reflect::get(&encrypted, &"encoded".into()) + .expect("encrypted encoding should be present") + .unchecked_into::() + .to_vec(), + encrypted_bytes + ); + } + + #[wasm_bindgen_test] + fn frame_ids_use_bigints_without_lossy_number_casts() { + let options = js_sys::Object::new(); + js_sys::Reflect::set( + &options, + &"sender".into(), + &JsValue::bigint_from_str("18446744073709551615"), + ) + .expect("sender option should be set"); + let bytes = build_frame("Pong", JsValue::NULL, options.into()).expect("build failed"); + let frame = CommunicationValue::from_bytes(&bytes).expect("decode failed"); + assert_eq!(frame.sender(), Some(u64::MAX)); + + let unsafe_number = js_sys::Object::new(); + js_sys::Reflect::set( + &unsafe_number, + &"sender".into(), + &JsValue::from_f64(MAX_SAFE_INT + 1.0), + ) + .expect("sender option should be set"); + assert!(build_frame("Pong", JsValue::NULL, unsafe_number.into()).is_err()); + } + + #[wasm_bindgen_test] + fn build_frame_with_payload_preserves_clear_and_protected_payloads() { + let (signer, _secret_key, _public_key) = Ed25519Signer::generate(); + let (_kem_secret_key, kem_public_key) = HybridKem::generate_keypair(); + let recipient = PublicKeyBundle::new( + kem_public_key, + SignaturePqPublicKey::new(Vec::new()), + SignaturePublicKey::new(Vec::new()), + ); + let clear = DataValue::Str("generic protected payload".into()); + let signed = clear + .clone() + .sign(0x0102_0304_0506_0708, ProtectionPurpose::from(7), &signer) + .expect("signing failed"); + let encrypted = clear + .clone() + .encrypt_for(&[recipient.clone()], ProtectionPurpose::from(9)) + .expect("encryption failed"); + let signed_encrypted = signed + .clone() + .encrypt_for(&[recipient], ProtectionPurpose::from(9)) + .expect("signed encryption failed"); + + for payload in [clear, signed, encrypted, signed_encrypted] { + let serialized = payload.to_bytes().expect("payload encoding failed"); + let frame = build_frame_with_payload("Pong", &serialized, JsValue::NULL) + .expect("frame encoding failed"); + let decoded = CommunicationValue::from_bytes(&frame).expect("frame decoding failed"); + assert_eq!( + decoded + .payload() + .to_bytes() + .expect("payload re-encoding failed"), + serialized + ); + } + } + + #[wasm_bindgen_test] + fn build_frame_with_payload_applies_frame_options() { + let payload = DataValue::Str("payload".into()) + .to_bytes() + .expect("payload encoding failed"); + let options = js_sys::Object::new(); + js_sys::Reflect::set(&options, &"id".into(), &JsValue::from_f64(17.0)) + .expect("id option should be set"); + js_sys::Reflect::set( + &options, + &"sender".into(), + &JsValue::bigint_from_str("18446744073709551615"), + ) + .expect("sender option should be set"); + js_sys::Reflect::set(&options, &"receiver".into(), &JsValue::from_f64(23.0)) + .expect("receiver option should be set"); + + let frame = build_frame_with_payload("Pong", &payload, options.into()) + .expect("frame encoding failed"); + let decoded = CommunicationValue::from_bytes(&frame).expect("frame decoding failed"); + assert_eq!(decoded.id(), Some(17)); + assert_eq!(decoded.sender(), Some(u64::MAX)); + assert_eq!(decoded.receiver(), Some(23)); + assert_eq!( + decoded + .payload() + .to_bytes() + .expect("payload re-encoding failed"), + payload + ); } #[wasm_bindgen_test] diff --git a/wasm/src/lib.rs b/wasm/src/lib.rs index abf79e7..83e651d 100644 --- a/wasm/src/lib.rs +++ b/wasm/src/lib.rs @@ -7,11 +7,14 @@ pub mod error; pub mod frame; pub mod logging; pub mod pipe; +pub mod protected; +pub mod relay; pub mod subscription; pub mod transport; pub use client::WasmClient; pub use config::{ConnectionConfig, WasmClientConfig}; +pub use crypto::{decrypt_data_value, encrypt_data_value, encrypt_data_value_for_recipients}; #[cfg(not(test))] use wasm_bindgen::prelude::*; diff --git a/wasm/src/protected.rs b/wasm/src/protected.rs new file mode 100644 index 0000000..d78f643 --- /dev/null +++ b/wasm/src/protected.rs @@ -0,0 +1,434 @@ +use wasm_bindgen::prelude::*; + +use mtp_codec::{ + DataValue, ProtectedError, ProtectedMessageBuilder, ProtectionError, ProtectionPurpose, + VerifiedProtectedMessage, +}; + +use crate::crypto::{ + keyrings_from_js, protection_policy_from_suite, public_key_bundles_from_js, + relay_signer_from_keyring, +}; +use crate::relay::{decode_frame, structured_error}; + +const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0; + +fn optional_u64(value: &JsValue, name: &str) -> Result, JsValue> { + if value.is_null() || value.is_undefined() { + return Ok(None); + } + if let Some(number) = value.as_f64() { + if !number.is_finite() + || number.fract() != 0.0 + || !(0.0..=MAX_SAFE_INTEGER).contains(&number) + { + return Err(structured_error( + "invalid-option", + format!("{name} must be an exact non-negative integer"), + )); + } + return Ok(Some(number as u64)); + } + if value.js_typeof().as_string().as_deref() == Some("bigint") { + let bigint = value.clone().unchecked_into::(); + let text = bigint.to_string(10)?.as_string().ok_or_else(|| { + structured_error("invalid-option", format!("failed to stringify {name}")) + })?; + return text + .parse::() + .map(Some) + .map_err(|_| structured_error("invalid-option", format!("{name} is out of range"))); + } + Err(structured_error( + "invalid-option", + format!("{name} must be a number or bigint"), + )) +} + +fn decode_data_value(value: &[u8]) -> Result { + DataValue::from_bytes(value) + .ok_or_else(|| structured_error("invalid-data-value", "invalid DataValue")) +} + +pub(crate) fn protected_error(error: ProtectedError) -> JsValue { + let code = protected_error_code(&error); + let value = structured_error(code, format!("protected opening failed: {error}")); + if let ProtectedError::UnsupportedProtectedVersion(version) = &error { + let _ = js_sys::Reflect::set( + &value, + &JsValue::from_str("protectedVersion"), + &JsValue::bigint_from_str(&version.to_string()), + ); + } + if let ProtectedError::ReservedApplicationType(application_type) = &error { + let _ = js_sys::Reflect::set( + &value, + &JsValue::from_str("applicationType"), + &JsValue::from_str(application_type), + ); + } + value +} + +fn protected_error_code(error: &ProtectedError) -> &'static str { + match error { + ProtectedError::NotApplicationFrame => "not-application-frame", + ProtectedError::MissingReceiver => "missing-receiver", + ProtectedError::PayloadNotEncrypted => "payload-not-encrypted", + ProtectedError::PayloadNotSigned => "payload-not-signed", + ProtectedError::MissingEnvelope => "missing-envelope", + ProtectedError::InvalidLayout(_) => "invalid-layout", + ProtectedError::MissingProtectedVersion => "missing-protected-version", + ProtectedError::UnsupportedProtectedVersion(_) => "unsupported-protected-version", + ProtectedError::MessageTypeMismatch => "message-type-mismatch", + ProtectedError::FinalRecipientMismatch => "final-recipient-mismatch", + ProtectedError::SenderMismatch => "sender-id-mismatch", + ProtectedError::ExpectedReceiverMismatch => "receiver-id-mismatch", + ProtectedError::ReservedApplicationType(_) => "reserved-application-type", + ProtectedError::Replay => "replay", + ProtectedError::ReplayGuard(_) => "replay-guard-error", + ProtectedError::Protection(error) => match error { + ProtectionError::NoMatchingRecipient => "no-matching-recipient", + ProtectionError::InvalidSignature => "invalid-signature", + ProtectionError::SignaturePolicyMismatch { .. } => "signature-policy-mismatch", + ProtectionError::PurposeMismatch { .. } => "purpose-mismatch", + ProtectionError::SignerIdMismatch { .. } => "signer-id-mismatch", + ProtectionError::SignerKeyNotFound(_) => "signer-key-not-found", + ProtectionError::Crypto(mtp_crypto::CryptoError::InvalidSignature) + | ProtectionError::Crypto(mtp_crypto::CryptoError::VerificationFailed) => { + "invalid-signature" + } + _ => "protection-error", + }, + } +} + +fn serialize_data_value(value: &DataValue) -> Result, JsValue> { + value.to_bytes().map_err(|error| { + structured_error( + "invalid-data-value", + format!("protected value encoding failed: {error}"), + ) + }) +} + +fn serialize_frame(frame: &mtp_codec::CommunicationValue) -> Result, JsValue> { + frame.to_bytes().map_err(|error| { + structured_error( + "invalid-frame", + format!("protected frame encoding failed: {error}"), + ) + }) +} + +#[wasm_bindgen] +pub struct WasmVerifiedProtectedMessage { + inner: VerifiedProtectedMessage, +} + +#[wasm_bindgen] +impl WasmVerifiedProtectedMessage { + pub fn protected_version(&self) -> u64 { + self.inner.protected_version + } + + pub fn signer_id(&self) -> u64 { + self.inner.signer_id + } + + pub fn final_recipient_id(&self) -> u64 { + self.inner.final_recipient_id + } + + pub fn message_id(&self) -> String { + self.inner.message_id.clone() + } + + pub fn created_at(&self) -> u64 { + self.inner.created_at + } + + pub fn message_type(&self) -> String { + self.inner.message_type.clone() + } + + pub fn content(&self) -> Result, JsValue> { + serialize_data_value(&self.inner.content) + } + + pub fn matched_signer_key_index(&self) -> usize { + self.inner.matched_signer_key_index + } +} + +/// Build a complete encrypted direct protected frame in the native codec. +/// The native builder owns both the protected envelope and the clear outer +/// routing fields, including the optional sender exposure and frame ID. +#[wasm_bindgen] +#[allow(clippy::too_many_arguments)] +pub fn build_protected_frame_with_keyring( + message_type: &str, + encoded_content: &[u8], + signer_id: u64, + final_recipient_id: u64, + message_id: &str, + created_at: u64, + signature_purpose: u8, + encryption_purpose: u8, + keyring_bytes: &[u8], + signature_suite: u8, + frame_id: Option, + expose_sender: bool, + recipient_public_key_bundles: JsValue, +) -> Result, JsValue> { + let content = decode_data_value(encoded_content)?; + let keyring = mtp_crypto::Keyring::from_bytes(keyring_bytes).map_err(|error| { + structured_error( + "invalid-keyring", + format!("keyring initialization failed: {error}"), + ) + })?; + let signer = relay_signer_from_keyring(&keyring, signature_suite)?; + let recipients = public_key_bundles_from_js(&recipient_public_key_bundles)?; + let mut builder = ProtectedMessageBuilder::new( + message_type, + content, + signer_id, + final_recipient_id, + &signer, + ProtectionPurpose::from(signature_purpose), + ProtectionPurpose::from(encryption_purpose), + ) + .message_id(message_id) + .created_at(created_at) + .recipients(recipients) + .expose_sender(expose_sender); + if let Some(frame_id) = frame_id { + builder = builder.frame_id(frame_id); + } + let frame = builder.build().map_err(protected_error)?; + serialize_frame(&frame) +} + +/// Read the claimed, unverified signer ID after decrypting the protected +/// payload. The result may only select trusted keys for the same signer ID. +#[wasm_bindgen] +pub fn protected_claimed_signer_id( + frame: &[u8], + keyrings: JsValue, + encryption_purpose: u8, +) -> Result { + let frame = decode_frame(frame)?; + let keyrings = keyrings_from_js(&keyrings).map_err(|error| { + structured_error( + "invalid-recipient-keyrings", + error.as_string().unwrap_or_default(), + ) + })?; + let references: Vec<&mtp_crypto::Keyring> = keyrings.iter().collect(); + mtp_codec::protected_claimed_signer_id( + &frame, + &references, + ProtectionPurpose::from(encryption_purpose), + ) + .map_err(protected_error) +} + +/// Open and verify a direct protected message in the native codec using +/// trusted signer-key history supplied by the SDK. +#[wasm_bindgen] +pub fn open_protected_with_keyrings( + frame: &[u8], + keyrings: JsValue, + expected_signer_id: JsValue, + signer_public_key_bundles: JsValue, + expected_receiver_id: JsValue, + signature_purpose: u8, + encryption_purpose: u8, + signature_suite: u8, +) -> Result { + let frame = decode_frame(frame)?; + let keyrings = keyrings_from_js(&keyrings).map_err(|error| { + structured_error( + "invalid-recipient-keyrings", + error.as_string().unwrap_or_default(), + ) + })?; + let references: Vec<&mtp_crypto::Keyring> = keyrings.iter().collect(); + let signer_public_keys = + public_key_bundles_from_js(&signer_public_key_bundles).map_err(|error| { + structured_error("invalid-signer-keys", error.as_string().unwrap_or_default()) + })?; + let expected_signer_id = optional_u64(&expected_signer_id, "expectedSignerId")? + .ok_or_else(|| structured_error("invalid-option", "expectedSignerId is required"))?; + let expected_receiver_id = optional_u64(&expected_receiver_id, "expectedReceiverId")?; + let policy = protection_policy_from_suite(signature_suite).map_err(|error| { + structured_error( + "unsupported-signature-suite", + error.as_string().unwrap_or_default(), + ) + })?; + let message = mtp_codec::open_protected_with_keys( + &frame, + &references, + expected_signer_id, + &signer_public_keys, + expected_receiver_id, + ProtectionPurpose::from(signature_purpose), + ProtectionPurpose::from(encryption_purpose), + policy, + None, + ) + .map_err(protected_error)?; + Ok(WasmVerifiedProtectedMessage { inner: message }) +} + +#[cfg(all(test, target_arch = "wasm32"))] +mod tests { + use super::*; + use crate::crypto::relay_signer_from_keyring; + use mtp_codec::{CommunicationType, CommunicationValue, DataType, TypeMap}; + use wasm_bindgen::JsCast; + use wasm_bindgen_test::*; + + const SIGNATURE_PURPOSE: u8 = 0x40; + const ENCRYPTION_PURPOSE: u8 = 0x41; + + fn structured_error_code(error: JsValue) -> String { + js_sys::Reflect::get(&error, &JsValue::from_str("code")) + .expect("structured error code") + .as_string() + .expect("structured error code string") + } + + fn protected_frame_with_version( + sender: &mtp_crypto::Keyring, + recipient: &mtp_crypto::Keyring, + version: Option, + ) -> Vec { + let type_map = TypeMap::latest(); + let field = |data_type: DataType| data_type.try_to_id(&type_map).expect("field mapping"); + let mut fields = Vec::new(); + if let Some(version) = version { + fields.push(( + field(DataType::ProtectedVersion), + DataValue::UnsignedNumber(version), + )); + } + fields.extend([ + ( + field(DataType::MessageType), + DataValue::Str("ProtectedMessage".into()), + ), + ( + field(DataType::FinalRecipientId), + DataValue::UnsignedNumber(42), + ), + ( + field(DataType::MessageId), + DataValue::Str("wasm-structured-error".into()), + ), + (field(DataType::CreatedAt), DataValue::UnsignedNumber(123)), + (field(DataType::Content), DataValue::Str("hello".into())), + ]); + let signer = relay_signer_from_keyring(sender, 1).expect("Ed25519 signer"); + let signed = DataValue::Container(fields) + .sign(7, ProtectionPurpose::from(SIGNATURE_PURPOSE), &signer) + .expect("sign protected envelope"); + let encrypted = signed + .encrypt_for( + &[recipient.public_key_bundle()], + ProtectionPurpose::from(ENCRYPTION_PURPOSE), + ) + .expect("encrypt protected envelope"); + CommunicationValue::new_with_type_map( + CommunicationType::from_name("ProtectedMessage").expect("application type"), + &type_map, + ) + .with_receiver(42) + .with_payload(encrypted) + .to_bytes() + .expect("encode protected frame") + } + + fn open_for_error( + frame: &[u8], + sender: &mtp_crypto::Keyring, + recipient: &mtp_crypto::Keyring, + ) -> JsValue { + let recipient_bytes = recipient.to_bytes(); + let signer_bundle_bytes = sender.public_key_bundle().as_bytes(); + match open_protected_with_keyrings( + frame, + js_sys::Uint8Array::from(&recipient_bytes[..]).into(), + JsValue::bigint_from_str("7"), + js_sys::Uint8Array::from(&signer_bundle_bytes[..]).into(), + JsValue::bigint_from_str("42"), + SIGNATURE_PURPOSE, + ENCRYPTION_PURPOSE, + 1, + ) { + Ok(_) => panic!("protected opening should fail"), + Err(error) => error, + } + } + + #[wasm_bindgen_test] + fn protected_builder_returns_the_complete_frame() { + let sender = mtp_crypto::Keyring::generate(); + let recipient = mtp_crypto::Keyring::generate(); + let sender_bytes = sender.to_bytes(); + let recipient_bundle_bytes = recipient.public_key_bundle().as_bytes(); + let content = DataValue::Str("complete-frame".into()) + .to_bytes() + .expect("encode content"); + let frame = build_protected_frame_with_keyring( + "ProtectedMessage", + &content, + 7, + 42, + "wasm-complete-frame", + 123, + SIGNATURE_PURPOSE, + ENCRYPTION_PURPOSE, + &sender_bytes, + 1, + Some(19), + true, + js_sys::Uint8Array::from(&recipient_bundle_bytes[..]).into(), + ) + .expect("build complete protected frame"); + let decoded = CommunicationValue::from_bytes(&frame).expect("decode complete frame"); + assert_eq!(decoded.id(), Some(19)); + assert_eq!(decoded.sender(), Some(7)); + assert_eq!(decoded.receiver(), Some(42)); + assert!(decoded.payload().as_encrypted().is_some()); + } + + #[wasm_bindgen_test] + fn protected_opening_maps_missing_and_unsupported_versions() { + let sender = mtp_crypto::Keyring::generate(); + let recipient = mtp_crypto::Keyring::generate(); + let missing = protected_frame_with_version(&sender, &recipient, None); + assert_eq!( + structured_error_code(open_for_error(&missing, &sender, &recipient)), + "missing-protected-version" + ); + + let unsupported = protected_frame_with_version(&sender, &recipient, Some(2)); + let error = open_for_error(&unsupported, &sender, &recipient); + assert_eq!( + structured_error_code(error.clone()), + "unsupported-protected-version" + ); + let version = js_sys::Reflect::get(&error, &JsValue::from_str("protectedVersion")) + .expect("protected version"); + let version = version + .unchecked_into::() + .to_string(10) + .expect("protected version string") + .as_string() + .expect("protected version text"); + assert_eq!(version, "2"); + } +} diff --git a/wasm/src/relay.rs b/wasm/src/relay.rs new file mode 100644 index 0000000..733303d --- /dev/null +++ b/wasm/src/relay.rs @@ -0,0 +1,267 @@ +use wasm_bindgen::prelude::*; + +use mtp_codec::{ + CommunicationValue, DataValue, ProtectionError, RelayError, VerifiedRelayContent, + VerifiedRelayMetadata, +}; + +use crate::crypto::{keyrings_from_js, protection_policy_from_suite, public_key_bundles_from_js}; + +const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0; + +pub(crate) fn structured_error(code: &str, message: impl Into) -> JsValue { + let error = js_sys::Error::new(&message.into()); + let value: JsValue = error.into(); + let _ = js_sys::Reflect::set(&value, &JsValue::from_str("code"), &JsValue::from_str(code)); + value +} + +fn wrapped_input_error(code: &str, error: JsValue) -> JsValue { + let message = error + .as_string() + .unwrap_or_else(|| "invalid relay operation input".to_owned()); + structured_error(code, message) +} + +pub(crate) fn relay_error(error: mtp_codec::RelayError) -> JsValue { + let code = relay_error_code(&error); + let message = format!("relay opening failed: {error}"); + let value = structured_error(code, message); + if let RelayError::UnsupportedRelayVersion(version) = &error { + let _ = js_sys::Reflect::set( + &value, + &JsValue::from_str("relayVersion"), + &JsValue::bigint_from_str(&version.to_string()), + ); + } + if let RelayError::ReservedApplicationType(application_type) = &error { + let _ = js_sys::Reflect::set( + &value, + &JsValue::from_str("applicationType"), + &JsValue::from_str(application_type), + ); + } + value +} + +fn relay_error_code(error: &RelayError) -> &'static str { + match error { + RelayError::NotRelay => "not-relay", + RelayError::OuterSenderPresent => "outer-sender-present", + RelayError::MissingNextHop => "missing-next-hop", + RelayError::InvalidLayout(_) => "invalid-layout", + RelayError::MissingRelayVersion => "missing-relay-version", + RelayError::UnsupportedRelayVersion(_) => "unsupported-relay-version", + RelayError::NotFinalRecipient => "not-final-recipient", + RelayError::Replay => "replay", + RelayError::ReservedApplicationType(_) => "reserved-application-type", + RelayError::ReplayGuard(_) => "replay-guard-error", + RelayError::Protection(error) => match error { + ProtectionError::NoMatchingRecipient => "no-matching-recipient", + ProtectionError::InvalidSignature => "invalid-signature", + ProtectionError::SignaturePolicyMismatch { .. } => "signature-policy-mismatch", + ProtectionError::PurposeMismatch { .. } => "purpose-mismatch", + ProtectionError::SignerIdMismatch { .. } => "signer-id-mismatch", + ProtectionError::SignerKeyNotFound(_) => "signer-key-not-found", + ProtectionError::Crypto(mtp_crypto::CryptoError::InvalidSignature) + | ProtectionError::Crypto(mtp_crypto::CryptoError::VerificationFailed) => { + "invalid-signature" + } + _ => "protection-error", + }, + } +} + +pub(crate) fn decode_frame(frame: &[u8]) -> Result { + CommunicationValue::from_bytes(frame).map_err(|error| { + structured_error( + "invalid-frame", + format!("relay frame decoding failed: {error}"), + ) + }) +} + +fn optional_u64(value: &JsValue, name: &str) -> Result, JsValue> { + if value.is_null() || value.is_undefined() { + return Ok(None); + } + if let Some(number) = value.as_f64() { + if !number.is_finite() + || number.fract() != 0.0 + || !(0.0..=MAX_SAFE_INTEGER).contains(&number) + { + return Err(structured_error( + "invalid-option", + format!("{name} must be an exact non-negative integer"), + )); + } + return Ok(Some(number as u64)); + } + if value.js_typeof().as_string().as_deref() == Some("bigint") { + let bigint = value.clone().unchecked_into::(); + let text = bigint.to_string(10)?.as_string().ok_or_else(|| { + structured_error("invalid-option", format!("failed to stringify {name}")) + })?; + return text + .parse::() + .map(Some) + .map_err(|_| structured_error("invalid-option", format!("{name} is out of range"))); + } + Err(structured_error( + "invalid-option", + format!("{name} must be a number or bigint"), + )) +} + +fn serialize_data_value(value: &DataValue) -> Result, JsValue> { + value.to_bytes().map_err(|error| { + structured_error( + "invalid-data-value", + format!("relay value encoding failed: {error}"), + ) + }) +} + +#[wasm_bindgen] +pub struct WasmVerifiedRelayMetadata { + inner: VerifiedRelayMetadata, +} + +#[wasm_bindgen] +impl WasmVerifiedRelayMetadata { + pub fn relay_version(&self) -> u64 { + self.inner.relay_version() + } + + pub fn signer_id(&self) -> u64 { + self.inner.signer_id() + } + + pub fn final_recipient_id(&self) -> u64 { + self.inner.final_recipient_id() + } + + pub fn message_id(&self) -> String { + self.inner.message_id().to_owned() + } + + pub fn created_at(&self) -> u64 { + self.inner.created_at() + } + + pub fn metadata(&self) -> Result { + match self.inner.metadata() { + Some(value) => { + let bytes = serialize_data_value(value)?; + Ok(js_sys::Uint8Array::from(&bytes[..]).into()) + } + None => Ok(JsValue::NULL), + } + } + + pub fn encrypted_content(&self) -> Result, JsValue> { + serialize_data_value(self.inner.encrypted_content()) + } + + pub fn matched_signer_key_index(&self) -> usize { + self.inner.matched_signer_key_index() + } +} + +#[wasm_bindgen] +pub struct WasmVerifiedRelayContent { + inner: VerifiedRelayContent, +} + +#[wasm_bindgen] +impl WasmVerifiedRelayContent { + pub fn signer_id(&self) -> u64 { + self.inner.signer_id + } + + pub fn final_recipient_id(&self) -> u64 { + self.inner.final_recipient_id + } + + pub fn message_type(&self) -> String { + self.inner.message_type.clone() + } + + pub fn content(&self) -> Result, JsValue> { + serialize_data_value(&self.inner.content) + } +} + +/// Read the claimed, unverified signer ID from a relay without duplicating the +/// versioned relay metadata parser in the JavaScript SDK. The caller must bind +/// this value as the expected signer during the subsequent verification call. +#[wasm_bindgen] +pub fn relay_metadata_claimed_signer_id(frame: &[u8], keyrings: JsValue) -> Result { + let frame = decode_frame(frame)?; + let keyrings = keyrings_from_js(&keyrings) + .map_err(|error| wrapped_input_error("invalid-recipient-keyrings", error))?; + let references: Vec<&mtp_crypto::Keyring> = keyrings.iter().collect(); + mtp_codec::relay_metadata_claimed_signer_id(&frame, &references).map_err(relay_error) +} + +/// Open and verify relay metadata in the native codec. JavaScript resolves +/// the trusted signing-key history before calling this function, while the +/// codec owns all relay layout and version interpretation. +#[wasm_bindgen] +pub fn open_relay_metadata_with_keyrings( + frame: &[u8], + keyrings: JsValue, + expected_signer_id: JsValue, + signer_public_key_bundles: JsValue, + signature_suite: u8, +) -> Result { + let frame = decode_frame(frame)?; + let keyrings = keyrings_from_js(&keyrings) + .map_err(|error| wrapped_input_error("invalid-recipient-keyrings", error))?; + let references: Vec<&mtp_crypto::Keyring> = keyrings.iter().collect(); + let signer_public_keys = public_key_bundles_from_js(&signer_public_key_bundles) + .map_err(|error| wrapped_input_error("invalid-signer-keys", error))?; + let expected_signer_id = optional_u64(&expected_signer_id, "expectedSignerId")? + .ok_or_else(|| structured_error("invalid-option", "expectedSignerId is required"))?; + let policy = protection_policy_from_suite(signature_suite) + .map_err(|error| wrapped_input_error("unsupported-signature-suite", error))?; + let metadata = mtp_codec::open_relay_metadata_with_keys( + &frame, + &references, + expected_signer_id, + &signer_public_keys, + policy, + ) + .map_err(relay_error)?; + Ok(WasmVerifiedRelayMetadata { inner: metadata }) +} + +/// Open and verify relay content in the native codec using recipient and +/// signer key histories supplied by the SDK. +#[wasm_bindgen] +pub fn open_relay_content_with_keyrings( + metadata: &WasmVerifiedRelayMetadata, + keyrings: JsValue, + signer_public_key_bundles: JsValue, + expected_final_recipient_id: JsValue, + signature_suite: u8, +) -> Result { + let keyrings = keyrings_from_js(&keyrings) + .map_err(|error| wrapped_input_error("invalid-recipient-keyrings", error))?; + let references: Vec<&mtp_crypto::Keyring> = keyrings.iter().collect(); + let signer_public_keys = public_key_bundles_from_js(&signer_public_key_bundles) + .map_err(|error| wrapped_input_error("invalid-signer-keys", error))?; + let expected_final_recipient_id = + optional_u64(&expected_final_recipient_id, "expectedFinalRecipientId")?; + let policy = protection_policy_from_suite(signature_suite) + .map_err(|error| wrapped_input_error("unsupported-signature-suite", error))?; + let content = mtp_codec::open_relay_content_with_keyrings( + &metadata.inner, + &references, + &signer_public_keys, + expected_final_recipient_id, + policy, + ) + .map_err(relay_error)?; + Ok(WasmVerifiedRelayContent { inner: content }) +} diff --git a/wasm/src/transport.rs b/wasm/src/transport.rs index 8e151a8..c8d4f76 100644 --- a/wasm/src/transport.rs +++ b/wasm/src/transport.rs @@ -1,12 +1,14 @@ use std::cell::{Cell, RefCell}; use std::rc::Rc; +use futures_util::lock::Mutex as AsyncMutex; use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::JsFuture; use crate::error::js_error; -use crate::frame::parse_frame_value; +use crate::frame::parse_frame_value_with_type_map; +use mtp_codec::TypeMap; const CLOSE_FRAME_LEN: u32 = u32::MAX; @@ -126,6 +128,11 @@ pub struct WasmTransport { buffer: Rc>>, /// Set to `true` when `open_next_stream` succeeds; cleared after the first frame is parsed. new_stream_frame: Rc>, + /// A single ordered browser send stream shared by all cloned transports. + outgoing_writer: Rc>>, + /// Serializes stream creation and writes across concurrent callers. + send_lock: Rc>, + type_map: Rc>, } impl WasmTransport { @@ -188,6 +195,9 @@ impl WasmTransport { stream_reader: Rc::new(RefCell::new(None)), buffer: Rc::new(RefCell::new(Vec::new())), new_stream_frame: Rc::new(Cell::new(false)), + outgoing_writer: Rc::new(RefCell::new(None)), + send_lock: Rc::new(AsyncMutex::new(())), + type_map: Rc::new(RefCell::new(TypeMap::latest())), }) } @@ -195,40 +205,48 @@ impl WasmTransport { &self.inner } + pub fn set_type_map(&self, type_map: &TypeMap) { + *self.type_map.borrow_mut() = type_map.clone(); + } + + pub fn type_map(&self) -> TypeMap { + self.type_map.borrow().clone() + } + pub async fn send_frame(&self, frame: &[u8]) -> Result<(), JsValue> { + let _send_guard = self.send_lock.lock().await; if frame.len() as u64 > self.max_message_size as u64 || frame.len() as u64 >= CLOSE_FRAME_LEN as u64 { return Err(js_error("message too large")); } - let create_stream = js_sys::Reflect::get( - &self.inner, - &JsValue::from_str("createUnidirectionalStream"), - )? - .dyn_into::() - .map_err(|_| js_error("createUnidirectionalStream not a function"))?; - let stream_promise = create_stream - .call0(&self.inner)? - .dyn_into::() - .map_err(|_| js_error("createUnidirectionalStream did not return a Promise"))?; - let stream = JsFuture::from(stream_promise).await?; - - let writable_or_stream = resolve_stream_writable(&stream)?; - - let writer_val = js_sys::Reflect::get(&writable_or_stream, &JsValue::from_str("getWriter")) - .map_err(|_| js_error("missing getWriter"))? + let writer_val = if let Some(writer) = self.outgoing_writer.borrow().clone() { + writer + } else { + let create_stream = js_sys::Reflect::get( + &self.inner, + &JsValue::from_str("createUnidirectionalStream"), + )? .dyn_into::() - .map_err(|_| js_error("getWriter not a function"))? - .call0(&writable_or_stream) - .map_err(|_| js_error("getWriter call failed"))?; + .map_err(|_| js_error("createUnidirectionalStream not a function"))?; + let stream_promise = create_stream + .call0(&self.inner)? + .dyn_into::() + .map_err(|_| js_error("createUnidirectionalStream did not return a Promise"))?; + let stream = JsFuture::from(stream_promise).await?; + let writable_or_stream = resolve_stream_writable(&stream)?; + let writer = js_sys::Reflect::get(&writable_or_stream, &JsValue::from_str("getWriter")) + .map_err(|_| js_error("missing getWriter"))? + .dyn_into::() + .map_err(|_| js_error("getWriter not a function"))? + .call0(&writable_or_stream) + .map_err(|_| js_error("getWriter call failed"))?; + *self.outgoing_writer.borrow_mut() = Some(writer.clone()); + writer + }; - let len = frame.len() as u32; - let mut wire = Vec::with_capacity(4 + frame.len()); - wire.extend_from_slice(&len.to_be_bytes()); - wire.extend_from_slice(frame); - - let chunk = js_sys::Uint8Array::from(&wire[..]); + let chunk = js_sys::Uint8Array::from(frame); let write_fn = js_sys::Reflect::get(&writer_val, &JsValue::from_str("write")) .map_err(|_| js_error("missing write"))? @@ -239,25 +257,11 @@ impl WasmTransport { .map_err(|e| js_error(format!("write failed: {:?}", e)))?; if let Err(e) = JsFuture::from(write_promise.unchecked_into::()).await { log_stream_error_code(&e, "send_frame write"); + self.outgoing_writer.borrow_mut().take(); release_writer_lock(&writer_val); return Err(e); } - let close_fn = js_sys::Reflect::get(&writer_val, &JsValue::from_str("close")) - .map_err(|_| js_error("missing close"))? - .dyn_into::() - .map_err(|_| js_error("close not a function"))?; - let close_promise = close_fn - .call0(&writer_val) - .map_err(|e| js_error(format!("close failed: {:?}", e)))?; - if let Err(e) = JsFuture::from(close_promise.unchecked_into::()).await { - // Write succeeded; STOP_SENDING on close just means peer stopped reading before FIN. - log_stream_error_code(&e, "send_frame close"); - } - - // Release the lock so the writer isn't treated as an abort. - release_writer_lock(&writer_val); - Ok(()) } @@ -366,21 +370,21 @@ impl WasmTransport { if buf.len() < 4 { return Ok(None); } - let frame_len = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]); - if frame_len == CLOSE_FRAME_LEN { + let body_len = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]); + if body_len == CLOSE_FRAME_LEN { return Ok(Some(FrameOutcome::Closed)); } + let frame_len = body_len + .checked_add(4) + .ok_or_else(|| js_error("invalid frame length"))?; if frame_len > max_message_size { return Err(js_error("message too large")); } - let frame_len = frame_len as usize; - let Some(frame_end) = 4usize.checked_add(frame_len) else { - return Err(js_error("invalid frame length")); - }; + let frame_end = frame_len as usize; if frame_end > buf.len() { return Ok(None); } - let frame = buf[4..frame_end].to_vec(); + let frame = buf[..frame_end].to_vec(); drop(buf); self.buffer.borrow_mut().drain(..frame_end); Ok(Some(FrameOutcome::Frame(frame))) @@ -456,15 +460,18 @@ impl WasmTransport { { loop { match self.next_frame(self.max_message_size).await { - Ok(FrameOutcome::Frame(frame)) => match parse_frame_value(&frame) { - Ok(parsed) => { - on_message(parsed); + Ok(FrameOutcome::Frame(frame)) => { + let type_map = self.type_map(); + match parse_frame_value_with_type_map(&frame, &type_map) { + Ok(parsed) => { + on_message(parsed); + } + Err(e) => { + let message = e.as_string().unwrap_or_else(|| format!("{:?}", e)); + let _ = on_error.call1(&JsValue::NULL, &JsValue::from_str(&message)); + } } - Err(e) => { - let message = e.as_string().unwrap_or_else(|| format!("{:?}", e)); - let _ = on_error.call1(&JsValue::NULL, &JsValue::from_str(&message)); - } - }, + } Ok(FrameOutcome::Closed) | Ok(FrameOutcome::Ended) => break, Err(e) => { let _ = on_error.call1(&JsValue::NULL, &e); @@ -487,19 +494,28 @@ impl WasmTransport { G: FnMut(crate::pipe::PipeReader), H: FnMut(JsValue), { - let pipe_request_type = - mtp_codec::CommunicationType::PipeRequest.try_to_id(&mtp_codec::TypeMap::latest()); - loop { match self.next_frame(self.max_message_size).await { Ok(FrameOutcome::Frame(frame)) => { + let type_map = self.type_map(); + let pipe_request_type = + mtp_codec::CommunicationType::PipeRequest.try_to_id(&type_map); + let pipe_response_type = + mtp_codec::CommunicationType::PipeResponse.try_to_id(&type_map); let is_first = self.new_stream_frame.get(); if is_first { self.new_stream_frame.set(false); - if let Ok(comm) = mtp_codec::CommunicationValue::from_bytes(&frame) + if let Ok(comm) = + mtp_codec::CommunicationValue::from_bytes_with(&frame, &type_map) && Some(comm.get_type()) == pipe_request_type { - let pipe_id = comm.get_id(); + let Some(pipe_id) = comm.id().filter(|id| *id != 0) else { + on_error(JsValue::from_str( + "PipeRequest frame must contain a non-zero id", + )); + self.close(); + break; + }; let description = comm .get_str(mtp_codec::DataType::Description) .unwrap_or("") @@ -523,7 +539,33 @@ impl WasmTransport { } } - match parse_frame_value(&frame) { + if let Ok(comm) = + mtp_codec::CommunicationValue::from_bytes_with(&frame, &type_map) + && Some(comm.get_type()) == pipe_response_type + && !matches!(comm.id(), Some(id) if id != 0) + { + on_error(JsValue::from_str( + "PipeResponse frame must contain a non-zero id", + )); + self.close(); + break; + } + + if let Ok(comm) = + mtp_codec::CommunicationValue::from_bytes_with(&frame, &type_map) + && !matches!(comm.id(), Some(id) if id != 0) + && comm + .get_type_name() + .is_some_and(|name| name.ends_with("Response")) + { + on_error(JsValue::from_str( + "response frame must contain a non-zero id", + )); + self.close(); + break; + } + + match parse_frame_value_with_type_map(&frame, &type_map) { Ok(parsed) => { on_message(parsed); } @@ -550,6 +592,7 @@ impl WasmTransport { pipe_id: u32, description: &str, ) -> Result { + let _send_guard = self.send_lock.lock().await; let create_stream = js_sys::Reflect::get( &self.inner, &JsValue::from_str("createUnidirectionalStream"), @@ -570,22 +613,21 @@ impl WasmTransport { .call0(&writable_or_stream) .map_err(|_| js_error("getWriter call failed"))?; - let request = mtp_codec::CommunicationValue::new(mtp_codec::CommunicationType::PipeRequest) - .with_id(pipe_id) - .add_typed_default( - mtp_codec::DataType::Description, - mtp_codec::DataValue::Str(description.to_string()), - ); + let type_map = self.type_map(); + let request = mtp_codec::CommunicationValue::new_with_type_map( + mtp_codec::CommunicationType::PipeRequest, + &type_map, + ) + .with_id(pipe_id) + .add_typed_default( + mtp_codec::DataType::Description, + mtp_codec::DataValue::Str(description.to_string()), + ); let frame_bytes = request .to_bytes() .map_err(|e| js_error(format!("encode failed: {}", e)))?; - let len = frame_bytes.len() as u32; - let mut wire = Vec::with_capacity(4 + frame_bytes.len()); - wire.extend_from_slice(&len.to_be_bytes()); - wire.extend_from_slice(&frame_bytes); - - let chunk = js_sys::Uint8Array::from(&wire[..]); + let chunk = js_sys::Uint8Array::from(&frame_bytes[..]); let write_fn = js_sys::Reflect::get(&writer_val, &JsValue::from_str("write")) .map_err(|_| js_error("missing write"))? .dyn_into::() @@ -603,6 +645,12 @@ impl WasmTransport { } pub fn close(&self) { + if let Some(writer) = self.outgoing_writer.borrow_mut().take() { + // The WebTransport session close below terminates the stream. The + // lock must be released first so dropping it is not interpreted as + // an application abort. + release_writer_lock(&writer); + } // Release reader locks before closing so they aren't treated as cancels. if let Some(reader) = self.stream_reader.borrow_mut().take() { release_reader_lock(&reader); diff --git a/wasm/types/mtp_wasm.d.ts b/wasm/types/mtp_wasm.d.ts index 58c3cda..59ff743 100644 --- a/wasm/types/mtp_wasm.d.ts +++ b/wasm/types/mtp_wasm.d.ts @@ -34,12 +34,40 @@ export interface ParsedResponse { [field: string]: unknown; } +export interface ParsedEncryptedValue { + kind: "encrypted"; + encryptionType: number; + purpose: number; + recipientCount: number; + encoded: Uint8Array; +} + +export interface ParsedSignedValue { + kind: "signed"; + signatureType: number; + purpose: number; + signerId: bigint; + value: ParsedDataValue; +} + +export type ParsedDataValue = + | boolean + | number + | bigint + | string + | Uint8Array + | ParsedDataValue[] + | { [key: string]: ParsedDataValue } + | ParsedEncryptedValue + | ParsedSignedValue + | null; + export interface ParsedFrame { id?: number; type: string; sender?: bigint; receiver?: bigint; - data: Record; + data: ParsedDataValue; raw: Uint8Array; } @@ -89,6 +117,13 @@ export class WasmEncapsulated implements DisposableWasmObject { readonly ciphertext: Uint8Array; } +export class WasmKemKeypair implements DisposableWasmObject { + readonly public_key: Uint8Array; + readonly secret_key: Uint8Array; + free(): void; + [Symbol.dispose](): void; +} + export class WasmClient implements DisposableWasmObject { constructor( on_state_change: StateChangeCallback, @@ -122,6 +157,7 @@ export class WasmClient implements DisposableWasmObject { unsubscribe(id: number): boolean; static is_supported(): boolean; readonly ping_ms: number | undefined; + readonly client_id: bigint; readonly state: ConnectionState; } @@ -140,6 +176,7 @@ export class WasmKeyring implements DisposableWasmObject { static from_bytes(bytes: Uint8Array): WasmKeyring; public_key_bundle(): WasmPublicKeyBundle; to_bytes(): Uint8Array; + validate_full(): void; } export class WasmPublicKeyBundle implements DisposableWasmObject { @@ -147,6 +184,7 @@ export class WasmPublicKeyBundle implements DisposableWasmObject { free(): void; [Symbol.dispose](): void; static from_bytes(bytes: Uint8Array): WasmPublicKeyBundle; + static from_bytes_unvalidated(bytes: Uint8Array): WasmPublicKeyBundle; to_bytes(): Uint8Array; readonly kem_public_key: Uint8Array; readonly sig_cl_public_key: Uint8Array; @@ -162,6 +200,44 @@ export class WasmSubscriptionRouter implements DisposableWasmObject { unsubscribe(message_type: string): boolean; } +export class WasmVerifiedProtectedMessage implements DisposableWasmObject { + private constructor(); + free(): void; + [Symbol.dispose](): void; + content(): Uint8Array; + created_at(): bigint; + final_recipient_id(): bigint; + matched_signer_key_index(): number; + message_id(): string; + message_type(): string; + protected_version(): bigint; + signer_id(): bigint; +} + +export class WasmVerifiedRelayContent implements DisposableWasmObject { + private constructor(); + free(): void; + [Symbol.dispose](): void; + content(): Uint8Array; + final_recipient_id(): bigint; + message_type(): string; + signer_id(): bigint; +} + +export class WasmVerifiedRelayMetadata implements DisposableWasmObject { + private constructor(); + free(): void; + [Symbol.dispose](): void; + created_at(): bigint; + encrypted_content(): Uint8Array; + final_recipient_id(): bigint; + message_id(): string; + metadata(): Uint8Array | null; + matched_signer_key_index(): number; + relay_version(): bigint; + signer_id(): bigint; +} + export function build_ping_frame( client_id: bigint, description: string, @@ -174,10 +250,98 @@ export function build_frame(message_type: string, data: Record, sender?: bigint | number; receiver?: bigint | number; }): Uint8Array; +export function build_frame_with_payload(message_type: string, serialized_payload: Uint8Array, options?: { + id?: number; + sender?: bigint | number; + receiver?: bigint | number; +}): Uint8Array; + +export function verify_data_value_with_policy(value: Uint8Array, public_key_bundle: Uint8Array, expected_signer_id: bigint, expected_purpose: number, signature_suite: number): void; +export function encrypt_data_value(value: Uint8Array, recipient_public_key_bundle: Uint8Array, purpose: number): Uint8Array; +export function encrypt_data_value_for_recipients(value: Uint8Array, recipient_public_key_bundles: Uint8Array | Uint8Array[], purpose: number): Uint8Array; +export function decrypt_data_value(value: Uint8Array, keyring: Uint8Array, expected_purpose: number): Uint8Array; +export function decrypt_data_value_with_keyrings(value: Uint8Array, keyrings: Uint8Array | Uint8Array[], expected_purpose: number): Uint8Array; +export function mtp_relay_metadata_encryption_purpose(): number; +export function mtp_relay_content_signature_purpose(): number; +export function mtp_relay_content_encryption_purpose(): number; +export function mtp_relay_metadata_signature_purpose(): number; +export function mtp_pipe_session_signature_purpose(): number; +export function mtp_pipe_session_encryption_purpose(): number; +export function mtp_protection_signature_suite_ed25519(): number; +export function mtp_protection_signature_suite_dual(): number; +export function open_relay_content_with_keyrings( + metadata: WasmVerifiedRelayMetadata, + keyrings: Uint8Array | Uint8Array[], + signer_public_key_bundles: Uint8Array | Uint8Array[], + expected_final_recipient_id: bigint | number | null, + signature_suite: number, +): WasmVerifiedRelayContent; +export function open_relay_metadata_with_keyrings( + frame: Uint8Array, + keyrings: Uint8Array | Uint8Array[], + expected_signer_id: bigint | number, + signer_public_key_bundles: Uint8Array | Uint8Array[], + signature_suite: number, +): WasmVerifiedRelayMetadata; +export function relay_metadata_claimed_signer_id( + frame: Uint8Array, + keyrings: Uint8Array | Uint8Array[], +): bigint; +export function forward_encrypted_relay_frame(frame: Uint8Array, next_hop_receiver_id: bigint): Uint8Array; +export function sign_data_value_with_keyring(value: Uint8Array, signer_id: bigint, purpose: number, keyring: Uint8Array, signature_suite: number): Uint8Array; +export function build_encrypted_relay_frame_with_keyring( + message_type: string, + data: unknown, + signer_id: bigint, + final_recipient_id: bigint, + next_hop_id: bigint, + message_id: string, + created_at: bigint, + encoded_metadata: Uint8Array | null | undefined, + keyring_bytes: Uint8Array, + signature_suite: number, + metadata_recipient_public_key_bundles: Uint8Array | Uint8Array[], + content_recipient_public_key_bundles: Uint8Array | Uint8Array[], +): Uint8Array; + +export function build_protected_frame_with_keyring( + message_type: string, + encoded_content: Uint8Array, + signer_id: bigint, + final_recipient_id: bigint, + message_id: string, + created_at: bigint, + signature_purpose: number, + encryption_purpose: number, + keyring_bytes: Uint8Array, + signature_suite: number, + frame_id: number | null | undefined, + expose_sender: boolean, + recipient_public_key_bundles: Uint8Array | Uint8Array[], +): Uint8Array; + +export function open_protected_with_keyrings( + frame: Uint8Array, + keyrings: Uint8Array | Uint8Array[], + expected_signer_id: bigint | number, + signer_public_key_bundles: Uint8Array | Uint8Array[], + expected_receiver_id: bigint | number | null, + signature_purpose: number, + encryption_purpose: number, + signature_suite: number, +): WasmVerifiedProtectedMessage; + +export function protected_claimed_signer_id( + frame: Uint8Array, + keyrings: Uint8Array | Uint8Array[], + encryption_purpose: number, +): bigint; export function ed25519_generate(): Ed25519GenerateResult; export function ed25519_verify(public_key: Uint8Array, message: Uint8Array, signature: Uint8Array): void; export function format_frame(frame: Uint8Array): string; +export function parse_data_value(value: Uint8Array): ParsedDataValue; +export function encode_data_value(value: unknown): Uint8Array; export function keyring_from_ed25519(secret_key: Uint8Array, public_key: Uint8Array): Uint8Array; export function keyring_generate(): Uint8Array; export function main(): void; @@ -187,6 +351,7 @@ export function wasm_derive_encryption_key(ikm: Uint8Array, salt: Uint8Array, co export function wasm_hkdf_expand(ikm: Uint8Array, salt: Uint8Array, info: Uint8Array, len: number): Uint8Array; export function wasm_kem_decapsulate(recipient_private_key: Uint8Array, ciphertext: Uint8Array): Uint8Array; export function wasm_kem_encapsulate(recipient_public_key: Uint8Array): WasmEncapsulated; +export function wasm_kem_generate_keypair(): WasmKemKeypair; export function wasm_sha256(data: Uint8Array): Uint8Array; export function wasm_sha256_double(data: Uint8Array): Uint8Array; diff --git a/web-client/public/host_public_key_bundle.hex b/web-client/public/host_public_key_bundle.hex deleted file mode 100644 index 7f61923..0000000 --- a/web-client/public/host_public_key_bundle.hex +++ /dev/null @@ -1 +0,0 @@ -04c001677af7e94c06766442fb751083c39e9897fd20988aa825676a380975771d4a904a8a86c3f4ad245a8948c64753a86fd147cd590084da1a80b28675ebe723447170a5b815ad077f8ea54be37391f992c5a0737eff13927a04b8b6ba72699346feb517640cb0d897c8020c40aa286b8772589746596782877de47f5a5556d42744b85b521c035bcbdc866b86483cfa0a7f446db26294d5c0a865698292875513fc44c43162d5d5c991d89ac44c8dd0b69501a413e76a0ea4e39e3a0a25a1b3763c98aaed0539a6872bb1b6429d51c77286464b839e0f5547dce9851bd40a0491149ef8592a767c4a40c0a8e7582fc40a355a4e080a06934bc4598c378563a9109942c485b407da42a32a515948770aac3c3fc68fdc8591f8591ea12a989e194da491bf3263a9e133031b544664e4b790182242298649e714d388b022f49a8853c0275830ae704335858290d403752324b819877fcb21d1228f8d103e32d64cc8cb27840c92228975e7b43493175c123a2e31759bf148816a2a02bc27128a1b8c263c3ed375ad5e9392812c7f273271570074929387f962686c33b8541822ca293fb89624be73ab412a19d48b3bbc62a8b627974b58429c8624fef8cb6e4492b630097a091d31b666c8890382db2ba6fcca34c25dce351263f376fae02278aa8d5f03a514f95e9ba1615fa11f0eb28b9ad803a6ec36b164a0cf5955ee4449395b124c00446d099496d1c1b535908ed119f8cb48b325957f320c1157abacf658d23954b18cc197e189eb9b7c6b2c6372374085da909d5a96943694bc04c65cb880611a4b058c649088916388a4f8d6cedda8a906c2c8956b47e5d4666560a35517510759a8d8ec3c1227430e7539ee10056434a8dd87b024d0b87c15cb5ae638bf44b423455445c4c810f8a987207c9ba85af358c44343ab04bc962d54b9749397ef5b2fbdd6c2b3b2be81285ce9d65f77b3b165a26af403b1c1ec0b9d108c688886bd5633344335c4fb3dc992a57da901fa8600e8fc5926791544fb9165d26e54437443f414f8972e5fbc147d84bf98a70308d3073b8399369b797222a7814650192499d0fb6ec2aa2a464204445c2c17f48290793873d54806b04e9d4c2c4964cf35ac4c74c5332dba2043bc92fb356e201b1ee8161536511ebc2c8dfc83c5dc49ae6ba0106d7533eac7085e038739674239483c348015f7ca324dfc793e0536c1dcb93bb4af15953e219482dc3c46a000c0f3476f29013d8f4cc6760b447f3c1310555332817067540f95969cff481929315d5c744f4bf83fe89cc45975926e54a27d764c82883251e0cbd8933f120bd074969ec94c73cd613d7a919e4c0614168c877346ade2d03d49a77eafe7b588a9b859e87b9f06b8b62b561ae40139025dd3621e84939a9c695add0a28967152800b360ea4a55495b488c97dc5d94f3c3b2069e12022a10b20d5895891373f309cc82b3482cc6f64f940f22208e6fab7c3008cbef992367a3c3eeaa60e772e1a784304e2ac1ac1866cc491dc6c5606949738a08f06f18e80824055b5786bd5816c89a36bb296c4e7a6bbf07f54eb30b349714df98986c3b45c8aa5b2f2c86668a62ef773bc630374e89f1b162d3f024e33d45516fb6f7960fdca24443a360773e1a0a119a4017d6893924f374d219fe001184dbb673987eec3cf66832cbb2826582356e57b57baa25ba48cbbc2f2266a07a09a96c013d5f6facf69b4116f64c2bc2624d1bf40900710d0f320069c15dd240ff016b633276630e75aaf5d80669c1944036b0e63b9b498286ea62942fdafb2574ad7c5a76c39e1c82734af4f1865d3e6961475c5cc23a1b6afa1dda5e277de8bf8014dd0699c4b6752e669acc37ab15b2b3a3cb58c40b977f036226daaf30b4a51920eb3a0adf2d95e8b085e2564f8e6e400a5733e858c1b1c15137a041fdd26d42f401fa596fa5f4602f2026a642728f6a2d493eb9e5635adb70a8de3489078995c73265c58d7e4fed4ededa30130bd1b2fdfb7aeae548d93eed51d388deb13e9f6884f471e5d773b34d1098fb1b499a6ae45e7e75ee7f2519aee085912a7a58e389da3c74a8c3e42457a7e5aeb63cdd2201cc2adf0756395fc4e2a2228de54783672f984fb733a37b61f071e1e70e50cf211dd2796ef63be852f4a97eb8793b65fec919d30f38a05a08117ca6dc1885be86b6c722018a7fa9d1c3ae4358c59a4f379a3a34bc1b06644064ed3185feb7def81afc0a16e98fb4a915805926b077ee7e792a7a9deed525c5a58df2c966693eed750493258c1d1cafbd968ab0ab552c738d990517a7f50791edf077520714d7111b1e9267d7e6cb11b3f9cf558d38c9ec607d2ac8dda767f14def65dd3d829dfe065c9d3683e374c1973b4f955150f55f0faa9a75c36ff4a12273ccfa8eb112c2a92a24431aeaa5046f98f26d793229a4ac36ad7023ddc8c9804850a97bd815dc9b8f5a6758349fc6d618e4a7456eace085388d17562f829cfca14806c91507ecc87a4f84d1d58c2a8639f2dd0143608ddcd6d2000863ec3100a1e135c0bc1641fb35fe16e5b246c7345c6dcba4bc0acd1b80fbb4ee18c122e95c4fdd22a5e192c7bea9dc0909b519705aeb1c69d3c1e874caa835b6aec34c55103cf592fdce846abefa065718e824f7493118ce1dbd3518b1e720183d104bba135c5c1df735d6454e4365a15c16a011bf23fc21580cabacb31519faaeebcc44b4d88719b30813523d4e011002882393efaf5586bf73f0105f04cfc36bf579f849f23ce5d2b6a5c7da7464b8d2a4513aaae6421967af762e1ce044ca539fa1dc766744c26f3cab7e519bf5953b606c9323f2af31698b772c1677a6ce8f503f76a0bbc2c1a4cde94bae2958d45d1e923d56dce2a551078a5290da02cb401714c82ad9bd084e098598bb093a18eac191dbc18c49e7ab00aedc4a78595f7cec90048204fa10b3e125e30bb2bffb60c31c18071ef14bdfd2ed5e4dd1b9cc05dedd6d815931c733cad3b531ddc4cf9cebd47dcc953d23d69125cc06e71141d0e5c80bcf9b8054b1321cdc6d56ff542793da3b57c07b060fd3b9725d6822f13c59ec90950b779d7f4a841549d0a260135d120d772314d866d774c760213d04316637bea3c84c6b28e37b10f02b2719eb38a5aae5504adb6ea0aa7621d7beb691b9e8b73b3711577a146cd8be689e428eae161f6a75fb933f92abbdcbf30c238d1ed8421935c2b556219b2e55cde881e194050ddc3ac63d70a4310d30222b916bfe49a449274837be565d9a847d7bb602227d422cd2332003ed402aa4659f7b265e9b798b14e8a704c63f3fd924294f8251c42725f21dba1e0082ef4ac5d9c6f5f07fc0c5de2828960c37e7366aa416f048a017293b4ca7174691d8e67c310548c85992ddc96fd97addf7db794ddd587026ae6c03838a3a6b246c9ab50abf4bd7b6634f8e7ea0322b2404b7c1a71c89c1e766f6266a6495709a0046a599f457c251ddcb65f9f051016363f214ff7fb1b4af6c3d5fce89c27b5a9612fd8b2ce29a155880f5178691ce61eb6074d340899e3abe703afa6f505f0f7dd352c7de6d3d04e797c4ae7e7430e0e0c6ebea3e77eec8ee0d8307b1fa5057b7f8f3a629d7146519413a8d70b1ffcfecd92fdb54a17442b39eef37d6ebbe599fa4459fd39c1732aabecca568a160ec2280889eb82717a86dfcafd17959021f1992f79f9e6b42560757638f59e47f019fcdd48399336050675be222a8f860ba0d478a524b97d61dfd1b4aa9b8f8853c3f5eb3deef9050e91e67bbf24dfaae7af866a6d8611c6a7ee322b13808642a2ab2370e6d530afed1e49a18af59687350333496c97674554baeb9a89c54cb445d1a4c8f01abbe63809a0e72e2087826c07cc78ae23c8e133768d0405ab08674787d7a000832d8c24a8ba142a7e01080e44c5aa4e6f38953700d510d34148cbae9602c42546efadddfe961c7988c6ae4f19fa7e3cf8e9da1025291edede71a18308ed5e8ea9f42a23d0aafbfa25f3777a0f6fd16137212148847a481aacc5ccab6a6a89270906a50fb34641879c6a3929cb7592370950de1b6974d1c31e8012992c58fbe486c7276adf6266af73d56a788732d9f0b62b2a6dc1779a027f5b8527d3758649f96bdf7bbbf871c9bb5d3220514177824e277f4b37c29a7ebacb46bd60ed21dfefa2d5197490700fb54a91014978a314fb06c676f21621fca4109f1a550fa61a6c8b45f640fd8e8711cb18625a8eaa7e59e1959d68c6a689fc1f32f4127e2e9c84f52d1add2ccf90298cab62a9a1715308ae0eee330bb0a3953a06baf2fb75c27f9e9beb5e745f33c1261e40aee02b3401b29df8ca438e046344e2dad0d9ac11822fd45df4c55e8bf1428c853b9c51760dbb096d1d4c48e01ce98cccc416954b9e974af59faed634a217e48be89d5abbcee715968db1792f6e73b4d4127f7884ad064e169662f473b014ab0020841ea3b27d982a63f39f87316233025c34e584c6e10c05bf97734e36d5b5c670 \ No newline at end of file diff --git a/web-client/public/mtp_dev_cert_hash.txt b/web-client/public/mtp_dev_cert_hash.txt deleted file mode 100644 index fa60889..0000000 --- a/web-client/public/mtp_dev_cert_hash.txt +++ /dev/null @@ -1 +0,0 @@ -8853799814eb1f542bd4f34c08d93a87d98b41e018bdba3e47c4a7d3c0109a5a \ No newline at end of file