From 188caf56cc994910bd05ea437ee45bd45d3e3027 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:39:09 +0200 Subject: [PATCH 01/22] [Fix] Clean --- Cargo.lock | 1 - client/src/pipe.rs | 9 +- codec/src/lib.rs | 4 +- codec/src/protected.rs | 172 ++++++++++++++------------------- example/Cargo.lock | 1 - example/server/src/handlers.rs | 12 ++- host/src/engine.rs | 28 +++--- mtp-webserver/Cargo.toml | 3 +- mtp-webserver/src/transport.rs | 2 + transport/src/connection.rs | 23 ++--- transport/tests/integration.rs | 12 ++- wasm/src/protected.rs | 14 +-- 12 files changed, 124 insertions(+), 157 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 93d18c9..a6c3d32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1485,7 +1485,6 @@ dependencies = [ "mtp-host", "mtp-transport", "quinn", - "rand", "rcgen", "rustls", "thiserror 2.0.20", diff --git a/client/src/pipe.rs b/client/src/pipe.rs index 4dc590a..f9944e8 100644 --- a/client/src/pipe.rs +++ b/client/src/pipe.rs @@ -200,14 +200,13 @@ pub(crate) async fn expire_pending_request( 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 + if expired.len() >= MAX_EXPIRED_REQUEST_TOMBSTONES + && let Some(oldest) = expired .iter() .min_by_key(|(_, expires_at)| **expires_at) .map(|(id, _)| *id) - { - expired.remove(&oldest); - } + { + expired.remove(&oldest); } expired.insert(request_id, now + EXPIRED_REQUEST_TOMBSTONE_TTL); } diff --git a/codec/src/lib.rs b/codec/src/lib.rs index 7249251..c910cd8 100644 --- a/codec/src/lib.rs +++ b/codec/src/lib.rs @@ -16,8 +16,8 @@ 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, + ProtectedOpenOptions, ReplayError, ReplayGuard, VerifiedProtectedMessage, open_protected, + open_protected_with, open_protected_with_keys, protected_claimed_signer_id, }; #[cfg(feature = "crypto")] pub use relay::{ diff --git a/codec/src/protected.rs b/codec/src/protected.rs index f423ec9..e68689f 100644 --- a/codec/src/protected.rs +++ b/codec/src/protected.rs @@ -247,6 +247,35 @@ pub struct VerifiedProtectedMessage { pub matched_signer_key_index: usize, } +/// Options that control verification of a direct protected message. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ProtectedOpenOptions { + /// Require the protected frame to be addressed to this receiver when set. + pub expected_receiver_id: Option, + /// Purpose used to verify the protected envelope signature. + pub signature_purpose: ProtectionPurpose, + /// Purpose used to decrypt the protected envelope. + pub encryption_purpose: ProtectionPurpose, + /// Signature algorithms accepted by the receiver. + pub policy: ProtectionPolicy, +} + +impl ProtectedOpenOptions { + pub const fn new( + expected_receiver_id: Option, + signature_purpose: ProtectionPurpose, + encryption_purpose: ProtectionPurpose, + policy: ProtectionPolicy, + ) -> Self { + Self { + expected_receiver_id, + signature_purpose, + encryption_purpose, + policy, + } + } +} + fn protected_field_id( data_type: DataType, type_map: &TypeMap, @@ -385,10 +414,7 @@ pub fn open_protected_with( keyrings: &[&Keyring], expected_signer_id: Option, resolve_signer_keys: F, - expected_receiver_id: Option, - signature_purpose: ProtectionPurpose, - encryption_purpose: ProtectionPurpose, - policy: ProtectionPolicy, + options: ProtectedOpenOptions, replay_guard: Option<&mut dyn ReplayGuard>, ) -> Result where @@ -396,7 +422,7 @@ where { 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 decrypted = decrypt_protected_payload(frame, keyrings, options.encryption_purpose)?; let signed = decrypted .as_signed() .ok_or(ProtectedError::PayloadNotSigned)?; @@ -411,16 +437,7 @@ where } 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_decrypted_protected(frame, type_map, signed, &signer_keys, options, replay_guard) } /// Open a direct protected message against already resolved trusted signer @@ -431,14 +448,11 @@ pub fn open_protected_with_keys( keyrings: &[&Keyring], expected_signer_id: u64, signer_public_keys: &[PublicKeyBundle], - expected_receiver_id: Option, - signature_purpose: ProtectionPurpose, - encryption_purpose: ProtectionPurpose, - policy: ProtectionPolicy, + options: ProtectedOpenOptions, replay_guard: Option<&mut dyn ReplayGuard>, ) -> Result { let type_map = validate_protected_frame(frame)?; - let decrypted = decrypt_protected_payload(frame, keyrings, encryption_purpose)?; + let decrypted = decrypt_protected_payload(frame, keyrings, options.encryption_purpose)?; let signed = decrypted .as_signed() .ok_or(ProtectedError::PayloadNotSigned)?; @@ -454,9 +468,7 @@ pub fn open_protected_with_keys( type_map, signed, signer_public_keys, - expected_receiver_id, - signature_purpose, - policy, + options, replay_guard, ) } @@ -468,21 +480,15 @@ pub fn open_protected( keyring: &Keyring, expected_signer_id: u64, signer_public_key: &PublicKeyBundle, - expected_receiver_id: Option, - signature_purpose: ProtectionPurpose, - encryption_purpose: ProtectionPurpose, - policy: ProtectionPolicy, + options: ProtectedOpenOptions, 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, + std::slice::from_ref(signer_public_key), + options, replay_guard, ) } @@ -492,19 +498,20 @@ fn open_decrypted_protected( type_map: TypeMap, signed: &crate::SignedValue, signer_public_keys: &[PublicKeyBundle], - expected_receiver_id: Option, - signature_purpose: ProtectionPurpose, - policy: ProtectionPolicy, + options: ProtectedOpenOptions, 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, + options.signature_purpose, + options.policy, )?; let receiver_id = frame.receiver().ok_or(ProtectedError::MissingReceiver)?; - if expected_receiver_id.is_some_and(|expected| expected != receiver_id) { + if options + .expected_receiver_id + .is_some_and(|expected| expected != receiver_id) + { return Err(ProtectedError::ExpectedReceiverMismatch); } if frame @@ -568,6 +575,15 @@ mod tests { const SIGNATURE_PURPOSE: ProtectionPurpose = ProtectionPurpose(0x40); const ENCRYPTION_PURPOSE: ProtectionPurpose = ProtectionPurpose(0x41); + fn open_options(expected_receiver_id: Option) -> ProtectedOpenOptions { + ProtectedOpenOptions::new( + expected_receiver_id, + SIGNATURE_PURPOSE, + ENCRYPTION_PURPOSE, + ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + ) + } + #[derive(Default)] struct RecordingReplayGuard { created_at: Option, @@ -720,10 +736,7 @@ mod tests { &recipient, 7, &sender.public_key_bundle(), - Some(42), - SIGNATURE_PURPOSE, - ENCRYPTION_PURPOSE, - ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + open_options(Some(42)), Some(&mut guard), ) .expect("protected message should open"); @@ -737,10 +750,7 @@ mod tests { &recipient, 7, &sender.public_key_bundle(), - Some(42), - SIGNATURE_PURPOSE, - ENCRYPTION_PURPOSE, - ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + open_options(Some(42)), Some(&mut guard), ), Err(ProtectedError::Replay) @@ -777,10 +787,7 @@ mod tests { &recipient, 7, &sender.public_key_bundle(), - Some(42), - SIGNATURE_PURPOSE, - ENCRYPTION_PURPOSE, - ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + open_options(Some(42)), None, ) .expect("outer fields should verify"); @@ -813,10 +820,7 @@ mod tests { &recipient, 7, &sender.public_key_bundle(), - Some(42), - SIGNATURE_PURPOSE, - ENCRYPTION_PURPOSE, - ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + open_options(Some(42)), None, ), Err(ProtectedError::MissingProtectedVersion) @@ -844,10 +848,7 @@ mod tests { &recipient, 7, &sender.public_key_bundle(), - Some(42), - SIGNATURE_PURPOSE, - ENCRYPTION_PURPOSE, - ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + open_options(Some(42)), None, ), Err(ProtectedError::UnsupportedProtectedVersion(2)) @@ -897,10 +898,7 @@ mod tests { &recipient, 7, &sender.public_key_bundle(), - None, - SIGNATURE_PURPOSE, - ENCRYPTION_PURPOSE, - ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + open_options(None), None, ), Err(ProtectedError::MessageTypeMismatch) @@ -913,10 +911,7 @@ mod tests { &recipient, 7, &sender.public_key_bundle(), - None, - SIGNATURE_PURPOSE, - ENCRYPTION_PURPOSE, - ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + open_options(None), None, ), Err(ProtectedError::FinalRecipientMismatch) @@ -944,10 +939,7 @@ mod tests { &recipient, 7, &sender.public_key_bundle(), - None, - SIGNATURE_PURPOSE, - ENCRYPTION_PURPOSE, - ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + open_options(None), None, ), Err(ProtectedError::FinalRecipientMismatch) @@ -959,10 +951,7 @@ mod tests { &recipient, 7, &sender.public_key_bundle(), - Some(43), - SIGNATURE_PURPOSE, - ENCRYPTION_PURPOSE, - ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + open_options(Some(43)), None, ), Err(ProtectedError::ExpectedReceiverMismatch) @@ -994,10 +983,7 @@ mod tests { &recipient, 7, &sender.public_key_bundle(), - Some(42), - SIGNATURE_PURPOSE, - ENCRYPTION_PURPOSE, - ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + open_options(Some(42)), None, ) .expect("matching exposed sender"); @@ -1007,10 +993,7 @@ mod tests { &recipient, 7, &sender.public_key_bundle(), - Some(42), - SIGNATURE_PURPOSE, - ENCRYPTION_PURPOSE, - ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + open_options(Some(42)), None, ), Err(ProtectedError::SenderMismatch) @@ -1049,10 +1032,7 @@ mod tests { &recipient, 7, &sender.public_key_bundle(), - Some(42), - SIGNATURE_PURPOSE, - ENCRYPTION_PURPOSE, - ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + open_options(Some(42)), None, ), Err(ProtectedError::PayloadNotEncrypted) @@ -1066,10 +1046,7 @@ mod tests { &recipient, 7, &sender.public_key_bundle(), - Some(42), - SIGNATURE_PURPOSE, - ENCRYPTION_PURPOSE, - ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + open_options(Some(42)), None, ), Err(ProtectedError::PayloadNotSigned) @@ -1090,10 +1067,7 @@ mod tests { resolver_calls += 1; Some(vec![sender.public_key_bundle()]) }, - Some(42), - SIGNATURE_PURPOSE, - ENCRYPTION_PURPOSE, - ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + open_options(Some(42)), None, ); assert!(matches!( @@ -1138,10 +1112,7 @@ mod tests { current_sender.public_key_bundle(), old_sender.public_key_bundle(), ], - Some(42), - SIGNATURE_PURPOSE, - ENCRYPTION_PURPOSE, - ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + open_options(Some(42)), None, ) .expect("key history should open"); @@ -1167,10 +1138,7 @@ mod tests { &recipient, 7, &sender.public_key_bundle(), - Some(42), - SIGNATURE_PURPOSE, - ENCRYPTION_PURPOSE, - ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + open_options(Some(42)), None, ) .expect("arbitrary application value should open"); diff --git a/example/Cargo.lock b/example/Cargo.lock index 1c7fa26..1c15380 100644 --- a/example/Cargo.lock +++ b/example/Cargo.lock @@ -1404,7 +1404,6 @@ dependencies = [ "mtp-host", "mtp-transport", "quinn", - "rand", "rustls", "thiserror 2.0.20", "tokio", diff --git a/example/server/src/handlers.rs b/example/server/src/handlers.rs index 65dfedd..1e6320b 100644 --- a/example/server/src/handlers.rs +++ b/example/server/src/handlers.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use mtp::codec::{ CommunicationType, CommunicationValue, DataType, DataTypeId, DataValue, InMemoryReplayGuard, - ProtectionPolicy, ProtectionPurpose, SignaturePolicy, TypeMap, + ProtectedOpenOptions, ProtectionPolicy, ProtectionPurpose, SignaturePolicy, TypeMap, forward_relay_frame, open_protected_with, open_relay_content, open_relay_metadata_with, }; use mtp::crypto::{Keyring, PublicKeyBundle}; @@ -70,10 +70,12 @@ fn process_direct_protected( 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, + ProtectedOpenOptions::new( + 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}"))?; diff --git a/host/src/engine.rs b/host/src/engine.rs index 078c2f1..cd16934 100644 --- a/host/src/engine.rs +++ b/host/src/engine.rs @@ -344,7 +344,7 @@ impl HandshakeEngine { // 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) + if Some(first_msg.get_type()) == CommunicationType::Register.try_to_id(tm) || first_msg.get_data(DataType::PublicKeys).is_some() { send_rejection_generic( @@ -400,7 +400,7 @@ impl HandshakeEngine { let tm = codec.type_map(); // Register frames always go through full authentication - if Some(first_msg.get_type()) == CommunicationType::Register.try_to_id(&tm) { + if Some(first_msg.get_type()) == CommunicationType::Register.try_to_id(tm) { let bundle = match extract_register_bundle(&first_msg) { Ok(bundle) => bundle, Err(error) => { @@ -425,7 +425,7 @@ impl HandshakeEngine { } // Identification: try lookup, fall back to guest - if Some(first_msg.get_type()) == CommunicationType::Identification.try_to_id(&tm) { + if Some(first_msg.get_type()) == CommunicationType::Identification.try_to_id(tm) { let cid = match first_msg.get_data(DataType::Id) { Some(DataValue::UnsignedNumber(n)) => u64::try_from(*n).unwrap_or(0), _ => 0, @@ -504,7 +504,7 @@ impl HandshakeEngine { let tm = codec.type_map(); let (flow, response_type) = if Some(first_msg.get_type()) - == CommunicationType::Identification.try_to_id(&tm) + == CommunicationType::Identification.try_to_id(tm) { let cid = match first_msg.get_data(DataType::Id) { Some(DataValue::UnsignedNumber(n)) => match u64::try_from(*n) { @@ -545,7 +545,7 @@ impl HandshakeEngine { Flow::Login { id: cid, bundle }, CommunicationType::IdentificationResponse, ) - } else if Some(first_msg.get_type()) == CommunicationType::Register.try_to_id(&tm) { + } else if Some(first_msg.get_type()) == CommunicationType::Register.try_to_id(tm) { let bundle = match extract_register_bundle(&first_msg) { Ok(bundle) => bundle, Err(error) => { @@ -710,7 +710,7 @@ impl HandshakeEngine { sender.close(); AcceptError::Receive(e) })?; - if Some(proof.get_type()) != CommunicationType::ChallengeResponse.try_to_id(&tm) { + if Some(proof.get_type()) != CommunicationType::ChallengeResponse.try_to_id(tm) { let error = AcceptError::AuthenticationFailed("missing challenge response".into()); reject_error_generic(sender, &error, tm).await; return Err(error); @@ -1041,8 +1041,8 @@ 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 } + async fn set_type_map(&self, type_map: &TypeMap) { + self.set_type_map(type_map).await; } fn close(&self) { let sender = self.clone(); @@ -1058,8 +1058,8 @@ 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 } + async fn set_type_map(&self, type_map: &TypeMap) { + self.set_type_map(type_map).await; } } @@ -1075,8 +1075,8 @@ 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 } + async fn set_type_map(&self, type_map: &TypeMap) { + self.set_type_map(type_map).await; } fn close(&self) { mtp_transport::GenericSender::close(self); @@ -1093,8 +1093,8 @@ 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 } + async fn set_type_map(&self, type_map: &TypeMap) { + self.set_type_map(type_map).await; } } diff --git a/mtp-webserver/Cargo.toml b/mtp-webserver/Cargo.toml index 88f7bb1..6b5ef4e 100644 --- a/mtp-webserver/Cargo.toml +++ b/mtp-webserver/Cargo.toml @@ -25,7 +25,6 @@ rustls = "0.23" tracing = "0.1" thiserror = "2" async-trait = "0.1" -rand = { version = "0.10.1", optional = true } [dev-dependencies] rcgen = "0.14" @@ -33,5 +32,5 @@ hyper = { version = "1", features = ["client", "http2"] } [features] default = [] -crypto = ["mtp-host/crypto", "dep:rand"] +crypto = ["mtp-host/crypto"] pipes = ["mtp-host/pipes", "mtp-transport/pipes"] diff --git a/mtp-webserver/src/transport.rs b/mtp-webserver/src/transport.rs index 3834f64..5c81765 100644 --- a/mtp-webserver/src/transport.rs +++ b/mtp-webserver/src/transport.rs @@ -220,6 +220,7 @@ pub type WebMtpReceiver = GenericReceiver; pub type WebMTPConnection = mtp_host::MTPConnection; +#[allow(clippy::too_many_arguments)] pub(crate) async fn accept_web_connection( session: Arc, path: String, @@ -268,6 +269,7 @@ pub(crate) async fn accept_web_connection( .await } +#[allow(clippy::too_many_arguments)] async fn accept_web_connection_inner( session: Arc, path: String, diff --git a/transport/src/connection.rs b/transport/src/connection.rs index 7f0b5d6..c21bd67 100644 --- a/transport/src/connection.rs +++ b/transport/src/connection.rs @@ -1078,18 +1078,12 @@ impl Receiver { #[instrument(skip(self), level = "trace")] pub async fn receive(&self) -> Result { let mut close_rx = self.inner.handle.subscribe_close(); - if close_rx.borrow().is_some() { - return Err(self - .inner - .handle - .close_reason() - .unwrap_or(CommunicationError::StreamClosed)); - } #[cfg(feature = "pipes")] { let mut rx = self.inner.msg_rx.lock().await; let result = tokio::select! { + biased; message = rx.recv() => message, _ = close_rx.changed() => return Err(close_rx .borrow() @@ -1113,6 +1107,7 @@ impl Receiver { { let mut rx = self.inner.rx.lock().await; let result = tokio::select! { + biased; message = rx.recv() => message, _ = close_rx.changed() => return Err(close_rx .borrow() @@ -1136,17 +1131,11 @@ impl Receiver { #[cfg(feature = "pipes")] #[instrument(skip(self), level = "trace")] pub async fn receive_event(&self) -> Result { - if self.inner.handle.is_closed() { - return Err(self - .inner - .handle - .close_reason() - .unwrap_or(CommunicationError::StreamClosed)); - } - + let mut close_rx = self.inner.handle.subscribe_close(); let mut msg_rx = self.inner.msg_rx.lock().await; let mut pipe_rx = self.inner.pipe_rx.lock().await; tokio::select! { + biased; msg = msg_rx.recv() => { match msg { Some(Ok(val)) => { @@ -1174,6 +1163,10 @@ impl Receiver { .unwrap_or(CommunicationError::StreamClosed)), } } + _ = close_rx.changed() => Err(close_rx + .borrow() + .clone() + .unwrap_or(CommunicationError::StreamClosed)), } } diff --git a/transport/tests/integration.rs b/transport/tests/integration.rs index 7028528..3154747 100644 --- a/transport/tests/integration.rs +++ b/transport/tests/integration.rs @@ -342,13 +342,17 @@ async fn test_max_frames_per_stream_enforced() -> Result<(), Box Date: Fri, 14 Aug 2026 16:00:47 +0300 Subject: [PATCH 02/22] Update Rust crate tokio-stream to v0.1.19 --- Cargo.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a6c3d32..5a2146f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -561,7 +561,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1778,7 +1778,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1949,7 +1949,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2407,9 +2407,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -2697,7 +2697,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] From a7e804c60355a6591948ff2360164bb70cb9b849 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:57:45 +0200 Subject: [PATCH 03/22] [Fix] Harden MTP codec, transport, and SDK security --- Cargo.lock | 3 +- client/src/connection.rs | 46 +- client/src/lib.rs | 21 +- client/src/ping.rs | 8 +- client/src/pipe.rs | 183 +- codec/src/communication_value.rs | 244 ++- codec/src/data_value.rs | 1108 ++++++++-- codec/src/lib.rs | 27 +- codec/src/protected.rs | 543 ++++- codec/src/registry.rs | 68 +- codec/src/relay.rs | 646 +++++- common/src/lib.rs | 4 + crypto/Cargo.toml | 2 + crypto/src/error.rs | 2 + crypto/src/helper.rs | 277 ++- crypto/src/kdf.rs | 26 + crypto/src/keypair.rs | 92 +- crypto/src/lib.rs | 14 +- docs/NATIVE-CLIENT.md | 4 +- docs/NATIVE-HOST.md | 2 +- docs/PROTOCOL-REFERENCE.md | 33 +- docs/SECURITY.md | 49 +- docs/TYPE-MAP.md | 37 +- example/client/src/protected.rs | 9 +- example/keygen/src/main.rs | 18 +- example/server/src/handlers.rs | 14 +- example/server/src/keys.rs | 2 +- example/server/src/main.rs | 3 +- files/Cargo.toml | 3 +- files/src/lib.rs | 48 +- host/Cargo.toml | 1 + host/src/config.rs | 230 ++ host/src/connection.rs | 57 +- host/src/engine.rs | 159 +- host/src/handshake.rs | 31 +- host/src/lib.rs | 5 +- host/src/pipe.rs | 185 +- mtp-webserver/src/transport.rs | 13 +- package.json | 3 +- src/sdk/client.ts | 2635 ++++++++++++++++++++++ src/sdk/codec.ts | 959 ++++++++ src/sdk/credentials.ts | 26 + src/sdk/index.ts | 3150 +-------------------------- src/sdk/passphrase-worker.ts | 33 + src/sdk/protection.ts | 258 +++ src/sdk/relay.ts | 204 ++ src/sdk/signature-policy.ts | 10 +- src/sdk/timeout.ts | 27 + src/sdk/wasm-init.ts | 27 + test/wasm-init.mjs | 20 + transport/src/connection.rs | 427 +++- transport/src/connection_handle.rs | 11 +- transport/src/framing.rs | 22 +- transport/src/generic_connection.rs | 148 +- transport/src/lib.rs | 5 +- transport/tests/generic_pipe.rs | 4 +- transport/tests/integration.rs | 16 +- wasm/Cargo.toml | 2 +- wasm/src/client.rs | 1389 ------------ wasm/src/client/authentication.rs | 630 ++++++ wasm/src/client/connection.rs | 102 + wasm/src/client/dispatch.rs | 184 ++ wasm/src/client/mod.rs | 299 +++ wasm/src/client/pipes.rs | 76 + wasm/src/client/receive.rs | 328 +++ wasm/src/client_pipe.rs | 282 ++- wasm/src/config.rs | 1 + wasm/src/crypto.rs | 191 +- wasm/src/frame.rs | 192 +- wasm/src/protected.rs | 358 ++- wasm/src/relay.rs | 246 ++- wasm/src/transport.rs | 62 +- wasm/types/mtp_wasm.d.ts | 1132 +++++++--- 73 files changed, 11906 insertions(+), 5770 deletions(-) create mode 100644 src/sdk/client.ts create mode 100644 src/sdk/codec.ts create mode 100644 src/sdk/credentials.ts create mode 100644 src/sdk/passphrase-worker.ts create mode 100644 src/sdk/protection.ts create mode 100644 src/sdk/relay.ts create mode 100644 src/sdk/timeout.ts create mode 100644 src/sdk/wasm-init.ts create mode 100644 test/wasm-init.mjs delete mode 100644 wasm/src/client.rs create mode 100644 wasm/src/client/authentication.rs create mode 100644 wasm/src/client/connection.rs create mode 100644 wasm/src/client/dispatch.rs create mode 100644 wasm/src/client/mod.rs create mode 100644 wasm/src/client/pipes.rs create mode 100644 wasm/src/client/receive.rs diff --git a/Cargo.lock b/Cargo.lock index a6c3d32..0745d18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1372,6 +1372,7 @@ name = "mtp-crypto" version = "0.3.0" dependencies = [ "aes-gcm", + "argon2", "base64 0.22.1", "chacha20poly1305", "ed25519-dalek", @@ -1395,7 +1396,6 @@ dependencies = [ name = "mtp-files" version = "0.3.0" dependencies = [ - "argon2", "mtp-crypto", "rand", "thiserror 2.0.20", @@ -1411,6 +1411,7 @@ dependencies = [ "mtp-crypto", "mtp-transport", "rand", + "thiserror 2.0.20", "tokio", "tracing", "wtransport", diff --git a/client/src/connection.rs b/client/src/connection.rs index 1218dbb..16a85db 100644 --- a/client/src/connection.rs +++ b/client/src/connection.rs @@ -13,6 +13,10 @@ use crate::error::AuthState; use crate::ping::{PingSession, start_ping_session}; #[cfg(feature = "pipes")] use crate::pipe::PipeRequest; +#[cfg(feature = "pipes")] +use crate::pipe::is_expired_creation; +#[cfg(feature = "pipes")] +use crate::pipe::{PendingCreation, PendingCreationGuard}; use crate::pipe::{PendingRequest, PipeDispatcher, run_dispatcher}; pub struct MTPConnection { @@ -134,17 +138,33 @@ impl MTPConnection { description: &str, ) -> Result { let (tx, rx) = tokio::sync::oneshot::channel(); + let token = Arc::new(()); let pipe_id = { - let mut pending = self.pipe_dispatcher.pending_creations.lock().await; + let mut pending = self + .pipe_dispatcher + .pending_creations + .lock() + .map_err(|_| mtp_common::PipeError::ConnectionClosed)?; let pipe_id = loop { let candidate = rand::random::(); - if candidate != 0 && !pending.contains_key(&candidate) { + if candidate != 0 + && !pending.contains_key(&candidate) + && !is_expired_creation(&self.pipe_dispatcher, candidate) + { break candidate; } }; - pending.insert(pipe_id, tx); + pending.insert( + pipe_id, + PendingCreation { + token: token.clone(), + sender: tx, + }, + ); pipe_id }; + let mut creation_guard = + PendingCreationGuard::new(self.pipe_dispatcher.clone(), pipe_id, token.clone()); let request = CommunicationValue::new_with_type_map( mtp_codec::CommunicationType::PipeRequest, @@ -154,19 +174,17 @@ impl MTPConnection { .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)); } + creation_guard.disarm(); Ok(crate::pipe::PipeHandle { pipe_id, description: description.to_string(), sender: self.sender.clone(), response_rx: rx, + dispatcher: self.pipe_dispatcher.clone(), + token, }) } @@ -207,18 +225,19 @@ pub(crate) async fn connection_from_parts( #[cfg(feature = "pipes")] { + let receiver_queue_capacity = config.policy.receiver_queue_capacity.max(1); let (app_tx, app_rx) = mpsc::channel::>( - config.policy.receiver_queue_capacity, + receiver_queue_capacity, ); - let (pipe_req_tx, pipe_req_rx) = - mpsc::channel::(config.policy.receiver_queue_capacity); + let (pipe_req_tx, pipe_req_rx) = mpsc::channel::(receiver_queue_capacity); 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_creations: std::sync::Mutex::new(std::collections::HashMap::new()), + expired_creations: std::sync::Mutex::new(std::collections::HashMap::new()), pending_pipes: Mutex::new(std::collections::HashMap::new()), policy: Arc::new(config.policy), }); @@ -255,8 +274,9 @@ pub(crate) async fn connection_from_parts( #[cfg(not(feature = "pipes"))] { + let receiver_queue_capacity = config.policy.receiver_queue_capacity.max(1); let (app_tx, app_rx) = mpsc::channel::>( - config.policy.receiver_queue_capacity, + receiver_queue_capacity, ); let dispatcher = Arc::new(PipeDispatcher { pending_requests: Mutex::new(std::collections::HashMap::new()), diff --git a/client/src/lib.rs b/client/src/lib.rs index bf603f8..9a4a2f5 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -222,6 +222,10 @@ impl MTPClient { sender.set_type_map(&tm).await; receiver.set_type_map(&tm).await; let version_str = format!("{}", PROTOCOL_VERSION); + let public_key_bytes = keys + .public_key_bundle() + .try_as_bytes() + .map_err(|error| CommunicationError::ParseError(error.to_string()))?; let mut ident = CommunicationValue::new_with_type_map(CommunicationType::Identification, &tm) @@ -233,10 +237,7 @@ impl MTPClient { // 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()), - ); + .add_typed_default(DataType::PublicKeys, DataValue::Bytes(public_key_bytes)); if let Some(desc) = &config.description { ident = ident.add_typed_default(DataType::Description, DataValue::Str(desc.clone())); } @@ -415,7 +416,9 @@ impl MTPClient { 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 pk_bytes = pk_bundle + .try_as_bytes() + .map_err(|error| CommunicationError::ParseError(error.to_string()))?; let mut register = CommunicationValue::new_with_type_map(CommunicationType::Register, &tm) .add_typed_default(DataType::Version, DataValue::Str(version_str.clone())) @@ -601,7 +604,9 @@ mod tests { #[cfg(feature = "pipes")] type_map: mtp_codec::TypeMap::latest(), #[cfg(feature = "pipes")] - pending_creations: Mutex::new(HashMap::new()), + pending_creations: std::sync::Mutex::new(HashMap::new()), + #[cfg(feature = "pipes")] + expired_creations: std::sync::Mutex::new(HashMap::new()), #[cfg(feature = "pipes")] pending_pipes: Mutex::new(HashMap::new()), #[cfg(feature = "pipes")] @@ -639,7 +644,9 @@ mod tests { #[cfg(feature = "pipes")] type_map: mtp_codec::TypeMap::latest(), #[cfg(feature = "pipes")] - pending_creations: Mutex::new(HashMap::new()), + pending_creations: std::sync::Mutex::new(HashMap::new()), + #[cfg(feature = "pipes")] + expired_creations: std::sync::Mutex::new(HashMap::new()), #[cfg(feature = "pipes")] pending_pipes: Mutex::new(HashMap::new()), #[cfg(feature = "pipes")] diff --git a/client/src/ping.rs b/client/src/ping.rs index 4c3f3c6..5e917de 100644 --- a/client/src/ping.rs +++ b/client/src/ping.rs @@ -66,8 +66,8 @@ pub(crate) async fn start_ping_session( return None; } - let (pong_tx, mut pong_rx) = mpsc::unbounded_channel(); - receiver.observe_pongs(pong_tx).await; + let (pong_tx, mut pong_rx) = mpsc::channel(1); + receiver.observe_pongs_bounded(pong_tx).await; let last_ping = Arc::new(Mutex::new(None)); let ping_state = last_ping.clone(); let interval = config.ping_interval; @@ -75,6 +75,7 @@ pub(crate) async fn start_ping_session( let max_missed_pings = config.max_missed_pings; let ping_timestamp = config.ping_timestamp; let type_map = type_map.clone(); + let ping_receiver = receiver.clone(); let mut close_rx = receiver.handle().subscribe_close(); let task = tokio::spawn(async move { @@ -91,6 +92,7 @@ pub(crate) async fn start_ping_session( } _ = ticker.tick() => { let missed_pings = tracker.begin_round(); + ping_receiver.set_expected_pong_id(None).await; if max_missed_pings > 0 && missed_pings >= max_missed_pings { sender.close().await; break; @@ -121,7 +123,9 @@ pub(crate) async fn start_ping_session( sender.close().await; break; }; + ping_receiver.set_expected_pong_id(Some(id)).await; if sender.send(&ping).await.is_err() { + ping_receiver.set_expected_pong_id(None).await; sender.close().await; break; } diff --git a/client/src/pipe.rs b/client/src/pipe.rs index f9944e8..8e839dd 100644 --- a/client/src/pipe.rs +++ b/client/src/pipe.rs @@ -5,6 +5,8 @@ use mtp_common::CommunicationError; use mtp_transport::Receiver; use std::collections::HashMap; use std::sync::Arc; +#[cfg(feature = "pipes")] +use std::sync::Mutex as StdMutex; use tokio::sync::{Mutex, mpsc}; use tokio::time::{Duration, Instant}; @@ -21,6 +23,8 @@ pub struct PipeHandle { pub(crate) description: String, pub(crate) sender: Sender, pub(crate) response_rx: tokio::sync::oneshot::Receiver>, + pub(crate) dispatcher: Arc, + pub(crate) token: Arc<()>, } #[cfg(feature = "pipes")] @@ -33,9 +37,11 @@ impl PipeHandle { &self.description } - pub async fn wait(self) -> Result, PipeError> { - match self.response_rx.await { - Ok(Ok(true)) => { + pub async fn wait(mut self) -> Result, PipeError> { + let response = + tokio::time::timeout(self.dispatcher.policy.read_timeout, &mut self.response_rx).await; + match response { + Ok(Ok(Ok(true))) => { let writer = self .sender .open_pipe(self.pipe_id, &self.description) @@ -43,13 +49,30 @@ impl PipeHandle { .map_err(PipeError::from)?; Ok(Some(writer)) } - Ok(Ok(false)) => Ok(None), - Ok(Err(e)) => Err(e), - Err(_) => Err(PipeError::StreamClosed), + Ok(Ok(Ok(false))) => Ok(None), + Ok(Ok(Err(error))) => { + expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token); + Err(error) + } + Ok(Err(_)) => { + expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token); + Err(PipeError::StreamClosed) + } + Err(_) => { + expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token); + Err(PipeError::HandshakeTimeout) + } } } } +#[cfg(feature = "pipes")] +impl Drop for PipeHandle { + fn drop(&mut self) { + expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token); + } +} + #[cfg(feature = "pipes")] pub struct PipeRequest { pub(crate) pipe_id: u32, @@ -129,14 +152,54 @@ pub(crate) struct PendingRequest { pub(crate) sender: tokio::sync::oneshot::Sender>, } +#[cfg(feature = "pipes")] +pub(crate) struct PendingCreation { + pub(crate) token: Arc<()>, + pub(crate) sender: tokio::sync::oneshot::Sender>, +} + +#[cfg(feature = "pipes")] +pub(crate) struct PendingCreationGuard { + dispatcher: Arc, + pipe_id: u32, + token: Arc<()>, + armed: bool, +} + +#[cfg(feature = "pipes")] +impl PendingCreationGuard { + pub(crate) fn new(dispatcher: Arc, pipe_id: u32, token: Arc<()>) -> Self { + Self { + dispatcher, + pipe_id, + token, + armed: true, + } + } + + pub(crate) fn disarm(&mut self) { + self.armed = false; + } +} + +#[cfg(feature = "pipes")] +impl Drop for PendingCreationGuard { + fn drop(&mut self) { + if self.armed { + expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token); + } + } +} + 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>>>, + pub(crate) pending_creations: StdMutex>, + #[cfg(feature = "pipes")] + pub(crate) expired_creations: StdMutex>, #[cfg(feature = "pipes")] pub(crate) pending_pipes: Mutex>>, @@ -144,6 +207,90 @@ pub(crate) struct PipeDispatcher { pub(crate) policy: Arc, } +#[cfg(feature = "pipes")] +const EXPIRED_CREATION_TOMBSTONE_TTL: Duration = Duration::from_secs(60); +#[cfg(feature = "pipes")] +const MAX_EXPIRED_CREATION_TOMBSTONES: usize = 1024; + +#[cfg(feature = "pipes")] +pub(crate) fn expire_pending_creation(dispatcher: &PipeDispatcher, pipe_id: u32, token: &Arc<()>) { + let removed = dispatcher + .pending_creations + .lock() + .ok() + .and_then(|mut pending| { + if pending + .get(&pipe_id) + .is_some_and(|entry| Arc::ptr_eq(&entry.token, token)) + { + pending.remove(&pipe_id); + Some(()) + } else { + None + } + }); + if removed.is_none() { + return; + } + let Ok(mut expired) = dispatcher.expired_creations.lock() else { + return; + }; + let now = Instant::now(); + expired.retain(|_, expires_at| *expires_at > now); + if expired.len() >= MAX_EXPIRED_CREATION_TOMBSTONES + && let Some(oldest) = expired + .iter() + .min_by_key(|(_, expires_at)| **expires_at) + .map(|(id, _)| *id) + { + expired.remove(&oldest); + } + expired.insert(pipe_id, now + EXPIRED_CREATION_TOMBSTONE_TTL); +} + +#[cfg(feature = "pipes")] +fn consume_expired_creation(dispatcher: &PipeDispatcher, pipe_id: u32) -> bool { + let Ok(mut expired) = dispatcher.expired_creations.lock() else { + return false; + }; + let now = Instant::now(); + expired.retain(|_, expires_at| *expires_at > now); + expired.remove(&pipe_id).is_some() +} + +#[cfg(feature = "pipes")] +pub(crate) fn is_expired_creation(dispatcher: &PipeDispatcher, pipe_id: u32) -> bool { + let Ok(mut expired) = dispatcher.expired_creations.lock() else { + return true; + }; + let now = Instant::now(); + expired.retain(|_, expires_at| *expires_at > now); + expired.contains_key(&pipe_id) +} + +#[cfg(feature = "pipes")] +pub(crate) fn fail_pending_creations(dispatcher: &PipeDispatcher, error: &CommunicationError) { + let pending = dispatcher + .pending_creations + .lock() + .ok() + .map(|mut pending| std::mem::take(&mut *pending)); + if let Some(pending) = pending { + let error = PipeError::from(error.clone()); + for (_, pending) in pending { + let _ = pending.sender.send(Err(error.clone())); + } + } + if let Ok(mut expired) = dispatcher.expired_creations.lock() { + expired.clear(); + } +} + +#[cfg(feature = "pipes")] +pub(crate) async fn fail_pending_pipes(dispatcher: &PipeDispatcher) { + dispatcher.pending_pipes.lock().await.clear(); +} + pub(crate) async fn route_message( msg: CommunicationValue, app_tx: &mpsc::Sender>, @@ -283,9 +430,15 @@ pub(crate) async fn run_dispatcher( 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) { - let _ = tx.send(Ok(accepted)); + let pending = dispatcher + .pending_creations + .lock() + .ok() + .and_then(|mut pending| pending.remove(&pipe_id)); + if let Some(entry) = pending { + let _ = entry.sender.send(Ok(accepted)); + } else { + let _ = consume_expired_creation(&dispatcher, pipe_id); } continue; } @@ -303,6 +456,10 @@ pub(crate) async fn run_dispatcher( } Err(e) => { fail_pending_requests(&dispatcher, e.clone()).await; + #[cfg(feature = "pipes")] + fail_pending_creations(&dispatcher, &e); + #[cfg(feature = "pipes")] + fail_pending_pipes(&dispatcher).await; let _ = app_tx.send(Err(e)).await; break; } @@ -325,6 +482,10 @@ pub(crate) async fn run_dispatcher( } Err(e) => { fail_pending_requests(&dispatcher, e.clone()).await; + #[cfg(feature = "pipes")] + fail_pending_creations(&dispatcher, &e); + #[cfg(feature = "pipes")] + fail_pending_pipes(&dispatcher).await; let _ = app_tx.send(Err(e)).await; break; } diff --git a/codec/src/communication_value.rs b/codec/src/communication_value.rs index d7aac8e..95b0cf0 100644 --- a/codec/src/communication_value.rs +++ b/codec/src/communication_value.rs @@ -2,7 +2,7 @@ use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; use std::fmt; use std::io::Cursor; -use crate::data_value::{DataKind, DataValue, DecodeLimits}; +use crate::data_value::{DataKind, DataValue, DecodeError, DecodeLimits, EncodeLimits}; use crate::rand_u32; use mtp_common::CodecError; use mtp_type_map::{ @@ -260,23 +260,50 @@ impl CommunicationValue { #[must_use] pub fn reply_to(&self, comm_type: CommunicationType) -> Self { - let mut response = Self::new(comm_type); + let type_map = self + .type_map + .as_ref() + .cloned() + .unwrap_or_else(TypeMap::latest); + let mut response = Self::new_with_type_map(comm_type, &type_map); response.sender = self.receiver; response.receiver = self.sender; response } - pub fn merge(&mut self, other: &Self) { - if self.mapping_error.is_none() { - self.mapping_error.clone_from(&other.mapping_error); + /// Merge clear container fields after confirming both values use the same + /// negotiated type map. + pub fn try_merge(&mut self, other: &Self) -> Result<(), CodecError> { + if let Some(error) = &self.mapping_error { + return Err(error.clone()); } - let Some(other_entries) = other.payload.container_entries() else { - self.mapping_error - .get_or_insert(CodecError::InvalidEncoding); - return; - }; + let left = self.type_map().ok_or(CodecError::MissingTypeMap)?; + let right = other.type_map().ok_or(CodecError::MissingTypeMap)?; + if left.version != right.version { + return Err(CodecError::TypeMapMismatch { + expected: left.version.to_string(), + actual: right.version.to_string(), + }); + } + if let Some(error) = &other.mapping_error { + return Err(error.clone()); + } + let other_entries = other + .payload + .container_entries() + .ok_or(CodecError::InvalidEncoding)?; for (id, value) in other_entries { - let _ = self.insert_data(*id, value.clone()); + self.insert_data(*id, value.clone())?; + } + Ok(()) + } + + // Migrate to `try_merge` so a map mismatch cannot be silently recorded in + // a frame that is later sent over the wire. + #[deprecated(note = "migrate to try_merge to handle negotiated type-map mismatches")] + pub fn merge(&mut self, other: &Self) { + if let Err(error) = self.try_merge(other) { + self.mapping_error.get_or_insert(error); } } @@ -315,9 +342,22 @@ impl CommunicationValue { } pub fn to_bytes(&self) -> Result, CodecError> { + self.to_bytes_with_limits(EncodeLimits::default()) + } + + pub fn to_bytes_with_limits(&self, limits: EncodeLimits) -> Result, CodecError> { if let Some(error) = &self.mapping_error { return Err(error.clone()); } + let header_len = self.frame_header_len(); + let payload_limit = limits + .max_output_size + .checked_sub(header_len) + .ok_or(CodecError::TooManyEntries)?; + let payload = self.payload.to_bytes_with_limits(EncodeLimits { + max_output_size: payload_limit, + ..limits + })?; let mut body = Vec::new(); body.write_u16::(self.comm_type.0) .map_err(|_| CodecError::InvalidEncoding)?; @@ -344,44 +384,71 @@ impl CommunicationValue { body.write_u64::(receiver) .map_err(|_| CodecError::InvalidEncoding)?; } - body.extend_from_slice(&self.payload.to_bytes()?); + body.extend_from_slice(&payload); let length = u32::try_from(body.len()).map_err(|_| CodecError::TooManyEntries)?; - let mut out = Vec::with_capacity(4 + body.len()); + let total_len = 4usize + .checked_add(body.len()) + .ok_or(CodecError::TooManyEntries)?; + if total_len > limits.max_output_size { + return Err(CodecError::TooManyEntries); + } + let mut out = Vec::with_capacity(total_len); out.write_u32::(length) .map_err(|_| CodecError::InvalidEncoding)?; out.extend_from_slice(&body); Ok(out) } + fn frame_header_len(&self) -> usize { + 4 + 2 + + 1 + + self.id.is_some() as usize * 4 + + self.sender.is_some() as usize * 8 + + self.receiver.is_some() as usize * 8 + } + pub fn from_bytes(bytes: &[u8]) -> Result { Self::from_bytes_with_limits(bytes, DecodeLimits::default()) } pub fn from_bytes_with_limits(bytes: &[u8], limits: DecodeLimits) -> Result { + Self::try_from_bytes_with_limits(bytes, limits).map_err(|_| CodecError::InvalidEncoding) + } + + pub fn try_from_bytes(bytes: &[u8]) -> Result { + Self::try_from_bytes_with_limits(bytes, DecodeLimits::default()) + } + + pub fn try_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; + .map_err(|_| DecodeError::MalformedEncoding)? as usize; let end = 4usize .checked_add(length) - .ok_or(CodecError::InvalidEncoding)?; + .ok_or(DecodeError::MalformedEncoding)?; if end != bytes.len() { - return Err(CodecError::InvalidEncoding); + return Err(DecodeError::MalformedEncoding); } let comm_type = CommunicationTypeId( cursor .read_u16::() - .map_err(|_| CodecError::InvalidEncoding)?, + .map_err(|_| DecodeError::MalformedEncoding)?, ); - let flags = cursor.read_u8().map_err(|_| CodecError::InvalidEncoding)?; + let flags = cursor + .read_u8() + .map_err(|_| DecodeError::MalformedEncoding)?; if flags & !FLAG_KNOWN != 0 { - return Err(CodecError::InvalidEncoding); + return Err(DecodeError::MalformedEncoding); } let id = if flags & FLAG_HAS_ID != 0 { Some( cursor .read_u32::() - .map_err(|_| CodecError::InvalidEncoding)?, + .map_err(|_| DecodeError::MalformedEncoding)?, ) } else { None @@ -390,7 +457,7 @@ impl CommunicationValue { Some( cursor .read_u64::() - .map_err(|_| CodecError::InvalidEncoding)?, + .map_err(|_| DecodeError::MalformedEncoding)?, ) } else { None @@ -399,14 +466,14 @@ impl CommunicationValue { Some( cursor .read_u64::() - .map_err(|_| CodecError::InvalidEncoding)?, + .map_err(|_| DecodeError::MalformedEncoding)?, ) } else { None }; - let payload = DataValue::read_from_with_limits(&mut cursor, limits)?; + let payload = DataValue::read_from_with_diagnostics(&mut cursor, limits)?; if cursor.position() as usize != end { - return Err(CodecError::InvalidEncoding); + return Err(DecodeError::MalformedEncoding); } Ok(Self { id, @@ -420,13 +487,36 @@ impl CommunicationValue { } pub fn from_bytes_with(bytes: &[u8], type_map: &TypeMap) -> Result { - let mut value = Self::from_bytes(bytes)?; + Self::try_from_bytes_with(bytes, type_map).map_err(|_| CodecError::InvalidEncoding) + } + + pub fn try_from_bytes_with(bytes: &[u8], type_map: &TypeMap) -> Result { + Self::try_from_bytes_with_type_map_and_limits(bytes, type_map, DecodeLimits::default()) + } + + pub fn try_from_bytes_with_type_map_and_limits( + bytes: &[u8], + type_map: &TypeMap, + limits: DecodeLimits, + ) -> Result { + let mut value = Self::try_from_bytes_with_limits(bytes, limits)?; value.set_type_map(type_map); Ok(value) } #[cfg(feature = "registry")] pub fn migrate(&self, target: &TypeMap) -> Result { + self.migrate_with_limits(target, EncodeLimits::default()) + } + + /// Migrate a clear frame while bounding the recursive traversal used to + /// translate its type IDs. + #[cfg(feature = "registry")] + pub fn migrate_with_limits( + &self, + target: &TypeMap, + limits: EncodeLimits, + ) -> Result { if let Some(error) = &self.mapping_error { return Err(error.clone()); } @@ -441,7 +531,8 @@ impl CommunicationValue { .comm_id_enum(comm) .ok_or_else(|| CodecError::UnknownCommunicationType(comm_name.to_string()))?, ); - let payload = migrate_data_value(&self.payload, source, target)?; + let mut context = MigrationContext::new(limits); + let payload = migrate_data_value(&self.payload, source, target, &mut context)?; Ok(Self { id: self.id, comm_type, @@ -454,15 +545,63 @@ impl CommunicationValue { } } +#[cfg(feature = "registry")] +struct MigrationContext { + limits: EncodeLimits, + depth: usize, + values: usize, +} + +#[cfg(feature = "registry")] +impl MigrationContext { + fn new(limits: EncodeLimits) -> Self { + Self { + limits, + depth: 0, + values: 0, + } + } + + fn value(&mut self) -> Result<(), CodecError> { + self.values = self + .values + .checked_add(1) + .ok_or(CodecError::TooManyEntries)?; + if self.values > self.limits.max_values { + return Err(CodecError::TooManyEntries); + } + Ok(()) + } + + fn enter(&mut self) -> Result<(), CodecError> { + self.depth = self + .depth + .checked_add(1) + .ok_or(CodecError::TooManyEntries)?; + if self.depth > self.limits.max_depth { + return Err(CodecError::TooManyEntries); + } + Ok(()) + } + + fn leave(&mut self) { + self.depth = self.depth.saturating_sub(1); + } +} + #[cfg(feature = "registry")] fn migrate_data_value( value: &DataValue, source: &TypeMap, target: &TypeMap, + context: &mut MigrationContext, ) -> Result { + context.value()?; match value { DataValue::Container(entries) => { - let mut migrated = Vec::with_capacity(entries.len()); + context.enter()?; + let count = u16::try_from(entries.len()).map_err(|_| CodecError::TooManyEntries)?; + let mut migrated = Vec::with_capacity(usize::from(count)); for (old_id, value) in entries { let name = source .data_type_name(old_id.0) @@ -474,16 +613,20 @@ fn migrate_data_value( .data_id_enum(data) .ok_or_else(|| CodecError::UnknownDataType(name.to_string()))?, ); - migrated.push((new_id, migrate_data_value(value, source, target)?)); + migrated.push((new_id, migrate_data_value(value, source, target, context)?)); } + context.leave(); Ok(DataValue::Container(migrated)) } - DataValue::Array(values) => Ok(DataValue::Array( - values - .iter() - .map(|value| migrate_data_value(value, source, target)) - .collect::, _>>()?, - )), + DataValue::Array(values) => { + context.enter()?; + let mut migrated = Vec::with_capacity(values.len()); + for value in values { + migrated.push(migrate_data_value(value, source, target, context)?); + } + context.leave(); + Ok(DataValue::Array(migrated)) + } #[cfg(feature = "crypto")] DataValue::Signed(_) | DataValue::Encrypted(_) => Err(CodecError::InvalidEncoding), scalar => Ok(scalar.clone()), @@ -641,6 +784,39 @@ mod tests { assert_eq!(frame.get_data(DataType::Version), None); } + #[test] + fn replies_retain_the_request_type_map() { + let type_map = TypeMap::new(mtp_type_map::Version::new(3, 0)); + let request = CommunicationValue::new_with_type_map(CommunicationType::Ping, &type_map) + .with_sender(7) + .with_receiver(9); + let reply = request.reply_to(CommunicationType::Pong); + + assert_eq!( + reply.type_map().map(|map| &map.version), + Some(&type_map.version) + ); + assert_eq!(reply.sender(), Some(9)); + assert_eq!(reply.receiver(), Some(7)); + } + + #[test] + fn try_merge_rejects_frames_from_different_type_maps() { + let left_map = TypeMap::new(mtp_type_map::Version::new(3, 0)); + let right_map = TypeMap::new(mtp_type_map::Version::new(4, 0)); + let mut left = CommunicationValue::new_with_type_map(CommunicationType::Ping, &left_map); + let right = CommunicationValue::new_with_type_map(CommunicationType::Ping, &right_map); + + assert_eq!( + left.try_merge(&right), + Err(CodecError::TypeMapMismatch { + expected: "3.0".into(), + actual: "4.0".into(), + }) + ); + assert_eq!(left.data_len(), 0); + } + #[test] fn generic_payload_roundtrips_without_becoming_a_container() { let payload = DataValue::Array(vec![ diff --git a/codec/src/data_value.rs b/codec/src/data_value.rs index 44ab92c..36065f8 100644 --- a/codec/src/data_value.rs +++ b/codec/src/data_value.rs @@ -1,10 +1,11 @@ use base64::Engine; use base64::engine::general_purpose; -use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use byteorder::{BigEndian, ReadBytesExt}; use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::hash::{Hash, Hasher}; use std::io::Cursor; +use std::mem::size_of; use mtp_common::CodecError; use mtp_type_map::DataTypeId; @@ -46,7 +47,7 @@ pub enum DataKind { /// 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)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct DecodeLimits { /// Maximum number of nested `Array`, `Container`, and `Signed` wrappers. pub max_depth: usize, @@ -57,6 +58,8 @@ pub struct DecodeLimits { pub max_blob_size: usize, /// Maximum number of recipients in one encrypted envelope. pub max_recipients: usize, + /// Maximum aggregate memory allocated for owned decoder output. + pub max_allocated_bytes: usize, } impl Default for DecodeLimits { @@ -66,21 +69,80 @@ impl Default for DecodeLimits { max_values: 65_536, max_blob_size: 16 * 1024 * 1024, max_recipients: 64, + max_allocated_bytes: 64 * 1024 * 1024, } } } +/// Conservative multiplier used when deriving decoder allocation capacity +/// from an admitted transport frame. A frame can result in owned wrapper, +/// recipient, ciphertext, and value allocations, so this is intentionally +/// larger than the number of bytes on the wire. +pub const DEFAULT_TRANSPORT_ALLOCATION_FACTOR: u64 = 4; + 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 { + Self::for_transport_message_size_with_allocation_factor( + max_message_size, + DEFAULT_TRANSPORT_ALLOCATION_FACTOR, + ) + } + + /// Derive transport limits with an explicit allocation multiplier. + /// + /// The multiplier is a deployment knob for transports whose crypto or + /// framing implementation has a different copy profile. A zero value is + /// treated as one so the allocation budget never becomes accidentally + /// unbounded by arithmetic underflow or unusably small by configuration. + pub fn for_transport_message_size_with_allocation_factor( + max_message_size: u64, + allocation_factor: u64, + ) -> Self { let max_blob_size = usize::try_from(max_message_size.saturating_sub(4)) .unwrap_or(usize::MAX) .min(u32::MAX as usize); + let allocation_factor = allocation_factor.max(1); + let max_allocated_bytes = + usize::try_from(max_message_size.saturating_mul(allocation_factor)) + .unwrap_or(usize::MAX); Self { max_blob_size, + max_allocated_bytes, + ..Self::default() + } + } +} + +/// Resource limits applied while encoding recursive `DataValue` structures. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct EncodeLimits { + /// Maximum number of nested `Array`, `Container`, and `Signed` wrappers. + pub max_depth: usize, + /// Maximum number of `DataValue` nodes in one encoded value. + pub max_values: usize, + /// Maximum serialized size of the encoded value. + pub max_output_size: usize, +} + +impl Default for EncodeLimits { + fn default() -> Self { + Self { + max_depth: 64, + max_values: 65_536, + max_output_size: 16 * 1024 * 1024, + } + } +} + +impl EncodeLimits { + /// Derive encoder limits from the transport's admitted complete frame size. + pub fn for_transport_message_size(max_message_size: u64) -> Self { + Self { + max_output_size: usize::try_from(max_message_size).unwrap_or(usize::MAX), ..Self::default() } } @@ -91,6 +153,36 @@ struct DecodeContext { limits: DecodeLimits, depth: usize, values: usize, + allocated_bytes: usize, +} + +/// Structured failures returned by the diagnostic decoder. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum DecodeError { + #[error("malformed encoding")] + MalformedEncoding, + #[error("decoder nesting depth limit exceeded")] + DepthLimit, + #[error("decoder value-count limit exceeded")] + ValueCountLimit, + #[error("decoder blob-size limit exceeded")] + BlobLimit, + #[error("decoder allocation limit exceeded")] + AllocationLimit, + #[error("decoder recipient-count limit exceeded")] + RecipientLimit, + #[error("duplicate container field")] + DuplicateField, +} + +// Keep the name used by the original internal diagnostics in this module. +type DecodeFailure = DecodeError; + +#[derive(Debug, Clone, Copy)] +struct EncodeContext { + limits: EncodeLimits, + depth: usize, + values: usize, } impl DecodeContext { @@ -99,21 +191,36 @@ impl DecodeContext { limits, depth: 0, values: 0, + allocated_bytes: 0, } } - fn value(&mut self) -> Option<()> { - self.values = self.values.checked_add(1)?; - (self.values <= self.limits.max_values).then_some(()) + fn allocate(&mut self, bytes: usize) -> Result<(), DecodeFailure> { + self.allocated_bytes = self + .allocated_bytes + .checked_add(bytes) + .ok_or(DecodeFailure::AllocationLimit)?; + if self.allocated_bytes > self.limits.max_allocated_bytes { + return Err(DecodeFailure::AllocationLimit); + } + Ok(()) } - fn enter(&mut self) -> Option<()> { - self.depth = self.depth.checked_add(1)?; - if self.depth <= self.limits.max_depth { - Some(()) - } else { - None - } + fn value(&mut self) -> Result<(), DecodeFailure> { + self.values = self + .values + .checked_add(1) + .ok_or(DecodeFailure::ValueCountLimit)?; + (self.values <= self.limits.max_values) + .then_some(()) + .ok_or(DecodeFailure::ValueCountLimit) + } + + fn enter(&mut self) -> Result<(), DecodeFailure> { + self.depth = self.depth.checked_add(1).ok_or(DecodeFailure::DepthLimit)?; + (self.depth <= self.limits.max_depth) + .then_some(()) + .ok_or(DecodeFailure::DepthLimit) } fn leave(&mut self) { @@ -121,6 +228,52 @@ impl DecodeContext { } } +impl EncodeContext { + fn new(limits: EncodeLimits) -> Self { + Self { + limits, + depth: 0, + values: 0, + } + } + + fn value(&mut self) -> Result<(), CodecError> { + self.values = self + .values + .checked_add(1) + .ok_or(CodecError::TooManyEntries)?; + if self.values > self.limits.max_values { + return Err(CodecError::TooManyEntries); + } + Ok(()) + } + + fn enter(&mut self) -> Result<(), CodecError> { + self.depth = self + .depth + .checked_add(1) + .ok_or(CodecError::TooManyEntries)?; + if self.depth > self.limits.max_depth { + return Err(CodecError::TooManyEntries); + } + Ok(()) + } + + fn leave(&mut self) { + self.depth = self.depth.saturating_sub(1); + } + + fn check_output(&self, current: usize, additional: usize) -> Result<(), CodecError> { + let next = current + .checked_add(additional) + .ok_or(CodecError::TooManyEntries)?; + if next > self.limits.max_output_size { + return Err(CodecError::TooManyEntries); + } + Ok(()) + } +} + impl fmt::Display for DataKind { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -273,6 +426,31 @@ pub struct ProtectionPolicy { #[cfg(feature = "crypto")] impl Default for ProtectionPolicy { fn default() -> Self { + // Boundary verification should not silently widen when a new + // signature suite is compiled in. Callers that intentionally need + // the historical permissive behavior must opt into + // `ProtectionPolicy::any_supported()` explicitly. + Self::ed25519() + } +} + +#[cfg(feature = "crypto")] +impl ProtectionPolicy { + pub const fn ed25519() -> Self { + Self { + signature: SignaturePolicy::Ed25519, + } + } + + pub const fn dual() -> Self { + Self { + signature: SignaturePolicy::Dual, + } + } + + /// Explicit compatibility profile for callers that must accept every + /// signature suite compiled into the current build. + pub const fn any_supported() -> Self { Self { signature: SignaturePolicy::AnySupported, } @@ -310,6 +488,8 @@ pub enum ProtectionError { SignerIdMismatch { expected: u64, actual: u64 }, #[error("no verification key for signer ID {0}")] SignerKeyNotFound(u64), + #[error("protected resource limit exceeded: {0}")] + ResourceLimit(&'static str), #[error("codec error: {0}")] Codec(#[from] CodecError), #[error("crypto error: {0}")] @@ -557,7 +737,20 @@ impl DataValue { purpose: ProtectionPurpose, signer: &(impl SignatureScheme + ?Sized), ) -> Result { - let inner = self.to_bytes()?; + self.sign_with_limits(signer_id, purpose, signer, EncodeLimits::default()) + } + + /// Sign after bounding the recursive serialization used to construct the + /// authenticated bytes. + #[cfg(feature = "crypto")] + pub fn sign_with_limits( + self, + signer_id: u64, + purpose: ProtectionPurpose, + signer: &(impl SignatureScheme + ?Sized), + limits: EncodeLimits, + ) -> Result { + let inner = self.to_bytes_with_limits(limits)?; let algorithm = signer.algorithm(); let signing_bytes = signed_message(algorithm, purpose.0, signer_id, &inner); let signature = signer.sign(&signing_bytes)?; @@ -572,6 +765,7 @@ impl DataValue { } #[cfg(feature = "crypto")] + #[deprecated(note = "migrate to verify_with_policy with an explicit ProtectionPolicy")] /// Verify with the compatibility policy that accepts any supported suite. /// Protocol boundaries should prefer [`Self::verify_with_policy`]. pub fn verify( @@ -584,7 +778,7 @@ impl DataValue { expected_signer_id, public_keys, expected_purpose, - ProtectionPolicy::default(), + ProtectionPolicy::any_supported(), ) } @@ -605,6 +799,7 @@ impl DataValue { } #[cfg(feature = "crypto")] + #[deprecated(note = "migrate to verify_with_resolver_policy with an explicit ProtectionPolicy")] pub fn verify_with( &self, resolve: F, @@ -613,7 +808,11 @@ impl DataValue { where F: FnOnce(u64) -> Option, { - self.verify_with_resolver_policy(resolve, expected_purpose, ProtectionPolicy::default()) + self.verify_with_resolver_policy( + resolve, + expected_purpose, + ProtectionPolicy::any_supported(), + ) } #[cfg(feature = "crypto")] @@ -637,6 +836,7 @@ impl DataValue { } #[cfg(feature = "crypto")] + #[deprecated(note = "migrate to into_verified_with_policy with an explicit ProtectionPolicy")] /// Consume a signed value using the compatibility policy that accepts any /// supported suite. Protocol boundaries should prefer the policy-aware /// counterpart. @@ -650,7 +850,7 @@ impl DataValue { expected_signer_id, public_keys, expected_purpose, - ProtectionPolicy::default(), + ProtectionPolicy::any_supported(), ) } @@ -679,7 +879,18 @@ impl DataValue { recipients: &[PublicKeyBundle], purpose: ProtectionPurpose, ) -> Result { - let plaintext = self.to_bytes()?; + self.encrypt_for_with_limits(recipients, purpose, EncodeLimits::default()) + } + + /// Encrypt after bounding the recursive serialization of the plaintext. + #[cfg(feature = "crypto")] + pub fn encrypt_for_with_limits( + self, + recipients: &[PublicKeyBundle], + purpose: ProtectionPurpose, + limits: EncodeLimits, + ) -> Result { + let plaintext = self.to_bytes_with_limits(limits)?; let message = mtp_crypto::encrypt_multi_for( EncryptionType::MlKemChaCha20Poly1305, purpose.0, @@ -703,21 +914,52 @@ impl DataValue { self.decrypt_with_limits(keyring, expected_purpose, DecodeLimits::default()) } - /// Try a local key history without exposing recipient-key identifiers on - /// the wire. Entries are attempted in the caller's preferred order. + // Migrate to `decrypt_with_limits` or + // `decrypt_with_keyrings_and_limits` at a protocol boundary so the + // receive policy is not replaced by an intermediate default. + #[deprecated(note = "migrate to decrypt_with_keyrings_and_limits with explicit DecodeLimits")] #[cfg(feature = "crypto")] pub fn decrypt_with_keyrings( &self, keyrings: &[&Keyring], expected_purpose: ProtectionPurpose, + ) -> Result { + self.decrypt_with_keyrings_and_limits(keyrings, expected_purpose, DecodeLimits::default()) + } + + /// Try a local key history without exposing recipient-key identifiers on + /// the wire, parsing each successful plaintext with the supplied policy. + #[cfg(feature = "crypto")] + pub fn decrypt_with_keyrings_and_limits( + &self, + keyrings: &[&Keyring], + expected_purpose: ProtectionPurpose, + limits: DecodeLimits, ) -> Result { if keyrings.is_empty() { return Err(ProtectionError::NoMatchingRecipient); } + let ciphertext_len = match self { + Self::Encrypted(value) => value.ciphertext.len(), + _ => return Err(ProtectionError::NotEncrypted), + }; + let mut remaining_allocations = limits.max_allocated_bytes; for keyring in keyrings { - match self.decrypt(keyring, expected_purpose) { + if ciphertext_len > remaining_allocations { + return Err(ProtectionError::ResourceLimit("decryption attempts")); + } + let attempt_limits = DecodeLimits { + max_allocated_bytes: remaining_allocations, + ..limits + }; + match self.decrypt_with_limits(keyring, expected_purpose, attempt_limits) { Ok(value) => return Ok(value), - Err(ProtectionError::NoMatchingRecipient) => {} + Err(ProtectionError::NoMatchingRecipient) => { + /* A failed attempt may have allocated a plaintext buffer + as large as the ciphertext. Reserve that upper bound + before trying the next historical key. */ + remaining_allocations -= ciphertext_len; + } Err(error) => return Err(error), } } @@ -737,83 +979,180 @@ impl DataValue { 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) + if value.ciphertext.len() > limits.max_allocated_bytes { + return Err(ProtectionError::ResourceLimit("decrypted plaintext")); + } + let plaintext = mtp_crypto::decrypt_multi_for_parts_with_limit( + value.encryption_type, + value.purpose, + &value.recipients, + &value.ciphertext, + expected_purpose.0, + keyring, + limits.max_allocated_bytes, + ) + .map_err(protection_error_from_decryption)?; + if plaintext.len() > limits.max_allocated_bytes { + return Err(ProtectionError::ResourceLimit("decrypted plaintext")); + } + let mut decode_limits = limits; + decode_limits.max_allocated_bytes -= plaintext.len(); + Self::try_from_bytes_with_limits(&plaintext, decode_limits).map_err(|error| match error { + DecodeError::DepthLimit => ProtectionError::ResourceLimit("decrypted value depth"), + DecodeError::ValueCountLimit => ProtectionError::ResourceLimit("decrypted value count"), + DecodeError::BlobLimit => ProtectionError::ResourceLimit("decrypted blob"), + DecodeError::AllocationLimit => { + ProtectionError::ResourceLimit("decrypted value allocation") + } + DecodeError::RecipientLimit => ProtectionError::ResourceLimit("decrypted recipients"), + DecodeError::MalformedEncoding | DecodeError::DuplicateField => { + ProtectionError::Malformed + } + }) } + /// Encode with the compatibility resource policy. + /// + /// New protocol boundaries should pass an explicit [`EncodeLimits`] value + /// derived from their admission policy. pub fn to_bytes(&self) -> Result, CodecError> { + self.to_bytes_with_limits(EncodeLimits::default()) + } + + pub fn to_bytes_with_limits(&self, limits: EncodeLimits) -> Result, CodecError> { let mut out = Vec::new(); - self.write_to(&mut out)?; + self.write_to_with_limits(&mut out, limits)?; Ok(out) } + /// Encode into an existing output buffer while enforcing depth, node, and + /// serialized-size limits before recursive output is produced. 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.write_to_with_limits(out, EncodeLimits::default()) + } + + pub fn write_to_with_limits( + &self, + out: &mut Vec, + limits: EncodeLimits, + ) -> Result<(), CodecError> { + let mut sizing = EncodeContext::new(limits); + let size = self.encoded_len_with_context(&mut sizing)?; + sizing.check_output(out.len(), size)?; + + let mut context = EncodeContext::new(limits); + self.write_to_with_context(out, &mut context) + } + + fn encoded_len_with_context(&self, context: &mut EncodeContext) -> Result { + context.value()?; + let size = match self { + Self::BoolTrue | Self::BoolFalse | Self::Bool(_) | Self::Null => 1, + Self::SignedNumber(_) | Self::UnsignedNumber(_) => 1 + 16, + Self::Float(_) => 1 + 8, + Self::Str(value) => checked_add(1, blob_len(value.as_bytes())?)?, + Self::Bytes(value) => checked_add(1, blob_len(value)?)?, Self::Array(values) => { - write_count(out, values.len())?; + context.enter()?; + let _ = checked_count(values.len())?; + let mut size = 1 + 2; for value in values { - value.write_to(out)?; + size = checked_add(size, value.encoded_len_with_context(context)?)?; } + context.leave(); + size } 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)?; + context.enter()?; + let _ = checked_count(entries.len())?; + let mut size = 1 + 2; + for (_, value) in entries { + size = checked_add( + checked_add(size, 2)?, + value.encoded_len_with_context(context)?, + )?; } + context.leave(); + size } #[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 = + context.enter()?; + let signature_len = SigAlgorithm::length(value.algorithm).ok_or(CodecError::InvalidEncoding)?; - if value.signature.len() != expected { + if value.signature.len() != signature_len { return Err(CodecError::InvalidEncoding); } - wrapper.extend_from_slice(&value.signature); - value.value.write_to(&mut wrapper)?; - write_blob(out, &wrapper)?; + let inner_len = value.value.encoded_len_with_context(context)?; + context.leave(); + let wrapper_len = checked_add(1 + 1 + 8 + signature_len, inner_len)?; + checked_add(1, checked_add(4, wrapper_len)?)? } #[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)?; + let envelope_len = encrypted_envelope_len(value)?; + checked_add(1, checked_add(4, envelope_len)?)? } + }; + Ok(size) + } + + fn write_to_with_context( + &self, + out: &mut Vec, + context: &mut EncodeContext, + ) -> Result<(), CodecError> { + context.value()?; + append_bytes(out, context, &[Self::kind_marker(self)])?; + match self { + Self::BoolTrue | Self::BoolFalse | Self::Bool(_) | Self::Null => {} + Self::SignedNumber(value) => append_bytes(out, context, &value.to_be_bytes())?, + Self::UnsignedNumber(value) => append_bytes(out, context, &value.to_be_bytes())?, + Self::Float(value) => append_bytes(out, context, &value.to_bits().to_be_bytes())?, + Self::Str(value) => write_blob_with_context(out, context, value.as_bytes())?, + Self::Bytes(value) => write_blob_with_context(out, context, value)?, + Self::Array(values) => { + context.enter()?; + append_bytes(out, context, &checked_count(values.len())?.to_be_bytes())?; + for value in values { + value.write_to_with_context(out, context)?; + } + context.leave(); + } + Self::Container(entries) => { + ensure_unique_container_fields(entries)?; + context.enter()?; + append_bytes(out, context, &checked_count(entries.len())?.to_be_bytes())?; + for (id, value) in entries { + append_bytes(out, context, &id.0.to_be_bytes())?; + value.write_to_with_context(out, context)?; + } + context.leave(); + } + #[cfg(feature = "crypto")] + Self::Signed(value) => { + context.enter()?; + let signature_len = + SigAlgorithm::length(value.algorithm).ok_or(CodecError::InvalidEncoding)?; + if value.signature.len() != signature_len { + return Err(CodecError::InvalidEncoding); + } + let inner_len = value + .value + .encoded_len_with_context(&mut EncodeContext::new(context.limits))?; + let wrapper_len = checked_add(1 + 1 + 8 + signature_len, inner_len)?; + let wrapper_len = + u32::try_from(wrapper_len).map_err(|_| CodecError::InvalidEncoding)?; + append_bytes(out, context, &wrapper_len.to_be_bytes())?; + append_bytes(out, context, &[value.algorithm, value.purpose])?; + append_bytes(out, context, &value.signer_id.to_be_bytes())?; + append_bytes(out, context, &value.signature)?; + value.value.write_to_with_context(out, context)?; + context.leave(); + } + #[cfg(feature = "crypto")] + Self::Encrypted(value) => write_encrypted_with_context(out, context, value)?, } Ok(()) } @@ -823,9 +1162,23 @@ impl DataValue { } pub fn from_bytes_with_limits(bytes: &[u8], limits: DecodeLimits) -> Option { + Self::try_from_bytes_with_limits(bytes, limits).ok() + } + + pub fn try_from_bytes(bytes: &[u8]) -> Result { + Self::try_from_bytes_with_limits(bytes, DecodeLimits::default()) + } + + pub fn try_from_bytes_with_limits( + bytes: &[u8], + limits: DecodeLimits, + ) -> Result { 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) + let value = Self::read_from_with_diagnostics(&mut cursor, limits)?; + if cursor.position() as usize != bytes.len() { + return Err(DecodeError::MalformedEncoding); + } + Ok(value) } pub fn read_from(cursor: &mut Cursor<&[u8]>) -> Result { @@ -836,8 +1189,15 @@ impl DataValue { cursor: &mut Cursor<&[u8]>, limits: DecodeLimits, ) -> Result { + Self::read_from_with_diagnostics(cursor, limits).map_err(|_| CodecError::InvalidEncoding) + } + + pub fn read_from_with_diagnostics( + cursor: &mut Cursor<&[u8]>, + limits: DecodeLimits, + ) -> Result { let mut context = DecodeContext::new(limits); - Self::read_value(cursor, &mut context).ok_or(CodecError::InvalidEncoding) + Self::read_value(cursor, &mut context) } pub fn to_base64(&self) -> Result { @@ -877,67 +1237,124 @@ impl DataValue { } } - fn read_value(cursor: &mut Cursor<&[u8]>, context: &mut DecodeContext) -> Option { + fn read_value( + cursor: &mut Cursor<&[u8]>, + context: &mut DecodeContext, + ) -> Result { 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()?)), + match cursor + .read_u8() + .map_err(|_| DecodeFailure::MalformedEncoding)? + { + Self::KIND_BOOL_TRUE => Ok(Self::BoolTrue), + Self::KIND_BOOL_FALSE => Ok(Self::BoolFalse), + Self::KIND_SIGNED_NUMBER => Ok(Self::SignedNumber( + cursor + .read_i128::() + .map_err(|_| DecodeFailure::MalformedEncoding)?, + )), + Self::KIND_UNSIGNED_NUMBER => Ok(Self::UnsignedNumber( + cursor + .read_u128::() + .map_err(|_| DecodeFailure::MalformedEncoding)?, + )), + Self::KIND_FLOAT => Ok(Self::Float( + cursor + .read_f64::() + .map_err(|_| DecodeFailure::MalformedEncoding)?, + )), Self::KIND_STR => { - let bytes = read_blob(cursor, context.limits.max_blob_size)?; - Some(Self::Str(String::from_utf8(bytes).ok()?)) + let bytes = read_blob_owned(cursor, context)?; + Ok(Self::Str( + String::from_utf8(bytes).map_err(|_| DecodeFailure::MalformedEncoding)?, + )) } - Self::KIND_BYTES => Some(Self::Bytes(read_blob( - cursor, - context.limits.max_blob_size, - )?)), + Self::KIND_BYTES => Ok(Self::Bytes(read_blob_owned(cursor, context)?)), Self::KIND_ARRAY => { context.enter()?; - let count = cursor.read_u16::().ok()? as usize; - let mut values = Vec::with_capacity(count.min(remaining(cursor))); + let count = cursor + .read_u16::() + .map_err(|_| DecodeFailure::MalformedEncoding)? + as usize; + context.allocate( + count + .checked_mul(size_of::()) + .ok_or(DecodeFailure::AllocationLimit)?, + )?; + let mut values = Vec::with_capacity(count); for _ in 0..count { values.push(Self::read_value(cursor, context)?); } context.leave(); - Some(Self::Array(values)) + Ok(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(); + let count = cursor + .read_u16::() + .map_err(|_| DecodeFailure::MalformedEncoding)? + as usize; + context.allocate( + count + .checked_mul(size_of::<(DataTypeId, DataValue)>()) + .ok_or(DecodeFailure::AllocationLimit)?, + )?; + let mut values = Vec::with_capacity(count); + /* DataTypeId is a u16, so a fixed bitset gives duplicate + detection a predictable allocation instead of hidden + per-node BTreeSet allocations. */ + let seen_words = (usize::from(u16::MAX) + 1) / 64; + context.allocate( + seen_words + .checked_mul(size_of::()) + .ok_or(DecodeFailure::AllocationLimit)?, + )?; + let mut seen = vec![0_u64; seen_words]; for _ in 0..count { - let id = DataTypeId(cursor.read_u16::().ok()?); - if !seen.insert(id) { - return None; + let id = DataTypeId( + cursor + .read_u16::() + .map_err(|_| DecodeFailure::MalformedEncoding)?, + ); + let index = usize::from(id.0); + let word = index / 64; + let bit = 1_u64 << (index % 64); + if seen[word] & bit != 0 { + return Err(DecodeFailure::DuplicateField); } + seen[word] |= bit; values.push((id, Self::read_value(cursor, context)?)); } context.leave(); - Some(Self::Container(values)) + Ok(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 wrapper = read_blob_slice(cursor, context.limits.max_blob_size)?; + let mut inner = Cursor::new(wrapper); + let algorithm = inner + .read_u8() + .map_err(|_| DecodeFailure::MalformedEncoding)?; + let purpose = inner + .read_u8() + .map_err(|_| DecodeFailure::MalformedEncoding)?; + let signer_id = inner + .read_u64::() + .map_err(|_| DecodeFailure::MalformedEncoding)?; + let signature_len = + SigAlgorithm::length(algorithm).ok_or(DecodeFailure::MalformedEncoding)?; + context.allocate(signature_len)?; + let signature = read_slice(&mut inner, signature_len) + .ok_or(DecodeFailure::MalformedEncoding)? + .to_vec(); let value = Self::read_value(&mut inner, context)?; if inner.position() as usize != wrapper.len() { - return None; + return Err(DecodeFailure::MalformedEncoding); } + context.allocate(size_of::())?; context.leave(); - Some(Self::Signed(SignedValue { + Ok(Self::Signed(SignedValue { algorithm, purpose, signer_id, @@ -947,27 +1364,51 @@ impl DataValue { } #[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; + let envelope = read_blob_slice(cursor, context.limits.max_blob_size)?; + let message = mtp_crypto::MultiEncryptedMessageRef::from_bytes(envelope) + .map_err(|_| DecodeFailure::MalformedEncoding)?; + if message.recipient_count() > context.limits.max_recipients { + return Err(DecodeFailure::RecipientLimit); } - Some(Self::Encrypted(EncryptedValue { - encryption_type: message.encryption_type, - purpose: message.purpose, - recipients: message.recipients, - ciphertext: message.ciphertext, + let kem_len = message.encryption_type().kem_ciphertext_len(); + let wrapped_len = message.encryption_type().wrapped_key_len(); + let entry_size = size_of::() + .checked_add(kem_len) + .and_then(|size| size.checked_add(wrapped_len)) + .ok_or(DecodeFailure::AllocationLimit)?; + let owned_size = message + .recipient_count() + .checked_mul(entry_size) + .and_then(|size| size.checked_add(message.ciphertext().len())) + .ok_or(DecodeFailure::AllocationLimit)?; + context.allocate(owned_size)?; + let mut recipients = Vec::with_capacity(message.recipient_count()); + for index in 0..message.recipient_count() { + let (kem_ciphertext, encrypted_key) = message + .recipient(index) + .ok_or(DecodeFailure::MalformedEncoding)?; + recipients.push(mtp_crypto::RecipientEntry { + kem_ciphertext: kem_ciphertext.to_vec(), + encrypted_key: encrypted_key.to_vec(), + }); + } + Ok(Self::Encrypted(EncryptedValue { + encryption_type: message.encryption_type(), + purpose: message.purpose(), + recipients, + ciphertext: message.ciphertext().to_vec(), })) } - Self::KIND_NULL => Some(Self::Null), + Self::KIND_NULL => Ok(Self::Null), // 0x0C was the old SignedEncryptedContainer kind and is reserved. - _ => None, + _ => Err(DecodeFailure::MalformedEncoding), } } } #[cfg(feature = "crypto")] impl SignedValue { + #[deprecated(note = "migrate to verify_with_policy with an explicit ProtectionPolicy")] pub fn verify( &self, expected_signer_id: u64, @@ -978,7 +1419,7 @@ impl SignedValue { expected_signer_id, public_keys, expected_purpose, - ProtectionPolicy::default(), + ProtectionPolicy::any_supported(), ) } @@ -988,6 +1429,25 @@ impl SignedValue { public_keys: &PublicKeyBundle, expected_purpose: ProtectionPurpose, policy: ProtectionPolicy, + ) -> Result<(), ProtectionError> { + self.verify_with_policy_and_limits( + expected_signer_id, + public_keys, + expected_purpose, + policy, + EncodeLimits::default(), + ) + } + + /// Verify a signed value while bounding the serialization used to + /// reconstruct its authenticated bytes. + pub fn verify_with_policy_and_limits( + &self, + expected_signer_id: u64, + public_keys: &PublicKeyBundle, + expected_purpose: ProtectionPurpose, + policy: ProtectionPolicy, + limits: EncodeLimits, ) -> Result<(), ProtectionError> { if self.signer_id != expected_signer_id { return Err(ProtectionError::SignerIdMismatch { @@ -1008,7 +1468,13 @@ impl SignedValue { }); } validate_signature(self.algorithm, &self.signature)?; - let inner = self.value.to_bytes()?; + let inner = self.value.to_bytes_with_limits(limits).map_err(|error| { + if matches!(error, CodecError::TooManyEntries) { + ProtectionError::ResourceLimit("signed value encoding") + } else { + ProtectionError::Codec(error) + } + })?; let message = signed_message(self.algorithm, self.purpose, self.signer_id, &inner); let result = match self.algorithm { SigAlgorithm::ED25519 => mtp_crypto::verify_ed25519( @@ -1044,6 +1510,7 @@ impl SignedValue { result.map_err(|_| ProtectionError::InvalidSignature) } + #[deprecated(note = "migrate to into_verified_with_policy with an explicit ProtectionPolicy")] /// Verify this signed wrapper and return its inner value. pub fn into_verified( self, @@ -1055,7 +1522,7 @@ impl SignedValue { expected_signer_id, public_keys, expected_purpose, - ProtectionPolicy::default(), + ProtectionPolicy::any_supported(), ) } @@ -1072,6 +1539,7 @@ impl SignedValue { Ok(*self.value) } + #[deprecated(note = "migrate to verify_with_resolver_policy with an explicit ProtectionPolicy")] pub fn verify_with( &self, resolve: F, @@ -1080,7 +1548,11 @@ impl SignedValue { where F: FnOnce(u64) -> Option, { - self.verify_with_resolver_policy(resolve, expected_purpose, ProtectionPolicy::default()) + self.verify_with_resolver_policy( + resolve, + expected_purpose, + ProtectionPolicy::any_supported(), + ) } pub fn verify_with_resolver_policy( @@ -1107,11 +1579,32 @@ impl SignedValue { expected_purpose: ProtectionPurpose, policy: ProtectionPolicy, ) -> Result<(), ProtectionError> { - self.verify_with_key_history_index( + self.verify_with_key_history_index_and_limits( expected_signer_id, public_keys, expected_purpose, policy, + EncodeLimits::default(), + ) + .map(|_| ()) + } + + /// Verify against a signing-key history with a bounded authenticated-byte + /// reconstruction policy. + pub fn verify_with_key_history_and_limits( + &self, + expected_signer_id: u64, + public_keys: &[PublicKeyBundle], + expected_purpose: ProtectionPurpose, + policy: ProtectionPolicy, + limits: EncodeLimits, + ) -> Result<(), ProtectionError> { + self.verify_with_key_history_index_and_limits( + expected_signer_id, + public_keys, + expected_purpose, + policy, + limits, ) .map(|_| ()) } @@ -1124,11 +1617,35 @@ impl SignedValue { public_keys: &[PublicKeyBundle], expected_purpose: ProtectionPurpose, policy: ProtectionPolicy, + ) -> Result { + self.verify_with_key_history_index_and_limits( + expected_signer_id, + public_keys, + expected_purpose, + policy, + EncodeLimits::default(), + ) + } + + /// Verify against a signing-key history and bound every authenticated + /// value serialization attempt. + pub fn verify_with_key_history_index_and_limits( + &self, + expected_signer_id: u64, + public_keys: &[PublicKeyBundle], + expected_purpose: ProtectionPurpose, + policy: ProtectionPolicy, + limits: EncodeLimits, ) -> 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) - { + match self.verify_with_policy_and_limits( + expected_signer_id, + public_key, + expected_purpose, + policy, + limits, + ) { Ok(()) => return Ok(index), Err(error @ ProtectionError::InvalidSignature) => last_error = Some(error), Err(error @ ProtectionError::Crypto(_)) => last_error = Some(error), @@ -1165,14 +1682,34 @@ fn protection_error_from_decryption(error: mtp_crypto::CryptoError) -> Protectio match error { mtp_crypto::CryptoError::MalformedEnvelope => ProtectionError::Malformed, mtp_crypto::CryptoError::NoMatchingRecipient => ProtectionError::NoMatchingRecipient, + mtp_crypto::CryptoError::AllocationLimit => { + ProtectionError::ResourceLimit("decrypted plaintext") + } 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 checked_count(count: usize) -> Result { + u16::try_from(count).map_err(|_| CodecError::TooManyEntries) +} + +fn checked_add(left: usize, right: usize) -> Result { + left.checked_add(right).ok_or(CodecError::TooManyEntries) +} + +fn blob_len(bytes: &[u8]) -> Result { + let _ = u32::try_from(bytes.len()).map_err(|_| CodecError::InvalidEncoding)?; + checked_add(4, bytes.len()) +} + +fn append_bytes( + out: &mut Vec, + context: &EncodeContext, + bytes: &[u8], +) -> Result<(), CodecError> { + context.check_output(out.len(), bytes.len())?; + out.extend_from_slice(bytes); + Ok(()) } fn ensure_unique_container_fields(entries: &[(DataTypeId, DataValue)]) -> Result<(), CodecError> { @@ -1184,20 +1721,85 @@ fn ensure_unique_container_fields(entries: &[(DataTypeId, DataValue)]) -> Result } } -fn write_blob(out: &mut Vec, bytes: &[u8]) -> Result<(), CodecError> { +fn write_blob_with_context( + out: &mut Vec, + context: &EncodeContext, + 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(()) + append_bytes(out, context, &len.to_be_bytes())?; + append_bytes(out, context, bytes) } -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; +#[cfg(feature = "crypto")] +fn encrypted_envelope_len(value: &EncryptedValue) -> Result { + let kem_len = value.encryption_type.kem_ciphertext_len(); + let wrapped_len = value.encryption_type.wrapped_key_len(); + if value.recipients.is_empty() + || value.recipients.len() > mtp_crypto::MAX_RECIPIENTS + || value.ciphertext.len() < value.encryption_type.minimum_ciphertext_len() + || value.recipients.iter().any(|recipient| { + recipient.kem_ciphertext.len() != kem_len + || recipient.encrypted_key.len() != wrapped_len + }) + { + return Err(CodecError::InvalidEncoding); } - Some(read_slice(cursor, len)?.to_vec()) + let _ = checked_count(value.recipients.len())?; + let entry_len = kem_len + .checked_add(wrapped_len) + .ok_or(CodecError::TooManyEntries)?; + let entries_len = value + .recipients + .len() + .checked_mul(entry_len) + .ok_or(CodecError::TooManyEntries)?; + checked_add(4, checked_add(entries_len, value.ciphertext.len())?) +} + +#[cfg(feature = "crypto")] +fn write_encrypted_with_context( + out: &mut Vec, + context: &EncodeContext, + value: &EncryptedValue, +) -> Result<(), CodecError> { + let envelope_len = encrypted_envelope_len(value)?; + let envelope_len = u32::try_from(envelope_len).map_err(|_| CodecError::InvalidEncoding)?; + append_bytes(out, context, &envelope_len.to_be_bytes())?; + append_bytes( + out, + context, + &[value.encryption_type.to_byte(), value.purpose], + )?; + let count = checked_count(value.recipients.len())?; + append_bytes(out, context, &count.to_be_bytes())?; + for recipient in &value.recipients { + append_bytes(out, context, &recipient.kem_ciphertext)?; + append_bytes(out, context, &recipient.encrypted_key)?; + } + append_bytes(out, context, &value.ciphertext) +} + +fn read_blob_slice<'a>( + cursor: &mut Cursor<&'a [u8]>, + max_size: usize, +) -> Result<&'a [u8], DecodeFailure> { + let len = cursor + .read_u32::() + .map_err(|_| DecodeFailure::MalformedEncoding)? as usize; + if len > max_size { + return Err(DecodeFailure::BlobLimit); + } + read_slice(cursor, len).ok_or(DecodeFailure::MalformedEncoding) +} + +fn read_blob_owned( + cursor: &mut Cursor<&[u8]>, + context: &mut DecodeContext, +) -> Result, DecodeFailure> { + let bytes = read_blob_slice(cursor, context.limits.max_blob_size)?; + context.allocate(bytes.len())?; + Ok(bytes.to_vec()) } fn read_slice<'a>(cursor: &mut Cursor<&'a [u8]>, len: usize) -> Option<&'a [u8]> { @@ -1210,13 +1812,6 @@ fn read_slice<'a>(cursor: &mut Cursor<&'a [u8]>, len: usize) -> Option<&'a [u8]> 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 { @@ -1555,15 +2150,174 @@ mod tests { assert!(DataValue::from_bytes_with_limits(&bytes, limits).is_none()); } + #[test] + fn decoder_diagnostics_distinguish_policy_rejections() { + let nested = DataValue::Array(vec![DataValue::Array(vec![DataValue::BoolTrue])]); + let nested_bytes = nested.to_bytes().expect("nested value should encode"); + let mut limits = DecodeLimits { + max_depth: 1, + ..DecodeLimits::default() + }; + let mut cursor = Cursor::new(nested_bytes.as_slice()); + assert_eq!( + DataValue::read_from_with_diagnostics(&mut cursor, limits), + Err(DecodeFailure::DepthLimit) + ); + + let many = DataValue::Array(vec![DataValue::BoolTrue, DataValue::BoolFalse]); + let many_bytes = many.to_bytes().expect("array should encode"); + limits = DecodeLimits { + max_values: 2, + ..DecodeLimits::default() + }; + let mut cursor = Cursor::new(many_bytes.as_slice()); + assert_eq!( + DataValue::read_from_with_diagnostics(&mut cursor, limits), + Err(DecodeFailure::ValueCountLimit) + ); + + let allocation_blob = DataValue::Bytes(vec![1, 2, 3]); + let allocation_blob_bytes = allocation_blob.to_bytes().expect("blob should encode"); + limits = DecodeLimits { + max_blob_size: 2, + ..DecodeLimits::default() + }; + let mut cursor = Cursor::new(allocation_blob_bytes.as_slice()); + assert_eq!( + DataValue::read_from_with_diagnostics(&mut cursor, limits), + Err(DecodeFailure::BlobLimit) + ); + + let duplicate = [0x09, 0, 2, 0, 1, 0x01, 0, 1, 0x02]; + let mut cursor = Cursor::new(duplicate.as_slice()); + assert_eq!( + DataValue::read_from_with_diagnostics(&mut cursor, DecodeLimits::default()), + Err(DecodeFailure::DuplicateField) + ); + + let mut cursor = Cursor::new([0xFE].as_slice()); + assert_eq!( + DataValue::read_from_with_diagnostics(&mut cursor, DecodeLimits::default()), + Err(DecodeFailure::MalformedEncoding) + ); + + let blob = DataValue::Bytes(vec![1, 2, 3]); + let blob_bytes = blob.to_bytes().expect("blob should encode"); + let limits = DecodeLimits { + max_allocated_bytes: 2, + ..DecodeLimits::default() + }; + let mut cursor = Cursor::new(blob_bytes.as_slice()); + assert_eq!( + DataValue::read_from_with_diagnostics(&mut cursor, limits), + Err(DecodeFailure::AllocationLimit) + ); + } + + #[cfg(feature = "crypto")] + #[test] + fn nested_signed_values_hit_cumulative_allocation_limit() + -> Result<(), Box> { + use mtp_crypto::Ed25519Signer; + + let (signer, _, _) = Ed25519Signer::generate(); + let mut value = DataValue::Null; + for _ in 0..3 { + value = value.sign(7, ProtectionPurpose::from(1), &signer)?; + } + let bytes = value.to_bytes()?; + let limits = DecodeLimits { + max_allocated_bytes: 100, + ..DecodeLimits::default() + }; + let mut cursor = Cursor::new(bytes.as_slice()); + + assert_eq!( + DataValue::read_from_with_diagnostics(&mut cursor, limits), + Err(DecodeFailure::AllocationLimit) + ); + Ok(()) + } + + #[test] + fn encoder_limits_bound_nodes_depth_and_output() { + let value = DataValue::Array(vec![DataValue::BoolTrue]); + + let limits = EncodeLimits { + max_values: 1, + ..EncodeLimits::default() + }; + assert_eq!( + value.to_bytes_with_limits(limits), + Err(CodecError::TooManyEntries) + ); + + let limits = EncodeLimits { + max_depth: 0, + ..EncodeLimits::default() + }; + assert_eq!( + value.to_bytes_with_limits(limits), + Err(CodecError::TooManyEntries) + ); + + let limits = EncodeLimits { + max_output_size: 1, + ..EncodeLimits::default() + }; + assert_eq!( + value.to_bytes_with_limits(limits), + Err(CodecError::TooManyEntries) + ); + } + #[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_allocated_bytes, + 1024 * DEFAULT_TRANSPORT_ALLOCATION_FACTOR as usize + ); assert_eq!(limits.max_depth, DecodeLimits::default().max_depth); assert_eq!( limits.max_recipients, DecodeLimits::default().max_recipients ); + + let custom = DecodeLimits::for_transport_message_size_with_allocation_factor(1024, 2); + assert_eq!(custom.max_allocated_bytes, 2048); + } + + #[test] + fn decoder_allocation_budget_counts_utf8_bytes_and_capacity() { + let value = DataValue::Str("é".into()); + let bytes = value.to_bytes().expect("string should encode"); + + let mut limits = DecodeLimits { + max_allocated_bytes: 1, + ..DecodeLimits::default() + }; + let mut cursor = Cursor::new(bytes.as_slice()); + assert_eq!( + DataValue::read_from_with_diagnostics(&mut cursor, limits), + Err(DecodeFailure::AllocationLimit) + ); + + limits.max_allocated_bytes = "é".len(); + assert_eq!( + DataValue::try_from_bytes_with_limits(&bytes, limits), + Ok(value) + ); + + let array = DataValue::Array(vec![DataValue::BoolTrue]); + let array_bytes = array.to_bytes().expect("array should encode"); + limits.max_allocated_bytes = size_of::() - 1; + let mut cursor = Cursor::new(array_bytes.as_slice()); + assert_eq!( + DataValue::read_from_with_diagnostics(&mut cursor, limits), + Err(DecodeFailure::AllocationLimit) + ); } #[test] @@ -1680,6 +2434,58 @@ mod tests { Ok(()) } + #[cfg(feature = "crypto")] + #[test] + fn reordered_containers_have_distinct_signed_bytes() -> Result<(), Box> { + use mtp_crypto::Ed25519Signer; + + let (signer, _, _) = Ed25519Signer::generate(); + let first = DataValue::Container(vec![ + (DataTypeId(1), DataValue::BoolTrue), + (DataTypeId(2), DataValue::BoolFalse), + ]) + .sign(1, ProtectionPurpose::from(7), &signer)?; + let second = DataValue::Container(vec![ + (DataTypeId(2), DataValue::BoolFalse), + (DataTypeId(1), DataValue::BoolTrue), + ]) + .sign(1, ProtectionPurpose::from(7), &signer)?; + + assert_ne!(first.to_bytes()?, second.to_bytes()?); + Ok(()) + } + + #[cfg(feature = "crypto")] + #[test] + fn encrypted_decode_limits_bound_owned_entries() -> Result<(), Box> { + use mtp_crypto::Keyring; + + let keyring = Keyring::generate(); + let encrypted = DataValue::Bytes(vec![0xAB; 32]).encrypt_for( + std::slice::from_ref(&keyring.public_key_bundle()), + ProtectionPurpose::from(9), + )?; + let bytes = encrypted.to_bytes()?; + let mut limits = DecodeLimits { + max_allocated_bytes: 1, + ..DecodeLimits::default() + }; + let mut cursor = Cursor::new(bytes.as_slice()); + assert_eq!( + DataValue::read_from_with_diagnostics(&mut cursor, limits), + Err(DecodeFailure::AllocationLimit) + ); + + limits.max_allocated_bytes = usize::MAX; + limits.max_recipients = 0; + let mut cursor = Cursor::new(bytes.as_slice()); + assert_eq!( + DataValue::read_from_with_diagnostics(&mut cursor, limits), + Err(DecodeFailure::RecipientLimit) + ); + Ok(()) + } + #[cfg(feature = "crypto")] #[test] fn signed_value_authenticates_its_metadata_and_inner_value() diff --git a/codec/src/lib.rs b/codec/src/lib.rs index c910cd8..fe0af7f 100644 --- a/codec/src/lib.rs +++ b/codec/src/lib.rs @@ -11,20 +11,33 @@ pub use data_value::{ ApplicationProtectionPurpose, EncryptedValue, MtpProtectionPurpose, ProtectionError, ProtectionPolicy, ProtectionPurpose, ProtectionPurposeError, SignaturePolicy, SignedValue, }; -pub use data_value::{DataKind, DataValue, DecodeLimits}; +pub use data_value::{ + DEFAULT_TRANSPORT_ALLOCATION_FACTOR, DataKind, DataValue, DecodeError, DecodeLimits, + EncodeLimits, +}; pub use mtp_common::{CodecError, TimeError, unix_time_millis}; #[cfg(feature = "crypto")] +#[allow(deprecated)] pub use protected::{ - CURRENT_PROTECTED_VERSION, InMemoryReplayGuard, ProtectedError, ProtectedMessageBuilder, - ProtectedOpenOptions, ReplayError, ReplayGuard, VerifiedProtectedMessage, open_protected, - open_protected_with, open_protected_with_keys, protected_claimed_signer_id, + CURRENT_PROTECTED_VERSION, InMemoryReplayGuard, ProtectedError, ProtectedLimits, + ProtectedMessageBuilder, ProtectedOpenOptions, ReplayError, ReplayGuard, + VerifiedProtectedMessage, open_protected_checked, open_protected_with_checked, + open_protected_with_keys_checked, open_protected_with_keys_without_replay, + open_protected_with_without_replay, open_protected_without_replay, protected_claimed_signer_id, + protected_claimed_signer_id_with_limits, protected_claimed_signer_id_with_options, }; #[cfg(feature = "crypto")] +#[allow(deprecated)] pub use relay::{ - CURRENT_RELAY_VERSION, RelayError, SealedRelayBuilder, VerifiedRelayContent, + CURRENT_RELAY_VERSION, RelayError, RelayOpenOptions, 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, + open_relay_content_with_keyrings, open_relay_content_with_keyrings_and_limits, + open_relay_content_with_keys, open_relay_content_with_limits, + open_relay_content_with_limits_without_replay, open_relay_metadata_checked, + open_relay_metadata_with_checked, open_relay_metadata_with_limits_checked, + open_relay_metadata_with_limits_without_replay, open_relay_metadata_with_without_replay, + open_relay_metadata_without_replay, relay_metadata_claimed_signer_id, + relay_metadata_claimed_signer_id_with_limits, relay_metadata_claimed_signer_id_with_options, }; pub use mtp_type_map::{ diff --git a/codec/src/protected.rs b/codec/src/protected.rs index e68689f..cd8ee0f 100644 --- a/codec/src/protected.rs +++ b/codec/src/protected.rs @@ -7,15 +7,39 @@ #![cfg(feature = "crypto")] use mtp_crypto::{Keyring, PublicKeyBundle, SignatureScheme}; -use mtp_type_map::{CommunicationType, DataType, TypeMap}; -use std::collections::HashSet; +use mtp_type_map::{CommunicationType, DataType, DataTypeId, TypeMap}; +use std::collections::{HashSet, VecDeque}; -use crate::{CommunicationValue, DataValue, ProtectionError, ProtectionPolicy, ProtectionPurpose}; +use crate::{ + CommunicationValue, DataValue, DecodeLimits, EncodeLimits, ProtectionError, ProtectionPolicy, + ProtectionPurpose, +}; /// The direct protected-message envelope schema version emitted by this /// codec. pub const CURRENT_PROTECTED_VERSION: u64 = 1; +/// Semantic limits for fields that are retained after a protected message is +/// opened. These are intentionally separate from generic transport blobs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ProtectedLimits { + pub max_message_id_bytes: usize, + pub max_metadata_encoded_bytes: usize, + pub max_signer_key_history: usize, + pub max_decryption_key_history: usize, +} + +impl Default for ProtectedLimits { + fn default() -> Self { + Self { + max_message_id_bytes: 256, + max_metadata_encoded_bytes: 1024 * 1024, + max_signer_key_history: 8, + max_decryption_key_history: 8, + } + } +} + #[derive(Debug, thiserror::Error)] pub enum ProtectedError { #[error("value is not an application communication frame")] @@ -46,6 +70,8 @@ pub enum ProtectedError { ReservedApplicationType(String), #[error("protected message was already accepted")] Replay, + #[error("protected resource limit exceeded: {0}")] + ResourceLimit(&'static str), #[error("protection error: {0}")] Protection(#[from] ProtectionError), #[error("replay guard error: {0}")] @@ -78,9 +104,35 @@ pub enum ReplayError { /// Small in-memory guard useful for tests and short-lived clients. Production /// consumers should implement [`ReplayGuard`] over persistent storage. -#[derive(Debug, Default)] +#[derive(Debug)] pub struct InMemoryReplayGuard { accepted: HashSet<(u64, String)>, + order: VecDeque<(u64, String)>, + capacity: usize, +} + +impl Default for InMemoryReplayGuard { + fn default() -> Self { + Self::with_capacity(10_000) + } +} + +impl InMemoryReplayGuard { + pub fn new(capacity: usize) -> Self { + Self::with_capacity(capacity) + } + + pub fn with_capacity(capacity: usize) -> Self { + Self { + accepted: HashSet::new(), + order: VecDeque::new(), + capacity, + } + } + + pub fn len(&self) -> usize { + self.accepted.len() + } } impl ReplayGuard for InMemoryReplayGuard { @@ -90,7 +142,21 @@ impl ReplayGuard for InMemoryReplayGuard { message_id: &str, _created_at: u64, ) -> Result { - Ok(self.accepted.insert((signer_id, message_id.to_owned()))) + let key = (signer_id, message_id.to_owned()); + if self.accepted.contains(&key) { + return Ok(false); + } + if self.capacity == 0 { + return Ok(false); + } + self.accepted.insert(key.clone()); + self.order.push_back(key); + while self.accepted.len() > self.capacity { + if let Some(oldest) = self.order.pop_front() { + self.accepted.remove(&oldest); + } + } + Ok(true) } } @@ -110,6 +176,8 @@ pub struct ProtectedMessageBuilder<'a> { type_map: Option, frame_id: Option, expose_sender: bool, + limits: ProtectedLimits, + encode_limits: EncodeLimits, } impl<'a> ProtectedMessageBuilder<'a> { @@ -136,6 +204,8 @@ impl<'a> ProtectedMessageBuilder<'a> { type_map: None, frame_id: None, expose_sender: false, + limits: ProtectedLimits::default(), + encode_limits: EncodeLimits::default(), } } @@ -176,6 +246,16 @@ impl<'a> ProtectedMessageBuilder<'a> { self } + pub fn protected_limits(mut self, limits: ProtectedLimits) -> Self { + self.limits = limits; + self + } + + pub fn encode_limits(mut self, limits: EncodeLimits) -> Self { + self.encode_limits = limits; + self + } + pub fn build(self) -> Result { let message_id = self.message_id.ok_or(ProtectedError::InvalidLayout( "protected builder requires a message ID", @@ -188,6 +268,9 @@ impl<'a> ProtectedMessageBuilder<'a> { "protected identifiers must be non-empty", )); } + if message_id.len() > self.limits.max_message_id_bytes { + return Err(ProtectedError::ResourceLimit("message ID")); + } if self.recipients.is_empty() { return Err(ProtectedError::InvalidLayout( "protected builder requires at least one recipient", @@ -217,8 +300,17 @@ impl<'a> ProtectedMessageBuilder<'a> { (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 signed = envelope.sign_with_limits( + self.signer_id, + self.signature_purpose, + self.signer, + self.encode_limits, + )?; + let encrypted = signed.encrypt_for_with_limits( + &self.recipients, + self.encryption_purpose, + self.encode_limits, + )?; let mut frame = CommunicationValue::new_with_type_map(application_type, &type_map) .with_receiver(self.final_recipient_id) @@ -258,6 +350,12 @@ pub struct ProtectedOpenOptions { pub encryption_purpose: ProtectionPurpose, /// Signature algorithms accepted by the receiver. pub policy: ProtectionPolicy, + /// Recursive and cumulative allocation policy used while opening. + pub decode_limits: DecodeLimits, + /// Bound used when reconstructing signed bytes for verification. + pub encode_limits: EncodeLimits, + /// Semantic limits for retained protected fields and key histories. + pub protected_limits: ProtectedLimits, } impl ProtectedOpenOptions { @@ -272,8 +370,41 @@ impl ProtectedOpenOptions { signature_purpose, encryption_purpose, policy, + decode_limits: DecodeLimits { + max_depth: 64, + max_values: 65_536, + max_blob_size: 16 * 1024 * 1024, + max_recipients: 64, + max_allocated_bytes: 64 * 1024 * 1024, + }, + encode_limits: EncodeLimits { + max_depth: 64, + max_values: 65_536, + max_output_size: 16 * 1024 * 1024, + }, + protected_limits: ProtectedLimits { + max_message_id_bytes: 256, + max_metadata_encoded_bytes: 1024 * 1024, + max_signer_key_history: 8, + max_decryption_key_history: 8, + }, } } + + pub const fn with_limits( + mut self, + decode_limits: DecodeLimits, + protected_limits: ProtectedLimits, + ) -> Self { + self.decode_limits = decode_limits; + self.protected_limits = protected_limits; + self + } + + pub const fn with_encode_limits(mut self, encode_limits: EncodeLimits) -> Self { + self.encode_limits = encode_limits; + self + } } fn protected_field_id( @@ -328,23 +459,26 @@ fn validate_protected_frame(frame: &CommunicationValue) -> Result( - value: &'a DataValue, + entries: &'a [(DataTypeId, DataValue)], data_type: DataType, type_map: &TypeMap, ) -> Result<&'a DataValue, ProtectedError> { - value - .get_field(protected_field_id(data_type, type_map)?) + let field_id = protected_field_id(data_type, type_map)?; + entries + .iter() + .find(|(id, _)| *id == field_id) + .map(|(_, value)| value) .ok_or(ProtectedError::InvalidLayout( "required protected field is missing", )) } fn unsigned_field( - value: &DataValue, + entries: &[(DataTypeId, DataValue)], data_type: DataType, type_map: &TypeMap, ) -> Result { - field(value, data_type, type_map)? + field(entries, data_type, type_map)? .as_unsigned_number() .ok_or(ProtectedError::InvalidLayout( "protected field is not unsigned", @@ -352,11 +486,11 @@ fn unsigned_field( } fn string_field( - value: &DataValue, + entries: &[(DataTypeId, DataValue)], data_type: DataType, type_map: &TypeMap, ) -> Result { - field(value, data_type, type_map)? + field(entries, data_type, type_map)? .as_string() .filter(|value| !value.is_empty()) .ok_or(ProtectedError::InvalidLayout( @@ -364,10 +498,28 @@ fn string_field( )) } -fn protected_version(value: &DataValue, type_map: &TypeMap) -> Result { +fn string_field_ref<'a>( + entries: &'a [(DataTypeId, DataValue)], + data_type: DataType, + type_map: &TypeMap, +) -> Result<&'a str, ProtectedError> { + field(entries, data_type, type_map)? + .as_str() + .filter(|value| !value.is_empty()) + .ok_or(ProtectedError::InvalidLayout( + "protected field is not a non-empty string", + )) +} + +fn protected_version( + entries: &[(DataTypeId, DataValue)], + type_map: &TypeMap, +) -> Result { let version_id = protected_field_id(DataType::ProtectedVersion, type_map)?; - let version = value - .get_field(version_id) + let version = entries + .iter() + .find(|(id, _)| *id == version_id) + .map(|(_, value)| value) .ok_or(ProtectedError::MissingProtectedVersion)? .as_unsigned_number() .ok_or(ProtectedError::InvalidLayout( @@ -381,10 +533,15 @@ fn decrypt_protected_payload( frame: &CommunicationValue, keyrings: &[&Keyring], encryption_purpose: ProtectionPurpose, + decode_limits: DecodeLimits, + max_decryption_key_history: usize, ) -> Result { + if keyrings.len() > max_decryption_key_history { + return Err(ProtectedError::ResourceLimit("decryption key history")); + } frame .payload() - .decrypt_with_keyrings(keyrings, encryption_purpose) + .decrypt_with_keyrings_and_limits(keyrings, encryption_purpose, decode_limits) .map_err(|error| match error { ProtectionError::NotEncrypted => ProtectedError::PayloadNotEncrypted, other => ProtectedError::Protection(other), @@ -394,22 +551,63 @@ fn decrypt_protected_payload( /// 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. +#[deprecated(note = "use protected_claimed_signer_id_with_limits; pass the receive DecodeLimits")] pub fn protected_claimed_signer_id( frame: &CommunicationValue, keyrings: &[&Keyring], encryption_purpose: ProtectionPurpose, +) -> Result { + // Migrate to `protected_claimed_signer_id_with_limits` at receive boundaries. + protected_claimed_signer_id_with_limits( + frame, + keyrings, + encryption_purpose, + DecodeLimits::default(), + ) +} + +pub fn protected_claimed_signer_id_with_limits( + frame: &CommunicationValue, + keyrings: &[&Keyring], + encryption_purpose: ProtectionPurpose, + decode_limits: DecodeLimits, +) -> Result { + protected_claimed_signer_id_with_options( + frame, + keyrings, + encryption_purpose, + decode_limits, + ProtectedLimits::default(), + ) +} + +/// Return the claimed signer ID while applying the complete receive policy. +/// +/// This is deliberately separate from the compatibility decoder above: the +/// claimed ID is used to select a signer-key history, so the decryption-key +/// history bound must be the same bound used by the eventual open operation. +pub fn protected_claimed_signer_id_with_options( + frame: &CommunicationValue, + keyrings: &[&Keyring], + encryption_purpose: ProtectionPurpose, + decode_limits: DecodeLimits, + protected_limits: ProtectedLimits, ) -> Result { validate_protected_frame(frame)?; - let decrypted = decrypt_protected_payload(frame, keyrings, encryption_purpose)?; + let decrypted = decrypt_protected_payload( + frame, + keyrings, + encryption_purpose, + decode_limits, + protected_limits.max_decryption_key_history, + )?; 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( +fn open_protected_with_impl( frame: &CommunicationValue, keyrings: &[&Keyring], expected_signer_id: Option, @@ -422,7 +620,13 @@ where { validate_protected_frame(frame)?; let type_map = frame.type_map().cloned().unwrap_or_else(TypeMap::latest); - let decrypted = decrypt_protected_payload(frame, keyrings, options.encryption_purpose)?; + let decrypted = decrypt_protected_payload( + frame, + keyrings, + options.encryption_purpose, + options.decode_limits, + options.protected_limits.max_decryption_key_history, + )?; let signed = decrypted .as_signed() .ok_or(ProtectedError::PayloadNotSigned)?; @@ -437,13 +641,54 @@ where } let signer_keys = resolve_signer_keys(signed.signer_id) .ok_or(ProtectionError::SignerKeyNotFound(signed.signer_id))?; + if signer_keys.len() > options.protected_limits.max_signer_key_history { + return Err(ProtectedError::ResourceLimit("signer key history")); + } open_decrypted_protected(frame, type_map, signed, &signer_keys, options, 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( +pub fn open_protected_with_checked( + frame: &CommunicationValue, + keyrings: &[&Keyring], + expected_signer_id: Option, + resolve_signer_keys: F, + options: ProtectedOpenOptions, + replay_guard: &mut dyn ReplayGuard, +) -> Result +where + F: FnOnce(u64) -> Option>, +{ + open_protected_with_impl( + frame, + keyrings, + expected_signer_id, + resolve_signer_keys, + options, + Some(replay_guard), + ) +} + +pub fn open_protected_with_without_replay( + frame: &CommunicationValue, + keyrings: &[&Keyring], + expected_signer_id: Option, + resolve_signer_keys: F, + options: ProtectedOpenOptions, +) -> Result +where + F: FnOnce(u64) -> Option>, +{ + open_protected_with_impl( + frame, + keyrings, + expected_signer_id, + resolve_signer_keys, + options, + None, + ) +} + +fn open_protected_with_keys_impl( frame: &CommunicationValue, keyrings: &[&Keyring], expected_signer_id: u64, @@ -452,7 +697,13 @@ pub fn open_protected_with_keys( replay_guard: Option<&mut dyn ReplayGuard>, ) -> Result { let type_map = validate_protected_frame(frame)?; - let decrypted = decrypt_protected_payload(frame, keyrings, options.encryption_purpose)?; + let decrypted = decrypt_protected_payload( + frame, + keyrings, + options.encryption_purpose, + options.decode_limits, + options.protected_limits.max_decryption_key_history, + )?; let signed = decrypted .as_signed() .ok_or(ProtectedError::PayloadNotSigned)?; @@ -473,23 +724,73 @@ pub fn open_protected_with_keys( ) } -/// Open a direct protected message when the expected signer and one trusted -/// public key are already known. -pub fn open_protected( +pub fn open_protected_with_keys_checked( + frame: &CommunicationValue, + keyrings: &[&Keyring], + expected_signer_id: u64, + signer_public_keys: &[PublicKeyBundle], + options: ProtectedOpenOptions, + replay_guard: &mut dyn ReplayGuard, +) -> Result { + open_protected_with_keys_impl( + frame, + keyrings, + expected_signer_id, + signer_public_keys, + options, + Some(replay_guard), + ) +} + +pub fn open_protected_with_keys_without_replay( + frame: &CommunicationValue, + keyrings: &[&Keyring], + expected_signer_id: u64, + signer_public_keys: &[PublicKeyBundle], + options: ProtectedOpenOptions, +) -> Result { + open_protected_with_keys_impl( + frame, + keyrings, + expected_signer_id, + signer_public_keys, + options, + None, + ) +} + +pub fn open_protected_checked( frame: &CommunicationValue, keyring: &Keyring, expected_signer_id: u64, signer_public_key: &PublicKeyBundle, options: ProtectedOpenOptions, - replay_guard: Option<&mut dyn ReplayGuard>, + replay_guard: &mut dyn ReplayGuard, ) -> Result { - open_protected_with_keys( + open_protected_with_keys_impl( frame, std::slice::from_ref(&keyring), expected_signer_id, std::slice::from_ref(signer_public_key), options, - replay_guard, + Some(replay_guard), + ) +} + +pub fn open_protected_without_replay( + frame: &CommunicationValue, + keyring: &Keyring, + expected_signer_id: u64, + signer_public_key: &PublicKeyBundle, + options: ProtectedOpenOptions, +) -> Result { + open_protected_with_keys_impl( + frame, + std::slice::from_ref(&keyring), + expected_signer_id, + std::slice::from_ref(signer_public_key), + options, + None, ) } @@ -501,11 +802,15 @@ fn open_decrypted_protected( options: ProtectedOpenOptions, mut replay_guard: Option<&mut dyn ReplayGuard>, ) -> Result { - let matched_signer_key_index = signed.verify_with_key_history_index( + if signer_public_keys.len() > options.protected_limits.max_signer_key_history { + return Err(ProtectedError::ResourceLimit("signer key history")); + } + let matched_signer_key_index = signed.verify_with_key_history_index_and_limits( signed.signer_id, signer_public_keys, options.signature_purpose, options.policy, + options.encode_limits, )?; let receiver_id = frame.receiver().ok_or(ProtectedError::MissingReceiver)?; if options @@ -520,22 +825,23 @@ fn open_decrypted_protected( { return Err(ProtectedError::SenderMismatch); } + /* The authenticated value is already owned by the decoder. Keep this + inspection borrowed so opening a large envelope does not clone it. */ let envelope = signed .value - .as_container() + .container_entries() .ok_or(ProtectedError::MissingEnvelope)?; - let envelope = DataValue::Container(envelope); - let version = protected_version(&envelope, &type_map)?; + 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 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, + envelope, DataType::FinalRecipientId, &type_map, )?) @@ -543,10 +849,14 @@ fn open_decrypted_protected( 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)?) + let message_id = string_field_ref(envelope, DataType::MessageId, &type_map)?; + if message_id.len() > options.protected_limits.max_message_id_bytes { + return Err(ProtectedError::ResourceLimit("message ID")); + } + let message_id = message_id.to_owned(); + 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(); + 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)? @@ -569,7 +879,7 @@ fn open_decrypted_protected( #[cfg(test)] mod tests { use super::*; - use mtp_crypto::{Ed25519Signer, Keyring}; + use mtp_crypto::{Ed25519Signer, Keyring, PublicKeyBundle}; use mtp_type_map::{DataType, DataTypeId}; const SIGNATURE_PURPOSE: ProtectionPurpose = ProtectionPurpose(0x40); @@ -584,10 +894,98 @@ mod tests { ) } + // Keep the existing test cases concise while making the production API + // choice explicit: every call below is routed to either the checked or + // the named without-replay entry point. + fn open_protected( + frame: &CommunicationValue, + keyring: &Keyring, + expected_signer_id: u64, + signer_public_key: &PublicKeyBundle, + options: ProtectedOpenOptions, + replay_guard: Option<&mut dyn ReplayGuard>, + ) -> Result { + match replay_guard { + Some(replay_guard) => super::open_protected_checked( + frame, + keyring, + expected_signer_id, + signer_public_key, + options, + replay_guard, + ), + None => super::open_protected_without_replay( + frame, + keyring, + expected_signer_id, + signer_public_key, + options, + ), + } + } + + fn open_protected_with( + frame: &CommunicationValue, + keyrings: &[&Keyring], + expected_signer_id: Option, + resolve_signer_keys: F, + options: ProtectedOpenOptions, + replay_guard: Option<&mut dyn ReplayGuard>, + ) -> Result + where + F: FnOnce(u64) -> Option>, + { + match replay_guard { + Some(replay_guard) => super::open_protected_with_checked( + frame, + keyrings, + expected_signer_id, + resolve_signer_keys, + options, + replay_guard, + ), + None => super::open_protected_with_without_replay( + frame, + keyrings, + expected_signer_id, + resolve_signer_keys, + options, + ), + } + } + + fn open_protected_with_keys( + frame: &CommunicationValue, + keyrings: &[&Keyring], + expected_signer_id: u64, + signer_public_keys: &[PublicKeyBundle], + options: ProtectedOpenOptions, + replay_guard: Option<&mut dyn ReplayGuard>, + ) -> Result { + match replay_guard { + Some(replay_guard) => super::open_protected_with_keys_checked( + frame, + keyrings, + expected_signer_id, + signer_public_keys, + options, + replay_guard, + ), + None => super::open_protected_with_keys_without_replay( + frame, + keyrings, + expected_signer_id, + signer_public_keys, + options, + ), + } + } + #[derive(Default)] struct RecordingReplayGuard { created_at: Option, accepted: bool, + calls: usize, } impl ReplayGuard for RecordingReplayGuard { @@ -597,6 +995,7 @@ mod tests { _message_id: &str, created_at: u64, ) -> Result { + self.calls += 1; self.created_at = Some(created_at); if self.accepted { Ok(false) @@ -607,6 +1006,38 @@ mod tests { } } + #[test] + fn in_memory_replay_guard_is_bounded_and_deduplicates() { + let mut guard = InMemoryReplayGuard::with_capacity(2); + assert!(guard.accept(7, "first", 1).expect("first replay decision")); + assert!( + guard + .accept(7, "second", 2) + .expect("second replay decision") + ); + assert!( + !guard + .accept(7, "first", 3) + .expect("duplicate replay decision") + ); + assert_eq!(guard.len(), 2); + + assert!(guard.accept(7, "third", 4).expect("third replay decision")); + assert_eq!(guard.len(), 2); + assert!( + guard + .accept(7, "first", 5) + .expect("evicted replay decision") + ); + + let mut disabled = InMemoryReplayGuard::with_capacity(0); + assert!( + !disabled + .accept(7, "disabled", 1) + .expect("disabled replay decision") + ); + } + fn protected_field(data_type: DataType, type_map: &TypeMap) -> DataTypeId { data_type .try_to_id(type_map) @@ -743,6 +1174,7 @@ mod tests { 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!(guard.calls, 1); assert_eq!(opened.content, DataValue::Str("hello".into())); assert!(matches!( open_protected( @@ -757,6 +1189,29 @@ mod tests { )); } + #[test] + fn oversized_message_id_is_rejected_before_replay_guard() { + let sender = Keyring::generate(); + let recipient = Keyring::generate(); + let frame = valid_frame(&sender, &recipient, DataValue::Str("hello".into())); + let mut options = open_options(Some(42)); + options.protected_limits.max_message_id_bytes = 3; + let mut guard = RecordingReplayGuard::default(); + + assert!(matches!( + open_protected_checked( + &frame, + &recipient, + 7, + &sender.public_key_bundle(), + options, + &mut guard, + ), + Err(ProtectedError::ResourceLimit("message ID")) + )); + assert_eq!(guard.calls, 0); + } + #[test] fn builder_owns_outer_sender_and_frame_id() { let sender = Keyring::generate(); diff --git a/codec/src/registry.rs b/codec/src/registry.rs index 1d06860..a7fcded 100644 --- a/codec/src/registry.rs +++ b/codec/src/registry.rs @@ -2,6 +2,7 @@ use mtp_common::CodecError; use mtp_type_map::{PROTOCOL_VERSION, TypeMap, Version}; use crate::CommunicationValue; +use crate::EncodeLimits; pub use mtp_type_map::Registry; @@ -42,7 +43,41 @@ impl VersionedCodec { /// Encode a value using the codec's negotiated framing rules. pub fn encode(&self, value: &CommunicationValue) -> Result, CodecError> { - value.to_bytes() + self.encode_with_limits(value, EncodeLimits::default()) + } + + /// Encode using an explicit output/resource limit after verifying the + /// value belongs to this codec's negotiated type map. + pub fn encode_with_limits( + &self, + value: &CommunicationValue, + limits: EncodeLimits, + ) -> Result, CodecError> { + let value_map = value.type_map().ok_or(CodecError::MissingTypeMap)?; + if value_map.version != self.type_map.version { + return Err(CodecError::TypeMapMismatch { + expected: self.type_map.version.to_string(), + actual: value_map.version.to_string(), + }); + } + value.to_bytes_with_limits(limits) + } + + /// Explicitly migrate a clear frame to this codec's negotiated type map + /// before encoding it. + pub fn encode_migrating(&self, value: &CommunicationValue) -> Result, CodecError> { + self.encode_migrating_with_limits(value, EncodeLimits::default()) + } + + /// Explicitly migrate and encode with bounded traversal/output. + pub fn encode_migrating_with_limits( + &self, + value: &CommunicationValue, + limits: EncodeLimits, + ) -> Result, CodecError> { + value + .migrate_with_limits(&self.type_map, limits)? + .to_bytes_with_limits(limits) } /// Decode a frame and retain the negotiated type map for typed access. @@ -58,3 +93,34 @@ impl VersionedCodec { &self.registry } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::DataValue; + use mtp_type_map::{CommunicationType, Version}; + + #[test] + fn encode_rejects_a_value_from_another_negotiated_map() { + let mut registry = Registry::new(); + let version_a = Version::new(3, 0); + let version_b = Version::new(4, 0); + registry.register(TypeMap::new(version_a.clone())); + registry.register(TypeMap::new(version_b.clone())); + + let codec = VersionedCodec::for_version(registry, version_b).expect("codec version"); + let value = CommunicationValue::new_with_type_map( + CommunicationType::Ping, + &TypeMap::new(version_a.clone()), + ) + .with_payload(DataValue::Null); + + assert_eq!( + codec.encode(&value), + Err(CodecError::TypeMapMismatch { + expected: "4.0".into(), + actual: "3.0".into(), + }) + ); + } +} diff --git a/codec/src/relay.rs b/codec/src/relay.rs index bb64d7f..a09ef4f 100644 --- a/codec/src/relay.rs +++ b/codec/src/relay.rs @@ -9,11 +9,11 @@ #![cfg(feature = "crypto")] use mtp_crypto::{Keyring, PublicKeyBundle, SignatureScheme}; -use mtp_type_map::{CommunicationType, DataType, TypeMap}; +use mtp_type_map::{CommunicationType, DataType, DataTypeId, TypeMap}; use crate::{ - CommunicationValue, DataValue, MtpProtectionPurpose, ProtectionError, ProtectionPolicy, - ReplayError, ReplayGuard, + CommunicationValue, DataValue, DecodeLimits, EncodeLimits, MtpProtectionPurpose, + ProtectedLimits, ProtectionError, ProtectionPolicy, ReplayError, ReplayGuard, }; /// The relay metadata schema emitted by [`SealedRelayBuilder`]. @@ -37,6 +37,8 @@ pub enum RelayError { NotFinalRecipient, #[error("relay message was already accepted")] Replay, + #[error("relay resource limit exceeded: {0}")] + ResourceLimit(&'static str), #[error("relay application message type is reserved: {0}")] ReservedApplicationType(String), #[error("protection error: {0}")] @@ -62,6 +64,9 @@ pub struct VerifiedRelayMetadata { encrypted_content: DataValue, type_map: TypeMap, matched_signer_key_index: usize, + decode_limits: DecodeLimits, + encode_limits: EncodeLimits, + protected_limits: ProtectedLimits, // There is intentionally no public constructor. This marker documents // that the fields originate from a successful authenticated open. _verified: VerifiedMarker, @@ -84,6 +89,18 @@ impl VerifiedRelayMetadata { self.final_recipient_id } + pub fn decode_limits(&self) -> DecodeLimits { + self.decode_limits + } + + pub fn protected_limits(&self) -> ProtectedLimits { + self.protected_limits + } + + pub fn encode_limits(&self) -> EncodeLimits { + self.encode_limits + } + pub fn message_id(&self) -> &str { &self.message_id } @@ -120,6 +137,40 @@ pub struct VerifiedRelayContent { pub content: DataValue, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct RelayOpenOptions { + pub policy: ProtectionPolicy, + pub decode_limits: DecodeLimits, + pub encode_limits: EncodeLimits, + pub protected_limits: ProtectedLimits, +} + +impl RelayOpenOptions { + pub fn new(policy: ProtectionPolicy) -> Self { + Self { + policy, + decode_limits: DecodeLimits::default(), + encode_limits: EncodeLimits::default(), + protected_limits: ProtectedLimits::default(), + } + } + + pub const fn with_limits( + mut self, + decode_limits: DecodeLimits, + protected_limits: ProtectedLimits, + ) -> Self { + self.decode_limits = decode_limits; + self.protected_limits = protected_limits; + self + } + + pub const fn with_encode_limits(mut self, encode_limits: EncodeLimits) -> Self { + self.encode_limits = encode_limits; + self + } +} + /// Native sealed-relay builder shared by non-WASM applications. /// /// The browser SDK and this builder intentionally produce the same reserved @@ -139,6 +190,8 @@ pub struct SealedRelayBuilder<'a> { metadata_recipients: Vec, content_recipients: Vec, type_map: Option, + limits: ProtectedLimits, + encode_limits: EncodeLimits, } impl<'a> SealedRelayBuilder<'a> { @@ -163,6 +216,8 @@ impl<'a> SealedRelayBuilder<'a> { metadata_recipients: Vec::new(), content_recipients: Vec::new(), type_map: None, + limits: ProtectedLimits::default(), + encode_limits: EncodeLimits::default(), } } @@ -192,6 +247,16 @@ impl<'a> SealedRelayBuilder<'a> { self } + pub fn protected_limits(mut self, limits: ProtectedLimits) -> Self { + self.limits = limits; + self + } + + pub fn encode_limits(mut self, limits: EncodeLimits) -> Self { + self.encode_limits = limits; + 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. @@ -212,11 +277,17 @@ impl<'a> SealedRelayBuilder<'a> { "relay builder identifiers must be non-empty", )); } + if message_id.len() > self.limits.max_message_id_bytes { + return Err(RelayError::ResourceLimit("message ID")); + } if self.metadata_recipients.is_empty() || self.content_recipients.is_empty() { return Err(RelayError::InvalidLayout( "relay builder requires metadata and content recipients", )); } + if let Some(metadata) = self.metadata.as_ref() { + validate_metadata_size(metadata, &self.limits)?; + } let type_map = self.type_map.unwrap_or_else(TypeMap::latest); validate_application_message_type(&self.message_type, &type_map)?; @@ -232,14 +303,16 @@ impl<'a> SealedRelayBuilder<'a> { (message_type_id, DataValue::Str(self.message_type)), (content_id, self.content), ]); - let signed_content = content.sign( + let signed_content = content.sign_with_limits( self.signer_id, MtpProtectionPurpose::RelayContentSignature.into(), self.signer, + self.encode_limits, )?; - let encrypted_content = signed_content.encrypt_for( + let encrypted_content = signed_content.encrypt_for_with_limits( &self.content_recipients, MtpProtectionPurpose::RelayContentEncryption.into(), + self.encode_limits, )?; let mut metadata_fields = vec![ ( @@ -258,14 +331,16 @@ impl<'a> SealedRelayBuilder<'a> { metadata_fields.push((metadata_id, application_metadata)); } let metadata = DataValue::Container(metadata_fields); - let signed_metadata = metadata.sign( + let signed_metadata = metadata.sign_with_limits( self.signer_id, MtpProtectionPurpose::RelayMetadataSignature.into(), self.signer, + self.encode_limits, )?; - let encrypted_metadata = signed_metadata.encrypt_for( + let encrypted_metadata = signed_metadata.encrypt_for_with_limits( &self.metadata_recipients, MtpProtectionPurpose::RelayMetadataEncryption.into(), + self.encode_limits, )?; Ok( CommunicationValue::new_with_type_map(CommunicationType::Relay, &type_map) @@ -305,22 +380,44 @@ fn validate_application_message_type( Ok(()) } +fn validate_metadata_size( + metadata: &DataValue, + limits: &ProtectedLimits, +) -> Result<(), RelayError> { + let encoded = metadata + .to_bytes_with_limits(EncodeLimits { + max_output_size: limits.max_metadata_encoded_bytes, + ..EncodeLimits::default() + }) + .map_err(|error| match error { + mtp_common::CodecError::TooManyEntries => RelayError::ResourceLimit("metadata"), + _ => RelayError::InvalidLayout("metadata cannot be encoded"), + })?; + if encoded.len() > limits.max_metadata_encoded_bytes { + return Err(RelayError::ResourceLimit("metadata")); + } + Ok(()) +} + fn field<'a>( - value: &'a DataValue, + entries: &'a [(DataTypeId, DataValue)], data_type: DataType, type_map: &TypeMap, ) -> Result<&'a DataValue, RelayError> { - value - .get_field(relay_field(data_type, type_map)?) + let field_id = relay_field(data_type, type_map)?; + entries + .iter() + .find(|(id, _)| *id == field_id) + .map(|(_, value)| value) .ok_or(RelayError::InvalidLayout("required relay field is missing")) } fn string_field( - value: &DataValue, + entries: &[(DataTypeId, DataValue)], data_type: DataType, type_map: &TypeMap, ) -> Result { - field(value, data_type, type_map)? + field(entries, data_type, type_map)? .as_string() .filter(|value| !value.is_empty()) .ok_or(RelayError::InvalidLayout( @@ -328,21 +425,36 @@ fn string_field( )) } -fn optional_metadata_field( - value: &DataValue, +fn string_field_ref<'a>( + entries: &'a [(DataTypeId, DataValue)], + data_type: DataType, type_map: &TypeMap, -) -> Result, RelayError> { - Ok(value - .get_field(relay_field(DataType::Metadata, type_map)?) - .cloned()) +) -> Result<&'a str, RelayError> { + field(entries, data_type, type_map)? + .as_str() + .filter(|value| !value.is_empty()) + .ok_or(RelayError::InvalidLayout( + "relay field is not a non-empty string", + )) +} + +fn optional_metadata_field<'a>( + entries: &'a [(DataTypeId, DataValue)], + type_map: &TypeMap, +) -> Result, RelayError> { + let field_id = relay_field(DataType::Metadata, type_map)?; + Ok(entries + .iter() + .find(|(id, _)| *id == field_id) + .map(|(_, value)| value)) } fn unsigned_field( - value: &DataValue, + entries: &[(DataTypeId, DataValue)], data_type: DataType, type_map: &TypeMap, ) -> Result { - field(value, data_type, type_map)? + field(entries, data_type, type_map)? .as_unsigned_number() .ok_or(RelayError::InvalidLayout("relay field is not unsigned")) } @@ -356,55 +468,86 @@ struct RelayMetadataV1 { encrypted_content: DataValue, } -fn relay_version(value: &DataValue, type_map: &TypeMap) -> Result { +fn relay_version( + entries: &[(DataTypeId, DataValue)], + type_map: &TypeMap, +) -> Result { let version_id = relay_field(DataType::RelayVersion, type_map)?; - let version = value - .get_field(version_id) + let version = entries + .iter() + .find(|(id, _)| *id == version_id) + .map(|(_, value)| value) .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)?) +fn parse_relay_v1( + entries: &[(DataTypeId, DataValue)], + type_map: &TypeMap, + limits: &ProtectedLimits, +) -> Result { + let final_recipient_id = u64::try_from(unsigned_field( + entries, + DataType::FinalRecipientId, + type_map, + )?) + .map_err(|_| RelayError::InvalidLayout("final recipient ID is out of range"))?; + let created_at = u64::try_from(unsigned_field(entries, 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(); + let encrypted_content = field(entries, DataType::Content, type_map)?.clone(); if encrypted_content.as_encrypted().is_none() { return Err(RelayError::InvalidLayout("content is not encrypted")); } + let message_id = string_field_ref(entries, DataType::MessageId, type_map)?; + if message_id.len() > limits.max_message_id_bytes { + return Err(RelayError::ResourceLimit("message ID")); + } + let metadata = optional_metadata_field(entries, type_map)?; + if let Some(metadata) = metadata { + validate_metadata_size(metadata, limits)?; + } Ok(RelayMetadataV1 { final_recipient_id, - message_id: string_field(value, DataType::MessageId, type_map)?, + message_id: message_id.to_owned(), created_at, - metadata: optional_metadata_field(value, type_map)?, + metadata: metadata.cloned(), 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( +pub fn open_relay_metadata_checked( frame: &CommunicationValue, keyring: &Keyring, expected_signer_id: u64, signer_public_key: &PublicKeyBundle, - policy: ProtectionPolicy, + options: RelayOpenOptions, + replay_guard: &mut dyn ReplayGuard, ) -> Result { - open_relay_metadata_with( + open_relay_metadata_with_limits_checked( frame, std::slice::from_ref(&keyring), Some(expected_signer_id), |_| Some(vec![signer_public_key.clone()]), - policy, - None, + options, + replay_guard, + ) +} + +pub fn open_relay_metadata_without_replay( + frame: &CommunicationValue, + keyring: &Keyring, + expected_signer_id: u64, + signer_public_key: &PublicKeyBundle, + options: RelayOpenOptions, +) -> Result { + open_relay_metadata_with_limits_without_replay( + frame, + std::slice::from_ref(&keyring), + Some(expected_signer_id), + |_| Some(vec![signer_public_key.clone()]), + options, ) } @@ -425,15 +568,45 @@ fn validate_relay_frame(frame: &CommunicationValue) -> Result<(), RelayError> { /// 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`]. +#[deprecated(note = "use relay_metadata_claimed_signer_id_with_limits")] pub fn relay_metadata_claimed_signer_id( frame: &CommunicationValue, keyrings: &[&Keyring], ) -> Result { - validate_relay_frame(frame)?; + // Migrate to `relay_metadata_claimed_signer_id_with_limits` at receive boundaries. + relay_metadata_claimed_signer_id_with_limits(frame, keyrings, DecodeLimits::default()) +} - let decrypted = frame.payload().decrypt_with_keyrings( +pub fn relay_metadata_claimed_signer_id_with_limits( + frame: &CommunicationValue, + keyrings: &[&Keyring], + decode_limits: DecodeLimits, +) -> Result { + relay_metadata_claimed_signer_id_with_options( + frame, + keyrings, + decode_limits, + ProtectedLimits::default(), + ) +} + +/// Return the claimed relay signer ID while applying the complete receive +/// policy, including the caller's decryption-key history bound. +pub fn relay_metadata_claimed_signer_id_with_options( + frame: &CommunicationValue, + keyrings: &[&Keyring], + decode_limits: DecodeLimits, + protected_limits: ProtectedLimits, +) -> Result { + validate_relay_frame(frame)?; + if keyrings.len() > protected_limits.max_decryption_key_history { + return Err(RelayError::ResourceLimit("decryption key history")); + } + + let decrypted = frame.payload().decrypt_with_keyrings_and_limits( keyrings, MtpProtectionPurpose::RelayMetadataEncryption.into(), + decode_limits, )?; let signed = decrypted .as_signed() @@ -441,49 +614,118 @@ pub fn relay_metadata_claimed_signer_id( 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( +pub fn open_relay_metadata_with_checked( frame: &CommunicationValue, keyrings: &[&Keyring], expected_signer_id: Option, resolve_signer_keys: F, - policy: ProtectionPolicy, + options: RelayOpenOptions, + replay_guard: &mut dyn ReplayGuard, +) -> Result +where + F: FnOnce(u64) -> Option>, +{ + open_relay_metadata_with_limits_checked( + frame, + keyrings, + expected_signer_id, + resolve_signer_keys, + options, + replay_guard, + ) +} + +/// Open relay metadata for message processing with replay protection required +/// by the type system. +pub fn open_relay_metadata_with_limits_checked( + frame: &CommunicationValue, + keyrings: &[&Keyring], + expected_signer_id: Option, + resolve_signer_keys: F, + options: RelayOpenOptions, + replay_guard: &mut dyn ReplayGuard, +) -> Result +where + F: FnOnce(u64) -> Option>, +{ + open_relay_metadata_impl( + frame, + keyrings, + expected_signer_id, + resolve_signer_keys, + options, + Some(replay_guard), + ) +} + +pub fn open_relay_metadata_with_without_replay( + frame: &CommunicationValue, + keyrings: &[&Keyring], + expected_signer_id: Option, + resolve_signer_keys: F, + options: RelayOpenOptions, +) -> Result +where + F: FnOnce(u64) -> Option>, +{ + open_relay_metadata_with_limits_without_replay( + frame, + keyrings, + expected_signer_id, + resolve_signer_keys, + options, + ) +} + +/// Open relay metadata for stored/forensic use without replay protection. +/// The name makes the security trade-off explicit at the call site. +pub fn open_relay_metadata_with_limits_without_replay( + frame: &CommunicationValue, + keyrings: &[&Keyring], + expected_signer_id: Option, + resolve_signer_keys: F, + options: RelayOpenOptions, +) -> Result +where + F: FnOnce(u64) -> Option>, +{ + open_relay_metadata_impl( + frame, + keyrings, + expected_signer_id, + resolve_signer_keys, + options, + None, + ) +} + +fn open_relay_metadata_impl( + frame: &CommunicationValue, + keyrings: &[&Keyring], + expected_signer_id: Option, + resolve_signer_keys: F, + options: RelayOpenOptions, mut replay_guard: Option<&mut dyn ReplayGuard>, ) -> Result where - F: Fn(u64) -> Option>, + F: FnOnce(u64) -> Option>, { validate_relay_frame(frame)?; + if keyrings.len() > options.protected_limits.max_decryption_key_history { + return Err(RelayError::ResourceLimit("decryption key history")); + } + let type_map = frame.type_map().cloned().unwrap_or_else(TypeMap::latest); - let decrypted = frame.payload().decrypt_with_keyrings( + let decrypted = frame.payload().decrypt_with_keyrings_and_limits( keyrings, MtpProtectionPurpose::RelayMetadataEncryption.into(), + options.decode_limits, )?; let signed = decrypted .as_signed() @@ -499,23 +741,30 @@ where } 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( + if signer_keys.len() > options.protected_limits.max_signer_key_history { + return Err(RelayError::ResourceLimit("signer key history")); + } + let matched_signer_key_index = signed.verify_with_key_history_index_and_limits( signed.signer_id, &signer_keys, MtpProtectionPurpose::RelayMetadataSignature.into(), - policy, + options.policy, + options.encode_limits, )?; + /* Verification leaves the signed envelope owned by `decrypted`; inspect + its entries in place to avoid a second attacker-controlled clone. */ let metadata = signed .value - .as_container() + .container_entries() .ok_or(RelayError::InvalidLayout("metadata is not a container"))?; - let metadata = DataValue::Container(metadata); - let relay_version = relay_version(&metadata, &type_map)?; + let relay_version = relay_version(metadata, &type_map)?; let parsed = match relay_version { - 1 => parse_relay_v1(&metadata, &type_map)?, + 1 => parse_relay_v1(metadata, &type_map, &options.protected_limits)?, other => return Err(RelayError::UnsupportedRelayVersion(other)), }; - + if parsed.message_id.len() > options.protected_limits.max_message_id_bytes { + return Err(RelayError::ResourceLimit("message ID")); + } let result = VerifiedRelayMetadata { relay_version, signer_id: signed.signer_id, @@ -526,6 +775,9 @@ where encrypted_content: parsed.encrypted_content, type_map, matched_signer_key_index, + decode_limits: options.decode_limits, + encode_limits: options.encode_limits, + protected_limits: options.protected_limits, _verified: VerifiedMarker, }; if let Some(guard) = replay_guard.as_mut() @@ -537,6 +789,7 @@ where } /// Open and verify content after metadata has been authenticated. +#[deprecated(note = "use open_relay_content_with_limits_without_replay")] pub fn open_relay_content( metadata: &VerifiedRelayMetadata, keyring: &Keyring, @@ -544,17 +797,23 @@ pub fn open_relay_content( expected_recipient_id: u64, policy: ProtectionPolicy, ) -> Result { - open_relay_content_with_keys( + open_relay_content_with_limits_without_replay( metadata, - keyring, + std::slice::from_ref(&keyring), std::slice::from_ref(signer_public_key), - expected_recipient_id, - policy, + Some(expected_recipient_id), + RelayOpenOptions { + policy, + decode_limits: metadata.decode_limits, + encode_limits: metadata.encode_limits, + protected_limits: metadata.protected_limits, + }, ) } /// Open relay content against trusted signing-key history for the metadata's /// authenticated signer ID. +#[deprecated(note = "use open_relay_content_with_limits_without_replay")] pub fn open_relay_content_with_keys( metadata: &VerifiedRelayMetadata, keyring: &Keyring, @@ -562,18 +821,24 @@ pub fn open_relay_content_with_keys( expected_recipient_id: u64, policy: ProtectionPolicy, ) -> Result { - open_relay_content_with_keyrings( + open_relay_content_with_limits_without_replay( metadata, std::slice::from_ref(&keyring), signer_public_keys, Some(expected_recipient_id), - policy, + RelayOpenOptions { + policy, + decode_limits: metadata.decode_limits, + encode_limits: metadata.encode_limits, + protected_limits: metadata.protected_limits, + }, ) } /// 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. +#[deprecated(note = "use open_relay_content_with_limits_without_replay")] pub fn open_relay_content_with_keyrings( metadata: &VerifiedRelayMetadata, keyrings: &[&Keyring], @@ -581,15 +846,96 @@ pub fn open_relay_content_with_keyrings( expected_recipient_id: Option, policy: ProtectionPolicy, ) -> Result { + // Migrate to `open_relay_content_with_limits_without_replay` to keep the decode policy + // explicit across metadata and content opening. + open_relay_content_with_limits_without_replay( + metadata, + keyrings, + signer_public_keys, + expected_recipient_id, + RelayOpenOptions { + policy, + decode_limits: metadata.decode_limits, + encode_limits: metadata.encode_limits, + protected_limits: metadata.protected_limits, + }, + ) +} + +#[deprecated(note = "use open_relay_content_with_limits_without_replay")] +pub fn open_relay_content_with_keyrings_and_limits( + metadata: &VerifiedRelayMetadata, + keyrings: &[&Keyring], + signer_public_keys: &[PublicKeyBundle], + expected_recipient_id: Option, + options: RelayOpenOptions, +) -> Result { + open_relay_content_with_limits_without_replay( + metadata, + keyrings, + signer_public_keys, + expected_recipient_id, + options, + ) +} + +/// Compatibility alias for callers that already hold authenticated relay +/// metadata. New code should use the explicit `_without_replay` name. +#[deprecated(note = "use open_relay_content_with_limits_without_replay")] +pub fn open_relay_content_with_limits( + metadata: &VerifiedRelayMetadata, + keyrings: &[&Keyring], + signer_public_keys: &[PublicKeyBundle], + expected_recipient_id: Option, + options: RelayOpenOptions, +) -> Result { + open_relay_content_with_limits_without_replay( + metadata, + keyrings, + signer_public_keys, + expected_recipient_id, + options, + ) +} + +/// Open relay content after the authenticated metadata operation without +/// making a second replay decision. Replay is consumed by the metadata +/// processing boundary; this explicit name prevents callers from mistaking +/// content opening for an independent replay check. +pub fn open_relay_content_with_limits_without_replay( + metadata: &VerifiedRelayMetadata, + keyrings: &[&Keyring], + signer_public_keys: &[PublicKeyBundle], + expected_recipient_id: Option, + options: RelayOpenOptions, +) -> Result { + let options = RelayOpenOptions { + policy: options.policy, + decode_limits: restrict_decode_limits(options.decode_limits, metadata.decode_limits), + encode_limits: restrict_encode_limits(options.encode_limits, metadata.encode_limits), + protected_limits: restrict_protected_limits( + options.protected_limits, + metadata.protected_limits, + ), + }; if expected_recipient_id.is_some_and(|id| metadata.final_recipient_id != id) { return Err(RelayError::NotFinalRecipient); } + if keyrings.len() > options.protected_limits.max_decryption_key_history { + return Err(RelayError::ResourceLimit("decryption key history")); + } + if signer_public_keys.len() > options.protected_limits.max_signer_key_history { + return Err(RelayError::ResourceLimit("signer key history")); + } let type_map = &metadata.type_map; - let decrypted = metadata.encrypted_content.decrypt_with_keyrings( - keyrings, - MtpProtectionPurpose::RelayContentEncryption.into(), - )?; + let decrypted = metadata + .encrypted_content + .decrypt_with_keyrings_and_limits( + keyrings, + MtpProtectionPurpose::RelayContentEncryption.into(), + options.decode_limits, + )?; let signed = decrypted .as_signed() .ok_or(RelayError::InvalidLayout("content is not signed"))?; @@ -600,27 +946,60 @@ pub fn open_relay_content_with_keyrings( } // Content is a separately signed value and must not inherit a weaker // metadata policy. - signed.verify_with_key_history( + signed.verify_with_key_history_and_limits( metadata.signer_id, signer_public_keys, MtpProtectionPurpose::RelayContentSignature.into(), - policy, + options.policy, + options.encode_limits, )?; let content = signed .value - .as_container() + .container_entries() .ok_or(RelayError::InvalidLayout("content is not a container"))?; - let content = DataValue::Container(content); - let message_type = string_field(&content, DataType::MessageType, type_map)?; + 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(), + content: field(content, DataType::Content, type_map)?.clone(), }) } +fn restrict_decode_limits(left: DecodeLimits, right: DecodeLimits) -> DecodeLimits { + DecodeLimits { + max_depth: left.max_depth.min(right.max_depth), + max_values: left.max_values.min(right.max_values), + max_blob_size: left.max_blob_size.min(right.max_blob_size), + max_recipients: left.max_recipients.min(right.max_recipients), + max_allocated_bytes: left.max_allocated_bytes.min(right.max_allocated_bytes), + } +} + +fn restrict_encode_limits(left: EncodeLimits, right: EncodeLimits) -> EncodeLimits { + EncodeLimits { + max_depth: left.max_depth.min(right.max_depth), + max_values: left.max_values.min(right.max_values), + max_output_size: left.max_output_size.min(right.max_output_size), + } +} + +fn restrict_protected_limits(left: ProtectedLimits, right: ProtectedLimits) -> ProtectedLimits { + ProtectedLimits { + max_message_id_bytes: left.max_message_id_bytes.min(right.max_message_id_bytes), + max_metadata_encoded_bytes: left + .max_metadata_encoded_bytes + .min(right.max_metadata_encoded_bytes), + max_signer_key_history: left + .max_signer_key_history + .min(right.max_signer_key_history), + max_decryption_key_history: left + .max_decryption_key_history + .min(right.max_decryption_key_history), + } +} + /// 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. @@ -636,13 +1015,78 @@ pub fn forward_relay_frame( mod tests { use super::*; use crate::InMemoryReplayGuard; - use mtp_crypto::{Ed25519Signer, Keyring}; + use mtp_crypto::{Ed25519Signer, Keyring, PublicKeyBundle}; use mtp_type_map::DataTypeId; fn ed_signer(keyring: &Keyring) -> Ed25519Signer { Ed25519Signer::new(&keyring.sig_cl_secret_key).expect("Ed25519 signer") } + // Test-only compatibility shims keep older fixture setup readable while + // routing every invocation to an explicit replay choice in production. + fn open_relay_metadata( + frame: &CommunicationValue, + keyring: &Keyring, + expected_signer_id: u64, + signer_public_key: &PublicKeyBundle, + policy: ProtectionPolicy, + ) -> Result { + super::open_relay_metadata_without_replay( + frame, + keyring, + expected_signer_id, + signer_public_key, + RelayOpenOptions::new(policy), + ) + } + + fn open_relay_metadata_with( + frame: &CommunicationValue, + keyrings: &[&Keyring], + expected_signer_id: Option, + resolve_signer_keys: F, + policy: ProtectionPolicy, + replay_guard: Option<&mut dyn ReplayGuard>, + ) -> Result + where + F: Fn(u64) -> Option>, + { + match replay_guard { + Some(replay_guard) => super::open_relay_metadata_with_checked( + frame, + keyrings, + expected_signer_id, + resolve_signer_keys, + RelayOpenOptions::new(policy), + replay_guard, + ), + None => super::open_relay_metadata_with_without_replay( + frame, + keyrings, + expected_signer_id, + resolve_signer_keys, + RelayOpenOptions::new(policy), + ), + } + } + + 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(); + super::open_relay_metadata_with_limits_without_replay( + frame, + keyrings, + Some(expected_signer_id), + move |_| Some(signer_public_keys), + RelayOpenOptions::new(policy), + ) + } + fn relay_frame_with_version( version: Option, include_v1_fields: bool, @@ -874,6 +1318,22 @@ mod tests { assert_eq!(metadata.created_at(), 123); assert_eq!(metadata.metadata(), Some(&application_metadata)); + let mut limited_options = RelayOpenOptions::new(policy); + limited_options.protected_limits.max_message_id_bytes = 1; + let mut limited_guard = InMemoryReplayGuard::default(); + assert!(matches!( + open_relay_metadata_with_limits_checked( + &frame, + &[&metadata_recipient], + None, + |signer_id| (signer_id == 7).then(|| vec![sender.public_key_bundle()]), + limited_options, + &mut limited_guard, + ), + Err(RelayError::ResourceLimit("message ID")) + )); + assert_eq!(limited_guard.len(), 0); + // A final recipient may be included in the metadata recipient set and // therefore open both authenticated layers directly. let final_metadata = open_relay_metadata( diff --git a/common/src/lib.rs b/common/src/lib.rs index c6d4c94..1a7fe66 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -41,6 +41,10 @@ pub enum CodecError { InvalidEncoding, #[error("Too many entries to encode")] TooManyEntries, + #[error("Missing negotiated type map")] + MissingTypeMap, + #[error("Type-map mismatch: expected {expected}, actual {actual}")] + TypeMapMismatch { expected: String, actual: String }, #[error("Crypto failed: {0}")] CryptoFailed(String), #[error("Missing required field: {0}")] diff --git a/crypto/Cargo.toml b/crypto/Cargo.toml index 2305b8a..88a37f4 100644 --- a/crypto/Cargo.toml +++ b/crypto/Cargo.toml @@ -23,6 +23,7 @@ rand = "0.10.2" getrandom = "0.4.3" mlkem-tls = { version = "0.2", optional = true } ml-dsa = { version = "0.1.1", optional = true } +argon2 = { version = "0.5", optional = true } serde = { version = "1", optional = true, features = ["derive"] } rcgen = { version = "0.14", optional = true } time = { version = "0.3", optional = true } @@ -43,3 +44,4 @@ hkdf = ["dep:hkdf", "dep:sha2"] sha2 = ["dep:sha2"] tls = ["dep:rcgen", "dep:time"] parallel = ["dep:tokio"] +password-kdf = ["dep:argon2"] diff --git a/crypto/src/error.rs b/crypto/src/error.rs index 3e70a5f..b7b9c48 100644 --- a/crypto/src/error.rs +++ b/crypto/src/error.rs @@ -6,6 +6,8 @@ pub enum CryptoError { EncryptionFailed, #[error("decryption failed")] DecryptionFailed, + #[error("decryption output exceeds the caller's allocation limit")] + AllocationLimit, #[error("malformed encryption envelope")] MalformedEnvelope, #[error("no encryption recipients")] diff --git a/crypto/src/helper.rs b/crypto/src/helper.rs index b8a0e8f..018f0cd 100644 --- a/crypto/src/helper.rs +++ b/crypto/src/helper.rs @@ -40,6 +40,114 @@ pub struct MultiEncryptedMessage { pub ciphertext: Vec, } +/// Borrowed view of a canonical encrypted envelope. +/// +/// The codec uses this view while validating an attacker-controlled envelope +/// so parsing it does not first create a complete temporary copy of every +/// recipient entry and the ciphertext. +#[derive(Debug, Clone, Copy)] +pub struct MultiEncryptedMessageRef<'a> { + encryption_type: EncryptionType, + purpose: u8, + bytes: &'a [u8], + entries_start: usize, + entry_len: usize, + count: usize, + ciphertext_start: usize, +} + +impl<'a> MultiEncryptedMessageRef<'a> { + pub fn from_bytes(bytes: &'a [u8]) -> Result { + 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() + .checked_add(encryption_type.wrapped_key_len()) + .ok_or(CryptoError::MalformedEnvelope)?; + let entries_len = count + .checked_mul(entry_len) + .ok_or(CryptoError::MalformedEnvelope)?; + let entries_start = 4usize; + let ciphertext_start = entries_start + .checked_add(entries_len) + .ok_or(CryptoError::MalformedEnvelope)?; + let ciphertext_len = bytes + .len() + .checked_sub(ciphertext_start) + .ok_or(CryptoError::MalformedEnvelope)?; + if ciphertext_len < encryption_type.minimum_ciphertext_len() { + return Err(CryptoError::MalformedEnvelope); + } + Ok(Self { + encryption_type, + purpose, + bytes, + entries_start, + entry_len, + count, + ciphertext_start, + }) + } + + pub const fn encryption_type(&self) -> EncryptionType { + self.encryption_type + } + + pub const fn purpose(&self) -> u8 { + self.purpose + } + + pub const fn recipient_count(&self) -> usize { + self.count + } + + pub fn recipient(&self, index: usize) -> Option<(&'a [u8], &'a [u8])> { + if index >= self.count { + return None; + } + let offset = self + .entries_start + .checked_add(index.checked_mul(self.entry_len)?)?; + let kem_len = self.encryption_type.kem_ciphertext_len(); + let kem_end = offset.checked_add(kem_len)?; + let end = offset.checked_add(self.entry_len)?; + Some(( + self.bytes.get(offset..kem_end)?, + self.bytes.get(kem_end..end)?, + )) + } + + pub fn ciphertext(&self) -> &'a [u8] { + &self.bytes[self.ciphertext_start..] + } + + pub fn to_owned(&self) -> MultiEncryptedMessage { + let recipients = (0..self.count) + .filter_map(|index| { + let (kem_ciphertext, encrypted_key) = self.recipient(index)?; + Some(RecipientEntry { + kem_ciphertext: kem_ciphertext.to_vec(), + encrypted_key: encrypted_key.to_vec(), + }) + }) + .collect(); + MultiEncryptedMessage { + encryption_type: self.encryption_type, + purpose: self.purpose, + recipients, + ciphertext: self.ciphertext().to_vec(), + } + } +} + impl MultiEncryptedMessage { /// Serialize the envelope body without redundant per-recipient lengths. pub fn to_bytes(&self) -> Result, CryptoError> { @@ -72,50 +180,7 @@ impl MultiEncryptedMessage { /// Parse the canonical envelope body. pub fn from_bytes(bytes: &[u8]) -> Result { - 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 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, - ciphertext: bytes[offset..].to_vec(), - }) + Ok(MultiEncryptedMessageRef::from_bytes(bytes)?.to_owned()) } } @@ -197,20 +262,51 @@ pub fn decrypt_multi_for( purpose: u8, keyring: &Keyring, ) -> Result, CryptoError> { - 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() + decrypt_multi_for_parts( + message.encryption_type, + message.purpose, + &message.recipients, + &message.ciphertext, + purpose, + keyring, + ) +} + +/// Decrypt an envelope represented by borrowed recipient and ciphertext +/// slices. This keeps protected-value opening from cloning an already-owned +/// envelope solely to call the cryptographic primitive. +#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] +pub fn decrypt_multi_for_parts( + encryption_type: EncryptionType, + envelope_purpose: u8, + recipients: &[RecipientEntry], + ciphertext: &[u8], + purpose: u8, + keyring: &Keyring, +) -> Result, CryptoError> { + if recipients.is_empty() + || recipients.len() > MAX_RECIPIENTS + || envelope_purpose != purpose + || ciphertext.len() < encryption_type.minimum_ciphertext_len() + || recipients.iter().any(|recipient| { + recipient.kem_ciphertext.len() != encryption_type.kem_ciphertext_len() + || recipient.encrypted_key.len() != encryption_type.wrapped_key_len() }) { return Err(CryptoError::MalformedEnvelope); } - let payload_aad = payload_aad(message)?; - for entry in &message.recipients { + let count = u16::try_from(recipients.len()).map_err(|_| CryptoError::MalformedEnvelope)?; + let mut payload_aad = Vec::new(); + payload_aad.extend_from_slice(ENCRYPT_DOMAIN); + payload_aad.push(encryption_type.to_byte()); + payload_aad.push(envelope_purpose); + payload_aad.extend_from_slice(&count.to_be_bytes()); + for entry in recipients { + payload_aad.extend_from_slice(&entry.kem_ciphertext); + payload_aad.extend_from_slice(&entry.encrypted_key); + } + for entry in recipients { let shared_secret = match HybridKem::decapsulate(&keyring.kem_secret_key, &entry.kem_ciphertext) { Ok(secret) => secret, @@ -219,30 +315,51 @@ pub fn decrypt_multi_for( let wrap_key = Zeroizing::new(derive_encryption_key( &shared_secret, KEY_WRAP_DOMAIN, - &[message.encryption_type.to_byte(), purpose], + &[encryption_type.to_byte(), purpose], )?); - 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, - ) { + let aad = wrap_aad(encryption_type, purpose, &entry.kem_ciphertext); + let cek = match open_with_key(encryption_type, *wrap_key, &entry.encrypted_key, &aad) { Ok(key) => key, Err(_) => continue, }; let cek: [u8; 32] = cek.try_into().map_err(|_| CryptoError::DecryptionFailed)?; - return open_with_key( - message.encryption_type, - cek, - &message.ciphertext, - &payload_aad, - ); + return open_with_key(encryption_type, cek, ciphertext, &payload_aad); } Err(CryptoError::NoMatchingRecipient) } +/// Decrypt a canonical envelope only when its plaintext can fit inside the +/// caller's allocation budget. +/// +/// The AEAD implementation allocates its output buffer internally. Checking +/// the ciphertext upper bound before entering that implementation makes the +/// codec's reservation meaningful instead of merely checking the result +/// after the allocation has already happened. +#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] +pub fn decrypt_multi_for_parts_with_limit( + encryption_type: EncryptionType, + envelope_purpose: u8, + recipients: &[RecipientEntry], + ciphertext: &[u8], + purpose: u8, + keyring: &Keyring, + max_plaintext_len: usize, +) -> Result, CryptoError> { + if ciphertext.len() > max_plaintext_len { + return Err(CryptoError::AllocationLimit); + } + + decrypt_multi_for_parts( + encryption_type, + envelope_purpose, + recipients, + ciphertext, + purpose, + keyring, + ) +} + #[cfg(test)] mod tests { use super::*; @@ -325,4 +442,30 @@ mod tests { Err(CryptoError::MalformedEnvelope) )); } + + #[cfg(all(feature = "mlkem-tls", feature = "hkdf", feature = "chacha20poly1305"))] + #[test] + fn bounded_decryption_rejects_before_plaintext_allocation() -> Result<(), CryptoError> { + let recipient = Keyring::generate(); + let message = encrypt_multi_for( + EncryptionType::MlKemChaCha20Poly1305, + 1, + b"bounded plaintext", + &[recipient.public_key_bundle()], + )?; + + assert!(matches!( + decrypt_multi_for_parts_with_limit( + message.encryption_type, + message.purpose, + &message.recipients, + &message.ciphertext, + message.purpose, + &recipient, + message.ciphertext.len() - 1, + ), + Err(CryptoError::AllocationLimit) + )); + Ok(()) + } } diff --git a/crypto/src/kdf.rs b/crypto/src/kdf.rs index 9b6f1a6..caf90a1 100644 --- a/crypto/src/kdf.rs +++ b/crypto/src/kdf.rs @@ -36,3 +36,29 @@ pub fn derive_encryption_key( out.copy_from_slice(&key); Ok(out) } + +#[cfg(feature = "password-kdf")] +pub fn derive_password_key( + passphrase: &[u8], + salt: &[u8], + memory_kib: u32, + iterations: u32, + lanes: u32, +) -> Result<[u8; 32], CryptoError> { + if passphrase.is_empty() + || salt.len() < 16 + || !(8 * 1024..=256 * 1024).contains(&memory_kib) + || !(1..=10).contains(&iterations) + || !(1..=8).contains(&lanes) + { + return Err(CryptoError::KdfError); + } + let params = argon2::Params::new(memory_kib, iterations, lanes, Some(32)) + .map_err(|_| CryptoError::KdfError)?; + let argon = argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params); + let mut key = [0u8; 32]; + argon + .hash_password_into(passphrase, salt, &mut key) + .map_err(|_| CryptoError::KdfError)?; + Ok(key) +} diff --git a/crypto/src/keypair.rs b/crypto/src/keypair.rs index 33ab456..d738118 100644 --- a/crypto/src/keypair.rs +++ b/crypto/src/keypair.rs @@ -340,9 +340,9 @@ impl Keyring { Ok(out) } - pub fn to_bytes(&self) -> Zeroizing> { + #[deprecated(note = "use try_to_bytes for the primary fallible serializer")] + pub fn to_bytes(&self) -> Result>, crate::error::CryptoError> { self.try_to_bytes() - .expect("key material length exceeds wire limit") } pub fn from_bytes(bytes: &[u8]) -> Result { @@ -383,16 +383,26 @@ impl Keyring { }) } - pub fn to_hex(&self) -> String { - bytes_to_hex(&self.to_bytes()) + #[deprecated(note = "use try_to_hex for the primary fallible serializer")] + pub fn to_hex(&self) -> Result { + self.try_to_hex() + } + + pub fn try_to_hex(&self) -> Result { + Ok(bytes_to_hex(&self.try_to_bytes()?)) } pub fn from_hex(s: &str) -> Result { Self::from_bytes(&hex_to_bytes(s)?) } - pub fn to_base64(&self) -> String { - bytes_to_base64(&self.to_bytes()) + #[deprecated(note = "use try_to_base64 for the primary fallible serializer")] + pub fn to_base64(&self) -> Result { + self.try_to_base64() + } + + pub fn try_to_base64(&self) -> Result { + Ok(bytes_to_base64(&self.try_to_bytes()?)) } pub fn from_base64(s: &str) -> Result { @@ -507,9 +517,9 @@ impl PublicKeyBundle { Ok(out) } - pub fn as_bytes(&self) -> Vec { + #[deprecated(note = "use try_as_bytes for the primary fallible serializer")] + pub fn as_bytes(&self) -> Result, crate::error::CryptoError> { self.try_as_bytes() - .expect("public key bundle field exceeds wire limit") } /// Parse a complete suite-compatible public bundle. @@ -582,8 +592,13 @@ impl PublicKeyBundle { Self::from_bytes(bytes) } - pub fn to_base64(&self) -> String { - bytes_to_base64(&self.as_bytes()) + #[deprecated(note = "use try_to_base64 for the primary fallible serializer")] + pub fn to_base64(&self) -> Result { + self.try_to_base64() + } + + pub fn try_to_base64(&self) -> Result { + Ok(bytes_to_base64(&self.try_as_bytes()?)) } pub fn from_base64(s: &str) -> Result { @@ -602,12 +617,6 @@ impl TryFrom<&[u8]> for PublicKeyBundle { } } -impl From<&PublicKeyBundle> for Vec { - fn from(bundle: &PublicKeyBundle) -> Vec { - bundle.as_bytes() - } -} - impl fmt::Debug for PublicKeyBundle { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("PublicKeyBundle") @@ -629,7 +638,7 @@ mod tests { let cl = SignaturePublicKey::new(vec![3u8; 32]); let bundle = PublicKeyBundle::new(kem, pq, cl); - let bytes = bundle.as_bytes(); + let bytes = bundle.try_as_bytes()?; let recovered = PublicKeyBundle::from_bytes_unvalidated(&bytes)?; assert_eq!( @@ -672,9 +681,9 @@ mod tests { SignaturePqPublicKey::new(vec![0xCDu8; 96]), SignaturePublicKey::new(vec![0xEFu8; 32]), ); - let bytes: Vec = Vec::from(&bundle); + let bytes = bundle.try_as_bytes()?; let recovered = PublicKeyBundle::from_bytes_unvalidated(bytes.as_slice())?; - assert_eq!(bundle.as_bytes(), recovered.as_bytes()); + assert_eq!(bundle.try_as_bytes()?, recovered.try_as_bytes()?); Ok(()) } @@ -688,8 +697,8 @@ mod tests { SignaturePublicKey::new(vec![5u8; 32]), SignaturePrivateKey::new(vec![6u8; 32]), ); - let bytes = keyring.to_bytes(); - let recovered = Keyring::from_bytes(&bytes)?; + let bytes = keyring.try_to_bytes()?; + let recovered = Keyring::from_bytes(bytes.as_slice())?; assert_eq!( keyring.kem_public_key.as_bytes(), recovered.kem_public_key.as_bytes() @@ -722,7 +731,7 @@ mod tests { } #[test] - fn canonical_key_parsers_reject_trailing_bytes() { + fn canonical_key_parsers_reject_trailing_bytes() -> Result<(), Box> { let keyring = Keyring::new( KemPublicKey::new(vec![1u8; 16]), KemPrivateKey::new(vec![2u8; 16]), @@ -731,25 +740,40 @@ mod tests { SignaturePublicKey::new(vec![5u8; 16]), SignaturePrivateKey::new(vec![6u8; 16]), ); - let mut keyring_bytes = keyring.to_bytes().to_vec(); + let mut keyring_bytes = keyring.try_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(); + let mut bundle_bytes = bundle.try_as_bytes()?; bundle_bytes.push(0xBB); assert!(PublicKeyBundle::from_bytes(&bundle_bytes).is_err()); + Ok(()) } #[test] - fn validated_bundle_rejects_partial_suite_keys() { + fn public_key_bundle_try_as_bytes_rejects_fields_larger_than_wire_length() { + let bundle = PublicKeyBundle::new( + KemPublicKey::new(vec![0u8; 65_536]), + SignaturePqPublicKey::new(Vec::new()), + SignaturePublicKey::new(Vec::new()), + ); + assert!(matches!( + bundle.try_as_bytes(), + Err(crate::error::CryptoError::InvalidKeyLength) + )); + } + + #[test] + fn validated_bundle_rejects_partial_suite_keys() -> Result<(), Box> { 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()); + assert!(PublicKeyBundle::from_bytes_validated(&bundle.try_as_bytes()?).is_err()); + Ok(()) } #[test] @@ -762,9 +786,9 @@ mod tests { SignaturePublicKey::new(vec![4u8; 16]), SignaturePrivateKey::new(vec![5u8; 16]), ); - let bytes = keyring.to_bytes(); + let bytes = keyring.try_to_bytes()?; let recovered = Keyring::try_from(bytes.as_slice())?; - assert_eq!(keyring.to_bytes(), recovered.to_bytes()); + assert_eq!(keyring.try_to_bytes()?, recovered.try_to_bytes()?); Ok(()) } @@ -788,9 +812,9 @@ mod tests { SignaturePublicKey::new(vec![5u8; 16]), SignaturePrivateKey::new(vec![6u8; 16]), ); - let hex = keyring.to_hex(); + let hex = keyring.try_to_hex()?; let recovered = Keyring::from_hex(&hex)?; - assert_eq!(keyring.to_bytes(), recovered.to_bytes()); + assert_eq!(keyring.try_to_bytes()?, recovered.try_to_bytes()?); Ok(()) } @@ -804,9 +828,9 @@ mod tests { SignaturePublicKey::new(vec![5u8; 16]), SignaturePrivateKey::new(vec![6u8; 16]), ); - let b64 = keyring.to_base64(); + let b64 = keyring.try_to_base64()?; let recovered = Keyring::from_base64(&b64)?; - assert_eq!(keyring.to_bytes(), recovered.to_bytes()); + assert_eq!(keyring.try_to_bytes()?, recovered.try_to_bytes()?); Ok(()) } @@ -817,9 +841,9 @@ mod tests { SignaturePqPublicKey::new(vec![2u8; 64]), SignaturePublicKey::new(vec![3u8; 32]), ); - let b64 = bundle.to_base64(); + let b64 = bundle.try_to_base64()?; let recovered = PublicKeyBundle::from_base64_unvalidated(&b64)?; - assert_eq!(bundle.as_bytes(), recovered.as_bytes()); + assert_eq!(bundle.try_as_bytes()?, recovered.try_as_bytes()?); Ok(()) } diff --git a/crypto/src/lib.rs b/crypto/src/lib.rs index afcf21b..4cd1e25 100644 --- a/crypto/src/lib.rs +++ b/crypto/src/lib.rs @@ -60,6 +60,8 @@ pub use sign::{DualSignature, DualSigner, sign_dual}; #[cfg(feature = "sha2")] pub use hash::{Sha256Hasher, sha256, sha256_double}; +#[cfg(feature = "password-kdf")] +pub use kdf::derive_password_key; #[cfg(feature = "hkdf")] pub use kdf::{derive_encryption_key, hkdf_expand, hkdf_extract}; @@ -83,7 +85,9 @@ pub use helper::{ENCRYPT_DOMAIN, KEY_WRAP_DOMAIN}; #[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] pub use helper::{ - MAX_RECIPIENTS, MultiEncryptedMessage, RecipientEntry, decrypt_multi_for, encrypt_multi_for, + MAX_RECIPIENTS, MultiEncryptedMessage, MultiEncryptedMessageRef, RecipientEntry, + decrypt_multi_for, decrypt_multi_for_parts, decrypt_multi_for_parts_with_limit, + encrypt_multi_for, }; /* ================================ TESTS ================================ */ @@ -311,7 +315,9 @@ mod tests { #[test] fn keyring_serialize_roundtrip() { let kr = Keyring::generate(); - let bytes = kr.to_bytes(); + let bytes = kr + .try_to_bytes() + .expect("keyring serialization should succeed"); let loaded = Keyring::from_bytes(&bytes).expect("keyring roundtrip should succeed"); assert_eq!( kr.kem_public_key.as_bytes(), @@ -332,7 +338,9 @@ mod tests { fn public_key_bundle_serialize_roundtrip() { let kr = Keyring::generate(); let bundle = kr.public_key_bundle(); - let bytes = bundle.as_bytes(); + let bytes = bundle + .try_as_bytes() + .expect("bundle serialization should succeed"); let loaded = PublicKeyBundle::from_bytes(&bytes).expect("bundle roundtrip should succeed"); assert_eq!( bundle.kem_public_key.as_bytes(), diff --git a/docs/NATIVE-CLIENT.md b/docs/NATIVE-CLIENT.md index 5f40f9d..d764727 100644 --- a/docs/NATIVE-CLIENT.md +++ b/docs/NATIVE-CLIENT.md @@ -142,7 +142,7 @@ let conn = MTPClient::auth_register(config, &keyring, &host_pk).await?; // Save for next session let id = conn.client_id; -let keyring_bytes = keyring.to_bytes(); +let keyring_bytes = keyring.try_to_bytes()?; ``` When callers already know whether a saved client ID exists, the convenience helper uses `Some(id)` for login and `None` for registration: @@ -175,7 +175,7 @@ pub struct Keyring { } ``` -- Serialise: `keyring.to_bytes()` -> `Vec` +- Serialise: `keyring.try_to_bytes()` -> `Result>, CryptoError>` - Deserialise: `Keyring::from_bytes(&bytes)` -> `Result` - Get public half: `keyring.public_key_bundle()` -> `PublicKeyBundle` diff --git a/docs/NATIVE-HOST.md b/docs/NATIVE-HOST.md index d6ca79b..33726ec 100644 --- a/docs/NATIVE-HOST.md +++ b/docs/NATIVE-HOST.md @@ -212,7 +212,7 @@ let (kem_sk, kem_pk) = HybridKem::generate_keypair(); let host_keyring = Keyring::new(kem_pk, kem_sk, sig_pq_pk, sig_pq_sk, sig_pk, sig_sk); // Save to disk -let bytes = host_keyring.to_bytes(); +let bytes = host_keyring.try_to_bytes()?; std::fs::write("host_keys.bin", bytes)?; ``` diff --git a/docs/PROTOCOL-REFERENCE.md b/docs/PROTOCOL-REFERENCE.md index 7715828..5c02ad9 100644 --- a/docs/PROTOCOL-REFERENCE.md +++ b/docs/PROTOCOL-REFERENCE.md @@ -55,8 +55,28 @@ 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. +the replay-explicit `open_protected_checked` or `open_protected_without_replay` +APIs; language bindings delegate envelope construction and opening to this +codec boundary. + +Message processing uses the replay-required native APIs +`open_protected_checked` and `open_relay_metadata_checked` (or the equivalent +browser client path). Stored-message or forensic tooling must opt into the +explicit `*_without_replay` APIs. Native in-memory guards are bounded and +configurable; durable guards must perform an atomic insert-if-absent on +`(signer ID, MessageId)`. + +Protected identifiers have semantic limits separate from the generic codec +blob limit. The default maximum `MessageId` is 256 UTF-8 bytes and relay +metadata is limited to 1 MiB of encoded metadata. Deployments can provide +stricter limits through the receive policy. Limits are checked after +authentication and before retained values enter replay or application state. + +Transport-derived resource policies use a conservative decoder allocation +factor of `4 * max_message_size`, in addition to the frame-size output limit. +This factor accounts for owned wrapper, recipient, ciphertext, and decoded +value copies; it is an implementation admission policy rather than a wire +field. ## Authentication Flow @@ -77,6 +97,15 @@ Client Host Login proof binds the protocol version, client ID, host challenge, and client nonce. Registration proof binds the protocol version, public key bundle, host challenge, and client nonce. The host challenge is generated per connection. +Authentication attempts pass through a deployment-configurable limiter before +client lookup, key validation, challenge signing, or registration callbacks. +The default host configuration uses a bounded in-memory window. Hosts may key +limits by connection, peer identity, claimed client ID, or registration flow. +When identity concealment is enabled, an unknown client ID follows a dummy +challenge/proof path and receives the same generic authentication failure as a +known client with an invalid proof; disabling concealment restores the legacy +identity-specific response for deployments where IDs are public. + `ForceAuthentication` requires login or registration. `AllowAuthentication` accepts authenticated and unauthenticated clients. `Unauthenticated` rejects authentication attempts. The connection states are `Pending`, `Authenticated`, `Unauthenticated`, and `Failed`. ## Version Negotiation diff --git a/docs/SECURITY.md b/docs/SECURITY.md index b4cc18f..154f8e8 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -160,6 +160,14 @@ process boundaries. A guard should atomically record a new ID before dispatching application content. Transport frame IDs must not be used for this purpose. +Native message-processing boundaries require a replay guard through the +checked opening APIs. Reopening stored or forensic frames without a guard is +available only through an explicitly named `without_replay` API. The reference +in-memory guard is bounded and FIFO-evicts old entries, so it is a duplicate +suppression cache rather than durable replay protection. A durable deployment +must use an atomic insert-if-absent operation keyed by `(signer ID, MessageId)`; +a separate read followed by insert is race-prone. + `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 @@ -174,9 +182,11 @@ fallback. 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 +an explicit `ed25519` default and permits an operation or client override. Its +`MTPSecurityProfile` resolves protected-message sender/receiver suites, +encrypted-pipe suites, and the authentication PQ requirement together; +`any-supported` remains an explicit compatibility value. It never derives +receive policy from the recipient keyring. Signature policy must be applied independently to relay metadata, relay content, and pipe session establishment. ### Key history and rotation @@ -261,16 +271,43 @@ 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. +Key-material parsing is explicit in the SDK: use the hex, Base64, or byte +helpers for encoded key material. Arbitrary strings are no longer treated as +passphrases by the compatibility `secretKeyFromString` helper. Applications +migrating data written by the old implicit-HKDF behavior can use the explicitly +named, deprecated `legacySecretKeyFromStringV1` helper only for that migration; +new data must not use it. Passwords must use the explicit Argon2id passphrase +API with a stored per-record salt and versioned parameters. The SDK's +`deriveKeyFromPassphrase` uses a worker when browser workers are available; +the explicitly named `deriveKeyFromPassphraseSync` form is for workers and +command-line migrations. HKDF helpers are for high-entropy key material and +are not password-hardening functions. + ## 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. +64 encrypted recipients, and a 64 MiB cumulative decoder allocation budget. +Decrypted values are parsed with the same limits. Transport derives the blob, +allocation, and encoder output budgets from its admitted frame size rather than +serializing an unrestricted recursive value first. The default transport +allocation budget is four times the admitted frame size to cover conservative +owned-copy and crypto-buffer accounting; deployments may choose another +factor with `DecodeLimits::for_transport_message_size_with_allocation_factor`. -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. +The host applies an authentication-attempt limiter before storage lookups, +public-key validation, challenge signing, and registration callbacks. The +default limiter is a bounded in-memory sliding window; configure a durable or +distributed limiter when limits must coordinate across host instances. Unknown +client IDs are sent through a fixed dummy challenge/proof path by default, so +they receive a generic authentication failure instead of an enumeration hint. +Deployments that intentionally publish client IDs can disable this concealment. + +Keepalive Pong observation is bounded and accepts only the currently pending +ping ID. Unsolicited Pongs are dropped before they can consume application +receiver capacity. ## Security Limitations diff --git a/docs/TYPE-MAP.md b/docs/TYPE-MAP.md index 157850f..492cf89 100644 --- a/docs/TYPE-MAP.md +++ b/docs/TYPE-MAP.md @@ -1,6 +1,8 @@ # Type Map This file documents the Type Map & Registry configuration used by the MTP protocol. It will assume you are working with the [example-type-maps.yaml](./../example-type-maps.yaml). +A type musn't be the version of MTP, it stays independant. +MTP version defines the codec. The Type-Map version defines the available Types. ## Binary Frame Format @@ -85,6 +87,15 @@ The envelope length counts the bytes after the length field. A recipient entry i 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. +### Container ordering and signatures + +Container entries are ordered sequences in the current format. Insertion order +is therefore semantic: two containers with the same field/value pairs in a +different order have different serialized bytes and different signatures. The +decoder rejects duplicate field IDs. Applications that need map semantics must +canonicalize their own input before signing; a future canonical map encoding +requires a protocol-format version and cannot be inferred by a receiver. + ## TypeMap & Compile-Time Type Safety A `TypeMap` maps Communication-Types and Data-Types to their wire IDs. Each protocol version has its own `TypeMap` because the same type name may use different wire IDs in different versions. @@ -175,17 +186,33 @@ mtp = { path = "..", features = ["host"] } ```rust use mtp::codec::registry::{Registry, VersionedCodec}; +use mtp::codec::{CommunicationType, CommunicationValue, DataValue}; +use mtp_type_map::Version; let registry = Registry::builtin(); -let codec = VersionedCodec::new(registry); +let codec = VersionedCodec::for_version(registry, Version(3, 0)).unwrap(); +let value = CommunicationValue::new_with_type_map( + CommunicationType::Ping, + codec.type_map(), +).with_payload(DataValue::Null); -// Encode with a specific version -let bytes = codec.encode(&value, Version(3, 0)).unwrap(); +// The value must retain the negotiated map used to construct it. +let bytes = codec.encode(&value).unwrap(); -// Decode with a specific version -let decoded = codec.decode(&bytes, Version(3, 0)).unwrap(); +let decoded = codec.decode(&bytes).unwrap(); + +// A clear value can be migrated explicitly when the application has chosen +// that behavior. Protected values are not silently remapped. +let migrated = codec.encode_migrating(&value).unwrap(); ``` +`VersionedCodec::encode` compares the retained map identity (its protocol +version) and returns `CodecError::MissingTypeMap` or +`CodecError::TypeMapMismatch` on failure. `reply_to` retains the request's +map, while `try_merge` rejects frames from different maps before copying any +fields. The deprecated `merge` method records the error for compatibility; new +code should migrate to `try_merge` and handle the result. + ## Customizing Type Maps in Downstream Projects External projects must provide their own type map configuration. Browser projects use the Vite plugin from [Defining Type Maps](#defining-type-maps) and do not need to publish, fork, or copy a generated WASM package. diff --git a/example/client/src/protected.rs b/example/client/src/protected.rs index 50b7925..74214b8 100644 --- a/example/client/src/protected.rs +++ b/example/client/src/protected.rs @@ -3,8 +3,9 @@ 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, + ProtectionPurpose, SealedRelayBuilder, SignaturePolicy, TypeMap, + open_relay_content_with_limits_without_replay, + open_relay_metadata_without_replay, }; use mtp::common::unix_time_millis; use mtp::crypto::{Ed25519Signer, Keyring, PublicKeyBundle}; @@ -159,7 +160,7 @@ pub async fn send_sealed_relay( return Err("relay forwarding changed the sealed-sender boundary".into()); } - let metadata = open_relay_metadata( + let metadata = open_relay_metadata_without_replay( &forwarded, &final_recipient_keyring, signer_id, @@ -169,7 +170,7 @@ pub async fn send_sealed_relay( let application_metadata = metadata .metadata() .ok_or("forwarded relay metadata was missing")?; - let content = open_relay_content( + let content = open_relay_content_with_limits_without_replay( &metadata, &final_recipient_keyring, &signer_keyring.public_key_bundle(), diff --git a/example/keygen/src/main.rs b/example/keygen/src/main.rs index 81acb51..a9543ee 100644 --- a/example/keygen/src/main.rs +++ b/example/keygen/src/main.rs @@ -17,14 +17,22 @@ fn main() -> Result<(), files::FileError> { /* Read both back to confirm the files round-trip through the on-disk format. */ let loaded_keyring = load_keyring_raw(&keyring_path)?; let loaded_bundle = load_public_key_bundle(&bundle_path)?; - assert_eq!(keyring.to_bytes(), loaded_keyring.to_bytes()); + assert_eq!(keyring.try_to_bytes()?, loaded_keyring.try_to_bytes()?); + let bundle_bytes = keyring.public_key_bundle().try_as_bytes()?; + let loaded_bundle_bytes = loaded_bundle.try_as_bytes()?; assert_eq!( - keyring.public_key_bundle().as_bytes(), - loaded_bundle.as_bytes() + bundle_bytes, + loaded_bundle_bytes + ); + println!( + "\nPrivateKeyRing (base64):\n{}", + keyring.try_to_base64()? ); - println!("\nPrivateKeyRing (base64):\n{}", keyring.to_base64()); - println!("\nPublicKeyBundle (base64):\n{}", loaded_bundle.to_base64()); + println!( + "\nPublicKeyBundle (base64):\n{}", + loaded_bundle.try_to_base64()? + ); println!("Wrote keyring -> {}", keyring_path.display()); println!("Wrote bundle -> {}", bundle_path.display()); diff --git a/example/server/src/handlers.rs b/example/server/src/handlers.rs index 1e6320b..2d9d0cd 100644 --- a/example/server/src/handlers.rs +++ b/example/server/src/handlers.rs @@ -3,7 +3,9 @@ use std::collections::HashMap; use mtp::codec::{ CommunicationType, CommunicationValue, DataType, DataTypeId, DataValue, InMemoryReplayGuard, ProtectedOpenOptions, ProtectionPolicy, ProtectionPurpose, SignaturePolicy, TypeMap, - forward_relay_frame, open_protected_with, open_relay_content, open_relay_metadata_with, + forward_relay_frame, open_protected_with_checked, + open_relay_content_with_limits_without_replay, + open_relay_metadata_with_checked, }; use mtp::crypto::{Keyring, PublicKeyBundle}; @@ -65,7 +67,7 @@ fn process_direct_protected( )); } - let opened = open_protected_with( + let opened = open_protected_with_checked( msg, std::slice::from_ref(&host_keyring), None, @@ -76,7 +78,7 @@ fn process_direct_protected( ProtectionPurpose::from(DIRECT_ENCRYPTION_PURPOSE), SIGNATURE_POLICY, ), - Some(accepted_messages), + accepted_messages, ) .map_err(|e| format!("direct protected message could not be authenticated: {e}"))?; let signer_id = opened.signer_id; @@ -133,7 +135,7 @@ fn process_sealed_relay( )); } - let metadata = open_relay_metadata_with( + let metadata = open_relay_metadata_with_checked( msg, std::slice::from_ref(&host_keyring), None, @@ -141,7 +143,7 @@ fn process_sealed_relay( resolve_signer_key(signer_id, registered_clients).map(|key| vec![key]) }, SIGNATURE_POLICY, - Some(accepted_messages), + accepted_messages, ) .map_err(|e| format!("metadata relay could not authenticate metadata: {e}"))?; println!( @@ -160,7 +162,7 @@ fn process_sealed_relay( .len() ); - let content_result = open_relay_content( + let content_result = open_relay_content_with_limits_without_replay( &metadata, host_keyring, &resolve_signer_key(metadata.signer_id(), registered_clients) diff --git a/example/server/src/keys.rs b/example/server/src/keys.rs index 53f83af..41bd2d5 100644 --- a/example/server/src/keys.rs +++ b/example/server/src/keys.rs @@ -27,7 +27,7 @@ pub async fn export_host_public_keys( save_public_key_bundle(&bundle, "host.mpkb")?; /* The web client fetches the bundle as hex over HTTP. */ - let bundle_hex = hex::encode(bundle.as_bytes()); + let bundle_hex = hex::encode(bundle.try_as_bytes()?); fs::write("host_public_key_bundle.hex", &bundle_hex).await?; fs::create_dir_all("web-client/public").await?; fs::write("web-client/public/host_public_key_bundle.hex", &bundle_hex).await?; diff --git a/example/server/src/main.rs b/example/server/src/main.rs index 9854d2f..b8ce1be 100644 --- a/example/server/src/main.rs +++ b/example/server/src/main.rs @@ -111,8 +111,9 @@ async fn main() -> Result<(), Box> { }) as Pin + Send>> }; + let decrypt_keyring_bytes = host_keyring.try_to_bytes()?; let decrypt_keyring = Arc::new( - match mtp::crypto::Keyring::from_bytes(&host_keyring.to_bytes()) { + match mtp::crypto::Keyring::from_bytes(&decrypt_keyring_bytes) { Ok(keyring) => keyring, Err(e) => { return Err(format!("failed to re-load host keyring for decryption: {e}").into()); diff --git a/files/Cargo.toml b/files/Cargo.toml index 8ca0625..244d1d2 100644 --- a/files/Cargo.toml +++ b/files/Cargo.toml @@ -6,8 +6,7 @@ edition = "2024" [dependencies] # 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.3.0", path = "../crypto", default-features = false, features = ["chacha20poly1305", "hkdf"] } -argon2 = "0.5" +mtp-crypto = { version = "0.3.0", path = "../crypto", default-features = false, features = ["chacha20poly1305", "hkdf", "password-kdf"] } rand = "0.10.2" thiserror = "2" diff --git a/files/src/lib.rs b/files/src/lib.rs index 7c499bf..366f305 100644 --- a/files/src/lib.rs +++ b/files/src/lib.rs @@ -119,6 +119,20 @@ fn temporary_path(path: &Path, attempt: u64) -> io::Result { static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); +#[cfg(unix)] +fn sync_parent_directory(path: &Path) -> io::Result<()> { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + fs::File::open(parent)?.sync_all() +} + +#[cfg(not(unix))] +fn sync_parent_directory(_path: &Path) -> io::Result<()> { + Ok(()) +} + fn write_secret_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { use std::io::Write; @@ -146,7 +160,7 @@ fn write_secret_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { let _ = fs::remove_file(&temporary); return Err(error); } - Ok(()) + sync_parent_directory(path) } fn derive_key( @@ -156,21 +170,12 @@ fn derive_key( 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) - { + if salt.len() != SALT_LEN { 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) + Ok(Zeroizing::new(mtp_crypto::derive_password_key( + passphrase, salt, memory_kib, iterations, lanes, + )?)) } fn protected_header_aad(parameters: &[u8]) -> Vec { @@ -206,7 +211,7 @@ pub fn save_keyring( 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 plaintext = keyring.try_to_bytes()?; 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); @@ -259,7 +264,7 @@ pub fn load_keyring(path: impl AsRef, passphrase: &[u8]) -> Result) -> Result<(), FileError> { - let payload = keyring.to_bytes(); + let payload = keyring.try_to_bytes()?; let bytes = Zeroizing::new(encode(KEYRING_MAGIC, RAW_FORMAT_VERSION, &payload)); write_secret_atomic(path.as_ref(), &bytes)?; Ok(()) @@ -286,7 +291,8 @@ pub fn save_public_key_bundle( bundle: &PublicKeyBundle, path: impl AsRef, ) -> Result<(), FileError> { - let bytes = encode(BUNDLE_MAGIC, BUNDLE_FORMAT_VERSION, &bundle.as_bytes()); + let bundle_bytes = bundle.try_as_bytes()?; + let bytes = encode(BUNDLE_MAGIC, BUNDLE_FORMAT_VERSION, &bundle_bytes); fs::write(path, bytes)?; Ok(()) } @@ -339,7 +345,7 @@ mod tests { let keyring = sample_keyring(); save_keyring(&keyring, &path, b"correct horse battery staple")?; let loaded = load_keyring(&path, b"correct horse battery staple")?; - assert_eq!(keyring.to_bytes(), loaded.to_bytes()); + assert_eq!(keyring.try_to_bytes()?, loaded.try_to_bytes()?); let _ = fs::remove_file(&path); Ok(()) } @@ -350,7 +356,7 @@ mod tests { let bundle = Keyring::generate().public_key_bundle(); save_public_key_bundle(&bundle, &path)?; let loaded = load_public_key_bundle(&path)?; - assert_eq!(bundle.as_bytes(), loaded.as_bytes()); + assert_eq!(bundle.try_as_bytes()?, loaded.try_as_bytes()?); let _ = fs::remove_file(&path); Ok(()) } @@ -414,7 +420,7 @@ mod tests { Err(FileError::UnprotectedKeyring) )); let loaded = load_keyring_raw(&path)?; - assert_eq!(keyring.to_bytes(), loaded.to_bytes()); + assert_eq!(keyring.try_to_bytes()?, loaded.try_to_bytes()?); let _ = fs::remove_file(&path); Ok(()) } @@ -423,7 +429,7 @@ mod tests { fn protected_keyring_is_not_plaintext() -> Result<(), Box> { let path = temp_path(KEYRING_EXTENSION); let keyring = sample_keyring(); - let serialized = keyring.to_bytes(); + let serialized = keyring.try_to_bytes()?; save_keyring(&keyring, &path, b"passphrase")?; let stored = fs::read(&path)?; assert!( diff --git a/host/Cargo.toml b/host/Cargo.toml index 306c673..ad09a54 100644 --- a/host/Cargo.toml +++ b/host/Cargo.toml @@ -9,6 +9,7 @@ mtp-codec = { version = "0.3.0", path = "../codec", features = ["registry"] } mtp-transport = { version = "0.3.0", path = "../transport", features = ["host"] } mtp-crypto = { version = "0.3.0", path = "../crypto", optional = true } rand = "0.10" +thiserror = "2" tokio = { version = "1", features = ["macros", "rt", "time", "sync"] } tracing = "0.1" wtransport = "0.7" diff --git a/host/src/config.rs b/host/src/config.rs index 2335437..6ab4c36 100644 --- a/host/src/config.rs +++ b/host/src/config.rs @@ -5,10 +5,14 @@ use std::collections::HashMap; #[cfg(feature = "crypto")] use std::collections::HashSet; #[cfg(feature = "crypto")] +use std::collections::VecDeque; +#[cfg(feature = "crypto")] use std::pin::Pin; #[cfg(feature = "crypto")] use std::sync::{Arc, Mutex}; #[cfg(feature = "crypto")] +use std::time::{Duration as StdDuration, Instant}; +#[cfg(feature = "crypto")] use tokio::time::Duration; pub use mtp_transport::Policy; @@ -82,6 +86,158 @@ pub enum AuthenticationPolicy { Unauthenticated, } +/// Transport-supplied identity used to scope authentication attempt limits. +/// Concrete hosts should populate these fields from the accepted connection; +/// the zero/empty defaults exist only for transport-neutral callers. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AuthenticationContext { + pub peer_network_identity: Option, + pub connection_id: u64, +} + +#[cfg(feature = "crypto")] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthenticationAttempt { + pub peer_network_identity: Option, + pub connection_id: u64, + pub claimed_client_id: Option, + pub registration: bool, +} + +#[cfg(feature = "crypto")] +#[derive(Debug, thiserror::Error)] +pub enum AuthenticationLimitError { + #[error("authentication limiter storage is unavailable")] + Store, +} + +#[cfg(feature = "crypto")] +pub trait AuthenticationAttemptLimiter: Send + Sync { + fn allow(&self, context: &AuthenticationAttempt) -> Result; +} + +#[cfg(feature = "crypto")] +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +enum AuthenticationLimitKey { + Peer(String), + Connection(u64), + Client(u64), + Registration, + Global, +} + +#[cfg(feature = "crypto")] +#[derive(Debug)] +pub struct InMemoryAuthenticationAttemptLimiter { + max_attempts: usize, + window: StdDuration, + max_keys: usize, + by_peer: bool, + by_connection: bool, + by_client: bool, + by_registration: bool, + attempts: Mutex>>, +} + +#[cfg(feature = "crypto")] +impl InMemoryAuthenticationAttemptLimiter { + pub fn new(max_attempts: usize, window: StdDuration) -> Self { + Self { + max_attempts, + window, + max_keys: 100_000, + by_peer: true, + by_connection: true, + by_client: true, + by_registration: true, + attempts: Mutex::new(HashMap::new()), + } + } + + pub fn with_keys( + mut self, + by_peer: bool, + by_connection: bool, + by_client: bool, + by_registration: bool, + ) -> Self { + self.by_peer = by_peer; + self.by_connection = by_connection; + self.by_client = by_client; + self.by_registration = by_registration; + self + } + + pub fn with_max_keys(mut self, max_keys: usize) -> Self { + self.max_keys = max_keys.max(1); + self + } + + fn keys(&self, context: &AuthenticationAttempt) -> Vec { + let mut keys = Vec::with_capacity(5); + if self.by_peer + && let Some(peer) = context.peer_network_identity.as_ref() + { + keys.push(AuthenticationLimitKey::Peer(peer.clone())); + } + if self.by_connection && context.connection_id != 0 { + keys.push(AuthenticationLimitKey::Connection(context.connection_id)); + } + if self.by_client + && let Some(client_id) = context.claimed_client_id + { + keys.push(AuthenticationLimitKey::Client(client_id)); + } + if self.by_registration && context.registration { + keys.push(AuthenticationLimitKey::Registration); + } + // Keep one global bucket as a backstop when an attacker varies the + // claimed client ID or presents no peer/connection identity. + keys.push(AuthenticationLimitKey::Global); + keys + } +} + +#[cfg(feature = "crypto")] +impl AuthenticationAttemptLimiter for InMemoryAuthenticationAttemptLimiter { + fn allow(&self, context: &AuthenticationAttempt) -> Result { + if self.max_attempts == 0 { + return Ok(false); + } + let now = Instant::now(); + let cutoff = now.checked_sub(self.window); + let keys = self.keys(context); + let mut attempts = self + .attempts + .lock() + .map_err(|_| AuthenticationLimitError::Store)?; + + for key in &keys { + if let Some(history) = attempts.get_mut(key) { + while history + .front() + .is_some_and(|timestamp| cutoff.is_some_and(|cutoff| *timestamp <= cutoff)) + { + history.pop_front(); + } + if history.len() >= self.max_attempts { + return Ok(false); + } + } + } + + for key in keys { + if !attempts.contains_key(&key) && attempts.len() >= self.max_keys { + if let Some(oldest) = attempts.keys().next().cloned() { + attempts.remove(&oldest); + } + } + attempts.entry(key).or_default().push_back(now); + } + Ok(true) + } +} + pub struct HostConfig { pub ip: IpAddr, pub port: u16, @@ -113,6 +269,10 @@ pub struct HostConfig { pub complete_register: CompleteRegister, #[cfg(feature = "crypto")] pub find_registered_client: Option, + #[cfg(feature = "crypto")] + pub auth_limiter: Arc, + #[cfg(feature = "crypto")] + pub conceal_authentication_identities: bool, } impl HostConfig { @@ -153,6 +313,13 @@ impl HostConfig { complete_register: Box::new(|_, _| Box::pin(async { 0 })), #[cfg(feature = "crypto")] find_registered_client: None, + #[cfg(feature = "crypto")] + auth_limiter: Arc::new(InMemoryAuthenticationAttemptLimiter::new( + 32, + StdDuration::from_secs(60), + )), + #[cfg(feature = "crypto")] + conceal_authentication_identities: true, } } @@ -210,4 +377,67 @@ impl HostConfig { self.find_registered_client = Some(lookup); self } + + #[cfg(feature = "crypto")] + pub fn with_authentication_limiter( + mut self, + limiter: Arc, + ) -> Self { + self.auth_limiter = limiter; + self + } + + #[cfg(feature = "crypto")] + pub fn with_authentication_identity_concealment(mut self, conceal: bool) -> Self { + self.conceal_authentication_identities = conceal; + self + } +} + +#[cfg(all(test, feature = "crypto"))] +mod tests { + use super::*; + + #[test] + fn authentication_attempt_limiter_rejects_repeated_attempts() { + let limiter = InMemoryAuthenticationAttemptLimiter::new(1, StdDuration::from_secs(60)) + .with_keys(false, true, false, false); + let attempt = AuthenticationAttempt { + peer_network_identity: None, + connection_id: 9, + claimed_client_id: Some(42), + registration: false, + }; + + assert!(limiter.allow(&attempt).expect("first attempt decision")); + assert!(!limiter.allow(&attempt).expect("second attempt decision")); + } + + #[test] + fn authentication_attempt_limiter_can_scope_registration_separately() { + let limiter = InMemoryAuthenticationAttemptLimiter::new(2, StdDuration::from_secs(60)) + .with_keys(false, false, false, true); + let login = AuthenticationAttempt { + peer_network_identity: None, + connection_id: 1, + claimed_client_id: None, + registration: false, + }; + let registration = AuthenticationAttempt { + registration: true, + ..login.clone() + }; + + assert!(limiter.allow(&login).expect("login attempt decision")); + assert!( + limiter + .allow(®istration) + .expect("registration attempt decision") + ); + assert!( + !limiter + .allow(®istration) + .expect("repeated registration decision") + ); + } } diff --git a/host/src/connection.rs b/host/src/connection.rs index 20cc76d..71c60db 100644 --- a/host/src/connection.rs +++ b/host/src/connection.rs @@ -11,7 +11,10 @@ use tokio::sync::{Mutex, mpsc}; #[cfg(feature = "crypto")] use crate::error::random_client_id; #[cfg(feature = "pipes")] -use crate::pipe::{PipeDispatcher, PipeReceiver, PipeRequest, PipeSender, run_dispatcher}; +use crate::pipe::{ + PendingCreationGuard, PipeDispatcher, PipeReceiver, PipeRequest, PipeSender, + is_expired_creation, run_dispatcher, +}; #[cfg(feature = "pipes")] use mtp_transport::Policy; @@ -145,10 +148,12 @@ where remote_addr: Option, ) -> Self { let policy = Arc::new(Policy::default()); - let (app_tx, app_rx) = mpsc::channel(policy.receiver_queue_capacity); - let (pipe_req_tx, pipe_req_rx) = mpsc::channel(policy.receiver_queue_capacity); + let receiver_queue_capacity = policy.receiver_queue_capacity.max(1); + let (app_tx, app_rx) = mpsc::channel(receiver_queue_capacity); + let (pipe_req_tx, pipe_req_rx) = mpsc::channel(receiver_queue_capacity); let dispatcher = Arc::new(PipeDispatcher { - pending_creations: Mutex::new(std::collections::HashMap::new()), + pending_creations: std::sync::Mutex::new(std::collections::HashMap::new()), + expired_creations: std::sync::Mutex::new(std::collections::HashMap::new()), pending_pipes: Mutex::new(std::collections::HashMap::new()), policy, type_map: codec.type_map().clone(), @@ -198,10 +203,12 @@ where remote_addr: Option, policy: Arc, ) -> Self { - let (app_tx, app_rx) = mpsc::channel(policy.receiver_queue_capacity); - let (pipe_req_tx, pipe_req_rx) = mpsc::channel(policy.receiver_queue_capacity); + let receiver_queue_capacity = policy.receiver_queue_capacity.max(1); + let (app_tx, app_rx) = mpsc::channel(receiver_queue_capacity); + let (pipe_req_tx, pipe_req_rx) = mpsc::channel(receiver_queue_capacity); let dispatcher = Arc::new(PipeDispatcher { - pending_creations: Mutex::new(std::collections::HashMap::new()), + pending_creations: std::sync::Mutex::new(std::collections::HashMap::new()), + expired_creations: std::sync::Mutex::new(std::collections::HashMap::new()), pending_pipes: Mutex::new(std::collections::HashMap::new()), policy, type_map: codec.type_map().clone(), @@ -321,19 +328,37 @@ where pub async fn create_pipe( &self, description: &str, - ) -> Result, mtp_common::PipeError> { + ) -> Result, mtp_common::PipeError> { let (response_tx, response_rx) = tokio::sync::oneshot::channel(); let pipe_id = { - let mut pending = self.pipe_dispatcher.pending_creations.lock().await; + let mut pending = self + .pipe_dispatcher + .pending_creations + .lock() + .map_err(|_| mtp_common::PipeError::ConnectionClosed)?; let pipe_id = loop { let candidate = rand::random::(); - if candidate != 0 && !pending.contains_key(&candidate) { + if candidate != 0 + && !pending.contains_key(&candidate) + && !is_expired_creation(&self.pipe_dispatcher, candidate) + { break candidate; } }; - pending.insert(pipe_id, response_tx); - pipe_id + let token = Arc::new(()); + pending.insert( + pipe_id, + crate::pipe::PendingCreation { + token: token.clone(), + sender: response_tx, + }, + ); + drop(pending); + (pipe_id, token) }; + let (pipe_id, token) = pipe_id; + let mut creation_guard = + PendingCreationGuard::new(self.pipe_dispatcher.clone(), pipe_id, token.clone()); let request = CommunicationValue::new_with_type_map( CommunicationType::PipeRequest, @@ -342,19 +367,17 @@ where .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)); } + creation_guard.disarm(); Ok(crate::pipe::PipeHandle { pipe_id, description: description.to_owned(), sender: self.sender.clone(), response_rx, + dispatcher: self.pipe_dispatcher.clone(), + token, }) } diff --git a/host/src/engine.rs b/host/src/engine.rs index cd16934..38e8422 100644 --- a/host/src/engine.rs +++ b/host/src/engine.rs @@ -4,7 +4,7 @@ //! and the web server's `MTPWebServer` to perform the MTP opening handshake, //! version negotiation, authentication, and guest assignment. -use crate::config::HostConfig; +use crate::config::{AuthenticationContext, HostConfig}; use crate::error::AcceptError; use mtp_codec::{ CommunicationType, CommunicationValue, DataType, DataValue, TypeMap, Version, @@ -127,19 +127,32 @@ impl HandshakeEngine { &self, sender: &S, receiver: &R, + ) -> Result { + self.accept_with_context(sender, receiver, AuthenticationContext::default()) + .await + } + + /// Run the opening handshake with transport-provided authentication + /// scoping information. + pub async fn accept_with_context( + &self, + sender: &S, + receiver: &R, + context: AuthenticationContext, ) -> Result { #[cfg(feature = "crypto")] { - self.accept_until( + self.accept_until_with_context( sender, receiver, tokio::time::Instant::now() + self.config.auth_timeout, + context, ) .await } #[cfg(not(feature = "crypto"))] { - let result = self.accept_inner(sender, receiver).await; + let result = self.accept_inner(sender, receiver, &context).await; if result.is_err() { sender.close(); } @@ -159,7 +172,22 @@ impl HandshakeEngine { receiver: &R, deadline: tokio::time::Instant, ) -> Result { - match tokio::time::timeout_at(deadline, self.accept_inner(sender, receiver)).await { + self.accept_until_with_context(sender, receiver, deadline, AuthenticationContext::default()) + .await + } + + /// Run the crypto handshake until a deadline with transport-provided + /// authentication scoping information. + #[cfg(feature = "crypto")] + pub async fn accept_until_with_context( + &self, + sender: &S, + receiver: &R, + deadline: tokio::time::Instant, + context: AuthenticationContext, + ) -> Result { + match tokio::time::timeout_at(deadline, self.accept_inner(sender, receiver, &context)).await + { Ok(result) => { if result.is_err() { sender.close(); @@ -186,6 +214,7 @@ impl HandshakeEngine { &self, sender: &S, receiver: &R, + _authentication_context: &AuthenticationContext, ) -> Result { let mut first_msg = receiver.receive().await.map_err(AcceptError::Receive)?; @@ -257,6 +286,42 @@ impl HandshakeEngine { #[cfg(feature = "crypto")] { + let claimed_client_id = match first_msg.get_data(DataType::Id) { + Some(DataValue::UnsignedNumber(value)) => u64::try_from(*value).ok(), + _ => None, + }; + let registration = Some(first_msg.get_type()) + == CommunicationType::Register.try_to_id(codec.type_map()); + let authentication_requested = matches!( + self.config.authentication_policy, + crate::config::AuthenticationPolicy::ForceAuthentication + ) || registration + || first_msg.get_data(DataType::PublicKeys).is_some() + || claimed_client_id.is_some_and(|client_id| client_id != 0); + if authentication_requested { + let attempt = crate::config::AuthenticationAttempt { + peer_network_identity: _authentication_context.peer_network_identity.clone(), + connection_id: _authentication_context.connection_id, + claimed_client_id, + registration, + }; + match self.config.auth_limiter.allow(&attempt) { + Ok(true) => {} + Ok(false) | Err(_) => { + let error = + AcceptError::AuthenticationFailed("authentication rejected".into()); + send_rejection_generic( + sender, + RejectionReason::RateLimited, + Some(codec.type_map()), + ) + .await; + sender.close(); + return Err(error); + } + } + } + match self.config.authentication_policy { crate::config::AuthenticationPolicy::ForceAuthentication => { self.force_auth_handshake( @@ -408,7 +473,14 @@ impl HandshakeEngine { return Err(error); } }; - let pk_bytes = bundle.as_bytes(); + let pk_bytes = match bundle.try_as_bytes() { + Ok(bytes) => bytes, + Err(error) => { + let error = AcceptError::AuthenticationFailed(error.to_string()); + reject_error_generic(sender, &error, tm).await; + return Err(error); + } + }; return self .complete_auth_handshake( sender, @@ -453,6 +525,28 @@ impl HandshakeEngine { // 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() { + if self.config.conceal_authentication_identities && cid > 0 { + /* Keep an unknown authenticated ID on the same + challenge/proof path as a known ID. The fixed host + identity makes the eventual proof fail without + disclosing whether the lookup succeeded. */ + return self + .complete_auth_handshake( + sender, + receiver, + Flow::Login { + id: cid, + bundle: self.config.host_keyring.public_key_bundle(), + }, + CommunicationType::IdentificationResponse, + &negotiated, + &codec, + description, + version_str, + client_version, + ) + .await; + } let error = AcceptError::AuthenticationFailed( "unknown authenticated client identity".into(), ); @@ -525,20 +619,29 @@ impl HandshakeEngine { let bundle = match (self.config.get_existing_client)(cid, description.clone()).await { Some(b) => b, None => { - 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( - "unknown client id".into(), - )); + if self.config.conceal_authentication_identities { + // Use a valid fixed-cost dummy identity so an unknown + // client follows the same challenge/proof sequence as + // a registered client. The host public bundle is + // already public and the peer cannot produce its + // private-key proof. + self.config.host_keyring.public_key_bundle() + } else { + 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( + "unknown client id".into(), + )); + } } }; ( @@ -553,7 +656,14 @@ impl HandshakeEngine { return Err(error); } }; - let pk_bytes = bundle.as_bytes(); + let pk_bytes = match bundle.try_as_bytes() { + Ok(bytes) => bytes, + Err(error) => { + let error = AcceptError::AuthenticationFailed(error.to_string()); + reject_error_generic(sender, &error, tm).await; + return Err(error); + } + }; ( Flow::Register { bundle, pk_bytes }, CommunicationType::RegisterResponse, @@ -787,7 +897,14 @@ impl HandshakeEngine { Flow::Login { id, bundle } => (id, bundle), Flow::Register { bundle, .. } => { let _registration_guard = self.config.registration_lock.lock().await; - let identity = bundle.as_bytes(); + let identity = match bundle.try_as_bytes() { + Ok(identity) => identity, + Err(error) => { + let error = AcceptError::AuthenticationFailed(error.to_string()); + reject_error_generic(sender, &error, tm).await; + return Err(error); + } + }; let cached_id = self .config .registration_ids diff --git a/host/src/handshake.rs b/host/src/handshake.rs index ae4c211..89924ab 100644 --- a/host/src/handshake.rs +++ b/host/src/handshake.rs @@ -11,7 +11,7 @@ use std::time::Instant; #[cfg(feature = "pipes")] use tokio::sync::mpsc; -use crate::config::HostConfig; +use crate::config::{AuthenticationContext, HostConfig}; use crate::connection::MTPConnection; use crate::engine::HandshakeEngine; use crate::error::AcceptError; @@ -135,7 +135,16 @@ impl HandshakeContext { receiver: Receiver, ) -> Result, AcceptError> { let engine = HandshakeEngine::new(self.registry.clone(), self.config.clone()); - let result = engine.accept(&sender, &receiver).await?; + let authentication_context = AuthenticationContext { + peer_network_identity: sender + .handle() + .remote_addr() + .map(|address| address.to_string()), + connection_id: sender.handle().connection_id(), + }; + let result = engine + .accept_with_context(&sender, &receiver, authentication_context) + .await?; #[cfg(feature = "crypto")] { Ok(Some(self.connection_from_handshake_result( @@ -171,12 +180,13 @@ impl HandshakeContext { receiver.respond_to_pings(sender.clone()); } - let (app_tx, app_rx) = mpsc::channel(self.config.policy.receiver_queue_capacity); - let (pipe_req_tx, pipe_req_rx) = - mpsc::channel(self.config.policy.receiver_queue_capacity); + let receiver_queue_capacity = self.config.policy.receiver_queue_capacity.max(1); + let (app_tx, app_rx) = mpsc::channel(receiver_queue_capacity); + let (pipe_req_tx, pipe_req_rx) = mpsc::channel(receiver_queue_capacity); let dispatcher = Arc::new(PipeDispatcher { - pending_creations: tokio::sync::Mutex::new(std::collections::HashMap::new()), + pending_creations: std::sync::Mutex::new(std::collections::HashMap::new()), + expired_creations: std::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(), @@ -260,12 +270,13 @@ impl HandshakeContext { receiver.respond_to_pings(sender.clone()); } - let (app_tx, app_rx) = mpsc::channel(self.config.policy.receiver_queue_capacity); - let (pipe_req_tx, pipe_req_rx) = - mpsc::channel(self.config.policy.receiver_queue_capacity); + let receiver_queue_capacity = self.config.policy.receiver_queue_capacity.max(1); + let (app_tx, app_rx) = mpsc::channel(receiver_queue_capacity); + let (pipe_req_tx, pipe_req_rx) = mpsc::channel(receiver_queue_capacity); let dispatcher = Arc::new(PipeDispatcher { - pending_creations: tokio::sync::Mutex::new(std::collections::HashMap::new()), + pending_creations: std::sync::Mutex::new(std::collections::HashMap::new()), + expired_creations: std::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, diff --git a/host/src/lib.rs b/host/src/lib.rs index fe5e7bf..c86a74c 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -29,8 +29,9 @@ pub use mtp_codec::registry::Registry; #[cfg(feature = "crypto")] pub use config::{ - AuthenticationPolicy, CompleteRegister, FindRegisteredClient, GetExistingClient, - GuestIdGenerator, + AuthenticationAttempt, AuthenticationAttemptLimiter, AuthenticationContext, + AuthenticationLimitError, AuthenticationPolicy, CompleteRegister, FindRegisteredClient, + GetExistingClient, GuestIdGenerator, InMemoryAuthenticationAttemptLimiter, }; #[cfg(feature = "crypto")] pub use error::AuthState; diff --git a/host/src/pipe.rs b/host/src/pipe.rs index 6985d97..eae1383 100644 --- a/host/src/pipe.rs +++ b/host/src/pipe.rs @@ -3,6 +3,7 @@ use mtp_common::{CommunicationError, PipeError}; use mtp_transport::{PipeReader, PipeWriter, Policy, TransportEvent}; use std::collections::HashMap; use std::sync::Arc; +use std::sync::Mutex as StdMutex; use tokio::sync::{Mutex, mpsc}; /// The sender operations needed by the transport-independent pipe protocol. @@ -93,14 +94,20 @@ where } } -pub struct PipeHandle { +pub struct PipeHandle { pub(crate) pipe_id: u32, pub(crate) description: String, pub(crate) sender: S, pub(crate) response_rx: tokio::sync::oneshot::Receiver>, + pub(crate) dispatcher: Arc>, + pub(crate) token: Arc<()>, } -impl PipeHandle { +impl PipeHandle +where + S: PipeSender, + P: tokio::io::AsyncRead + Send + Unpin + 'static, +{ pub fn pipe_id(&self) -> u32 { self.pipe_id } @@ -109,21 +116,42 @@ impl PipeHandle { &self.description } - pub async fn wait(self) -> Result>, PipeError> { - match self.response_rx.await { - Ok(Ok(true)) => self + pub async fn wait(mut self) -> Result>, PipeError> { + let response = + tokio::time::timeout(self.dispatcher.policy.read_timeout, &mut self.response_rx).await; + match response { + Ok(Ok(Ok(true))) => self .sender .open_pipe_stream(self.pipe_id, &self.description) .await .map(Some) .map_err(PipeError::from), - Ok(Ok(false)) => Ok(None), - Ok(Err(error)) => Err(error), - Err(_) => Err(PipeError::StreamClosed), + Ok(Ok(Ok(false))) => Ok(None), + Ok(Ok(Err(error))) => { + expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token); + Err(error) + } + Ok(Err(_)) => { + expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token); + Err(PipeError::StreamClosed) + } + Err(_) => { + expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token); + Err(PipeError::HandshakeTimeout) + } } } } +impl Drop for PipeHandle +where + S: PipeSender, +{ + fn drop(&mut self) { + expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token); + } +} + pub struct PipeRequest { pub(crate) pipe_id: u32, pub(crate) description: String, @@ -203,13 +231,132 @@ where } pub(crate) struct PipeDispatcher

{ - pub(crate) pending_creations: - Mutex>>>, + pub(crate) pending_creations: StdMutex>, + pub(crate) expired_creations: StdMutex>, pub(crate) pending_pipes: Mutex>>>, pub(crate) policy: Arc, pub(crate) type_map: TypeMap, } +pub(crate) struct PendingCreation { + pub(crate) token: Arc<()>, + pub(crate) sender: tokio::sync::oneshot::Sender>, +} + +pub(crate) struct PendingCreationGuard

{ + dispatcher: Arc>, + pipe_id: u32, + token: Arc<()>, + armed: bool, +} + +impl

PendingCreationGuard

{ + pub(crate) fn new(dispatcher: Arc>, pipe_id: u32, token: Arc<()>) -> Self { + Self { + dispatcher, + pipe_id, + token, + armed: true, + } + } + + pub(crate) fn disarm(&mut self) { + self.armed = false; + } +} + +impl

Drop for PendingCreationGuard

{ + fn drop(&mut self) { + if self.armed { + expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token); + } + } +} + +const EXPIRED_CREATION_TOMBSTONE_TTL: tokio::time::Duration = tokio::time::Duration::from_secs(60); +const MAX_EXPIRED_CREATION_TOMBSTONES: usize = 1024; + +pub(crate) fn expire_pending_creation

( + dispatcher: &PipeDispatcher

, + pipe_id: u32, + token: &Arc<()>, +) { + let removed = dispatcher + .pending_creations + .lock() + .ok() + .and_then(|mut pending| { + if pending + .get(&pipe_id) + .is_some_and(|entry| Arc::ptr_eq(&entry.token, token)) + { + pending.remove(&pipe_id); + Some(()) + } else { + None + } + }); + if removed.is_none() { + return; + } + let Ok(mut expired) = dispatcher.expired_creations.lock() else { + return; + }; + let now = tokio::time::Instant::now(); + expired.retain(|_, expires_at| *expires_at > now); + if expired.len() >= MAX_EXPIRED_CREATION_TOMBSTONES + && let Some(oldest) = expired + .iter() + .min_by_key(|(_, expires_at)| **expires_at) + .map(|(id, _)| *id) + { + expired.remove(&oldest); + } + expired.insert(pipe_id, now + EXPIRED_CREATION_TOMBSTONE_TTL); +} + +fn consume_expired_creation

(dispatcher: &PipeDispatcher

, pipe_id: u32) -> bool { + let Ok(mut expired) = dispatcher.expired_creations.lock() else { + return false; + }; + let now = tokio::time::Instant::now(); + expired.retain(|_, expires_at| *expires_at > now); + expired.remove(&pipe_id).is_some() +} + +pub(crate) fn is_expired_creation

(dispatcher: &PipeDispatcher

, pipe_id: u32) -> bool { + let Ok(mut expired) = dispatcher.expired_creations.lock() else { + return true; + }; + let now = tokio::time::Instant::now(); + expired.retain(|_, expires_at| *expires_at > now); + expired.contains_key(&pipe_id) +} + +pub(crate) fn fail_pending_creations

( + dispatcher: &PipeDispatcher

, + error: &CommunicationError, +) { + let pending = dispatcher + .pending_creations + .lock() + .ok() + .map(|mut pending| std::mem::take(&mut *pending)); + if let Some(pending) = pending { + let error = PipeError::from(error.clone()); + for (_, pending) in pending { + let _ = pending.sender.send(Err(error.clone())); + } + } + if let Ok(mut expired) = dispatcher.expired_creations.lock() { + expired.clear(); + } +} + +pub(crate) async fn fail_pending_pipes

(dispatcher: &PipeDispatcher

) { + dispatcher.pending_pipes.lock().await.clear(); +} + pub(crate) async fn run_dispatcher( receiver: R, sender: S, @@ -256,10 +403,17 @@ pub(crate) async fn run_dispatcher( } continue; }; - let mut pending = dispatcher.pending_creations.lock().await; - if let Some(reply) = pending.remove(&pipe_id) { - let _ = - reply.send(Ok(message.get_bool(DataType::Accepted).unwrap_or(false))); + let pending = dispatcher + .pending_creations + .lock() + .ok() + .and_then(|mut pending| pending.remove(&pipe_id)); + if let Some(entry) = pending { + let _ = entry + .sender + .send(Ok(message.get_bool(DataType::Accepted).unwrap_or(false))); + } else if consume_expired_creation(&dispatcher, pipe_id) { + tracing::debug!(pipe_id, "ignored late pipe creation response"); } continue; } @@ -297,9 +451,12 @@ pub(crate) async fn run_dispatcher( let _ = pipe_req_tx.send(request).await; } Err(error) => { + fail_pending_creations(&dispatcher, &error); + fail_pending_pipes(&dispatcher).await; if app_tx.send(Err(error)).await.is_err() { break; } + break; } } } diff --git a/mtp-webserver/src/transport.rs b/mtp-webserver/src/transport.rs index 5c81765..a484f26 100644 --- a/mtp-webserver/src/transport.rs +++ b/mtp-webserver/src/transport.rs @@ -42,6 +42,11 @@ impl H3TransportConnection { pub(crate) fn remote_addr(&self) -> std::net::SocketAddr { self.quinn.remote_address() } + + #[cfg(feature = "crypto")] + pub(crate) fn connection_id(&self) -> u64 { + self.quinn.stable_id() as u64 + } } #[async_trait::async_trait] @@ -283,6 +288,8 @@ async fn accept_web_connection_inner( let max_message_size = policy.max_message_size; let transport = H3TransportConnection::new(session, quinn); let remote_addr = transport.remote_addr(); + #[cfg(feature = "crypto")] + let connection_id = transport.connection_id(); let policy = Arc::new(policy); let sender = WebMtpSender::new(transport.clone(), policy.clone()); let receiver = WebMtpReceiver::new(transport, policy.clone()); @@ -290,10 +297,14 @@ async fn accept_web_connection_inner( let engine = mtp_host::HandshakeEngine::new(Registry::builtin(), host_config); #[cfg(feature = "crypto")] let result = engine - .accept_until( + .accept_until_with_context( &sender, &receiver, deadline.expect("crypto WebTransport handshakes have a deadline"), + mtp_host::AuthenticationContext { + peer_network_identity: Some(remote_addr.to_string()), + connection_id, + }, ) .await?; #[cfg(not(feature = "crypto"))] diff --git a/package.json b/package.json index 9d155a5..3a96cdf 100644 --- a/package.json +++ b/package.json @@ -63,10 +63,11 @@ "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:wasm-init": "tsc && node --test test/wasm-init.mjs", "test:types": "tsc -p tsconfig.type-tests.json --noEmit", "test:vite": "tsc && node test/vite-type-map.mjs", "test:boundary": "node --test test/package-boundary.mjs", - "test": "pnpm run test:e2e && pnpm run test:secrets && pnpm run test:types && pnpm run test:vite && pnpm run test:boundary" + "test": "pnpm run test:e2e && pnpm run test:secrets && pnpm run test:wasm-init && pnpm run test:types && pnpm run test:vite && pnpm run test:boundary" }, "devDependencies": { "@types/node": "^26.0.1", diff --git a/src/sdk/client.ts b/src/sdk/client.ts new file mode 100644 index 0000000..d3c31be --- /dev/null +++ b/src/sdk/client.ts @@ -0,0 +1,2635 @@ +// Private SDK client implementation. The public facade remains in index.ts. +import { + ConnectionConfig, + ConnectionState, + WasmClient, + WasmPipeHandle, + keyring_generate, +} from "mtp/raw"; +import * as bindings from "mtp/raw"; +import { unixTimeMillis, utf8Encode } from "./utils.js"; +import type * as RawBindings from "../raw/index"; +import type { MTPCommunicationType } from "../type-map/index"; +import type { MTPSessionStorage, MTPSessionState } from "./session"; +import { MTPSessionManager } from "./session.js"; +import { + assertApplicationCommunicationType, + assertKnownCommunicationType, + base64ToBytes, + bytesFrom, + bytesToBase64, + cloneParsedFrame, + cloneParsedValue, + codec, + crypto, + decode, + decodeDataValueWithLimits, + decodeWithLimits, + encode, + encodeMTPDataValue, + errorMessage, + format, + inputU64, + isBytes, + keyringToKeys, + normalizeBytes, + parseProtectedFrame, + protectedFrameBytes, + publicKeyBundleToKeys, + secretKeyFromString, + legacySecretKeyFromStringV1, + deriveKeyFromPassphraseSync, + validateMTPDataValue, +} from "./codec.js"; +import type { + MTPEncryptedSecretRecord, + MTPEncryptedSecretProvider, +} from "./encrypted-secret"; +import { InMemoryEncryptedSecretProvider } from "./encrypted-secret.js"; +import { InMemorySessionStorage } from "./session.js"; +import { + publicCredentials, + zeroCredentials, +} from "./credentials.js"; +import type { InternalCredentials } from "./credentials.js"; +import { withTimeout } from "./timeout.js"; +import { initWasmOnce } from "./wasm-init.js"; +import { + acceptMTPPipeSession, + acceptMTPPipeSessionAuto, + acceptMTPForwardSecurePipeSession, + initiateMTPForwardSecurePipeSession, + initiateMTPPipeSession, + MTPEncryptedPipeReader, + MTPEncryptedPipeWriter, + validateApplicationProtectionPurpose, +} from "./encrypted-pipe.js"; +import { + InMemoryReplayGuard, + MTPReplayError, + effectiveProtectionSignatureSuite, + normalizeRecipientBundles, + protectedOpeningError, + protectionSignatureSuiteValue, + resolveDecryptionIdentity, + resolveProtectionIdentity, +} from "./protection.js"; +import type { + ResolvedDecryptionIdentity, + SignerResolutionOptions, +} from "./protection.js"; +import { + MTPVerifiedRelayMetadata, + RELAY_METADATA_TOKEN, + relayMetadataState, + relayOpeningError, + registerRelayMetadata, +} from "./relay.js"; +import type { MTPRelayMetadataState } from "./relay.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; + +export interface MTPCredentialStorage { + getItem(key: string): StorageValue | Promise; + setItem(key: string, value: string): void | Promise; + removeItem(key: string): void | Promise; +} + +export type MTPStorage = MTPCredentialStorage; + +export type MTPLogEvent = + | { + hint: "info" | "warning"; + type: string; + data: unknown; + direction?: "send" | "recv"; + } + | { + hint: "error"; + type: string | "error"; + error: string; + data?: unknown; + direction?: "send" | "recv"; + }; + +export type ParsedFrame = RawBindings.ParsedFrame; + +export type Ed25519GenerateResult = ReturnType< + typeof bindings.ed25519_generate +>; + +export type WasmEncapsulated = RawBindings.WasmEncapsulated; + +export interface MTPCrypto { + generateKeyring(): Uint8Array; + generateEd25519(): Ed25519GenerateResult; + keyringFromEd25519(secretKey: Uint8Array, publicKey: Uint8Array): Uint8Array; + verifyEd25519( + publicKey: Uint8Array, + message: Uint8Array, + signature: Uint8Array, + ): void; + deriveEncryptionKey( + ikm: Uint8Array, + salt: Uint8Array, + context: Uint8Array, + ): Uint8Array; + hkdfExpand( + ikm: Uint8Array, + salt: Uint8Array, + info: Uint8Array, + len: number, + ): Uint8Array; + sha256(data: Uint8Array): Uint8Array; + sha256Double(data: Uint8Array): Uint8Array; + keyringToKeys(keyring: MTPKeyMaterialInput): MTPKeyringKeys; + publicKeyBundleToKeys( + publicKeyBundle: MTPKeyMaterialInput, + ): MTPPublicKeyBundleKeys; + encrypt(key: Uint8Array, input: Uint8Array): Promise; + decrypt(key: Uint8Array, input: Uint8Array): Promise; + encryptText(key: Uint8Array, plaintext: string): Promise; + decryptText(key: Uint8Array, ciphertext: string): Promise; + encapsulate(otherPublicKey: Uint8Array): WasmEncapsulated; + decapsulate(ownPrivateKey: Uint8Array, ciphertext: Uint8Array): Uint8Array; +} + +export type MTPRawBindings = typeof bindings; + +export interface MTPRaw { + /** + * Underlying generated WASM client instance. + * + * Prefer the `MTPClient` methods for application code. Calling the raw client + * bypasses SDK-level validation, credential persistence, logging, timeout + * handling, frame parsing helpers, and ping lifecycle management. Use this + * escape hatch only when integrating a feature that the SDK wrapper does not + * expose yet. + */ + client: RawBindings.WasmClient; + + /** + * Generated WASM binding module exported by `mtp/raw`. + * + * These bindings mirror the lower-level WASM API and can change shape as the + * generated interface evolves. Prefer the SDK wrapper where possible so your + * code keeps the safer, typed MTPClient flow instead of depending directly on + * transport internals. + */ + bindings: MTPRawBindings; +} + +export type MTPBytesInput = Uint8Array | number[]; + +/** A textual key/blob input whose wire encoding is selected explicitly. */ +export interface MTPEncodedBytesInput { + value: string; + encoding: "hex" | "base64"; +} + +export type MTPKeyMaterialInput = MTPBytesInput | MTPEncodedBytesInput; + +export interface MTPCodecOptions { + id?: number; + sender?: bigint | number; + receiver?: bigint | number; +} + +export interface MTPCodec { + encode( + type: MTPCommunicationType, + data: Record, + options?: MTPCodecOptions, + ): Uint8Array; + decode(frame: MTPBytesInput): ParsedFrame; + format(frame: MTPBytesInput): string; +} + +export interface MTPCredentials { + clientId: bigint | string | number | null; + keyring: MTPBytesInput; + hostPublicKey?: MTPKeyMaterialInput; +} + +export interface MTPClientCredentials { + clientId: bigint | null; + keyring: Uint8Array; + hostPublicKey?: Uint8Array; +} + +export interface MTPKeyringKeys { + kemPublicKey: Uint8Array; + kemSecretKey: Uint8Array; + sigPqPublicKey: Uint8Array; + sigPqSecretKey: Uint8Array; + sigClPublicKey: Uint8Array; + sigClSecretKey: Uint8Array; +} + +export interface MTPPublicKeyBundleKeys { + kemPublicKey: Uint8Array; + sigPqPublicKey: Uint8Array; + sigClPublicKey: Uint8Array; +} + +export interface MTPClientOptions { + url: string; + descriptor?: string; + hostPublicKey?: MTPKeyMaterialInput; + credentials?: MTPCredentials | string | null; + credentialsStorageKey?: string; + storage?: MTPCredentialStorage; + serverCertificateHashes?: string[]; + maxMessageSize?: number; + authTimeoutMs?: number; + /** Require the hybrid PQ authentication proof when the host supports it. */ + requirePq?: boolean; + requestTimeoutMs?: number; + pings?: boolean | { intervalMs?: number }; + wasm?: + | RawBindings.InitInput + | Promise + | { + module_or_path: RawBindings.InitInput | Promise; + }; + logger?: (event: MTPLogEvent) => void; + sessionStorage?: MTPSessionStorage; + /** + * 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; + /** Coherent security defaults for protected messages, encrypted pipes, and authentication. */ + securityProfile?: MTPSecurityProfile; + /** One receive resource policy shared by frame and protected-value opening. */ + receiveLimits?: MTPReceiveLimits; +} + +export interface MTPSecurityProfile { + /** Signature suite used by protected-message and relay senders. */ + protectedSignatureSuite?: MTPProtectionSignatureSuite; + /** Receiver policy for protected messages and relay metadata/content. */ + protectedSignaturePolicy?: MTPSignatureVerificationPolicy; + /** Signature suite used by encrypted-pipe senders. */ + encryptedPipeSignatureSuite?: MTPProtectionSignatureSuite; + /** Receiver policy used by encrypted-pipe acceptors. */ + encryptedPipeSignaturePolicy?: MTPSignatureVerificationPolicy; + /** Authentication PQ requirement when the host supports the hybrid proof. */ + requirePq?: boolean; +} + +export interface MTPEncodeLimits { + maxDepth?: number; + maxValues?: number; + maxOutputSize?: number; +} + +/** Resource limits forwarded to the bounded native/WASM receive decoder. */ +export interface MTPReceiveLimits { + maxDepth?: number; + maxValues?: number; + maxBlobSize?: number; + maxRecipients?: number; + maxAllocatedBytes?: number; + /** Maximum reconstructed signed-value encoding size. */ + maxOutputSize?: number; + maxMessageIdBytes?: number; + maxMetadataEncodedBytes?: number; + maxSignerKeyHistory?: number; + maxDecryptionKeyHistory?: number; +} + +export type Unsubscribe = () => void; + +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: MTPKeyMaterialInput; +} + +/** + * 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: MTPKeyMaterialInput; + /** + * Previously used recipient keyrings, ordered newest to oldest. The + * current keyring is always attempted first. + */ + keyringHistory?: MTPKeyMaterialInput[]; +} + +/** 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 = MTPKeyMaterialInput[]; + +/** + * 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: MTPKeyMaterialInput[]; + contentRecipients: MTPKeyMaterialInput[]; + metadata?: MTPDataValueInput; +} + +export interface MTPSendProtectedOptions extends MTPFrameIdOptions { + receiverId: bigint | number | string; + identity?: MTPProtectionIdentity; + recipients: MTPKeyMaterialInput[]; + signaturePurpose: number; + encryptionPurpose: number; + signatureSuite?: MTPProtectionSignatureSuite; + exposeSender?: boolean; + /** Semantic protected-field limits applied before the WASM boundary. */ + limits?: MTPReceiveLimits; +} + +export interface MTPSendSealedRelayOptions extends MTPRelayPlan { + identity?: MTPProtectionIdentity; + signatureSuite?: MTPProtectionSignatureSuite; + /** Semantic protected/relay limits applied before the WASM boundary. */ + limits?: MTPReceiveLimits; +} + +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; + /** Override the client's receive resource policy for this operation. */ + limits?: MTPReceiveLimits; +} + +export interface MTPOpenRelayMetadataOptions extends MTPRelayVerificationOptions { + /** Override the default process-local guard with an application-owned guard. */ + 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; + /** Override the client's receive resource policy for this operation. */ + limits?: MTPReceiveLimits; +} + +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 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; +} + +export interface MTPPipeWriter { + write(data: Uint8Array): Promise; + close(): Promise; + abort(): void; + readonly pipeId: number; +} + +export interface MTPPipeReader { + read(): Promise; + readonly pipeId: number; + readonly description: string; +} + +export interface MTPPipeRequest { + pipeId: number; + description: string; +} + +export interface MTPOutgoingPipeHandle { + readonly pipeId: number; + readonly description: string; + wait(): Promise; +} + +export interface MTPCreateEncryptedPipeOptions { + recipientId: bigint | number | string; + recipientPublicKey?: MTPKeyMaterialInput; + recipientPublicKeys?: MTPKeyMaterialInput[]; + description?: string; + purpose?: number; + direction?: number; + signatureSuite?: MTPProtectionSignatureSuite; +} + +export interface MTPAcceptEncryptedPipeOptions { + senderId: bigint | number | string; + senderPublicKey?: MTPKeyMaterialInput; + senderPublicKeys?: MTPKeyMaterialInput[]; + purpose?: number; + direction?: number; + signaturePolicy?: MTPSignatureVerificationPolicy; +} + +type NormalizedMTPClientOptions = Omit< + MTPClientOptions, + "hostPublicKey" | "receiveLimits" +> & { + hostPublicKey?: Uint8Array; + receiveLimits?: MTPReceiveLimits; + receiveLimitsExplicit: boolean; + securityProfile: ResolvedSecurityProfile; +}; + +interface ResolvedSecurityProfile { + protectedSignatureSuite: MTPProtectionSignatureSuite; + protectedSignaturePolicy: MTPSignatureVerificationPolicy; + encryptedPipeSignatureSuite: MTPProtectionSignatureSuite; + encryptedPipeSignaturePolicy: MTPSignatureVerificationPolicy; + requirePq: boolean; +} + +const DEFAULT_CREDENTIALS_KEY = "mtp:credentials"; +const DEFAULT_MAX_MESSAGE_SIZE = 16 * 1024 * 1024; +const DEFAULT_MAX_DEPTH = 64; +const DEFAULT_MAX_VALUES = 65_536; +const DEFAULT_MAX_RECIPIENTS = 64; +/** Must match codec::DEFAULT_TRANSPORT_ALLOCATION_FACTOR. */ +export const DEFAULT_TRANSPORT_ALLOCATION_FACTOR = 4; + +function createMessageId(): string { + const bytes = new Uint8Array(16); + globalThis.crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join( + "", + ); +} + +function emit( + logger: MTPClientOptions["logger"] | undefined, + event: MTPLogEvent, +): void { + if (typeof logger === "function") { + logger(event); + } +} + +function isErrorType(type: string): boolean { + return ( + type === "Error" || + type.startsWith("Error") || + [ + "BadRequest", + "Unauthorized", + "Forbidden", + "NotFound", + "TooManyRequests", + "InternalServerError", + "BadGateway", + "ServiceUnavailable", + "GatewayTimeout", + ].includes(type) + ); +} + +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 { + if (storage) { + await storage.setItem(key, value); + } +} + +async function storageRemove( + storage: MTPCredentialStorage | undefined, + key: string, +): Promise { + if (storage) { + await storage.removeItem(key); + } +} + +function normalizeCredentials( + value: MTPCredentials | string | null, +): MTPCredentials | null { + if (!value) { + return null; + } + + if (typeof value === "string") { + return JSON.parse(value); + } + + return value; +} + +function toBigInt( + value: bigint | string | number | null | undefined, +): bigint | null { + if (value == null || value === "") { + return null; + } + return inputU64(value, "clientId"); +} + +function generateKeyringBytes() { + const checkedGenerator = ( + bindings as typeof bindings & { + keyring_generate_checked?: () => Uint8Array; + } + ).keyring_generate_checked; + return checkedGenerator?.() ?? keyring_generate(); +} + +function serializeCredentials(credentials) { + return JSON.stringify({ + clientId: credentials.clientId?.toString() ?? null, + keyring: Array.from(credentials.keyring ?? []), + hostPublicKey: credentials.hostPublicKey + ? Array.from(credentials.hostPublicKey) + : undefined, + }); +} + +const RECEIVE_LIMIT_KEYS = [ + "maxDepth", + "maxValues", + "maxBlobSize", + "maxRecipients", + "maxAllocatedBytes", + "maxOutputSize", + "maxMessageIdBytes", + "maxMetadataEncodedBytes", + "maxSignerKeyHistory", + "maxDecryptionKeyHistory", +] as const; + +function normalizeReceiveLimits( + value: MTPReceiveLimits | undefined, + name: string, +): MTPReceiveLimits | undefined { + if (value == null) return undefined; + if (typeof value !== "object" || Array.isArray(value)) { + throw new TypeError(`${name} must be an object`); + } + const normalized: MTPReceiveLimits = {}; + for (const key of RECEIVE_LIMIT_KEYS) { + const limit = value[key]; + if (limit == null) continue; + if (!Number.isSafeInteger(limit) || limit < 0) { + throw new TypeError(`${name}.${key} must be a non-negative safe integer`); + } + normalized[key] = limit; + } + return normalized; +} + +function effectiveReceiveLimits( + configured: MTPReceiveLimits | undefined, + maxMessageSize: number, +): MTPReceiveLimits { + const transportBlob = Math.min( + Math.max(0, maxMessageSize - 4), + 0xffff_ffff, + ); + const transportAllocated = + maxMessageSize > Number.MAX_SAFE_INTEGER / DEFAULT_TRANSPORT_ALLOCATION_FACTOR + ? Number.MAX_SAFE_INTEGER + : maxMessageSize * DEFAULT_TRANSPORT_ALLOCATION_FACTOR; + const intersect = (left: number | undefined, right: number): number => + Math.min(left ?? right, right); + return { + ...configured, + maxDepth: intersect(configured?.maxDepth, DEFAULT_MAX_DEPTH), + maxValues: intersect(configured?.maxValues, DEFAULT_MAX_VALUES), + maxBlobSize: intersect(configured?.maxBlobSize, transportBlob), + maxRecipients: intersect(configured?.maxRecipients, DEFAULT_MAX_RECIPIENTS), + maxAllocatedBytes: intersect( + configured?.maxAllocatedBytes, + transportAllocated, + ), + maxOutputSize: intersect(configured?.maxOutputSize, maxMessageSize), + }; +} + +function resolveSecurityProfile(options: MTPClientOptions): ResolvedSecurityProfile { + const profile = options.securityProfile ?? {}; + const suite = (value: unknown, name: string): MTPProtectionSignatureSuite => { + if (value == null) return "ed25519"; + if (value !== "ed25519" && value !== "dual") { + throw new TypeError(`${name} must be 'ed25519' or 'dual'`); + } + return value; + }; + const policy = ( + value: MTPSignatureVerificationPolicy | undefined, + name: string, + ): MTPSignatureVerificationPolicy => { + try { + return resolveSignatureVerificationPolicy(value, undefined); + } catch (error) { + throw new TypeError(`${name} is invalid`, { cause: error }); + } + }; + if (profile.requirePq != null && typeof profile.requirePq !== "boolean") { + throw new TypeError("securityProfile.requirePq must be a boolean"); + } + return { + protectedSignatureSuite: suite( + profile.protectedSignatureSuite, + "securityProfile.protectedSignatureSuite", + ), + protectedSignaturePolicy: policy( + profile.protectedSignaturePolicy, + "securityProfile.protectedSignaturePolicy", + ), + encryptedPipeSignatureSuite: suite( + profile.encryptedPipeSignatureSuite, + "securityProfile.encryptedPipeSignatureSuite", + ), + encryptedPipeSignaturePolicy: policy( + profile.encryptedPipeSignaturePolicy, + "securityProfile.encryptedPipeSignaturePolicy", + ), + requirePq: profile.requirePq ?? true, + }; +} + +type BoundedBindings = typeof bindings & { + protected_claimed_signer_id_with_limits?: ( + frame: Uint8Array, + keyrings: Uint8Array[], + encryptionPurpose: number, + limits: MTPReceiveLimits, + ) => bigint; + open_protected_with_keyrings_with_limits_without_replay?: ( + frame: Uint8Array, + keyrings: Uint8Array[], + expectedSignerId: bigint, + signerPublicKeys: Uint8Array[], + expectedReceiverId: bigint | null, + signaturePurpose: number, + encryptionPurpose: number, + signatureSuite: number, + limits: MTPReceiveLimits, + ) => RawBindings.WasmVerifiedProtectedMessage; + relay_metadata_claimed_signer_id_with_limits?: ( + frame: Uint8Array, + keyrings: Uint8Array[], + limits: MTPReceiveLimits, + ) => bigint; + open_relay_metadata_with_keyrings_with_limits_without_replay?: ( + frame: Uint8Array, + keyrings: Uint8Array[], + expectedSignerId: bigint, + signerPublicKeys: Uint8Array[], + signatureSuite: number, + limits: MTPReceiveLimits, + ) => RawBindings.WasmVerifiedRelayMetadata; + open_relay_content_with_keyrings_with_limits_without_replay?: ( + metadata: RawBindings.WasmVerifiedRelayMetadata, + keyrings: Uint8Array[], + signerPublicKeys: Uint8Array[], + expectedFinalRecipientId: bigint | null, + signatureSuite: number, + limits: MTPReceiveLimits, + ) => RawBindings.WasmVerifiedRelayContent; +}; + +function boundedBindings(): BoundedBindings { + return bindings as unknown as BoundedBindings; +} + +function deserializeCredentials(credentials) { + const normalized = normalizeCredentials(credentials); + if (!normalized) { + return null; + } + + const keyring = normalized.keyring; + if (!isBytes(keyring)) { + throw new TypeError("credentials.keyring must be a Uint8Array or number[]"); + } + + return { + clientId: toBigInt(normalized.clientId), + keyring: bytesFrom(keyring, "credentials.keyring").slice(), + hostPublicKey: + normalized.hostPublicKey == null + ? undefined + : normalizeBytes(normalized.hostPublicKey, "credentials.hostPublicKey").slice(), + }; +} + +function validateOptions(options) { + if (!options || typeof options !== "object") { + throw new TypeError("MTPClient.create requires an options object"); + } + if (typeof options.url !== "string" || !options.url.trim()) { + throw new TypeError("MTPClient.create requires a non-empty url"); + } + if (options.descriptor != null && typeof options.descriptor !== "string") { + throw new TypeError("descriptor must be a string"); + } + resolveSignatureVerificationPolicy( + undefined, + options.defaultSignatureVerificationPolicy, + ); + if ( + options.securityProfile != null && + (typeof options.securityProfile !== "object" || + Array.isArray(options.securityProfile)) + ) { + throw new TypeError("securityProfile must be an object"); + } + resolveSecurityProfile(options); + normalizeReceiveLimits(options.receiveLimits, "receiveLimits"); + if (options.storage) { + for (const method of ["getItem", "setItem", "removeItem"]) { + if (typeof options.storage[method] !== "function") { + throw new TypeError(`storage.${method} must be a function`); + } + } + } + if ( + options.maxMessageSize != null && + (!Number.isSafeInteger(options.maxMessageSize) || + options.maxMessageSize <= 0) + ) { + throw new TypeError("maxMessageSize must be a positive safe integer"); + } + if ( + options.authTimeoutMs != null && + (!Number.isSafeInteger(options.authTimeoutMs) || options.authTimeoutMs <= 0) + ) { + throw new TypeError("authTimeoutMs must be a positive safe integer"); + } + if (options.requirePq != null && typeof options.requirePq !== "boolean") { + throw new TypeError("requirePq must be a boolean"); + } + if ( + options.requestTimeoutMs != null && + (!Number.isSafeInteger(options.requestTimeoutMs) || + options.requestTimeoutMs <= 0) + ) { + throw new TypeError("requestTimeoutMs must be a positive safe integer"); + } +} + +export class MTPClient { + static readonly crypto = crypto; + static readonly codec = codec; + + #credentials: InternalCredentials | null; + #options: NormalizedMTPClientOptions; + readonly #protectedReplayGuard = new InMemoryReplayGuard(); + readonly #relayReplayGuard = new InMemoryReplayGuard(); + readonly raw: MTPRaw; + + readonly crypto = MTPClient.crypto; + readonly codec = MTPClient.codec; + + readonly sessionManager: MTPSessionManager; + /** Independent encrypted-secret storage selected by the caller. */ + readonly encryptedSecretProvider: MTPEncryptedSecretProvider; + + private constructor( + options: NormalizedMTPClientOptions, + client: RawBindings.WasmClient, + ) { + this.#options = options; + this.#credentials = deserializeCredentials(options.credentials); + this.raw = { client, bindings }; + this.encryptedSecretProvider = + options.encryptedSecretProvider ?? new InMemoryEncryptedSecretProvider(); + this.sessionManager = new MTPSessionManager( + options.sessionStorage ?? new InMemorySessionStorage(), + ); + } + + static async create(options: MTPClientOptions): Promise { + validateOptions(options); + await MTPClient.init(options.wasm); + + const normalizedOptions = { + ...options, + hostPublicKey: + options.hostPublicKey == null + ? undefined + : normalizeBytes(options.hostPublicKey, "hostPublicKey").slice(), + receiveLimits: effectiveReceiveLimits( + normalizeReceiveLimits(options.receiveLimits, "receiveLimits"), + options.maxMessageSize ?? DEFAULT_MAX_MESSAGE_SIZE, + ), + // Always retain and apply the effective transport policy. Even when the + // caller did not provide overrides, protected/relay opening must not + // fall back to a larger codec default after frame admission. + receiveLimitsExplicit: true, + securityProfile: resolveSecurityProfile(options), + }; + + let sdk: MTPClient | undefined; + const client = new WasmClient( + (state) => + emit(normalizedOptions.logger, { + hint: "info", + type: "state", + data: ConnectionState[state] ?? state, + }), + (frame) => { + if (sdk) { + sdk.#handleFrame(frame); + } + }, + (error) => + emit(normalizedOptions.logger, { + hint: "error", + type: "Error", + error: String(error), + }), + ); + + if (normalizedOptions.receiveLimits) { + const rawClient = client as unknown as { + set_receive_limits?: (limits: MTPReceiveLimits) => void; + setReceiveLimits?: (limits: MTPReceiveLimits) => void; + }; + const setReceiveLimits = + rawClient.set_receive_limits ?? rawClient.setReceiveLimits; + if (!setReceiveLimits) { + throw new Error( + "effective receive limits require a rebuilt bounded WASM package", + ); + } + setReceiveLimits.call(rawClient, normalizedOptions.receiveLimits); + } + + sdk = new MTPClient(normalizedOptions, client); + await sdk.#loadStoredCredentials(); + if (!sdk.#credentials) { + sdk.#credentials = { + clientId: null, + keyring: generateKeyringBytes(), + hostPublicKey: normalizedOptions.hostPublicKey, + }; + } else if ( + !sdk.#credentials.hostPublicKey && + normalizedOptions.hostPublicKey + ) { + sdk.#credentials = { + ...sdk.#credentials, + hostPublicKey: normalizedOptions.hostPublicKey, + }; + } else if ( + !normalizedOptions.hostPublicKey && + sdk.#credentials.hostPublicKey + ) { + sdk.#options = { + ...sdk.#options, + hostPublicKey: sdk.#credentials.hostPublicKey, + }; + } + return sdk; + } + + static isSupported(): boolean { + return WasmClient.is_supported(); + } + + static async init( + wasm?: MTPClientOptions["wasm"], + ): Promise>> { + return await initWasmOnce(wasm); + } + + get credentials(): MTPClientCredentials | null { + return publicCredentials(this.#credentials); + } + + get defaultSignatureVerificationPolicy(): MTPSignatureVerificationPolicy { + return resolveSignatureVerificationPolicy( + undefined, + this.#options.defaultSignatureVerificationPolicy ?? + this.#options.securityProfile.protectedSignaturePolicy, + ); + } + + get state(): RawBindings.ConnectionState { + return this.raw.client.state; + } + + get pingMs(): number | null { + return this.raw.client.ping_ms ?? null; + } + + async #loadStoredCredentials() { + if (this.#credentials || !this.#options.storage) { + return; + } + + const stored = await storageGet( + this.#options.storage, + this.#options.credentialsStorageKey ?? DEFAULT_CREDENTIALS_KEY, + ); + this.#credentials = deserializeCredentials(stored); + } + + #connectionConfig() { + const config = new ConnectionConfig(this.#options.url); + if (this.#options.serverCertificateHashes) { + config.server_certificate_hashes = this.#options.serverCertificateHashes; + } + if (this.#options.maxMessageSize != null) { + config.max_message_size = this.#options.maxMessageSize; + } + config.require_pq = + this.#options.requirePq ?? this.#options.securityProfile.requirePq; + if (this.#options.descriptor != null) { + config.description = this.#options.descriptor; + } + return config; + } + + async connect(): Promise { + if (this.#credentials?.clientId != null && this.#options.hostPublicKey) { + await this.auth(); + return; + } + + await this.connectUnauthenticated(); + } + + async connectUnauthenticated(): Promise { + const config = this.#connectionConfig(); + try { + // The deprecated raw method clones its borrowed config synchronously and + // remains safe when the SDK timeout wins the race. Keep using it here so + // applications that instrument the historical raw API continue to work. + await withTimeout( + this.raw.client.connect(config), + this.#options.authTimeoutMs, + "connection timed out", + () => this.raw.client.disconnect(), + ); + const clientId = ( + this.raw.client as RawBindings.WasmClient & { readonly client_id: bigint } + ).client_id; + this.#startPings(clientId); + } finally { + config.free(); + } + } + + async auth(): Promise { + if (!this.#options.hostPublicKey) { + throw new Error("MTPClient.auth requires hostPublicKey"); + } + return this.#credentials?.clientId == null + ? await this.register() + : await this.#connectAuthenticated(); + } + + async #connectAuthenticated() { + if (!this.#options.hostPublicKey) { + throw new Error( + "MTPClient.connect requires hostPublicKey for authenticated connections", + ); + } + if ( + !this.#credentials?.keyring?.length || + this.#credentials.clientId == null + ) { + throw new Error( + "MTPClient.connect requires credentials with clientId and keyring", + ); + } + + const config = this.#connectionConfig(); + try { + // Keep the deprecated raw spelling as the compatibility path. The WASM + // wrapper takes owned copies before entering its asynchronous handshake. + const clientId = await withTimeout( + this.raw.client.auth_connect( + config, + this.#options.hostPublicKey, + this.#credentials.keyring, + this.#credentials.clientId, + ), + this.#options.authTimeoutMs, + "authentication timed out", + () => this.raw.client.disconnect(), + ); + this.#credentials = { ...this.#credentials, clientId }; + await this.#persistCredentials(); + this.#startPings(clientId); + return clientId; + } finally { + config.free(); + } + } + + async register(): Promise { + if (!this.#options.hostPublicKey) { + throw new Error("MTPClient.register requires hostPublicKey"); + } + if (!this.#credentials?.keyring?.length) { + this.#credentials = { + clientId: null, + keyring: generateKeyringBytes(), + hostPublicKey: this.#options.hostPublicKey, + }; + } + + const config = this.#connectionConfig(); + try { + const clientId = await withTimeout( + this.raw.client.auth_register( + config, + this.#options.hostPublicKey, + this.#credentials.keyring, + ), + this.#options.authTimeoutMs, + "authentication timed out", + () => this.raw.client.disconnect(), + ); + this.#credentials = { ...this.#credentials, clientId }; + await this.#persistCredentials(); + this.#startPings(clientId); + return clientId; + } finally { + config.free(); + } + } + + async #persistCredentials() { + await storageSet( + this.#options.storage, + this.#options.credentialsStorageKey ?? DEFAULT_CREDENTIALS_KEY, + serializeCredentials(this.#credentials), + ); + } + + async clearCredentials(): Promise { + zeroCredentials(this.#credentials); + this.#credentials = null; + await storageRemove( + this.#options.storage, + this.#options.credentialsStorageKey ?? DEFAULT_CREDENTIALS_KEY, + ); + } + + #startPings(clientId) { + const pings = this.#options.pings; + if (!pings) { + this.raw.client.stop_protocol_pings(); + return; + } + const intervalMs = + typeof pings === "object" ? (pings.intervalMs ?? 30_000) : 30_000; + this.raw.client.start_protocol_pings(intervalMs, clientId); + } + + #encodeLimits(): MTPEncodeLimits { + return { + maxDepth: DEFAULT_MAX_DEPTH, + maxValues: DEFAULT_MAX_VALUES, + maxOutputSize: + this.#options.maxMessageSize ?? DEFAULT_MAX_MESSAGE_SIZE, + }; + } + + #buildFrame(typeOrFrame, data, options) { + if (typeOrFrame instanceof Uint8Array) { + if ( + typeOrFrame.length > + (this.#options.maxMessageSize ?? DEFAULT_MAX_MESSAGE_SIZE) + ) { + throw new RangeError("MTP frame exceeds maxMessageSize"); + } + return typeOrFrame; + } + if (typeof typeOrFrame !== "string" || !typeOrFrame) { + throw new TypeError( + "message type must be a non-empty string or Uint8Array frame", + ); + } + if (data == null || typeof data !== "object" || Array.isArray(data)) { + throw new TypeError("message data must be an object"); + } + const limits = this.#encodeLimits(); + validateMTPDataValue(data as MTPDataValueInput, limits); + const bounded = ( + this.raw.bindings as typeof bindings & { + build_frame_with_limits?: ( + type: string, + data: Record, + options: MTPCodecOptions, + limits: MTPEncodeLimits, + ) => Uint8Array; + } + ).build_frame_with_limits; + if (!bounded) { + throw new Error("bounded WASM frame encoding is unavailable; rebuild mtp-wasm"); + } + const frame = bounded(typeOrFrame, data, options ?? {}, limits); + if (frame.length > (limits.maxOutputSize ?? DEFAULT_MAX_MESSAGE_SIZE)) { + throw new RangeError("MTP frame exceeds maxMessageSize"); + } + return frame; + } + + async send(message: Uint8Array): Promise; + async send( + type: MTPCommunicationType, + data: Record, + options?: MTPSendOptions, + ): Promise; + async send( + typeOrFrame: Uint8Array | MTPCommunicationType, + data?: Record, + options?: MTPSendOptions, + ): Promise { + const message = this.#buildFrame(typeOrFrame, data, options); + + try { + const frame = this.raw.bindings.parse_frame(message); + emit( + this.#options.logger, + isErrorType(frame.type) + ? { + hint: "error", + type: frame.type, + error: errorMessage(frame), + data: frame.data, + direction: "send", + } + : { + hint: "info", + type: frame.type, + data: frame.data, + direction: "send", + }, + ); + } catch (error) { + emit(this.#options.logger, { + hint: "error", + type: "Error", + error: String(error), + direction: "send", + }); + } + + 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, + options?: MTPRequestOptions, + ): Promise; + async request( + type: MTPCommunicationType, + data: Record, + options?: MTPRequestOptions, + ): Promise; + async request( + typeOrFrame: Uint8Array | MTPCommunicationType, + data?: Record, + options: MTPRequestOptions = {}, + ): Promise { + 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"); + } + const frame = this.#buildFrame(typeOrFrame, data, options); + try { + const parsed = this.raw.bindings.parse_frame(frame); + emit( + this.#options.logger, + isErrorType(parsed.type) + ? { + hint: "error", + type: parsed.type, + error: errorMessage(parsed), + data: parsed.data, + direction: "send", + } + : { + hint: "info", + type: parsed.type, + data: parsed.data, + direction: "send", + }, + ); + } catch (error) { + emit(this.#options.logger, { + hint: "error", + type: "Error", + error: String(error), + direction: "send", + }); + } + // 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, + ); + } + + subscribe( + type: MTPCommunicationType, + handler: (message: ParsedFrame) => void, + ): Unsubscribe { + if (typeof type !== "string" || !type) { + throw new TypeError("subscription type must be a non-empty string"); + } + if (typeof handler !== "function") { + throw new TypeError("subscription handler must be a function"); + } + const id = this.raw.client.subscribe(type, handler); + return () => this.raw.client.unsubscribe(id); + } + + #handleFrame(frame) { + if (isErrorType(frame.type)) { + emit(this.#options.logger, { + hint: "error", + type: frame.type, + error: errorMessage(frame), + data: frame.data, + direction: "recv", + }); + } else { + emit(this.#options.logger, { + hint: "info", + type: frame.type, + data: frame.data, + direction: "recv", + }); + } + } + + #getKemPublicKey(): Uint8Array { + if (!this.#credentials?.keyring?.length) { + throw new Error("No keyring available"); + } + const keys = keyringToKeys(this.#credentials.keyring); + return keys.kemPublicKey; + } + + #getKemSecretKey(): Uint8Array { + if (!this.#credentials?.keyring?.length) { + throw new Error("No keyring available"); + } + const keys = keyringToKeys(this.#credentials.keyring); + return keys.kemSecretKey; + } + + #getPublicKeyBundleBytes(): Uint8Array { + if (!this.#credentials?.keyring?.length) { + throw new Error("No keyring available"); + } + const keyring = bindings.WasmKeyring.from_bytes( + this.#credentials.keyring, + ); + try { + const bundle = keyring.public_key_bundle(); + try { + const fallible = ( + bundle as typeof bundle & { try_to_bytes?: () => Uint8Array } + ).try_to_bytes; + if (!fallible) { + throw new Error( + "fallible public-key serialization is unavailable; rebuild mtp-wasm", + ); + } + return fallible.call(bundle); + } finally { + bundle.free(); + } + } finally { + keyring.free(); + } + } + + #resolveDecryptionIdentity( + explicit?: MTPDecryptionIdentity, + ): ResolvedDecryptionIdentity { + return resolveDecryptionIdentity(explicit, this.#credentials); + } + + #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 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; + } + + /** + * 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 signaturePolicy = resolveSignatureVerificationPolicy( + options.signaturePolicy, + this.#options.defaultSignatureVerificationPolicy ?? + this.#options.securityProfile.protectedSignaturePolicy, + ); + const requestedReceiveLimits = normalizeReceiveLimits( + options.limits, + "limits", + ); + const receiveLimits = requestedReceiveLimits + ? effectiveReceiveLimits( + requestedReceiveLimits, + this.#options.maxMessageSize ?? DEFAULT_MAX_MESSAGE_SIZE, + ) + : this.#options.receiveLimits; + const receiveLimitsExplicit = + options.limits != null || this.#options.receiveLimitsExplicit; + const frameBytes = frame.raw.slice(); + const frameSnapshot = receiveLimitsExplicit && receiveLimits + ? decodeWithLimits(frameBytes, receiveLimits) + : bindings.parse_frame(frameBytes); + const recipient = this.#resolveDecryptionIdentity(options.recipient); + let signerId: bigint; + try { + const bounded = boundedBindings(); + if (bounded.relay_metadata_claimed_signer_id_with_limits) { + signerId = BigInt( + bounded.relay_metadata_claimed_signer_id_with_limits( + frameBytes, + recipient.keyrings, + receiveLimits ?? {}, + ), + ); + } else if (receiveLimitsExplicit) { + throw new Error( + "configured receive limits require a rebuilt bounded WASM package", + ); + } else { + 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); + } + + let native: RawBindings.WasmVerifiedRelayMetadata | undefined; + let ownershipTransferred = false; + try { + const bounded = boundedBindings(); + if (!bounded.open_relay_metadata_with_keyrings_with_limits_without_replay) { + throw new Error( + "configured receive limits require a rebuilt bounded WASM package", + ); + } + native = bounded.open_relay_metadata_with_keyrings_with_limits_without_replay( + frameBytes, + recipient.keyrings, + signerId, + signerBundles, + signatureVerificationPolicyValue(signaturePolicy), + receiveLimits ?? {}, + ); + } 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( + receiveLimits + ? decodeDataValueWithLimits(metadataBytes, receiveLimits) + : 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()); + const replayGuard = options.replayGuard ?? this.#relayReplayGuard; + const accepted = await 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, + receiveLimits: receiveLimits ? { ...receiveLimits } : undefined, + receiveLimitsExplicit, + 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"); + registerRelayMetadata(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", + ); + const requestedReceiveLimits = normalizeReceiveLimits( + options.limits, + "limits", + ); + const receiveLimits = requestedReceiveLimits + ? effectiveReceiveLimits( + requestedReceiveLimits, + this.#options.maxMessageSize ?? DEFAULT_MAX_MESSAGE_SIZE, + ) + : state.receiveLimits; + const receiveLimitsExplicit = + options.limits != null || state.receiveLimitsExplicit; + // 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, + ); + } + + 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; + } + + // 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"); + } + + let nativeContent: RawBindings.WasmVerifiedRelayContent; + try { + const bounded = boundedBindings(); + const nativeExpectedFinalRecipientId = + expectedFinalRecipientId == null + ? null + : BigInt(expectedFinalRecipientId); + if (!bounded.open_relay_content_with_keyrings_with_limits_without_replay) { + throw new Error( + "configured receive limits require a rebuilt bounded WASM package", + ); + } + nativeContent = bounded.open_relay_content_with_keyrings_with_limits_without_replay( + state.native, + recipient.keyrings, + signerBundles, + nativeExpectedFinalRecipientId, + signatureVerificationPolicyValue(signaturePolicy), + receiveLimits ?? {}, + ); + } catch (error) { + throw relayOpeningError(error, state.signerId); + } + try { + return this.#formatRelayContent(nativeContent, state); + } finally { + nativeContent.free(); + } + } + + #formatRelayContent( + nativeContent: RawBindings.WasmVerifiedRelayContent, + state: MTPRelayMetadataState, + ): MTPVerifiedRelayContent { + const contentBytes = nativeContent.content(); + const data = state.receiveLimitsExplicit && state.receiveLimits + ? decodeDataValueWithLimits(contentBytes, state.receiveLimits) + : (bindings.parse_data_value(contentBytes) as MTPDataValue); + + 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, + }; + } + + 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"); + } + + // 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 requestedReceiveLimits = normalizeReceiveLimits( + options.limits, + "limits", + ); + const receiveLimits = requestedReceiveLimits + ? effectiveReceiveLimits( + requestedReceiveLimits, + this.#options.maxMessageSize ?? DEFAULT_MAX_MESSAGE_SIZE, + ) + : this.#options.receiveLimits; + const receiveLimitsExplicit = + options.limits != null || this.#options.receiveLimitsExplicit; + const frame = cloneParsedFrame( + parseProtectedFrame( + frameInput, + receiveLimitsExplicit ? receiveLimits : undefined, + ), + ); + assertKnownCommunicationType(frame); + const frameBytes = protectedFrameBytes(frame, receiveLimits); + validateApplicationProtectionPurpose(options.signaturePurpose); + validateApplicationProtectionPurpose(options.encryptionPurpose); + + const recipient = this.#resolveDecryptionIdentity(options.recipient); + const signaturePolicy = resolveSignatureVerificationPolicy( + options.signaturePolicy, + this.#options.defaultSignatureVerificationPolicy ?? + this.#options.securityProfile.protectedSignaturePolicy, + ); + const expectedReceiverId = + options.expectedReceiverId == null + ? recipient.id + : inputU64(options.expectedReceiverId, "expectedReceiverId"); + let signerId: bigint; + try { + const bounded = boundedBindings(); + if (bounded.protected_claimed_signer_id_with_limits) { + signerId = BigInt( + bounded.protected_claimed_signer_id_with_limits( + frameBytes, + recipient.keyrings, + options.encryptionPurpose, + receiveLimits ?? {}, + ), + ); + } else if (receiveLimitsExplicit) { + throw new Error( + "configured receive limits require a rebuilt bounded WASM package", + ); + } else { + 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 { + const bounded = boundedBindings(); + const nativeExpectedReceiverId = + expectedReceiverId == null ? null : expectedReceiverId; + if (!bounded.open_protected_with_keyrings_with_limits_without_replay) { + throw new Error( + "configured receive limits require a rebuilt bounded WASM package", + ); + } + native = bounded.open_protected_with_keyrings_with_limits_without_replay( + frameBytes, + recipient.keyrings, + signerId, + signerBundles, + nativeExpectedReceiverId, + options.signaturePurpose, + options.encryptionPurpose, + signatureVerificationPolicyValue(signaturePolicy), + receiveLimits ?? {}, + ); + } 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 contentBytes = native.content(); + const data = receiveLimitsExplicit && receiveLimits + ? decodeDataValueWithLimits(contentBytes, receiveLimits) + : (bindings.parse_data_value(contentBytes) 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(error), + direction: "recv", + }); + } + }, + ); + + return () => this.raw.client.unsubscribe(sub); + } + + async sendProtected( + type: MTPCommunicationType, + data: MTPDataValueInput, + options: MTPSendProtectedOptions, + ): Promise { + 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 ?? + this.#options.securityProfile.protectedSignatureSuite, + ); + const encodeLimits = this.#encodeLimits(); + const protectedLimits = normalizeReceiveLimits(options.limits, "options.limits"); + const messageIdBytes = utf8Encode(messageId).length; + if ( + protectedLimits?.maxMessageIdBytes != null && + messageIdBytes > protectedLimits.maxMessageIdBytes + ) { + throw new RangeError("protected message ID exceeds maxMessageIdBytes"); + } + const encodedContent = encodeMTPDataValue(data, encodeLimits); + const builderLimits = { ...encodeLimits, ...protectedLimits }; + let frame: Uint8Array; + try { + const bounded = ( + bindings as typeof bindings & { + build_protected_frame_with_keyring_with_limits?: ( + messageType: string, + encodedContent: Uint8Array, + signerId: bigint, + finalRecipientId: bigint, + messageId: string, + createdAt: bigint, + signaturePurpose: number, + encryptionPurpose: number, + keyring: Uint8Array, + signatureSuite: number, + frameId: number | null, + exposeSender: boolean, + recipients: Uint8Array[], + limits: MTPReceiveLimits & MTPEncodeLimits, + ) => Uint8Array; + } + ).build_protected_frame_with_keyring_with_limits; + if (!bounded) { + throw new Error( + "bounded WASM protected-message encoding is unavailable; rebuild mtp-wasm", + ); + } + frame = bounded( + messageType, + encodedContent, + identity.signerId, + receiverId, + messageId, + createdAt, + options.signaturePurpose, + options.encryptionPurpose, + identity.keyring, + protectionSignatureSuiteValue(signatureSuite), + options.id ?? null, + options.exposeSender ?? false, + recipients, + builderLimits, + ); + } catch (error) { + throw protectedOpeningError(error, identity.signerId); + } + await this.send(frame); + } + + 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 ?? + this.#options.securityProfile.protectedSignatureSuite, + ); + const encodeLimits = this.#encodeLimits(); + const protectedLimits = normalizeReceiveLimits(options.limits, "options.limits"); + validateMTPDataValue(data, encodeLimits); + const encodedMetadata = + options.metadata === undefined + ? undefined + : encodeMTPDataValue(options.metadata, encodeLimits); + if ( + encodedMetadata && + protectedLimits?.maxMetadataEncodedBytes != null && + encodedMetadata.length > protectedLimits.maxMetadataEncodedBytes + ) { + throw new RangeError("relay metadata exceeds maxMetadataEncodedBytes"); + } + const builderLimits = { ...encodeLimits, ...protectedLimits }; + const messageId = createMessageId(); + const createdAt = unixTimeMillis(); + const bounded = ( + bindings as typeof bindings & { + build_encrypted_relay_frame_with_keyring_with_limits?: ( + messageType: string, + data: MTPDataValueInput, + signerId: bigint, + finalRecipientId: bigint, + nextHopId: bigint, + messageId: string, + createdAt: bigint, + encodedMetadata: Uint8Array | undefined, + keyring: Uint8Array, + signatureSuite: number, + metadataRecipients: Uint8Array[], + contentRecipients: Uint8Array[], + limits: MTPReceiveLimits & MTPEncodeLimits, + ) => Uint8Array; + } + ).build_encrypted_relay_frame_with_keyring_with_limits; + if (!bounded) { + throw new Error( + "bounded WASM relay encoding is unavailable; rebuild mtp-wasm", + ); + } + const frame = bounded( + messageType, + data, + identity.signerId, + finalRecipientId, + nextHopId, + messageId, + createdAt, + encodedMetadata, + identity.keyring, + protectionSignatureSuiteValue(signatureSuite), + metadataRecipients, + contentRecipients, + builderLimits, + ); + await this.send(frame); + } + + 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; + } + this.raw.client.set_on_pipe_request( + (event: { pipeId: number; description: string }) => { + emit(this.#options.logger, { + hint: "info", + type: "PipeRequest", + data: event, + direction: "recv", + }); + handler({ pipeId: event.pipeId, description: event.description }); + }, + ); + } + + async createPipe(description: string): Promise { + if (typeof description !== "string") { + throw new TypeError("description must be a string"); + } + const handle: WasmPipeHandle = + await this.raw.client.create_pipe(description); + const sdk = this; + return { + pipeId: handle.pipeId, + description: handle.description, + async wait(): Promise { + const result = await handle.wait(); + if (result == null) { + return null; + } + emit(sdk.#options.logger, { + hint: "info", + type: "PipeCreated", + data: { pipeId: result.pipeId }, + direction: "send", + }); + return result as unknown as MTPPipeWriter; + }, + }; + } + + /** + * 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 ?? + this.#options.securityProfile.encryptedPipeSignatureSuite, + ); + } + + async acceptPipe(pipeId: number): Promise { + if (typeof pipeId !== "number" || !Number.isFinite(pipeId)) { + throw new TypeError("pipeId must be a finite number"); + } + const reader = await this.raw.client.accept_pipe(pipeId); + emit(this.#options.logger, { + hint: "info", + type: "PipeAccepted", + data: { pipeId: reader.pipeId, description: reader.description }, + direction: "send", + }); + 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 ?? + this.#options.securityProfile.encryptedPipeSignaturePolicy, + ); + 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"); + } + await this.raw.client.deny_pipe(pipeId); + emit(this.#options.logger, { + hint: "info", + type: "PipeDenied", + data: { pipeId }, + direction: "send", + }); + } + + disconnect(): void { + this.raw.client.stop_protocol_pings(); + this.raw.client.disconnect(); + } +} + +export { ConnectionState, bindings as raw }; +export { + crypto, + codec, + encode, + decode, + decodeDataValueWithLimits, + decodeWithLimits, + format, + bytesToBase64, + base64ToBytes, + bytesFromEncodedString, + strictHexDecode, + strictBase64Decode, + secretKeyFromBytes, + secretKeyFromHex, + secretKeyFromBase64, + secretKeyFromString, + legacySecretKeyFromStringV1, + deriveKeyFromPassphrase, + deriveKeyFromPassphraseSync, + keyringToKeys, + publicKeyBundleToKeys, +} from "./codec.js"; +export { + InMemoryReplayGuard, + MTPReplayError, + MTPMissingProtectedVersionError, + MTPResourceLimitError, + MTPUnsupportedProtectedVersionError, +} from "./protection.js"; +export { + MTPMissingRelayVersionError, + MTPUnsupportedRelayVersionError, + MTPVerifiedRelayMetadata, +} from "./relay.js"; + +// E2EE exports +export type { + MTPSessionState, + MTPSessionStorage, + MTPSessionTranscriptContext, + SkippedMessageKey, +} from "./session"; +export { + MTPSessionManager, + InMemorySessionStorage, + 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, + encryptPayload, + decryptPayload, + MTP_E2EE_VERSION, + FLAG_INIT, + MAX_RATCHET_SKIP, +} from "./encrypted-message.js"; +export type { + EncryptedMessageHeader, + SerializedEncryptedMessage, +} from "./encrypted-message"; +export type { + MTPEncryptedSecretRecord, + MTPEncryptedSecretProvider, +} from "./encrypted-secret"; +export { InMemoryEncryptedSecretProvider } from "./encrypted-secret.js"; diff --git a/src/sdk/codec.ts b/src/sdk/codec.ts new file mode 100644 index 0000000..05252dd --- /dev/null +++ b/src/sdk/codec.ts @@ -0,0 +1,959 @@ +import * as bindings from "mtp/raw"; +import type { MTPCommunicationType } from "../type-map/index"; +import { RESERVED_COMMUNICATION_TYPE_IDS } from "../type-map/reserved.js"; +import { utf8Encode } from "./utils.js"; +import { initWasmOnce } from "./wasm-init.js"; +import type { + MTPBytesInput, + MTPCodec, + MTPCodecOptions, + MTPDataValue, + MTPDataValueInput, + MTPEncodeLimits, + MTPEncodedBytesInput, + MTPKeyringKeys, + MTPKeyMaterialInput, + MTPReceiveLimits, + MTPCrypto, + MTPPublicKeyBundleKeys, + MTPProtectedFrameInput, + MTPProtectionSignatureSuite, + ParsedFrame, +} from "./client.js"; + +const checkedKeyringGenerator = ( + bindings as typeof bindings & { + keyring_generate_checked?: () => Uint8Array; + } +).keyring_generate_checked; + +export const crypto: MTPCrypto = { + generateKeyring: () => + checkedKeyringGenerator?.() ?? bindings.keyring_generate(), + generateEd25519: () => bindings.ed25519_generate(), + keyringFromEd25519: (secretKey, publicKey) => + bindings.keyring_from_ed25519(secretKey, publicKey), + verifyEd25519: (publicKey, message, signature) => + bindings.ed25519_verify(publicKey, message, signature), + deriveEncryptionKey: (ikm, salt, context) => + bindings.wasm_derive_encryption_key(ikm, salt, context), + hkdfExpand: (ikm, salt, info, len) => + bindings.wasm_hkdf_expand(ikm, salt, info, len), + sha256: (data) => bindings.wasm_sha256(data), + sha256Double: (data) => bindings.wasm_sha256_double(data), + keyringToKeys: (keyring) => keyringToKeys(keyring), + publicKeyBundleToKeys: (publicKeyBundle) => + publicKeyBundleToKeys(publicKeyBundle), + + encrypt: async (key, input) => { + const cipher = new bindings.WasmChaCha20Poly1305(key); + try { + return cipher.encrypt(input, new Uint8Array(0)); + } finally { + cipher.free(); + } + }, + + decrypt: async (key, input) => { + const cipher = new bindings.WasmChaCha20Poly1305(key); + try { + return cipher.decrypt(input, new Uint8Array(0)); + } finally { + cipher.free(); + } + }, + + encryptText: async (key, plaintext) => { + const cipher = new bindings.WasmChaCha20Poly1305(key); + try { + const ciphertext = cipher.encrypt( + utf8Encode(plaintext), + new Uint8Array(0), + ); + return bytesToBase64(ciphertext); + } finally { + cipher.free(); + } + }, + + decryptText: async (key, ciphertext) => { + const cipher = new bindings.WasmChaCha20Poly1305(key); + try { + const decoded = base64ToBytes(ciphertext); + const plaintext = cipher.decrypt(decoded, new Uint8Array(0)); + return utf8Decode(plaintext); + } finally { + cipher.free(); + } + }, + + encapsulate: (otherPublicKey) => + bindings.wasm_kem_encapsulate(otherPublicKey), + + decapsulate: (ownPrivateKey, ciphertext) => + bindings.wasm_kem_decapsulate(ownPrivateKey, ciphertext), +}; + +export function encode( + type: MTPCommunicationType, + data: Record, + options?: MTPCodecOptions, +): Uint8Array { + const limits: MTPEncodeLimits = { + maxDepth: MAX_DATA_VALUE_DEPTH, + maxValues: MAX_DATA_VALUE_VALUES, + maxOutputSize: 16 * 1024 * 1024, + }; + const maxOutputSize = limits.maxOutputSize ?? 16 * 1024 * 1024; + validateMTPDataValue(data as MTPDataValueInput, limits); + const bounded = ( + bindings as typeof bindings & { + build_frame_with_limits?: ( + type: string, + data: Record, + options: MTPCodecOptions, + limits: MTPEncodeLimits, + ) => Uint8Array; + } + ).build_frame_with_limits; + if (!bounded) { + throw new Error("bounded WASM frame encoding is unavailable; rebuild mtp-wasm"); + } + const frame = bounded(type, data, options ?? {}, limits); + if (frame.length > maxOutputSize) { + throw new RangeError("MTP frame encoded output limit exceeded"); + } + return frame; +} + +export function decode(frame: MTPBytesInput): ParsedFrame { + return bindings.parse_frame(bytesFrom(frame, "frame")); +} + +export function decodeWithLimits( + frame: MTPBytesInput, + limits: MTPReceiveLimits, +): ParsedFrame { + const parse = ( + bindings as typeof bindings & { + parse_frame_with_limits?: ( + frame: Uint8Array, + limits: MTPReceiveLimits, + ) => ParsedFrame; + } + ).parse_frame_with_limits; + if (!parse) { + throw new Error( + "configured receive limits require a rebuilt bounded WASM package", + ); + } + return parse(bytesFrom(frame, "frame"), limits); +} + +export function decodeDataValueWithLimits( + value: MTPBytesInput, + limits: MTPReceiveLimits, +): MTPDataValue { + const parse = ( + bindings as typeof bindings & { + parse_data_value_with_limits?: ( + value: Uint8Array, + limits: MTPReceiveLimits, + ) => MTPDataValue; + } + ).parse_data_value_with_limits; + if (!parse) { + throw new Error( + "configured receive limits require a rebuilt bounded WASM package", + ); + } + return parse(bytesFrom(value, "data value"), limits); +} + +export function format(frame: MTPBytesInput): string { + return bindings.format_frame(bytesFrom(frame, "frame")); +} + +export const codec: MTPCodec = { encode, decode, format }; + +export function isBytes(value: unknown): value is MTPBytesInput { + return value instanceof Uint8Array || Array.isArray(value); +} + +export function bytesFrom(value: MTPBytesInput, name: string): Uint8Array { + if (value instanceof Uint8Array) return value.slice(); + if (Array.isArray(value)) { + for (const byte of value) { + if (!Number.isInteger(byte) || byte < 0 || byte > 255) { + throw new RangeError(`${name} contains a non-byte value`); + } + } + return Uint8Array.from(value); + } + throw new TypeError(`${name} must be a Uint8Array or number[]`); +} + +export function strictHexDecode(value: string, name = "value"): Uint8Array { + if (typeof value !== "string") throw new TypeError(`${name} must be a string`); + const text = value.replace(/^0x/i, ""); + if (text.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(text)) { + throw new TypeError(`${name} must be an even-length hexadecimal string`); + } + const bytes = new Uint8Array(text.length / 2); + for (let i = 0; i < bytes.length; i += 1) { + bytes[i] = Number.parseInt(text.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +export function strictBase64Decode(value: string, name = "value"): Uint8Array { + if (typeof value !== "string") throw new TypeError(`${name} must be a string`); + if (value.length === 0) return new Uint8Array(0); + if ( + value.length % 4 !== 0 || + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test( + value, + ) + ) { + throw new TypeError(`${name} is not valid padded base64`); + } + + let bytes: Uint8Array; + try { + if (typeof atob === "function") { + const binary = atob(value); + bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + } else if (typeof Buffer !== "undefined") { + bytes = new Uint8Array(Buffer.from(value, "base64")); + } else { + throw new TypeError("base64 decoding is not available in this environment"); + } + } catch (error) { + throw new TypeError(`${name} is not valid base64`, { cause: error }); + } + + if (bytesToBase64(bytes) !== value) { + throw new TypeError(`${name} is not canonical padded base64`); + } + + return bytes; +} + +export function bytesFromEncodedString( + value: string, + encoding: "hex" | "base64", + name: string, +): Uint8Array { + return encoding === "hex" + ? strictHexDecode(value, name) + : strictBase64Decode(value, name); +} + +/* + * Compatibility parser for the historical format-detecting API. New callers + * should select `bytesFromEncodedString` explicitly so a value cannot change + * meaning when it happens to contain only hexadecimal characters. + */ +/** @deprecated Use `bytesFromEncodedString(value, encoding, name)`. */ +export function bytesFromString(value: string, name: string): Uint8Array { + const trimmed = value.trim(); + if (!trimmed) throw new TypeError(`${name} must not be empty`); + + const hex = trimmed.replace(/^(0x)/i, "").replace(/[\s:_-]/g, ""); + if (/^[0-9a-fA-F]+$/.test(hex)) { + return strictHexDecode(hex, name); + } + return strictBase64Decode(trimmed, name); +} + +const HEX_DIGITS = "0123456789abcdef"; + +function bytesToHex(bytes: Uint8Array): string { + let out = ""; + for (let i = 0; i < bytes.length; i += 1) { + out += HEX_DIGITS[(bytes[i] >> 4) & 0xf] + HEX_DIGITS[bytes[i] & 0xf]; + } + return out; +} + +export function bytesToBase64(bytes: Uint8Array): string { + if (typeof btoa === "function") { + let binary = ""; + for (let i = 0; i < bytes.length; i += 1) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); + } + if (typeof Buffer !== "undefined") { + return Buffer.from(bytes).toString("base64"); + } + throw new TypeError("base64 encoding is not available in this environment"); +} + +export function base64ToBytes(input: string): Uint8Array { + return strictBase64Decode(input, "base64"); +} + +function utf8Decode(bytes: Uint8Array): string { + if (typeof TextDecoder !== "undefined") { + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch (error) { + throw new TypeError("invalid UTF-8", { cause: error }); + } + } + let out = ""; + let i = 0; + while (i < bytes.length) { + const b = bytes[i]; + if (b < 0x80) { + out += String.fromCharCode(b); + i += 1; + } else if (b >= 0xc2 && b <= 0xdf) { + if (i + 1 >= bytes.length || (bytes[i + 1] & 0xc0) !== 0x80) { + throw new TypeError("invalid UTF-8"); + } + out += String.fromCharCode(((b & 0x1f) << 6) | (bytes[i + 1] & 0x3f)); + i += 2; + } else if (b >= 0xe0 && b <= 0xef) { + if ( + i + 2 >= bytes.length || + (bytes[i + 1] & 0xc0) !== 0x80 || + (bytes[i + 2] & 0xc0) !== 0x80 || + (b === 0xe0 && bytes[i + 1] < 0xa0) || + (b === 0xed && bytes[i + 1] >= 0xa0) + ) { + throw new TypeError("invalid UTF-8"); + } + out += String.fromCharCode( + ((b & 0x0f) << 12) | + ((bytes[i + 1] & 0x3f) << 6) | + (bytes[i + 2] & 0x3f), + ); + i += 3; + } else if (b >= 0xf0 && b <= 0xf4) { + if ( + i + 3 >= bytes.length || + (bytes[i + 1] & 0xc0) !== 0x80 || + (bytes[i + 2] & 0xc0) !== 0x80 || + (bytes[i + 3] & 0xc0) !== 0x80 || + (b === 0xf0 && bytes[i + 1] < 0x90) || + (b === 0xf4 && bytes[i + 1] >= 0x90) + ) { + throw new TypeError("invalid UTF-8"); + } + const cp = + ((b & 0x07) << 18) | + ((bytes[i + 1] & 0x3f) << 12) | + ((bytes[i + 2] & 0x3f) << 6) | + (bytes[i + 3] & 0x3f); + out += String.fromCodePoint(cp); + i += 4; + } else { + throw new TypeError("invalid UTF-8"); + } + } + return out; +} + +function requiredSecretKeyLength(): number { + const lengthBinding = ( + bindings as typeof bindings & { + mtp_symmetric_key_length?: () => number; + } + ).mtp_symmetric_key_length; + if (!lengthBinding) return 32; + try { + return lengthBinding(); + } catch { + // The generated WASM wrapper is callable only after initialization. Keep + // the historical size as a pre-initialization validation fallback. + return 32; + } +} + +export function secretKeyFromBytes(value: MTPBytesInput): Uint8Array { + const bytes = bytesFrom(value, "secret key"); + const requiredLength = requiredSecretKeyLength(); + if (bytes.length !== requiredLength) { + throw new RangeError(`secret key must be exactly ${requiredLength} bytes`); + } + return bytes; +} + +export function secretKeyFromHex(value: string): Uint8Array { + return secretKeyFromBytes(strictHexDecode(value, "secret key")); +} + +export function secretKeyFromBase64(value: string): Uint8Array { + return secretKeyFromBytes(strictBase64Decode(value, "secret key")); +} + +/* + * Compatibility entry point. It now accepts only explicitly encoded key + * material; arbitrary strings are no longer silently treated as passphrases. + */ +/** @deprecated Use `secretKeyFromBytes`, `secretKeyFromHex`, or `secretKeyFromBase64`. */ +export function secretKeyFromString(secret: string): Uint8Array { + if (typeof secret !== "string" || !secret.trim()) { + throw new TypeError("secret must be a non-empty string"); + } + const trimmed = secret.trim(); + const hex = trimmed.replace(/^(0x)/i, ""); + if (/^[0-9a-fA-F]+$/.test(hex)) return secretKeyFromHex(hex); + return secretKeyFromBase64(trimmed); +} + +/** + * Reproduce the pre-v1 implicit-HKDF derivation for data migration only. + * + * @deprecated Do not use for new secrets. Replace this with explicit key + * material or `deriveKeyFromPassphrase` and persist a password-KDF salt. + */ +export function legacySecretKeyFromStringV1(secret: string): Uint8Array { + if (typeof secret !== "string" || !secret.trim()) { + throw new TypeError("secret must be a non-empty string"); + } + const trimmed = secret.trim(); + const hex = trimmed.replace(/^(0x)/i, "").replace(/[\s:_-]/g, ""); + if (/^[0-9a-fA-F]+$/.test(hex) && hex.length === 64) { + return strictHexDecode(hex, "legacy secret key"); + } + try { + const decoded = bytesFromString(trimmed, "legacy secret key"); + if (decoded.length === requiredSecretKeyLength()) return decoded; + } catch { + // Preserve the historical fallback to HKDF for non-encoded strings. + } + const context = utf8Encode("mtp-symmetric-key"); + return bindings.wasm_derive_encryption_key( + utf8Encode(trimmed), + context, + context, + ); +} + +export interface PasswordKdfParameters { + memoryKiB: number; + iterations: number; + lanes: number; +} + +function validatePasswordKdfInput( + passphrase: string, + salt: MTPBytesInput, + parameters: PasswordKdfParameters, +): { passphrase: string; salt: Uint8Array; parameters: PasswordKdfParameters } { + if (typeof passphrase !== "string" || passphrase.length === 0) { + throw new TypeError("passphrase must not be empty"); + } + const saltBytes = bytesFrom(salt, "passphrase salt"); + if (saltBytes.length < 16) { + throw new RangeError("passphrase salt must be at least 16 bytes"); + } + if ( + !Number.isInteger(parameters.memoryKiB) || + parameters.memoryKiB < 8 * 1024 || + parameters.memoryKiB > 256 * 1024 || + !Number.isInteger(parameters.iterations) || + parameters.iterations < 1 || + parameters.iterations > 10 || + !Number.isInteger(parameters.lanes) || + parameters.lanes < 1 || + parameters.lanes > 8 + ) { + throw new RangeError("invalid Argon2id password-KDF parameters"); + } + return { passphrase, salt: saltBytes, parameters }; +} + +function deriveKeyFromPassphraseSyncImpl( + passphrase: string, + salt: MTPBytesInput, + parameters: PasswordKdfParameters, +): Uint8Array { + const validated = validatePasswordKdfInput(passphrase, salt, parameters); + const kdf = (bindings as unknown as { + wasm_argon2id?: ( + passphrase: Uint8Array, + salt: Uint8Array, + memoryKiB: number, + iterations: number, + lanes: number, + ) => Uint8Array; + }).wasm_argon2id; + if (!kdf) { + throw new Error("Argon2id password derivation is unavailable in this WASM build"); + } + return kdf( + utf8Encode(validated.passphrase), + validated.salt, + validated.parameters.memoryKiB, + validated.parameters.iterations, + validated.parameters.lanes, + ); +} + +/** + * Derive a passphrase key without yielding. Prefer the asynchronous API in + * browser applications; this form is retained for workers and synchronous + * command-line migrations. + */ +/** @deprecated Use `deriveKeyFromPassphrase` in browser-facing code. */ +export function deriveKeyFromPassphraseSync( + passphrase: string, + salt: MTPBytesInput, + parameters: PasswordKdfParameters, +): Uint8Array { + return deriveKeyFromPassphraseSyncImpl(passphrase, salt, parameters); +} + +/** + * Derive a passphrase key off the browser main thread when workers are + * available. The worker imports the same generated WASM binding, so the + * Argon2id computation does not block UI/event-loop work. + */ +export function deriveKeyFromPassphrase( + passphrase: string, + salt: MTPBytesInput, + parameters: PasswordKdfParameters, +): Promise { + const validated = validatePasswordKdfInput(passphrase, salt, parameters); + if (typeof Worker === "undefined") { + return initWasmOnce().then( + () => + new Promise((resolve) => { + setTimeout( + () => + resolve( + deriveKeyFromPassphraseSyncImpl( + validated.passphrase, + validated.salt, + validated.parameters, + ), + ), + 0, + ); + }), + ); + } + + const worker = new Worker(new URL("./passphrase-worker.js", import.meta.url), { + type: "module", + }); + return new Promise((resolve, reject) => { + const cleanup = () => worker.terminate(); + worker.onmessage = (event: MessageEvent) => { + cleanup(); + if (event.data && "error" in event.data) { + reject(new Error(event.data.error)); + } else { + resolve(new Uint8Array(event.data)); + } + }; + worker.onerror = (event) => { + cleanup(); + reject(new Error(event.message || "Argon2id worker failed")); + }; + const passphraseBytes = utf8Encode(validated.passphrase); + const saltBytes = validated.salt.slice(); + worker.postMessage( + { + passphrase: passphraseBytes, + salt: saltBytes, + parameters: validated.parameters, + }, + [passphraseBytes.buffer, saltBytes.buffer], + ); + }); +} + +export function normalizeBytes( + value: string | MTPBytesInput | MTPEncodedBytesInput, + name: string, + encoding?: "hex" | "base64", +): Uint8Array { + if (typeof value === "string") { + if (!encoding) { + throw new TypeError( + `${name} string input requires an explicit 'hex' or 'base64' encoding`, + ); + } + return bytesFromEncodedString(value, encoding, name); + } + if ( + value !== null && + typeof value === "object" && + !(value instanceof Uint8Array) && + !Array.isArray(value) + ) { + const encoded = value as Partial; + if ( + typeof encoded.value !== "string" || + (encoded.encoding !== "hex" && encoded.encoding !== "base64") + ) { + throw new TypeError( + `${name} must be bytes or { value: string, encoding: 'hex' | 'base64' }`, + ); + } + return bytesFromEncodedString(encoded.value, encoded.encoding, name); + } + return bytesFrom(value, name); +} + +export 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; +} + +export function toBigInt( + value: bigint | string | number | null | undefined, +): bigint | null { + if (value == null || value === "") return null; + return inputU64(value, "clientId"); +} + +const KEM_PUBLIC_KEY_LEN = 1216; +const SIG_PQ_PUBLIC_KEY_LEN = 1952; +const SIG_CL_PUBLIC_KEY_LEN = 32; + +export function keyringToKeys(keyring: MTPKeyMaterialInput): MTPKeyringKeys { + const bytes = normalizeBytes(keyring, "keyring"); + if (bytes.length < 12) { + throw new TypeError("keyring data is too short to contain 6 keys"); + } + + 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; + }; + + const result = { + kemPublicKey: readKey(), + kemSecretKey: readKey(), + sigPqPublicKey: readKey(), + sigPqSecretKey: readKey(), + sigClPublicKey: readKey(), + sigClSecretKey: readKey(), + }; + if (offset !== bytes.length) throw new TypeError("keyring has trailing data"); + return result; +} + +export function publicKeyBundleToKeys( + publicKeyBundle: MTPKeyMaterialInput, +): MTPPublicKeyBundleKeys { + const bytes = normalizeBytes(publicKeyBundle, "publicKeyBundle"); + if (bytes.length < 6) { + throw new TypeError("public key bundle data is too short to contain 3 keys"); + } + + let offset = 0; + const readKey = () => { + if (offset + 2 > bytes.length) { + throw new TypeError("public key bundle is truncated"); + } + const len = (bytes[offset] << 8) | bytes[offset + 1]; + offset += 2; + if (offset + len > bytes.length) { + throw new TypeError("public key bundle is truncated"); + } + const key = bytes.slice(offset, offset + len); + offset += len; + return key; + }; + + const result = { + kemPublicKey: readKey(), + sigPqPublicKey: readKey(), + sigClPublicKey: readKey(), + }; + if (offset !== bytes.length) { + 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; +} + +export 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; +} + +export function cloneParsedFrame(frame: ParsedFrame): ParsedFrame { + return cloneParsedValue(frame) as ParsedFrame; +} + +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; +} + +export function errorMessage( + frame: Pick | null | undefined, +): string { + const data = parsedDataObject(frame?.data); + return String( + data.ErrorMessage ?? + data.Error ?? + data.Description ?? + `Received ${frame?.type ?? "error"} frame`, + ); +} + +export function parseProtectedFrame( + frame: MTPProtectedFrameInput, + limits?: MTPReceiveLimits, +): ParsedFrame { + const parse = (bytes: Uint8Array): ParsedFrame => + limits ? decodeWithLimits(bytes, limits) : bindings.parse_frame(bytes); + if (isBytes(frame)) return parse(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 parse(frame.raw); + return frame; +} + +export 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 }); + } +} + +export function protectedFrameBytes( + frame: ParsedFrame, + limits?: MTPReceiveLimits, +): 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"); + } + const options = { + id: frame.id, + ...(frame.sender == null ? {} : { sender: frame.sender }), + ...(frame.receiver == null ? {} : { receiver: frame.receiver }), + }; + const bounded = ( + bindings as typeof bindings & { + build_frame_with_payload_with_limits?: ( + type: string, + payload: Uint8Array, + options: MTPCodecOptions, + limits: MTPReceiveLimits, + ) => Uint8Array; + } + ).build_frame_with_payload_with_limits; + if (!bounded) { + throw new Error("bounded WASM frame encoding is unavailable; rebuild mtp-wasm"); + } + return bounded(frame.type, encoded, options, limits ?? {}); +} + +export 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; +} + +export const MAX_DATA_VALUE_DEPTH = 64; +export const MAX_DATA_VALUE_VALUES = 65_536; + +const DEFAULT_ENCODE_LIMITS: Required = { + maxDepth: MAX_DATA_VALUE_DEPTH, + maxValues: MAX_DATA_VALUE_VALUES, + maxOutputSize: 16 * 1024 * 1024, +}; + +function normalizedEncodeLimits( + limits: MTPEncodeLimits | undefined, +): Required { + const result = { ...DEFAULT_ENCODE_LIMITS, ...(limits ?? {}) }; + for (const [key, value] of Object.entries(result)) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new TypeError(`encode limits ${key} must be a non-negative safe integer`); + } + } + return result as Required; +} + +/** Validate a JS DataValue before crossing into the recursive WASM parser. */ +export function validateMTPDataValue( + value: MTPDataValueInput, + limits?: MTPEncodeLimits, +): void { + const effective = normalizedEncodeLimits(limits); + const ancestors = new WeakSet(); + let values = 0; + const validate = (candidate: unknown, depth: number): void => { + values += 1; + if (values > effective.maxValues) { + throw new RangeError("MTP DataValue value-count limit exceeded"); + } + if (depth > effective.maxDepth) { + throw new RangeError("MTP DataValue nesting-depth limit exceeded"); + } + 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); + try { + for (const entry of entries) validate(entry, depth + 1); + } finally { + ancestors.delete(object); + } + }; + validate(value, 0); +} + +export function encodeMTPDataValue( + value: MTPDataValueInput, + limits?: MTPEncodeLimits, +): Uint8Array { + const effective = normalizedEncodeLimits(limits); + validateMTPDataValue(value, effective); + const bounded = ( + bindings as typeof bindings & { + encode_data_value_with_limits?: ( + value: MTPDataValueInput, + limits: MTPEncodeLimits, + ) => Uint8Array; + } + ).encode_data_value_with_limits; + if (!bounded) { + throw new Error("bounded WASM DataValue encoding is unavailable; rebuild mtp-wasm"); + } + const encoded = bounded(value, effective); + if (encoded.length > effective.maxOutputSize) { + throw new RangeError("MTP DataValue encoded output limit exceeded"); + } + return encoded; +} + +export function inputDataValueBigInt(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 malformed protected metadata below. + } + throw new Error(`protected metadata field ${name} is not an integer`); +} + +export function inputDataValueString(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`); +} + +export function signatureSuiteValue( + suite: MTPProtectionSignatureSuite, +): number { + return suite === "dual" + ? bindings.mtp_protection_signature_suite_dual() + : bindings.mtp_protection_signature_suite_ed25519(); +} + +export function formatDataValue(value: MTPDataValue): MTPDataValue { + return cloneParsedValue(value) as MTPDataValue; +} diff --git a/src/sdk/credentials.ts b/src/sdk/credentials.ts new file mode 100644 index 0000000..0470b9d --- /dev/null +++ b/src/sdk/credentials.ts @@ -0,0 +1,26 @@ +import type { MTPClientCredentials } from "./index.js"; + +export type InternalCredentials = { + clientId: bigint | null; + keyring: Uint8Array; + hostPublicKey?: Uint8Array; +}; + +export function publicCredentials( + credentials: InternalCredentials | null, +): MTPClientCredentials | null { + if (!credentials) { + return null; + } + return { + clientId: credentials.clientId, + keyring: credentials.keyring.slice(), + hostPublicKey: credentials.hostPublicKey?.slice(), + }; +} + +export function zeroCredentials(credentials: InternalCredentials | null): void { + // The host public key is intentionally not wiped: it is public configuration + // and may also be retained by the connection options. + credentials?.keyring.fill(0); +} diff --git a/src/sdk/index.ts b/src/sdk/index.ts index 82325c8..e93ca7e 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -1,3149 +1,7 @@ -import initWasm, { - ConnectionConfig, - ConnectionState, - WasmClient, - WasmPipeHandle, - keyring_generate, -} from "mtp/raw"; -import * as bindings from "mtp/raw"; -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 } from "./session.js"; -import type { - MTPEncryptedSecretRecord, - MTPEncryptedSecretProvider, -} from "./encrypted-secret"; -import { InMemoryEncryptedSecretProvider } from "./encrypted-secret.js"; -import { InMemorySessionStorage } from "./session.js"; -import { - 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; - -export interface MTPCredentialStorage { - getItem(key: string): StorageValue | Promise; - setItem(key: string, value: string): void | Promise; - removeItem(key: string): void | Promise; -} - -export type MTPStorage = MTPCredentialStorage; - -export type MTPLogEvent = - | { - hint: "info" | "warning"; - type: string; - data: unknown; - direction?: "send" | "recv"; - } - | { - hint: "error"; - type: string | "error"; - error: string; - data?: unknown; - direction?: "send" | "recv"; - }; - -export type ParsedFrame = RawBindings.ParsedFrame; - -export type Ed25519GenerateResult = ReturnType< - typeof bindings.ed25519_generate ->; - -export type WasmEncapsulated = RawBindings.WasmEncapsulated; - -export interface MTPCrypto { - generateKeyring(): Uint8Array; - generateEd25519(): Ed25519GenerateResult; - keyringFromEd25519(secretKey: Uint8Array, publicKey: Uint8Array): Uint8Array; - verifyEd25519( - publicKey: Uint8Array, - message: Uint8Array, - signature: Uint8Array, - ): void; - deriveEncryptionKey( - ikm: Uint8Array, - salt: Uint8Array, - context: Uint8Array, - ): Uint8Array; - hkdfExpand( - ikm: Uint8Array, - salt: Uint8Array, - info: Uint8Array, - len: number, - ): Uint8Array; - sha256(data: Uint8Array): Uint8Array; - sha256Double(data: Uint8Array): Uint8Array; - keyringToKeys(keyring: string | MTPBytesInput): MTPKeyringKeys; - publicKeyBundleToKeys( - publicKeyBundle: string | MTPBytesInput, - ): MTPPublicKeyBundleKeys; - encrypt(key: Uint8Array, input: Uint8Array): Promise; - decrypt(key: Uint8Array, input: Uint8Array): Promise; - encryptText(key: Uint8Array, plaintext: string): Promise; - decryptText(key: Uint8Array, ciphertext: string): Promise; - encapsulate(otherPublicKey: Uint8Array): WasmEncapsulated; - decapsulate(ownPrivateKey: Uint8Array, ciphertext: Uint8Array): Uint8Array; -} - -export const crypto: MTPCrypto = { - generateKeyring: () => bindings.keyring_generate(), - generateEd25519: () => bindings.ed25519_generate(), - keyringFromEd25519: (secretKey, publicKey) => - bindings.keyring_from_ed25519(secretKey, publicKey), - verifyEd25519: (publicKey, message, signature) => - bindings.ed25519_verify(publicKey, message, signature), - deriveEncryptionKey: (ikm, salt, context) => - bindings.wasm_derive_encryption_key(ikm, salt, context), - hkdfExpand: (ikm, salt, info, len) => - bindings.wasm_hkdf_expand(ikm, salt, info, len), - sha256: (data) => bindings.wasm_sha256(data), - sha256Double: (data) => bindings.wasm_sha256_double(data), - keyringToKeys: (keyring) => keyringToKeys(keyring), - publicKeyBundleToKeys: (publicKeyBundle) => - publicKeyBundleToKeys(publicKeyBundle), - - encrypt: async (key, input) => { - const cipher = new bindings.WasmChaCha20Poly1305(key); - try { - return cipher.encrypt(input, new Uint8Array(0)); - } finally { - cipher.free(); - } - }, - - decrypt: async (key, input) => { - const cipher = new bindings.WasmChaCha20Poly1305(key); - try { - return cipher.decrypt(input, new Uint8Array(0)); - } finally { - cipher.free(); - } - }, - - encryptText: async (key, plaintext) => { - const cipher = new bindings.WasmChaCha20Poly1305(key); - try { - const ciphertext = cipher.encrypt( - utf8Encode(plaintext), - new Uint8Array(0), - ); - return bytesToBase64(ciphertext); - } finally { - cipher.free(); - } - }, - - decryptText: async (key, ciphertext) => { - const cipher = new bindings.WasmChaCha20Poly1305(key); - try { - const decoded = base64ToBytes(ciphertext); - const plaintext = cipher.decrypt(decoded, new Uint8Array(0)); - return utf8Decode(plaintext); - } finally { - cipher.free(); - } - }, - - encapsulate: (otherPublicKey) => - bindings.wasm_kem_encapsulate(otherPublicKey), - - decapsulate: (ownPrivateKey, ciphertext) => - bindings.wasm_kem_decapsulate(ownPrivateKey, ciphertext), -}; - -export type MTPRawBindings = typeof bindings; - -export interface MTPRaw { - /** - * Underlying generated WASM client instance. - * - * Prefer the `MTPClient` methods for application code. Calling the raw client - * bypasses SDK-level validation, credential persistence, logging, timeout - * handling, frame parsing helpers, and ping lifecycle management. Use this - * escape hatch only when integrating a feature that the SDK wrapper does not - * expose yet. - */ - client: RawBindings.WasmClient; - - /** - * Generated WASM binding module exported by `mtp/raw`. - * - * These bindings mirror the lower-level WASM API and can change shape as the - * generated interface evolves. Prefer the SDK wrapper where possible so your - * code keeps the safer, typed MTPClient flow instead of depending directly on - * transport internals. - */ - bindings: MTPRawBindings; -} - -export type MTPBytesInput = Uint8Array | number[]; - -export interface MTPCodecOptions { - id?: number; - sender?: bigint | number; - receiver?: bigint | number; -} - -export interface MTPCodec { - encode( - type: MTPCommunicationType, - data: Record, - options?: MTPCodecOptions, - ): Uint8Array; - decode(frame: MTPBytesInput): ParsedFrame; - format(frame: MTPBytesInput): string; -} - -export function encode( - type: MTPCommunicationType, - data: Record, - options?: MTPCodecOptions, -): Uint8Array { - return bindings.build_frame(type, data, options ?? {}); -} - -export function decode(frame: MTPBytesInput): ParsedFrame { - return bindings.parse_frame(bytesFrom(frame, "frame")); -} - -export function format(frame: MTPBytesInput): string { - return bindings.format_frame(bytesFrom(frame, "frame")); -} - -export const codec: MTPCodec = { - encode, - decode, - format, -}; - -export interface MTPCredentials { - clientId: bigint | string | number | null; - keyring: MTPBytesInput; - hostPublicKey?: MTPBytesInput | string; -} - -export interface MTPClientCredentials { - clientId: bigint | null; - keyring: Uint8Array; - hostPublicKey?: Uint8Array; -} - -export interface MTPKeyringKeys { - kemPublicKey: Uint8Array; - kemSecretKey: Uint8Array; - sigPqPublicKey: Uint8Array; - sigPqSecretKey: Uint8Array; - sigClPublicKey: Uint8Array; - sigClSecretKey: Uint8Array; -} - -export interface MTPPublicKeyBundleKeys { - kemPublicKey: Uint8Array; - sigPqPublicKey: Uint8Array; - sigClPublicKey: Uint8Array; -} - -export interface MTPClientOptions { - url: string; - descriptor?: string; - hostPublicKey?: MTPBytesInput | string; - credentials?: MTPCredentials | string | null; - credentialsStorageKey?: string; - storage?: MTPCredentialStorage; - serverCertificateHashes?: string[]; - maxMessageSize?: number; - authTimeoutMs?: number; - requestTimeoutMs?: number; - pings?: boolean | { intervalMs?: number }; - wasm?: - | RawBindings.InitInput - | Promise - | { - module_or_path: RawBindings.InitInput | Promise; - }; - logger?: (event: MTPLogEvent) => void; - sessionStorage?: MTPSessionStorage; - /** - * 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 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. + * Public SDK compatibility facade. * - * This identity is independent from transport authentication. Its optional - * ID is used only for structural destination checks when a receive operation - * supports one. + * Implementation lives in private SDK modules. This entry point intentionally + * keeps the package's historical exports stable. */ -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; -} - -export interface MTPPipeWriter { - write(data: Uint8Array): Promise; - close(): Promise; - abort(): void; - readonly pipeId: number; -} - -export interface MTPPipeReader { - read(): Promise; - readonly pipeId: number; - readonly description: string; -} - -export interface MTPPipeRequest { - pipeId: number; - description: string; -} - -export interface MTPOutgoingPipeHandle { - readonly pipeId: number; - readonly description: string; - wait(): Promise; -} - -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; - keyring: Uint8Array; - hostPublicKey?: Uint8Array; -}; - -type NormalizedMTPClientOptions = Omit & { - hostPublicKey?: Uint8Array; -}; - -const DEFAULT_CREDENTIALS_KEY = "mtp:credentials"; -let wasmInitPromise: Promise>> | undefined; - -function createMessageId(): string { - const bytes = new Uint8Array(16); - globalThis.crypto.getRandomValues(bytes); - return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join( - "", - ); -} - -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); - } -} - -function isErrorType(type: string): boolean { - return ( - type === "Error" || - type.startsWith("Error") || - [ - "BadRequest", - "Unauthorized", - "Forbidden", - "NotFound", - "TooManyRequests", - "InternalServerError", - "BadGateway", - "ServiceUnavailable", - "GatewayTimeout", - ].includes(type) - ); -} - -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 ?? - data.Description ?? - `Received ${frame?.type ?? "error"} frame`, - ); -} - -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 { - if (storage) { - await storage.setItem(key, value); - } -} - -async function storageRemove( - storage: MTPCredentialStorage | undefined, - key: string, -): Promise { - if (storage) { - await storage.removeItem(key); - } -} - -function isBytes(value: unknown): value is MTPBytesInput { - return value instanceof Uint8Array || Array.isArray(value); -} - -function bytesFrom(value: MTPBytesInput, name: string): Uint8Array { - if (value instanceof Uint8Array) { - return value; - } - if (Array.isArray(value)) { - return new Uint8Array(value); - } - throw new TypeError(`${name} must be a Uint8Array or number[]`); -} - -function bytesFromString(value: string, name: string): Uint8Array { - const trimmed = value.trim(); - if (!trimmed) { - throw new TypeError(`${name} must not be empty`); - } - - const hex = trimmed.replace(/^(0x)/i, "").replace(/[\s:_-]/g, ""); - if (/^[0-9a-fA-F]+$/.test(hex)) { - if (hex.length % 2 !== 0) { - throw new TypeError(`${name} hex string has an odd length`); - } - const bytes = new Uint8Array(hex.length / 2); - for (let i = 0; i < bytes.length; i += 1) { - bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16); - } - return bytes; - } - - if (typeof atob === "function") { - const binary = atob(trimmed); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i += 1) { - bytes[i] = binary.charCodeAt(i); - } - return bytes; - } - - if (typeof Buffer !== "undefined") { - return new Uint8Array(Buffer.from(trimmed, "base64")); - } - - throw new TypeError(`${name} must be bytes, hex, or base64`); -} - -const HEX_DIGITS = "0123456789abcdef"; - -function bytesToHex(bytes: Uint8Array): string { - let out = ""; - for (let i = 0; i < bytes.length; i += 1) { - out += HEX_DIGITS[(bytes[i] >> 4) & 0xf] + HEX_DIGITS[bytes[i] & 0xf]; - } - return out; -} - -export function bytesToBase64(bytes: Uint8Array): string { - if (typeof btoa === "function") { - let binary = ""; - for (let i = 0; i < bytes.length; i += 1) { - binary += String.fromCharCode(bytes[i]); - } - return btoa(binary); - } - if (typeof Buffer !== "undefined") { - return Buffer.from(bytes).toString("base64"); - } - throw new TypeError("base64 encoding is not available in this environment"); -} - -export function base64ToBytes(input: string): Uint8Array { - if (typeof atob === "function") { - const binary = atob(input); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i += 1) { - bytes[i] = binary.charCodeAt(i); - } - return bytes; - } - if (typeof Buffer !== "undefined") { - return new Uint8Array(Buffer.from(input, "base64")); - } - throw new TypeError("base64 decoding is not available in this environment"); -} - -function utf8Decode(bytes: Uint8Array): string { - if (typeof TextDecoder !== "undefined") { - return new TextDecoder().decode(bytes); - } - if (typeof Buffer !== "undefined") { - return Buffer.from(bytes).toString("utf-8"); - } - let out = ""; - let i = 0; - while (i < bytes.length) { - const b = bytes[i]; - if (b < 0x80) { - out += String.fromCharCode(b); - i += 1; - } else if (b < 0xc0) { - i += 1; - } else if (b < 0xe0) { - out += String.fromCharCode(((b & 0x1f) << 6) | (bytes[i + 1] & 0x3f)); - i += 2; - } else if (b < 0xf0) { - out += String.fromCharCode( - ((b & 0x0f) << 12) | - ((bytes[i + 1] & 0x3f) << 6) | - (bytes[i + 2] & 0x3f), - ); - i += 3; - } else { - const cp = - ((b & 0x07) << 18) | - ((bytes[i + 1] & 0x3f) << 12) | - ((bytes[i + 2] & 0x3f) << 6) | - (bytes[i + 3] & 0x3f); - out += String.fromCodePoint(cp); - i += 4; - } - } - return out; -} - -const SYMMETRIC_KEY_SALT = utf8Encode("mtp-symmetric-key"); - -export function secretKeyFromString(secret: string): Uint8Array { - if (typeof secret !== "string" || !secret.trim()) { - throw new TypeError("secret must be a non-empty string"); - } - - const trimmed = secret.trim(); - const hex = trimmed.replace(/^(0x)/i, "").replace(/[\s:_-]/g, ""); - if (/^[0-9a-fA-F]+$/.test(hex) && hex.length === 64) { - const bytes = new Uint8Array(32); - for (let i = 0; i < 32; i += 1) { - bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16); - } - return bytes; - } - - if (typeof atob === "function" || typeof Buffer !== "undefined") { - try { - const decoded = bytesFromString(trimmed, "secret"); - if (decoded.length === 32) { - return decoded; - } - } catch { - // fall through to HKDF derivation - } - } - - const ikm = utf8Encode(trimmed); - return bindings.wasm_derive_encryption_key( - ikm, - SYMMETRIC_KEY_SALT, - SYMMETRIC_KEY_SALT, - ); -} - -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 { - if (!value) { - return null; - } - - if (typeof value === "string") { - return JSON.parse(value); - } - - return value; -} - -function toBigInt( - value: bigint | string | number | null | undefined, -): bigint | null { - if (value == null || value === "") { - return null; - } - return inputU64(value, "clientId"); -} - -function generateKeyringBytes() { - return keyring_generate(); -} - -export function keyringToKeys(keyring: string | MTPBytesInput): MTPKeyringKeys { - const bytes = - typeof keyring === "string" - ? bytesFromString(keyring, "keyring") - : bytesFrom(keyring, "keyring"); - - if (bytes.length < 12) { - throw new TypeError("keyring data is too short to contain 6 keys"); - } - - 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; - }; - - const result = { - kemPublicKey: readKey(), - kemSecretKey: readKey(), - sigPqPublicKey: readKey(), - sigPqSecretKey: readKey(), - sigClPublicKey: readKey(), - sigClSecretKey: readKey(), - }; - if (offset !== bytes.length) { - throw new TypeError("keyring has trailing data"); - } - return result; -} - -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", - ); - } - - let offset = 0; - const readKey = () => { - if (offset + 2 > bytes.length) { - throw new TypeError("public key bundle is truncated"); - } - const len = (bytes[offset] << 8) | bytes[offset + 1]; - offset += 2; - if (offset + len > bytes.length) { - throw new TypeError("public key bundle is truncated"); - } - const key = bytes.slice(offset, offset + len); - offset += len; - return key; - }; - - const result = { - kemPublicKey: readKey(), - sigPqPublicKey: readKey(), - sigClPublicKey: readKey(), - }; - - if (offset !== bytes.length) { - 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.keyring ?? []), - hostPublicKey: credentials.hostPublicKey - ? Array.from(credentials.hostPublicKey) - : undefined, - }); -} - -function deserializeCredentials(credentials) { - const normalized = normalizeCredentials(credentials); - if (!normalized) { - return null; - } - - const keyring = normalized.keyring; - if (!isBytes(keyring)) { - throw new TypeError("credentials.keyring must be a Uint8Array or number[]"); - } - - return { - clientId: toBigInt(normalized.clientId), - keyring: bytesFrom(keyring, "credentials.keyring"), - hostPublicKey: - normalized.hostPublicKey == null - ? undefined - : normalizeBytes(normalized.hostPublicKey, "credentials.hostPublicKey"), - }; -} - -function publicCredentials(credentials) { - if (!credentials) { - return null; - } - return { - clientId: credentials.clientId, - keyring: credentials.keyring, - hostPublicKey: credentials.hostPublicKey, - }; -} - -function validateOptions(options) { - if (!options || typeof options !== "object") { - throw new TypeError("MTPClient.create requires an options object"); - } - if (typeof options.url !== "string" || !options.url.trim()) { - throw new TypeError("MTPClient.create requires a non-empty url"); - } - 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") { - throw new TypeError(`storage.${method} must be a function`); - } - } - } - if ( - options.maxMessageSize != null && - (!Number.isSafeInteger(options.maxMessageSize) || - options.maxMessageSize <= 0) - ) { - throw new TypeError("maxMessageSize must be a positive safe integer"); - } - if ( - options.authTimeoutMs != null && - (!Number.isSafeInteger(options.authTimeoutMs) || options.authTimeoutMs <= 0) - ) { - throw new TypeError("authTimeoutMs must be a positive safe integer"); - } - if ( - options.requestTimeoutMs != null && - (!Number.isSafeInteger(options.requestTimeoutMs) || - options.requestTimeoutMs <= 0) - ) { - throw new TypeError("requestTimeoutMs must be a positive safe integer"); - } -} - -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(() => { - 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); - } - } -} - -export class MTPClient { - static readonly crypto = crypto; - static readonly codec = codec; - - #credentials: InternalCredentials | null; - #options: NormalizedMTPClientOptions; - readonly #protectedReplayGuard = new InMemoryReplayGuard(); - readonly raw: MTPRaw; - - readonly crypto = MTPClient.crypto; - readonly codec = MTPClient.codec; - - readonly sessionManager: MTPSessionManager; - /** Independent encrypted-secret storage selected by the caller. */ - readonly encryptedSecretProvider: MTPEncryptedSecretProvider; - - private constructor( - options: NormalizedMTPClientOptions, - client: RawBindings.WasmClient, - ) { - this.#options = options; - this.#credentials = deserializeCredentials(options.credentials); - this.raw = { client, bindings }; - this.encryptedSecretProvider = - options.encryptedSecretProvider ?? new InMemoryEncryptedSecretProvider(); - this.sessionManager = new MTPSessionManager( - options.sessionStorage ?? new InMemorySessionStorage(), - ); - } - - static async create(options: MTPClientOptions): Promise { - validateOptions(options); - await MTPClient.init(options.wasm); - - const normalizedOptions = { - ...options, - hostPublicKey: - options.hostPublicKey == null - ? undefined - : normalizeBytes(options.hostPublicKey, "hostPublicKey"), - }; - - let sdk: MTPClient | undefined; - const client = new WasmClient( - (state) => - emit(normalizedOptions.logger, { - hint: "info", - type: "state", - data: ConnectionState[state] ?? state, - }), - (frame) => { - if (sdk) { - sdk.#handleFrame(frame); - } - }, - (error) => - emit(normalizedOptions.logger, { - hint: "error", - type: "Error", - error: String(error), - }), - ); - - sdk = new MTPClient(normalizedOptions, client); - await sdk.#loadStoredCredentials(); - if (!sdk.#credentials) { - sdk.#credentials = { - clientId: null, - keyring: generateKeyringBytes(), - hostPublicKey: normalizedOptions.hostPublicKey, - }; - } else if ( - !sdk.#credentials.hostPublicKey && - normalizedOptions.hostPublicKey - ) { - sdk.#credentials = { - ...sdk.#credentials, - hostPublicKey: normalizedOptions.hostPublicKey, - }; - } else if ( - !normalizedOptions.hostPublicKey && - sdk.#credentials.hostPublicKey - ) { - sdk.#options = { - ...sdk.#options, - hostPublicKey: sdk.#credentials.hostPublicKey, - }; - } - return sdk; - } - - static isSupported(): boolean { - return WasmClient.is_supported(); - } - - static async init( - wasm?: MTPClientOptions["wasm"], - ): Promise>> { - wasmInitPromise ??= initWasm(wasm); - return await wasmInitPromise; - } - - get credentials(): MTPClientCredentials | null { - return publicCredentials(this.#credentials); - } - - get defaultSignatureVerificationPolicy(): MTPSignatureVerificationPolicy { - return resolveSignatureVerificationPolicy( - undefined, - this.#options.defaultSignatureVerificationPolicy, - ); - } - - get state(): RawBindings.ConnectionState { - return this.raw.client.state; - } - - get pingMs(): number | null { - return this.raw.client.ping_ms ?? null; - } - - async #loadStoredCredentials() { - if (this.#credentials || !this.#options.storage) { - return; - } - - const stored = await storageGet( - this.#options.storage, - this.#options.credentialsStorageKey ?? DEFAULT_CREDENTIALS_KEY, - ); - this.#credentials = deserializeCredentials(stored); - } - - #connectionConfig() { - const config = new ConnectionConfig(this.#options.url); - if (this.#options.serverCertificateHashes) { - config.server_certificate_hashes = this.#options.serverCertificateHashes; - } - if (this.#options.maxMessageSize != null) { - config.max_message_size = this.#options.maxMessageSize; - } - if (this.#options.descriptor != null) { - config.description = this.#options.descriptor; - } - return config; - } - - async connect(): Promise { - if (this.#credentials?.clientId != null && this.#options.hostPublicKey) { - await this.auth(); - 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(), - ); - const clientId = ( - this.raw.client as RawBindings.WasmClient & { readonly client_id: bigint } - ).client_id; - this.#startPings(clientId); - } finally { - config.free(); - } - } - - async auth(): Promise { - if (!this.#options.hostPublicKey) { - throw new Error("MTPClient.auth requires hostPublicKey"); - } - return this.#credentials?.clientId == null - ? await this.register() - : await this.#connectAuthenticated(); - } - - async #connectAuthenticated() { - if (!this.#options.hostPublicKey) { - throw new Error( - "MTPClient.connect requires hostPublicKey for authenticated connections", - ); - } - if ( - !this.#credentials?.keyring?.length || - this.#credentials.clientId == null - ) { - throw new Error( - "MTPClient.connect requires credentials with clientId and keyring", - ); - } - - const config = this.#connectionConfig(); - try { - const clientId = await withTimeout( - this.raw.client.auth_connect( - config, - this.#options.hostPublicKey, - this.#credentials.keyring, - this.#credentials.clientId, - ), - this.#options.authTimeoutMs, - "authentication timed out", - () => this.raw.client.disconnect(), - ); - this.#credentials = { ...this.#credentials, clientId }; - await this.#persistCredentials(); - this.#startPings(clientId); - return clientId; - } finally { - config.free(); - } - } - - async register(): Promise { - if (!this.#options.hostPublicKey) { - throw new Error("MTPClient.register requires hostPublicKey"); - } - if (!this.#credentials?.keyring?.length) { - this.#credentials = { - clientId: null, - keyring: generateKeyringBytes(), - hostPublicKey: this.#options.hostPublicKey, - }; - } - - const config = this.#connectionConfig(); - try { - const clientId = await withTimeout( - this.raw.client.auth_register( - config, - this.#options.hostPublicKey, - this.#credentials.keyring, - ), - this.#options.authTimeoutMs, - "authentication timed out", - () => this.raw.client.disconnect(), - ); - this.#credentials = { ...this.#credentials, clientId }; - await this.#persistCredentials(); - this.#startPings(clientId); - return clientId; - } finally { - config.free(); - } - } - - async #persistCredentials() { - await storageSet( - this.#options.storage, - this.#options.credentialsStorageKey ?? DEFAULT_CREDENTIALS_KEY, - serializeCredentials(this.#credentials), - ); - } - - async clearCredentials(): Promise { - this.#credentials = null; - await storageRemove( - this.#options.storage, - this.#options.credentialsStorageKey ?? DEFAULT_CREDENTIALS_KEY, - ); - } - - #startPings(clientId) { - const pings = this.#options.pings; - if (!pings) { - this.raw.client.stop_protocol_pings(); - return; - } - const intervalMs = - typeof pings === "object" ? (pings.intervalMs ?? 30_000) : 30_000; - this.raw.client.start_protocol_pings(intervalMs, clientId); - } - - #buildFrame(typeOrFrame, data, options) { - if (typeOrFrame instanceof Uint8Array) { - return typeOrFrame; - } - if (typeof typeOrFrame !== "string" || !typeOrFrame) { - throw new TypeError( - "message type must be a non-empty string or Uint8Array frame", - ); - } - if (data == null || typeof data !== "object" || Array.isArray(data)) { - throw new TypeError("message data must be an object"); - } - return this.raw.bindings.build_frame(typeOrFrame, data, options ?? {}); - } - - async send(message: Uint8Array): Promise; - async send( - type: MTPCommunicationType, - data: Record, - options?: MTPSendOptions, - ): Promise; - async send( - typeOrFrame: Uint8Array | MTPCommunicationType, - data?: Record, - options?: MTPSendOptions, - ): Promise { - const message = this.#buildFrame(typeOrFrame, data, options); - - try { - const frame = this.raw.bindings.parse_frame(message); - emit( - this.#options.logger, - isErrorType(frame.type) - ? { - hint: "error", - type: frame.type, - error: errorMessage(frame), - data: frame.data, - direction: "send", - } - : { - hint: "info", - type: frame.type, - data: frame.data, - direction: "send", - }, - ); - } catch (error) { - emit(this.#options.logger, { - hint: "error", - type: "Error", - error: String(error), - direction: "send", - }); - } - - 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, - options?: MTPRequestOptions, - ): Promise; - async request( - type: MTPCommunicationType, - data: Record, - options?: MTPRequestOptions, - ): Promise; - async request( - typeOrFrame: Uint8Array | MTPCommunicationType, - data?: Record, - options: MTPRequestOptions = {}, - ): Promise { - 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"); - } - const frame = this.#buildFrame(typeOrFrame, data, options); - try { - const parsed = this.raw.bindings.parse_frame(frame); - emit( - this.#options.logger, - isErrorType(parsed.type) - ? { - hint: "error", - type: parsed.type, - error: errorMessage(parsed), - data: parsed.data, - direction: "send", - } - : { - hint: "info", - type: parsed.type, - data: parsed.data, - direction: "send", - }, - ); - } catch (error) { - emit(this.#options.logger, { - hint: "error", - type: "Error", - error: String(error), - direction: "send", - }); - } - // 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, - ); - } - - subscribe( - type: MTPCommunicationType, - handler: (message: ParsedFrame) => void, - ): Unsubscribe { - if (typeof type !== "string" || !type) { - throw new TypeError("subscription type must be a non-empty string"); - } - if (typeof handler !== "function") { - throw new TypeError("subscription handler must be a function"); - } - const id = this.raw.client.subscribe(type, handler); - return () => this.raw.client.unsubscribe(id); - } - - #handleFrame(frame) { - if (isErrorType(frame.type)) { - emit(this.#options.logger, { - hint: "error", - type: frame.type, - error: errorMessage(frame), - data: frame.data, - direction: "recv", - }); - } else { - emit(this.#options.logger, { - hint: "info", - type: frame.type, - data: frame.data, - direction: "recv", - }); - } - } - - #getKemPublicKey(): Uint8Array { - if (!this.#credentials?.keyring?.length) { - throw new Error("No keyring available"); - } - const keys = keyringToKeys(this.#credentials.keyring); - return keys.kemPublicKey; - } - - #getKemSecretKey(): Uint8Array { - if (!this.#credentials?.keyring?.length) { - throw new Error("No keyring available"); - } - const keys = keyringToKeys(this.#credentials.keyring); - return keys.kemSecretKey; - } - - #getPublicKeyBundleBytes(): Uint8Array { - if (!this.#credentials?.keyring?.length) { - throw new Error("No keyring available"); - } - const keyring = bindings.WasmKeyring.from_bytes( - this.#credentials.keyring, - ); - try { - const bundle = keyring.public_key_bundle(); - try { - return bundle.to_bytes(); - } finally { - bundle.free(); - } - } finally { - keyring.free(); - } - } - - #resolveDecryptionIdentity( - explicit?: MTPDecryptionIdentity, - ): ResolvedDecryptionIdentity { - return resolveDecryptionIdentity(explicit, this.#credentials); - } - - #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 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; - } - - /** - * 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); - } - - 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, - ); - } - - 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; - } - - // 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"); - } - - 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(); - } - } - - #formatRelayContent( - nativeContent: RawBindings.WasmVerifiedRelayContent, - state: MTPRelayMetadataState, - ): MTPVerifiedRelayContent { - const data = bindings.parse_data_value(nativeContent.content()) as MTPDataValue; - - 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, - }; - } - - 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"); - } - - // 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(error), - direction: "recv", - }); - } - }, - ); - - return () => this.raw.client.unsubscribe(sub); - } - - async sendProtected( - type: MTPCommunicationType, - data: MTPDataValueInput, - options: MTPSendProtectedOptions, - ): Promise { - 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 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); - } - - 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; - } - this.raw.client.set_on_pipe_request( - (event: { pipeId: number; description: string }) => { - emit(this.#options.logger, { - hint: "info", - type: "PipeRequest", - data: event, - direction: "recv", - }); - handler({ pipeId: event.pipeId, description: event.description }); - }, - ); - } - - async createPipe(description: string): Promise { - if (typeof description !== "string") { - throw new TypeError("description must be a string"); - } - const handle: WasmPipeHandle = - await this.raw.client.create_pipe(description); - const sdk = this; - return { - pipeId: handle.pipeId, - description: handle.description, - async wait(): Promise { - const result = await handle.wait(); - if (result == null) { - return null; - } - emit(sdk.#options.logger, { - hint: "info", - type: "PipeCreated", - data: { pipeId: result.pipeId }, - direction: "send", - }); - return result as unknown as MTPPipeWriter; - }, - }; - } - - /** - * 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"); - } - const reader = await this.raw.client.accept_pipe(pipeId); - emit(this.#options.logger, { - hint: "info", - type: "PipeAccepted", - data: { pipeId: reader.pipeId, description: reader.description }, - direction: "send", - }); - 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"); - } - await this.raw.client.deny_pipe(pipeId); - emit(this.#options.logger, { - hint: "info", - type: "PipeDenied", - data: { pipeId }, - direction: "send", - }); - } - - disconnect(): void { - this.raw.client.stop_protocol_pings(); - this.raw.client.disconnect(); - } -} - -export { ConnectionState, bindings as raw }; - -// E2EE exports -export type { - MTPSessionState, - MTPSessionStorage, - MTPSessionTranscriptContext, - SkippedMessageKey, -} from "./session"; -export { - MTPSessionManager, - InMemorySessionStorage, - 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, - encryptPayload, - decryptPayload, - MTP_E2EE_VERSION, - FLAG_INIT, - MAX_RATCHET_SKIP, -} from "./encrypted-message.js"; -export type { - EncryptedMessageHeader, - SerializedEncryptedMessage, -} from "./encrypted-message"; -export type { - MTPEncryptedSecretRecord, - MTPEncryptedSecretProvider, -} from "./encrypted-secret"; -export { InMemoryEncryptedSecretProvider } from "./encrypted-secret.js"; +export * from "./client.js"; diff --git a/src/sdk/passphrase-worker.ts b/src/sdk/passphrase-worker.ts new file mode 100644 index 0000000..78f37b4 --- /dev/null +++ b/src/sdk/passphrase-worker.ts @@ -0,0 +1,33 @@ +import initWasm, * as bindings from "mtp/raw"; + +interface PasswordKdfWorkerRequest { + passphrase: Uint8Array; + salt: Uint8Array; + parameters: { + memoryKiB: number; + iterations: number; + lanes: number; + }; +} + +const scope = globalThis as unknown as { + onmessage: ((event: MessageEvent) => void) | null; + postMessage(message: Uint8Array | { error: string }, transfer?: Transferable[]): void; +}; + +scope.onmessage = async (event) => { + try { + await initWasm(); + const { passphrase, salt, parameters } = event.data; + const key = bindings.wasm_argon2id( + passphrase, + salt, + parameters.memoryKiB, + parameters.iterations, + parameters.lanes, + ); + scope.postMessage(key, [key.buffer]); + } catch (error) { + scope.postMessage({ error: String(error) }); + } +}; diff --git a/src/sdk/protection.ts b/src/sdk/protection.ts new file mode 100644 index 0000000..b440276 --- /dev/null +++ b/src/sdk/protection.ts @@ -0,0 +1,258 @@ +import type { InternalCredentials } from "./credentials.js"; +import { + inputU64, + keyringToKeys, + normalizeBytes, + publicKeyBundleToKeys, + signatureSuiteValue, +} from "./codec.js"; +import { + MTPSignatureVerificationError, + signerKeysUnavailable, +} from "./signature-policy.js"; +import type { MTPSignatureVerificationPolicy } from "./signature-policy.js"; +import type { + MTPDecryptionIdentity, + MTPProtectionIdentity, + MTPProtectionSignatureSuite, + MTPReplayGuard, + MTPSignerKeyResolver, + MTPBytesInput, + MTPKeyMaterialInput, +} from "./client.js"; + +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 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; + } +} + +export class MTPResourceLimitError extends Error { + constructor(message = "MTP receive resource limit exceeded") { + super(message); + this.name = "MTPResourceLimitError"; + } +} + +export interface ResolvedProtectionIdentity { + signerId: bigint; + keyring: Uint8Array; +} + +export interface ResolvedDecryptionIdentity { + id?: bigint; + keyrings: Uint8Array[]; +} + +export interface SignerResolutionOptions { + expectedSignerId?: bigint | number | string; + resolveSignerPublicKeys?: MTPSignerKeyResolver; +} + +export function protectionSignatureSuiteValue( + suite: MTPProtectionSignatureSuite, +): number { + return signatureSuiteValue(suite); +} + +export 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; +} + +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; +} + +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: MTPKeyMaterialInput, 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; +} + +export function normalizeRecipientBundles( + recipients: MTPKeyMaterialInput[], + 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(); + }); +} + +export 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", + ); +} + +export 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", + ); +} + +export 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"); + case "resource-limit": + return new MTPResourceLimitError(); + } + } + } + return error instanceof Error ? error : new Error(String(error)); +} + +export type { MTPSignatureVerificationPolicy }; diff --git a/src/sdk/relay.ts b/src/sdk/relay.ts new file mode 100644 index 0000000..1e0fbec --- /dev/null +++ b/src/sdk/relay.ts @@ -0,0 +1,204 @@ +import type * as RawBindings from "../raw/index"; +import { cloneParsedFrame, cloneParsedValue, inputU64 } from "./codec.js"; +import { + MTPSignatureVerificationError, + signerKeysUnavailable, +} from "./signature-policy.js"; +import { MTPResourceLimitError } from "./protection.js"; +import type { MTPSignatureVerificationPolicy } from "./signature-policy.js"; +import type { + MTPDataValue, + MTPReceiveLimits, + MTPVerifiedRelayContent, + ParsedFrame, +} from "./client.js"; + +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 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"); + case "resource-limit": + return new MTPResourceLimitError(); + } + } + } + return error instanceof Error ? error : new Error(String(error)); +} + +export 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; + receiveLimits?: MTPReceiveLimits; + receiveLimitsExplicit: boolean; + disposed: boolean; + finalizerToken: object; +} + +export 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. + } +}); + +export const RELAY_METADATA_TOKEN = Symbol("mtp-authenticated-relay-metadata"); + +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; + } + + 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. + } + } + + free(): void { + this.dispose(); + } + + [Symbol.dispose](): void { + this.dispose(); + } + + get frame(): ParsedFrame { + return cloneParsedFrame(this.state.frame); + } + get signerId(): bigint { + return this.state.signerId; + } + get relayVersion(): number { + return this.state.relayVersion; + } + get finalRecipientId(): bigint { + return this.state.finalRecipientId; + } + get messageId(): string { + return this.state.messageId; + } + 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(); + } + get signerPublicKeys(): Uint8Array[] { + return this.state.signerPublicKeys.map((bundle) => bundle.slice()); + } + get matchedSignerKeyIndex(): number { + return this.state.matchedSignerKeyIndex; + } + 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 function registerRelayMetadata( + metadata: MTPVerifiedRelayMetadata, + native: RawBindings.WasmVerifiedRelayMetadata, + finalizerToken: object, +): void { + relayMetadataFinalizer.register(metadata, native, finalizerToken); +} + +export type { MTPVerifiedRelayContent }; diff --git a/src/sdk/signature-policy.ts b/src/sdk/signature-policy.ts index a9778a7..47518ce 100644 --- a/src/sdk/signature-policy.ts +++ b/src/sdk/signature-policy.ts @@ -92,8 +92,14 @@ export function signatureVerificationPolicyValue( return bindings.mtp_protection_signature_suite_ed25519(); case "dual": return bindings.mtp_protection_signature_suite_dual(); - case "any-supported": - return 0; + case "any-supported": { + const compatibility = ( + bindings as typeof bindings & { + mtp_protection_signature_suite_any_supported?: () => number; + } + ).mtp_protection_signature_suite_any_supported; + return compatibility?.() ?? 0; + } } } diff --git a/src/sdk/timeout.ts b/src/sdk/timeout.ts new file mode 100644 index 0000000..b4c3ddc --- /dev/null +++ b/src/sdk/timeout.ts @@ -0,0 +1,27 @@ +export async function withTimeout( + promise: Promise, + timeoutMs: number | undefined, + message: string, + cancel?: () => void, +): Promise { + if (!timeoutMs) { + return await promise; + } + + let timeoutId: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timeoutId = setTimeout(() => { + cancel?.(); + reject(new Error(message)); + }, timeoutMs); + }), + ]); + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + } +} diff --git a/src/sdk/wasm-init.ts b/src/sdk/wasm-init.ts new file mode 100644 index 0000000..8c843eb --- /dev/null +++ b/src/sdk/wasm-init.ts @@ -0,0 +1,27 @@ +import initWasm from "mtp/raw"; + +type WasmInitInput = Parameters[0]; +type WasmExports = Awaited>; +type WasmInitializer = (input?: WasmInitInput) => Promise; + +export function createWasmInitializer( + initialize: WasmInitializer = initWasm, +): WasmInitializer { + let wasmInitPromise: Promise | undefined; + + /** + * Keep the successful WASM singleton, but make a failed attempt retryable. + * A rejected promise is never retained in the module cache. + */ + return (input?: WasmInitInput): Promise => { + if (!wasmInitPromise) { + wasmInitPromise = initialize(input).catch((error) => { + wasmInitPromise = undefined; + throw error; + }); + } + return wasmInitPromise; + } +} + +export const initWasmOnce = createWasmInitializer(); diff --git a/test/wasm-init.mjs b/test/wasm-init.mjs new file mode 100644 index 0000000..c7057ee --- /dev/null +++ b/test/wasm-init.mjs @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createWasmInitializer } from "../dist/sdk/wasm-init.js"; + +test("WASM initialization can retry after a rejected attempt", async () => { + let attempts = 0; + const expected = { initialized: true }; + const init = createWasmInitializer(async () => { + attempts += 1; + if (attempts === 1) { + throw new Error("initialization failed"); + } + return expected; + }); + + await assert.rejects(init(), /initialization failed/); + assert.equal(await init(), expected); + assert.equal(await init(), expected); + assert.equal(attempts, 2); +}); diff --git a/transport/src/connection.rs b/transport/src/connection.rs index c21bd67..3e30b93 100644 --- a/transport/src/connection.rs +++ b/transport/src/connection.rs @@ -1,12 +1,14 @@ use crate::ConnectionHandle; +use crate::framing::RetryClassifier; #[cfg(feature = "pipes")] use crate::pipe::PipeReader; -use mtp_codec::{CommunicationValue, DecodeLimits, TypeMap}; +use mtp_codec::{CommunicationValue, DecodeError, DecodeLimits, EncodeLimits, TypeMap}; use mtp_common::CommunicationError; +use std::ops::Deref; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use tokio::sync::{Mutex, Notify, RwLock, Semaphore, mpsc}; -use tokio::time::{Duration, sleep, timeout}; +use tokio::time::{Duration, Instant, sleep, timeout, timeout_at}; use tracing::{debug, info, instrument, trace, warn}; use wtransport::Connection; @@ -19,6 +21,63 @@ pub enum TransportEvent { const APPLICATION_CLOSE_REASON: &str = "mtp-close"; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DecodeRejectionClass { + Malformed, + ResourceLimit, + DuplicateField, +} + +pub fn classify_decode_error(error: &DecodeError) -> DecodeRejectionClass { + match error { + DecodeError::MalformedEncoding => DecodeRejectionClass::Malformed, + DecodeError::DepthLimit + | DecodeError::ValueCountLimit + | DecodeError::BlobLimit + | DecodeError::AllocationLimit + | DecodeError::RecipientLimit => DecodeRejectionClass::ResourceLimit, + DecodeError::DuplicateField => DecodeRejectionClass::DuplicateField, + } +} + +#[derive(Debug, Default)] +pub struct DecodeRejectionCounters { + malformed: AtomicU64, + resource_limit: AtomicU64, + duplicate_field: AtomicU64, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct DecodeRejectionCounts { + pub malformed: u64, + pub resource_limit: u64, + pub duplicate_field: u64, +} + +impl DecodeRejectionCounters { + pub(crate) fn record(&self, error: &DecodeError) { + match classify_decode_error(error) { + DecodeRejectionClass::Malformed => { + self.malformed.fetch_add(1, Ordering::Relaxed); + } + DecodeRejectionClass::ResourceLimit => { + self.resource_limit.fetch_add(1, Ordering::Relaxed); + } + DecodeRejectionClass::DuplicateField => { + self.duplicate_field.fetch_add(1, Ordering::Relaxed); + } + } + } + + pub(crate) fn snapshot(&self) -> DecodeRejectionCounts { + DecodeRejectionCounts { + malformed: self.malformed.load(Ordering::Relaxed), + resource_limit: self.resource_limit.load(Ordering::Relaxed), + duplicate_field: self.duplicate_field.load(Ordering::Relaxed), + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SendMode { PersistentStream, @@ -135,27 +194,62 @@ impl Policy { } } +/// A validated policy snapshot used after a public [`Policy`] crosses into a +/// transport implementation. `Policy` intentionally remains a plain public +/// struct for source compatibility, so callers can construct it directly and +/// bypass builder methods. Every transport constructor takes this snapshot +/// before creating channels or semaphores. +#[derive(Debug, Clone, Copy)] +pub(crate) struct RuntimePolicy(Policy); + +impl RuntimePolicy { + pub(crate) fn from_public(policy: &Policy) -> Self { + let mut policy = *policy; + policy.receiver_queue_capacity = policy.receiver_queue_capacity.max(1); + policy.max_concurrent_stream_tasks = policy.max_concurrent_stream_tasks.max(1); + Self(policy) + } +} + +impl Deref for RuntimePolicy { + type Target = Policy; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + enum ReceivedFrame { Message(CommunicationValue), ClosedByPeer, Idle, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SenderState { + Open, + Closing, + Closed, +} + #[derive(Clone)] pub struct Sender { send_guard: Arc>, stream_guard: Arc>>, + state: Arc>, handle: Arc, connection: Connection, - policy: Arc, + policy: Arc, type_map: Arc>, } impl Sender { pub fn new(connection: Connection, handle: Arc, policy: Arc) -> Self { + let policy = Arc::new(RuntimePolicy::from_public(&policy)); Self { send_guard: Arc::new(Mutex::new(())), stream_guard: Arc::new(Mutex::new(None)), + state: Arc::new(Mutex::new(SenderState::Open)), handle, connection, policy, @@ -174,7 +268,11 @@ impl Sender { data: &CommunicationValue, policy: &Policy, ) -> Result<(), CommunicationError> { - let bytes = data.to_bytes().map_err(|_| CommunicationError::Encode)?; + let bytes = data + .to_bytes_with_limits(EncodeLimits::for_transport_message_size( + policy.max_message_size, + )) + .map_err(|_| CommunicationError::Encode)?; if bytes.len() as u64 > policy.max_message_size || bytes.len() as u64 >= policy.close_frame_len as u64 { @@ -269,11 +367,11 @@ impl Sender { return Ok(()); } - let err = res.err().unwrap_or(CommunicationError::StreamError); - if !matches!( - err, - CommunicationError::StreamError | CommunicationError::StreamClosed - ) { + let err = match res { + Ok(()) => return Ok(()), + Err(error) => error, + }; + if !RetryClassifier::retry_persistent_stream(&err) { return Err(err); } *stream_opt = None; @@ -356,20 +454,24 @@ impl Sender { #[instrument(skip(self, data), level = "trace")] pub async fn send(&self, data: &CommunicationValue) -> Result<(), CommunicationError> { - if self.handle.is_closed() { - return Err(self - .handle - .close_reason() - .unwrap_or(CommunicationError::UseAfterClosed)); - } - let _send_lock = self.send_guard.lock().await; + { + let state = self.state.lock().await; + if *state != SenderState::Open { + return Err(self + .handle + .close_reason() + .unwrap_or(CommunicationError::StreamClosed)); + } + } + if self.connection.quic_connection().close_reason().is_some() { let reason = self .handle .close_reason() .unwrap_or(CommunicationError::StreamClosed); + *self.state.lock().await = SenderState::Closed; self.handle.close(Some(reason.clone())); return Err(reason); } @@ -402,6 +504,7 @@ impl Sender { if self.connection.quic_connection().close_reason().is_some() || matches!(normalized, CommunicationError::StreamClosed) { + *self.state.lock().await = SenderState::Closed; self.handle.close(Some(normalized.clone())); } @@ -413,6 +516,12 @@ impl Sender { #[instrument(skip(self), level = "trace")] pub async fn finish_stream(&self) -> Result<(), CommunicationError> { let _send_lock = self.send_guard.lock().await; + if *self.state.lock().await != SenderState::Open { + return Err(self + .handle + .close_reason() + .unwrap_or(CommunicationError::StreamClosed)); + } let mut stream_opt = self.stream_guard.lock().await; if let Some(mut stream) = stream_opt.take() { match timeout(self.policy.write_timeout, stream.finish()).await { @@ -445,11 +554,12 @@ impl Sender { pipe_id: u32, description: &str, ) -> Result { - if self.handle.is_closed() { + let _send_lock = self.send_guard.lock().await; + if *self.state.lock().await != SenderState::Open { return Err(self .handle .close_reason() - .unwrap_or(CommunicationError::UseAfterClosed)); + .unwrap_or(CommunicationError::StreamClosed)); } if self.connection.quic_connection().close_reason().is_some() { @@ -486,31 +596,47 @@ impl Sender { let handle = self.handle.clone(); let policy = self.policy.clone(); let stream_guard = self.stream_guard.clone(); + let send_guard = self.send_guard.clone(); + let state = self.state.clone(); tokio::spawn(async move { + let _send_lock = send_guard.lock().await; + { + let mut sender_state = state.lock().await; + if *sender_state != SenderState::Open { + return; + } + *sender_state = SenderState::Closing; + } + if connection.quic_connection().close_reason().is_some() || handle.is_closed() { + *state.lock().await = SenderState::Closed; handle.close(Some(CommunicationError::StreamClosed)); return; } - if let Some(mut stream) = stream_guard.lock().await.take() { - match timeout(policy.write_timeout, stream.finish()).await { - Ok(Ok(())) => {} - Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => warn!( - "[Sender] persistent stream finish failed: peer sent STOP_SENDING (error code {code})" - ), - Ok(Err(e)) => { - warn!("[Sender] persistent stream finish failed: {e}") + { + if let Some(mut stream) = stream_guard.lock().await.take() { + match timeout(policy.write_timeout, stream.finish()).await { + Ok(Ok(())) => {} + Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => warn!( + "[Sender] persistent stream finish failed: peer sent STOP_SENDING (error code {code})" + ), + Ok(Err(e)) => { + warn!("[Sender] persistent stream finish failed: {e}") + } + Err(_) => warn!("[Sender] persistent stream finish timed out"), } - Err(_) => warn!("[Sender] persistent stream finish timed out"), } } let _ = Self::send_close_frame(&connection, &policy).await; + *state.lock().await = SenderState::Closed; handle.close(Some(CommunicationError::StreamClosed)); info!(target = "mtp.transport", "connection closed"); + drop(_send_lock); sleep(policy.force_close_delay).await; if connection.quic_connection().close_reason().is_none() { connection.quic_connection().close( @@ -528,35 +654,48 @@ impl Sender { let connection = self.connection.clone(); let handle = self.handle.clone(); let policy = self.policy.clone(); - let mut stream_opt = self.stream_guard.lock().await; + let _send_lock = self.send_guard.lock().await; + { + let mut state = self.state.lock().await; + if *state != SenderState::Open { + return; + } + *state = SenderState::Closing; + } if connection.quic_connection().close_reason().is_some() || handle.is_closed() { + *self.state.lock().await = SenderState::Closed; handle.close(Some(CommunicationError::StreamClosed)); return; } - if let Some(mut stream) = stream_opt.take() { - let close_bytes = policy.close_frame_len.to_be_bytes(); - let close_write = async { - stream.write_all(&close_bytes).await?; - stream.finish().await - }; + { + let mut stream_opt = self.stream_guard.lock().await; + if let Some(mut stream) = stream_opt.take() { + let close_bytes = policy.close_frame_len.to_be_bytes(); + let close_write = async { + stream.write_all(&close_bytes).await?; + stream.finish().await + }; - match timeout(policy.write_timeout, close_write).await { - Ok(Ok(())) => {} - Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => { - warn!("[Sender] close failed: peer sent STOP_SENDING (error code {code})") + match timeout(policy.write_timeout, close_write).await { + Ok(Ok(())) => {} + Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => { + warn!("[Sender] close failed: peer sent STOP_SENDING (error code {code})") + } + Ok(Err(e)) => warn!("[Sender] close failed: {e}"), + Err(_) => warn!("[Sender] close timed out"), } - Ok(Err(e)) => warn!("[Sender] close failed: {e}"), - Err(_) => warn!("[Sender] close timed out"), + } else { + let _ = Self::send_close_frame(&connection, &policy).await; } - } else { - let _ = Self::send_close_frame(&connection, &policy).await; } + *self.state.lock().await = SenderState::Closed; handle.close(Some(CommunicationError::StreamClosed)); info!(target = "mtp.transport", "connection closed"); + drop(_send_lock); sleep(policy.force_close_delay).await; if connection.quic_connection().close_reason().is_none() { connection.quic_connection().close( @@ -605,6 +744,7 @@ struct ReceiverInner { queue_notify: Arc, max_message_size: Arc, type_map: Arc>, + decode_rejections: Arc, } impl Clone for Receiver { @@ -626,12 +766,26 @@ impl Drop for Receiver { #[derive(Clone, Default)] struct PingControl { pong_sender: Option, - pong_observer: Option>, + pong_observer: Option>, + expected_pong_id: Option, +} + +impl PingControl { + fn accepts_pong(&mut self, id: Option) -> bool { + if self.expected_pong_id == id && id.is_some() { + self.expected_pong_id = None; + true + } else { + false + } + } } impl Receiver { pub fn new(connection: Connection, handle: Arc, policy: Arc) -> Self { - Self::new_with_max_message_size(connection, handle, policy.clone(), policy.max_message_size) + let policy = Arc::new(RuntimePolicy::from_public(&policy)); + let max_message_size = policy.max_message_size; + Self::new_with_max_message_size(connection, handle, policy, max_message_size) } #[cfg(feature = "host")] @@ -640,6 +794,7 @@ impl Receiver { handle: Arc, policy: Arc, ) -> Self { + let policy = Arc::new(RuntimePolicy::from_public(&policy)); let initial_max = policy .handshake_max_message_size .min(policy.max_message_size); @@ -649,7 +804,7 @@ impl Receiver { fn new_with_max_message_size( connection: Connection, handle: Arc, - policy: Arc, + policy: Arc, initial_max_message_size: u64, ) -> Self { #[cfg(feature = "pipes")] @@ -674,6 +829,8 @@ impl Receiver { 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 decode_rejections = Arc::new(DecodeRejectionCounters::default()); + let accept_decode_rejections = decode_rejections.clone(); let stream_limit = Arc::new(Semaphore::new(policy.max_concurrent_stream_tasks.max(1))); let accept_stream_limit = stream_limit.clone(); debug!( @@ -742,6 +899,7 @@ impl Receiver { 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(); + let stream_decode_rejections = accept_decode_rejections.clone(); tokio::spawn(async move { let _permit = permit; @@ -761,7 +919,14 @@ impl Receiver { } let frame_limit = stream_max_message_size.load(Ordering::Relaxed); - match Self::read_one_frame(&mut s, &stream_policy, frame_limit).await { + match Self::read_one_frame( + &mut s, + &stream_policy, + frame_limit, + &stream_decode_rejections, + ) + .await + { Ok(ReceivedFrame::Message(mut msg)) => { let negotiated_type_map = stream_type_map.read().await.clone(); @@ -805,24 +970,17 @@ impl Receiver { } } - let control = { - let control = stream_ping_control.read().await; - if msg.is_type(mtp_codec::CommunicationType::Ping) { - control - .pong_sender - .clone() - .map(|sender| (Some(sender), None)) - } else if msg.is_type(mtp_codec::CommunicationType::Pong) { - control - .pong_observer - .clone() - .map(|observer| (None, Some(observer))) - } else { - None - } + let pong_sender = if msg.is_type(mtp_codec::CommunicationType::Ping) { + stream_ping_control + .read() + .await + .pong_sender + .clone() + } else { + None }; - if let Some((Some(sender), _)) = control { + if let Some(sender) = pong_sender { let mut pong = CommunicationValue::new_with_type_map( mtp_codec::CommunicationType::Pong, &negotiated_type_map, @@ -844,8 +1002,18 @@ impl Receiver { continue; } - if let Some((_, Some(observer))) = control { - let _ = observer.send(msg); + if msg.is_type(mtp_codec::CommunicationType::Pong) { + let observer = { + let mut control = stream_ping_control.write().await; + if control.accepts_pong(msg.id()) { + control.pong_observer.clone() + } else { + None + } + }; + if let Some(observer) = observer { + let _ = observer.try_send(msg); + } continue; } @@ -942,6 +1110,7 @@ impl Receiver { queue_notify, max_message_size, type_map, + decode_rejections, }), } } @@ -958,6 +1127,14 @@ impl Receiver { *self.inner.type_map.write().await = type_map.clone(); } + /// Return local counts for frames rejected by the structured decoder. + /// + /// These counters are intentionally local-only; peers continue to receive + /// the generic protocol parse failure. + pub fn decode_rejection_counts(&self) -> DecodeRejectionCounts { + self.inner.decode_rejections.snapshot() + } + /* 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() { @@ -967,16 +1144,21 @@ impl Receiver { } } - /* Route reserved Pong frames to a connection-level observer. */ - pub async fn observe_pongs(&self, observer: mpsc::UnboundedSender) { + /* Route only the currently expected reserved Pong through a bounded observer. */ + pub async fn observe_pongs_bounded(&self, observer: mpsc::Sender) { self.inner.ping_control.write().await.pong_observer = Some(observer); } - #[instrument(skip(stream, policy), level = "trace")] + pub async fn set_expected_pong_id(&self, expected_pong_id: Option) { + self.inner.ping_control.write().await.expected_pong_id = expected_pong_id; + } + + #[instrument(skip(stream, policy, decode_rejections), level = "trace")] async fn read_one_frame( stream: &mut wtransport::RecvStream, - policy: &Policy, + policy: &RuntimePolicy, max_message_size: u64, + decode_rejections: &DecodeRejectionCounters, ) -> Result { use wtransport::error::{StreamReadError, StreamReadExactError}; @@ -1011,6 +1193,7 @@ impl Receiver { if len == policy.close_frame_len { return Ok(ReceivedFrame::ClosedByPeer); } + let deadline = Instant::now() + policy.read_timeout; let body_len = len as usize; let frame_len = body_len @@ -1020,29 +1203,29 @@ impl Receiver { 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(body_len.min(16 * 1024)) + // The length has already been checked against the admitted frame + // limit, so reserve one bounded framing buffer and decode it without a + // second prefix-plus-body allocation/copy. + let mut frame = Vec::new(); + frame + .try_reserve_exact(frame_len) .map_err(|_| CommunicationError::MessageTooLarge)?; - 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, - stream.read_exact(&mut chunk[..chunk_len]), + frame.extend_from_slice(&len_buf); + frame.resize(frame_len, 0); + let mut body_offset = 4usize; + while body_offset < frame_len { + let chunk_len = (frame_len - body_offset).min(16 * 1024); + match timeout_at( + deadline, + stream.read_exact(&mut frame[body_offset..body_offset + chunk_len]), ) .await { - Ok(Ok(())) => { - buf.try_reserve(chunk_len) - .map_err(|_| CommunicationError::MessageTooLarge)?; - buf.extend_from_slice(&chunk[..chunk_len]); - } + Ok(Ok(())) => body_offset += chunk_len, Ok(Err(StreamReadExactError::FinishedEarly(n))) => { warn!( "[Receiver] body read ended early ({}/{body_len} bytes): stream closed by peer", - buf.len() + n + body_offset.saturating_sub(4) + n ); return Err(CommunicationError::StreamError); } @@ -1063,14 +1246,21 @@ impl Receiver { } } - 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( + let message = match CommunicationValue::try_from_bytes_with_limits( &frame, DecodeLimits::for_transport_message_size(max_message_size), - ) - .map_err(|_| CommunicationError::ParseCommunicationValue)?; + ) { + Ok(message) => message, + Err(error) => { + decode_rejections.record(&error); + warn!( + ?error, + class = ?classify_decode_error(&error), + "[Receiver] rejected frame during bounded decode" + ); + return Err(CommunicationError::ParseCommunicationValue); + } + }; Ok(ReceivedFrame::Message(message)) } @@ -1267,4 +1457,61 @@ mod tests { let debug_str = format!("{:?}", p); assert!(debug_str.contains("Policy")); } + + #[test] + fn runtime_policy_normalizes_zero_channel_and_task_limits() { + let mut policy = Policy::default(); + policy.receiver_queue_capacity = 0; + policy.max_concurrent_stream_tasks = 0; + + let runtime = RuntimePolicy::from_public(&policy); + + assert_eq!(runtime.receiver_queue_capacity, 1); + assert_eq!(runtime.max_concurrent_stream_tasks, 1); + assert_eq!(policy.receiver_queue_capacity, 0); + assert_eq!(policy.max_concurrent_stream_tasks, 0); + } + + #[test] + fn ping_control_accepts_only_the_current_expected_id() { + let mut control = PingControl { + expected_pong_id: Some(7), + ..PingControl::default() + }; + + assert!(!control.accepts_pong(Some(6))); + assert_eq!(control.expected_pong_id, Some(7)); + assert!(control.accepts_pong(Some(7))); + assert_eq!(control.expected_pong_id, None); + assert!(!control.accepts_pong(Some(7))); + } + + #[test] + fn decode_rejection_classes_are_stable_and_counted() { + assert_eq!( + classify_decode_error(&DecodeError::MalformedEncoding), + DecodeRejectionClass::Malformed + ); + assert_eq!( + classify_decode_error(&DecodeError::AllocationLimit), + DecodeRejectionClass::ResourceLimit + ); + assert_eq!( + classify_decode_error(&DecodeError::DuplicateField), + DecodeRejectionClass::DuplicateField + ); + + let counters = DecodeRejectionCounters::default(); + counters.record(&DecodeError::MalformedEncoding); + counters.record(&DecodeError::DepthLimit); + counters.record(&DecodeError::DuplicateField); + assert_eq!( + counters.snapshot(), + DecodeRejectionCounts { + malformed: 1, + resource_limit: 1, + duplicate_field: 1, + } + ); + } } diff --git a/transport/src/connection_handle.rs b/transport/src/connection_handle.rs index 18176d3..3b1e839 100644 --- a/transport/src/connection_handle.rs +++ b/transport/src/connection_handle.rs @@ -2,22 +2,26 @@ use mtp_common::CommunicationError; use std::net::SocketAddr; use std::sync::{ Arc, - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicU64, Ordering}, }; use tokio::sync::watch; #[derive(Debug)] pub struct ConnectionHandle { + connection_id: u64, closed: AtomicBool, close_tx: watch::Sender>, close_rx: watch::Receiver>, remote_addr: Option, } +static NEXT_CONNECTION_ID: AtomicU64 = AtomicU64::new(1); + impl ConnectionHandle { pub fn new() -> Self { let (close_tx, close_rx) = watch::channel(None); Self { + connection_id: NEXT_CONNECTION_ID.fetch_add(1, Ordering::Relaxed).max(1), closed: AtomicBool::new(false), close_tx, close_rx, @@ -35,6 +39,11 @@ impl ConnectionHandle { self.remote_addr } + /// Stable process-local identifier for authentication-rate-limit scopes. + pub fn connection_id(&self) -> u64 { + self.connection_id + } + pub fn is_open(&self) -> bool { !self.closed.load(Ordering::SeqCst) } diff --git a/transport/src/framing.rs b/transport/src/framing.rs index 42e6831..9fa1d80 100644 --- a/transport/src/framing.rs +++ b/transport/src/framing.rs @@ -1,7 +1,21 @@ use crate::{Policy, TransportSendStream}; -use mtp_codec::CommunicationValue; +use mtp_codec::{CommunicationValue, EncodeLimits}; use mtp_common::CommunicationError; +/// Classifies failures that may be recovered by replacing a persistent +/// application stream. Encoding and frame-size failures are deterministic and +/// must reach the caller without opening more streams. +pub(crate) struct RetryClassifier; + +impl RetryClassifier { + pub(crate) fn retry_persistent_stream(error: &CommunicationError) -> bool { + matches!( + error, + CommunicationError::StreamError | CommunicationError::StreamClosed + ) + } +} + /// Writes the canonical self-framed MTP value used by every transport. /// /// `CommunicationValue` already begins with the four-byte body length. The @@ -12,7 +26,11 @@ pub(crate) async fn write_frame( value: &CommunicationValue, policy: &Policy, ) -> Result<(), CommunicationError> { - let bytes = value.to_bytes().map_err(|_| CommunicationError::Encode)?; + let bytes = value + .to_bytes_with_limits(EncodeLimits::for_transport_message_size( + policy.max_message_size, + )) + .map_err(|_| CommunicationError::Encode)?; if bytes.len() as u64 > policy.max_message_size || bytes.len() as u64 >= policy.close_frame_len as u64 { diff --git a/transport/src/generic_connection.rs b/transport/src/generic_connection.rs index 94fdd3f..23f3fe9 100644 --- a/transport/src/generic_connection.rs +++ b/transport/src/generic_connection.rs @@ -5,21 +5,23 @@ //! wrappers while the framing implementation below is shared by adapters. use crate::{ - Policy, TransportConnection, TransportRecvStream, TransportSendStream, framing::write_frame, + Policy, TransportConnection, TransportRecvStream, TransportSendStream, + connection::{DecodeRejectionCounters, RuntimePolicy, classify_decode_error}, + framing::{RetryClassifier, write_frame}, }; use mtp_codec::{CommunicationValue, DecodeLimits, TypeMap}; use mtp_common::CommunicationError; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; -use tokio::sync::{Mutex, RwLock, Semaphore, mpsc}; -use tokio::time::timeout; +use tokio::sync::{Mutex, Notify, RwLock, Semaphore, mpsc}; +use tokio::time::{Instant, timeout, timeout_at}; #[cfg(feature = "pipes")] use crate::pipe::{PipeReader, PipeWriter}; pub struct GenericSender { connection: C, - policy: Arc, + policy: Arc, persistent: Arc>>, send_lock: Arc>, type_map: Arc>, @@ -39,6 +41,7 @@ impl Clone for GenericSender { impl GenericSender { pub fn new(connection: C, policy: Arc) -> Self { + let policy = Arc::new(RuntimePolicy::from_public(&policy)); Self { connection, policy, @@ -84,20 +87,30 @@ impl GenericSender { if stream.is_none() { *stream = Some(self.open().await?); } - let result = timeout( - self.policy.write_timeout, - write_frame(stream.as_mut().unwrap(), value, &self.policy), - ) - .await - .map_err(|_| CommunicationError::StreamError) - .and_then(|r| r); + let result = match stream.as_mut() { + Some(stream) => timeout( + self.policy.write_timeout, + write_frame(stream, value, &self.policy), + ) + .await + .map_err(|_| CommunicationError::StreamError) + .and_then(|result| result), + None => Err(CommunicationError::StreamError), + }; if result.is_ok() { - return result; + return Ok(()); + } + let error = match result { + Ok(()) => return Ok(()), + Err(error) => error, + }; + if !RetryClassifier::retry_persistent_stream(&error) { + return Err(error); } *stream = None; attempts += 1; if attempts > self.policy.persistent_stream_max_retries { - return result; + return Err(error); } tokio::time::sleep( self.policy.persistent_stream_retry_backoff * attempts as u32, @@ -114,6 +127,7 @@ impl GenericSender { pipe_id: u32, description: &str, ) -> Result, CommunicationError> { + let _send_lock = self.send_lock.lock().await; if self.connection.close_reason().is_some() { return Err(CommunicationError::StreamClosed); } @@ -179,6 +193,8 @@ pub struct GenericReceiver { ping_sender: Arc>>>, max_message_size: Arc, type_map: Arc>, + queue_notify: Arc, + decode_rejections: Arc, _accept_task: Arc>, } @@ -192,6 +208,8 @@ impl Clone for GenericReceiver { ping_sender: self.ping_sender.clone(), max_message_size: self.max_message_size.clone(), type_map: self.type_map.clone(), + queue_notify: self.queue_notify.clone(), + decode_rejections: self.decode_rejections.clone(), _accept_task: self._accept_task.clone(), } } @@ -207,6 +225,7 @@ impl Drop for GenericReceiver { impl GenericReceiver { pub fn new(connection: C, policy: Arc) -> Self { + let policy = Arc::new(RuntimePolicy::from_public(&policy)); let (tx, rx) = mpsc::channel(policy.receiver_queue_capacity); #[cfg(feature = "pipes")] let (pipe_tx, pipe_rx) = mpsc::channel(policy.receiver_queue_capacity); @@ -222,6 +241,10 @@ impl GenericReceiver { 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 queue_notify = Arc::new(Notify::new()); + let task_queue_notify = queue_notify.clone(); + let decode_rejections = Arc::new(DecodeRejectionCounters::default()); + let task_decode_rejections = decode_rejections.clone(); let task_accept_task_tx = tx.clone(); #[cfg(feature = "pipes")] let task_accept_task_pipe_tx = pipe_tx.clone(); @@ -237,8 +260,10 @@ impl GenericReceiver { #[cfg(not(feature = "pipes"))] let cap_full = task_accept_task_tx.capacity() == 0; + let notified = task_queue_notify.notified(); + tokio::pin!(notified); if cap_full { - tokio::time::sleep(std::time::Duration::from_millis(1)).await; + notified.await; continue; } @@ -277,11 +302,12 @@ impl GenericReceiver { let ping_sender = task_ping_sender.clone(); let connection = task_connection.clone(); let type_map = task_type_map.clone(); + let decode_rejections = task_decode_rejections.clone(); tokio::spawn(async move { let _permit = permit; let mut stream = stream; let mut frames = 0usize; - loop { + 'stream: loop { if policy .max_frames_per_stream .is_some_and(|max| frames >= max) @@ -304,7 +330,7 @@ impl GenericReceiver { policy.application_close_code, b"frame header read error", ); - break; + break 'stream; } Err(_) => { break; @@ -314,6 +340,7 @@ impl GenericReceiver { if len == policy.close_frame_len { break; } + let deadline = Instant::now() + policy.read_timeout; let frame_limit = max_message_size.load(Ordering::Relaxed); let body_len = len as usize; let frame_len = match body_len.checked_add(4) { @@ -330,29 +357,25 @@ impl GenericReceiver { connection.close(policy.application_close_code, b"frame too large"); break; } - let target_len = body_len; - let mut body = Vec::new(); - if body.try_reserve(target_len.min(16 * 1024)).is_err() { - tracing::warn!( - target_len, - "MTP receive stream could not reserve frame body" - ); + let mut frame = Vec::new(); + if frame.try_reserve_exact(frame_len).is_err() { + tracing::warn!(frame_len, "MTP receive stream could not reserve frame"); let _ = tx.send(Err(CommunicationError::MessageTooLarge)).await; connection .close(policy.application_close_code, b"frame allocation failed"); break; } - while body.len() < target_len { - let chunk_len = (target_len - body.len()).min(16 * 1024); - let mut chunk = [0u8; 16 * 1024]; - let body_read = tokio::time::timeout( - policy.read_timeout, - stream.read_exact(&mut chunk[..chunk_len]), + frame.extend_from_slice(&len.to_be_bytes()); + frame.resize(frame_len, 0); + let mut body_offset = 4usize; + while body_offset < frame_len { + let chunk_len = (frame_len - body_offset).min(16 * 1024); + let body_read = timeout_at( + deadline, + stream.read_exact(&mut frame[body_offset..body_offset + chunk_len]), ) .await; - if !matches!(&body_read, Ok(Ok(()))) - || body.try_reserve(chunk_len).is_err() - { + if !matches!(&body_read, Ok(Ok(()))) { tracing::warn!( pipe_chunk_len = chunk_len, ?body_read, @@ -361,24 +384,23 @@ impl GenericReceiver { let _ = tx.send(Err(CommunicationError::StreamError)).await; connection .close(policy.application_close_code, b"frame body read error"); - break; + break 'stream; } - body.extend_from_slice(&chunk[..chunk_len]); - } - if body.len() != target_len { - break; + body_offset += chunk_len; } frames += 1; - 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( + let mut message = match CommunicationValue::try_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"); + Err(error) => { + tracing::warn!( + ?error, + class = ?classify_decode_error(&error), + "MTP receive stream rejected by bounded decode" + ); + decode_rejections.record(&error); let _ = tx .send(Err(CommunicationError::ParseCommunicationValue)) .await; @@ -463,6 +485,8 @@ impl GenericReceiver { ping_sender, max_message_size, type_map, + queue_notify, + decode_rejections, _accept_task: Arc::new(accept_task), } } @@ -480,13 +504,23 @@ impl GenericReceiver { pub async fn set_type_map(&self, type_map: &TypeMap) { *self.type_map.write().await = type_map.clone(); } + + /// Return local counts for frames rejected by the structured decoder. + pub fn decode_rejection_counts(&self) -> crate::DecodeRejectionCounts { + self.decode_rejections.snapshot() + } pub async fn receive(&self) -> Result { - self.incoming + let result = self + .incoming .lock() .await .recv() .await - .unwrap_or(Err(CommunicationError::StreamClosed)) + .unwrap_or(Err(CommunicationError::StreamClosed)); + if result.is_ok() { + self.queue_notify.notify_one(); + } + result } #[cfg(feature = "pipes")] @@ -498,7 +532,10 @@ impl GenericReceiver { tokio::select! { msg = incoming.recv() => { match msg { - Some(Ok(val)) => Ok(crate::TransportEvent::Message(val)), + Some(Ok(val)) => { + self.queue_notify.notify_one(); + Ok(crate::TransportEvent::Message(val)) + } Some(Err(e)) => Err(e), None => Err(self .connection @@ -508,7 +545,10 @@ impl GenericReceiver { } pipe = pipes.recv() => { match pipe { - Some(reader) => Ok(crate::TransportEvent::Pipe(reader)), + Some(reader) => { + self.queue_notify.notify_one(); + Ok(crate::TransportEvent::Pipe(reader)) + } None => Err(self .connection .close_reason() @@ -520,12 +560,17 @@ impl GenericReceiver { #[cfg(feature = "pipes")] pub async fn receive_pipe(&self) -> Result, CommunicationError> { - self.pipes + let result = self + .pipes .lock() .await .recv() .await - .ok_or(CommunicationError::StreamClosed) + .ok_or(CommunicationError::StreamClosed); + if result.is_ok() { + self.queue_notify.notify_one(); + } + result } #[cfg(feature = "pipes")] @@ -534,7 +579,10 @@ impl GenericReceiver { ) -> Result>, CommunicationError> { match self.pipes.try_lock() { Ok(mut rx) => match rx.try_recv() { - Ok(reader) => Ok(Some(reader)), + Ok(reader) => { + self.queue_notify.notify_one(); + Ok(Some(reader)) + } Err(mpsc::error::TryRecvError::Empty) => Ok(None), Err(mpsc::error::TryRecvError::Disconnected) => { Err(CommunicationError::StreamClosed) diff --git a/transport/src/lib.rs b/transport/src/lib.rs index 6df5311..15fc78d 100644 --- a/transport/src/lib.rs +++ b/transport/src/lib.rs @@ -11,7 +11,10 @@ pub mod encrypted_pipe; #[cfg(feature = "pipes")] pub mod pipe; -pub use connection::{Policy, Receiver, SendMode, Sender}; +pub use connection::{ + DecodeRejectionClass, DecodeRejectionCounters, DecodeRejectionCounts, Policy, Receiver, + SendMode, Sender, classify_decode_error, +}; pub use generic_connection::{GenericReceiver, GenericSender}; #[cfg(feature = "pipes")] diff --git a/transport/tests/generic_pipe.rs b/transport/tests/generic_pipe.rs index 3b6c355..81a875f 100644 --- a/transport/tests/generic_pipe.rs +++ b/transport/tests/generic_pipe.rs @@ -265,7 +265,7 @@ async fn test_regular_messages_still_work_alongside_pipes() -> Result<(), Box Result<(), Box Result<(), Box> assert_numbered_message(&received, CommunicationType::Ping, 42, &tm); // Host sends a response - let resp = numbered_message(CommunicationType::Pong, 99, &tm); + let resp = numbered_message(CommunicationType::BadRequest, 99, &tm); host_tx.send(&resp).await?; // Client receives it let client_received = client_rx.receive().await?; - assert_numbered_message(&client_received, CommunicationType::Pong, 99, &tm); + assert_numbered_message(&client_received, CommunicationType::BadRequest, 99, &tm); // Close both sides client_tx.close().await; @@ -179,13 +179,13 @@ async fn test_concurrent_messages() -> Result<(), Box> { // Send 3 responses back for i in 0..3u128 { - let msg = numbered_message(CommunicationType::Pong, i * 10, &tm); + let msg = numbered_message(CommunicationType::BadRequest, i * 10, &tm); client_tx.send(&msg).await?; } for i in 0..3u128 { let received = host_rx.receive().await?; - assert_numbered_message(&received, CommunicationType::Pong, i * 10, &tm); + assert_numbered_message(&received, CommunicationType::BadRequest, i * 10, &tm); } client_tx.close().await; @@ -260,11 +260,11 @@ async fn test_drop_receiver_keeps_sender_alive() -> Result<(), Box, -} - -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)) - .ok() - .filter(|value| !value.is_null() && !value.is_undefined()) -} - -fn frame_id(frame: &JsValue) -> Option { - frame_property(frame, "id") - .and_then(|value| value.as_f64()) - .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 { - frame_property(frame, "type").and_then(|value| value.as_string()) -} - -fn route_incoming_frame( - frame: &JsValue, - generation: u32, - on_message: &js_sys::Function, - subscriptions: &Rc>>, - pending_requests: &Rc>>, - expired_requests: &Rc>>, - pending_pings: &Rc>>, - ping_ms: &Rc>>, -) { - let message_type = frame_type(frame); - - 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 = { - 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 - .as_ref() - .zip(message_type.as_ref()) - .map(|(expected, actual)| expected == actual) - .unwrap_or(true); - if type_matches { - let _ = pending.sender.send(Ok(frame.clone())); - } else { - let actual = message_type.clone().unwrap_or_else(|| "unknown".into()); - let _ = pending.sender.send(Err(js_error(format!( - "unexpected response type: expected {}, got {}", - pending.response_type.unwrap_or_else(|| "unknown".into()), - actual - )))); - } - return; - } - if client_pipe::consume_expired_request(expired_requests, request_id) { - return; - } - } - - let _ = on_message.call1(&JsValue::NULL, frame); - - let Some(message_type) = message_type else { - return; - }; - let callbacks: Vec = subscriptions - .borrow() - .iter() - .filter(|(_, (t, _))| t == &message_type) - .map(|(_, (_, cb))| cb.clone()) - .collect(); - for callback in callbacks { - let _ = callback.call1(&JsValue::NULL, frame); - } -} - -fn stop_ping_timer(ping_timer: &Rc>>) { - let Some(timer) = ping_timer.borrow_mut().take() else { - return; - }; - if let Ok(clear_interval) = - js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("clearInterval")) - .and_then(|value| value.dyn_into::()) - { - let _ = clear_interval.call1(&JsValue::NULL, &JsValue::from_f64(timer.id as f64)); - } - drop(timer.closure); -} - -fn reject_pending_requests( - pending_requests: &Rc>>, - message: &str, -) { - let pending = std::mem::take(&mut *pending_requests.borrow_mut()); - for (_, pending) in pending { - let _ = pending.sender.send(Err(js_error(message))); - } -} - -async fn wait_for_timeout(timeout_ms: u32) -> Result<(), JsValue> { - let promise = js_sys::Promise::new(&mut |resolve, reject| { - let result = js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("setTimeout")) - .and_then(|value| value.dyn_into::()) - .and_then(|set_timeout| { - set_timeout.call2( - &JsValue::NULL, - &resolve, - &JsValue::from_f64(timeout_ms as f64), - ) - }); - if let Err(error) = result { - let _ = reject.call1(&JsValue::NULL, &error); - } - }); - wasm_bindgen_futures::JsFuture::from(promise).await?; - Ok(()) -} - -fn set_shared_state( - state: &Rc>, - pending_state_callbacks: &Rc>>, - state_callback: &JsValue, - new_state: ConnectionState, -) { - state.set(new_state); - pending_state_callbacks.borrow_mut().push_back(new_state); - - let global = js_sys::global(); - let qmt = js_sys::Reflect::get(&global, &JsValue::from_str("queueMicrotask")) - .and_then(|f| f.dyn_into::()); - let scheduled = qmt - .and_then(|qmt| qmt.call1(&global, state_callback)) - .is_ok(); - if !scheduled - && js_sys::Reflect::get(&global, &JsValue::from_str("setTimeout")) - .and_then(|f| f.dyn_into::()) - .and_then(|set_timeout| { - set_timeout.call2(&global, state_callback, &JsValue::from_f64(0.0)) - }) - .is_err() - { - pending_state_callbacks.borrow_mut().pop_back(); - } -} - -#[wasm_bindgen] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ConnectionState { - Disconnected = 0, - Connecting = 1, - Connected = 2, - Failed = 3, -} - -#[wasm_bindgen] -pub struct WasmClient { - transport: Rc>>, - attempt_transport: Rc>>, - connection_generation: Rc>, - state: Rc>, - pending_state_callbacks: Rc>>, - state_callback: Closure, - pub(crate) on_message: js_sys::Function, - pub(crate) on_error: js_sys::Function, - subscriptions: Rc>>, - next_subscription_id: Rc>, - pending_requests: Rc>>, - expired_requests: Rc>>, - ping_timer: Rc>>, - pending_pings: Rc>>, - ping_ms: Rc>>, - pending_pipe_creations: client_pipe::PendingPipeCreations, - pending_pipes: client_pipe::PendingPipes, - connection_client_id: Rc>, - on_pipe_request: Rc>>, -} - -#[wasm_bindgen] -impl WasmClient { - #[wasm_bindgen(constructor)] - pub fn new( - on_state_change: Option, - on_message: Option, - on_error: Option, - ) -> Self { - let noop = || js_sys::Function::new_no_args(""); - let on_state_change = on_state_change.unwrap_or_else(noop); - let pending_state_callbacks = Rc::new(RefCell::new(VecDeque::new())); - let callback_queue = pending_state_callbacks.clone(); - let callback = on_state_change.clone(); - let state_callback = Closure::wrap(Box::new(move || { - let state = callback_queue.borrow_mut().pop_front(); - if let Some(state) = state { - let _ = callback.call1(&JsValue::NULL, &JsValue::from(state as u8)); - } - }) 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, - state_callback, - on_message: on_message.unwrap_or_else(noop), - on_error: on_error.unwrap_or_else(noop), - 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)), - } - } - - #[wasm_bindgen] - pub fn is_supported() -> bool { - js_sys::Reflect::has(&js_sys::global(), &JsValue::from_str("WebTransport")).unwrap_or(false) - } - - #[wasm_bindgen(getter)] - pub fn state(&self) -> u8 { - self.state.get() as u8 - } - - #[wasm_bindgen(getter)] - pub fn ping_ms(&self) -> Option { - 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 = match WasmTransport::connect( - &config.url, - config.server_certificate_hashes.clone(), - config.max_message_size, - ) - .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 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_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(()) - } - .await; - if let Err(error) = &result { - self.abort_attempt(&transport, generation); - let _ = error; - } - result - } - - #[wasm_bindgen] - pub async fn auth_connect( - &self, - config: &ConnectionConfig, - host_public_key_bytes: &[u8], - keyring_bytes: &[u8], - client_id: u64, - ) -> Result { - let generation = self.begin_connection(); - - 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 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 = match WasmTransport::connect( - &config.url, - config.server_certificate_hashes.clone(), - config.max_message_size, - ) - .await - { - Ok(transport) => transport, - Err(error) => { - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(error); - } - }; - transport.set_type_map(&tm); - if !self.install_attempt_transport(&transport, generation) { - return Err(js_error("connection attempt superseded")); - } - - 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] - pub async fn auth_register( - &self, - config: &ConnectionConfig, - host_public_key_bytes: &[u8], - keyring_bytes: &[u8], - ) -> Result { - let generation = self.begin_connection(); - - 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 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 = match WasmTransport::connect( - &config.url, - config.server_certificate_hashes.clone(), - config.max_message_size, - ) - .await - { - Ok(transport) => transport, - Err(error) => { - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(error); - } - }; - transport.set_type_map(&tm); - if !self.install_attempt_transport(&transport, generation) { - return Err(js_error("connection attempt superseded")); - } - - 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> { - 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")), - } - } - - #[wasm_bindgen] - pub async fn request( - &self, - frame: Vec, - response_type: Option, - timeout_ms: Option, - ) -> Result { - 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(()); - { - let mut pending = self.pending_requests.borrow_mut(); - if pending.contains_key(&request_id) { - return Err(js_error(format!( - "request id {request_id} is already pending" - ))); - } - pending.insert( - request_id, - PendingRequest { - generation, - token: token.clone(), - response_type, - sender, - }, - ); - } - - let timeout_ms = timeout_ms.unwrap_or(DEFAULT_REQUEST_TIMEOUT_MS); - let response = async { - transport.send_frame(&frame).await?; - match receiver.await { - Ok(result) => result, - Err(_) => Err(js_error("request cancelled")), - } - } - .fuse(); - let timeout = wait_for_timeout(timeout_ms).fuse(); - pin_mut!(response, timeout); - select! { - result = response => { - if result.is_err() { - client_pipe::remove_pending_request(&self.pending_requests, request_id, &token); - } - result - }, - result = timeout => { - 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" - ))) - }, - } - } - - #[wasm_bindgen] - pub fn subscribe(&self, message_type: String, callback: js_sys::Function) -> u32 { - let id = self.next_subscription_id.get(); - self.next_subscription_id.set(id.wrapping_add(1).max(1)); - self.subscriptions - .borrow_mut() - .insert(id, (message_type, callback)); - id - } - - #[wasm_bindgen] - pub fn unsubscribe(&self, id: u32) -> bool { - self.subscriptions.borrow_mut().remove(&id).is_some() - } - - #[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 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| { - 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) => { - 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 { - 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); - } - } - Err(error) => { - let _ = on_error.call1(&JsValue::NULL, &error); - } - } - }); - }) as Box); - - let set_interval = - js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("setInterval"))? - .dyn_into::()?; - let id = set_interval - .call2( - &JsValue::NULL, - closure.as_ref().unchecked_ref(), - &JsValue::from_f64(interval_ms as f64), - )? - .as_f64() - .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(()) - } - - #[wasm_bindgen] - pub fn stop_protocol_pings(&self) { - self.pending_pings.borrow_mut().clear(); - self.ping_ms.set(None); - let Some(timer) = self.ping_timer.borrow_mut().take() else { - return; - }; - if let Ok(clear_interval) = - js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("clearInterval")) - .and_then(|value| value.dyn_into::()) - { - let _ = clear_interval.call1(&JsValue::NULL, &JsValue::from_f64(timer.id as f64)); - } - drop(timer.closure); - } - - #[wasm_bindgen] - pub fn disconnect(&self) { - self.connection_generation - .set(self.connection_generation.get().wrapping_add(1)); - self.stop_protocol_pings(); - 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); - } - - #[wasm_bindgen] - pub fn set_on_pipe_request(&self, callback: Option) { - *self.on_pipe_request.borrow_mut() = callback; - } - - #[wasm_bindgen] - pub async fn create_pipe( - &self, - description: &str, - ) -> 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"))?; - - let pipe_id = client_pipe::random_pipe_id()?; - client_pipe::wasm_create_pipe( - &transport, - 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"))?; - - 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() - .clone() - .ok_or_else(|| js_error("not connected"))?; - - client_pipe::wasm_deny_pipe(&transport, pipe_id).await - } - - fn set_state(&self, new_state: ConnectionState) { - set_shared_state( - &self.state, - &self.pending_state_callbacks, - self.state_callback.as_ref(), - new_state, - ); - } - - fn set_state_if_current(&self, generation: u32, new_state: ConnectionState) { - if self.connection_generation.get() == generation { - self.set_state(new_state); - } - } - - 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); - self.stop_protocol_pings(); - 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, - 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); - - let connection_generation = self.connection_generation.clone(); - let error_generation = connection_generation.clone(); - let state = self.state.clone(); - let pending_state_callbacks = self.pending_state_callbacks.clone(); - let state_callback = self.state_callback.as_ref().clone(); - let on_msg = self.on_message.clone(); - let on_err = self.on_error.clone(); - 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(); - let ping_ms = self.ping_ms.clone(); - 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 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( - &data, - &JsValue::from_str("Description"), - ) - .ok()?; - desc.as_string() - }) - .unwrap_or_default(); - - let cb = on_pipe_request.borrow(); - if let Some(ref callback) = *cb { - let obj = js_sys::Object::new(); - let _ = js_sys::Reflect::set( - &obj, - &"pipeId".into(), - &JsValue::from_f64(pipe_id as f64), - ); - let _ = js_sys::Reflect::set( - &obj, - &"description".into(), - &JsValue::from_str(&description), - ); - let _ = callback.call1(&JsValue::NULL, &obj.into()); - } - return; - } - - if msg_type == "PipeResponse" { - 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( - &data, - &JsValue::from_str("Accepted"), - ) - .ok()?; - acc.as_bool() - }) - .unwrap_or(false); - - let mut pending = loop_pipe_creations.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(accepted)); - } - return; - } - } - - route_incoming_frame( - &frame, - loop_generation, - &on_msg, - &subscriptions, - &loop_pending_requests, - &loop_expired_requests, - &loop_pending_pings, - &loop_ping_ms, - ); - }, - move |error| { - if error_generation.get() == generation { - let _ = on_err.call1(&JsValue::NULL, &error); - } - }, - move |pipe_reader: PipeReader| { - let pipe_id = pipe_reader.pipe_id(); - 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)); - } - }, - ) - .await; - 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, - &state_callback, - ConnectionState::Disconnected, - ); - stop_ping_timer(&ping_timer); - 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, - tm: &mtp_codec::TypeMap, - host_pk: &mtp_crypto::PublicKeyBundle, - bound_id: u64, - context: &str, - require_pq: bool, - client_has_pq_key: bool, - generation: u32, - ) -> Result { - let challenge_bytes = transport.read_one_frame().await?; - 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) - .ok_or_else(|| js_error("Challenge is absent from the type map"))?; - if challenge.get_type() != expected { - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(auth::unexpected_response_type_error( - context, - expected, - challenge.get_type(), - &challenge_bytes, - &challenge, - )); - } - - let server_challenge = match challenge.get_data(DataType::ServerNonce) { - 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) == 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", - )); - } - - if let Err(e) = auth::verify_host_challenge( - &challenge, - tm, - host_pk, - bound_id, - server_challenge, - require_pq, - ) { - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(e); - } - - Ok(server_challenge) - } -} diff --git a/wasm/src/client/authentication.rs b/wasm/src/client/authentication.rs new file mode 100644 index 0000000..45cec8d --- /dev/null +++ b/wasm/src/client/authentication.rs @@ -0,0 +1,630 @@ +use wasm_bindgen::prelude::*; + +use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue, PROTOCOL_VERSION}; + +use crate::auth; +use crate::client::{ConnectionState, WasmClient}; +use crate::config::ConnectionConfig; +use crate::error::js_error; +use crate::transport::WasmTransport; + +#[wasm_bindgen] +#[allow(deprecated)] +impl WasmClient { + pub async fn connect(&self, config: &ConnectionConfig) -> Result<(), JsValue> { + self.connect_owned(config.clone()).await + } + + #[wasm_bindgen(js_name = connectOwned)] + pub async fn connect_owned(&self, config: ConnectionConfig) -> Result<(), JsValue> { + let generation = self.begin_connection(); + let transport = match WasmTransport::connect_with_limits( + &config.url, + config.server_certificate_hashes.clone(), + config.max_message_size, + self.receive_decode_limits(), + ) + .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 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::try_from_bytes_with_type_map_and_limits( + &outcome_bytes, + opening_codec.type_map(), + transport.decode_limits(), + ) + .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::try_from_bytes_with_type_map_and_limits( + &outcome_bytes, + codec.type_map(), + transport.decode_limits(), + ) + .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(()) + } + .await; + if let Err(error) = &result { + self.abort_attempt(&transport, generation); + let _ = error; + } + result + } + + #[wasm_bindgen] + #[deprecated( + note = "use the SDK authentication methods; this raw method remains for compatibility" + )] + pub async fn auth_connect( + &self, + config: &ConnectionConfig, + host_public_key_bytes: &[u8], + keyring_bytes: &[u8], + client_id: u64, + ) -> Result { + self.auth_connect_owned( + config.clone(), + host_public_key_bytes.to_vec(), + keyring_bytes.to_vec(), + client_id, + ) + .await + } + + #[wasm_bindgen(js_name = authConnectOwned)] + pub async fn auth_connect_owned( + &self, + config: ConnectionConfig, + host_public_key_bytes: Vec, + keyring_bytes: Vec, + client_id: u64, + ) -> Result { + let generation = self.begin_connection(); + + 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 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 public_key_bytes = keyring + .public_key_bundle() + .try_as_bytes() + .map_err(|error| js_error(format!("public key serialization failed: {error}")))?; + + let transport = match WasmTransport::connect_with_limits( + &config.url, + config.server_certificate_hashes.clone(), + config.max_message_size, + self.receive_decode_limits(), + ) + .await + { + Ok(transport) => transport, + Err(error) => { + self.set_state_if_current(generation, ConnectionState::Disconnected); + return Err(error); + } + }; + transport.set_type_map(&tm); + if !self.install_attempt_transport(&transport, generation) { + return Err(js_error("connection attempt superseded")); + } + + 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(public_key_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, + 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::try_from_bytes_with_type_map_and_limits( + &response, + &tm, + transport.decode_limits(), + ) + .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::try_from_bytes_with_type_map_and_limits( + &response, + codec.type_map(), + transport.decode_limits(), + ) + .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] + #[deprecated( + note = "use the SDK registration methods; this raw method remains for compatibility" + )] + pub async fn auth_register( + &self, + config: &ConnectionConfig, + host_public_key_bytes: &[u8], + keyring_bytes: &[u8], + ) -> Result { + self.auth_register_owned( + config.clone(), + host_public_key_bytes.to_vec(), + keyring_bytes.to_vec(), + ) + .await + } + + #[wasm_bindgen(js_name = authRegisterOwned)] + pub async fn auth_register_owned( + &self, + config: ConnectionConfig, + host_public_key_bytes: Vec, + keyring_bytes: Vec, + ) -> Result { + let generation = self.begin_connection(); + + 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 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() + .try_as_bytes() + .map_err(|error| js_error(format!("public key serialization failed: {error}")))?; + + let transport = match WasmTransport::connect_with_limits( + &config.url, + config.server_certificate_hashes.clone(), + config.max_message_size, + self.receive_decode_limits(), + ) + .await + { + Ok(transport) => transport, + Err(error) => { + self.set_state_if_current(generation, ConnectionState::Disconnected); + return Err(error); + } + }; + transport.set_type_map(&tm); + if !self.install_attempt_transport(&transport, generation) { + return Err(js_error("connection attempt superseded")); + } + + 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::try_from_bytes_with_type_map_and_limits( + &response, + &tm, + transport.decode_limits(), + ) + .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::try_from_bytes_with_type_map_and_limits( + &response, + codec.type_map(), + transport.decode_limits(), + ) + .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 + } + + async fn read_verified_challenge( + &self, + transport: &WasmTransport, + tm: &mtp_codec::TypeMap, + host_pk: &mtp_crypto::PublicKeyBundle, + bound_id: u64, + context: &str, + require_pq: bool, + client_has_pq_key: bool, + generation: u32, + ) -> Result { + let challenge_bytes = transport.read_one_frame().await?; + let challenge = CommunicationValue::try_from_bytes_with_type_map_and_limits( + &challenge_bytes, + tm, + transport.decode_limits(), + ) + .map_err(|e| js_error(format!("parse challenge: {}", e)))?; + let expected = CommunicationType::Challenge + .try_to_id(tm) + .ok_or_else(|| js_error("Challenge is absent from the type map"))?; + if challenge.get_type() != expected { + self.set_state_if_current(generation, ConnectionState::Disconnected); + return Err(auth::unexpected_response_type_error( + context, + expected, + challenge.get_type(), + &challenge_bytes, + &challenge, + )); + } + + let server_challenge = match challenge.get_data(DataType::ServerNonce) { + 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) == 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", + )); + } + + if let Err(e) = auth::verify_host_challenge( + &challenge, + tm, + host_pk, + bound_id, + server_challenge, + require_pq, + ) { + self.set_state_if_current(generation, ConnectionState::Disconnected); + return Err(e); + } + + Ok(server_challenge) + } +} diff --git a/wasm/src/client/connection.rs b/wasm/src/client/connection.rs new file mode 100644 index 0000000..261e3f9 --- /dev/null +++ b/wasm/src/client/connection.rs @@ -0,0 +1,102 @@ +use wasm_bindgen::prelude::*; + +use crate::client::{ConnectionState, WasmClient}; +use crate::client_pipe; +use crate::transport::WasmTransport; + +use super::dispatch::set_shared_state; + +#[wasm_bindgen] +impl WasmClient { + pub fn disconnect(&self) { + self.connection_generation + .set(self.connection_generation.get().wrapping_add(1)); + self.stop_protocol_pings(); + 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"); + self.expired_pipe_creations.borrow_mut().clear(); + client_pipe::reject_pending_pipes(&self.pending_pipes, "disconnected"); + self.connection_client_id.set(0); + self.set_state(ConnectionState::Disconnected); + } + + pub(super) fn set_state(&self, new_state: ConnectionState) { + set_shared_state( + &self.state, + &self.pending_state_callbacks, + self.state_callback.as_ref(), + new_state, + ); + } + + pub(super) fn set_state_if_current(&self, generation: u32, new_state: ConnectionState) { + if self.connection_generation.get() == generation { + self.set_state(new_state); + } + } + + pub(super) 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 + } + + pub(super) 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", + ); + self.expired_pipe_creations.borrow_mut().clear(); + client_pipe::reject_pending_pipes(&self.pending_pipes, "connection failed"); + self.connection_client_id.set(0); + self.set_state(ConnectionState::Disconnected); + } + + pub(super) fn begin_connection(&self) -> u32 { + let generation = self.connection_generation.get().wrapping_add(1); + self.connection_generation.set(generation); + self.stop_protocol_pings(); + 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", + ); + self.expired_pipe_creations.borrow_mut().clear(); + client_pipe::reject_pending_pipes(&self.pending_pipes, "connection replaced"); + self.connection_client_id.set(0); + self.set_state(ConnectionState::Connecting); + generation + } +} diff --git a/wasm/src/client/dispatch.rs b/wasm/src/client/dispatch.rs new file mode 100644 index 0000000..92a8cc1 --- /dev/null +++ b/wasm/src/client/dispatch.rs @@ -0,0 +1,184 @@ +use std::cell::{Cell, RefCell}; +use std::collections::{HashMap, VecDeque}; +use std::rc::Rc; + +use wasm_bindgen::prelude::*; + +use crate::client::ConnectionState; +use crate::client_pipe::{self, PendingRequest}; + +pub(super) struct PingTimer { + pub(super) id: i32, + pub(super) closure: Closure, +} + +pub(super) struct PendingPing { + pub(super) generation: u32, + pub(super) sent_at: f64, +} + +pub(super) fn frame_property(frame: &JsValue, key: &str) -> Option { + js_sys::Reflect::get(frame, &JsValue::from_str(key)) + .ok() + .filter(|value| !value.is_null() && !value.is_undefined()) +} + +pub(super) fn frame_id(frame: &JsValue) -> Option { + frame_property(frame, "id") + .and_then(|value| value.as_f64()) + .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()) +} + +pub(super) fn frame_type(frame: &JsValue) -> Option { + frame_property(frame, "type").and_then(|value| value.as_string()) +} + +pub(super) fn route_incoming_frame( + frame: &JsValue, + generation: u32, + on_message: &js_sys::Function, + subscriptions: &Rc>>, + pending_requests: &Rc>>, + expired_requests: &Rc>>, + pending_pings: &Rc>>, + ping_ms: &Rc>>, +) { + let message_type = frame_type(frame); + + 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 = { + 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 + .as_ref() + .zip(message_type.as_ref()) + .map(|(expected, actual)| expected == actual) + .unwrap_or(true); + if type_matches { + let _ = pending.sender.send(Ok(frame.clone())); + } else { + let actual = message_type.clone().unwrap_or_else(|| "unknown".into()); + let _ = pending.sender.send(Err(crate::error::js_error(format!( + "unexpected response type: expected {}, got {}", + pending.response_type.unwrap_or_else(|| "unknown".into()), + actual + )))); + } + return; + } + if client_pipe::consume_expired_request(expired_requests, request_id) { + return; + } + } + + let _ = on_message.call1(&JsValue::NULL, frame); + let Some(message_type) = message_type else { + return; + }; + let callbacks: Vec = subscriptions + .borrow() + .iter() + .filter(|(_, (t, _))| t == &message_type) + .map(|(_, (_, cb))| cb.clone()) + .collect(); + for callback in callbacks { + let _ = callback.call1(&JsValue::NULL, frame); + } +} + +pub(super) fn stop_ping_timer(ping_timer: &Rc>>) { + let Some(timer) = ping_timer.borrow_mut().take() else { + return; + }; + if let Ok(clear_interval) = + js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("clearInterval")) + .and_then(|value| value.dyn_into::()) + { + let _ = clear_interval.call1(&JsValue::NULL, &JsValue::from_f64(timer.id as f64)); + } + drop(timer.closure); +} + +pub(super) fn reject_pending_requests( + pending_requests: &Rc>>, + message: &str, +) { + let pending = std::mem::take(&mut *pending_requests.borrow_mut()); + for (_, pending) in pending { + let _ = pending.sender.send(Err(crate::error::js_error(message))); + } +} + +pub(super) async fn wait_for_timeout(timeout_ms: u32) -> Result<(), JsValue> { + let promise = js_sys::Promise::new(&mut |resolve, reject| { + let result = js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("setTimeout")) + .and_then(|value| value.dyn_into::()) + .and_then(|set_timeout| { + set_timeout.call2( + &JsValue::NULL, + &resolve, + &JsValue::from_f64(timeout_ms as f64), + ) + }); + if let Err(error) = result { + let _ = reject.call1(&JsValue::NULL, &error); + } + }); + wasm_bindgen_futures::JsFuture::from(promise).await?; + Ok(()) +} + +pub(super) fn set_shared_state( + state: &Rc>, + pending_state_callbacks: &Rc>>, + state_callback: &JsValue, + new_state: ConnectionState, +) { + state.set(new_state); + pending_state_callbacks.borrow_mut().push_back(new_state); + + let global = js_sys::global(); + let qmt = js_sys::Reflect::get(&global, &JsValue::from_str("queueMicrotask")) + .and_then(|f| f.dyn_into::()); + let scheduled = qmt + .and_then(|qmt| qmt.call1(&global, state_callback)) + .is_ok(); + if !scheduled + && js_sys::Reflect::get(&global, &JsValue::from_str("setTimeout")) + .and_then(|f| f.dyn_into::()) + .and_then(|set_timeout| { + set_timeout.call2(&global, state_callback, &JsValue::from_f64(0.0)) + }) + .is_err() + { + pending_state_callbacks.borrow_mut().pop_back(); + } +} diff --git a/wasm/src/client/mod.rs b/wasm/src/client/mod.rs new file mode 100644 index 0000000..a0bcd3a --- /dev/null +++ b/wasm/src/client/mod.rs @@ -0,0 +1,299 @@ +// WASM client facade. Lifecycle, authentication, receive dispatch, and pipes +// live in private child modules below. +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; +use std::collections::VecDeque; +use std::rc::Rc; + +use futures_channel::oneshot; +use futures_util::{FutureExt, pin_mut, select}; +use wasm_bindgen::prelude::*; + +use mtp_codec::{CommunicationValue, DecodeLimits, EncodeLimits}; + +use crate::client_pipe::{self, PendingRequest}; +use crate::error::js_error; +use crate::transport::WasmTransport; + +mod authentication; +mod connection; +mod dispatch; +mod pipes; +mod receive; +use dispatch::{PendingPing, PingTimer, wait_for_timeout}; + +const DEFAULT_REQUEST_TIMEOUT_MS: u32 = 30_000; +const MAX_SAFE_JS_INTEGER: f64 = 9_007_199_254_740_991.0; + +fn decode_limit(value: &JsValue, key: &str, default: usize) -> Result { + if value.is_null() || value.is_undefined() { + return Ok(default); + } + let value = js_sys::Reflect::get(value, &JsValue::from_str(key))?; + if value.is_null() || value.is_undefined() { + return Ok(default); + } + let Some(number) = value.as_f64() else { + return Err(js_error(format!("{key} must be a number"))); + }; + if !number.is_finite() || number.fract() != 0.0 || number < 0.0 || number > MAX_SAFE_JS_INTEGER + { + return Err(js_error(format!("{key} must be a non-negative integer"))); + } + usize::try_from(number as u64).map_err(|_| js_error(format!("{key} is out of range"))) +} + +pub(crate) fn encode_limits_from_js(value: &JsValue) -> Result { + let defaults = EncodeLimits::default(); + Ok(EncodeLimits { + max_depth: decode_limit(value, "maxDepth", defaults.max_depth)?, + max_values: decode_limit(value, "maxValues", defaults.max_values)?, + max_output_size: decode_limit(value, "maxOutputSize", defaults.max_output_size)?, + }) +} + +pub(crate) fn decode_limits_from_js(value: &JsValue) -> Result { + let defaults = DecodeLimits::default(); + Ok(DecodeLimits { + max_depth: decode_limit(value, "maxDepth", defaults.max_depth)?, + max_values: decode_limit(value, "maxValues", defaults.max_values)?, + max_blob_size: decode_limit(value, "maxBlobSize", defaults.max_blob_size)?, + max_recipients: decode_limit(value, "maxRecipients", defaults.max_recipients)?, + max_allocated_bytes: decode_limit( + value, + "maxAllocatedBytes", + defaults.max_allocated_bytes, + )?, + }) +} + +#[wasm_bindgen] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConnectionState { + Disconnected = 0, + Connecting = 1, + Connected = 2, + Failed = 3, +} + +#[wasm_bindgen] +pub struct WasmClient { + transport: Rc>>, + attempt_transport: Rc>>, + connection_generation: Rc>, + state: Rc>, + pending_state_callbacks: Rc>>, + state_callback: Closure, + pub(crate) on_message: js_sys::Function, + pub(crate) on_error: js_sys::Function, + subscriptions: Rc>>, + next_subscription_id: Rc>, + pending_requests: Rc>>, + expired_requests: Rc>>, + ping_timer: Rc>>, + pending_pings: Rc>>, + ping_ms: Rc>>, + pending_pipe_creations: client_pipe::PendingPipeCreations, + expired_pipe_creations: Rc>>, + pending_pipes: client_pipe::PendingPipes, + connection_client_id: Rc>, + on_pipe_request: Rc>>, + receive_decode_limits: Rc>>, +} + +#[wasm_bindgen] +#[allow(deprecated)] +impl WasmClient { + #[wasm_bindgen(constructor)] + pub fn new( + on_state_change: Option, + on_message: Option, + on_error: Option, + ) -> Self { + let noop = || js_sys::Function::new_no_args(""); + let on_state_change = on_state_change.unwrap_or_else(noop); + let pending_state_callbacks = Rc::new(RefCell::new(VecDeque::new())); + let callback_queue = pending_state_callbacks.clone(); + let callback = on_state_change.clone(); + let state_callback = Closure::wrap(Box::new(move || { + let state = callback_queue.borrow_mut().pop_front(); + if let Some(state) = state { + let _ = callback.call1(&JsValue::NULL, &JsValue::from(state as u8)); + } + }) 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, + state_callback, + on_message: on_message.unwrap_or_else(noop), + on_error: on_error.unwrap_or_else(noop), + 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())), + expired_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)), + receive_decode_limits: Rc::new(RefCell::new(None)), + } + } + pub fn is_supported() -> bool { + js_sys::Reflect::has(&js_sys::global(), &JsValue::from_str("WebTransport")).unwrap_or(false) + } + + #[wasm_bindgen(getter)] + pub fn state(&self) -> u8 { + self.state.get() as u8 + } + + #[wasm_bindgen(getter)] + pub fn ping_ms(&self) -> Option { + self.ping_ms.get() + } + + #[wasm_bindgen(getter)] + pub fn client_id(&self) -> u64 { + self.connection_client_id.get() + } + + /// Apply one decoder policy to frames received by this raw WASM client. + /// The high-level SDK calls this before authentication so handshake, + /// transport, and protected opening share the same policy input. + #[wasm_bindgen] + pub fn set_receive_limits(&self, limits: JsValue) -> Result<(), JsValue> { + let parsed = if limits.is_null() || limits.is_undefined() { + None + } else { + Some(decode_limits_from_js(&limits)?) + }; + *self.receive_decode_limits.borrow_mut() = parsed; + Ok(()) + } + + pub(super) fn receive_decode_limits(&self) -> Option { + *self.receive_decode_limits.borrow() + } + + #[wasm_bindgen] + #[deprecated( + note = "use the SDK connection methods; this raw method remains for compatibility" + )] + pub async fn send(&self, frame: Vec) -> Result<(), JsValue> { + 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")), + } + } + + #[wasm_bindgen] + pub async fn request( + &self, + frame: Vec, + response_type: Option, + timeout_ms: Option, + ) -> Result { + 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::try_from_bytes_with_type_map_and_limits( + &frame, + &transport.type_map(), + transport.decode_limits(), + ) + .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(()); + { + let mut pending = self.pending_requests.borrow_mut(); + if pending.contains_key(&request_id) { + return Err(js_error(format!( + "request id {request_id} is already pending" + ))); + } + pending.insert( + request_id, + PendingRequest { + generation, + token: token.clone(), + response_type, + sender, + }, + ); + } + + let timeout_ms = timeout_ms.unwrap_or(DEFAULT_REQUEST_TIMEOUT_MS); + let response = async { + transport.send_frame(&frame).await?; + match receiver.await { + Ok(result) => result, + Err(_) => Err(js_error("request cancelled")), + } + } + .fuse(); + let timeout = wait_for_timeout(timeout_ms).fuse(); + pin_mut!(response, timeout); + select! { + result = response => { + if result.is_err() { + client_pipe::remove_pending_request(&self.pending_requests, request_id, &token); + } + result + }, + result = timeout => { + 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" + ))) + }, + } + } + + #[wasm_bindgen] + pub fn subscribe(&self, message_type: String, callback: js_sys::Function) -> u32 { + let id = self.next_subscription_id.get(); + self.next_subscription_id.set(id.wrapping_add(1).max(1)); + self.subscriptions + .borrow_mut() + .insert(id, (message_type, callback)); + id + } + + #[wasm_bindgen] + pub fn unsubscribe(&self, id: u32) -> bool { + self.subscriptions.borrow_mut().remove(&id).is_some() + } +} diff --git a/wasm/src/client/pipes.rs b/wasm/src/client/pipes.rs new file mode 100644 index 0000000..2948e49 --- /dev/null +++ b/wasm/src/client/pipes.rs @@ -0,0 +1,76 @@ +use wasm_bindgen::prelude::*; + +use crate::client::{ConnectionState, WasmClient}; +use crate::client_pipe; +use crate::error::js_error; +use crate::pipe::PipeReader; + +#[wasm_bindgen] +impl WasmClient { + pub fn set_on_pipe_request(&self, callback: Option) { + *self.on_pipe_request.borrow_mut() = callback; + } + + #[wasm_bindgen] + pub async fn create_pipe( + &self, + description: &str, + ) -> 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"))?; + + let pipe_id = client_pipe::random_pipe_id()?; + client_pipe::wasm_create_pipe( + &transport, + description, + pipe_id, + &self.pending_pipe_creations, + &self.expired_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"))?; + + 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() + .clone() + .ok_or_else(|| js_error("not connected"))?; + + client_pipe::wasm_deny_pipe(&transport, pipe_id).await + } +} diff --git a/wasm/src/client/receive.rs b/wasm/src/client/receive.rs new file mode 100644 index 0000000..35ceb02 --- /dev/null +++ b/wasm/src/client/receive.rs @@ -0,0 +1,328 @@ +use wasm_bindgen::prelude::*; + +use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue}; + +use crate::client::{ConnectionState, WasmClient}; +use crate::client_pipe; +use crate::error::js_error; +use crate::pipe::PipeReader; +use crate::transport::WasmTransport; + +use super::MAX_SAFE_JS_INTEGER; +use super::dispatch::{ + PendingPing, PingTimer, frame_id, frame_property, frame_type, reject_pending_requests, + route_incoming_frame, set_shared_state, stop_ping_timer, +}; + +#[wasm_bindgen] +impl WasmClient { + 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 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| { + 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) => { + 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 { + 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); + } + } + Err(error) => { + let _ = on_error.call1(&JsValue::NULL, &error); + } + } + }); + }) as Box); + + let set_interval = + js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("setInterval"))? + .dyn_into::()?; + let id = set_interval + .call2( + &JsValue::NULL, + closure.as_ref().unchecked_ref(), + &JsValue::from_f64(interval_ms as f64), + )? + .as_f64() + .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(()) + } + + #[wasm_bindgen] + pub fn stop_protocol_pings(&self) { + self.pending_pings.borrow_mut().clear(); + self.ping_ms.set(None); + let Some(timer) = self.ping_timer.borrow_mut().take() else { + return; + }; + if let Ok(clear_interval) = + js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("clearInterval")) + .and_then(|value| value.dyn_into::()) + { + let _ = clear_interval.call1(&JsValue::NULL, &JsValue::from_f64(timer.id as f64)); + } + drop(timer.closure); + } + + pub(super) 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); + + let connection_generation = self.connection_generation.clone(); + let error_generation = connection_generation.clone(); + let state = self.state.clone(); + let pending_state_callbacks = self.pending_state_callbacks.clone(); + let state_callback = self.state_callback.as_ref().clone(); + let on_msg = self.on_message.clone(); + let on_err = self.on_error.clone(); + 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(); + let ping_ms = self.ping_ms.clone(); + let loop_ping_ms = ping_ms.clone(); + let pending_pipe_creations = self.pending_pipe_creations.clone(); + let expired_pipe_creations = self.expired_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_expired_pipe_creations = expired_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 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( + &data, + &JsValue::from_str("Description"), + ) + .ok()?; + desc.as_string() + }) + .unwrap_or_default(); + + let cb = on_pipe_request.borrow(); + if let Some(ref callback) = *cb { + let obj = js_sys::Object::new(); + let _ = js_sys::Reflect::set( + &obj, + &"pipeId".into(), + &JsValue::from_f64(pipe_id as f64), + ); + let _ = js_sys::Reflect::set( + &obj, + &"description".into(), + &JsValue::from_str(&description), + ); + let _ = callback.call1(&JsValue::NULL, &obj.into()); + } + return; + } + + if msg_type == "PipeResponse" { + 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( + &data, + &JsValue::from_str("Accepted"), + ) + .ok()?; + acc.as_bool() + }) + .unwrap_or(false); + + let pending = { + let mut pending = loop_pipe_creations.borrow_mut(); + if pending + .get(&pipe_id) + .is_some_and(|entry| entry.generation == loop_generation) + { + pending.remove(&pipe_id) + } else { + None + } + }; + if let Some(entry) = pending { + let _ = entry.sender.send(Ok(accepted)); + } else { + let _ = client_pipe::consume_expired_pipe_creation( + &loop_expired_pipe_creations, + pipe_id, + ); + } + return; + } + } + + route_incoming_frame( + &frame, + loop_generation, + &on_msg, + &subscriptions, + &loop_pending_requests, + &loop_expired_requests, + &loop_pending_pings, + &loop_ping_ms, + ); + }, + move |error| { + if error_generation.get() == generation { + let _ = on_err.call1(&JsValue::NULL, &error); + } + }, + move |pipe_reader: PipeReader| { + let pipe_id = pipe_reader.pipe_id(); + 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)); + } + }, + ) + .await; + 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, + &state_callback, + ConnectionState::Disconnected, + ); + stop_ping_timer(&ping_timer); + 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"); + expired_pipe_creations.borrow_mut().clear(); + client_pipe::reject_pending_pipes(&pending_pipes, "disconnected"); + connection_client_id.set(0); + }); + self.connection_client_id.set(client_id); + true + } + + pub(super) fn reject_pending_requests(&self, message: &str) { + reject_pending_requests(&self.pending_requests, message); + self.expired_requests.borrow_mut().clear(); + } +} diff --git a/wasm/src/client_pipe.rs b/wasm/src/client_pipe.rs index 1e0ea5a..3c08863 100644 --- a/wasm/src/client_pipe.rs +++ b/wasm/src/client_pipe.rs @@ -3,8 +3,10 @@ use std::collections::HashMap; use std::rc::Rc; use futures_channel::oneshot; +use futures_util::{FutureExt, pin_mut, select}; use tracing::debug; use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::JsFuture; use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue}; @@ -21,12 +23,17 @@ pub(crate) struct PendingRequest { pub(crate) struct PendingPipeCreation { pub(crate) generation: u32, + pub(crate) token: Rc<()>, pub(crate) sender: oneshot::Sender>, } pub(crate) type PendingPipeCreations = Rc>>; type PipeResponseReceiver = oneshot::Receiver>; type PipeResponseCell = Rc>>; +const DEFAULT_PIPE_CREATION_TIMEOUT_MS: u32 = 30_000; +const EXPIRED_PIPE_CREATION_TOMBSTONE_TTL_MS: f64 = 60_000.0; +const MAX_EXPIRED_PIPE_CREATION_TOMBSTONES: usize = 1024; + pub(crate) struct PendingPipe { pub(crate) generation: u32, pub(crate) sender: oneshot::Sender>, @@ -114,6 +121,10 @@ pub struct WasmPipeHandle { description: String, transport: WasmTransport, response_rx: PipeResponseCell, + pending: PendingPipeCreations, + expired: Rc>>, + generation: u32, + token: Rc<()>, } #[wasm_bindgen] @@ -125,9 +136,37 @@ impl WasmPipeHandle { .take() .ok_or_else(|| js_error("handle already consumed"))?; - let accepted = rx - .await - .map_err(|_| js_error("pipe handle channel closed"))?; + let response = rx.fuse(); + let timeout = wait_for_timeout(DEFAULT_PIPE_CREATION_TIMEOUT_MS).fuse(); + pin_mut!(response, timeout); + let accepted = select! { + result = response => match result { + Ok(result) => result, + Err(_) => { + expire_pending_pipe_creation( + &self.pending, + &self.expired, + self.pipe_id, + self.generation, + &self.token, + ); + return Err(js_error("pipe handle channel closed")); + } + }, + result = timeout => { + result?; + expire_pending_pipe_creation( + &self.pending, + &self.expired, + self.pipe_id, + self.generation, + &self.token, + ); + return Err(js_error(format!( + "pipe creation timed out after {DEFAULT_PIPE_CREATION_TIMEOUT_MS}ms" + ))); + }, + }; match accepted { Ok(true) => { @@ -153,6 +192,18 @@ impl WasmPipeHandle { } } +impl Drop for WasmPipeHandle { + fn drop(&mut self) { + expire_pending_pipe_creation( + &self.pending, + &self.expired, + self.pipe_id, + self.generation, + &self.token, + ); + } +} + pub(crate) fn random_pipe_id() -> Result { let mut bytes = [0u8; 4]; getrandom_v04::fill(&mut bytes).map_err(|_| js_error("rng failed"))?; @@ -166,6 +217,146 @@ pub(crate) fn reject_pending_pipe_creations(pending: &PendingPipeCreations, mess } } +async fn wait_for_timeout(timeout_ms: u32) -> Result<(), JsValue> { + let promise = js_sys::Promise::new(&mut |resolve, reject| { + let result = js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("setTimeout")) + .and_then(|value| value.dyn_into::()) + .and_then(|set_timeout| { + set_timeout.call2( + &JsValue::NULL, + &resolve, + &JsValue::from_f64(timeout_ms as f64), + ) + }); + if let Err(error) = result { + let _ = reject.call1(&JsValue::NULL, &error); + } + }); + JsFuture::from(promise).await?; + Ok(()) +} + +fn expire_pending_pipe_creation( + pending: &PendingPipeCreations, + expired: &Rc>>, + pipe_id: u32, + generation: u32, + token: &Rc<()>, +) { + let removed = { + let mut pending = pending.borrow_mut(); + if pending + .get(&pipe_id) + .is_some_and(|entry| entry.generation == generation && Rc::ptr_eq(&entry.token, token)) + { + pending.remove(&pipe_id); + true + } else { + false + } + }; + if !removed { + return; + } + let now = js_sys::Date::now(); + let mut expired = expired.borrow_mut(); + expired.retain(|_, expires_at| *expires_at > now); + if expired.len() >= MAX_EXPIRED_PIPE_CREATION_TOMBSTONES + && let Some(oldest) = expired + .iter() + .min_by(|(_, left), (_, right)| left.total_cmp(right)) + .map(|(id, _)| *id) + { + expired.remove(&oldest); + } + expired.insert(pipe_id, now + EXPIRED_PIPE_CREATION_TOMBSTONE_TTL_MS); +} + +struct PendingPipeCreationGuard { + pending: PendingPipeCreations, + expired: Rc>>, + pipe_id: u32, + generation: u32, + token: Rc<()>, + armed: bool, +} + +impl PendingPipeCreationGuard { + fn new( + pending: PendingPipeCreations, + expired: Rc>>, + pipe_id: u32, + generation: u32, + token: Rc<()>, + ) -> Self { + Self { + pending, + expired, + pipe_id, + generation, + token, + armed: true, + } + } + + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for PendingPipeCreationGuard { + fn drop(&mut self) { + if self.armed { + expire_pending_pipe_creation( + &self.pending, + &self.expired, + self.pipe_id, + self.generation, + &self.token, + ); + } + } +} + +struct PendingPipeGuard { + pending: PendingPipes, + pipe_id: u32, + generation: u32, +} + +impl PendingPipeGuard { + fn new(pending: PendingPipes, pipe_id: u32, generation: u32) -> Self { + Self { + pending, + pipe_id, + generation, + } + } +} + +impl Drop for PendingPipeGuard { + fn drop(&mut self) { + remove_pending_pipe(&self.pending, self.pipe_id, self.generation); + } +} + +pub(crate) fn consume_expired_pipe_creation( + expired: &Rc>>, + pipe_id: u32, +) -> bool { + let now = js_sys::Date::now(); + let mut expired = expired.borrow_mut(); + expired.retain(|_, expires_at| *expires_at > now); + expired.remove(&pipe_id).is_some() +} + +fn is_expired_pipe_creation(expired: &Rc>>, pipe_id: u32) -> bool { + let now = js_sys::Date::now(); + let mut expired = expired.borrow_mut(); + expired.retain(|_, expires_at| *expires_at > now); + expired.contains_key(&pipe_id) +} + pub(crate) fn reject_pending_pipes(pending: &PendingPipes, message: &str) { let pending = std::mem::take(&mut *pending.borrow_mut()); for (_, entry) in pending { @@ -178,19 +369,26 @@ pub(crate) async fn wasm_create_pipe( description: &str, pipe_id: u32, pending_pipe_creations: &PendingPipeCreations, + expired_pipe_creations: &Rc>>, generation: u32, current_generation: &Rc>, ) -> Result { let (tx, rx) = oneshot::channel(); + let token = Rc::new(()); let mut pipe_id = pipe_id; for _ in 0..128 { - let occupied = pipe_id == 0 || pending_pipe_creations.borrow().contains_key(&pipe_id); + let occupied = pipe_id == 0 + || pending_pipe_creations.borrow().contains_key(&pipe_id) + || is_expired_pipe_creation(expired_pipe_creations, pipe_id); if !occupied { break; } pipe_id = random_pipe_id()?; } - if pipe_id == 0 || pending_pipe_creations.borrow().contains_key(&pipe_id) { + if pipe_id == 0 + || pending_pipe_creations.borrow().contains_key(&pipe_id) + || is_expired_pipe_creation(expired_pipe_creations, pipe_id) + { return Err(js_error("could not allocate a unique pipe id")); } let type_map = transport.type_map(); @@ -207,9 +405,17 @@ pub(crate) async fn wasm_create_pipe( pipe_id, PendingPipeCreation { generation, + token: token.clone(), sender: tx, }, ); + let mut creation_guard = PendingPipeCreationGuard::new( + pending_pipe_creations.clone(), + expired_pipe_creations.clone(), + pipe_id, + generation, + token.clone(), + ); debug!( target = "mtp.wasm", pipe_id, @@ -218,31 +424,22 @@ pub(crate) async fn wasm_create_pipe( "sending pipe request" ); 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")); } + creation_guard.disarm(); Ok(WasmPipeHandle { pipe_id, description: description.to_string(), transport: transport.clone(), response_rx: Rc::new(RefCell::new(Some(rx))), + pending: pending_pipe_creations.clone(), + expired: expired_pipe_creations.clone(), + generation, + token, }) } @@ -282,6 +479,7 @@ pub(crate) async fn wasm_accept_pipe( }, ); } + let _acceptance_guard = PendingPipeGuard::new(pending_pipes.clone(), pipe_id, generation); debug!( target = "mtp.wasm", @@ -291,28 +489,42 @@ pub(crate) async fn wasm_accept_pipe( "sending pipe response" ); 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"))? + let response = rx.fuse(); + let timeout = wait_for_timeout(DEFAULT_PIPE_CREATION_TIMEOUT_MS).fuse(); + pin_mut!(response, timeout); + let result = select! { + result = response => match result { + Ok(result) => result, + Err(_) => { + remove_pending_pipe(pending_pipes, pipe_id, generation); + return Err(js_error("pipe closed before stream arrived")); + } + }, + result = timeout => { + result?; + remove_pending_pipe(pending_pipes, pipe_id, generation); + return Err(js_error(format!( + "pipe acceptance timed out after {DEFAULT_PIPE_CREATION_TIMEOUT_MS}ms" + ))); + }, + }; + result +} + +fn remove_pending_pipe(pending_pipes: &PendingPipes, pipe_id: u32, generation: u32) { + let mut pending = pending_pipes.borrow_mut(); + if pending + .get(&pipe_id) + .is_some_and(|entry| entry.generation == generation) + { + pending.remove(&pipe_id); + } } pub(crate) async fn wasm_deny_pipe(transport: &WasmTransport, pipe_id: u32) -> Result<(), JsValue> { diff --git a/wasm/src/config.rs b/wasm/src/config.rs index c0a3325..efeffd3 100644 --- a/wasm/src/config.rs +++ b/wasm/src/config.rs @@ -1,5 +1,6 @@ use wasm_bindgen::prelude::*; +#[derive(Clone)] #[wasm_bindgen] pub struct ConnectionConfig { pub(crate) url: String, diff --git a/wasm/src/crypto.rs b/wasm/src/crypto.rs index a96d42a..baa2f32 100644 --- a/wasm/src/crypto.rs +++ b/wasm/src/crypto.rs @@ -2,8 +2,8 @@ use wasm_bindgen::prelude::*; use zeroize::Zeroizing; use mtp_codec::{ - DataValue, MtpProtectionPurpose, PROTOCOL_VERSION, ProtectionPolicy, ProtectionPurpose, - SealedRelayBuilder, SignaturePolicy, TypeMap, + DataValue, DecodeLimits, EncodeLimits, MtpProtectionPurpose, PROTOCOL_VERSION, + ProtectionPolicy, ProtectionPurpose, SealedRelayBuilder, SignaturePolicy, TypeMap, }; use mtp_crypto::{ AeadDecrypt, AeadEncrypt, DualSigner, Ed25519Signer, HybridKem, KemPrivateKey, KemPublicKey, @@ -12,10 +12,18 @@ use mtp_crypto::{ }; use crate::error::{from_protection_error, js_error}; -use crate::relay::{decode_frame, relay_error, structured_error}; +use crate::relay::{decode_error, decode_frame, relay_error, structured_error}; fn decode_data_value(value: &[u8]) -> Result { - DataValue::from_bytes(value).ok_or_else(|| js_error("invalid DataValue")) + DataValue::try_from_bytes_with_limits(value, DecodeLimits::default()).map_err(|error| { + let value = decode_error(error, "DataValue decoding failed"); + let _ = js_sys::Reflect::set( + &value, + &JsValue::from_str("code"), + &JsValue::from_str("invalid-data-value"), + ); + value + }) } fn decode_public_key_bundle( @@ -74,10 +82,19 @@ pub struct WasmKeyring { #[wasm_bindgen] impl WasmKeyring { - /// Serialise the keyring to bytes. + /// Serialise the keyring to bytes and report malformed caller-owned + /// material as a JavaScript exception. #[wasm_bindgen] - pub fn to_bytes(&self) -> Vec { - self.inner.to_bytes().to_vec() + pub fn to_bytes(&self) -> Result, JsValue> { + self.try_to_bytes() + } + + #[wasm_bindgen] + pub fn try_to_bytes(&self) -> Result, JsValue> { + self.inner + .try_to_bytes() + .map(|bytes| bytes.to_vec()) + .map_err(|error| js_error(format!("Keyring serialization failed: {error}"))) } /// Deserialise a keyring from bytes. @@ -118,8 +135,17 @@ impl WasmKeyring { /// Generate a full keyring with KEM, ML-DSA, and Ed25519 keys. #[wasm_bindgen] -pub fn keyring_generate() -> Vec { - Keyring::generate().to_bytes().to_vec() +pub fn keyring_generate() -> Result, JsValue> { + keyring_generate_checked() +} + +/// Generate a full keyring and report serialization failures to JavaScript. +#[wasm_bindgen] +pub fn keyring_generate_checked() -> Result, JsValue> { + Keyring::generate() + .try_to_bytes() + .map(|bytes| bytes.to_vec()) + .map_err(|error| js_error(format!("generated keyring serialization failed: {error}"))) } /// Build a [`Keyring`] containing only an Ed25519 keypair (no KEM, no ML-DSA). @@ -142,7 +168,10 @@ pub fn keyring_from_ed25519(secret_key: &[u8], public_key: &[u8]) -> Result Vec { - self.inner.as_bytes() + pub fn to_bytes(&self) -> Result, JsValue> { + self.try_to_bytes() + } + + #[wasm_bindgen] + pub fn try_to_bytes(&self) -> Result, JsValue> { + self.inner + .try_as_bytes() + .map_err(|error| js_error(format!("public key bundle serialization failed: {error}"))) } #[wasm_bindgen] @@ -428,6 +464,14 @@ pub fn wasm_sha256_double(data: &[u8]) -> Vec { // KDF // =========================================================================== +/// Length, in bytes, of symmetric keys produced by the MTP key-derivation +/// bindings. SDKs should query this instead of duplicating the crypto +/// primitive's output size. +#[wasm_bindgen] +pub fn mtp_symmetric_key_length() -> u32 { + 32 +} + /// HKDF-expand: derive `len` bytes from `ikm` with `salt` and `info`. #[wasm_bindgen] pub fn wasm_hkdf_expand( @@ -452,6 +496,21 @@ pub fn wasm_derive_encryption_key( .map_err(|e| js_error(format!("derive_encryption_key failed: {}", e))) } +/// Derive a 32-byte key from a passphrase using explicit Argon2id parameters. +/// The salt and parameters are part of the caller's protected-data format. +#[wasm_bindgen] +pub fn wasm_argon2id( + passphrase: &[u8], + salt: &[u8], + memory_kib: u32, + iterations: u32, + lanes: u32, +) -> Result, JsValue> { + mtp_crypto::derive_password_key(passphrase, salt, memory_kib, iterations, lanes) + .map(|key| key.to_vec()) + .map_err(|e| js_error(format!("argon2id password derivation 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; @@ -564,10 +623,11 @@ pub fn verify_data_value_with_policy( let value = decode_data_value(value)?; let bundle = decode_public_key_bundle(public_key_bundle, None)?; let result = if signature_suite == 0 { - value.verify( + value.verify_with_policy( expected_signer_id, &bundle, ProtectionPurpose::from(expected_purpose), + ProtectionPolicy::any_supported(), ) } else { value.verify_with_policy( @@ -650,7 +710,11 @@ pub fn decrypt_data_value_with_keyrings( let keyrings = keyrings_from_js(&keyrings)?; let references: Vec<&Keyring> = keyrings.iter().collect(); value - .decrypt_with_keyrings(&references, ProtectionPurpose::from(expected_purpose)) + .decrypt_with_keyrings_and_limits( + &references, + ProtectionPurpose::from(expected_purpose), + DecodeLimits::default(), + ) .map_err(from_protection_error)? .to_bytes() .map_err(|e| js_error(format!("decryption failed: {e}"))) @@ -746,6 +810,13 @@ pub fn mtp_protection_signature_suite_dual() -> u8 { PROTECTION_SIGNATURE_SUITE_DUAL } +/// Explicit compatibility policy value accepting any signature suite +/// supported by this WASM build. New callers should prefer a fixed suite. +#[wasm_bindgen] +pub fn mtp_protection_signature_suite_any_supported() -> u8 { + 0 +} + /// Forward a sealed relay frame to another clear next hop without opening or /// re-encoding its authenticated encrypted payload. #[wasm_bindgen] @@ -776,12 +847,27 @@ fn build_encrypted_relay_frame_impl( signer: &dyn SignatureScheme, metadata_recipient_public_key_bundles: JsValue, content_recipient_public_key_bundles: JsValue, + limits: JsValue, ) -> Result, JsValue> { let tm = TypeMap::new(PROTOCOL_VERSION); - let application_content = crate::frame::js_to_data_value(&data, &tm)?; + let encode_limits = if limits.is_null() || limits.is_undefined() { + EncodeLimits::default() + } else { + crate::client::encode_limits_from_js(&limits)? + }; + let relay_options = + crate::relay::relay_open_options(ProtectionPolicy::any_supported(), &limits)?; + let application_content = + crate::frame::js_to_data_value_with_limits(&data, &tm, encode_limits)?; let application_metadata = encoded_metadata .as_deref() - .map(decode_data_value) + .map(|bytes| { + DataValue::try_from_bytes_with_limits( + bytes, + DecodeLimits::for_transport_message_size(encode_limits.max_output_size as u64), + ) + .map_err(|error| crate::relay::decode_error(error, "metadata decoding failed")) + }) .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)?; @@ -798,6 +884,8 @@ fn build_encrypted_relay_frame_impl( .created_at(created_at) .metadata_recipients(metadata_recipients) .content_recipients(content_recipients) + .encode_limits(encode_limits) + .protected_limits(relay_options.protected_limits) .type_map(&tm); let builder = match application_metadata { Some(metadata) => builder.metadata(metadata), @@ -807,7 +895,7 @@ fn build_encrypted_relay_frame_impl( builder .build() .map_err(relay_error)? - .to_bytes() + .to_bytes_with_limits(encode_limits) .map_err(|e| js_error(format!("relay frame encoding failed: {e}"))) } @@ -844,6 +932,45 @@ pub fn build_encrypted_relay_frame_with_keyring( &signer, metadata_recipient_public_key_bundles, content_recipient_public_key_bundles, + JsValue::UNDEFINED, + ) +} + +/// Build a sealed relay frame with explicit encoder and semantic field +/// limits. The same limits are applied by the native relay builder. +#[wasm_bindgen] +#[allow(clippy::too_many_arguments)] +pub fn build_encrypted_relay_frame_with_keyring_with_limits( + 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, + limits: 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, + limits, ) } @@ -891,7 +1018,7 @@ mod tests { }, }; - let bytes = bundle.to_bytes(); + let bytes = bundle.try_to_bytes().expect("bundle serialization"); let restored = WasmPublicKeyBundle::from_bytes_unvalidated(&bytes) .expect("from_bytes_unvalidated failed"); assert_eq!(restored.sig_cl_public_key(), pk); @@ -1098,7 +1225,7 @@ mod tests { let value = DataValue::Str("signed through wasm".into()) .to_bytes() .expect("value encoding failed"); - let keyring_bytes = keyring.to_bytes(); + let keyring_bytes = keyring.try_to_bytes().expect("keyring serialization"); let signed = sign_data_value_with_keyring( &value, 0xfeed_beef, @@ -1111,7 +1238,7 @@ mod tests { verify_data_value_with_policy( &signed, - &bundle.as_bytes(), + &bundle.try_as_bytes().expect("bundle serialization"), 0xfeed_beef, 7, PROTECTION_SIGNATURE_SUITE_ED25519, @@ -1121,7 +1248,7 @@ mod tests { assert!( verify_data_value_with_policy( &signed, - &wrong_bundle.as_bytes(), + &wrong_bundle.try_as_bytes().expect("bundle serialization"), 0xfeed_beef, 7, PROTECTION_SIGNATURE_SUITE_ED25519, @@ -1137,21 +1264,29 @@ mod tests { 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"); + let recipient_bytes = recipient.try_as_bytes().expect("recipient serialization"); + let encrypted = + encrypt_data_value(&value, &recipient_bytes, 9).expect("encrypt_data_value failed"); + let keyring_bytes = keyring.try_to_bytes().expect("keyring serialization"); + let decrypted = + decrypt_data_value(&encrypted, &keyring_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 second_recipient_bytes = second_recipient + .try_as_bytes() + .expect("second recipient serialization"); + recipients.push(&js_sys::Uint8Array::from(&recipient_bytes[..])); + recipients.push(&js_sys::Uint8Array::from(&second_recipient_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) + let second_keyring_bytes = second_keyring + .try_to_bytes() + .expect("second keyring serialization"); + let opened_by_second = decrypt_data_value(&multi, &second_keyring_bytes, 9) .expect("second recipient could not decrypt"); assert_eq!(opened_by_second, value); } diff --git a/wasm/src/frame.rs b/wasm/src/frame.rs index a81edf0..44db54f 100644 --- a/wasm/src/frame.rs +++ b/wasm/src/frame.rs @@ -1,9 +1,13 @@ use wasm_bindgen::{JsCast, prelude::*}; -use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue, PROTOCOL_VERSION}; +use mtp_codec::{ + CommunicationType, CommunicationValue, DataType, DataValue, DecodeLimits, EncodeLimits, + PROTOCOL_VERSION, +}; use mtp_type_map::TypeMap; use crate::error::js_error; +use crate::relay::decode_error; #[wasm_bindgen(typescript_custom_section)] const PARSED_FRAME_TS: &'static str = r#" @@ -137,7 +141,48 @@ pub(crate) fn data_value_to_js(value: &DataValue, tm: &TypeMap) -> Result Result<(), JsValue> { + if depth > self.limits.max_depth { + return Err(js_error("MTP DataValue nesting-depth limit exceeded")); + } + self.values = self + .values + .checked_add(1) + .ok_or_else(|| js_error("MTP DataValue value-count limit exceeded"))?; + if self.values > self.limits.max_values { + return Err(js_error("MTP DataValue value-count limit exceeded")); + } + Ok(()) + } +} + pub(crate) fn js_to_data_value(value: &JsValue, tm: &TypeMap) -> Result { + js_to_data_value_with_limits(value, tm, EncodeLimits::default()) +} + +pub(crate) fn js_to_data_value_with_limits( + value: &JsValue, + tm: &TypeMap, + limits: EncodeLimits, +) -> Result { + let mut context = JsDataValueEncodeContext { limits, values: 0 }; + js_to_data_value_with_context(value, tm, &mut context, 0) +} + +fn js_to_data_value_with_context( + value: &JsValue, + tm: &TypeMap, + context: &mut JsDataValueEncodeContext, + depth: usize, +) -> Result { + context.visit(depth)?; if value.is_null() || value.is_undefined() { return Ok(DataValue::Null); } @@ -152,9 +197,17 @@ pub(crate) fn js_to_data_value(value: &JsValue, tm: &TypeMap) -> Result context.limits.max_values { + return Err(js_error("MTP DataValue value-count limit exceeded")); + } let mut values = Vec::with_capacity(array.length() as usize); for item in array.iter() { - values.push(js_to_data_value(&item, tm)?); + values.push(js_to_data_value_with_context( + &item, + tm, + context, + depth + 1, + )?); } return Ok(DataValue::Array(values)); } @@ -198,6 +251,9 @@ pub(crate) fn js_to_data_value(value: &JsValue, tm: &TypeMap) -> Result context.limits.max_values { + return Err(js_error("MTP DataValue value-count limit exceeded")); + } let mut entries = Vec::with_capacity(keys.length() as usize); for key in keys.iter() { let key = key @@ -212,7 +268,10 @@ pub(crate) fn js_to_data_value(value: &JsValue, tm: &TypeMap) -> Result Result { - parse_frame_value_with_type_map(frame, &TypeMap::latest()) + parse_frame_value_with_limits(frame, &TypeMap::latest(), DecodeLimits::default()) } -pub(crate) fn parse_frame_value_with_type_map( +pub(crate) fn parse_frame_value_with_limits( frame: &[u8], type_map: &TypeMap, + limits: DecodeLimits, ) -> Result { - let comm = CommunicationValue::from_bytes_with(frame, type_map) - .map_err(|e| js_error(format!("parse failed: {}", e)))?; + let comm = CommunicationValue::try_from_bytes_with_type_map_and_limits(frame, type_map, limits) + .map_err(|error| decode_error(error, "parse failed"))?; let tm = type_map; let obj = js_sys::Object::new(); @@ -355,8 +415,8 @@ pub fn build_ping_frame( /// Parse an auth response frame into a JS object. #[wasm_bindgen(unchecked_return_type = "AuthResponse")] pub fn parse_auth_response(response: &[u8]) -> Result { - let comm = CommunicationValue::from_bytes(response) - .map_err(|e| js_error(format!("parse failed: {}", e)))?; + let comm = CommunicationValue::try_from_bytes_with_limits(response, DecodeLimits::default()) + .map_err(|error| decode_error(error, "parse failed"))?; let connected = matches!( comm.get_data(DataType::Connected), @@ -418,8 +478,8 @@ pub fn parse_auth_response(response: &[u8]) -> Result { /// Parse any MTP frame into the human-readable CommunicationValue display form. #[wasm_bindgen] pub fn format_frame(frame: &[u8]) -> Result { - let comm = CommunicationValue::from_bytes(frame) - .map_err(|e| js_error(format!("parse failed: {}", e)))?; + let comm = CommunicationValue::try_from_bytes_with_limits(frame, DecodeLimits::default()) + .map_err(|error| decode_error(error, "parse failed"))?; Ok(comm.to_string()) } @@ -429,31 +489,80 @@ pub fn parse_frame(frame: &[u8]) -> Result { parse_frame_value(frame) } +/// Parse a frame with the caller's bounded receive policy. The compatibility +/// `parse_frame` entry point retains the default policy for existing callers. +#[wasm_bindgen(unchecked_return_type = "ParsedFrame")] +pub fn parse_frame_with_limits(frame: &[u8], limits: JsValue) -> Result { + let limits = crate::client::decode_limits_from_js(&limits)?; + parse_frame_value_with_limits(frame, &TypeMap::latest(), limits) +} + /// 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"))?; + parse_data_value_with_decode_limits(value, DecodeLimits::default()) +} + +fn parse_data_value_with_decode_limits( + value: &[u8], + limits: DecodeLimits, +) -> Result { + let value = DataValue::try_from_bytes_with_limits(value, limits) + .map_err(|error| decode_error(error, "decode data value failed"))?; let tm = TypeMap::new(PROTOCOL_VERSION); data_value_to_js(&value, &tm) } +/// Parse a standalone serialized `DataValue` with the caller's bounded +/// receive policy. The compatibility `parse_data_value` entry point retains +/// the default policy for existing callers. +#[wasm_bindgen(unchecked_return_type = "ParsedDataValue")] +pub fn parse_data_value_with_limits(value: &[u8], limits: JsValue) -> Result { + let limits = crate::client::decode_limits_from_js(&limits)?; + parse_data_value_with_decode_limits(value, limits) +} + /// Encode one standalone `DataValue` using the negotiated/current type map. #[wasm_bindgen] pub fn encode_data_value(value: JsValue) -> Result, JsValue> { + encode_data_value_with_encode_limits(value, EncodeLimits::default()) +} + +fn encode_data_value_with_encode_limits( + value: JsValue, + limits: EncodeLimits, +) -> Result, JsValue> { let tm = TypeMap::new(PROTOCOL_VERSION); - js_to_data_value(&value, &tm)? - .to_bytes() + js_to_data_value_with_limits(&value, &tm, limits)? + .to_bytes_with_limits(limits) .map_err(|e| js_error(format!("encode data value failed: {e}"))) } +/// Encode one standalone `DataValue` using explicit recursion and output +/// limits. The compatibility entry point above keeps the historical default. +#[wasm_bindgen] +pub fn encode_data_value_with_limits(value: JsValue, limits: JsValue) -> Result, JsValue> { + let limits = crate::client::encode_limits_from_js(&limits)?; + encode_data_value_with_encode_limits(value, limits) +} + /// Build a typed MTP frame using generated communication/data type names. #[wasm_bindgen] pub fn build_frame( message_type: &str, data: JsValue, options: JsValue, +) -> Result, JsValue> { + build_frame_with_encode_limits(message_type, data, options, EncodeLimits::default()) +} + +fn build_frame_with_encode_limits( + message_type: &str, + data: JsValue, + options: JsValue, + limits: EncodeLimits, ) -> Result, JsValue> { let comm_type = CommunicationType::from_name(message_type) .ok_or_else(|| js_error(format!("unknown communication type: {message_type}")))?; @@ -478,7 +587,7 @@ pub fn build_frame( )) })?; msg = msg - .add_data(id, js_to_data_value(&value, &tm)?) + .add_data(id, js_to_data_value_with_limits(&value, &tm, limits)?) .map_err(|e| js_error(format!("add data failed: {e}")))?; } } else if !data.is_null() && !data.is_undefined() { @@ -487,10 +596,24 @@ pub fn build_frame( )); } - msg.to_bytes() + msg.to_bytes_with_limits(limits) .map_err(|e| js_error(format!("encode failed: {}", e))) } +/// Build a typed frame with explicit recursion and complete-frame output +/// limits. High-level SDK sends use this entry point with the transport's +/// admitted message size. +#[wasm_bindgen] +pub fn build_frame_with_limits( + message_type: &str, + data: JsValue, + options: JsValue, + limits: JsValue, +) -> Result, JsValue> { + let limits = crate::client::encode_limits_from_js(&limits)?; + build_frame_with_encode_limits(message_type, data, options, limits) +} + /// Build a typed MTP frame around a complete serialized `DataValue` payload. /// /// Unlike [`build_frame`], this does not interpret the payload as a clear data @@ -501,19 +624,50 @@ pub fn build_frame_with_payload( message_type: &str, serialized_payload: &[u8], options: JsValue, +) -> Result, JsValue> { + build_frame_with_payload_with_encode_limits( + message_type, + serialized_payload, + options, + EncodeLimits::default(), + ) +} + +fn build_frame_with_payload_with_encode_limits( + message_type: &str, + serialized_payload: &[u8], + options: JsValue, + limits: EncodeLimits, ) -> 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 payload = DataValue::try_from_bytes_with_limits( + serialized_payload, + DecodeLimits::for_transport_message_size(limits.max_output_size as u64), + ) + .map_err(|error| decode_error(error, "invalid serialized DataValue payload"))?; let message = apply_frame_options(CommunicationValue::new(comm_type), &options)?.with_payload(payload); message - .to_bytes() + .to_bytes_with_limits(limits) .map_err(|e| js_error(format!("encode failed: {e}"))) } +/// Build a typed frame around a serialized payload with explicit output +/// limits. The payload is also parsed with a policy derived from that limit so +/// an oversized/deep input cannot bypass the bounded builder. +#[wasm_bindgen] +pub fn build_frame_with_payload_with_limits( + message_type: &str, + serialized_payload: &[u8], + options: JsValue, + limits: JsValue, +) -> Result, JsValue> { + let limits = crate::client::encode_limits_from_js(&limits)?; + build_frame_with_payload_with_encode_limits(message_type, serialized_payload, options, limits) +} + #[cfg(test)] #[cfg(target_arch = "wasm32")] mod tests { diff --git a/wasm/src/protected.rs b/wasm/src/protected.rs index e064cf1..283b3f4 100644 --- a/wasm/src/protected.rs +++ b/wasm/src/protected.rs @@ -1,7 +1,8 @@ use wasm_bindgen::prelude::*; use mtp_codec::{ - DataValue, ProtectedError, ProtectedMessageBuilder, ProtectedOpenOptions, ProtectionError, + DataValue, DecodeLimits, EncodeLimits, ProtectedError, ProtectedLimits, + ProtectedMessageBuilder, ProtectedOpenOptions, ProtectionError, ProtectionPolicy, ProtectionPurpose, VerifiedProtectedMessage, }; @@ -9,7 +10,7 @@ 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}; +use crate::relay::{decode_error, decode_frame_with_limits, structured_error}; const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0; @@ -45,9 +46,86 @@ fn optional_u64(value: &JsValue, name: &str) -> Result, JsValue> { )) } -fn decode_data_value(value: &[u8]) -> Result { - DataValue::from_bytes(value) - .ok_or_else(|| structured_error("invalid-data-value", "invalid DataValue")) +fn limit_usize(options: &JsValue, key: &str, default: usize) -> Result { + if options.is_null() || options.is_undefined() { + return Ok(default); + } + let value = js_sys::Reflect::get(options, &JsValue::from_str(key))?; + if value.is_null() || value.is_undefined() { + return Ok(default); + } + let Some(number) = value.as_f64() else { + return Err(structured_error( + "invalid-limit", + format!("{key} must be a number"), + )); + }; + if !number.is_finite() || number.fract() != 0.0 || number < 0.0 { + return Err(structured_error( + "invalid-limit", + format!("{key} must be a non-negative integer"), + )); + } + usize::try_from(number as u64) + .map_err(|_| structured_error("invalid-limit", format!("{key} is out of range"))) +} + +fn protected_open_options( + expected_receiver_id: Option, + signature_purpose: u8, + encryption_purpose: u8, + policy: mtp_codec::ProtectionPolicy, + limits: &JsValue, +) -> Result { + let defaults = DecodeLimits::default(); + let encode_defaults = EncodeLimits::default(); + let protected_defaults = ProtectedLimits::default(); + let decode_limits = DecodeLimits { + max_depth: limit_usize(limits, "maxDepth", defaults.max_depth)?, + max_values: limit_usize(limits, "maxValues", defaults.max_values)?, + max_blob_size: limit_usize(limits, "maxBlobSize", defaults.max_blob_size)?, + max_recipients: limit_usize(limits, "maxRecipients", defaults.max_recipients)?, + max_allocated_bytes: limit_usize( + limits, + "maxAllocatedBytes", + defaults.max_allocated_bytes, + )?, + }; + let encode_limits = EncodeLimits { + max_depth: limit_usize(limits, "maxDepth", encode_defaults.max_depth)?, + max_values: limit_usize(limits, "maxValues", encode_defaults.max_values)?, + max_output_size: limit_usize(limits, "maxOutputSize", encode_defaults.max_output_size)?, + }; + let protected_limits = ProtectedLimits { + max_message_id_bytes: limit_usize( + limits, + "maxMessageIdBytes", + protected_defaults.max_message_id_bytes, + )?, + max_metadata_encoded_bytes: limit_usize( + limits, + "maxMetadataEncodedBytes", + protected_defaults.max_metadata_encoded_bytes, + )?, + max_signer_key_history: limit_usize( + limits, + "maxSignerKeyHistory", + protected_defaults.max_signer_key_history, + )?, + max_decryption_key_history: limit_usize( + limits, + "maxDecryptionKeyHistory", + protected_defaults.max_decryption_key_history, + )?, + }; + Ok(ProtectedOpenOptions::new( + expected_receiver_id, + ProtectionPurpose::from(signature_purpose), + ProtectionPurpose::from(encryption_purpose), + policy, + ) + .with_limits(decode_limits, protected_limits) + .with_encode_limits(encode_limits)) } pub(crate) fn protected_error(error: ProtectedError) -> JsValue { @@ -86,6 +164,7 @@ fn protected_error_code(error: &ProtectedError) -> &'static str { ProtectedError::ExpectedReceiverMismatch => "receiver-id-mismatch", ProtectedError::ReservedApplicationType(_) => "reserved-application-type", ProtectedError::Replay => "replay", + ProtectedError::ResourceLimit(_) => "resource-limit", ProtectedError::ReplayGuard(_) => "replay-guard-error", ProtectedError::Protection(error) => match error { ProtectionError::NoMatchingRecipient => "no-matching-recipient", @@ -94,6 +173,7 @@ fn protected_error_code(error: &ProtectedError) -> &'static str { ProtectionError::PurposeMismatch { .. } => "purpose-mismatch", ProtectionError::SignerIdMismatch { .. } => "signer-id-mismatch", ProtectionError::SignerKeyNotFound(_) => "signer-key-not-found", + ProtectionError::ResourceLimit(_) => "resource-limit", ProtectionError::Crypto(mtp_crypto::CryptoError::InvalidSignature) | ProtectionError::Crypto(mtp_crypto::CryptoError::VerificationFailed) => { "invalid-signature" @@ -112,15 +192,6 @@ fn serialize_data_value(value: &DataValue) -> Result, JsValue> { }) } -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, @@ -181,7 +252,58 @@ pub fn build_protected_frame_with_keyring( expose_sender: bool, recipient_public_key_bundles: JsValue, ) -> Result, JsValue> { - let content = decode_data_value(encoded_content)?; + build_protected_frame_with_keyring_impl( + message_type, + encoded_content, + signer_id, + final_recipient_id, + message_id, + created_at, + signature_purpose, + encryption_purpose, + keyring_bytes, + signature_suite, + frame_id, + expose_sender, + recipient_public_key_bundles, + JsValue::UNDEFINED, + ) +} + +#[allow(clippy::too_many_arguments)] +fn build_protected_frame_with_keyring_impl( + 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, + limits: JsValue, +) -> Result, JsValue> { + let encode_limits = if limits.is_null() || limits.is_undefined() { + EncodeLimits::default() + } else { + crate::client::encode_limits_from_js(&limits)? + }; + let open_options = protected_open_options( + None, + signature_purpose, + encryption_purpose, + ProtectionPolicy::any_supported(), + &limits, + )?; + let content = DataValue::try_from_bytes_with_limits( + encoded_content, + DecodeLimits::for_transport_message_size(encode_limits.max_output_size as u64), + ) + .map_err(|error| decode_error(error, "DataValue decoding failed"))?; let keyring = mtp_crypto::Keyring::from_bytes(keyring_bytes).map_err(|error| { structured_error( "invalid-keyring", @@ -202,42 +324,149 @@ pub fn build_protected_frame_with_keyring( .message_id(message_id) .created_at(created_at) .recipients(recipients) + .encode_limits(encode_limits) + .protected_limits(open_options.protected_limits) .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) + frame.to_bytes_with_limits(encode_limits).map_err(|error| { + structured_error( + "invalid-frame", + format!("protected frame encoding failed: {error}"), + ) + }) +} + +/// Build a complete encrypted protected frame with explicit encoder and +/// semantic protected-field limits. +#[wasm_bindgen] +#[allow(clippy::too_many_arguments)] +pub fn build_protected_frame_with_keyring_with_limits( + 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, + limits: JsValue, +) -> Result, JsValue> { + build_protected_frame_with_keyring_impl( + message_type, + encoded_content, + signer_id, + final_recipient_id, + message_id, + created_at, + signature_purpose, + encryption_purpose, + keyring_bytes, + signature_suite, + frame_id, + expose_sender, + recipient_public_key_bundles, + limits, + ) } /// 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] +#[deprecated(note = "use protected_claimed_signer_id_with_limits")] pub fn protected_claimed_signer_id( frame: &[u8], keyrings: JsValue, encryption_purpose: u8, ) -> Result { - let frame = decode_frame(frame)?; + protected_claimed_signer_id_impl(frame, keyrings, encryption_purpose, JsValue::UNDEFINED) +} + +fn protected_claimed_signer_id_impl( + frame: &[u8], + keyrings: JsValue, + encryption_purpose: u8, + limits: JsValue, +) -> Result { + let options = protected_open_options( + None, + 0, + encryption_purpose, + ProtectionPolicy::any_supported(), + &limits, + )?; + let frame = decode_frame_with_limits(frame, options.decode_limits)?; let keyrings = keyrings_from_js(&keyrings).map_err(|error| { structured_error( "invalid-recipient-keyrings", error.as_string().unwrap_or_default(), ) })?; + if keyrings.len() > options.protected_limits.max_decryption_key_history { + return Err(protected_error(ProtectedError::ResourceLimit( + "decryption key history", + ))); + } let references: Vec<&mtp_crypto::Keyring> = keyrings.iter().collect(); - mtp_codec::protected_claimed_signer_id( + mtp_codec::protected_claimed_signer_id_with_options( &frame, &references, ProtectionPurpose::from(encryption_purpose), + options.decode_limits, + options.protected_limits, ) .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( +pub fn protected_claimed_signer_id_with_limits( + frame: &[u8], + keyrings: JsValue, + encryption_purpose: u8, + limits: JsValue, +) -> Result { + let options = protected_open_options( + None, + 0, + encryption_purpose, + ProtectionPolicy::any_supported(), + &limits, + )?; + let frame = decode_frame_with_limits(frame, options.decode_limits)?; + let keyrings = keyrings_from_js(&keyrings).map_err(|error| { + structured_error( + "invalid-recipient-keyrings", + error.as_string().unwrap_or_default(), + ) + })?; + if keyrings.len() > options.protected_limits.max_decryption_key_history { + return Err(protected_error(ProtectedError::ResourceLimit( + "decryption key history", + ))); + } + let references: Vec<&mtp_crypto::Keyring> = keyrings.iter().collect(); + mtp_codec::protected_claimed_signer_id_with_options( + &frame, + &references, + ProtectionPurpose::from(encryption_purpose), + options.decode_limits, + options.protected_limits, + ) + .map_err(protected_error) +} + +/// Open a protected value without replay protection. This raw entry point is +/// intended for stored/forensic messages; message-processing callers should +/// apply their replay guard in the SDK or use a checked native API. +#[wasm_bindgen] +pub fn open_protected_with_keyrings_without_replay( frame: &[u8], keyrings: JsValue, expected_signer_id: JsValue, @@ -247,7 +476,30 @@ pub fn open_protected_with_keyrings( encryption_purpose: u8, signature_suite: u8, ) -> Result { - let frame = decode_frame(frame)?; + open_protected_with_keyrings_impl( + frame, + keyrings, + expected_signer_id, + signer_public_key_bundles, + expected_receiver_id, + signature_purpose, + encryption_purpose, + signature_suite, + JsValue::UNDEFINED, + ) +} + +fn open_protected_with_keyrings_impl( + 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, + limits: JsValue, +) -> Result { let keyrings = keyrings_from_js(&keyrings).map_err(|error| { structured_error( "invalid-recipient-keyrings", @@ -268,23 +520,53 @@ pub fn open_protected_with_keyrings( error.as_string().unwrap_or_default(), ) })?; - let message = mtp_codec::open_protected_with_keys( + let options = protected_open_options( + expected_receiver_id, + signature_purpose, + encryption_purpose, + policy, + &limits, + )?; + let frame = decode_frame_with_limits(frame, options.decode_limits)?; + let message = mtp_codec::open_protected_with_keys_without_replay( &frame, &references, expected_signer_id, &signer_public_keys, - ProtectedOpenOptions::new( - expected_receiver_id, - ProtectionPurpose::from(signature_purpose), - ProtectionPurpose::from(encryption_purpose), - policy, - ), - None, + options, ) .map_err(protected_error)?; Ok(WasmVerifiedProtectedMessage { inner: message }) } +/// Open a bounded protected value without replay protection. The raw WASM +/// boundary cannot accept a native replay-guard trait, so message-processing +/// callers must use the SDK guard or a native checked API. +#[wasm_bindgen] +pub fn open_protected_with_keyrings_with_limits_without_replay( + 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, + limits: JsValue, +) -> Result { + open_protected_with_keyrings_impl( + frame, + keyrings, + expected_signer_id, + signer_public_key_bundles, + expected_receiver_id, + signature_purpose, + encryption_purpose, + signature_suite, + limits, + ) +} + #[cfg(all(test, target_arch = "wasm32"))] mod tests { use super::*; @@ -358,9 +640,12 @@ mod tests { 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( + let recipient_bytes = recipient.try_to_bytes().expect("recipient serialization"); + let signer_bundle_bytes = sender + .public_key_bundle() + .try_as_bytes() + .expect("signer bundle serialization"); + match open_protected_with_keyrings_without_replay( frame, js_sys::Uint8Array::from(&recipient_bytes[..]).into(), JsValue::bigint_from_str("7"), @@ -379,8 +664,11 @@ mod tests { 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 sender_bytes = sender.try_to_bytes().expect("sender serialization"); + let recipient_bundle_bytes = recipient + .public_key_bundle() + .try_as_bytes() + .expect("recipient bundle serialization"); let content = DataValue::Str("complete-frame".into()) .to_bytes() .expect("encode content"); diff --git a/wasm/src/relay.rs b/wasm/src/relay.rs index 733303d..3ce2df4 100644 --- a/wasm/src/relay.rs +++ b/wasm/src/relay.rs @@ -1,8 +1,8 @@ use wasm_bindgen::prelude::*; use mtp_codec::{ - CommunicationValue, DataValue, ProtectionError, RelayError, VerifiedRelayContent, - VerifiedRelayMetadata, + CommunicationValue, DataValue, DecodeLimits, EncodeLimits, ProtectedLimits, ProtectionError, + ProtectionPolicy, RelayError, RelayOpenOptions, VerifiedRelayContent, VerifiedRelayMetadata, }; use crate::crypto::{keyrings_from_js, protection_policy_from_suite, public_key_bundles_from_js}; @@ -16,6 +16,28 @@ pub(crate) fn structured_error(code: &str, message: impl Into) -> JsValu value } +pub(crate) fn decode_error(error: mtp_codec::DecodeError, context: &str) -> JsValue { + let value = structured_error("invalid-frame", format!("{context}: {error}")); + let _ = js_sys::Reflect::set( + &value, + &JsValue::from_str("decodeCode"), + &JsValue::from_str(decode_error_code(&error)), + ); + value +} + +pub(crate) fn decode_error_code(error: &mtp_codec::DecodeError) -> &'static str { + match error { + mtp_codec::DecodeError::MalformedEncoding => "malformed-encoding", + mtp_codec::DecodeError::DepthLimit => "depth-limit", + mtp_codec::DecodeError::ValueCountLimit => "value-count-limit", + mtp_codec::DecodeError::BlobLimit => "blob-limit", + mtp_codec::DecodeError::AllocationLimit => "allocation-limit", + mtp_codec::DecodeError::RecipientLimit => "recipient-limit", + mtp_codec::DecodeError::DuplicateField => "duplicate-field", + } +} + fn wrapped_input_error(code: &str, error: JsValue) -> JsValue { let message = error .as_string() @@ -54,6 +76,7 @@ fn relay_error_code(error: &RelayError) -> &'static str { RelayError::UnsupportedRelayVersion(_) => "unsupported-relay-version", RelayError::NotFinalRecipient => "not-final-recipient", RelayError::Replay => "replay", + RelayError::ResourceLimit(_) => "resource-limit", RelayError::ReservedApplicationType(_) => "reserved-application-type", RelayError::ReplayGuard(_) => "replay-guard-error", RelayError::Protection(error) => match error { @@ -63,6 +86,7 @@ fn relay_error_code(error: &RelayError) -> &'static str { ProtectionError::PurposeMismatch { .. } => "purpose-mismatch", ProtectionError::SignerIdMismatch { .. } => "signer-id-mismatch", ProtectionError::SignerKeyNotFound(_) => "signer-key-not-found", + ProtectionError::ResourceLimit(_) => "resource-limit", ProtectionError::Crypto(mtp_crypto::CryptoError::InvalidSignature) | ProtectionError::Crypto(mtp_crypto::CryptoError::VerificationFailed) => { "invalid-signature" @@ -73,12 +97,15 @@ fn relay_error_code(error: &RelayError) -> &'static str { } 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}"), - ) - }) + decode_frame_with_limits(frame, DecodeLimits::default()) +} + +pub(crate) fn decode_frame_with_limits( + frame: &[u8], + limits: DecodeLimits, +) -> Result { + CommunicationValue::try_from_bytes_with_limits(frame, limits) + .map_err(|error| decode_error(error, "relay frame decoding failed")) } fn optional_u64(value: &JsValue, name: &str) -> Result, JsValue> { @@ -113,6 +140,79 @@ fn optional_u64(value: &JsValue, name: &str) -> Result, JsValue> { )) } +fn limit_usize(options: &JsValue, key: &str, default: usize) -> Result { + if options.is_null() || options.is_undefined() { + return Ok(default); + } + let value = js_sys::Reflect::get(options, &JsValue::from_str(key))?; + if value.is_null() || value.is_undefined() { + return Ok(default); + } + let Some(number) = value.as_f64() else { + return Err(structured_error( + "invalid-limit", + format!("{key} must be a number"), + )); + }; + if !number.is_finite() || number.fract() != 0.0 || number < 0.0 { + return Err(structured_error( + "invalid-limit", + format!("{key} must be a non-negative integer"), + )); + } + usize::try_from(number as u64) + .map_err(|_| structured_error("invalid-limit", format!("{key} is out of range"))) +} + +pub(crate) fn relay_open_options( + policy: mtp_codec::ProtectionPolicy, + limits: &JsValue, +) -> Result { + let decode_defaults = DecodeLimits::default(); + let encode_defaults = EncodeLimits::default(); + let protected_defaults = ProtectedLimits::default(); + let options = RelayOpenOptions::new(policy).with_limits( + DecodeLimits { + max_depth: limit_usize(limits, "maxDepth", decode_defaults.max_depth)?, + max_values: limit_usize(limits, "maxValues", decode_defaults.max_values)?, + max_blob_size: limit_usize(limits, "maxBlobSize", decode_defaults.max_blob_size)?, + max_recipients: limit_usize(limits, "maxRecipients", decode_defaults.max_recipients)?, + max_allocated_bytes: limit_usize( + limits, + "maxAllocatedBytes", + decode_defaults.max_allocated_bytes, + )?, + }, + ProtectedLimits { + max_message_id_bytes: limit_usize( + limits, + "maxMessageIdBytes", + protected_defaults.max_message_id_bytes, + )?, + max_metadata_encoded_bytes: limit_usize( + limits, + "maxMetadataEncodedBytes", + protected_defaults.max_metadata_encoded_bytes, + )?, + max_signer_key_history: limit_usize( + limits, + "maxSignerKeyHistory", + protected_defaults.max_signer_key_history, + )?, + max_decryption_key_history: limit_usize( + limits, + "maxDecryptionKeyHistory", + protected_defaults.max_decryption_key_history, + )?, + }, + ); + Ok(options.with_encode_limits(EncodeLimits { + max_depth: limit_usize(limits, "maxDepth", encode_defaults.max_depth)?, + max_values: limit_usize(limits, "maxValues", encode_defaults.max_values)?, + max_output_size: limit_usize(limits, "maxOutputSize", encode_defaults.max_output_size)?, + })) +} + fn serialize_data_value(value: &DataValue) -> Result, JsValue> { value.to_bytes().map_err(|error| { structured_error( @@ -196,19 +296,49 @@ impl WasmVerifiedRelayContent { /// 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] +#[deprecated(note = "use relay_metadata_claimed_signer_id_with_limits")] pub fn relay_metadata_claimed_signer_id(frame: &[u8], keyrings: JsValue) -> Result { - let frame = decode_frame(frame)?; + relay_metadata_claimed_signer_id_impl(frame, keyrings, JsValue::UNDEFINED) +} + +fn relay_metadata_claimed_signer_id_impl( + frame: &[u8], + keyrings: JsValue, + limits: JsValue, +) -> Result { + let options = relay_open_options(ProtectionPolicy::any_supported(), &limits)?; + let frame = decode_frame_with_limits(frame, options.decode_limits)?; let keyrings = keyrings_from_js(&keyrings) .map_err(|error| wrapped_input_error("invalid-recipient-keyrings", error))?; + if keyrings.len() > options.protected_limits.max_decryption_key_history { + return Err(relay_error(RelayError::ResourceLimit( + "decryption key history", + ))); + } let references: Vec<&mtp_crypto::Keyring> = keyrings.iter().collect(); - mtp_codec::relay_metadata_claimed_signer_id(&frame, &references).map_err(relay_error) + mtp_codec::relay_metadata_claimed_signer_id_with_options( + &frame, + &references, + options.decode_limits, + options.protected_limits, + ) + .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( +pub fn relay_metadata_claimed_signer_id_with_limits( + frame: &[u8], + keyrings: JsValue, + limits: JsValue, +) -> Result { + relay_metadata_claimed_signer_id_impl(frame, keyrings, limits) +} + +/// Open relay metadata without replay protection. This raw entry point is for +/// stored/forwarded messages; message-processing paths should add a guard in +/// the SDK or use the checked native API. +#[wasm_bindgen] +pub fn open_relay_metadata_with_keyrings_without_replay( frame: &[u8], keyrings: JsValue, expected_signer_id: JsValue, @@ -225,21 +355,54 @@ pub fn open_relay_metadata_with_keyrings( .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( + let metadata = mtp_codec::open_relay_metadata_with_limits_without_replay( &frame, &references, - expected_signer_id, - &signer_public_keys, - policy, + Some(expected_signer_id), + move |_| Some(signer_public_keys), + RelayOpenOptions::new(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. +/// Open bounded relay metadata without replay protection. Use the SDK's +/// message-processing guard or a native checked API for live traffic. #[wasm_bindgen] -pub fn open_relay_content_with_keyrings( +pub fn open_relay_metadata_with_keyrings_with_limits_without_replay( + frame: &[u8], + keyrings: JsValue, + expected_signer_id: JsValue, + signer_public_key_bundles: JsValue, + signature_suite: u8, + limits: JsValue, +) -> 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_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 options = relay_open_options(policy, &limits)?; + let frame = decode_frame_with_limits(frame, options.decode_limits)?; + let metadata = mtp_codec::open_relay_metadata_with_limits_without_replay( + &frame, + &references, + Some(expected_signer_id), + move |_| Some(signer_public_keys), + options, + ) + .map_err(relay_error)?; + Ok(WasmVerifiedRelayMetadata { inner: metadata }) +} + +/// Open relay content without making a second replay decision. Replay is +/// consumed when live message processing accepts the authenticated metadata. +#[wasm_bindgen] +pub fn open_relay_content_with_keyrings_without_replay( metadata: &WasmVerifiedRelayMetadata, keyrings: JsValue, signer_public_key_bundles: JsValue, @@ -255,12 +418,49 @@ pub fn open_relay_content_with_keyrings( 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( + let content = mtp_codec::open_relay_content_with_limits_without_replay( &metadata.inner, &references, &signer_public_keys, expected_final_recipient_id, - policy, + RelayOpenOptions { + policy, + decode_limits: metadata.inner.decode_limits(), + encode_limits: metadata.inner.encode_limits(), + protected_limits: metadata.inner.protected_limits(), + }, + ) + .map_err(relay_error)?; + Ok(WasmVerifiedRelayContent { inner: content }) +} + +/// Open bounded relay content without replay protection. Replay is consumed +/// when metadata is accepted by the live SDK/native processing boundary. +#[wasm_bindgen] +pub fn open_relay_content_with_keyrings_with_limits_without_replay( + metadata: &WasmVerifiedRelayMetadata, + keyrings: JsValue, + signer_public_key_bundles: JsValue, + expected_final_recipient_id: JsValue, + signature_suite: u8, + limits: JsValue, +) -> 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 options = relay_open_options(policy, &limits)?; + let content = mtp_codec::open_relay_content_with_limits_without_replay( + &metadata.inner, + &references, + &signer_public_keys, + expected_final_recipient_id, + options, ) .map_err(relay_error)?; Ok(WasmVerifiedRelayContent { inner: content }) diff --git a/wasm/src/transport.rs b/wasm/src/transport.rs index c8d4f76..a4deea6 100644 --- a/wasm/src/transport.rs +++ b/wasm/src/transport.rs @@ -7,8 +7,8 @@ use wasm_bindgen::prelude::*; use wasm_bindgen_futures::JsFuture; use crate::error::js_error; -use crate::frame::parse_frame_value_with_type_map; -use mtp_codec::TypeMap; +use crate::frame::parse_frame_value_with_limits; +use mtp_codec::{DecodeLimits, EncodeLimits, TypeMap}; const CLOSE_FRAME_LEN: u32 = u32::MAX; @@ -133,6 +133,7 @@ pub struct WasmTransport { /// Serializes stream creation and writes across concurrent callers. send_lock: Rc>, type_map: Rc>, + decode_limits: Rc>, } impl WasmTransport { @@ -140,6 +141,15 @@ impl WasmTransport { url: &str, cert_hashes: Option>, max_message_size: u32, + ) -> Result { + Self::connect_with_limits(url, cert_hashes, max_message_size, None).await + } + + pub async fn connect_with_limits( + url: &str, + cert_hashes: Option>, + max_message_size: u32, + configured_limits: Option, ) -> Result { let ctor = js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("WebTransport"))? .dyn_into::() @@ -188,6 +198,10 @@ impl WasmTransport { JsFuture::from(ready) .await .map_err(|e| js_error(format!("WebTransport ready failed: {:?}", e)))?; + let transport_limits = DecodeLimits::for_transport_message_size(max_message_size as u64); + let decode_limits = configured_limits + .map(|limits| restrict_decode_limits(limits, transport_limits)) + .unwrap_or(transport_limits); Ok(Self { inner: transport, max_message_size, @@ -198,6 +212,7 @@ impl WasmTransport { outgoing_writer: Rc::new(RefCell::new(None)), send_lock: Rc::new(AsyncMutex::new(())), type_map: Rc::new(RefCell::new(TypeMap::latest())), + decode_limits: Rc::new(RefCell::new(decode_limits)), }) } @@ -213,6 +228,17 @@ impl WasmTransport { self.type_map.borrow().clone() } + pub fn decode_limits(&self) -> DecodeLimits { + *self.decode_limits.borrow() + } + + /// Encoder policy corresponding to the transport's admitted complete + /// frame size. SDK builders use this before constructing a frame so an + /// oversized value is rejected before its serialized buffer is created. + pub fn encode_limits(&self) -> EncodeLimits { + EncodeLimits::for_transport_message_size(self.max_message_size as u64) + } + 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 @@ -462,7 +488,7 @@ impl WasmTransport { match self.next_frame(self.max_message_size).await { Ok(FrameOutcome::Frame(frame)) => { let type_map = self.type_map(); - match parse_frame_value_with_type_map(&frame, &type_map) { + match parse_frame_value_with_limits(&frame, &type_map, self.decode_limits()) { Ok(parsed) => { on_message(parsed); } @@ -498,15 +524,23 @@ impl WasmTransport { match self.next_frame(self.max_message_size).await { Ok(FrameOutcome::Frame(frame)) => { let type_map = self.type_map(); + let decode_limits = self.decode_limits(); 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(); + let comm = + mtp_codec::CommunicationValue::try_from_bytes_with_type_map_and_limits( + &frame, + &type_map, + decode_limits, + ) + .ok(); + if is_first { self.new_stream_frame.set(false); - if let Ok(comm) = - mtp_codec::CommunicationValue::from_bytes_with(&frame, &type_map) + if let Some(comm) = comm.as_ref() && Some(comm.get_type()) == pipe_request_type { let Some(pipe_id) = comm.id().filter(|id| *id != 0) else { @@ -539,8 +573,7 @@ impl WasmTransport { } } - if let Ok(comm) = - mtp_codec::CommunicationValue::from_bytes_with(&frame, &type_map) + if let Some(comm) = comm.as_ref() && Some(comm.get_type()) == pipe_response_type && !matches!(comm.id(), Some(id) if id != 0) { @@ -551,8 +584,7 @@ impl WasmTransport { break; } - if let Ok(comm) = - mtp_codec::CommunicationValue::from_bytes_with(&frame, &type_map) + if let Some(comm) = comm.as_ref() && !matches!(comm.id(), Some(id) if id != 0) && comm .get_type_name() @@ -565,7 +597,7 @@ impl WasmTransport { break; } - match parse_frame_value_with_type_map(&frame, &type_map) { + match parse_frame_value_with_limits(&frame, &type_map, decode_limits) { Ok(parsed) => { on_message(parsed); } @@ -666,3 +698,13 @@ impl WasmTransport { } } } + +fn restrict_decode_limits(left: DecodeLimits, right: DecodeLimits) -> DecodeLimits { + DecodeLimits { + max_depth: left.max_depth.min(right.max_depth), + max_values: left.max_values.min(right.max_values), + max_blob_size: left.max_blob_size.min(right.max_blob_size), + max_recipients: left.max_recipients.min(right.max_recipients), + max_allocated_bytes: left.max_allocated_bytes.min(right.max_allocated_bytes), + } +} diff --git a/wasm/types/mtp_wasm.d.ts b/wasm/types/mtp_wasm.d.ts index 3fc0595..45cd84a 100644 --- a/wasm/types/mtp_wasm.d.ts +++ b/wasm/types/mtp_wasm.d.ts @@ -1,400 +1,856 @@ -export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; -export type SyncInitInput = BufferSource | WebAssembly.Module; - -export interface InitOutput { - readonly memory: WebAssembly.Memory; -} - -export interface DisposableWasmObject { - free(): void; - [Symbol.dispose](): void; -} - -export type StateChangeCallback = (state: ConnectionState) => void; -export type MessageCallback = (frame: ParsedFrame) => void; -export type ErrorCallback = (error: string) => void; -export interface PipeRequest { - pipeId: number; - description: string; -} -export type PipeRequestCallback = (request: PipeRequest) => void; - -export interface Ed25519GenerateResult { - signer: WasmEd25519Signer; - secretKey: Uint8Array; - publicKey: Uint8Array; -} - -export interface AuthResponse { - connected: boolean; - clientNonce?: Uint8Array; - assignedId?: bigint; - timestamp?: bigint; - signature?: Uint8Array; -} - -export interface ParsedResponse { - _id?: number; - _type: string; - [field: string]: unknown; -} +/* tslint:disable */ +/* eslint-disable */ export interface ParsedEncryptedValue { - kind: "encrypted"; - encryptionType: number; - purpose: number; - recipientCount: number; - encoded: Uint8Array; + kind: "encrypted"; + encryptionType: number; + purpose: number; + recipientCount: number; + encoded: Uint8Array; } export interface ParsedSignedValue { - kind: "signed"; - signatureType: number; - purpose: number; - signerId: bigint; - value: ParsedDataValue; + 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; +| boolean +| number +| bigint +| string +| Uint8Array +| ParsedDataValue[] +| { [key: string]: ParsedDataValue } +| ParsedEncryptedValue +| ParsedSignedValue +| null; export interface ParsedFrame { - id?: number; - type: string; - sender?: bigint; - receiver?: bigint; - data: ParsedDataValue; - raw: Uint8Array; + id?: number; + type: string; + sender?: bigint; + receiver?: bigint; + data: ParsedDataValue; + raw: Uint8Array; } -export class ConnectionConfig implements DisposableWasmObject { - constructor(url: string); - free(): void; - [Symbol.dispose](): void; - client_id: bigint; - description: string | undefined; - max_message_size: number; - require_pq: boolean; - server_certificate_hashes: string[]; - readonly url: string; + + +export interface PipeWriter { + write(data: Uint8Array): Promise; + close(): Promise; + abort(): void; + readonly pipeId: number; +} + +export interface PipeReader { + read(): Promise; + readonly pipeId: number; + readonly description: string; +} + + + +export interface WasmPipeHandle { + wait(): Promise; + readonly pipeId: number; + readonly description: string; +} + + + +export class ConnectionConfig { + free(): void; + [Symbol.dispose](): void; + constructor(url: string); + client_id: bigint; + get description(): string | undefined; + set description(value: string); + max_message_size: number; + require_pq: boolean; + set server_certificate_hashes(value: string[]); + readonly url: string; } export enum ConnectionState { - Disconnected = 0, - Connecting = 1, - Connected = 2, - Failed = 3, + Disconnected = 0, + Connecting = 1, + Connected = 2, + Failed = 3, } +export class PipeReader { + private constructor(); + free(): void; + [Symbol.dispose](): void; + description(): string; + pipe_id(): number; + read(): Promise; +} + +export class PipeWriter { + private constructor(); + free(): void; + [Symbol.dispose](): void; + abort(): void; + close(): Promise; + pipe_id(): number; + write(data: Uint8Array): Promise; +} + +export class WasmChaCha20Poly1305 { + free(): void; + [Symbol.dispose](): void; + /** + * Decrypt `nonce || ciphertext` with `aad`. + */ + decrypt(ciphertext: Uint8Array, aad: Uint8Array): Uint8Array; + /** + * Encrypt `plaintext` with `aad`. + * Returns `nonce || ciphertext`. + */ + encrypt(plaintext: Uint8Array, aad: Uint8Array): Uint8Array; + /** + * Create a new cipher with a 32-byte key. + */ + constructor(key: Uint8Array); +} + +export class WasmClient { + free(): void; + [Symbol.dispose](): void; + accept_pipe(pipe_id: number): Promise; + authConnectOwned(config: ConnectionConfig, host_public_key_bytes: Uint8Array, keyring_bytes: Uint8Array, client_id: bigint): Promise; + authRegisterOwned(config: ConnectionConfig, host_public_key_bytes: Uint8Array, keyring_bytes: Uint8Array): Promise; + auth_connect(config: ConnectionConfig, host_public_key_bytes: Uint8Array, keyring_bytes: Uint8Array, client_id: bigint): Promise; + auth_register(config: ConnectionConfig, host_public_key_bytes: Uint8Array, keyring_bytes: Uint8Array): Promise; + connect(config: ConnectionConfig): Promise; + connectOwned(config: ConnectionConfig): Promise; + create_pipe(description: string): Promise; + deny_pipe(pipe_id: number): Promise; + disconnect(): void; + static is_supported(): boolean; + constructor(on_state_change?: Function | null, on_message?: Function | null, on_error?: Function | null); + request(frame: Uint8Array, response_type?: string | null, timeout_ms?: number | null): Promise; + send(frame: Uint8Array): Promise; + set_on_pipe_request(callback?: Function | null): void; + /** + * Apply one decoder policy to frames received by this raw WASM client. + * The high-level SDK calls this before authentication so handshake, + * transport, and protected opening share the same policy input. + */ + set_receive_limits(limits: any): void; + start_protocol_pings(interval_ms: number, client_id: bigint): void; + stop_protocol_pings(): void; + subscribe(message_type: string, callback: Function): number; + unsubscribe(id: number): boolean; + readonly client_id: bigint; + readonly ping_ms: number | undefined; + readonly state: number; +} + +export class WasmEd25519Signer { + free(): void; + [Symbol.dispose](): void; + /** + * Load a signer from its 32-byte secret key. + */ + constructor(secret_key: Uint8Array); + /** + * Sign `message` and return the signature bytes. + */ + sign(message: Uint8Array): Uint8Array; + /** + * Verify `signature` against `message`. + */ + verify(message: Uint8Array, signature: Uint8Array): void; +} + +/** + * KEM encapsulation result returned to JavaScript. + * + * `shared_secret` is the symmetric key both parties will derive; `ciphertext` + * is the KEM ciphertext that must be sent to the recipient so they can + * decapsulate and recover the same shared secret. + */ +export class WasmEncapsulated { + private constructor(); + free(): void; + [Symbol.dispose](): void; + /** + * KEM ciphertext to transmit to the recipient. + */ + readonly ciphertext: Uint8Array; + /** + * Symmetric secret derived during encapsulation. + */ + readonly shared_secret: Uint8Array; +} + +/** + * A short-lived ephemeral hybrid-KEM keypair for the forward-secure pipe + * handshake. The secret is zeroized when the object is freed. + */ +export class WasmKemKeypair { + private constructor(); + free(): void; + [Symbol.dispose](): void; + readonly public_key: Uint8Array; + readonly secret_key: Uint8Array; +} + +export class WasmKeyring { + private constructor(); + free(): void; + [Symbol.dispose](): void; + /** + * Deserialise a keyring from bytes. + */ + static from_bytes(bytes: Uint8Array): WasmKeyring; + /** + * Return the public half of this keyring as a bundle. + */ + public_key_bundle(): WasmPublicKeyBundle; + /** + * Serialise the keyring to bytes and report malformed caller-owned + * material as a JavaScript exception. + */ + to_bytes(): Uint8Array; + try_to_bytes(): Uint8Array; + /** + * 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. + */ + validate_encryption(): void; + /** + * Validate that all full-suite public/private components correspond. + * Role-specific browser keyrings may intentionally fail this check. + */ + validate_full(): void; +} + +/** + * Log severity used by the public SDK when translating raw WASM events. + */ export enum WasmLogHint { - Info = 0, - Warning = 1, - Error = 2, + Info = 0, + Warning = 1, + Error = 2, } -export class WasmChaCha20Poly1305 implements DisposableWasmObject { - constructor(key: Uint8Array); - free(): void; - [Symbol.dispose](): void; - decrypt(ciphertext: Uint8Array, aad: Uint8Array): Uint8Array; - encrypt(plaintext: Uint8Array, aad: Uint8Array): Uint8Array; +export class WasmPipeHandle { + private constructor(); + free(): void; + [Symbol.dispose](): void; + wait(): Promise; + readonly description: string; + readonly pipe_id: number; } -export class PipeReader implements DisposableWasmObject { - private constructor(); - free(): void; - [Symbol.dispose](): void; - read(): Promise; - pipe_id(): number; - description(): string; +export class WasmPublicKeyBundle { + private constructor(); + free(): void; + [Symbol.dispose](): void; + static from_bytes(bytes: Uint8Array): WasmPublicKeyBundle; + /** + * Deserialise an explicitly partial bundle for development-only key + * material. Protocol encryption and signature verification use the + * strict `from_bytes` parser above. + */ + static from_bytes_unvalidated(bytes: Uint8Array): WasmPublicKeyBundle; + to_bytes(): Uint8Array; + try_to_bytes(): Uint8Array; + readonly kem_public_key: Uint8Array; + readonly sig_cl_public_key: Uint8Array; + readonly sig_pq_public_key: Uint8Array; } -export class PipeWriter implements DisposableWasmObject { - private constructor(); - free(): void; - [Symbol.dispose](): void; - write(data: Uint8Array): Promise; - close(): Promise; - abort(): void; - pipe_id(): number; +/** + * Minimal message router used by higher-level SDK subscription code. + */ +export class WasmSubscriptionRouter { + free(): void; + [Symbol.dispose](): void; + dispatch(message_type: string, message: any): boolean; + constructor(); + subscribe(message_type: string, callback: Function): void; + unsubscribe(message_type: string): boolean; } -export class WasmPipeHandle implements DisposableWasmObject { - private constructor(); - free(): void; - [Symbol.dispose](): void; - wait(): Promise; - readonly pipe_id: number; - readonly description: string; +export class WasmVerifiedProtectedMessage { + 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 interface KemEncapsulateResult { - shared_secret: Uint8Array; - ciphertext: Uint8Array; +export class WasmVerifiedRelayContent { + private constructor(); + free(): void; + [Symbol.dispose](): void; + content(): Uint8Array; + final_recipient_id(): bigint; + message_type(): string; + signer_id(): bigint; } -export class WasmEncapsulated implements DisposableWasmObject { - private constructor(); - free(): void; - [Symbol.dispose](): void; - readonly shared_secret: Uint8Array; - readonly ciphertext: Uint8Array; +export class WasmVerifiedRelayMetadata { + private constructor(); + free(): void; + [Symbol.dispose](): void; + created_at(): bigint; + encrypted_content(): Uint8Array; + final_recipient_id(): bigint; + matched_signer_key_index(): number; + message_id(): string; + metadata(): any; + relay_version(): bigint; + signer_id(): bigint; } -export class WasmKemKeypair implements DisposableWasmObject { - readonly public_key: Uint8Array; - readonly secret_key: Uint8Array; - free(): void; - [Symbol.dispose](): void; -} +/** + * Build a relay frame using an explicit Ed25519 or dual-signature policy. + * `created_at` is Unix epoch milliseconds. + */ +export function build_encrypted_relay_frame_with_keyring(message_type: string, data: any, 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: any, content_recipient_public_key_bundles: any): Uint8Array; -export class WasmClient implements DisposableWasmObject { - constructor( - on_state_change?: StateChangeCallback | null, - on_message?: MessageCallback | null, - on_error?: ErrorCallback | null, - ); - free(): void; - [Symbol.dispose](): void; - accept_pipe(pipe_id: number): Promise; - auth_connect( - config: ConnectionConfig, - host_public_key_bytes: Uint8Array, - keyring_bytes: Uint8Array, - client_id: bigint, - ): Promise; - auth_register( - config: ConnectionConfig, - host_public_key_bytes: Uint8Array, - keyring_bytes: Uint8Array, - ): Promise; - connect(config: ConnectionConfig): Promise; - disconnect(): void; - create_pipe(description: string): Promise; - deny_pipe(pipe_id: number): Promise; - set_on_pipe_request(callback?: PipeRequestCallback): void; - request( - frame: Uint8Array, - response_type?: string | null, - timeout_ms?: number | null, - ): Promise; - send(frame: Uint8Array): Promise; - start_protocol_pings(interval_ms: number, client_id: bigint): void; - stop_protocol_pings(): void; - subscribe(message_type: string, callback: MessageCallback): number; - unsubscribe(id: number): boolean; - static is_supported(): boolean; - readonly ping_ms: number | undefined; - readonly client_id: bigint; - readonly state: ConnectionState; -} +/** + * Build a sealed relay frame with explicit encoder and semantic field + * limits. The same limits are applied by the native relay builder. + */ +export function build_encrypted_relay_frame_with_keyring_with_limits(message_type: string, data: any, 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: any, content_recipient_public_key_bundles: any, limits: any): Uint8Array; -export class WasmEd25519Signer implements DisposableWasmObject { - constructor(secret_key: Uint8Array); - free(): void; - [Symbol.dispose](): void; - sign(message: Uint8Array): Uint8Array; - verify(message: Uint8Array, signature: Uint8Array): void; -} +/** + * Build a typed MTP frame using generated communication/data type names. + */ +export function build_frame(message_type: string, data: any, options: any): Uint8Array; -export class WasmKeyring implements DisposableWasmObject { - private constructor(); - free(): void; - [Symbol.dispose](): void; - static from_bytes(bytes: Uint8Array): WasmKeyring; - public_key_bundle(): WasmPublicKeyBundle; - to_bytes(): Uint8Array; - validate_encryption(): void; - validate_full(): void; -} +/** + * Build a typed frame with explicit recursion and complete-frame output + * limits. High-level SDK sends use this entry point with the transport's + * admitted message size. + */ +export function build_frame_with_limits(message_type: string, data: any, options: any, limits: any): Uint8Array; -export class WasmPublicKeyBundle implements DisposableWasmObject { - private constructor(); - 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; - readonly sig_pq_public_key: Uint8Array; -} +/** + * 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. + */ +export function build_frame_with_payload(message_type: string, serialized_payload: Uint8Array, options: any): Uint8Array; -export class WasmSubscriptionRouter implements DisposableWasmObject { - constructor(); - free(): void; - [Symbol.dispose](): void; - dispatch(message_type: string, message: unknown): boolean; - subscribe(message_type: string, callback: (message: unknown) => void): void; - unsubscribe(message_type: string): boolean; -} +/** + * Build a typed frame around a serialized payload with explicit output + * limits. The payload is also parsed with a policy derived from that limit so + * an oversized/deep input cannot bypass the bounded builder. + */ +export function build_frame_with_payload_with_limits(message_type: string, serialized_payload: Uint8Array, options: any, limits: any): Uint8Array; -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; -} +/** + * Build a protocol-level Ping frame with description, timestamp, and optional data. + */ +export function build_ping_frame(client_id: bigint, description: string, timestamp: bigint, data: Uint8Array): Uint8Array; -export class WasmVerifiedRelayContent implements DisposableWasmObject { - private constructor(); - free(): void; - [Symbol.dispose](): void; - content(): Uint8Array; - final_recipient_id(): bigint; - message_type(): string; - signer_id(): bigint; -} +/** + * 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. + */ +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: any): Uint8Array; -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; -} +/** + * Build a complete encrypted protected frame with explicit encoder and + * semantic protected-field limits. + */ +export function build_protected_frame_with_keyring_with_limits(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: any, limits: any): Uint8Array; -export function build_ping_frame( - client_id: bigint, - description: string, - timestamp: bigint, - data: Uint8Array, -): Uint8Array; - -export function build_frame(message_type: string, data: Record, options?: { - id?: number; - 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; +/** + * 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. + */ 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; +/** + * Decrypt using a caller-supplied local key history. Recipient key + * identifiers remain absent from the serialized envelope. + */ +export function decrypt_data_value_with_keyrings(value: Uint8Array, keyrings: any, expected_purpose: number): 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; +/** + * Generate a fresh Ed25519 keypair. + * + * Returns `{ signer: WasmEd25519Signer, secretKey: Uint8Array, publicKey: Uint8Array }`. + */ +export function ed25519_generate(): any; -export function protected_claimed_signer_id( - frame: Uint8Array, - keyrings: Uint8Array | Uint8Array[], - encryption_purpose: number, -): bigint; - -export function ed25519_generate(): Ed25519GenerateResult; +/** + * Standalone Ed25519 signature verification. + */ export function ed25519_verify(public_key: Uint8Array, message: Uint8Array, signature: Uint8Array): void; + +/** + * Encode one standalone `DataValue` using the negotiated/current type map. + */ +export function encode_data_value(value: any): Uint8Array; + +/** + * Encode one standalone `DataValue` using explicit recursion and output + * limits. The compatibility entry point above keeps the historical default. + */ +export function encode_data_value_with_limits(value: any, limits: any): Uint8Array; + +/** + * Encrypt a serialized `DataValue` for one recipient using the canonical + * multi-recipient envelope. + */ +export function encrypt_data_value(value: Uint8Array, recipient_public_key_bundle: Uint8Array, purpose: number): Uint8Array; + +/** + * 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. + */ +export function encrypt_data_value_for_recipients(value: Uint8Array, recipient_public_key_bundles: any, purpose: number): Uint8Array; + +/** + * Parse any MTP frame into the human-readable CommunicationValue display form. + */ export function format_frame(frame: Uint8Array): string; -export function parse_data_value(value: Uint8Array): ParsedDataValue; -export function encode_data_value(value: unknown): Uint8Array; + +/** + * Forward a sealed relay frame to another clear next hop without opening or + * re-encoding its authenticated encrypted payload. + */ +export function forward_encrypted_relay_frame(frame: Uint8Array, next_hop_receiver_id: bigint): Uint8Array; + +/** + * Build a [`Keyring`] containing only an Ed25519 keypair (no KEM, no ML-DSA). + * + * Takes the Ed25519 secret key and public key, each 32 bytes. + * Returns the serialised keyring bytes, suitable for passing to `WasmClient.auth_register`. + */ export function keyring_from_ed25519(secret_key: Uint8Array, public_key: Uint8Array): Uint8Array; + +/** + * Generate a full keyring with KEM, ML-DSA, and Ed25519 keys. + */ export function keyring_generate(): Uint8Array; + +/** + * Generate a full keyring and report serialization failures to JavaScript. + */ +export function keyring_generate_checked(): Uint8Array; + export function main(): void; + +/** + * Return the canonical MTP pipe-session encryption purpose. + */ +export function mtp_pipe_session_encryption_purpose(): number; + +/** + * Return the canonical MTP pipe-session signature purpose. + */ +export function mtp_pipe_session_signature_purpose(): number; + +/** + * Explicit compatibility policy value accepting any signature suite + * supported by this WASM build. New callers should prefer a fixed suite. + */ +export function mtp_protection_signature_suite_any_supported(): number; + +export function mtp_protection_signature_suite_dual(): number; + +export function mtp_protection_signature_suite_ed25519(): number; + +/** + * Return the canonical MTP relay content-encryption purpose. + */ +export function mtp_relay_content_encryption_purpose(): number; + +/** + * Return the canonical MTP relay content-signature purpose. + */ +export function mtp_relay_content_signature_purpose(): number; + +/** + * Return the canonical MTP relay metadata-encryption purpose. + */ +export function mtp_relay_metadata_encryption_purpose(): number; + +/** + * Return the canonical MTP relay metadata-signature purpose. + */ +export function mtp_relay_metadata_signature_purpose(): number; + +/** + * Length, in bytes, of symmetric keys produced by the MTP key-derivation + * bindings. SDKs should query this instead of duplicating the crypto + * primitive's output size. + */ +export function mtp_symmetric_key_length(): number; + +/** + * Open a bounded protected value without replay protection. The raw WASM + * boundary cannot accept a native replay-guard trait, so message-processing + * callers must use the SDK guard or a native checked API. + */ +export function open_protected_with_keyrings_with_limits_without_replay(frame: Uint8Array, keyrings: any, expected_signer_id: any, signer_public_key_bundles: any, expected_receiver_id: any, signature_purpose: number, encryption_purpose: number, signature_suite: number, limits: any): WasmVerifiedProtectedMessage; + +/** + * Open a protected value without replay protection. This raw entry point is + * intended for stored/forensic messages; message-processing callers should + * apply their replay guard in the SDK or use a checked native API. + */ +export function open_protected_with_keyrings_without_replay(frame: Uint8Array, keyrings: any, expected_signer_id: any, signer_public_key_bundles: any, expected_receiver_id: any, signature_purpose: number, encryption_purpose: number, signature_suite: number): WasmVerifiedProtectedMessage; + +/** + * Open bounded relay content without replay protection. Replay is consumed + * when metadata is accepted by the live SDK/native processing boundary. + */ +export function open_relay_content_with_keyrings_with_limits_without_replay(metadata: WasmVerifiedRelayMetadata, keyrings: any, signer_public_key_bundles: any, expected_final_recipient_id: any, signature_suite: number, limits: any): WasmVerifiedRelayContent; + +/** + * Open relay content without making a second replay decision. Replay is + * consumed when live message processing accepts the authenticated metadata. + */ +export function open_relay_content_with_keyrings_without_replay(metadata: WasmVerifiedRelayMetadata, keyrings: any, signer_public_key_bundles: any, expected_final_recipient_id: any, signature_suite: number): WasmVerifiedRelayContent; + +/** + * Open bounded relay metadata without replay protection. Use the SDK's + * message-processing guard or a native checked API for live traffic. + */ +export function open_relay_metadata_with_keyrings_with_limits_without_replay(frame: Uint8Array, keyrings: any, expected_signer_id: any, signer_public_key_bundles: any, signature_suite: number, limits: any): WasmVerifiedRelayMetadata; + +/** + * Open relay metadata without replay protection. This raw entry point is for + * stored/forwarded messages; message-processing paths should add a guard in + * the SDK or use the checked native API. + */ +export function open_relay_metadata_with_keyrings_without_replay(frame: Uint8Array, keyrings: any, expected_signer_id: any, signer_public_key_bundles: any, signature_suite: number): WasmVerifiedRelayMetadata; + +/** + * Parse an auth response frame into a JS object. + */ export function parse_auth_response(response: Uint8Array): AuthResponse; + +/** + * 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. + */ +export function parse_data_value(value: Uint8Array): ParsedDataValue; + +/** + * Parse a standalone serialized `DataValue` with the caller's bounded + * receive policy. The compatibility `parse_data_value` entry point retains + * the default policy for existing callers. + */ +export function parse_data_value_with_limits(value: Uint8Array, limits: any): ParsedDataValue; + +/** + * Parse any MTP frame into structured JavaScript data. + */ export function parse_frame(frame: Uint8Array): ParsedFrame; + +/** + * Parse a frame with the caller's bounded receive policy. The compatibility + * `parse_frame` entry point retains the default policy for existing callers. + */ +export function parse_frame_with_limits(frame: Uint8Array, limits: any): ParsedFrame; + +/** + * Read the claimed, unverified signer ID after decrypting the protected + * payload. The result may only select trusted keys for the same signer ID. + */ +export function protected_claimed_signer_id(frame: Uint8Array, keyrings: any, encryption_purpose: number): bigint; + +export function protected_claimed_signer_id_with_limits(frame: Uint8Array, keyrings: any, encryption_purpose: number, limits: any): bigint; + +/** + * 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. + */ +export function relay_metadata_claimed_signer_id(frame: Uint8Array, keyrings: any): bigint; + +export function relay_metadata_claimed_signer_id_with_limits(frame: Uint8Array, keyrings: any, limits: any): bigint; + +/** + * Sign a serialized `DataValue` using the selected suite from a serialized + * keyring. + */ +export function sign_data_value_with_keyring(value: Uint8Array, signer_id: bigint, purpose: number, keyring: Uint8Array, signature_suite: number): Uint8Array; + +/** + * 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. + */ +export function verify_data_value_with_policy(value: Uint8Array, public_key_bundle: Uint8Array, expected_signer_id: bigint, expected_purpose: number, signature_suite: number): void; + +/** + * Derive a 32-byte key from a passphrase using explicit Argon2id parameters. + * The salt and parameters are part of the caller's protected-data format. + */ +export function wasm_argon2id(passphrase: Uint8Array, salt: Uint8Array, memory_kib: number, iterations: number, lanes: number): Uint8Array; + +/** + * Derive a 32-byte encryption key from `ikm` with `salt` and `context`. + */ export function wasm_derive_encryption_key(ikm: Uint8Array, salt: Uint8Array, context: Uint8Array): Uint8Array; + +/** + * HKDF-expand: derive `len` bytes from `ikm` with `salt` and `info`. + */ export function wasm_hkdf_expand(ikm: Uint8Array, salt: Uint8Array, info: Uint8Array, len: number): Uint8Array; + +/** + * Decapsulate a KEM `ciphertext` with the recipient's `private_key`. + * + * Returns the same shared secret the initiator obtained from + * [`wasm_kem_encapsulate`]. + */ export function wasm_kem_decapsulate(recipient_private_key: Uint8Array, ciphertext: Uint8Array): Uint8Array; + +/** + * Encapsulate a fresh shared secret for `recipient_public_key`. + * + * Returns a [`WasmEncapsulated`] containing the shared secret and the KEM + * ciphertext that the recipient needs to recover it via + * [`wasm_kem_decapsulate`]. + */ export function wasm_kem_encapsulate(recipient_public_key: Uint8Array): WasmEncapsulated; + export function wasm_kem_generate_keypair(): WasmKemKeypair; + +/** + * SHA-256 digest. + */ export function wasm_sha256(data: Uint8Array): Uint8Array; + +/** + * Double SHA-256 (SHA-256 applied twice). + */ export function wasm_sha256_double(data: Uint8Array): Uint8Array; +export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; + +export interface InitOutput { + readonly memory: WebAssembly.Memory; + readonly __wbg_wasmchacha20poly1305_free: (a: number, b: number) => void; + readonly __wbg_wasmed25519signer_free: (a: number, b: number) => void; + readonly __wbg_wasmencapsulated_free: (a: number, b: number) => void; + readonly __wbg_wasmkemkeypair_free: (a: number, b: number) => void; + readonly __wbg_wasmkeyring_free: (a: number, b: number) => void; + readonly __wbg_wasmpublickeybundle_free: (a: number, b: number) => void; + readonly build_encrypted_relay_frame_with_keyring: (a: number, b: number, c: any, d: bigint, e: bigint, f: bigint, g: number, h: number, i: bigint, j: number, k: number, l: number, m: number, n: number, o: any, p: any) => [number, number, number, number]; + readonly build_encrypted_relay_frame_with_keyring_with_limits: (a: number, b: number, c: any, d: bigint, e: bigint, f: bigint, g: number, h: number, i: bigint, j: number, k: number, l: number, m: number, n: number, o: any, p: any, q: any) => [number, number, number, number]; + readonly decrypt_data_value: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number]; + readonly decrypt_data_value_with_keyrings: (a: number, b: number, c: any, d: number) => [number, number, number, number]; + readonly ed25519_generate: () => [number, number, number]; + readonly ed25519_verify: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number]; + readonly encrypt_data_value: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number]; + readonly encrypt_data_value_for_recipients: (a: number, b: number, c: any, d: number) => [number, number, number, number]; + readonly forward_encrypted_relay_frame: (a: number, b: number, c: bigint) => [number, number, number, number]; + readonly keyring_from_ed25519: (a: number, b: number, c: number, d: number) => [number, number, number, number]; + readonly keyring_generate: () => [number, number, number, number]; + readonly keyring_generate_checked: () => [number, number, number, number]; + readonly mtp_pipe_session_encryption_purpose: () => number; + readonly mtp_pipe_session_signature_purpose: () => number; + readonly mtp_protection_signature_suite_any_supported: () => number; + readonly mtp_protection_signature_suite_dual: () => number; + readonly mtp_protection_signature_suite_ed25519: () => number; + readonly mtp_relay_content_encryption_purpose: () => number; + readonly mtp_relay_content_signature_purpose: () => number; + readonly mtp_relay_metadata_encryption_purpose: () => number; + readonly mtp_relay_metadata_signature_purpose: () => number; + readonly mtp_symmetric_key_length: () => number; + readonly sign_data_value_with_keyring: (a: number, b: number, c: bigint, d: number, e: number, f: number, g: number) => [number, number, number, number]; + readonly verify_data_value_with_policy: (a: number, b: number, c: number, d: number, e: bigint, f: number, g: number) => [number, number]; + readonly wasm_argon2id: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => [number, number, number, number]; + readonly wasm_derive_encryption_key: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number, number, number]; + readonly wasm_hkdf_expand: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => [number, number, number, number]; + readonly wasm_kem_decapsulate: (a: number, b: number, c: number, d: number) => [number, number, number, number]; + readonly wasm_kem_encapsulate: (a: number, b: number) => [number, number, number]; + readonly wasm_kem_generate_keypair: () => number; + readonly wasm_sha256: (a: number, b: number) => [number, number]; + readonly wasm_sha256_double: (a: number, b: number) => [number, number]; + readonly wasmchacha20poly1305_decrypt: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number]; + readonly wasmchacha20poly1305_encrypt: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number]; + readonly wasmchacha20poly1305_new: (a: number, b: number) => [number, number, number]; + readonly wasmed25519signer_new: (a: number, b: number) => [number, number, number]; + readonly wasmed25519signer_sign: (a: number, b: number, c: number) => [number, number, number, number]; + readonly wasmed25519signer_verify: (a: number, b: number, c: number, d: number, e: number) => [number, number]; + readonly wasmencapsulated_ciphertext: (a: number) => [number, number]; + readonly wasmencapsulated_shared_secret: (a: number) => [number, number]; + readonly wasmkemkeypair_public_key: (a: number) => [number, number]; + readonly wasmkemkeypair_secret_key: (a: number) => [number, number]; + readonly wasmkeyring_from_bytes: (a: number, b: number) => [number, number, number]; + readonly wasmkeyring_public_key_bundle: (a: number) => number; + readonly wasmkeyring_to_bytes: (a: number) => [number, number, number, number]; + readonly wasmkeyring_try_to_bytes: (a: number) => [number, number, number, number]; + readonly wasmkeyring_validate_encryption: (a: number) => [number, number]; + readonly wasmkeyring_validate_full: (a: number) => [number, number]; + readonly wasmpublickeybundle_from_bytes: (a: number, b: number) => [number, number, number]; + readonly wasmpublickeybundle_from_bytes_unvalidated: (a: number, b: number) => [number, number, number]; + readonly wasmpublickeybundle_kem_public_key: (a: number) => [number, number]; + readonly wasmpublickeybundle_sig_cl_public_key: (a: number) => [number, number]; + readonly wasmpublickeybundle_sig_pq_public_key: (a: number) => [number, number]; + readonly wasmpublickeybundle_to_bytes: (a: number) => [number, number, number, number]; + readonly wasmpublickeybundle_try_to_bytes: (a: number) => [number, number, number, number]; + readonly build_frame: (a: number, b: number, c: any, d: any) => [number, number, number, number]; + readonly build_frame_with_limits: (a: number, b: number, c: any, d: any, e: any) => [number, number, number, number]; + readonly build_frame_with_payload: (a: number, b: number, c: number, d: number, e: any) => [number, number, number, number]; + readonly build_frame_with_payload_with_limits: (a: number, b: number, c: number, d: number, e: any, f: any) => [number, number, number, number]; + readonly build_ping_frame: (a: bigint, b: number, c: number, d: bigint, e: number, f: number) => [number, number, number, number]; + readonly encode_data_value: (a: any) => [number, number, number, number]; + readonly encode_data_value_with_limits: (a: any, b: any) => [number, number, number, number]; + readonly format_frame: (a: number, b: number) => [number, number, number, number]; + readonly parse_auth_response: (a: number, b: number) => [number, number, number]; + readonly parse_data_value: (a: number, b: number) => [number, number, number]; + readonly parse_data_value_with_limits: (a: number, b: number, c: any) => [number, number, number]; + readonly parse_frame: (a: number, b: number) => [number, number, number]; + readonly parse_frame_with_limits: (a: number, b: number, c: any) => [number, number, number]; + readonly __wbg_wasmclient_free: (a: number, b: number) => void; + readonly wasmclient_client_id: (a: number) => bigint; + readonly wasmclient_is_supported: () => number; + readonly wasmclient_new: (a: number, b: number, c: number) => number; + readonly wasmclient_ping_ms: (a: number) => [number, number]; + readonly wasmclient_request: (a: number, b: number, c: number, d: number, e: number, f: number) => any; + readonly wasmclient_send: (a: number, b: number, c: number) => any; + readonly wasmclient_set_receive_limits: (a: number, b: any) => [number, number]; + readonly wasmclient_state: (a: number) => number; + readonly wasmclient_subscribe: (a: number, b: number, c: number, d: any) => number; + readonly wasmclient_unsubscribe: (a: number, b: number) => number; + readonly __wbg_wasmverifiedrelaycontent_free: (a: number, b: number) => void; + readonly __wbg_wasmverifiedrelaymetadata_free: (a: number, b: number) => void; + readonly open_relay_content_with_keyrings_with_limits_without_replay: (a: number, b: any, c: any, d: any, e: number, f: any) => [number, number, number]; + readonly open_relay_content_with_keyrings_without_replay: (a: number, b: any, c: any, d: any, e: number) => [number, number, number]; + readonly open_relay_metadata_with_keyrings_with_limits_without_replay: (a: number, b: number, c: any, d: any, e: any, f: number, g: any) => [number, number, number]; + readonly open_relay_metadata_with_keyrings_without_replay: (a: number, b: number, c: any, d: any, e: any, f: number) => [number, number, number]; + readonly relay_metadata_claimed_signer_id: (a: number, b: number, c: any) => [bigint, number, number]; + readonly relay_metadata_claimed_signer_id_with_limits: (a: number, b: number, c: any, d: any) => [bigint, number, number]; + readonly wasmverifiedrelaycontent_content: (a: number) => [number, number, number, number]; + readonly wasmverifiedrelaycontent_final_recipient_id: (a: number) => bigint; + readonly wasmverifiedrelaycontent_message_type: (a: number) => [number, number]; + readonly wasmverifiedrelaycontent_signer_id: (a: number) => bigint; + readonly wasmverifiedrelaymetadata_created_at: (a: number) => bigint; + readonly wasmverifiedrelaymetadata_encrypted_content: (a: number) => [number, number, number, number]; + readonly wasmverifiedrelaymetadata_final_recipient_id: (a: number) => bigint; + readonly wasmverifiedrelaymetadata_matched_signer_key_index: (a: number) => number; + readonly wasmverifiedrelaymetadata_message_id: (a: number) => [number, number]; + readonly wasmverifiedrelaymetadata_metadata: (a: number) => [number, number, number]; + readonly wasmverifiedrelaymetadata_relay_version: (a: number) => bigint; + readonly wasmverifiedrelaymetadata_signer_id: (a: number) => bigint; + readonly __wbg_wasmverifiedprotectedmessage_free: (a: number, b: number) => void; + readonly build_protected_frame_with_keyring: (a: number, b: number, c: number, d: number, e: bigint, f: bigint, g: number, h: number, i: bigint, j: number, k: number, l: number, m: number, n: number, o: number, p: number, q: any) => [number, number, number, number]; + readonly build_protected_frame_with_keyring_with_limits: (a: number, b: number, c: number, d: number, e: bigint, f: bigint, g: number, h: number, i: bigint, j: number, k: number, l: number, m: number, n: number, o: number, p: number, q: any, r: any) => [number, number, number, number]; + readonly open_protected_with_keyrings_with_limits_without_replay: (a: number, b: number, c: any, d: any, e: any, f: any, g: number, h: number, i: number, j: any) => [number, number, number]; + readonly open_protected_with_keyrings_without_replay: (a: number, b: number, c: any, d: any, e: any, f: any, g: number, h: number, i: number) => [number, number, number]; + readonly protected_claimed_signer_id: (a: number, b: number, c: any, d: number) => [bigint, number, number]; + readonly protected_claimed_signer_id_with_limits: (a: number, b: number, c: any, d: number, e: any) => [bigint, number, number]; + readonly wasmverifiedprotectedmessage_content: (a: number) => [number, number, number, number]; + readonly wasmverifiedprotectedmessage_created_at: (a: number) => bigint; + readonly wasmverifiedprotectedmessage_final_recipient_id: (a: number) => bigint; + readonly wasmverifiedprotectedmessage_matched_signer_key_index: (a: number) => number; + readonly wasmverifiedprotectedmessage_message_id: (a: number) => [number, number]; + readonly wasmverifiedprotectedmessage_message_type: (a: number) => [number, number]; + readonly wasmverifiedprotectedmessage_protected_version: (a: number) => bigint; + readonly wasmverifiedprotectedmessage_signer_id: (a: number) => bigint; + readonly __wbg_wasmpipehandle_free: (a: number, b: number) => void; + readonly wasmpipehandle_description: (a: number) => [number, number]; + readonly wasmpipehandle_pipe_id: (a: number) => number; + readonly wasmpipehandle_wait: (a: number) => any; + readonly __wbg_wasmsubscriptionrouter_free: (a: number, b: number) => void; + readonly wasmsubscriptionrouter_dispatch: (a: number, b: number, c: number, d: any) => number; + readonly wasmsubscriptionrouter_new: () => number; + readonly wasmsubscriptionrouter_subscribe: (a: number, b: number, c: number, d: any) => void; + readonly wasmsubscriptionrouter_unsubscribe: (a: number, b: number, c: number) => number; + readonly main: () => void; + readonly wasmclient_authConnectOwned: (a: number, b: number, c: number, d: number, e: number, f: number, g: bigint) => any; + readonly wasmclient_authRegisterOwned: (a: number, b: number, c: number, d: number, e: number, f: number) => any; + readonly wasmclient_auth_connect: (a: number, b: number, c: number, d: number, e: number, f: number, g: bigint) => any; + readonly wasmclient_auth_register: (a: number, b: number, c: number, d: number, e: number, f: number) => any; + readonly wasmclient_connect: (a: number, b: number) => any; + readonly wasmclient_connectOwned: (a: number, b: number) => any; + readonly __wbg_connectionconfig_free: (a: number, b: number) => void; + readonly __wbg_pipereader_free: (a: number, b: number) => void; + readonly __wbg_pipewriter_free: (a: number, b: number) => void; + readonly connectionconfig_client_id: (a: number) => bigint; + readonly connectionconfig_description: (a: number) => [number, number]; + readonly connectionconfig_max_message_size: (a: number) => number; + readonly connectionconfig_new: (a: number, b: number) => number; + readonly connectionconfig_require_pq: (a: number) => number; + readonly connectionconfig_set_client_id: (a: number, b: bigint) => void; + readonly connectionconfig_set_description: (a: number, b: number, c: number) => void; + readonly connectionconfig_set_max_message_size: (a: number, b: number) => void; + readonly connectionconfig_set_require_pq: (a: number, b: number) => void; + readonly connectionconfig_set_server_certificate_hashes: (a: number, b: number, c: number) => void; + readonly connectionconfig_url: (a: number) => [number, number]; + readonly pipereader_description: (a: number) => [number, number]; + readonly pipereader_pipe_id: (a: number) => number; + readonly pipereader_read: (a: number) => any; + readonly pipewriter_abort: (a: number) => [number, number]; + readonly pipewriter_close: (a: number) => any; + readonly pipewriter_pipe_id: (a: number) => number; + readonly pipewriter_write: (a: number, b: number, c: number) => any; + readonly wasmclient_accept_pipe: (a: number, b: number) => any; + readonly wasmclient_create_pipe: (a: number, b: number, c: number) => any; + readonly wasmclient_deny_pipe: (a: number, b: number) => any; + readonly wasmclient_disconnect: (a: number) => void; + readonly wasmclient_set_on_pipe_request: (a: number, b: number) => void; + readonly wasmclient_start_protocol_pings: (a: number, b: number, c: bigint) => [number, number]; + readonly wasmclient_stop_protocol_pings: (a: number) => void; + readonly wasm_bindgen__convert__closures_____invoke__hbfde51476b904d71: (a: number, b: number, c: any) => [number, number]; + readonly wasm_bindgen__convert__closures_____invoke__h7cf76fd16cb52006: (a: number, b: number, c: any, d: any) => void; + readonly wasm_bindgen__convert__closures_____invoke__heaefed2e0f18042f: (a: number, b: number) => void; + readonly __wbindgen_malloc: (a: number, b: number) => number; + readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; + readonly __wbindgen_exn_store: (a: number) => void; + readonly __externref_table_alloc: () => number; + readonly __wbindgen_externrefs: WebAssembly.Table; + readonly __wbindgen_free: (a: number, b: number, c: number) => void; + readonly __wbindgen_destroy_closure: (a: number, b: number) => void; + readonly __externref_table_dealloc: (a: number) => void; + readonly __wbindgen_start: () => void; +} + +export type SyncInitInput = BufferSource | WebAssembly.Module; + +/** + * Instantiates the given `module`, which can either be bytes or + * a precompiled `WebAssembly.Module`. + * + * @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated. + * + * @returns {InitOutput} + */ export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput; -export default function init( - module_or_path?: { module_or_path: InitInput | Promise } | InitInput | Promise, -): Promise; +/** + * If `module_or_path` is {RequestInfo} or {URL}, makes a request and + * for everything else, calls `WebAssembly.instantiate` directly. + * + * @param {{ module_or_path: InitInput | Promise }} module_or_path - Passing `InitInput` directly is deprecated. + * + * @returns {Promise} + */ +export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise } | InitInput | Promise): Promise; From b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:53:02 +0200 Subject: [PATCH 04/22] [Fix] Clean --- codec/src/communication_value.rs | 14 +- codec/src/data_value.rs | 95 +++++++++--- codec/src/protected.rs | 4 + codec/src/relay.rs | 78 ++++++---- create-web-release.mjs | 243 +++++++++++++++++++++++++++++++ crypto/src/helper.rs | 2 +- example/client/src/protected.rs | 12 +- example/server/src/handlers.rs | 39 +++-- flake.nix | 1 - host/src/config.rs | 9 +- package.json | 3 +- transport/src/connection.rs | 8 +- 12 files changed, 433 insertions(+), 75 deletions(-) create mode 100644 create-web-release.mjs diff --git a/codec/src/communication_value.rs b/codec/src/communication_value.rs index 95b0cf0..1e48422 100644 --- a/codec/src/communication_value.rs +++ b/codec/src/communication_value.rs @@ -916,9 +916,19 @@ mod tests { 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))?; + signed.verify_with_policy( + SENDER_ID, + &signer_public_keys, + ProtectionPurpose::from(1), + crate::ProtectionPolicy::any_supported(), + )?; assert_eq!( - signed.into_verified(SENDER_ID, &signer_public_keys, ProtectionPurpose::from(1))?, + signed.into_verified_with_policy( + SENDER_ID, + &signer_public_keys, + ProtectionPurpose::from(1), + crate::ProtectionPolicy::any_supported(), + )?, clear_payload ); Ok(()) diff --git a/codec/src/data_value.rs b/codec/src/data_value.rs index 36065f8..6eb7c1b 100644 --- a/codec/src/data_value.rs +++ b/codec/src/data_value.rs @@ -2391,10 +2391,11 @@ mod tests { assert_eq!(&encoded[15 + signature_len..], inner); let decoded = DataValue::from_bytes(&encoded).ok_or("signed value did not decode")?; - decoded.verify( + decoded.verify_with_policy( 0x0102_0304_0506_0708, &public_keys, ProtectionPurpose::from(0xA5), + ProtectionPolicy::any_supported(), )?; assert!(matches!( decoded.clone().into_verified_with_policy( @@ -2406,16 +2407,18 @@ mod tests { Err(ProtectionError::SignaturePolicyMismatch { .. }) )); // Verification is non-consuming, so it can safely be repeated. - decoded.verify( + decoded.verify_with_policy( 0x0102_0304_0506_0708, &public_keys, ProtectionPurpose::from(0xA5), + ProtectionPolicy::any_supported(), )?; assert_eq!( - decoded.clone().into_verified( + decoded.clone().into_verified_with_policy( 0x0102_0304_0506_0708, &public_keys, ProtectionPurpose::from(0xA5), + ProtectionPolicy::any_supported(), )?, original ); @@ -2424,10 +2427,11 @@ mod tests { return Err("expected signed value".into()); }; assert_eq!( - wrapper.into_verified( + wrapper.into_verified_with_policy( 0x0102_0304_0506_0708, &public_keys, ProtectionPurpose::from(0xA5), + ProtectionPolicy::any_supported(), )?, original ); @@ -2503,7 +2507,12 @@ mod tests { }; wrong_purpose.purpose ^= 1; assert!(matches!( - wrong_purpose.verify(41, &public_keys, ProtectionPurpose::from(7)), + wrong_purpose.verify_with_policy( + 41, + &public_keys, + ProtectionPurpose::from(7), + ProtectionPolicy::any_supported(), + ), Err(ProtectionError::PurposeMismatch { .. }) )); @@ -2512,7 +2521,12 @@ mod tests { }; wrong_signer_id.signer_id ^= 1; assert!(matches!( - wrong_signer_id.verify(41, &public_keys, ProtectionPurpose::from(7)), + wrong_signer_id.verify_with_policy( + 41, + &public_keys, + ProtectionPurpose::from(7), + ProtectionPolicy::any_supported(), + ), Err(ProtectionError::SignerIdMismatch { .. }) )); @@ -2521,7 +2535,12 @@ mod tests { }; wrong_signature.signature[0] ^= 1; assert!(matches!( - wrong_signature.verify(41, &public_keys, ProtectionPurpose::from(7)), + wrong_signature.verify_with_policy( + 41, + &public_keys, + ProtectionPurpose::from(7), + ProtectionPolicy::any_supported(), + ), Err(ProtectionError::InvalidSignature) )); @@ -2530,7 +2549,12 @@ mod tests { }; *wrong_value.value = DataValue::Str("replacement".into()); assert!(matches!( - wrong_value.verify(41, &public_keys, ProtectionPurpose::from(7)), + wrong_value.verify_with_policy( + 41, + &public_keys, + ProtectionPurpose::from(7), + ProtectionPolicy::any_supported(), + ), Err(ProtectionError::InvalidSignature) )); Ok(()) @@ -2593,9 +2617,19 @@ mod tests { 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))?; + opened.verify_with_policy( + 7, + &public_keys, + ProtectionPurpose::from(1), + ProtectionPolicy::any_supported(), + )?; assert_eq!( - opened.into_verified(7, &public_keys, ProtectionPurpose::from(1))?, + opened.into_verified_with_policy( + 7, + &public_keys, + ProtectionPurpose::from(1), + ProtectionPolicy::any_supported(), + )?, value ); Ok(()) @@ -2629,8 +2663,18 @@ mod tests { 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))?; + signed.verify_with_policy( + 7, + &public_keys, + ProtectionPurpose::from(1), + ProtectionPolicy::any_supported(), + )?; + let encrypted = signed.into_verified_with_policy( + 7, + &public_keys, + ProtectionPurpose::from(1), + ProtectionPolicy::any_supported(), + )?; assert!(matches!(encrypted, DataValue::Encrypted(_))); assert_eq!( encrypted.decrypt(&keyring, ProtectionPurpose::from(2))?, @@ -2680,10 +2724,11 @@ mod tests { let mut signer_keys = outer_recipient.public_key_bundle(); signer_keys.sig_cl_public_key = signer_public; - let middle = outer_signed.into_verified( + let middle = outer_signed.into_verified_with_policy( OUTER_SIGNER_ID, &signer_keys, ProtectionPurpose::from(1), + ProtectionPolicy::any_supported(), )?; let DataValue::Container(entries) = middle else { return Err("expected container inside outer signature".into()); @@ -2700,10 +2745,11 @@ mod tests { }; assert_eq!(inner_wrapper.signer_id, INNER_SIGNER_ID); assert_eq!( - inner_signed.into_verified( + inner_signed.into_verified_with_policy( INNER_SIGNER_ID, &signer_keys, ProtectionPurpose::from(3), + ProtectionPolicy::any_supported(), )?, leaf ); @@ -2861,11 +2907,21 @@ mod tests { let public_keys = keyring.public_key_bundle(); assert!(matches!( - DataValue::Null.verify(1, &public_keys, ProtectionPurpose::from(1)), + DataValue::Null.verify_with_policy( + 1, + &public_keys, + ProtectionPurpose::from(1), + ProtectionPolicy::any_supported(), + ), Err(ProtectionError::NotSigned) )); assert!(matches!( - DataValue::Null.into_verified(1, &public_keys, ProtectionPurpose::from(1)), + DataValue::Null.into_verified_with_policy( + 1, + &public_keys, + ProtectionPurpose::from(1), + ProtectionPolicy::any_supported(), + ), Err(ProtectionError::NotSigned) )); assert!(matches!( @@ -2909,7 +2965,12 @@ mod tests { }; signed.signature[0] ^= 1; assert!(matches!( - DataValue::Signed(signed).verify(1, &signing_keys, ProtectionPurpose::from(1)), + DataValue::Signed(signed).verify_with_policy( + 1, + &signing_keys, + ProtectionPurpose::from(1), + ProtectionPolicy::any_supported(), + ), Err(ProtectionError::InvalidSignature) )); diff --git a/codec/src/protected.rs b/codec/src/protected.rs index cd8ee0f..dd4f1c2 100644 --- a/codec/src/protected.rs +++ b/codec/src/protected.rs @@ -133,6 +133,10 @@ impl InMemoryReplayGuard { pub fn len(&self) -> usize { self.accepted.len() } + + pub fn is_empty(&self) -> bool { + self.accepted.is_empty() + } } impl ReplayGuard for InMemoryReplayGuard { diff --git a/codec/src/relay.rs b/codec/src/relay.rs index a09ef4f..8633a75 100644 --- a/codec/src/relay.rs +++ b/codec/src/relay.rs @@ -1022,6 +1022,18 @@ mod tests { Ed25519Signer::new(&keyring.sig_cl_secret_key).expect("Ed25519 signer") } + fn content_open_options( + metadata: &VerifiedRelayMetadata, + policy: ProtectionPolicy, + ) -> RelayOpenOptions { + RelayOpenOptions { + policy, + decode_limits: metadata.decode_limits, + encode_limits: metadata.encode_limits, + protected_limits: metadata.protected_limits, + } + } + // Test-only compatibility shims keep older fixture setup readable while // routing every invocation to an explicit replay choice in production. fn open_relay_metadata( @@ -1345,12 +1357,12 @@ mod tests { ) .expect("final recipient metadata"); assert_eq!(final_metadata.metadata(), Some(&application_metadata)); - let final_content = open_relay_content( + let final_content = open_relay_content_with_limits_without_replay( &final_metadata, - &final_recipient, - &sender.public_key_bundle(), - 42, - policy, + &[&final_recipient], + std::slice::from_ref(&sender.public_key_bundle()), + Some(42), + content_open_options(&final_metadata, policy), ) .expect("final recipient content"); assert_eq!(final_content.content, DataValue::Str("hello".into())); @@ -1393,22 +1405,22 @@ mod tests { )); assert!( - open_relay_content( + open_relay_content_with_limits_without_replay( &metadata, - &metadata_recipient, - &sender.public_key_bundle(), - 42, - policy, + &[&metadata_recipient], + std::slice::from_ref(&sender.public_key_bundle()), + Some(42), + content_open_options(&metadata, policy), ) .is_err() ); - let content = open_relay_content( + let content = open_relay_content_with_limits_without_replay( &metadata, - &final_recipient, - &sender.public_key_bundle(), - 42, - policy, + &[&final_recipient], + std::slice::from_ref(&sender.public_key_bundle()), + Some(42), + content_open_options(&metadata, policy), ) .expect("content"); assert_eq!(content.signer_id, 7); @@ -1416,12 +1428,12 @@ mod tests { assert_eq!(content.message_type, "ProtectedMessage"); assert_eq!(content.content, DataValue::Str("hello".into())); assert!(matches!( - open_relay_content( + open_relay_content_with_limits_without_replay( &metadata, - &final_recipient, - &sender.public_key_bundle(), - 43, - policy, + &[&final_recipient], + std::slice::from_ref(&sender.public_key_bundle()), + Some(43), + content_open_options(&metadata, policy), ), Err(RelayError::NotFinalRecipient) )); @@ -1512,7 +1524,12 @@ mod tests { .expect("relay frame"); assert_eq!( - relay_metadata_claimed_signer_id(&frame, &[&recipient]).expect("signer ID"), + relay_metadata_claimed_signer_id_with_limits( + &frame, + &[&recipient], + DecodeLimits::default(), + ) + .expect("signer ID"), 7 ); let metadata = open_relay_metadata_with_keys( @@ -1523,12 +1540,15 @@ mod tests { ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), ) .expect("relay metadata"); - let content = open_relay_content_with_keyrings( + let content = open_relay_content_with_limits_without_replay( &metadata, &[&recipient], &[sender.public_key_bundle()], Some(42), - ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + content_open_options( + &metadata, + ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), + ), ) .expect("relay content"); @@ -1571,12 +1591,12 @@ mod tests { ) .expect("relay metadata"); - let content = open_relay_content_with_keyrings( + let content = open_relay_content_with_limits_without_replay( &metadata, &[¤t_content_recipient, &previous_content_recipient], &[sender.public_key_bundle()], Some(42), - policy, + content_open_options(&metadata, policy), ) .expect("previous content recipient key should decrypt"); @@ -1680,12 +1700,12 @@ mod tests { ) .expect("metadata signed by a previous key should verify"); assert_eq!(metadata.matched_signer_key_index(), 1); - let content = open_relay_content_with_keys( + let content = open_relay_content_with_limits_without_replay( &metadata, - &recipient, + &[&recipient], &[current_signer_public, old_signer_public], - 42, - policy, + Some(42), + content_open_options(&metadata, policy), ) .expect("content signed by a previous key should verify"); assert_eq!(content.message_type, "ProtectedMessage"); diff --git a/create-web-release.mjs b/create-web-release.mjs new file mode 100644 index 0000000..690fa5c --- /dev/null +++ b/create-web-release.mjs @@ -0,0 +1,243 @@ +#!/usr/bin/env node + +import { execFile, spawn } from "node:child_process"; +import { access, cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; + +const execFileAsync = promisify(execFile); +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "."); +const packageJsonPath = path.join(repositoryRoot, "package.json"); + +function usage() { + return `Usage: node create-web-release.mjs [options] + +Build and pack the browser package using the version of the root Cargo package. + +Options: + --skip-build Pack the existing dist/ and wasm/pkg/ artifacts + --output-dir Write the archive to this directory (default: repository root) + --help Show this help +`; +} + +function parseArguments(arguments_) { + const options = { + outputDir: repositoryRoot, + skipBuild: false, + }; + + for (let index = 0; index < arguments_.length; index += 1) { + const argument = arguments_[index]; + if (argument === "--help") { + options.help = true; + } else if (argument === "--skip-build") { + options.skipBuild = true; + } else if (argument === "--output-dir") { + const outputDir = arguments_[index + 1]; + if (!outputDir || outputDir.startsWith("--")) { + throw new Error("--output-dir requires a directory path"); + } + options.outputDir = path.resolve(repositoryRoot, outputDir); + index += 1; + } else if (argument.startsWith("--output-dir=")) { + const outputDir = argument.slice("--output-dir=".length); + if (!outputDir) { + throw new Error("--output-dir requires a directory path"); + } + options.outputDir = path.resolve(repositoryRoot, outputDir); + } else { + throw new Error(`Unknown option: ${argument}`); + } + } + + return options; +} + +async function readJson(filePath) { + const source = await readFile(filePath, "utf8"); + try { + return JSON.parse(source); + } catch (error) { + throw new Error(`Invalid JSON in ${path.relative(repositoryRoot, filePath)}`, { + cause: error, + }); + } +} + +async function run(command, arguments_, options = {}) { + const renderedArguments = arguments_.map((argument) => JSON.stringify(argument)).join(" "); + console.log(`\n> ${command}${renderedArguments ? ` ${renderedArguments}` : ""}`); + + await new Promise((resolve, reject) => { + const child = spawn(command, arguments_, { + cwd: options.cwd ?? repositoryRoot, + env: options.env ?? process.env, + stdio: "inherit", + }); + + child.once("error", (error) => { + reject(new Error(`Failed to run ${command}: ${error.message}`, { cause: error })); + }); + child.once("exit", (code, signal) => { + if (code === 0) { + resolve(); + return; + } + + const reason = signal ? `signal ${signal}` : `exit code ${code}`; + reject(new Error(`${command} failed with ${reason}`)); + }); + }); +} + +async function readCargoVersion() { + let stdout; + try { + ({ stdout } = await execFileAsync( + "cargo", + [ + "metadata", + "--no-deps", + "--format-version", + "1", + "--manifest-path", + path.join(repositoryRoot, "Cargo.toml"), + ], + { cwd: repositoryRoot, maxBuffer: 1024 * 1024 }, + )); + } catch (error) { + throw new Error(`Unable to read the root Cargo package version: ${error.message}`, { + cause: error, + }); + } + + let metadata; + try { + metadata = JSON.parse(stdout); + } catch (error) { + throw new Error("cargo metadata returned invalid JSON", { cause: error }); + } + + const rootPackage = metadata.packages?.find((packageMetadata) => packageMetadata.name === "mtp"); + if (!rootPackage || typeof rootPackage.version !== "string") { + throw new Error("The root Cargo package named 'mtp' was not found"); + } + + return rootPackage.version; +} + +function packageRelativePath(entry) { + if (typeof entry !== "string" || entry.length === 0) { + throw new Error("package.json files entries must be non-empty strings"); + } + + const relativePath = entry.replace(/\/$/, ""); + if ( + !relativePath || + path.isAbsolute(relativePath) || + relativePath.split(/[\\/]/u).includes("..") || + relativePath.includes("*") + ) { + throw new Error(`Unsupported package file entry: ${entry}`); + } + + return relativePath; +} + +async function copyPackageFiles(stageRoot, packageJson) { + if (!Array.isArray(packageJson.files)) { + throw new Error("package.json must declare a files array for Web releases"); + } + + for (const entry of packageJson.files) { + const relativePath = packageRelativePath(entry); + const sourcePath = path.join(repositoryRoot, relativePath); + const destinationPath = path.join(stageRoot, relativePath); + + try { + await access(sourcePath); + } catch (error) { + throw new Error(`Release file is missing: ${relativePath}`, { cause: error }); + } + + await mkdir(path.dirname(destinationPath), { recursive: true }); + await cp(sourcePath, destinationPath, { recursive: true }); + } +} + +async function createRelease({ outputDir, packageJson, version }) { + const stageRoot = await mkdtemp(path.join(os.tmpdir(), "mtp-web-release-")); + const stagedPackageJson = { + ...packageJson, + version, + }; + + try { + await writeFile( + path.join(stageRoot, "package.json"), + `${JSON.stringify(stagedPackageJson, null, 2)}\n`, + ); + await copyPackageFiles(stageRoot, packageJson); + + const stagedWasmPackagePath = path.join(stageRoot, "wasm", "pkg", "package.json"); + const stagedWasmPackageJson = await readJson(stagedWasmPackagePath); + stagedWasmPackageJson.version = version; + await writeFile( + stagedWasmPackagePath, + `${JSON.stringify(stagedWasmPackageJson, null, 2)}\n`, + ); + + await mkdir(outputDir, { recursive: true }); + const archiveName = `${packageJson.name}-${version}.tgz`; + const archivePath = path.join(outputDir, archiveName); + await rm(archivePath, { force: true }); + + await run("npm", ["pack", "--pack-destination", outputDir], { cwd: stageRoot }); + + try { + await access(archivePath); + } catch (error) { + throw new Error(`npm pack did not create ${archiveName}`, { cause: error }); + } + + return archivePath; + } finally { + await rm(stageRoot, { recursive: true, force: true }); + } +} + +async function main() { + const options = parseArguments(process.argv.slice(2)); + if (options.help) { + console.log(usage()); + return; + } + + const packageJson = await readJson(packageJsonPath); + if (packageJson.name !== "mtp") { + throw new Error("package.json must describe the 'mtp' Web package"); + } + + const version = await readCargoVersion(); + console.log(`Using Cargo package version ${version}`); + + if (!options.skipBuild) { + await run("pnpm", ["run", "clean"]); + await run("pnpm", ["run", "build"]); + } + + const archivePath = await createRelease({ + outputDir: options.outputDir, + packageJson, + version, + }); + console.log(`\nCreated ${path.relative(repositoryRoot, archivePath) || archivePath}`); +} + +main().catch((error) => { + console.error(`\n${error.message}`); + process.exitCode = 1; +}); diff --git a/crypto/src/helper.rs b/crypto/src/helper.rs index 018f0cd..6254cd8 100644 --- a/crypto/src/helper.rs +++ b/crypto/src/helper.rs @@ -129,7 +129,7 @@ impl<'a> MultiEncryptedMessageRef<'a> { &self.bytes[self.ciphertext_start..] } - pub fn to_owned(&self) -> MultiEncryptedMessage { + pub fn to_owned(self) -> MultiEncryptedMessage { let recipients = (0..self.count) .filter_map(|index| { let (kem_ciphertext, encrypted_key) = self.recipient(index)?; diff --git a/example/client/src/protected.rs b/example/client/src/protected.rs index 74214b8..e9c2981 100644 --- a/example/client/src/protected.rs +++ b/example/client/src/protected.rs @@ -3,7 +3,7 @@ use std::time::{Duration, Instant}; use mtp::client::MTPConnection; use mtp::codec::{ CommunicationType, DataType, DataValue, ProtectionPolicy, ProtectedMessageBuilder, - ProtectionPurpose, SealedRelayBuilder, SignaturePolicy, TypeMap, + ProtectionPurpose, RelayOpenOptions, SealedRelayBuilder, SignaturePolicy, TypeMap, open_relay_content_with_limits_without_replay, open_relay_metadata_without_replay, }; @@ -165,17 +165,17 @@ pub async fn send_sealed_relay( &final_recipient_keyring, signer_id, &signer_keyring.public_key_bundle(), - RELAY_SIGNATURE_POLICY, + RelayOpenOptions::new(RELAY_SIGNATURE_POLICY), )?; let application_metadata = metadata .metadata() .ok_or("forwarded relay metadata was missing")?; let content = open_relay_content_with_limits_without_replay( &metadata, - &final_recipient_keyring, - &signer_keyring.public_key_bundle(), - FINAL_RECIPIENT_ID, - RELAY_SIGNATURE_POLICY, + &[&final_recipient_keyring], + &[signer_keyring.public_key_bundle()], + Some(FINAL_RECIPIENT_ID), + RelayOpenOptions::new(RELAY_SIGNATURE_POLICY), )?; if content.message_type != "ProtectedMessage" { return Err(format!("unexpected relay message type: {}", content.message_type).into()); diff --git a/example/server/src/handlers.rs b/example/server/src/handlers.rs index 2d9d0cd..13a2990 100644 --- a/example/server/src/handlers.rs +++ b/example/server/src/handlers.rs @@ -2,7 +2,8 @@ use std::collections::HashMap; use mtp::codec::{ CommunicationType, CommunicationValue, DataType, DataTypeId, DataValue, InMemoryReplayGuard, - ProtectedOpenOptions, ProtectionPolicy, ProtectionPurpose, SignaturePolicy, TypeMap, + ProtectedOpenOptions, ProtectionPolicy, ProtectionPurpose, RelayOpenOptions, SignaturePolicy, + TypeMap, forward_relay_frame, open_protected_with_checked, open_relay_content_with_limits_without_replay, open_relay_metadata_with_checked, @@ -142,7 +143,7 @@ fn process_sealed_relay( |signer_id| { resolve_signer_key(signer_id, registered_clients).map(|key| vec![key]) }, - SIGNATURE_POLICY, + RelayOpenOptions::new(SIGNATURE_POLICY), accepted_messages, ) .map_err(|e| format!("metadata relay could not authenticate metadata: {e}"))?; @@ -164,11 +165,11 @@ fn process_sealed_relay( let content_result = open_relay_content_with_limits_without_replay( &metadata, - host_keyring, - &resolve_signer_key(metadata.signer_id(), registered_clients) - .ok_or("metadata signer key disappeared")?, - FINAL_RECIPIENT_ID, - SIGNATURE_POLICY, + &[host_keyring], + &[resolve_signer_key(metadata.signer_id(), registered_clients) + .ok_or("metadata signer key disappeared")?], + Some(FINAL_RECIPIENT_ID), + RelayOpenOptions::new(SIGNATURE_POLICY), ); if content_result.is_ok() { return Err("metadata relay unexpectedly decrypted final-recipient content".into()); @@ -309,12 +310,22 @@ pub fn process_and_respond( 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)) + .verify_with_policy( + signer_id, + pk_bundle, + mtp::codec::ProtectionPurpose::from(2), + SIGNATURE_POLICY, + ) .is_ok() { let dv = sig .clone() - .into_verified(signer_id, pk_bundle, mtp::codec::ProtectionPurpose::from(2)) + .into_verified_with_policy( + signer_id, + pk_bundle, + mtp::codec::ProtectionPurpose::from(2), + SIGNATURE_POLICY, + ) .ok(); if let Some(entries) = dv.and_then(|value| value.as_container()) { println!(" Verified SignedPayload: {:?}", entries); @@ -335,16 +346,22 @@ pub fn process_and_respond( if let Ok(opened) = secure.decrypt(host_keyring, mtp::codec::ProtectionPurpose::from(4)) && let Some(signed) = opened.as_signed() && opened - .verify( + .verify_with_policy( signed.signer_id, pk_bundle, mtp::codec::ProtectionPurpose::from(3), + SIGNATURE_POLICY, ) .is_ok() { let signer_id = signed.signer_id; let dv = opened - .into_verified(signer_id, pk_bundle, mtp::codec::ProtectionPurpose::from(3)) + .into_verified_with_policy( + signer_id, + pk_bundle, + mtp::codec::ProtectionPurpose::from(3), + SIGNATURE_POLICY, + ) .ok(); if let Some(entries) = dv.and_then(|value| value.as_container()) { println!(" Verified SecurePayload: {:?}", entries); diff --git a/flake.nix b/flake.nix index b03cd18..7951305 100644 --- a/flake.nix +++ b/flake.nix @@ -1,6 +1,5 @@ { description = "MTP - Methanium Transport Protocol"; - inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; rust-overlay.url = "github:oxalica/rust-overlay"; diff --git a/host/src/config.rs b/host/src/config.rs index 6ab4c36..0cef52a 100644 --- a/host/src/config.rs +++ b/host/src/config.rs @@ -227,10 +227,11 @@ impl AuthenticationAttemptLimiter for InMemoryAuthenticationAttemptLimiter { } for key in keys { - if !attempts.contains_key(&key) && attempts.len() >= self.max_keys { - if let Some(oldest) = attempts.keys().next().cloned() { - attempts.remove(&oldest); - } + if !attempts.contains_key(&key) + && attempts.len() >= self.max_keys + && let Some(oldest) = attempts.keys().next().cloned() + { + attempts.remove(&oldest); } attempts.entry(key).or_default().push_back(now); } diff --git a/package.json b/package.json index 3a96cdf..e4af66b 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,8 @@ "build:wasm": "MTP_TYPE_MAPS=$PWD/example-type-maps.yaml wasm-pack build wasm --target web --out-dir pkg --release && rm -f wasm/pkg/.gitignore", "build:ts": "rm -rf dist && tsc", "build": "pnpm run build:wasm && pnpm run build:ts", - "pack": "pnpm run clean && pnpm run build && pnpm pack", + "pack": "pnpm run release:web", + "release:web": "node create-web-release.mjs", "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 .", "test:e2e": "tsc && node test/e2ee.mjs", diff --git a/transport/src/connection.rs b/transport/src/connection.rs index 3e30b93..dece604 100644 --- a/transport/src/connection.rs +++ b/transport/src/connection.rs @@ -1460,9 +1460,11 @@ mod tests { #[test] fn runtime_policy_normalizes_zero_channel_and_task_limits() { - let mut policy = Policy::default(); - policy.receiver_queue_capacity = 0; - policy.max_concurrent_stream_tasks = 0; + let policy = Policy { + receiver_queue_capacity: 0, + max_concurrent_stream_tasks: 0, + ..Policy::default() + }; let runtime = RuntimePolicy::from_public(&policy); From d11eb04d12e35dbfb42dd9a3f201d2da15f4af70 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 19 Aug 2026 11:46:40 +0200 Subject: [PATCH 05/22] [Fix] Clean --- Cargo.lock | 154 +++--- deny.toml | 16 +- example/Cargo.lock | 3 +- example/client/src/messages.rs | 2 +- example/client/src/metrics.rs | 1 + example/server/src/handlers.rs | 4 +- example/server/src/main.rs | 8 +- example/server/src/metrics.rs | 6 +- package.json | 4 +- pnpm-lock.yaml | 935 ++------------------------------- wasm/src/frame.rs | 1 + wasm/src/protected.rs | 1 + wasm/src/relay.rs | 1 + 13 files changed, 163 insertions(+), 973 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4adc519..88e9b74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -90,9 +90,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", @@ -221,9 +221,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.4.2" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" dependencies = [ "find-msvc-tools", "jobserver", @@ -561,7 +561,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -596,9 +596,9 @@ checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" [[package]] name = "find-msvc-tools" -version = "0.1.10" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "fnv" @@ -629,9 +629,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -660,9 +660,9 @@ checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -764,9 +764,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", @@ -895,9 +895,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -966,9 +966,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -980,9 +980,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -993,9 +993,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1007,16 +1007,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -1027,15 +1028,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" dependencies = [ "displaydoc", "icu_locale_core", @@ -1153,9 +1154,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", @@ -1173,9 +1174,9 @@ dependencies = [ [[package]] name = "keccak" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +checksum = "ffd9697dc4a9a62e2da93389f34400b77a28f0287711263cabb203b3ccb9c0e4" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -1201,9 +1202,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "lock_api" @@ -1234,9 +1235,9 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "minicov" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +checksum = "c3aa3aa12b448ac225b3102217d1ac5cc717908f02722926524b0599c933c7a0" dependencies = [ "cc", "walkdir", @@ -1665,9 +1666,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "poly1305" @@ -1700,9 +1701,9 @@ checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -1745,9 +1746,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.16" +version = "0.11.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" dependencies = [ "aws-lc-rs", "bytes", @@ -1779,7 +1780,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1950,7 +1951,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2120,7 +2121,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" dependencies = [ "digest 0.11.3", - "keccak 0.2.0", + "keccak 0.2.1", "sponge-cursor", ] @@ -2345,9 +2346,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -2419,13 +2420,14 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -2570,9 +2572,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -2583,9 +2585,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -2593,9 +2595,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2603,9 +2605,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", @@ -2616,18 +2618,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] [[package]] name = "wasm-bindgen-test" -version = "0.3.76" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0d555ca874445df8d314f94f5c948a4e74e5418f332c89f660a3d8310a96f4" +checksum = "895a2607575412a4eda1df892084a375ea10dfeadc4d7d2ab87b854e4ddc7ba1" dependencies = [ "async-trait", "cast", @@ -2647,9 +2649,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-test-macro" -version = "0.3.76" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94eb68555b95bcea5e8cf4abe280b529049479fa995bfc23734af96a6aedc120" +checksum = "4288cb0ebe215033bf949ae1fd046726daa4c32a157f24b9dc6ac387a52aa759" dependencies = [ "proc-macro2", "quote", @@ -2658,9 +2660,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-test-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31d56021e873866c968588ed85ccdf56db5c426e44afdb4618c39895104b920" +checksum = "33ff1c1b360982e93b6d8ea9c04836f71dba0817a16f91e229cf3a51bdd9d987" [[package]] name = "wasm-tracing" @@ -2698,7 +2700,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2791,9 +2793,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wtransport" @@ -2938,9 +2940,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -2949,9 +2951,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" dependencies = [ "yoke", "zerofrom", @@ -2960,13 +2962,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] diff --git a/deny.toml b/deny.toml index 41c6bb9..30bd4fb 100644 --- a/deny.toml +++ b/deny.toml @@ -8,9 +8,23 @@ ignore = [] [bans] # Flag multiple versions of the same crate so duplicate trees are visible. -multiple-versions = "warn" +multiple-versions = "deny" wildcards = "deny" +# These versions are required by incompatible upstream dependency lines: +# - pem/rcgen/wtransport still use base64 0.22. +# - ring and wasm-bindgen still use getrandom 0.2. +# - current displaydoc/serde/thiserror/tokio and wasm-bindgen trees span syn 2 +# and syn 3. +# - ring still uses windows-sys 0.52 while the Tokio/QUIC tree uses 0.61. +# Keep the duplicate-version policy strict for every other crate/version. +skip = [ + { name = "base64", version = "0.22.1" }, + { name = "getrandom", version = "0.2.17" }, + { name = "syn", version = "2.0.119" }, + { name = "windows-sys", version = "0.52.0" }, +] + [licenses] # Allowlist of licenses acceptable for this project's dependencies. allow = [ diff --git a/example/Cargo.lock b/example/Cargo.lock index 1c15380..8d2fb30 100644 --- a/example/Cargo.lock +++ b/example/Cargo.lock @@ -1314,6 +1314,7 @@ dependencies = [ name = "mtp-crypto" version = "0.3.0" dependencies = [ + "argon2", "base64 0.22.1", "chacha20poly1305", "ed25519-dalek", @@ -1337,7 +1338,6 @@ dependencies = [ name = "mtp-files" version = "0.3.0" dependencies = [ - "argon2", "mtp-crypto", "rand", "thiserror 2.0.20", @@ -1353,6 +1353,7 @@ dependencies = [ "mtp-crypto", "mtp-transport", "rand", + "thiserror 2.0.20", "tokio", "tracing", "wtransport", diff --git a/example/client/src/messages.rs b/example/client/src/messages.rs index f5da0d1..12af612 100644 --- a/example/client/src/messages.rs +++ b/example/client/src/messages.rs @@ -62,7 +62,7 @@ pub fn build_demo_message( ) .add_typed_default( DataType::Timestamp, - DataValue::UnsignedNumber(timestamp as u128), + DataValue::UnsignedNumber(timestamp), ) .add_typed_default(DataType::Data, DataValue::Str("Hello, MTP!".into())) .add_typed_default(DataType::Flags, DataValue::BoolTrue) diff --git a/example/client/src/metrics.rs b/example/client/src/metrics.rs index fcc039a..3bca4d0 100644 --- a/example/client/src/metrics.rs +++ b/example/client/src/metrics.rs @@ -79,6 +79,7 @@ pub struct ClientMetrics { } impl ClientMetrics { + #[cfg(test)] pub fn new() -> Self { Self { sessions: Vec::new(), diff --git a/example/server/src/handlers.rs b/example/server/src/handlers.rs index 13a2990..867a95d 100644 --- a/example/server/src/handlers.rs +++ b/example/server/src/handlers.rs @@ -47,7 +47,7 @@ fn pong(tm: &TypeMap, data: impl Into) -> Result Result<(), Box> { serde_json::to_string_pretty(&*db).ok() }; - if let Some(json) = json { - if let Err(error) = tokio::fs::write("clients.json", json).await { - eprintln!("Failed to persist clients.json: {error}"); - } + if let Some(json) = json + && let Err(error) = tokio::fs::write("clients.json", json).await + { + eprintln!("Failed to persist clients.json: {error}"); } println!("Registered new client with ID: {id}"); diff --git a/example/server/src/metrics.rs b/example/server/src/metrics.rs index 10dcd62..b994922 100644 --- a/example/server/src/metrics.rs +++ b/example/server/src/metrics.rs @@ -108,6 +108,7 @@ pub struct ServerMetrics { } impl ServerMetrics { + #[cfg(test)] pub fn new() -> Self { Self { inner: Mutex::new(Inner { @@ -218,6 +219,7 @@ impl ServerMetrics { } } + #[cfg(test)] pub fn snapshot(&self) -> ServerMetricsFile { let inner = self.inner.lock().unwrap(); self.to_file(&inner) @@ -553,11 +555,11 @@ mod tests { let metrics = ServerMetrics::new(); for i in 0..3 { - let mut session = metrics.start_session(1000 + i as u64, format!("session {i}")); + let mut session = metrics.start_session(1000 + i, format!("session {i}")); for _ in 0..(i + 1) * 2 { session.record_message(Duration::from_millis(1 + i), true); } - session.record_pipe((i as u64 + 1) * 1000); + session.record_pipe((i + 1) * 1000); session.finish(format!("exit {i}")); } diff --git a/package.json b/package.json index e4af66b..dd4e7a1 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "pack": "pnpm run release:web", "release:web": "node create-web-release.mjs", "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/**' --min-lines 8 --min-tokens 80 --threshold 4 --reporters console --no-tips .", "test:e2e": "tsc && node test/e2ee.mjs", "test:secrets": "tsc && node --test --test-isolation=none test/encrypted-secret.mjs", "test:wasm-init": "tsc && node --test test/wasm-init.mjs", @@ -72,7 +72,7 @@ }, "devDependencies": { "@types/node": "^26.0.1", - "jscpd": "4.2.5", + "jscpd": "5.0.14", "typescript": "^7.0.0" }, "dependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72f1233..460b3de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,8 +16,8 @@ importers: specifier: ^26.0.1 version: 26.0.1 jscpd: - specifier: 4.2.5 - version: 4.2.5 + specifier: 5.0.14 + version: 5.0.14 typescript: specifier: ^7.0.0 version: 7.0.2 @@ -37,27 +37,6 @@ importers: packages: - '@babel/helper-string-parser@7.29.7': - resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.29.7': - resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.29.7': - resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/types@7.29.7': - resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} - engines: {node: '>=6.9.0'} - - '@colors/colors@1.5.0': - resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} - engines: {node: '>=0.1.90'} - '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} @@ -67,39 +46,12 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} - '@jscpd/badge-reporter@4.2.5': - resolution: {integrity: sha512-ktXrjPeRaRyUDktxTroSA2/w5sshXpQplWkUuq/e6XqEpKBSbGEnwZLIaegSijOrMwIcCXPQ9k4feXIz5eVJNA==} - - '@jscpd/core@4.2.5': - resolution: {integrity: sha512-Esf2deHxaoNEjePwf2jqP6Urzj+BAOsJVPFLbnnSsV+q7rLNMcn0UEEoKBXIOOt4qMkrkhl9DfwpMyPPOr6GkQ==} - - '@jscpd/finder@4.2.5': - resolution: {integrity: sha512-Rw0dtwp/EeLANbujOubuQeJIuXXXkAlT+f5geZhwkB9TxEYP0hqNrdOJUK/TDBKQjRGrOizEtdNy+S4UlbdzOQ==} - - '@jscpd/html-reporter@4.2.5': - resolution: {integrity: sha512-zMMIKbvi43dMgeNeHXlHQy1ovf+KJrzNlUubaBvCAVatqP23ksW8d3fmsevIQG9mMMTH0D1xOz+SxUn1FREOPg==} - - '@jscpd/tokenizer@4.2.5': - resolution: {integrity: sha512-UM8Wx/jwahmflqQExlcKMQTYOAy58N/fn7Pv6NYrkD3EZm/FTk7gW97wkXy5aDE1Ts9oBUpT9tLY2rz7ogCHAQ==} - '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - '@oxc-project/types@0.137.0': resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==} @@ -207,9 +159,6 @@ packages: '@types/node@26.0.1': resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} - '@types/sarif@2.1.7': - resolution: {integrity: sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==} - '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -330,113 +279,10 @@ packages: cpu: [x64] os: [win32] - acorn@7.4.1: - resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==} - engines: {node: '>=0.4.0'} - hasBin: true - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - asap@2.0.6: - resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} - - assert-never@1.4.0: - resolution: {integrity: sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==} - - babel-walk@3.0.0-canary-5: - resolution: {integrity: sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==} - engines: {node: '>= 10.0.0'} - - badgen@3.3.2: - resolution: {integrity: sha512-fbQwK9norfdzbdsoPwbLIAmgBXDGEme3jeIyqPAH7o6vp9lmuLHS7uXULvOiQ6XnMLkYNG4gDjILf74hgtTAug==} - - blamer@1.0.7: - resolution: {integrity: sha512-GbBStl/EVlSWkiJQBZps3H1iARBrC7vt++Jb/TTmCNu/jZ04VW7tSN1nScbFXBUy1AN+jzeL7Zep9sbQxLhXKA==} - engines: {node: '>=8.9'} - - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - - bytes@3.1.2: - resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} - engines: {node: '>= 0.8'} - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} - - character-parser@2.2.0: - resolution: {integrity: sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw==} - - cli-table3@0.6.5: - resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} - engines: {node: 10.* || >= 12.*} - - colors@1.4.0: - resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==} - engines: {node: '>=0.1.90'} - - commander@15.0.0: - resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} - engines: {node: '>=22.12.0'} - - constantinople@4.0.1: - resolution: {integrity: sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==} - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - doctypes@1.1.0: - resolution: {integrity: sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==} - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - end-of-stream@1.4.5: - resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-object-atoms@1.1.2: - resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} - engines: {node: '>= 0.4'} - - eventemitter3@5.0.4: - resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} - - execa@4.1.0: - resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==} - engines: {node: '>=10'} - - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} - - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -446,114 +292,49 @@ packages: picomatch: optional: true - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - - fs-extra@11.3.5: - resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} - engines: {node: '>=14.14'} - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + jscpd-darwin-arm64@5.0.14: + resolution: {integrity: sha512-Ojjl79SBuj9tEW6WbjZ1a/1ZOR89dneH9yLQYQu8WyWaQownttnx7RYFEHU6aGhS4jIvwUEbr+1wxzFTb37cwg==} + cpu: [arm64] + os: [darwin] - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} + jscpd-darwin-x64@5.0.14: + resolution: {integrity: sha512-DxFg5XvjMZ81iVeqillnM5apqcGCfNTbroNF+mPLr7RkHLGH6mudLgtO+ILL/hfpZXy1bF9oIY5BSudPmN/k9A==} + cpu: [x64] + os: [darwin] - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} + jscpd-linux-arm64-gnu@5.0.14: + resolution: {integrity: sha512-1uw+XBHEt9pONXNICSp5HpaVWPjG6mQ6deDXaq9Yb0xCNJkX4/8gmn0vhzekIyZD2DspRYKPUolbDsqm/HEdYg==} + cpu: [arm64] + os: [linux] + libc: [glibc] - get-stream@5.2.0: - resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} - engines: {node: '>=8'} + jscpd-linux-x64-gnu@5.0.14: + resolution: {integrity: sha512-dFTbyyrm+Z9pcXIVzJQCw8QAgiNqIiO69sm4AfA7/wFdPoizoVzjhaXsYXcSV4bs0aoPiWbNazg0J0HgslT/5A==} + cpu: [x64] + os: [linux] + libc: [glibc] - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} + jscpd-linux-x64-musl@5.0.14: + resolution: {integrity: sha512-SayS7qQJvixyy9eR0+UjepkTsUUwqvlsiuSxfIdHgG2qzqoh/thnkgiu4By8fsiiDpQONsQrRrZDwHRQ3GDrBQ==} + cpu: [x64] + os: [linux] + libc: [musl] - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} + jscpd-windows-x64-msvc@5.0.14: + resolution: {integrity: sha512-DqjxlVkUanlahGgY2lY7Zkrau4BUTI+AwWky+bPGK4kSK2AIOaUziY9Q19u8b58idXmJA9FKK98Fuu4ajNXVjQ==} + cpu: [x64] + os: [win32] - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - - hasown@2.0.4: - resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} - engines: {node: '>= 0.4'} - - human-signals@1.1.1: - resolution: {integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==} - engines: {node: '>=8.12.0'} - - is-core-module@2.16.2: - resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} - engines: {node: '>= 0.4'} - - is-expression@4.0.0: - resolution: {integrity: sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A==} - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - - is-promise@2.2.2: - resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} - - is-regex@1.2.1: - resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} - engines: {node: '>= 0.4'} - - is-stream@2.0.1: - resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} - engines: {node: '>=8'} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - js-stringify@1.0.2: - resolution: {integrity: sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==} - - jscpd-sarif-reporter@4.2.5: - resolution: {integrity: sha512-O8LcM9grAS5yO5x1Q0yegYaYcUX//IEBEyvzGFSYCeo1YzHbMnAI6EK7oTrwD+7Csjvfg9m8B8G7OOxzcSlr9w==} - - jscpd@4.2.5: - resolution: {integrity: sha512-KDpApYw1ChGelfHb7MwYTEx694OnW52pv3McAasidUV4ILcGDQMiVJzB+vI8ox+ZPVfOSvdXQCk8uRa9B0LXnw==} + jscpd@5.0.14: + resolution: {integrity: sha512-zge+FPZZAymt2Do5Z0+QHyIn4/XcUhrO/W7of9HcHZfx2AK8++dYhLA1uWtwXj47ml3Of8PbcUW4wUWvYMCc3w==} + engines: {node: '>=18'} hasBin: true - jsonfile@6.2.1: - resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} - - jstransformer@1.0.0: - resolution: {integrity: sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==} - lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -628,66 +409,14 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} - markdown-table@2.0.0: - resolution: {integrity: sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==} - - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - - mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} - nanoid@3.3.15: resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - node-sarif-builder@4.1.0: - resolution: {integrity: sha512-IWqZF6u0EI/07HTBm+zZ+MgXgWl09dnSJRGaDCPBSlOqilDcx6pj3Mpb3HvPN8V2Gr+ISw7ZrMsL7STWs1F++w==} - engines: {node: '>=20'} - - npm-run-path@4.0.1: - resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} - engines: {node: '>=8'} - - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - - onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} - engines: {node: '>=6'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.2: - resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} - engines: {node: '>=8.6'} - picomatch@4.0.4: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} @@ -696,117 +425,19 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} - promise@7.3.1: - resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} - - pug-attrs@3.0.0: - resolution: {integrity: sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==} - - pug-code-gen@3.0.4: - resolution: {integrity: sha512-6okWYIKdasTyXICyEtvobmTZAVX57JkzgzIi4iRJlin8kmhG+Xry2dsus+Mun/nGCn6F2U49haHI5mkELXB14g==} - - pug-error@2.1.0: - resolution: {integrity: sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg==} - - pug-filters@4.0.0: - resolution: {integrity: sha512-yeNFtq5Yxmfz0f9z2rMXGw/8/4i1cCFecw/Q7+D0V2DdtII5UvqE12VaZ2AY7ri6o5RNXiweGH79OCq+2RQU4A==} - - pug-lexer@5.0.1: - resolution: {integrity: sha512-0I6C62+keXlZPZkOJeVam9aBLVP2EnbeDw3An+k0/QlqdwH6rv8284nko14Na7c0TtqtogfWXcRoFE4O4Ff20w==} - - pug-linker@4.0.0: - resolution: {integrity: sha512-gjD1yzp0yxbQqnzBAdlhbgoJL5qIFJw78juN1NpTLt/mfPJ5VgC4BvkoD3G23qKzJtIIXBbcCt6FioLSFLOHdw==} - - pug-load@3.0.0: - resolution: {integrity: sha512-OCjTEnhLWZBvS4zni/WUMjH2YSUosnsmjGBB1An7CsKQarYSWQ0GCVyd4eQPMFJqZ8w9xgs01QdiZXKVjk92EQ==} - - pug-parser@6.0.0: - resolution: {integrity: sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw==} - - pug-runtime@3.0.1: - resolution: {integrity: sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg==} - - pug-strip-comments@2.0.0: - resolution: {integrity: sha512-zo8DsDpH7eTkPHCXFeAk1xZXJbyoTfdPlNR0bK7rpOMuhBYb0f5qUVCO1xlsitYd3w5FQTK7zpNVKb3rZoUrrQ==} - - pug-walk@2.0.0: - resolution: {integrity: sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ==} - - pug@3.0.4: - resolution: {integrity: sha512-kFfq5mMzrS7+wrl5pLJzZEzemx34OQ0w4SARfhy/3yxTlhbstsudDwJzhf1hP02yHzbjoVMSXUj/Sz6RNfMyXg==} - - pump@3.0.4: - resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} - - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - - repeat-string@1.6.1: - resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} - engines: {node: '>=0.10'} - - resolve@1.22.12: - resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} - engines: {node: '>= 0.4'} - hasBin: true - - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rolldown@1.1.3: resolution: {integrity: sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - spark-md5@3.0.2: - resolution: {integrity: sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-final-newline@2.0.0: - resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} - engines: {node: '>=6'} - - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - - token-stream@1.0.0: - resolution: {integrity: sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==} - tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -818,10 +449,6 @@ packages: undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} - universalify@2.0.1: - resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} - engines: {node: '>= 10.0.0'} - vite@8.1.0: resolution: {integrity: sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -865,22 +492,6 @@ packages: yaml: optional: true - void-elements@3.1.0: - resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} - engines: {node: '>=0.10.0'} - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - with@7.0.2: - resolution: {integrity: sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==} - engines: {node: '>= 10.0.0'} - - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} @@ -888,22 +499,6 @@ packages: snapshots: - '@babel/helper-string-parser@7.29.7': {} - - '@babel/helper-validator-identifier@7.29.7': {} - - '@babel/parser@7.29.7': - dependencies: - '@babel/types': 7.29.7 - - '@babel/types@7.29.7': - dependencies: - '@babel/helper-string-parser': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 - - '@colors/colors@1.5.0': - optional: true - '@emnapi/core@1.11.1': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -920,40 +515,6 @@ snapshots: tslib: 2.8.1 optional: true - '@jscpd/badge-reporter@4.2.5': - dependencies: - badgen: 3.3.2 - colors: 1.4.0 - fs-extra: 11.3.5 - - '@jscpd/core@4.2.5': - dependencies: - eventemitter3: 5.0.4 - - '@jscpd/finder@4.2.5': - dependencies: - '@jscpd/core': 4.2.5 - '@jscpd/tokenizer': 4.2.5 - blamer: 1.0.7 - bytes: 3.1.2 - cli-table3: 0.6.5 - colors: 1.4.0 - fast-glob: 3.3.3 - fs-extra: 11.3.5 - markdown-table: 2.0.0 - pug: 3.0.4 - - '@jscpd/html-reporter@4.2.5': - dependencies: - colors: 1.4.0 - fs-extra: 11.3.5 - pug: 3.0.4 - - '@jscpd/tokenizer@4.2.5': - dependencies: - '@jscpd/core': 4.2.5 - spark-md5: 3.0.2 - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 @@ -961,18 +522,6 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 - '@oxc-project/types@0.137.0': {} '@rolldown/binding-android-arm64@1.1.3': @@ -1035,8 +584,6 @@ snapshots: dependencies: undici-types: 8.3.0 - '@types/sarif@2.1.7': {} - '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -1097,239 +644,41 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - acorn@7.4.1: {} - - ansi-regex@5.0.1: {} - - asap@2.0.6: {} - - assert-never@1.4.0: {} - - babel-walk@3.0.0-canary-5: - dependencies: - '@babel/types': 7.29.7 - - badgen@3.3.2: {} - - blamer@1.0.7: - dependencies: - execa: 4.1.0 - which: 2.0.2 - - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - - bytes@3.1.2: {} - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - - character-parser@2.2.0: - dependencies: - is-regex: 1.2.1 - - cli-table3@0.6.5: - dependencies: - string-width: 4.2.3 - optionalDependencies: - '@colors/colors': 1.5.0 - - colors@1.4.0: {} - - commander@15.0.0: {} - - constantinople@4.0.1: - dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - detect-libc@2.1.2: {} - doctypes@1.1.0: {} - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - emoji-regex@8.0.0: {} - - end-of-stream@1.4.5: - dependencies: - once: 1.4.0 - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-object-atoms@1.1.2: - dependencies: - es-errors: 1.3.0 - - eventemitter3@5.0.4: {} - - execa@4.1.0: - dependencies: - cross-spawn: 7.0.6 - get-stream: 5.2.0 - human-signals: 1.1.1 - is-stream: 2.0.1 - merge-stream: 2.0.0 - npm-run-path: 4.0.1 - onetime: 5.1.2 - signal-exit: 3.0.7 - strip-final-newline: 2.0.0 - - fast-glob@3.3.3: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - - fastq@1.20.1: - dependencies: - reusify: 1.1.0 - fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - - fs-extra@11.3.5: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.2.1 - universalify: 2.0.1 - fsevents@2.3.3: optional: true - function-bind@1.1.2: {} + jscpd-darwin-arm64@5.0.14: + optional: true - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.2 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.4 - math-intrinsics: 1.1.0 + jscpd-darwin-x64@5.0.14: + optional: true - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.2 + jscpd-linux-arm64-gnu@5.0.14: + optional: true - get-stream@5.2.0: - dependencies: - pump: 3.0.4 + jscpd-linux-x64-gnu@5.0.14: + optional: true - glob-parent@5.1.2: - dependencies: - is-glob: 4.0.3 + jscpd-linux-x64-musl@5.0.14: + optional: true - gopd@1.2.0: {} + jscpd-windows-x64-msvc@5.0.14: + optional: true - graceful-fs@4.2.11: {} - - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - - hasown@2.0.4: - dependencies: - function-bind: 1.1.2 - - human-signals@1.1.1: {} - - is-core-module@2.16.2: - dependencies: - hasown: 2.0.4 - - is-expression@4.0.0: - dependencies: - acorn: 7.4.1 - object-assign: 4.1.1 - - is-extglob@2.1.1: {} - - is-fullwidth-code-point@3.0.0: {} - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-number@7.0.0: {} - - is-promise@2.2.2: {} - - is-regex@1.2.1: - dependencies: - call-bound: 1.0.4 - gopd: 1.2.0 - has-tostringtag: 1.0.2 - hasown: 2.0.4 - - is-stream@2.0.1: {} - - isexe@2.0.0: {} - - js-stringify@1.0.2: {} - - jscpd-sarif-reporter@4.2.5: - dependencies: - colors: 1.4.0 - fs-extra: 11.3.5 - node-sarif-builder: 4.1.0 - - jscpd@4.2.5: - dependencies: - '@jscpd/badge-reporter': 4.2.5 - '@jscpd/core': 4.2.5 - '@jscpd/finder': 4.2.5 - '@jscpd/html-reporter': 4.2.5 - '@jscpd/tokenizer': 4.2.5 - colors: 1.4.0 - commander: 15.0.0 - fs-extra: 11.3.5 - jscpd-sarif-reporter: 4.2.5 - - jsonfile@6.2.1: - dependencies: - universalify: 2.0.1 + jscpd@5.0.14: optionalDependencies: - graceful-fs: 4.2.11 - - jstransformer@1.0.0: - dependencies: - is-promise: 2.2.2 - promise: 7.3.1 + jscpd-darwin-arm64: 5.0.14 + jscpd-darwin-x64: 5.0.14 + jscpd-linux-arm64-gnu: 5.0.14 + jscpd-linux-x64-gnu: 5.0.14 + jscpd-linux-x64-musl: 5.0.14 + jscpd-windows-x64-msvc: 5.0.14 lightningcss-android-arm64@1.32.0: optional: true @@ -1380,52 +729,10 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 - markdown-table@2.0.0: - dependencies: - repeat-string: 1.6.1 - - math-intrinsics@1.1.0: {} - - merge-stream@2.0.0: {} - - merge2@1.4.1: {} - - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.2 - - mimic-fn@2.1.0: {} - nanoid@3.3.15: {} - node-sarif-builder@4.1.0: - dependencies: - '@types/sarif': 2.1.7 - fs-extra: 11.3.5 - - npm-run-path@4.0.1: - dependencies: - path-key: 3.1.1 - - object-assign@4.1.1: {} - - once@1.4.0: - dependencies: - wrappy: 1.0.2 - - onetime@5.1.2: - dependencies: - mimic-fn: 2.1.0 - - path-key@3.1.1: {} - - path-parse@1.0.7: {} - picocolors@1.1.1: {} - picomatch@2.3.2: {} - picomatch@4.0.4: {} postcss@8.5.15: @@ -1434,95 +741,6 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - promise@7.3.1: - dependencies: - asap: 2.0.6 - - pug-attrs@3.0.0: - dependencies: - constantinople: 4.0.1 - js-stringify: 1.0.2 - pug-runtime: 3.0.1 - - pug-code-gen@3.0.4: - dependencies: - constantinople: 4.0.1 - doctypes: 1.1.0 - js-stringify: 1.0.2 - pug-attrs: 3.0.0 - pug-error: 2.1.0 - pug-runtime: 3.0.1 - void-elements: 3.1.0 - with: 7.0.2 - - pug-error@2.1.0: {} - - pug-filters@4.0.0: - dependencies: - constantinople: 4.0.1 - jstransformer: 1.0.0 - pug-error: 2.1.0 - pug-walk: 2.0.0 - resolve: 1.22.12 - - pug-lexer@5.0.1: - dependencies: - character-parser: 2.2.0 - is-expression: 4.0.0 - pug-error: 2.1.0 - - pug-linker@4.0.0: - dependencies: - pug-error: 2.1.0 - pug-walk: 2.0.0 - - pug-load@3.0.0: - dependencies: - object-assign: 4.1.1 - pug-walk: 2.0.0 - - pug-parser@6.0.0: - dependencies: - pug-error: 2.1.0 - token-stream: 1.0.0 - - pug-runtime@3.0.1: {} - - pug-strip-comments@2.0.0: - dependencies: - pug-error: 2.1.0 - - pug-walk@2.0.0: {} - - pug@3.0.4: - dependencies: - pug-code-gen: 3.0.4 - pug-filters: 4.0.0 - pug-lexer: 5.0.1 - pug-linker: 4.0.0 - pug-load: 3.0.0 - pug-parser: 6.0.0 - pug-runtime: 3.0.1 - pug-strip-comments: 2.0.0 - - pump@3.0.4: - dependencies: - end-of-stream: 1.4.5 - once: 1.4.0 - - queue-microtask@1.2.3: {} - - repeat-string@1.6.1: {} - - resolve@1.22.12: - dependencies: - es-errors: 1.3.0 - is-core-module: 2.16.2 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - - reusify@1.1.0: {} - rolldown@1.1.3: dependencies: '@oxc-project/types': 0.137.0 @@ -1544,47 +762,13 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.1.3 '@rolldown/binding-win32-x64-msvc': 1.1.3 - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - signal-exit@3.0.7: {} - source-map-js@1.2.1: {} - spark-md5@3.0.2: {} - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - strip-final-newline@2.0.0: {} - - supports-preserve-symlinks-flag@1.0.0: {} - tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - - token-stream@1.0.0: {} - tslib@2.8.1: optional: true @@ -1613,8 +797,6 @@ snapshots: undici-types@8.3.0: {} - universalify@2.0.1: {} - vite@8.1.0(@types/node@26.0.1)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 @@ -1627,19 +809,4 @@ snapshots: fsevents: 2.3.3 yaml: 2.9.0 - void-elements@3.1.0: {} - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - with@7.0.2: - dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - assert-never: 1.4.0 - babel-walk: 3.0.0-canary-5 - - wrappy@1.0.2: {} - yaml@2.9.0: {} diff --git a/wasm/src/frame.rs b/wasm/src/frame.rs index 44db54f..347ffd7 100644 --- a/wasm/src/frame.rs +++ b/wasm/src/frame.rs @@ -163,6 +163,7 @@ impl JsDataValueEncodeContext { } } +#[cfg(test)] pub(crate) fn js_to_data_value(value: &JsValue, tm: &TypeMap) -> Result { js_to_data_value_with_limits(value, tm, EncodeLimits::default()) } diff --git a/wasm/src/protected.rs b/wasm/src/protected.rs index 283b3f4..d0d4da1 100644 --- a/wasm/src/protected.rs +++ b/wasm/src/protected.rs @@ -380,6 +380,7 @@ pub fn build_protected_frame_with_keyring_with_limits( /// 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] +#[allow(deprecated)] #[deprecated(note = "use protected_claimed_signer_id_with_limits")] pub fn protected_claimed_signer_id( frame: &[u8], diff --git a/wasm/src/relay.rs b/wasm/src/relay.rs index 3ce2df4..fcf768b 100644 --- a/wasm/src/relay.rs +++ b/wasm/src/relay.rs @@ -296,6 +296,7 @@ impl WasmVerifiedRelayContent { /// 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] +#[allow(deprecated)] #[deprecated(note = "use relay_metadata_claimed_signer_id_with_limits")] pub fn relay_metadata_claimed_signer_id(frame: &[u8], keyrings: JsValue) -> Result { relay_metadata_claimed_signer_id_impl(frame, keyrings, JsValue::UNDEFINED) From a6c4e56835229e1a0323ae60afc9f523b127bd6e Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 19 Aug 2026 12:37:22 +0200 Subject: [PATCH 06/22] [Upd] Docs --- README.md | 23 ++++++++++++++------ docs/CONNECTIONS.md | 12 +++++++---- docs/CONNECTOR.md | 39 ++++++++++++++++++++-------------- docs/ERRORS.md | 4 +++- docs/NATIVE-CLIENT.md | 14 +++++++----- docs/NATIVE-HOST-WEB-SERVER.md | 12 ++++++++--- docs/NATIVE-HOST.md | 16 ++++++++------ docs/OPERATIONS.md | 2 +- docs/PIPES.md | 5 ++++- docs/PROTOCOL-REFERENCE.md | 7 +++++- docs/SECURITY.md | 3 ++- docs/TROUBLESHOOTING.md | 2 +- docs/TYPE-MAP.md | 33 ++++++++++++++++------------ docs/WASM-CLIENT.md | 15 +++++++++++-- 14 files changed, 125 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 37f90cf..d7abaee 100644 --- a/README.md +++ b/README.md @@ -47,18 +47,29 @@ Feature summary: | Feature | Pulls in | Enables | | --- | --- | --- | -| `crypto` | `mtp::crypto` | AEAD, signatures, KEM, KDF, hashing | -| `host` | `mtp::host`, codec registry | QUIC host and version negotiation | -| `client` | `mtp::client` | QUIC client connections | -| `webserver` | `mtp::webserver` | HTTPS server with HTTP/1.1, HTTP/2, HTTP/3, and WebTransport MTP sessions | +| `serde` | Crypto serialization support | Serde implementations for crypto key types | +| `crypto` | `mtp::crypto` | AEAD, signatures, KEM, KDF, hashing, and connection authentication support | +| `host` | `mtp::host` | Native QUIC host and version negotiation | +| `client` | `mtp::client` | Native QUIC client connections | +| `transport` | `mtp-transport` dependency | Low-level transport support; enabled automatically by `host` and `client` | +| `pipes` | Pipe support in transport, host, client, and web server | Raw and encrypted byte streams | +| `files` | `mtp::files` | `.mk` keyrings and `.mpkb` public bundles; also enables `crypto` | +| `raw` | Raw file APIs | Legacy plaintext keyring migration APIs | +| `web-server` | `mtp::webserver` | HTTPS server with HTTP/1.1, HTTP/2, HTTP/3, and WebTransport MTP sessions | +| `full-server` | Native host and web-server surface | `host`, `web-server`, `crypto`, and `pipes` together | +| `tls` | `mtp::crypto::tls` | Development self-signed certificate generation | +| `insecure-tls` | Lower-level transport | Development-only certificate verification bypass, gated by `MTP_INSECURE_TLS=1` | -The core crates are always available: `codec`, `transport`, `common`, and `type_map`. See the [native client](./docs/NATIVE-CLIENT.md) and [native host](./docs/NATIVE-HOST.md) +The core modules always available from the facade are `codec`, `common`, and +`type_map`. Native `client` and `host` modules re-export the transport policy +types; the low-level transport crate is not exposed as `mtp::transport`. See the [native client](./docs/NATIVE-CLIENT.md) and [native host](./docs/NATIVE-HOST.md) guides for configuration and usage. See [Security](./docs/SECURITY.md) for security boundaries. ## Sub-crates The `mtp` facade re-exports the following modules: -`mtp::codec`, `mtp::transport`, `mtp::common`, `mtp::type_map`, `mtp::crypto`, `mtp::host`, and `mtp::client`. +`mtp::codec`, `mtp::common`, `mtp::type_map`, `mtp::crypto`, `mtp::host`, +`mtp::client`, `mtp::files`, and `mtp::webserver` when their features are enabled. ### Codec diff --git a/docs/CONNECTIONS.md b/docs/CONNECTIONS.md index 0ccf127..69eebad 100644 --- a/docs/CONNECTIONS.md +++ b/docs/CONNECTIONS.md @@ -1,19 +1,23 @@ # MTP Connections -Native clients and hosts share the same connection shape after the opening handshake. The client creates the connection; the host receives it from `accept()`. +Native clients and server-side hosts expose parallel connection handles after the +opening handshake. The client creates its handle; the host receives one from +`accept()`. | Member | Native client | Native host | Web host (`WebMTPConnection`) | | --- | --- | --- | --- | | `version` | Compiled client version accepted by the host | Version selected by the registry | Version selected by the registry | | `sender` | Sends `CommunicationValue` frames | Sends `CommunicationValue` frames | Sends `CommunicationValue` frames | -| `receiver` | Receives application frames | Receives application frames | Receives application frames | +| `receiver` | Underlying receiver; use `receive()` for application frames | Underlying receiver; use `receive()` for application frames | Underlying receiver; use `receive()` for application frames | | `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`) | +| `path` | — | Native hosts use `/` | 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. +`WebMTPConnection`, returned by `MTPWebServer::accept()`, exposes the same +server-side members as the native host connection. Its `path` contains the +HTTP/3 path used for the WebTransport extended CONNECT request. Server-side MTP connections expose `remote_addr`, the peer address observed by QUIC. HTTP route handlers receive the peer address as `HttpRequest::remote_addr`. diff --git a/docs/CONNECTOR.md b/docs/CONNECTOR.md index e560a71..355faa5 100644 --- a/docs/CONNECTOR.md +++ b/docs/CONNECTOR.md @@ -4,23 +4,28 @@ This file documents the connection and version negotiation logic. ## Registry -The `registry` module provides a multi-version `Registry` used by the host for version negotiation. Accessed through the `mtp` facade (requires the `host` feature): +The `registry` module provides a multi-version `Registry` used by the host for +version negotiation. Accessed through the `mtp` facade (requires the `host` +feature). In this repository, `Registry::builtin()` is generated from +[`example/type-maps.yaml`](../example/type-maps.yaml), which currently contains +protocol version 3.0 only. Downstream projects can register additional versions +in their own YAML configuration. ```rust -use mtp::codec::registry::Registry; +use mtp::codec::{Version, registry::Registry}; -let registry = Registry::builtin(); // loads all TypeMaps from config +let registry = Registry::builtin(); // loads all TypeMaps from the build config // Check if a version is supported -assert!(registry.supports(&Version(1, 0))); +assert!(registry.supports(&Version(3, 0))); // Find highest mutual version for a client -let client_versions = &[Version(0, 0), Version(1, 0)]; +let client_versions = &[Version(2, 0), Version(3, 0)]; let negotiated = registry.negotiate(client_versions); -assert_eq!(negotiated, Some(Version(1, 0))); +assert_eq!(negotiated, Some(Version(3, 0))); // Look up a version's TypeMap -let tm = registry.get(&Version(2, 0)).unwrap(); +let tm = registry.get(&Version(3, 0)).unwrap(); ``` The `Registry::builtin()` constructor uses the `TypeMap::vX_Y()` methods generated from the config. @@ -54,9 +59,9 @@ let mut host = MTPHost::new(config).await?; while let Some(conn) = host.accept().await? { // conn.version is the negotiated version // conn.codec is a VersionedCodec scoped to that version - // conn.sender / conn.receiver for raw CommunicationValue I/O + // conn.sender / conn.receive() for application CommunicationValue I/O - let msg = conn.receiver.receive().await?; + let msg = conn.receive().await?; } ``` @@ -94,29 +99,31 @@ The client's `PROTOCOL_VERSION` constant is set by `protocol_version` in `type-m ## Version Negotiation Flow ``` -Client (v2.0) Host (v0.0, v1.0, v2.0) +Client (v3.0) Host (v3.0) | | | QUIC connect | |----------------------->| | | | CommValue{ Ident. } | - | Version -> "2.0" | + | Version -> "3.0" | | Id -> 8765 | | (unsigned hello; auth | | challenge follows) | |----------------------->| - | | registry.negotiate(&[Version(2,0)]) - | | -> Some(Version(2,0)) + | | registry.negotiate(&[Version(3,0)]) + | | -> Some(Version(3,0)) | | - | Response | selected v2.0 TypeMap + | Response | selected v3.0 TypeMap |<-----------------------| | Status, version | | | | subsequent messages | - | use v2.0 TypeMap | + | use v3.0 TypeMap | ``` -If the client sends an unsupported version (e.g. v3.0 when the host only knows up to v2.0), `negotiate` returns `None` and the connection is closed. +If the client sends an unsupported version (for example, v2.0 to the current +repository builtin host), `negotiate` returns `None` and the connection is +closed. ## Protocol Ping and Pong diff --git a/docs/ERRORS.md b/docs/ERRORS.md index fa31dd4..93365dc 100644 --- a/docs/ERRORS.md +++ b/docs/ERRORS.md @@ -12,6 +12,8 @@ MTP reports codec failures separately from connection and transport failures. | `ReservedCommunicationType` | An application attempted to use a reserved communication type ID. | | `InvalidEncoding` | Bytes do not match the MTP value or frame format. | | `TooManyEntries` | A serialized value or frame exceeds its representable size. | +| `MissingTypeMap` | A versioned codec was asked to encode a value without a retained negotiated type map. | +| `TypeMapMismatch` | A value was created with a different protocol type map from the codec or peer operation. | | `CryptoFailed` | Signing, verification, encryption, or decryption failed while encoding or decoding. | | `MissingField` | A required typed field is absent. | @@ -43,4 +45,4 @@ Native builds may expose additional variants wrapping QUIC and WebTransport erro ## Authentication Rejections -The host reports unsupported or missing protocol versions through `AcceptError`. Authentication failures return `AcceptError::AuthenticationFailed` after the host sends a rejected handshake response. The authentication flow and its signed fields are defined in [Security](SECURITY.md). +The host reports unsupported or missing protocol versions through `AcceptError`. Authentication failures return `AcceptError::AuthenticationFailed` after the host sends a rejected handshake response; a handshake that exceeds the configured limit returns `AcceptError::AuthenticationTimedOut`. The authentication flow and its signed fields are defined in [Security](SECURITY.md). diff --git a/docs/NATIVE-CLIENT.md b/docs/NATIVE-CLIENT.md index d764727..485e6ed 100644 --- a/docs/NATIVE-CLIENT.md +++ b/docs/NATIVE-CLIENT.md @@ -19,7 +19,7 @@ let request = CommunicationValue::new(CommunicationType::Ping).with_id(1); conn.sender.send(&request).await?; let response = conn.receive().await?; println!("received {:?}", response.id()); -conn.sender.close(); +conn.sender.close().await; ``` ## Configuration @@ -230,7 +230,7 @@ let response = conn Requests are routed by id through the connection's receive dispatcher. Frames with other ids remain available through `conn.receive()`. -Two send modes (configured via `mtp::transport::Policy`): +Two send modes (configured via `mtp::client::Policy`): - `PersistentStream` (default): reuses one QUIC unidirectional stream - `SingleStreamPerMessage`: opens a new stream per message @@ -248,12 +248,16 @@ Inbound frames are queued internally. The `receive()` method returns the next av ### Close ```rust -conn.sender.close(); +conn.sender.close().await; // or conn.receiver.close(); ``` -Sends a close frame and signals the peer. The `Sender::close()` spawns an async task that sends the frame, waits for `force_close_delay` (default 300ms), then force-closes the QUIC connection if the peer has not already done so. +`Sender::close().await` gracefully finishes the active send stream, sends the +MTP close frame, and waits for `force_close_delay` (default 300ms) before +force-closing the QUIC connection if necessary. `Sender::close_immediate()` is +the fire-and-forget variant. `Receiver::close()` closes the local receive +handle without performing the sender's graceful close sequence. ### Pipes @@ -309,7 +313,7 @@ For `public_signer`, call `verify` and `into_verified` before calling `decrypt`; The `Policy` struct controls transport behaviour: ```rust -use mtp::transport::{Policy, SendMode}; +use mtp::client::{Policy, SendMode}; let policy = Policy { send_mode: SendMode::PersistentStream, diff --git a/docs/NATIVE-HOST-WEB-SERVER.md b/docs/NATIVE-HOST-WEB-SERVER.md index 36cadf6..c92f18b 100644 --- a/docs/NATIVE-HOST-WEB-SERVER.md +++ b/docs/NATIVE-HOST-WEB-SERVER.md @@ -130,7 +130,7 @@ while let Some(connection) = server.accept().await? { ``` > `MTPWebServer::new` consumes a `HostConfig` (not an `MTPHost` instance). It creates its own QUIC endpoint and does not share a port with a running `MTPHost`. -`server.accept()` returns `Option` for each WebTransport session. Ordinary HTTP routes do not surface through `accept()` because the server dispatches them internally. `WebMTPConnection` retains the negotiated version, codec, request path, remote address, description, sender, and receiver used by native MTP connections. +`server.accept()` returns `Option` for each WebTransport session. Ordinary HTTP routes do not surface through `accept()` because the server dispatches them internally. `WebMTPConnection` retains the negotiated version, codec, `path`, remote address, description, sender, and receiver used by native MTP connections. ## Deployment @@ -138,7 +138,7 @@ For direct browser access, leave `serve_tcp_https(true)` enabled. The server adv When a reverse proxy or another process owns TCP, use `WebServerConfig::new().serve_tcp_https(false)`. This retains the UDP HTTP/3/WebTransport endpoint and its shared router without claiming the TCP port. -With port `0` and TCP enabled, construction binds TCP first and binds UDP to the selected TCP port, so `local_addr()` reports the common address. With TCP disabled, Quinn selects the UDP port as before. `shutdown()` stops both accept loops, gracefully finishes active HTTP requests until `drain_timeout`, closes Quinn, and then aborts remaining work. `close()` and dropping the server stop both listeners immediately. +With port `0` and TCP enabled, construction binds TCP first and binds UDP to the selected TCP port, so `local_addr()` reports the common address. With TCP disabled, Quinn selects the UDP port as before. `shutdown().await` stops both accept loops, gracefully finishes active HTTP requests until `drain_timeout`, closes Quinn, and then aborts remaining work. `close().await` and dropping the server stop both listeners immediately. ### Authentication @@ -164,12 +164,18 @@ On success, the connection has `AuthState::Authenticated`, the assigned `client_ ## Errors -`MTPWebServer::new` returns `CommunicationError` for certificate parsing, certificate loading, bind failures, and rejected authentication policy. +`MTPWebServer::new` returns `CommunicationError` for certificate parsing, +certificate loading, and bind failures. Authentication policy is evaluated when +WebTransport sessions are accepted, not rejected during construction. `accept()` returns `AcceptError` for a missing or unsupported version, a receive failure, or a send failure during the WebTransport opening handshake. HTTP route failures are reported through `WebServerMetrics::error_occurred` when metrics are configured. See [Errors](ERRORS.md) for shared error variants. `WebServerMetrics` has these callbacks: ```rust +use std::time::Duration; + +fn connection_accepted(&self) +fn connection_closed(&self, duration: Duration, reason: &str) fn request_started(&self, path: &str) fn request_completed(&self, path: &str, status: u16, duration: Duration) fn error_occurred(&self, error: &WebServerError) diff --git a/docs/NATIVE-HOST.md b/docs/NATIVE-HOST.md index 33726ec..32d9146 100644 --- a/docs/NATIVE-HOST.md +++ b/docs/NATIVE-HOST.md @@ -83,18 +83,18 @@ network metadata, not an authenticated client identity. ## Version Negotiation -`accept()` uses the version-bearing opening frame and registry flow in [Connector](CONNECTOR.md). The host registry is built from the type maps in `type-maps.yaml` by `Registry::builtin()`. +`accept()` uses the version-bearing opening frame and registry flow in [Connector](CONNECTOR.md). The host registry is built from the type maps in [`example/type-maps.yaml`](../example/type-maps.yaml) by `Registry::builtin()` in this repository; downstream builds can provide their own `MTP_TYPE_MAPS` configuration. ### Registry ```rust -use mtp::codec::registry::Registry; +use mtp::codec::Version; let registry = host.registry(); -assert!(registry.supports(&Version(2, 0))); +assert!(registry.supports(&Version(3, 0))); -let negotiated = registry.negotiate(&[Version(1, 0), Version(2, 0)]); -// -> Some(Version(2, 0)) if both versions are registered +let negotiated = registry.negotiate(&[Version(2, 0), Version(3, 0)]); +// -> Some(Version(3, 0)) for this repository's builtin map ``` ## Authentication Flow @@ -105,13 +105,15 @@ After a successful handshake, `MTPConnection` exposes `AuthState::Authenticated` ## Handling Messages -Use `conn.sender` and `conn.receiver` for bidirectional message exchange: +Use `conn.sender` and `conn.receive()` for bidirectional message exchange. The +connection dispatcher owns the underlying receiver, especially when `pipes` is +enabled: ```rust while let Some(conn) = host.accept().await? { tokio::spawn(async move { loop { - match conn.receiver.receive().await { + match conn.receive().await { Ok(msg) => { let response = process_message(&msg, &conn); conn.sender.send(&response).await.ok(); diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 4ada886..383b1d1 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -39,4 +39,4 @@ Back up host keyrings and client keyrings as protected secrets. Test restoring a ### Graceful Shutdown -Stop accepting new connections, reject new work at the application layer, and allow active requests and pipe writers to finish. For `MTPWebServer`, call `shutdown()`; its `drain_timeout` controls graceful TCP HTTP completion and the QUIC drain period before remaining connection tasks are terminated. +Stop accepting new connections, reject new work at the application layer, and allow active requests and pipe writers to finish. For `MTPWebServer`, call `shutdown().await`; its `drain_timeout` controls graceful TCP HTTP completion and the QUIC drain period before remaining connection tasks are terminated. diff --git a/docs/PIPES.md b/docs/PIPES.md index 51b9e7e..0d276f4 100644 --- a/docs/PIPES.md +++ b/docs/PIPES.md @@ -167,6 +167,9 @@ if let Some(writer) = handle.wait().await? { ```rust // Host use mtp_transport::{PipeSessionParameters, accept_pipe_session}; +use sha2::{Digest, Sha256}; + +// The streaming digest below requires `sha2` as a direct application dependency. while let Ok(request) = conn.receive_pipe().await { if request.description() != "file-upload" { @@ -182,7 +185,7 @@ while let Ok(request) = conn.receive_pipe().await { let mut reader = accept_pipe_session( reader.into_inner(), ¶ms, &own_keyring, &client_public_bundle, ).await?; - let mut hasher = sha2::Sha256::new(); + let mut hasher = Sha256::new(); while let Some(chunk) = reader.read_record().await? { hasher.update(&chunk); process_chunk(&chunk).await?; diff --git a/docs/PROTOCOL-REFERENCE.md b/docs/PROTOCOL-REFERENCE.md index 5c02ad9..133cfb0 100644 --- a/docs/PROTOCOL-REFERENCE.md +++ b/docs/PROTOCOL-REFERENCE.md @@ -112,4 +112,9 @@ identity-specific response for deployments where IDs are public. 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. +The current self-delimiting `DataValue` codec and three-bit communication header +are used by the repository's protocol 3.0 map. The checked-in builtin registry +contains only 3.0, so its native clients and hosts do not provide legacy map +fallbacks. Type-map versions are configuration-driven; a custom registry may +register another version number, but its map must use the current codec format +and is not a fallback for a different legacy wire format. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 154f8e8..76fe901 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -27,7 +27,7 @@ For rotation, publish the replacement certificate or key before changing the ser ### Development Certificates -The `tls` feature exposes `mtp_crypto::tls::generate_self_signed_cert`. It creates an ECDSA P-256 server certificate for the requested domain, `127.0.0.1`, and `::1`; the certificate is valid for 13 days. `HostConfig::self_signed` provides a transport-level self-signed setup without the crypto certificate helper. +The `tls` feature exposes `mtp_crypto::tls::generate_self_signed_cert`. It creates an ECDSA P-256 server certificate for the requested domain, `127.0.0.1`, and `::1`; the certificate is valid for 13 days. The lower-level `mtp_transport::HostConfig::self_signed` provides a transport-level self-signed setup without the crypto certificate helper. Self-signed certificates are for development. Production deployments should use a certificate trusted by the client or an explicitly pinned certificate. @@ -212,6 +212,7 @@ The crate's feature groups are: | `serde` | Serialization support for key types | | `wasm` | `getrandom` support for WebAssembly | | `tls` | Development certificate generation | +| `password-kdf` | Argon2id password derivation for protected keyring files | 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`. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 6842c6f..ad83501 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -59,7 +59,7 @@ When `require_pq` is true, both Ed25519 and ML-DSA-65 keys and signatures must b **Prevention:** Treat generated type maps as versioned build artifacts. -`CodecError::UnknownVersion` means the codec was created for a version absent from its registry. `UnknownCommunicationType` and `UnknownDataType` mean the selected `TypeMap` has no mapping for the value being encoded. Select the negotiated type map and do not send an unmapped variant. +`CodecError::UnknownVersion` means the codec was created for a version absent from its registry. `UnknownCommunicationType` and `UnknownDataType` mean the selected `TypeMap` has no mapping for the value being encoded. `MissingTypeMap` means a versioned value lost its retained negotiated map; `TypeMapMismatch` means it was combined with a value or codec for another version. Select the negotiated type map and do not send an unmapped variant. `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. diff --git a/docs/TYPE-MAP.md b/docs/TYPE-MAP.md index 492cf89..28ab748 100644 --- a/docs/TYPE-MAP.md +++ b/docs/TYPE-MAP.md @@ -1,8 +1,14 @@ # Type Map -This file documents the Type Map & Registry configuration used by the MTP protocol. It will assume you are working with the [example-type-maps.yaml](./../example-type-maps.yaml). -A type musn't be the version of MTP, it stays independant. -MTP version defines the codec. The Type-Map version defines the available Types. +This file documents the type-map and registry configuration used by MTP. The +repository workspace uses [`example/type-maps.yaml`](../example/type-maps.yaml) +through [`.cargo/config.toml`](../.cargo/config.toml); that map currently +selects protocol version 3.0. The root [`example-type-maps.yaml`](../example-type-maps.yaml) +is a separate illustrative multi-version configuration used by the manual WASM +build script. Downstream applications should provide their own map. + +The protocol version selects the generated codec/type-map build, while the +type-map entries define the available application types and their IDs. ## Binary Frame Format @@ -131,7 +137,7 @@ After editing the config and rebuilding, `CommunicationType` and `DataType` enum use mtp::type_map::{CommunicationType, DataType, TypeMap}; let tm = TypeMap::v3_0(); -let id = tm.data_id_enum(DataType::SomeType).unwrap(); +let id = tm.data_id_enum(DataType::ExampleText).unwrap(); ``` For native builds with the `registry` feature, the enums are a **union across @@ -145,30 +151,31 @@ compiled by the Vite plugin. Encoding/decoding uses a `TypeMap` to resolve type names to wire IDs: ```rust -use mtp::codec::{encode, decode, DataValue}; +use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use mtp::type_map::TypeMap; -let tm = TypeMap::v2_0(); -let value = DataValue::Str("hello".into()); +let tm = TypeMap::v3_0(); +let value = CommunicationValue::new_with_type_map(CommunicationType::Ping, &tm) + .add_typed(DataType::Description, &tm, DataValue::Str("hello".into())); -let bytes = encode(&value, &tm).unwrap(); -let decoded = decode(&bytes, &tm).unwrap(); +let bytes = value.to_bytes().unwrap(); +let decoded = CommunicationValue::from_bytes_with(&bytes, &tm).unwrap(); ``` ```rust let tm_v3 = TypeMap::v3_0(); -assert!(tm_v3.data_id_enum(DataType::SomeType).is_some()); +assert!(tm_v3.data_id_enum(DataType::ExampleText).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. The self-delimiting codec begins at protocol version `3.0`; older versions are not codec fallbacks. +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 current repository map uses the self-delimiting codec format for protocol version `3.0`; a custom registry may register other version numbers, but those maps are not legacy wire-format 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: ``` -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 +v3.0 client sends DataType::ExampleText → host encodes with v3.0 TypeMap → wire ID 43 +v3.0 host receives a version absent from the registry → 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. diff --git a/docs/WASM-CLIENT.md b/docs/WASM-CLIENT.md index 8f4c0eb..02be900 100644 --- a/docs/WASM-CLIENT.md +++ b/docs/WASM-CLIENT.md @@ -602,8 +602,19 @@ The SDK logger receives parsed events: ```typescript type MTPLogEvent = - | { hint: "info" | "warning"; type: string; data: unknown } - | { hint: "error"; type: string | "error"; error: string }; + | { + hint: "info" | "warning"; + type: string; + data: unknown; + direction?: "send" | "recv"; + } + | { + hint: "error"; + type: string | "error"; + error: string; + data?: unknown; + direction?: "send" | "recv"; + }; ``` Incoming non-error frames and sent frames are logged as `info`. Error frames and transport errors are logged as `error`. From a5c8d4f0c898c78351e9d54124886c86e789a22a Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 19 Aug 2026 13:17:05 +0200 Subject: [PATCH 07/22] Update ci.yml --- .forgejo/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index ef9d7f8..70e6c39 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -33,6 +33,7 @@ jobs: cargo machete pnpm install --frozen-lockfile + pnpm add --save-dev --save-exact --workspace-root jscpd-linux-x64-gnu@5.0.14 pnpm run dup RUSTFLAGS="--cfg web_sys_unstable_apis" wasm-pack test --node wasm From 2b0bdc32574a01c56c9245054ba8159d18aad8cf Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:11:08 +0200 Subject: [PATCH 08/22] [Fix] Connections --- mtp-webserver/src/h3.rs | 5 ++ mtp-webserver/src/transport.rs | 75 +++++++++++++++++++++++++---- transport/src/generic_connection.rs | 15 ++++++ wasm/src/client/authentication.rs | 41 ++++++++++++++++ 4 files changed, 127 insertions(+), 9 deletions(-) diff --git a/mtp-webserver/src/h3.rs b/mtp-webserver/src/h3.rs index c09379c..abb17aa 100644 --- a/mtp-webserver/src/h3.rs +++ b/mtp-webserver/src/h3.rs @@ -143,6 +143,11 @@ pub(crate) async fn run_driver( return; } }; + tracing::debug!( + remote = %remote_addr, + session_id = ?session.session_id(), + "accepted WebTransport MTP session" + ); tokio::spawn(run_session_requests( session.clone(), router.clone(), diff --git a/mtp-webserver/src/transport.rs b/mtp-webserver/src/transport.rs index a484f26..31d8376 100644 --- a/mtp-webserver/src/transport.rs +++ b/mtp-webserver/src/transport.rs @@ -32,6 +32,8 @@ pub struct H3TransportSender { pub struct H3TransportReceiver { stream: H3RecvStream, + quinn: quinn::Connection, + read_exact_calls: u64, } impl H3TransportConnection { @@ -76,18 +78,42 @@ impl TransportSendStream for H3TransportSender { #[async_trait::async_trait] impl TransportRecvStream for H3TransportReceiver { async fn read_exact(&mut self, buf: &mut [u8]) -> Result<(), CommunicationError> { + let first_read = self.read_exact_calls == 0; + self.read_exact_calls += 1; self.stream .read_exact(buf) .await - .map(|_| ()) + .map(|_| { + if first_read { + tracing::debug!( + remote = %self.quinn.remote_address(), + bytes = buf.len(), + "received first bytes from WebTransport MTP stream" + ); + } + }) .map_err(|error| { - if error.kind() == std::io::ErrorKind::UnexpectedEof { - // Browser control frames are sent on one-frame uni streams. - // Reaching FIN while looking for another frame is normal. + if error.kind() == std::io::ErrorKind::UnexpectedEof + || self.quinn.close_reason().is_some() + { + /* + * Reaching FIN, or losing the enclosing QUIC connection, + * is a normal stream-closure path. Do not turn it into a + * frame-header failure and close the connection again. + */ return CommunicationError::StreamClosed; } - error!("[mtp-webserver] receive stream read_exact failed ({} bytes): {error}", buf.len()); - tracing::warn!(len = buf.len(), %error, "WebTransport receive stream read_exact failed"); + error!( + "[mtp-webserver] receive stream read_exact failed ({} bytes): {error}", + buf.len() + ); + tracing::warn!( + remote = %self.quinn.remote_address(), + first_read, + len = buf.len(), + %error, + "WebTransport receive stream read_exact failed" + ); CommunicationError::StreamError }) } @@ -101,6 +127,9 @@ impl TransportRecvStream for H3TransportReceiver { Ok(Some(buf)) } Err(error) => { + if self.quinn.close_reason().is_some() { + return Err(CommunicationError::StreamClosed); + } error!( "[mtp-webserver] receive stream read failed (max {} bytes): {error}", max @@ -167,10 +196,27 @@ impl TransportConnection for H3TransportConnection { loop { match self.session.accept_uni().await { Ok(Some((id, stream))) if id == self.session.session_id() => { - return Ok(H3TransportReceiver { stream }); + let stream_id = h3::quic::RecvStream::recv_id(&stream); + tracing::debug!( + remote = %self.quinn.remote_address(), + session_id = ?self.session.session_id(), + stream_id = ?stream_id, + "accepted WebTransport MTP receive stream" + ); + return Ok(H3TransportReceiver { + stream, + quinn: self.quinn.clone(), + read_exact_calls: 0, + }); } - Ok(Some(_)) => { + Ok(Some((stream_session_id, _stream))) => { consecutive_errors = 0; + tracing::debug!( + remote = %self.quinn.remote_address(), + session_id = ?self.session.session_id(), + stream_session_id = ?stream_session_id, + "ignored WebTransport receive stream belonging to another session" + ); continue; } Ok(None) => return Err(CommunicationError::StreamClosed), @@ -306,7 +352,18 @@ async fn accept_web_connection_inner( connection_id, }, ) - .await?; + .await; + #[cfg(feature = "crypto")] + if let Err(error) = &result { + tracing::warn!( + remote = %remote_addr, + connection_id, + %error, + "WebTransport MTP handshake failed" + ); + } + #[cfg(feature = "crypto")] + let result = result?; #[cfg(not(feature = "crypto"))] let result = engine.accept(&sender, &receiver).await?; diff --git a/transport/src/generic_connection.rs b/transport/src/generic_connection.rs index 23f3fe9..4fa6433 100644 --- a/transport/src/generic_connection.rs +++ b/transport/src/generic_connection.rs @@ -333,6 +333,18 @@ impl GenericReceiver { break 'stream; } Err(_) => { + if frames == 0 { + tracing::warn!( + timeout = ?policy.read_timeout, + "MTP receive stream timed out before its first complete frame" + ); + } else { + tracing::debug!( + frames, + timeout = ?policy.read_timeout, + "MTP receive stream idle timeout" + ); + } break; } } @@ -376,6 +388,9 @@ impl GenericReceiver { ) .await; if !matches!(&body_read, Ok(Ok(()))) { + if matches!(&body_read, Ok(Err(CommunicationError::StreamClosed))) { + break 'stream; + } tracing::warn!( pipe_chunk_len = chunk_len, ?body_read, diff --git a/wasm/src/client/authentication.rs b/wasm/src/client/authentication.rs index 45cec8d..df7989e 100644 --- a/wasm/src/client/authentication.rs +++ b/wasm/src/client/authentication.rs @@ -8,6 +8,14 @@ use crate::config::ConnectionConfig; use crate::error::js_error; use crate::transport::WasmTransport; +fn server_rejection_message(outcome: &CommunicationValue) -> Option<&str> { + (outcome.get_data(DataType::Connected) == Some(&DataValue::BoolFalse)).then(|| { + outcome + .get_str(DataType::ErrorMessage) + .unwrap_or("host rejected the connection") + }) +} + #[wasm_bindgen] #[allow(deprecated)] impl WasmClient { @@ -79,6 +87,18 @@ impl WasmClient { .unwrap_or("host does not support this protocol version"), )); } + + // Generic host rejections are IdentificationResponse frames with + // Connected=false. They intentionally do not carry a negotiated + // Version because negotiation never completed. Check this before + // reading Version, otherwise a useful server error such as an + // authentication timeout is reported as the misleading + // "host omitted a valid negotiated protocol version". + if let Some(message) = server_rejection_message(&outcome) { + self.set_state_if_current(generation, ConnectionState::Disconnected); + return Err(js_error(message)); + } + 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"))?, @@ -628,3 +648,24 @@ impl WasmClient { Ok(server_challenge) } } + +#[cfg(test)] +mod tests { + use super::server_rejection_message; + use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue}; + + #[test] + fn reports_rejection_reason_without_a_negotiated_version() { + let response = CommunicationValue::new(CommunicationType::IdentificationResponse) + .add_typed_default(DataType::Connected, DataValue::BoolFalse) + .add_typed_default( + DataType::ErrorMessage, + DataValue::Str("authentication handshake timed out".into()), + ); + + assert_eq!( + server_rejection_message(&response), + Some("authentication handshake timed out") + ); + } +} From bd660b2afbf08bb1aa3fef9cf93a11f3bdafa1d4 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:37:25 +0200 Subject: [PATCH 09/22] [Debug] --- host/src/engine.rs | 22 +++++++++ mtp-webserver/src/transport.rs | 1 + transport/src/generic_connection.rs | 6 +++ wasm/src/transport.rs | 70 +++++++++++++++-------------- 4 files changed, 66 insertions(+), 33 deletions(-) diff --git a/host/src/engine.rs b/host/src/engine.rs index 38e8422..d1ebd19 100644 --- a/host/src/engine.rs +++ b/host/src/engine.rs @@ -217,6 +217,12 @@ impl HandshakeEngine { _authentication_context: &AuthenticationContext, ) -> Result { let mut first_msg = receiver.receive().await.map_err(AcceptError::Receive)?; + tracing::debug!( + message_type = ?first_msg.get_type(), + version = ?first_msg.get_str(DataType::Version), + client_id = ?first_msg.get_data(DataType::Id), + "received MTP opening message" + ); let version_str = match first_msg.get_data(DataType::Version) { Some(DataValue::Str(s)) => s.clone(), @@ -271,6 +277,11 @@ impl HandshakeEngine { return Err(AcceptError::UnsupportedVersion(client_version)); } }; + tracing::debug!( + client_version = %client_version, + negotiated_version = %negotiated, + "MTP protocol version negotiated" + ); let codec = VersionedCodec::for_version(self.registry.clone(), negotiated.clone()) .ok_or_else(|| AcceptError::UnsupportedVersion(negotiated.clone()))?; @@ -1105,6 +1116,12 @@ async fn send_rejection_generic( .add_typed_default(DataType::Connected, DataValue::BoolFalse) .add_typed_default(DataType::ErrorMessage, DataValue::Str(reason.to_string())), }; + tracing::debug!( + reason = %reason, + response_type = ?response.get_type(), + has_version = response.get_data(DataType::Version).is_some(), + "sending MTP handshake rejection" + ); let _ = sender.send(&response).await; } @@ -1138,6 +1155,11 @@ async fn send_accepted_generic( if let Some(id) = assigned_id { response = response.add_typed_default(DataType::Id, DataValue::UnsignedNumber(id as u128)); } + tracing::debug!( + version = %version, + assigned_id = ?assigned_id, + "sending accepted MTP handshake response" + ); sender.send(&response).await?; sender.finish_stream().await } diff --git a/mtp-webserver/src/transport.rs b/mtp-webserver/src/transport.rs index 31d8376..9b7de76 100644 --- a/mtp-webserver/src/transport.rs +++ b/mtp-webserver/src/transport.rs @@ -88,6 +88,7 @@ impl TransportRecvStream for H3TransportReceiver { tracing::debug!( remote = %self.quinn.remote_address(), bytes = buf.len(), + header = ?buf, "received first bytes from WebTransport MTP stream" ); } diff --git a/transport/src/generic_connection.rs b/transport/src/generic_connection.rs index 4fa6433..830fb40 100644 --- a/transport/src/generic_connection.rs +++ b/transport/src/generic_connection.rs @@ -423,6 +423,12 @@ impl GenericReceiver { break; } }; + tracing::debug!( + frames, + frame_len, + message_type = ?message.get_type(), + "decoded MTP receive frame" + ); let negotiated_type_map = type_map.read().await.clone(); message.set_type_map(&negotiated_type_map); diff --git a/wasm/src/transport.rs b/wasm/src/transport.rs index a4deea6..dc0091e 100644 --- a/wasm/src/transport.rs +++ b/wasm/src/transport.rs @@ -128,8 +128,6 @@ 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>, @@ -209,7 +207,6 @@ 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())), decode_limits: Rc::new(RefCell::new(decode_limits)), @@ -247,30 +244,30 @@ impl WasmTransport { return Err(js_error("message too large")); } - 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"), - )? + // Use one WebTransport uni-stream per MTP frame. Chromium reliably + // publishes a browser-created uni-stream to the peer when it is + // closed; leaving a shared stream open can leave the server waiting + // in accept_uni() until the authentication deadline. The bytes are + // already the canonical MTP self-framed value, so no extra stream + // length prefix is added here. + 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"))? .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 = 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 - }; + .map_err(|_| js_error("getWriter not a function"))? + .call0(&writable_or_stream) + .map_err(|_| js_error("getWriter call failed"))?; let chunk = js_sys::Uint8Array::from(frame); @@ -283,11 +280,24 @@ 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 { + // The frame was already written; do not retry it merely because + // FIN failed, as that would duplicate the MTP frame. + log_stream_error_code(&e, "send_frame close"); + } + release_writer_lock(&writer_val); + Ok(()) } @@ -677,12 +687,6 @@ 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); From 4c10b56a6cccf90c48a9716ab330b7ea57785ad2 Mon Sep 17 00:00:00 2001 From: Alois Date: Thu, 20 Aug 2026 20:55:43 +0200 Subject: [PATCH 10/22] Update workflows --- .forgejo/workflows/ci.yml | 4 ---- .forgejo/workflows/release.yml | 9 --------- 2 files changed, 13 deletions(-) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 523e360..5fb883b 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -7,16 +7,12 @@ on: env: CARGO_TERM_COLOR: always - NIX_CONFIG: experimental-features = nix-command flakes jobs: checks: name: checks runs-on: nixos steps: - - name: Install node - run: nix profile add nixpkgs#nodejs_24 - - name: Checkout uses: https://data.forgejo.org/actions/checkout@v4 diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index fcda261..64d19ef 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -14,16 +14,10 @@ on: required: true type: string -env: - NIX_CONFIG: experimental-features = nix-command flakes - jobs: release: runs-on: nixos steps: - - name: Install node & bun - run: nix profile add nixpkgs#nodejs_24 nixpkgs#bun - - name: Check out repo uses: https://data.forgejo.org/actions/checkout@v4 with: @@ -32,9 +26,6 @@ jobs: - name: Install dependencies run: bun install - - name: Install cc linker, sed & jq - run: nix profile add nixpkgs#stdenv.cc nixpkgs#gnused nixpkgs#jq - - name: Build all run: bun build:all From 420831cd09428c9612935334fb5de2e49a85d904 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:42:23 +0200 Subject: [PATCH 11/22] [Add] Docs & patches --- host/src/engine.rs | 8 ++++++++ transport/src/generic_connection.rs | 11 ++++++++++- wasm/src/client/authentication.rs | 13 +++++++++++-- 3 files changed, 29 insertions(+), 3 deletions(-) mode change 100644 => 100755 host/src/engine.rs diff --git a/host/src/engine.rs b/host/src/engine.rs old mode 100644 new mode 100755 index d1ebd19..08ca87f --- a/host/src/engine.rs +++ b/host/src/engine.rs @@ -309,6 +309,12 @@ impl HandshakeEngine { ) || registration || first_msg.get_data(DataType::PublicKeys).is_some() || claimed_client_id.is_some_and(|client_id| client_id != 0); + tracing::info!( + claimed_client_id = ?claimed_client_id, + registration, + authentication_requested, + "classified MTP opening authentication mode" + ); if authentication_requested { let attempt = crate::config::AuthenticationAttempt { peer_network_identity: _authentication_context.peer_network_identity.clone(), @@ -566,6 +572,7 @@ impl HandshakeEngine { } // Unknown or zero ID: fall back to guest + tracing::info!("allocating MTP guest identity"); let guest_id_lease = match self.assign_guest_id().await { Ok(lease) => lease, Err(error) => { @@ -574,6 +581,7 @@ impl HandshakeEngine { } }; let guest_id = guest_id_lease.id; + tracing::info!(guest_id, "allocated MTP guest identity"); send_accepted_generic(sender, &negotiated, tm, Some(guest_id)) .await .map_err(AcceptError::Send)?; diff --git a/transport/src/generic_connection.rs b/transport/src/generic_connection.rs index 830fb40..a7a32de 100644 --- a/transport/src/generic_connection.rs +++ b/transport/src/generic_connection.rs @@ -9,7 +9,7 @@ use crate::{ connection::{DecodeRejectionCounters, RuntimePolicy, classify_decode_error}, framing::{RetryClassifier, write_frame}, }; -use mtp_codec::{CommunicationValue, DecodeLimits, TypeMap}; +use mtp_codec::{CommunicationValue, DataType, DecodeLimits, TypeMap}; use mtp_common::CommunicationError; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; @@ -67,6 +67,15 @@ impl GenericSender { if self.connection.close_reason().is_some() { return Err(CommunicationError::StreamClosed); } + if let Some(version) = value.get_str(DataType::Version) { + tracing::debug!( + message_type = ?value.get_type(), + version, + connected = ?value.get_data(DataType::Connected), + client_id = ?value.get_data(DataType::Id), + "sending MTP handshake response frame" + ); + } match self.policy.send_mode { crate::SendMode::SingleStreamPerMessage => { let mut stream = self.open().await?; diff --git a/wasm/src/client/authentication.rs b/wasm/src/client/authentication.rs index df7989e..73bfc9f 100644 --- a/wasm/src/client/authentication.rs +++ b/wasm/src/client/authentication.rs @@ -99,10 +99,19 @@ impl WasmClient { return Err(js_error(message)); } + let missing_version = || { + js_error(format!( + "host omitted a valid negotiated protocol version (response_type={:?}, connected={:?}, frame_len={})", + outcome.get_type(), + outcome.get_data(DataType::Connected), + outcome_bytes.len(), + )) + }; + 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")), + .ok_or_else(|| missing_version())?, + _ => return Err(missing_version()), }; if negotiated_version != PROTOCOL_VERSION { return Err(js_error( From 101b8322a1eb76490fee70c4606c7ded18327fe9 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:59:57 +0200 Subject: [PATCH 12/22] [Fix] Policy overwrites --- host/src/config.rs | 83 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/host/src/config.rs b/host/src/config.rs index 0cef52a..99d62a2 100644 --- a/host/src/config.rs +++ b/host/src/config.rs @@ -251,6 +251,8 @@ pub struct HostConfig { #[cfg(feature = "crypto")] pub authentication_policy: AuthenticationPolicy, #[cfg(feature = "crypto")] + authentication_policy_explicit: bool, + #[cfg(feature = "crypto")] pub auth_timeout: Duration, #[cfg(feature = "crypto")] pub require_pq: bool, @@ -288,6 +290,8 @@ impl HostConfig { #[cfg(feature = "crypto")] authentication_policy: AuthenticationPolicy::Unauthenticated, #[cfg(feature = "crypto")] + authentication_policy_explicit: false, + #[cfg(feature = "crypto")] auth_timeout: Duration::from_secs(30), #[cfg(feature = "crypto")] require_pq: true, @@ -341,7 +345,9 @@ impl HostConfig { get_existing_client: GetExistingClient, complete_register: CompleteRegister, ) -> Self { - self.authentication_policy = AuthenticationPolicy::ForceAuthentication; + if !self.authentication_policy_explicit { + self.authentication_policy = AuthenticationPolicy::ForceAuthentication; + } self.host_keyring = host_keyring; self.get_existing_client = Box::new(get_existing_client); self.complete_register = Box::new(complete_register); @@ -351,6 +357,7 @@ impl HostConfig { #[cfg(feature = "crypto")] pub fn with_authentication_policy(mut self, policy: AuthenticationPolicy) -> Self { self.authentication_policy = policy; + self.authentication_policy_explicit = true; self } @@ -441,4 +448,78 @@ mod tests { .expect("repeated registration decision") ); } + + fn test_keyring() -> mtp_crypto::Keyring { + mtp_crypto::Keyring::new( + mtp_crypto::KemPublicKey::new(Vec::new()), + mtp_crypto::KemPrivateKey::new(Vec::new()), + mtp_crypto::SignaturePqPublicKey::new(Vec::new()), + mtp_crypto::SignaturePqPrivateKey::new(Vec::new()), + mtp_crypto::SignaturePublicKey::new(Vec::new()), + mtp_crypto::SignaturePrivateKey::new(Vec::new()), + ) + } + + fn test_get_existing_client() -> GetExistingClient { + Box::new(|_, _| Box::pin(async { None })) + } + + fn test_complete_register() -> CompleteRegister { + Box::new(|_, _| Box::pin(async { 1 })) + } + + fn test_config() -> HostConfig { + HostConfig::new( + IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), + 4433, + Vec::new(), + Vec::new(), + ) + } + + #[test] + fn with_authentication_defaults_to_force_authentication() { + let config = test_config().with_authentication( + test_keyring(), + test_get_existing_client(), + test_complete_register(), + ); + + assert_eq!( + config.authentication_policy, + AuthenticationPolicy::ForceAuthentication + ); + } + + #[test] + fn explicit_authentication_policy_before_with_authentication_is_preserved() { + let config = test_config() + .with_authentication_policy(AuthenticationPolicy::AllowAuthentication) + .with_authentication( + test_keyring(), + test_get_existing_client(), + test_complete_register(), + ); + + assert_eq!( + config.authentication_policy, + AuthenticationPolicy::AllowAuthentication + ); + } + + #[test] + fn explicit_authentication_policy_after_with_authentication_is_preserved() { + let config = test_config() + .with_authentication( + test_keyring(), + test_get_existing_client(), + test_complete_register(), + ) + .with_authentication_policy(AuthenticationPolicy::AllowAuthentication); + + assert_eq!( + config.authentication_policy, + AuthenticationPolicy::AllowAuthentication + ); + } } From e83cd132a22b8a230dd35b21cb17d10af27f3c7c Mon Sep 17 00:00:00 2001 From: Alois Date: Thu, 27 Aug 2026 15:31:55 +0200 Subject: [PATCH 13/22] feat(wasm, native, h3): make wasm, native and h3 use unified interface --- client/src/pipe.rs | 42 ++- common/src/lib.rs | 36 ++ host/src/connection.rs | 4 +- host/src/pipe.rs | 81 ++++- mtp-webserver/src/transport.rs | 14 +- transport/src/connection.rs | 122 +++++-- transport/src/framing.rs | 4 + transport/src/generic_connection.rs | 112 ++++-- transport/src/transport_traits.rs | 26 +- transport/tests/generic_pipe.rs | 39 +- wasm/src/client/receive.rs | 7 + wasm/src/pipe.rs | 102 +++--- wasm/src/transport.rs | 546 +++++++++++++++------------- 13 files changed, 737 insertions(+), 398 deletions(-) diff --git a/client/src/pipe.rs b/client/src/pipe.rs index 8e839dd..136fe01 100644 --- a/client/src/pipe.rs +++ b/client/src/pipe.rs @@ -78,9 +78,41 @@ pub struct PipeRequest { pub(crate) pipe_id: u32, pub(crate) description: String, pub(crate) sender: Sender, + pub(crate) receiver: Receiver, pub(crate) dispatcher: Arc, } +#[cfg(feature = "pipes")] +struct ExpectedPipeGuard { + receiver: Receiver, + pipe_id: u32, + armed: bool, +} + +#[cfg(feature = "pipes")] +impl ExpectedPipeGuard { + fn new(receiver: Receiver, pipe_id: u32) -> Self { + Self { + receiver, + pipe_id, + armed: true, + } + } + + fn disarm(&mut self) { + self.armed = false; + } +} + +#[cfg(feature = "pipes")] +impl Drop for ExpectedPipeGuard { + fn drop(&mut self) { + if self.armed { + self.receiver.cancel_expected_pipe(self.pipe_id); + } + } +} + #[cfg(feature = "pipes")] impl PipeRequest { pub fn id(&self) -> u32 { @@ -92,6 +124,10 @@ impl PipeRequest { } pub async fn accept(self) -> Result { + self.receiver + .expect_pipe(self.pipe_id) + .map_err(PipeError::from)?; + let mut expected_pipe = ExpectedPipeGuard::new(self.receiver.clone(), self.pipe_id); let (pipe_tx, pipe_rx) = tokio::sync::oneshot::channel(); { let mut pending = self.dispatcher.pending_pipes.lock().await; @@ -115,7 +151,10 @@ impl PipeRequest { let timeout = self.dispatcher.policy.read_timeout; match tokio::time::timeout(timeout, pipe_rx).await { - Ok(Ok(reader)) => Ok(reader), + Ok(Ok(reader)) => { + expected_pipe.disarm(); + Ok(reader) + } Ok(Err(_)) => { self.dispatcher .pending_pipes @@ -413,6 +452,7 @@ pub(crate) async fn run_dispatcher( pipe_id, description, sender: sender.clone(), + receiver: receiver.clone(), dispatcher: dispatcher.clone(), }; let _ = pipe_req_tx.send(req).await; diff --git a/common/src/lib.rs b/common/src/lib.rs index 1a7fe66..71e87f2 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -164,6 +164,9 @@ pub enum CommunicationError { #[error("Stream Error")] StreamError, + #[error("Stream failed after delivery may have started")] + DeliveryUnknown, + #[error("Stream Error: {0}")] #[cfg(not(target_arch = "wasm32"))] StreamWriteError(#[from] wtransport::error::StreamWriteError), @@ -182,6 +185,38 @@ pub enum CommunicationError { Other(String), } +/// How the protocol layer should handle the first frame on a receive stream. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FirstFrameDisposition { + Message, + Pipe(u32), +} + +/// Classify a first frame without tying the decision to a WebTransport backend. +/// +/// `PipeRequest` is used both as a control message and as the header of the raw +/// stream opened after that request is accepted. Only the protocol layer knows +/// which raw stream IDs are currently expected. +pub fn classify_first_frame( + is_pipe_request: bool, + pipe_id: Option, + pipe_is_expected: bool, +) -> Result { + if !is_pipe_request { + return Ok(FirstFrameDisposition::Message); + } + + let pipe_id = pipe_id.filter(|id| *id != 0).ok_or_else(|| { + CommunicationError::Other("PipeRequest frame must contain a non-zero id".into()) + })?; + + if pipe_is_expected { + Ok(FirstFrameDisposition::Pipe(pipe_id)) + } else { + Ok(FirstFrameDisposition::Message) + } +} + // ---- manual PartialEq (quinn / wtransport types don't impl PartialEq) ---- impl PartialEq for CommunicationError { @@ -212,6 +247,7 @@ impl PartialEq for CommunicationError { (Self::ReadExactError(_), Self::ReadExactError(_)) => true, (Self::StreamClosed, Self::StreamClosed) => true, (Self::StreamError, Self::StreamError) => true, + (Self::DeliveryUnknown, Self::DeliveryUnknown) => true, #[cfg(not(target_arch = "wasm32"))] (Self::StreamWriteError(_), Self::StreamWriteError(_)) => true, #[cfg(not(target_arch = "wasm32"))] diff --git a/host/src/connection.rs b/host/src/connection.rs index 71c60db..7871d55 100644 --- a/host/src/connection.rs +++ b/host/src/connection.rs @@ -73,7 +73,7 @@ pub struct MTPConnection< #[cfg(feature = "pipes")] pub(crate) app_rx: Mutex>>, #[cfg(feature = "pipes")] - pub(crate) pipe_req_rx: Mutex>>, + pub(crate) pipe_req_rx: Mutex>>, #[cfg(feature = "pipes")] pub(crate) pipe_dispatcher: Arc>, #[cfg(not(feature = "pipes"))] @@ -381,7 +381,7 @@ where }) } - pub async fn receive_pipe(&self) -> Result, CommunicationError> { + pub async fn receive_pipe(&self) -> Result, CommunicationError> { self.pipe_req_rx .lock() .await diff --git a/host/src/pipe.rs b/host/src/pipe.rs index eae1383..192e3d7 100644 --- a/host/src/pipe.rs +++ b/host/src/pipe.rs @@ -27,6 +27,10 @@ pub trait PipeReceiver

: Clone + Send + Sync + 'static where P: tokio::io::AsyncRead + Send + Unpin + 'static, { + fn expect_pipe(&self, pipe_id: u32) -> Result<(), CommunicationError>; + + fn cancel_expected_pipe(&self, pipe_id: u32); + fn receive_pipe_event( &self, ) -> impl std::future::Future, CommunicationError>> + Send; @@ -52,6 +56,14 @@ impl PipeSender for mtp_transport::Sender { } impl PipeReceiver for mtp_transport::Receiver { + fn expect_pipe(&self, pipe_id: u32) -> Result<(), CommunicationError> { + self.expect_pipe(pipe_id) + } + + fn cancel_expected_pipe(&self, pipe_id: u32) { + self.cancel_expected_pipe(pipe_id); + } + async fn receive_pipe_event( &self, ) -> Result, CommunicationError> { @@ -87,6 +99,14 @@ where C: mtp_transport::TransportConnection, C::RecvStream: tokio::io::AsyncRead + Send + Unpin + 'static, { + fn expect_pipe(&self, pipe_id: u32) -> Result<(), CommunicationError> { + self.expect_pipe(pipe_id) + } + + fn cancel_expected_pipe(&self, pipe_id: u32) { + self.cancel_expected_pipe(pipe_id); + } + async fn receive_pipe_event( &self, ) -> Result, CommunicationError> { @@ -152,16 +172,60 @@ where } } -pub struct PipeRequest { +pub struct PipeRequest { pub(crate) pipe_id: u32, pub(crate) description: String, pub(crate) sender: S, + pub(crate) receiver: R, pub(crate) dispatcher: Arc>, } -impl PipeRequest +struct ExpectedPipeGuard +where + R: PipeReceiver

, + P: tokio::io::AsyncRead + Send + Unpin + 'static, +{ + receiver: R, + pipe_id: u32, + armed: bool, + _stream: std::marker::PhantomData

, +} + +impl ExpectedPipeGuard +where + R: PipeReceiver

, + P: tokio::io::AsyncRead + Send + Unpin + 'static, +{ + fn new(receiver: R, pipe_id: u32) -> Self { + Self { + receiver, + pipe_id, + armed: true, + _stream: std::marker::PhantomData, + } + } + + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for ExpectedPipeGuard +where + R: PipeReceiver

, + P: tokio::io::AsyncRead + Send + Unpin + 'static, +{ + fn drop(&mut self) { + if self.armed { + self.receiver.cancel_expected_pipe(self.pipe_id); + } + } +} + +impl PipeRequest where S: PipeSender, + R: PipeReceiver

, P: tokio::io::AsyncRead + Send + Unpin + 'static, { pub fn id(&self) -> u32 { @@ -173,6 +237,10 @@ where } pub async fn accept(self) -> Result, PipeError> { + self.receiver + .expect_pipe(self.pipe_id) + .map_err(PipeError::from)?; + let mut expected_pipe = ExpectedPipeGuard::::new(self.receiver.clone(), self.pipe_id); let (pipe_tx, pipe_rx) = tokio::sync::oneshot::channel(); self.dispatcher .pending_pipes @@ -196,7 +264,10 @@ where } match tokio::time::timeout(self.dispatcher.policy.read_timeout, pipe_rx).await { - Ok(Ok(reader)) => Ok(reader), + Ok(Ok(reader)) => { + expected_pipe.disarm(); + Ok(reader) + } Ok(Err(_)) => { self.dispatcher .pending_pipes @@ -361,7 +432,7 @@ pub(crate) async fn run_dispatcher( receiver: R, sender: S, app_tx: mpsc::Sender>, - pipe_req_tx: mpsc::Sender>, + pipe_req_tx: mpsc::Sender>, dispatcher: Arc>, ) where S: PipeSender, @@ -388,6 +459,7 @@ pub(crate) async fn run_dispatcher( .unwrap_or("") .to_owned(), sender: sender.clone(), + receiver: receiver.clone(), dispatcher: dispatcher.clone(), }; let _ = pipe_req_tx.send(request).await; @@ -446,6 +518,7 @@ pub(crate) async fn run_dispatcher( pipe_id, description: reader.description().to_owned(), sender: sender.clone(), + receiver: receiver.clone(), dispatcher: dispatcher.clone(), }; let _ = pipe_req_tx.send(request).await; diff --git a/mtp-webserver/src/transport.rs b/mtp-webserver/src/transport.rs index 9b7de76..9dcce5f 100644 --- a/mtp-webserver/src/transport.rs +++ b/mtp-webserver/src/transport.rs @@ -57,14 +57,14 @@ impl TransportSendStream for H3TransportSender { self.stream .write_all(buf) .await - .map_err(|_| CommunicationError::StreamError)?; + .map_err(|_| CommunicationError::DeliveryUnknown)?; // Control/authentication frames use a persistent stream. h3 keeps // those writes buffered until flushed; without this the peer can wait // for the challenge while the server waits for its proof. self.stream .flush() .await - .map_err(|_| CommunicationError::StreamError) + .map_err(|_| CommunicationError::DeliveryUnknown) } async fn finish(&mut self) -> Result<(), CommunicationError> { @@ -73,6 +73,11 @@ impl TransportSendStream for H3TransportSender { .await .map_err(|_| CommunicationError::StreamError) } + + fn reset(&mut self, code: u32) -> Result<(), CommunicationError> { + h3::quic::SendStream::reset(&mut self.stream, code as u64); + Ok(()) + } } #[async_trait::async_trait] @@ -140,6 +145,11 @@ impl TransportRecvStream for H3TransportReceiver { } } } + + fn stop(mut self, code: u32) -> Result<(), CommunicationError> { + h3::quic::RecvStream::stop_sending(&mut self.stream, code as u64); + Ok(()) + } } impl tokio::io::AsyncWrite for H3TransportSender { diff --git a/transport/src/connection.rs b/transport/src/connection.rs index dece604..7db09e8 100644 --- a/transport/src/connection.rs +++ b/transport/src/connection.rs @@ -4,6 +4,10 @@ use crate::framing::RetryClassifier; use crate::pipe::PipeReader; use mtp_codec::{CommunicationValue, DecodeError, DecodeLimits, EncodeLimits, TypeMap}; use mtp_common::CommunicationError; +#[cfg(feature = "pipes")] +use mtp_common::{FirstFrameDisposition, classify_first_frame}; +#[cfg(feature = "pipes")] +use std::collections::HashSet; use std::ops::Deref; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; @@ -288,15 +292,15 @@ impl Sender { Ok(Ok(())) => Ok(()), Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => { warn!("[Sender] write failed: peer sent STOP_SENDING (error code {code})"); - Err(CommunicationError::StreamClosed) + Err(CommunicationError::DeliveryUnknown) } Ok(Err(other)) => { warn!("[Sender] write failed: {other}"); - Err(CommunicationError::StreamError) + Err(CommunicationError::DeliveryUnknown) } Err(_) => { warn!("[Sender] write timed out (len={})", bytes.len()); - Err(CommunicationError::StreamError) + Err(CommunicationError::DeliveryUnknown) } } } @@ -398,15 +402,15 @@ impl Sender { Ok(Ok(())) => Ok(()), Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => { warn!("[Sender] finish failed: peer sent STOP_SENDING (error code {code})"); - Err(CommunicationError::StreamClosed) + Err(CommunicationError::DeliveryUnknown) } Ok(Err(other)) => { warn!("[Sender] finish failed: {other}"); - Err(CommunicationError::StreamError) + Err(CommunicationError::DeliveryUnknown) } Err(_) => { warn!("[Sender] finish timed out"); - Err(CommunicationError::StreamError) + Err(CommunicationError::DeliveryUnknown) } } } @@ -745,6 +749,8 @@ struct ReceiverInner { max_message_size: Arc, type_map: Arc>, decode_rejections: Arc, + #[cfg(feature = "pipes")] + expected_pipes: Arc>>, } impl Clone for Receiver { @@ -831,6 +837,10 @@ impl Receiver { let accept_type_map = type_map.clone(); let decode_rejections = Arc::new(DecodeRejectionCounters::default()); let accept_decode_rejections = decode_rejections.clone(); + #[cfg(feature = "pipes")] + let expected_pipes = Arc::new(std::sync::Mutex::new(HashSet::new())); + #[cfg(feature = "pipes")] + let accept_expected_pipes = expected_pipes.clone(); let stream_limit = Arc::new(Semaphore::new(policy.max_concurrent_stream_tasks.max(1))); let accept_stream_limit = stream_limit.clone(); debug!( @@ -900,6 +910,8 @@ impl Receiver { let stream_max_message_size = accept_max_message_size.clone(); let stream_type_map = accept_type_map.clone(); let stream_decode_rejections = accept_decode_rejections.clone(); + #[cfg(feature = "pipes")] + let stream_expected_pipes = accept_expected_pipes.clone(); tokio::spawn(async move { let _permit = permit; @@ -935,38 +947,54 @@ impl Receiver { #[cfg(feature = "pipes")] { - if msg.is_type(mtp_codec::CommunicationType::PipeRequest) - && frame_count == 1 - { - 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)); + if frame_count == 1 { + let is_pipe_request = msg.is_type( + mtp_codec::CommunicationType::PipeRequest, + ); + let pipe_id = msg.id().filter(|id| *id != 0); + let pipe_is_expected = is_pipe_request && pipe_id.is_some_and(|pipe_id| { + stream_expected_pipes + .lock() + .is_ok_and(|mut expected| expected.remove(&pipe_id)) + }); + let disposition = match classify_first_frame( + is_pipe_request, + msg.id(), + pipe_is_expected, + ) { + Ok(disposition) => disposition, + Err(error) => { + let _ = msg_tx_stream + .send(Err(error.clone())) + .await; + stream_handle.close(Some(error)); + break; + } + }; + + if let FirstFrameDisposition::Pipe(pipe_id) = disposition { + let description = msg + .get_str(mtp_codec::DataType::Description) + .unwrap_or("") + .to_string(); + + let pipe_reader = crate::pipe::PipeReader { + stream: s, + description, + pipe_id, + }; + + if pipe_tx_stream + .send(pipe_reader) + .await + .is_err() + { + stream_handle.close(Some( + CommunicationError::StreamClosed, + )); + } break; - }; - let description = msg - .get_str(mtp_codec::DataType::Description) - .unwrap_or("") - .to_string(); - - let pipe_reader = crate::pipe::PipeReader { - stream: s, - description, - pipe_id, - }; - - if pipe_tx_stream - .send(pipe_reader) - .await - .is_err() - { - stream_handle.close(Some( - CommunicationError::StreamClosed, - )); } - break; } } @@ -1111,6 +1139,8 @@ impl Receiver { max_message_size, type_map, decode_rejections, + #[cfg(feature = "pipes")] + expected_pipes, }), } } @@ -1127,6 +1157,26 @@ impl Receiver { *self.inner.type_map.write().await = type_map.clone(); } + #[cfg(feature = "pipes")] + pub fn expect_pipe(&self, pipe_id: u32) -> Result<(), CommunicationError> { + if pipe_id == 0 { + return Err(CommunicationError::Other("pipe id must be non-zero".into())); + } + self.inner + .expected_pipes + .lock() + .map_err(|_| CommunicationError::Other("expected pipe state is unavailable".into()))? + .insert(pipe_id); + Ok(()) + } + + #[cfg(feature = "pipes")] + pub fn cancel_expected_pipe(&self, pipe_id: u32) { + if let Ok(mut expected) = self.inner.expected_pipes.lock() { + expected.remove(&pipe_id); + } + } + /// Return local counts for frames rejected by the structured decoder. /// /// These counters are intentionally local-only; peers continue to receive diff --git a/transport/src/framing.rs b/transport/src/framing.rs index 9fa1d80..ac4e005 100644 --- a/transport/src/framing.rs +++ b/transport/src/framing.rs @@ -82,6 +82,10 @@ mod tests { async fn finish(&mut self) -> Result<(), CommunicationError> { Ok(()) } + + fn reset(&mut self, _code: u32) -> Result<(), CommunicationError> { + Ok(()) + } } #[tokio::test] diff --git a/transport/src/generic_connection.rs b/transport/src/generic_connection.rs index a7a32de..4361855 100644 --- a/transport/src/generic_connection.rs +++ b/transport/src/generic_connection.rs @@ -10,8 +10,12 @@ use crate::{ framing::{RetryClassifier, write_frame}, }; use mtp_codec::{CommunicationValue, DataType, DecodeLimits, TypeMap}; -use mtp_common::CommunicationError; +use mtp_common::{CommunicationError, FirstFrameDisposition, classify_first_frame}; +#[cfg(feature = "pipes")] +use std::collections::HashSet; use std::sync::Arc; +#[cfg(feature = "pipes")] +use std::sync::Mutex as StdMutex; use std::sync::atomic::{AtomicU64, Ordering}; use tokio::sync::{Mutex, Notify, RwLock, Semaphore, mpsc}; use tokio::time::{Instant, timeout, timeout_at}; @@ -85,9 +89,10 @@ impl GenericSender { ) .await .map_err(|_| CommunicationError::StreamError)??; - timeout(self.policy.write_timeout, stream.finish()) - .await - .map_err(|_| CommunicationError::StreamError)? + match timeout(self.policy.write_timeout, stream.finish()).await { + Ok(Ok(())) => Ok(()), + Ok(Err(_)) | Err(_) => Err(CommunicationError::DeliveryUnknown), + } } crate::SendMode::PersistentStream => { let mut stream = self.persistent.lock().await; @@ -204,6 +209,8 @@ pub struct GenericReceiver { type_map: Arc>, queue_notify: Arc, decode_rejections: Arc, + #[cfg(feature = "pipes")] + expected_pipes: Arc>>, _accept_task: Arc>, } @@ -219,6 +226,8 @@ impl Clone for GenericReceiver { type_map: self.type_map.clone(), queue_notify: self.queue_notify.clone(), decode_rejections: self.decode_rejections.clone(), + #[cfg(feature = "pipes")] + expected_pipes: self.expected_pipes.clone(), _accept_task: self._accept_task.clone(), } } @@ -254,6 +263,10 @@ impl GenericReceiver { let task_queue_notify = queue_notify.clone(); let decode_rejections = Arc::new(DecodeRejectionCounters::default()); let task_decode_rejections = decode_rejections.clone(); + #[cfg(feature = "pipes")] + let expected_pipes = Arc::new(StdMutex::new(HashSet::new())); + #[cfg(feature = "pipes")] + let task_expected_pipes = expected_pipes.clone(); let task_accept_task_tx = tx.clone(); #[cfg(feature = "pipes")] let task_accept_task_pipe_tx = pipe_tx.clone(); @@ -312,6 +325,8 @@ impl GenericReceiver { let connection = task_connection.clone(); let type_map = task_type_map.clone(); let decode_rejections = task_decode_rejections.clone(); + #[cfg(feature = "pipes")] + let expected_pipes = task_expected_pipes.clone(); tokio::spawn(async move { let _permit = permit; let mut stream = stream; @@ -443,37 +458,51 @@ impl GenericReceiver { #[cfg(feature = "pipes")] { - 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("") - .to_string(); - - let pipe_reader = PipeReader { - stream, - description, - pipe_id, + if frames == 1 { + let is_pipe_request = + message.is_type(mtp_codec::CommunicationType::PipeRequest); + let pipe_id = message.id().filter(|id| *id != 0); + let pipe_is_expected = is_pipe_request + && pipe_id.is_some_and(|pipe_id| { + expected_pipes + .lock() + .is_ok_and(|mut expected| expected.remove(&pipe_id)) + }); + let disposition = match classify_first_frame( + is_pipe_request, + message.id(), + pipe_is_expected, + ) { + Ok(disposition) => disposition, + Err(error) => { + let _ = tx.send(Err(error.clone())).await; + connection.close( + policy.application_close_code, + b"pipe request missing id", + ); + break; + } }; - tracing::debug!(pipe_id, description = %pipe_reader.description, "classified incoming pipe stream"); + if let FirstFrameDisposition::Pipe(pipe_id) = disposition { + let description = message + .get_str(mtp_codec::DataType::Description) + .unwrap_or("") + .to_string(); - if pipe_tx.send(pipe_reader).await.is_err() { - break; + let pipe_reader = PipeReader { + stream, + description, + pipe_id, + }; + + tracing::debug!(pipe_id, description = %pipe_reader.description, "classified incoming pipe stream"); + + if pipe_tx.send(pipe_reader).await.is_err() { + break; + } + return; } - return; } } @@ -517,6 +546,8 @@ impl GenericReceiver { type_map, queue_notify, decode_rejections, + #[cfg(feature = "pipes")] + expected_pipes, _accept_task: Arc::new(accept_task), } } @@ -524,6 +555,25 @@ impl GenericReceiver { *self.ping_sender.write().await = Some(sender); } + #[cfg(feature = "pipes")] + pub fn expect_pipe(&self, pipe_id: u32) -> Result<(), CommunicationError> { + if pipe_id == 0 { + return Err(CommunicationError::Other("pipe id must be non-zero".into())); + } + self.expected_pipes + .lock() + .map_err(|_| CommunicationError::Other("expected pipe state is unavailable".into()))? + .insert(pipe_id); + Ok(()) + } + + #[cfg(feature = "pipes")] + pub fn cancel_expected_pipe(&self, pipe_id: u32) { + if let Ok(mut expected) = self.expected_pipes.lock() { + expected.remove(&pipe_id); + } + } + /// Switch from the handshake frame limit to the application frame limit. pub fn set_max_message_size(&self, max_message_size: u64) { self.max_message_size diff --git a/transport/src/transport_traits.rs b/transport/src/transport_traits.rs index 63c0268..70395af 100644 --- a/transport/src/transport_traits.rs +++ b/transport/src/transport_traits.rs @@ -17,6 +17,7 @@ use mtp_common::CommunicationError; pub trait TransportSendStream: tokio::io::AsyncWrite + Send + Sync { async fn write_all(&mut self, buf: &[u8]) -> Result<(), CommunicationError>; async fn finish(&mut self) -> Result<(), CommunicationError>; + fn reset(&mut self, code: u32) -> Result<(), CommunicationError>; } /// A readable unidirectional stream suitable for MTP frames. @@ -28,6 +29,9 @@ pub trait TransportSendStream: tokio::io::AsyncWrite + Send + Sync { pub trait TransportRecvStream: tokio::io::AsyncRead + Send + Sync { async fn read_exact(&mut self, buf: &mut [u8]) -> Result<(), CommunicationError>; async fn read_chunk(&mut self, max: usize) -> Result>, CommunicationError>; + fn stop(self, code: u32) -> Result<(), CommunicationError> + where + Self: Sized; } /// A QUIC/WebTransport connection that provides MTP's unidirectional streams. @@ -47,7 +51,7 @@ impl TransportSendStream for wtransport::SendStream { async fn write_all(&mut self, buf: &[u8]) -> Result<(), CommunicationError> { wtransport::SendStream::write_all(self, buf) .await - .map_err(|_| CommunicationError::StreamError) + .map_err(|_| CommunicationError::DeliveryUnknown) } async fn finish(&mut self) -> Result<(), CommunicationError> { @@ -55,14 +59,23 @@ impl TransportSendStream for wtransport::SendStream { .await .map_err(|_| CommunicationError::StreamError) } + + fn reset(&mut self, code: u32) -> Result<(), CommunicationError> { + wtransport::SendStream::reset(self, wtransport::VarInt::from_u32(code)) + .map_err(|_| CommunicationError::StreamClosed) + } } #[async_trait] impl TransportRecvStream for wtransport::RecvStream { async fn read_exact(&mut self, buf: &mut [u8]) -> Result<(), CommunicationError> { - wtransport::RecvStream::read_exact(self, buf) - .await - .map_err(|_| CommunicationError::StreamError) + match wtransport::RecvStream::read_exact(self, buf).await { + Ok(()) => Ok(()), + Err(wtransport::error::StreamReadExactError::FinishedEarly(0)) => { + Err(CommunicationError::StreamClosed) + } + Err(_) => Err(CommunicationError::StreamError), + } } async fn read_chunk(&mut self, max: usize) -> Result>, CommunicationError> { @@ -76,6 +89,11 @@ impl TransportRecvStream for wtransport::RecvStream { Err(_) => Err(CommunicationError::StreamError), } } + + fn stop(self, code: u32) -> Result<(), CommunicationError> { + wtransport::RecvStream::stop(self, wtransport::VarInt::from_u32(code)); + Ok(()) + } } #[async_trait] diff --git a/transport/tests/generic_pipe.rs b/transport/tests/generic_pipe.rs index 81a875f..7dfb538 100644 --- a/transport/tests/generic_pipe.rs +++ b/transport/tests/generic_pipe.rs @@ -4,7 +4,7 @@ use async_trait::async_trait; use mtp_codec::CommunicationValue; use mtp_common::CommunicationError; use mtp_transport::{ - GenericReceiver, GenericSender, Policy, TransportConnection, TransportEvent, + GenericReceiver, GenericSender, Policy, SendMode, TransportConnection, TransportEvent, TransportRecvStream, TransportSendStream, }; use std::sync::Arc; @@ -53,6 +53,10 @@ impl TransportSendStream for MockSendStream { .await .map_err(|_| CommunicationError::StreamError) } + + fn reset(&mut self, _code: u32) -> Result<(), CommunicationError> { + Ok(()) + } } struct MockRecvStream { @@ -89,6 +93,10 @@ impl TransportRecvStream for MockRecvStream { Err(_) => Err(CommunicationError::StreamError), } } + + fn stop(self, _code: u32) -> Result<(), CommunicationError> { + Ok(()) + } } #[derive(Clone)] @@ -157,6 +165,7 @@ async fn test_open_pipe_and_receive_reader() -> Result<(), Box Result<(), Box let sender = GenericSender::new(conn_a, policy.clone()); let receiver = GenericReceiver::new(conn_b, policy); + receiver.expect_pipe(1)?; let mut pipe_writer = sender.open_pipe(1, "data-pipe").await?; let data = b"hello through the pipe"; @@ -195,6 +205,7 @@ async fn test_pipe_large_payload() -> Result<(), Box> { let sender = GenericSender::new(conn_a, policy.clone()); let receiver = GenericReceiver::new(conn_b, policy); + receiver.expect_pipe(7)?; let mut pipe_writer = sender.open_pipe(7, "big-pipe").await?; let data: Vec = (0..256 * 1024).map(|i| (i % 256) as u8).collect(); @@ -223,6 +234,7 @@ async fn test_receive_event_dispatches_pipe() -> Result<(), Box Result<(), Box Result<(), Box> { let (conn_a, conn_b) = mock_connected_pair().await; - let policy = Arc::new(Policy::default()); + let policy = Arc::new(Policy::default().with_send_mode(SendMode::SingleStreamPerMessage)); let sender = GenericSender::new(conn_a, policy.clone()); let receiver = GenericReceiver::new(conn_b, policy); - let msg = CommunicationValue::new(mtp_codec::CommunicationType::BadRequest); - sender.send(&msg).await?; - - let _pipe_writer = sender.open_pipe(1, "mixed-pipe").await?; + let request = CommunicationValue::new(mtp_codec::CommunicationType::PipeRequest) + .with_id(1) + .add_typed_default( + mtp_codec::DataType::Description, + mtp_codec::DataValue::Str("mixed-pipe".into()), + ); + sender.send(&request).await?; let received = receiver.receive().await?; - assert_eq!( - received.get_type(), - mtp_codec::CommunicationType::BadRequest - .try_to_id(&mtp_codec::TypeMap::latest()) - .unwrap() - ); + assert!(received.is_type(mtp_codec::CommunicationType::PipeRequest)); + + receiver.expect_pipe(1)?; + let _pipe_writer = sender.open_pipe(1, "mixed-pipe").await?; let pipe_reader = receiver.receive_pipe().await?; assert_eq!(pipe_reader.pipe_id(), 1); @@ -291,6 +304,8 @@ async fn test_multiple_pipes() -> Result<(), Box> { let sender = GenericSender::new(conn_a, policy.clone()); let receiver = GenericReceiver::new(conn_b, policy); + receiver.expect_pipe(10)?; + receiver.expect_pipe(20)?; let mut pw1 = sender.open_pipe(10, "first").await?; let mut pw2 = sender.open_pipe(20, "second").await?; diff --git a/wasm/src/client/receive.rs b/wasm/src/client/receive.rs index 35ceb02..51a48bf 100644 --- a/wasm/src/client/receive.rs +++ b/wasm/src/client/receive.rs @@ -179,6 +179,7 @@ impl WasmClient { let expired_pipe_creations = self.expired_pipe_creations.clone(); let pending_pipes = self.pending_pipes.clone(); let loop_pending_pipes = pending_pipes.clone(); + let expected_pending_pipes = pending_pipes.clone(); let on_pipe_request = self.on_pipe_request.clone(); let loop_pipe_creations = pending_pipe_creations.clone(); let loop_expired_pipe_creations = expired_pipe_creations.clone(); @@ -293,6 +294,12 @@ impl WasmClient { let _ = entry.sender.send(Ok(pipe_reader)); } }, + move |pipe_id| { + expected_pending_pipes + .borrow() + .get(&pipe_id) + .is_some_and(|entry| entry.generation == loop_generation) + }, ) .await; if connection_generation.get() != generation { diff --git a/wasm/src/pipe.rs b/wasm/src/pipe.rs index efa4f07..88783ae 100644 --- a/wasm/src/pipe.rs +++ b/wasm/src/pipe.rs @@ -1,9 +1,6 @@ -use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; -use wasm_bindgen_futures::JsFuture; -use crate::error::js_error; -use crate::transport::release_writer_lock; +use crate::transport::{BrowserRecvStream, BrowserSendStream, log_stream_error_code}; #[wasm_bindgen(typescript_custom_section)] const PIPE_TS: &str = r#" @@ -23,54 +20,41 @@ export interface PipeReader { #[wasm_bindgen] pub struct PipeWriter { - writer: JsValue, + stream: BrowserSendStream, pipe_id: u32, } impl PipeWriter { - pub fn new(writer: JsValue, pipe_id: u32) -> Self { - Self { writer, pipe_id } + pub(crate) fn new(stream: BrowserSendStream, pipe_id: u32) -> Self { + Self { stream, pipe_id } + } +} + +impl Drop for PipeWriter { + fn drop(&mut self) { + self.stream.release(); } } #[wasm_bindgen] impl PipeWriter { pub async fn write(&mut self, data: &[u8]) -> Result<(), JsValue> { - let chunk = js_sys::Uint8Array::from(data); - let write_fn = js_sys::Reflect::get(&self.writer, &JsValue::from_str("write")) - .map_err(|_| js_error("missing write"))? - .dyn_into::() - .map_err(|_| js_error("write not a function"))?; - let write_promise = write_fn - .call1(&self.writer, &chunk) - .map_err(|e| js_error(format!("write failed: {:?}", e)))?; - JsFuture::from(write_promise.unchecked_into::()).await?; - Ok(()) + self.stream.write_all(data).await } - pub async fn close(self) -> Result<(), JsValue> { - let close_fn = js_sys::Reflect::get(&self.writer, &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(&self.writer) - .map_err(|e| js_error(format!("close failed: {:?}", e)))?; - if let Err(e) = JsFuture::from(close_promise.unchecked_into::()).await { - crate::transport::log_stream_error_code(&e, "pipe writer close"); + pub async fn close(mut self) -> Result<(), JsValue> { + let result = self.stream.finish().await; + if let Err(error) = &result { + log_stream_error_code(error, "pipe writer close"); } - release_writer_lock(&self.writer); - Ok(()) + self.stream.release(); + result } pub fn abort(&mut self) -> Result<(), JsValue> { - let abort_fn = js_sys::Reflect::get(&self.writer, &JsValue::from_str("abort")) - .map_err(|_| js_error("missing abort"))? - .dyn_into::() - .map_err(|_| js_error("abort not a function"))?; - let _ = abort_fn.call0(&self.writer); - release_writer_lock(&self.writer); - Ok(()) + let result = self.stream.reset(0); + self.stream.release(); + result } pub fn pipe_id(&self) -> u32 { @@ -80,19 +64,26 @@ impl PipeWriter { #[wasm_bindgen] pub struct PipeReader { - reader: JsValue, + stream: BrowserRecvStream, description: String, pipe_id: u32, pending: Vec, + finished: bool, } impl PipeReader { - pub fn new(reader: JsValue, pipe_id: u32, description: String, pending: Vec) -> Self { + pub(crate) fn new( + stream: BrowserRecvStream, + pipe_id: u32, + description: String, + pending: Vec, + ) -> Self { Self { - reader, + stream, pipe_id, description, pending, + finished: false, } } } @@ -105,27 +96,18 @@ impl PipeReader { return Ok(js_sys::Uint8Array::from(&data[..]).into()); } - let read_fn = js_sys::Reflect::get(&self.reader, &JsValue::from_str("read")) - .map_err(|_| js_error("missing read"))? - .dyn_into::() - .map_err(|_| js_error("read not a function"))?; - let promise = read_fn - .call0(&self.reader) - .map_err(|_| js_error("read call failed"))? - .unchecked_into::(); - let result = JsFuture::from(promise).await?; - - let done = js_sys::Reflect::get(&result, &JsValue::from_str("done")) - .ok() - .and_then(|v| v.as_bool()) - .unwrap_or(true); - if done { + if self.finished { return Ok(JsValue::NULL); } - let value = js_sys::Reflect::get(&result, &JsValue::from_str("value")) - .map_err(|_| js_error("missing value"))?; - Ok(js_sys::Uint8Array::new(&value).into()) + match self.stream.read_chunk().await? { + Some(value) => Ok(js_sys::Uint8Array::from(&value[..]).into()), + None => { + self.stream.release(); + self.finished = true; + Ok(JsValue::NULL) + } + } } pub fn pipe_id(&self) -> u32 { @@ -136,3 +118,9 @@ impl PipeReader { self.description.clone() } } + +impl Drop for PipeReader { + fn drop(&mut self) { + self.stream.release(); + } +} diff --git a/wasm/src/transport.rs b/wasm/src/transport.rs index dc0091e..b7ffebe 100644 --- a/wasm/src/transport.rs +++ b/wasm/src/transport.rs @@ -9,6 +9,7 @@ use wasm_bindgen_futures::JsFuture; use crate::error::js_error; use crate::frame::parse_frame_value_with_limits; use mtp_codec::{DecodeLimits, EncodeLimits, TypeMap}; +use mtp_common::{FirstFrameDisposition, classify_first_frame}; const CLOSE_FRAME_LEN: u32 = u32::MAX; @@ -25,12 +26,6 @@ pub(crate) fn log_stream_error_code(error: &JsValue, context: &str) { let stream_error_code = js_sys::Reflect::get(error, &JsValue::from_str("streamErrorCode")) .ok() .and_then(|v| v.as_f64()); - if matches!(stream_error_code, Some(0.0)) { - // WebTransport reports peer-driven stream shutdown as code 0 in this - // environment. For one-frame handshake streams, that is expected and - // should not be surfaced as a warning. - return; - } let message = error .as_string() .or_else(|| { @@ -76,6 +71,233 @@ fn resolve_stream_readable(recv_stream: &JsValue) -> Result { } } +#[derive(Clone)] +struct BrowserConnection { + inner: JsValue, + incoming_reader: Rc>>, +} + +pub(crate) struct BrowserSendStream { + writer: JsValue, +} + +pub(crate) struct BrowserRecvStream { + reader: JsValue, +} + +impl BrowserConnection { + async fn connect(url: &str, cert_hashes: Option>) -> Result { + let constructor = + js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("WebTransport"))? + .dyn_into::() + .map_err(|_| js_error("WebTransport not available"))?; + let args = js_sys::Array::new(); + args.push(&JsValue::from_str(url)); + + if let Some(hashes) = cert_hashes { + let webtransport_hashes = js_sys::Array::new(); + for hash in hashes { + let (algorithm, value) = hash.split_once(':').unwrap_or(("sha-256", hash.as_str())); + if let Ok(value) = hex::decode(value) { + let entry = js_sys::Object::new(); + js_sys::Reflect::set( + &entry, + &JsValue::from_str("algorithm"), + &JsValue::from_str(algorithm), + )?; + js_sys::Reflect::set( + &entry, + &JsValue::from_str("value"), + &js_sys::Uint8Array::from(&value[..]), + )?; + webtransport_hashes.push(&entry); + } + } + if webtransport_hashes.length() > 0 { + let options = js_sys::Object::new(); + js_sys::Reflect::set( + &options, + &JsValue::from_str("serverCertificateHashes"), + &webtransport_hashes, + )?; + args.push(&options); + } + } + + let inner = js_sys::Reflect::construct(&constructor, &args)?; + let ready = js_sys::Reflect::get(&inner, &JsValue::from_str("ready"))? + .dyn_into::() + .map_err(|_| js_error("WebTransport.ready is not a Promise"))?; + JsFuture::from(ready) + .await + .map_err(|error| js_error(format!("WebTransport ready failed: {error:?}")))?; + Ok(Self { + inner, + incoming_reader: Rc::new(RefCell::new(None)), + }) + } + + async fn open_uni(&self) -> Result { + 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 = resolve_stream_writable(&stream)?; + let writer = js_sys::Reflect::get(&writable, &JsValue::from_str("getWriter")) + .map_err(|_| js_error("missing getWriter"))? + .dyn_into::() + .map_err(|_| js_error("getWriter not a function"))? + .call0(&writable) + .map_err(|_| js_error("getWriter call failed"))?; + Ok(BrowserSendStream { writer }) + } + + async fn accept_uni(&self) -> Result, JsValue> { + let streams_reader = if let Some(reader) = self.incoming_reader.borrow().clone() { + reader + } else { + let incoming = js_sys::Reflect::get( + &self.inner, + &JsValue::from_str("incomingUnidirectionalStreams"), + )?; + let reader = js_sys::Reflect::get(&incoming, &JsValue::from_str("getReader")) + .map_err(|_| js_error("missing getReader"))? + .dyn_into::() + .map_err(|_| js_error("getReader not a function"))? + .call0(&incoming) + .map_err(|_| js_error("getReader call failed"))?; + *self.incoming_reader.borrow_mut() = Some(reader.clone()); + reader + }; + + let read = js_sys::Reflect::get(&streams_reader, &JsValue::from_str("read")) + .map_err(|_| js_error("missing read"))? + .dyn_into::() + .map_err(|_| js_error("read not a function"))?; + let promise = read + .call0(&streams_reader) + .map_err(|_| js_error("read call failed"))? + .unchecked_into::(); + let result = JsFuture::from(promise).await.map_err(|error| { + log_stream_error_code(&error, "accept_uni"); + js_error(format!("accept stream failed: {error:?}")) + })?; + if js_sys::Reflect::get(&result, &JsValue::from_str("done")) + .ok() + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + return Ok(None); + } + + let stream = js_sys::Reflect::get(&result, &JsValue::from_str("value")) + .map_err(|_| js_error("missing value"))?; + let readable = resolve_stream_readable(&stream)?; + let reader = js_sys::Reflect::get(&readable, &JsValue::from_str("getReader")) + .map_err(|_| js_error("missing stream getReader"))? + .dyn_into::() + .map_err(|_| js_error("stream getReader not a function"))? + .call0(&readable) + .map_err(|_| js_error("stream getReader call failed"))?; + Ok(Some(BrowserRecvStream { reader })) + } + + fn close(&self) { + if let Some(reader) = self.incoming_reader.borrow_mut().take() { + release_reader_lock(&reader); + } + if let Ok(close) = js_sys::Reflect::get(&self.inner, &JsValue::from_str("close")) + .and_then(|value| value.dyn_into::()) + { + let _ = close.call1(&self.inner, &js_sys::Object::new()); + } + } +} + +impl BrowserSendStream { + pub(crate) async fn write_all(&mut self, bytes: &[u8]) -> Result<(), JsValue> { + let write = js_sys::Reflect::get(&self.writer, &JsValue::from_str("write")) + .map_err(|_| js_error("missing write"))? + .dyn_into::() + .map_err(|_| js_error("write not a function"))?; + let promise = write + .call1(&self.writer, &js_sys::Uint8Array::from(bytes)) + .map_err(|error| js_error(format!("write failed: {error:?}")))? + .unchecked_into::(); + JsFuture::from(promise).await.map(|_| ()) + } + + pub(crate) async fn finish(&mut self) -> Result<(), JsValue> { + let close = js_sys::Reflect::get(&self.writer, &JsValue::from_str("close")) + .map_err(|_| js_error("missing close"))? + .dyn_into::() + .map_err(|_| js_error("close not a function"))?; + let promise = close + .call0(&self.writer) + .map_err(|error| js_error(format!("close failed: {error:?}")))? + .unchecked_into::(); + JsFuture::from(promise).await.map(|_| ()) + } + + pub(crate) fn reset(&mut self, code: u32) -> Result<(), JsValue> { + let abort = js_sys::Reflect::get(&self.writer, &JsValue::from_str("abort")) + .map_err(|_| js_error("missing abort"))? + .dyn_into::() + .map_err(|_| js_error("abort not a function"))?; + let _ = abort.call1(&self.writer, &JsValue::from_f64(code as f64))?; + Ok(()) + } + + pub(crate) fn release(&self) { + release_writer_lock(&self.writer); + } +} + +impl BrowserRecvStream { + pub(crate) async fn read_chunk(&mut self) -> Result>, JsValue> { + let read = js_sys::Reflect::get(&self.reader, &JsValue::from_str("read")) + .map_err(|_| js_error("missing read"))? + .dyn_into::() + .map_err(|_| js_error("read not a function"))?; + let promise = read + .call0(&self.reader) + .map_err(|_| js_error("read call failed"))? + .unchecked_into::(); + let result = JsFuture::from(promise).await?; + if js_sys::Reflect::get(&result, &JsValue::from_str("done")) + .ok() + .and_then(|value| value.as_bool()) + .unwrap_or(true) + { + return Ok(None); + } + let value = js_sys::Reflect::get(&result, &JsValue::from_str("value")) + .map_err(|_| js_error("missing value"))?; + Ok(Some(js_sys::Uint8Array::new(&value).to_vec())) + } + + #[allow(dead_code)] + pub(crate) fn stop(self, code: u32) -> Result<(), JsValue> { + let cancel = js_sys::Reflect::get(&self.reader, &JsValue::from_str("cancel")) + .map_err(|_| js_error("missing cancel"))? + .dyn_into::() + .map_err(|_| js_error("cancel not a function"))?; + let _ = cancel.call1(&self.reader, &JsValue::from_f64(code as f64))?; + Ok(()) + } + + pub(crate) fn release(&self) { + release_reader_lock(&self.reader); + } +} + /// Releases a writer's lock so an abandoned writer isn't treated as an abort (which sends STOP_SENDING). pub(crate) fn release_writer_lock(writer: &JsValue) { if let Ok(release) = js_sys::Reflect::get(writer, &JsValue::from_str("releaseLock")) @@ -118,12 +340,10 @@ enum FrameOutcome { */ #[derive(Clone)] pub struct WasmTransport { - inner: JsValue, + connection: BrowserConnection, max_message_size: u32, - /// Reader over `incoming_unidirectional_streams()` (a singleton stream of streams). - streams_reader: Rc>>, - /// Reader over the host's current uni-directional stream, if one is open. - stream_reader: Rc>>, + /// Current incoming unidirectional stream, shared across handshake and receive loops. + stream_reader: Rc>>, /// Bytes already read from the current stream but not yet consumed as a frame. buffer: Rc>>, /// Set to `true` when `open_next_stream` succeeds; cleared after the first frame is parsed. @@ -149,61 +369,14 @@ impl WasmTransport { max_message_size: u32, configured_limits: Option, ) -> Result { - let ctor = js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("WebTransport"))? - .dyn_into::() - .map_err(|_| js_error("WebTransport not available"))?; - let args = js_sys::Array::new(); - args.push(&JsValue::from_str(url)); - - if let Some(hashes) = cert_hashes { - let wt_hashes = js_sys::Array::new(); - for h in hashes { - let (algo, hex_val) = match h.split_once(':') { - Some((algo, hex_val)) => (algo, hex_val), - None => ("sha-256", h.as_str()), - }; - - if let Ok(bytes) = hex::decode(hex_val) { - let hash = js_sys::Object::new(); - js_sys::Reflect::set( - &hash, - &JsValue::from_str("algorithm"), - &JsValue::from_str(algo), - )?; - js_sys::Reflect::set( - &hash, - &JsValue::from_str("value"), - &js_sys::Uint8Array::from(&bytes[..]), - )?; - wt_hashes.push(&hash); - } - } - if wt_hashes.length() > 0 { - let opts = js_sys::Object::new(); - js_sys::Reflect::set( - &opts, - &JsValue::from_str("serverCertificateHashes"), - &wt_hashes, - )?; - args.push(&opts); - } - }; - - let transport = js_sys::Reflect::construct(&ctor, &args)?; - let ready = js_sys::Reflect::get(&transport, &JsValue::from_str("ready"))? - .dyn_into::() - .map_err(|_| js_error("WebTransport.ready is not a Promise"))?; - JsFuture::from(ready) - .await - .map_err(|e| js_error(format!("WebTransport ready failed: {:?}", e)))?; + let connection = BrowserConnection::connect(url, cert_hashes).await?; let transport_limits = DecodeLimits::for_transport_message_size(max_message_size as u64); let decode_limits = configured_limits .map(|limits| restrict_decode_limits(limits, transport_limits)) .unwrap_or(transport_limits); Ok(Self { - inner: transport, + connection, max_message_size, - streams_reader: Rc::new(RefCell::new(None)), stream_reader: Rc::new(RefCell::new(None)), buffer: Rc::new(RefCell::new(Vec::new())), new_stream_frame: Rc::new(Cell::new(false)), @@ -214,7 +387,7 @@ impl WasmTransport { } pub fn inner(&self) -> &JsValue { - &self.inner + &self.connection.inner } pub fn set_type_map(&self, type_map: &TypeMap) { @@ -250,154 +423,55 @@ impl WasmTransport { // in accept_uni() until the authentication deadline. The bytes are // already the canonical MTP self-framed value, so no extra stream // length prefix is added here. - 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"))? - .dyn_into::() - .map_err(|_| js_error("getWriter not a function"))? - .call0(&writable_or_stream) - .map_err(|_| js_error("getWriter call failed"))?; - - 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"))? - .dyn_into::() - .map_err(|_| js_error("write not a function"))?; - let write_promise = write_fn - .call1(&writer_val, &chunk) - .map_err(|e| js_error(format!("write failed: {:?}", e)))?; - if let Err(e) = JsFuture::from(write_promise.unchecked_into::()).await { + let mut stream = self.connection.open_uni().await?; + if let Err(e) = stream.write_all(frame).await { log_stream_error_code(&e, "send_frame write"); - release_writer_lock(&writer_val); + stream.release(); 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 { + if let Err(e) = stream.finish().await { // The frame was already written; do not retry it merely because // FIN failed, as that would duplicate the MTP frame. log_stream_error_code(&e, "send_frame close"); } - release_writer_lock(&writer_val); + stream.release(); Ok(()) } - /// Get (creating once) the reader over `incoming_unidirectional_streams()`. - fn ensure_streams_reader(&self) -> Result { - if let Some(reader) = self.streams_reader.borrow().clone() { - return Ok(reader); - } - let incoming = js_sys::Reflect::get( - &self.inner, - &JsValue::from_str("incomingUnidirectionalStreams"), - )?; - let reader = js_sys::Reflect::get(&incoming, &JsValue::from_str("getReader")) - .map_err(|_| js_error("missing getReader"))? - .dyn_into::() - .map_err(|_| js_error("getReader not a function"))? - .call0(&incoming) - .map_err(|_| js_error("getReader call failed"))?; - *self.streams_reader.borrow_mut() = Some(reader.clone()); - Ok(reader) - } - /// Accept the next incoming uni-directional stream and make it current. /// Returns `false` if the incoming-streams readable has ended. async fn open_next_stream(&self) -> Result { - let streams_reader = self.ensure_streams_reader()?; - - let read_fn = js_sys::Reflect::get(&streams_reader, &JsValue::from_str("read")) - .map_err(|_| js_error("missing read"))? - .dyn_into::() - .map_err(|_| js_error("read not a function"))?; - let promise = read_fn - .call0(&streams_reader) - .map_err(|_| js_error("read call failed"))? - .unchecked_into::(); - let result = match JsFuture::from(promise).await { - Ok(r) => r, - Err(e) => { - log_stream_error_code(&e, "open_next_stream accept"); - return Err(js_error(format!("accept stream failed: {:?}", e))); - } + let Some(stream) = self.connection.accept_uni().await? else { + return Ok(false); }; - let done = js_sys::Reflect::get(&result, &JsValue::from_str("done")) - .ok() - .and_then(|v| v.as_bool()) - .unwrap_or(false); - if done { - return Ok(false); - } - - let recv_stream = js_sys::Reflect::get(&result, &JsValue::from_str("value")) - .map_err(|_| js_error("missing value"))?; - let readable = resolve_stream_readable(&recv_stream)?; - let reader = js_sys::Reflect::get(&readable, &JsValue::from_str("getReader")) - .map_err(|_| js_error("missing stream getReader"))? - .dyn_into::() - .map_err(|_| js_error("stream getReader not a function"))? - .call0(&readable) - .map_err(|_| js_error("stream getReader call failed"))?; - - *self.stream_reader.borrow_mut() = Some(reader); + *self.stream_reader.borrow_mut() = Some(stream); self.new_stream_frame.set(true); Ok(true) } /// Read one chunk from the current stream. `Ok(None)` means the stream ended. async fn read_chunk(&self) -> Result>, JsValue> { - let reader = match self.stream_reader.borrow().clone() { - Some(r) => r, + let mut stream = match self.stream_reader.borrow_mut().take() { + Some(stream) => stream, None => return Ok(None), }; - - let read_fn = js_sys::Reflect::get(&reader, &JsValue::from_str("read")) - .map_err(|_| js_error("missing read"))? - .dyn_into::() - .map_err(|_| js_error("read not a function"))?; - let promise = read_fn - .call0(&reader) - .map_err(|_| js_error("read call failed"))? - .unchecked_into::(); - let result = match JsFuture::from(promise).await { - Ok(r) => r, + let result = match stream.read_chunk().await { + Ok(result) => result, Err(e) => { log_stream_error_code(&e, "read_chunk"); + stream.release(); return Err(js_error(format!("read failed: {:?}", e))); } }; - - let done = js_sys::Reflect::get(&result, &JsValue::from_str("done")) - .ok() - .and_then(|v| v.as_bool()) - .unwrap_or(true); - if done { - return Ok(None); + if result.is_some() { + *self.stream_reader.borrow_mut() = Some(stream); + } else { + stream.release(); } - - let value = js_sys::Reflect::get(&result, &JsValue::from_str("value")) - .map_err(|_| js_error("missing value"))?; - Ok(Some(js_sys::Uint8Array::new(&value).to_vec())) + Ok(result) } /// Try to pull one complete frame out of the buffer without reading more. @@ -459,9 +533,7 @@ impl WasmTransport { } None => { // Stream finished; release the reader's lock to avoid a spurious cancel. - if let Some(reader) = self.stream_reader.borrow_mut().take() { - release_reader_lock(&reader); - } + // `read_chunk` releases the raw stream lock on clean FIN. // A frame is never allowed to span stream boundaries. The // native persistent-stream sender packs frames on one // stream, while the WASM sender uses one stream per frame; @@ -520,15 +592,17 @@ impl WasmTransport { /// Pipe-aware receive loop. Identical to `receive_loop` but detects /// `PipeRequest` as the first frame on a new incoming stream and routes /// the stream to `on_pipe` instead of `on_message`. - pub async fn receive_loop_with_pipes( + pub async fn receive_loop_with_pipes( &self, mut on_message: F, mut on_error: H, mut on_pipe: G, + mut pipe_is_expected: I, ) where F: FnMut(JsValue), G: FnMut(crate::pipe::PipeReader), H: FnMut(JsValue), + I: FnMut(u32) -> bool, { loop { match self.next_frame(self.max_message_size).await { @@ -550,36 +624,44 @@ impl WasmTransport { if is_first { self.new_stream_frame.set(false); - if let Some(comm) = comm.as_ref() - && Some(comm.get_type()) == pipe_request_type - { - 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("") - .to_string(); + if let Some(comm) = comm.as_ref() { + let is_pipe_request = Some(comm.get_type()) == pipe_request_type; + let pipe_id = comm.id().filter(|id| *id != 0); + let is_expected = + is_pipe_request && pipe_id.is_some_and(&mut pipe_is_expected); + let disposition = + match classify_first_frame(is_pipe_request, comm.id(), is_expected) + { + Ok(disposition) => disposition, + Err(error) => { + on_error(JsValue::from_str(&error.to_string())); + self.close(); + break; + } + }; - let pending = { - let mut buf = self.buffer.borrow_mut(); - std::mem::take(&mut *buf) - }; + if let FirstFrameDisposition::Pipe(pipe_id) = disposition { + let description = comm + .get_str(mtp_codec::DataType::Description) + .unwrap_or("") + .to_string(); - if let Some(reader) = self.stream_reader.borrow_mut().take() { - let pipe_reader = crate::pipe::PipeReader::new( - reader, - pipe_id, - description, - pending, - ); - on_pipe(pipe_reader); + let pending = { + let mut buf = self.buffer.borrow_mut(); + std::mem::take(&mut *buf) + }; + + if let Some(reader) = self.stream_reader.borrow_mut().take() { + let pipe_reader = crate::pipe::PipeReader::new( + reader, + pipe_id, + description, + pending, + ); + on_pipe(pipe_reader); + } + continue; } - continue; } } @@ -635,25 +717,7 @@ impl WasmTransport { description: &str, ) -> Result { let _send_guard = self.send_lock.lock().await; - 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"))? - .dyn_into::() - .map_err(|_| js_error("getWriter not a function"))? - .call0(&writable_or_stream) - .map_err(|_| js_error("getWriter call failed"))?; + let mut stream = self.connection.open_uni().await?; let type_map = self.type_map(); let request = mtp_codec::CommunicationValue::new_with_type_map( @@ -669,37 +733,21 @@ impl WasmTransport { .to_bytes() .map_err(|e| js_error(format!("encode failed: {}", e)))?; - 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::() - .map_err(|_| js_error("write not a function"))?; - let write_promise = write_fn - .call1(&writer_val, &chunk) - .map_err(|e| js_error(format!("write failed: {:?}", e)))?; - if let Err(e) = JsFuture::from(write_promise.unchecked_into::()).await { + if let Err(e) = stream.write_all(&frame_bytes).await { log_stream_error_code(&e, "open_pipe write"); - release_writer_lock(&writer_val); + stream.release(); return Err(e); } - Ok(crate::pipe::PipeWriter::new(writer_val, pipe_id)) + Ok(crate::pipe::PipeWriter::new(stream, pipe_id)) } pub fn close(&self) { // 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); - } - if let Some(reader) = self.streams_reader.borrow_mut().take() { - release_reader_lock(&reader); - } - - if let Ok(close) = js_sys::Reflect::get(&self.inner, &JsValue::from_str("close")) - .and_then(|value| value.dyn_into::()) - { - let _ = close.call1(&self.inner, &js_sys::Object::new()); + reader.release(); } + self.connection.close(); } } From c30315af944bda05ecdb9c4e9cc350cb99cf7b2e Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:55:52 +0200 Subject: [PATCH 14/22] Clean --- Cargo.lock | 4 ---- Cargo.toml | 5 ----- common/Cargo.toml | 1 - example/Cargo.lock | 1 - type-map/Cargo.toml | 4 ++++ 5 files changed, 4 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 88e9b74..cc6b180 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1326,9 +1326,6 @@ dependencies = [ "mtp-transport", "mtp-type-map", "mtp-webserver", - "rand", - "rcgen", - "tokio", ] [[package]] @@ -1363,7 +1360,6 @@ name = "mtp-common" version = "0.3.0" dependencies = [ "quinn", - "rustls", "thiserror 2.0.20", "wtransport", ] diff --git a/Cargo.toml b/Cargo.toml index ee1c93a..2c1b1ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -113,10 +113,5 @@ tls = ["crypto", "mtp-crypto?/tls"] # Requires MTP_INSECURE_TLS=1 at runtime. insecure-tls = ["dep:mtp-transport", "mtp-transport?/insecure-tls"] -[dev-dependencies] -tokio = { version = "1", features = ["full"] } -rcgen = "0.14" -rand = "0.10.1" - [package.metadata.cargo-machete] ignored = ["mtp-transport"] diff --git a/common/Cargo.toml b/common/Cargo.toml index 24d4777..5fae68e 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -15,7 +15,6 @@ wtransport = { version = "0.7.1", default-features = false, features = [ "quinn", "self-signed", ] } -rustls = { version = "0.23.41" } quinn = { version = "0.11.11", default-features = false, features = [ "rustls-aws-lc-rs", "rustls", diff --git a/example/Cargo.lock b/example/Cargo.lock index 8d2fb30..ba5bf49 100644 --- a/example/Cargo.lock +++ b/example/Cargo.lock @@ -1305,7 +1305,6 @@ name = "mtp-common" version = "0.3.0" dependencies = [ "quinn", - "rustls", "thiserror 2.0.20", "wtransport", ] diff --git a/type-map/Cargo.toml b/type-map/Cargo.toml index ad811ae..1cacaa0 100644 --- a/type-map/Cargo.toml +++ b/type-map/Cargo.toml @@ -16,3 +16,7 @@ pipes = [] [build-dependencies] serde = { version = "1", features = ["derive"] } serde_yaml = "0.9" + +[package.metadata.cargo-machete] +# cargo-machete does not inspect build.rs, where both build dependencies are used. +ignored = ["serde", "serde_yaml"] From bd5547ae6f631502ed38082428002ff0a5b006c3 Mon Sep 17 00:00:00 2001 From: Alois Date: Thu, 27 Aug 2026 19:29:53 +0200 Subject: [PATCH 15/22] feat(ts-sdk): add schemas --- docs/WASM-CLIENT.md | 57 +++++++++++ src/sdk/client.ts | 157 +++++++++++++++++++++++++---- src/sdk/index.ts | 1 + src/sdk/schema.ts | 234 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 431 insertions(+), 18 deletions(-) create mode 100644 src/sdk/schema.ts diff --git a/docs/WASM-CLIENT.md b/docs/WASM-CLIENT.md index 02be900..8a56bbd 100644 --- a/docs/WASM-CLIENT.md +++ b/docs/WASM-CLIENT.md @@ -104,6 +104,9 @@ if (!MTPClient.isSupported()) { | `requestTimeoutMs` | 30 seconds | Default `request()` timeout. | | `pings` | `false` | Protocol pings, or an object with `intervalMs`. | | `logger` | No-op | Receives SDK state and error events. | +| `schemas` | None | Client-wide request and response schema registry. | +| `throwProtocolErrors` | `false` | Reject requests whose correlated response is an `Error*` frame. | +| `onValidationError` | No-op | Receives subscription validation failures. | | `sessionStorage` | In-memory | E2EE session state storage. | | `encryptedSecretProvider` | In-memory | Independent caller-managed encrypted secret storage. | | `defaultSignatureVerificationPolicy` | `"ed25519"` | Receiver policy for protected signatures. | @@ -471,6 +474,60 @@ const unsubscribe = client.subscribe("SomeType", (message) => { unsubscribe(); ``` +### Zod request and response schemas + +Applications can provide their request and response schemas once when creating +the client. MTP uses `parseAsync`, so synchronous schemas, async refinements, +defaults, coercions, and transforms all work. MTP has no runtime dependency on +Zod; the application supplies its preferred Zod version. + +```typescript +import { z } from "zod"; +import { MTPClient, MTPValidationError } from "mtp"; + +const schemas = { + GetUser: { + request: z.object({ UserId: z.number().int().positive() }), + response: z.object({ + UserId: z.number().int().positive(), + Display: z.string(), + }), + }, +}; + +const client = await MTPClient.create({ + url, + schemas, + throwProtocolErrors: true, + onValidationError(error) { + console.error(error.messageType, error.cause); + }, +}); + +const response = await client.request("GetUser", { UserId: 42 }); +console.log(response.data.Display); +``` + +Request schemas run before frame encoding and transmission. Their transformed +output is sent. Response schemas run after request correlation, and their +transformed output replaces `frame.data`; `frame.raw`, when present, remains the +original wire frame. Invalid requests and responses reject with +`MTPValidationError`. Invalid subscription messages do not reach the handler +and are reported through `onValidationError`. + +`throwProtocolErrors: true` converts correlated `Error*` frames into +`MTPProtocolError`. It defaults to `false` for compatibility. + +`MTPProxyConnection` applies the same schema registry to another TypeScript +request/subscription transport, such as a Tauri command and event proxy: + +```typescript +const connection = new MTPProxyConnection(adapter, { + schemas, + throwProtocolErrors: true, +}); +``` + Protocol ping behavior is defined in [Protocol Reference](PROTOCOL-REFERENCE.md#protocol-keepalive). The SDK configuration is: ```typescript diff --git a/src/sdk/client.ts b/src/sdk/client.ts index d3c31be..499c44f 100644 --- a/src/sdk/client.ts +++ b/src/sdk/client.ts @@ -10,6 +10,15 @@ import * as bindings from "mtp/raw"; import { unixTimeMillis, utf8Encode } from "./utils.js"; import type * as RawBindings from "../raw/index"; import type { MTPCommunicationType } from "../type-map/index"; +import { MTPProtocol } from "./schema.js"; +import type { + MTPMessageType, + MTPFrame, + MTPNoSchemas, + MTPRequestData, + MTPResponseFrame, + MTPSchemaRegistry, +} from "./schema.js"; import type { MTPSessionStorage, MTPSessionState } from "./session"; import { MTPSessionManager } from "./session.js"; import { @@ -249,7 +258,9 @@ export interface MTPPublicKeyBundleKeys { sigClPublicKey: Uint8Array; } -export interface MTPClientOptions { +export interface MTPClientOptions< + Registry extends MTPSchemaRegistry = MTPNoSchemas, +> { url: string; descriptor?: string; hostPublicKey?: MTPKeyMaterialInput; @@ -282,6 +293,12 @@ export interface MTPClientOptions { securityProfile?: MTPSecurityProfile; /** One receive resource policy shared by frame and protected-value opening. */ receiveLimits?: MTPReceiveLimits; + /** Application request and response schemas, keyed by communication type. */ + schemas?: Registry; + /** Reject `request()` when the correlated response is an `Error*` frame. */ + throwProtocolErrors?: boolean; + /** Receives subscription validation failures. Request failures reject normally. */ + onValidationError?: (error: import("./schema.js").MTPValidationError) => void; } export interface MTPSecurityProfile { @@ -562,8 +579,8 @@ export interface MTPAcceptEncryptedPipeOptions { signaturePolicy?: MTPSignatureVerificationPolicy; } -type NormalizedMTPClientOptions = Omit< - MTPClientOptions, +type NormalizedMTPClientOptions = Omit< + MTPClientOptions, "hostPublicKey" | "receiveLimits" > & { hostPublicKey?: Uint8Array; @@ -914,14 +931,34 @@ function validateOptions(options) { ) { throw new TypeError("requestTimeoutMs must be a positive safe integer"); } + if (options.schemas != null) { + if (typeof options.schemas !== "object" || Array.isArray(options.schemas)) { + throw new TypeError("schemas must be an object"); + } + for (const [type, pair] of Object.entries(options.schemas)) { + if ( + !pair || + typeof pair !== "object" || + typeof (pair as { request?: { parseAsync?: unknown } }).request + ?.parseAsync !== "function" || + typeof (pair as { response?: { parseAsync?: unknown } }).response + ?.parseAsync !== "function" + ) { + throw new TypeError( + `schemas.${type} must contain request and response schemas with parseAsync()`, + ); + } + } + } } -export class MTPClient { +export class MTPClient { static readonly crypto = crypto; static readonly codec = codec; #credentials: InternalCredentials | null; - #options: NormalizedMTPClientOptions; + #options: NormalizedMTPClientOptions; + readonly #protocol: MTPProtocol | undefined; readonly #protectedReplayGuard = new InMemoryReplayGuard(); readonly #relayReplayGuard = new InMemoryReplayGuard(); readonly raw: MTPRaw; @@ -934,10 +971,17 @@ export class MTPClient { readonly encryptedSecretProvider: MTPEncryptedSecretProvider; private constructor( - options: NormalizedMTPClientOptions, + options: NormalizedMTPClientOptions, client: RawBindings.WasmClient, ) { this.#options = options; + this.#protocol = options.schemas + ? new MTPProtocol({ + schemas: options.schemas, + throwProtocolErrors: options.throwProtocolErrors, + onValidationError: options.onValidationError, + }) + : undefined; this.#credentials = deserializeCredentials(options.credentials); this.raw = { client, bindings }; this.encryptedSecretProvider = @@ -947,7 +991,11 @@ export class MTPClient { ); } - static async create(options: MTPClientOptions): Promise { + static async create< + const Registry extends MTPSchemaRegistry = MTPNoSchemas, + >( + options: MTPClientOptions, + ): Promise> { validateOptions(options); await MTPClient.init(options.wasm); @@ -968,7 +1016,7 @@ export class MTPClient { securityProfile: resolveSecurityProfile(options), }; - let sdk: MTPClient | undefined; + let sdk: MTPClient | undefined; const client = new WasmClient( (state) => emit(normalizedOptions.logger, { @@ -1004,7 +1052,7 @@ export class MTPClient { setReceiveLimits.call(rawClient, normalizedOptions.receiveLimits); } - sdk = new MTPClient(normalizedOptions, client); + sdk = new MTPClient(normalizedOptions, client); await sdk.#loadStoredCredentials(); if (!sdk.#credentials) { sdk.#credentials = { @@ -1238,6 +1286,35 @@ export class MTPClient { }; } + async #parseRequestData( + type: MTPCommunicationType, + data: unknown, + ): Promise> { + if (!this.#protocol || !this.#protocol.schemas[type]) { + return (data ?? {}) as Record; + } + const parsed = await this.#protocol.parseRequest( + type as MTPMessageType, + data as never, + ); + return (parsed ?? {}) as Record; + } + + async #parseResponseData( + requestedType: MTPCommunicationType, + frame: ParsedFrame, + phase: "response" | "subscription" = "response", + ): Promise> { + if (!this.#protocol || !this.#protocol.schemas[requestedType]) { + return frame; + } + return await this.#protocol.parseResponse( + requestedType as MTPMessageType, + frame, + phase, + ); + } + #buildFrame(typeOrFrame, data, options) { if (typeOrFrame instanceof Uint8Array) { if ( @@ -1279,6 +1356,11 @@ export class MTPClient { } async send(message: Uint8Array): Promise; + async send>( + type: Type, + data?: MTPRequestData, + options?: MTPSendOptions, + ): Promise; async send( type: MTPCommunicationType, data: Record, @@ -1286,10 +1368,14 @@ export class MTPClient { ): Promise; async send( typeOrFrame: Uint8Array | MTPCommunicationType, - data?: Record, + data?: unknown, options?: MTPSendOptions, ): Promise { - const message = this.#buildFrame(typeOrFrame, data, options); + const parsedData = + typeof typeOrFrame === "string" + ? await this.#parseRequestData(typeOrFrame, data) + : data; + const message = this.#buildFrame(typeOrFrame, parsedData, options); try { const frame = this.raw.bindings.parse_frame(message); @@ -1345,6 +1431,11 @@ export class MTPClient { data?: never, options?: MTPRequestOptions, ): Promise; + async request>( + type: Type, + data?: MTPRequestData, + options?: MTPRequestOptions, + ): Promise>; async request( type: MTPCommunicationType, data: Record, @@ -1352,15 +1443,19 @@ export class MTPClient { ): Promise; async request( typeOrFrame: Uint8Array | MTPCommunicationType, - data?: Record, + data?: unknown, options: MTPRequestOptions = {}, - ): Promise { + ): Promise> { 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"); } - const frame = this.#buildFrame(typeOrFrame, data, options); + const parsedData = + typeof typeOrFrame === "string" + ? await this.#parseRequestData(typeOrFrame, data) + : data; + const frame = this.#buildFrame(typeOrFrame, parsedData, options); try { const parsed = this.raw.bindings.parse_frame(frame); emit( @@ -1391,16 +1486,25 @@ export class MTPClient { // 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( + const response = await this.raw.client.request( frame, options.responseType ?? null, timeoutMs, ); + return typeof typeOrFrame === "string" + ? await this.#parseResponseData(typeOrFrame, response) + : response; } + subscribe>( + type: Type, + handler: ( + message: MTPResponseFrame, + ) => void | Promise, + ): Unsubscribe; subscribe( type: MTPCommunicationType, - handler: (message: ParsedFrame) => void, + handler: (message: MTPFrame) => void | Promise, ): Unsubscribe { if (typeof type !== "string" || !type) { throw new TypeError("subscription type must be a non-empty string"); @@ -1408,8 +1512,25 @@ export class MTPClient { if (typeof handler !== "function") { throw new TypeError("subscription handler must be a function"); } - const id = this.raw.client.subscribe(type, handler); - return () => this.raw.client.unsubscribe(id); + let active = true; + const id = this.raw.client.subscribe(type, (message) => { + if (!this.#protocol || !this.#protocol.schemas[type]) { + void handler(message); + return; + } + void this.#parseResponseData(type, message, "subscription").then( + (parsed) => { + if (active) void handler(parsed); + }, + (error) => { + this.#protocol?.reportValidationError(error); + }, + ); + }); + return () => { + active = false; + this.raw.client.unsubscribe(id); + }; } #handleFrame(frame) { diff --git a/src/sdk/index.ts b/src/sdk/index.ts index e93ca7e..5e3a1e9 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -5,3 +5,4 @@ * keeps the package's historical exports stable. */ export * from "./client.js"; +export * from "./schema.js"; diff --git a/src/sdk/schema.ts b/src/sdk/schema.ts new file mode 100644 index 0000000..9b9aaf6 --- /dev/null +++ b/src/sdk/schema.ts @@ -0,0 +1,234 @@ +import type { MTPRequestOptions, ParsedFrame, Unsubscribe } from "./client.js"; +import type { MTPCommunicationType } from "../type-map/index.js"; + +export interface MTPSchema { + readonly _input: Input; + readonly _output: Output; + parseAsync(value: unknown): Promise; +} + +export interface MTPSchemaPair< + Request extends MTPSchema = MTPSchema, + Response extends MTPSchema = MTPSchema, +> { + request: Request; + response: Response; +} + +export type MTPSchemaRegistry = Record; +export type MTPNoSchemas = Record; + +export type MTPSchemaInput = Schema["_input"]; +export type MTPSchemaOutput = Schema["_output"]; +export type MTPMessageType = + keyof Registry & string; + +export type MTPFrame = { + id?: number; + type: string; + data: Data; + sender?: ParsedFrame["sender"]; + receiver?: ParsedFrame["receiver"]; + raw?: ParsedFrame["raw"]; +}; + +export type MTPTypedFrame = MTPFrame; + +export type MTPResponseFrame< + Registry extends MTPSchemaRegistry, + Type extends MTPMessageType, +> = MTPTypedFrame>; + +export type MTPRequestData< + Registry extends MTPSchemaRegistry, + Type extends MTPMessageType, +> = MTPSchemaInput; + +export type MTPRequestFunction = < + Type extends MTPMessageType, +>( + type: Type, + data?: MTPRequestData, + options?: MTPRequestOptions, +) => Promise>; + +export type MTPSubscriptionFunction = < + Type extends MTPMessageType, +>( + type: Type, + handler: (message: MTPResponseFrame) => void | Promise, +) => Unsubscribe; + +export class MTPValidationError extends Error { + readonly phase: "request" | "response" | "subscription"; + readonly messageType: string; + readonly frame?: MTPFrame; + + constructor( + phase: MTPValidationError["phase"], + messageType: string, + cause: unknown, + frame?: MTPFrame, + ) { + super(`${phase} validation failed for ${messageType}`, { cause }); + this.name = "MTPValidationError"; + this.phase = phase; + this.messageType = messageType; + this.frame = frame; + } +} + +export class MTPProtocolError extends Error { + readonly type: string; + readonly id: number | undefined; + readonly communicationType: string; + readonly requestId: number | undefined; + readonly errorType: string | undefined; + readonly frame: MTPFrame; + + constructor(frame: MTPFrame) { + const errorType = + frame.data && + typeof frame.data === "object" && + !Array.isArray(frame.data) && + typeof (frame.data as Record).ErrorType === "string" + ? ((frame.data as Record).ErrorType as string) + : undefined; + super(errorType ? `${frame.type}: ${errorType}` : frame.type); + this.name = "MTPProtocolError"; + this.type = frame.type; + this.id = frame.id; + this.communicationType = frame.type; + this.requestId = frame.id; + this.errorType = errorType; + this.frame = frame; + } +} + +export interface MTPProtocolOptions { + schemas: Registry; + throwProtocolErrors?: boolean; + onValidationError?: (error: MTPValidationError) => void; +} + +function isErrorFrame(frame: MTPFrame): boolean { + return frame.type.startsWith("Error"); +} + +export class MTPProtocol { + readonly schemas: Registry; + readonly #throwProtocolErrors: boolean; + readonly #onValidationError: + | ((error: MTPValidationError) => void) + | undefined; + + constructor(options: MTPProtocolOptions) { + this.schemas = options.schemas; + this.#throwProtocolErrors = options.throwProtocolErrors ?? false; + this.#onValidationError = options.onValidationError; + } + + async parseRequest>( + type: Type, + data: MTPRequestData | undefined, + ): Promise> { + try { + return await this.schemas[type].request.parseAsync(data); + } catch (error) { + throw new MTPValidationError("request", type, error); + } + } + + async parseResponse>( + requestedType: Type, + frame: MTPFrame, + phase: "response" | "subscription" = "response", + ): Promise> { + if (isErrorFrame(frame)) { + if (phase === "response" && this.#throwProtocolErrors) { + throw new MTPProtocolError(frame); + } + return frame as MTPResponseFrame; + } + + const schema = + this.schemas[frame.type]?.response ?? + this.schemas[requestedType].response; + try { + const data = await schema.parseAsync(frame.data); + return { ...frame, data } as MTPResponseFrame; + } catch (error) { + throw new MTPValidationError( + phase, + frame.type || requestedType, + error, + frame, + ); + } + } + + reportValidationError(error: unknown): void { + if (error instanceof MTPValidationError) { + this.#onValidationError?.(error); + } + } +} + +export interface MTPProxyAdapter { + request( + type: MTPCommunicationType, + data: Record, + options?: MTPRequestOptions, + ): Promise; + subscribe( + type: MTPCommunicationType, + handler: (message: MTPFrame) => void, + ): Unsubscribe; +} + +export class MTPProxyConnection { + readonly #adapter: MTPProxyAdapter; + readonly #protocol: MTPProtocol; + + constructor(adapter: MTPProxyAdapter, options: MTPProtocolOptions) { + this.#adapter = adapter; + this.#protocol = new MTPProtocol(options); + } + + async request>( + type: Type, + data?: MTPRequestData, + options?: MTPRequestOptions, + ): Promise> { + const parsed = await this.#protocol.parseRequest(type, data); + const response = await this.#adapter.request( + type, + (parsed ?? {}) as Record, + options, + ); + return await this.#protocol.parseResponse(type, response); + } + + subscribe>( + type: Type, + handler: ( + message: MTPResponseFrame, + ) => void | Promise, + ): Unsubscribe { + let active = true; + const unsubscribe = this.#adapter.subscribe(type, (message) => { + void this.#protocol.parseResponse(type, message, "subscription").then( + (parsed) => { + if (active) void handler(parsed); + }, + (error) => { + this.#protocol.reportValidationError(error); + }, + ); + }); + return () => { + active = false; + unsubscribe(); + }; + } +} From 6348d7884f9a8ef8251fd42c4f28e443bedf262b Mon Sep 17 00:00:00 2001 From: Alois Date: Thu, 27 Aug 2026 20:17:11 +0200 Subject: [PATCH 16/22] Fix thing --- example/server/src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/example/server/src/main.rs b/example/server/src/main.rs index 17b4164..08969f4 100644 --- a/example/server/src/main.rs +++ b/example/server/src/main.rs @@ -38,6 +38,7 @@ async fn handle_pipe_loopback( conn: &mtp::webserver::WebMTPConnection, request: mtp::host::PipeRequest< mtp::webserver::WebMtpSender, + mtp::webserver::WebMtpReceiver, mtp::webserver::H3TransportReceiver, >, ) -> Result> { From df75fd2830b55ac77614a1d0c5e618432bd6d991 Mon Sep 17 00:00:00 2001 From: Alois Date: Thu, 27 Aug 2026 20:28:26 +0200 Subject: [PATCH 17/22] feat(qol): remove dup --- .forgejo/workflows/ci.yml | 2 -- package.json | 1 - 2 files changed, 3 deletions(-) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 2d870bc..970eece 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -29,8 +29,6 @@ jobs: cargo machete pnpm install --frozen-lockfile - pnpm add --save-dev --save-exact --workspace-root jscpd-linux-x64-gnu@5.0.14 - pnpm run dup RUSTFLAGS="--cfg web_sys_unstable_apis" wasm-pack test --node wasm RUSTFLAGS="--cfg web_sys_unstable_apis" wasm-pack build wasm --target web diff --git a/package.json b/package.json index dd4e7a1..72e0b12 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,6 @@ "pack": "pnpm run release:web", "release:web": "node create-web-release.mjs", "build:all": "nix run .#build-all", - "dup": "jscpd --pattern '**/*.{rs,ts}' --ignore 'target/**' --ignore 'wasm/pkg/**' --min-lines 8 --min-tokens 80 --threshold 4 --reporters console --no-tips .", "test:e2e": "tsc && node test/e2ee.mjs", "test:secrets": "tsc && node --test --test-isolation=none test/encrypted-secret.mjs", "test:wasm-init": "tsc && node --test test/wasm-init.mjs", From 22d13742aea2f37c13716da1dbc575fd426075c9 Mon Sep 17 00:00:00 2001 From: Alois Date: Thu, 27 Aug 2026 21:10:37 +0200 Subject: [PATCH 18/22] feat(qol): remove dup --- flake.nix | 67 ++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 41 insertions(+), 26 deletions(-) diff --git a/flake.nix b/flake.nix index 7951305..0a40973 100644 --- a/flake.nix +++ b/flake.nix @@ -5,26 +5,30 @@ rust-overlay.url = "github:oxalica/rust-overlay"; }; - outputs = { - self, - nixpkgs, - rust-overlay, - }: let - systems = [ - "aarch64-darwin" - "aarch64-linux" - "x86_64-darwin" - "x86_64-linux" - ]; - eachSystem = f: - nixpkgs.lib.foldl' nixpkgs.lib.recursiveUpdate {} ( - map (system: nixpkgs.lib.mapAttrs (_: value: {${system} = value;}) (f system)) systems - ); - in + outputs = + { + self, + nixpkgs, + rust-overlay, + }: + let + systems = [ + "aarch64-darwin" + "aarch64-linux" + "x86_64-darwin" + "x86_64-linux" + ]; + eachSystem = + f: + nixpkgs.lib.foldl' nixpkgs.lib.recursiveUpdate { } ( + map (system: nixpkgs.lib.mapAttrs (_: value: { ${system} = value; }) (f system)) systems + ); + in eachSystem ( - system: let - overlays = [rust-overlay.overlays.default]; - pkgs = import nixpkgs {inherit system overlays;}; + system: + let + overlays = [ rust-overlay.overlays.default ]; + pkgs = import nixpkgs { inherit system overlays; }; rustToolchain = pkgs.rust-bin.stable.latest.default.override { extensions = [ @@ -32,12 +36,12 @@ "clippy" "rustfmt" ]; - targets = ["wasm32-unknown-unknown"]; + targets = [ "wasm32-unknown-unknown" ]; }; clippyCheck = pkgs.writeShellApplication { name = "mtp-clippy"; - runtimeInputs = [rustToolchain]; + runtimeInputs = [ rustToolchain ]; text = '' 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 @@ -46,7 +50,7 @@ macheteCheck = pkgs.writeShellApplication { name = "mtp-machete"; - runtimeInputs = [pkgs.cargo-machete]; + runtimeInputs = [ pkgs.cargo-machete ]; text = '' cargo machete "$@" ''; @@ -54,7 +58,15 @@ buildAll = pkgs.writeShellApplication { name = "mtp-build-all"; - runtimeInputs = [rustToolchain pkgs.cargo-deny pkgs.wasm-pack pkgs.pnpm pkgs.coreutils clippyCheck macheteCheck]; + 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}" @@ -66,7 +78,6 @@ cargo check --manifest-path example/Cargo.toml --workspace --all-targets --all-features mtp-clippy mtp-machete - pnpm run dup pnpm run build RUSTFLAGS="--cfg web_sys_unstable_apis" wasm-pack test --node wasm pnpm run test:e2e @@ -79,13 +90,17 @@ healthCheck = pkgs.writeShellApplication { name = "mtp-health"; - runtimeInputs = [clippyCheck macheteCheck]; + runtimeInputs = [ + clippyCheck + macheteCheck + ]; text = '' mtp-clippy mtp-machete ''; }; - in { + in + { devShells = { default = pkgs.mkShell { name = "mtp-dev"; From 8d94bc54987640996f676b6a496c4f57502e6aaa Mon Sep 17 00:00:00 2001 From: Alois Date: Thu, 27 Aug 2026 21:55:36 +0200 Subject: [PATCH 19/22] feat(qol): add direnv --- .envrc | 1 + .gitignore | 1 + 2 files changed, 2 insertions(+) create mode 100644 .envrc diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore index 6dff05e..f600624 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist/ *.tgz wasm/pkg/ web_client/ +.direnv From c7c7afe5780a45c23725dedf54bbc342bb8cd6fd Mon Sep 17 00:00:00 2001 From: Alois Date: Thu, 27 Aug 2026 22:16:16 +0200 Subject: [PATCH 20/22] fix(example): web-client type --- src/sdk/client.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/sdk/client.ts b/src/sdk/client.ts index 499c44f..e0307cd 100644 --- a/src/sdk/client.ts +++ b/src/sdk/client.ts @@ -1504,7 +1504,11 @@ export class MTPClient { ): Unsubscribe; subscribe( type: MTPCommunicationType, - handler: (message: MTPFrame) => void | Promise, + handler: (message: ParsedFrame) => void | Promise, + ): Unsubscribe; + subscribe( + type: MTPCommunicationType, + handler: (message: any) => void | Promise, ): Unsubscribe { if (typeof type !== "string" || !type) { throw new TypeError("subscription type must be a non-empty string"); From 4493ed32cf8c267bc27d94ce504303014a1215db Mon Sep 17 00:00:00 2001 From: Alois Date: Thu, 27 Aug 2026 22:33:20 +0200 Subject: [PATCH 21/22] chore(deps): update chacha20 from 0.10.1 to 0.10.2 --- Cargo.lock | 6 +++--- codec/Cargo.lock | 6 +++--- common/Cargo.lock | 4 ++-- crypto/Cargo.lock | 6 +++--- example/Cargo.lock | 6 +++--- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cc6b180..8e8f6ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -256,9 +256,9 @@ dependencies = [ [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -1800,7 +1800,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20 0.10.1", + "chacha20 0.10.2", "getrandom 0.4.3", "rand_core 0.10.1", ] diff --git a/codec/Cargo.lock b/codec/Cargo.lock index 5ed6f53..a898cf4 100644 --- a/codec/Cargo.lock +++ b/codec/Cargo.lock @@ -187,9 +187,9 @@ dependencies = [ [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -1151,7 +1151,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20 0.10.1", + "chacha20 0.10.2", "getrandom 0.4.3", "rand_core 0.10.1", ] diff --git a/common/Cargo.lock b/common/Cargo.lock index 338d35d..8d0f18c 100644 --- a/common/Cargo.lock +++ b/common/Cargo.lock @@ -139,9 +139,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures", diff --git a/crypto/Cargo.lock b/crypto/Cargo.lock index e536096..5b86012 100644 --- a/crypto/Cargo.lock +++ b/crypto/Cargo.lock @@ -181,9 +181,9 @@ dependencies = [ [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -853,7 +853,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20 0.10.1", + "chacha20 0.10.2", "getrandom 0.4.3", "rand_core 0.10.1", ] diff --git a/example/Cargo.lock b/example/Cargo.lock index ba5bf49..f81b014 100644 --- a/example/Cargo.lock +++ b/example/Cargo.lock @@ -225,9 +225,9 @@ dependencies = [ [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -1701,7 +1701,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20 0.10.1", + "chacha20 0.10.2", "getrandom 0.4.3", "rand_core 0.10.1", ] From 9cea795d1cec8b9492f3f4e3a0da5db0218c7b0c Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:42:32 +0200 Subject: [PATCH 22/22] ChaCha20 --- Cargo.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cc6b180..8e8f6ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -256,9 +256,9 @@ dependencies = [ [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -1800,7 +1800,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20 0.10.1", + "chacha20 0.10.2", "getrandom 0.4.3", "rand_core 0.10.1", ]