[Fix] Harden MTP codec, transport, and SDK security

This commit is contained in:
Alex Emmet 2026-08-18 20:57:45 +02:00
commit a7e804c603
No known key found for this signature in database
73 changed files with 11892 additions and 5756 deletions

File diff suppressed because it is too large Load diff

View file

@ -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<u64, JsValue> {
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<u8>,
keyring_bytes: Vec<u8>,
client_id: u64,
) -> Result<u64, JsValue> {
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<u64, JsValue> {
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<u8>,
keyring_bytes: Vec<u8>,
) -> Result<u64, JsValue> {
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<u128, JsValue> {
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)
}
}

View file

@ -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
}
}

184
wasm/src/client/dispatch.rs Normal file
View file

@ -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<dyn FnMut()>,
}
pub(super) struct PendingPing {
pub(super) generation: u32,
pub(super) sent_at: f64,
}
pub(super) fn frame_property(frame: &JsValue, key: &str) -> Option<JsValue> {
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<u32> {
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<String> {
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<RefCell<HashMap<u32, (String, js_sys::Function)>>>,
pending_requests: &Rc<RefCell<HashMap<u32, PendingRequest>>>,
expired_requests: &Rc<RefCell<HashMap<u32, f64>>>,
pending_pings: &Rc<RefCell<HashMap<u32, PendingPing>>>,
ping_ms: &Rc<Cell<Option<f64>>>,
) {
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<js_sys::Function> = 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<RefCell<Option<PingTimer>>>) {
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::<js_sys::Function>())
{
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<RefCell<HashMap<u32, PendingRequest>>>,
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::<js_sys::Function>())
.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<Cell<ConnectionState>>,
pending_state_callbacks: &Rc<RefCell<VecDeque<ConnectionState>>>,
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::<js_sys::Function>());
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::<js_sys::Function>())
.and_then(|set_timeout| {
set_timeout.call2(&global, state_callback, &JsValue::from_f64(0.0))
})
.is_err()
{
pending_state_callbacks.borrow_mut().pop_back();
}
}

299
wasm/src/client/mod.rs Normal file
View file

@ -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<usize, JsValue> {
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<EncodeLimits, JsValue> {
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<DecodeLimits, JsValue> {
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<RefCell<Option<WasmTransport>>>,
attempt_transport: Rc<RefCell<Option<WasmTransport>>>,
connection_generation: Rc<Cell<u32>>,
state: Rc<Cell<ConnectionState>>,
pending_state_callbacks: Rc<RefCell<VecDeque<ConnectionState>>>,
state_callback: Closure<dyn FnMut()>,
pub(crate) on_message: js_sys::Function,
pub(crate) on_error: js_sys::Function,
subscriptions: Rc<RefCell<HashMap<u32, (String, js_sys::Function)>>>,
next_subscription_id: Rc<Cell<u32>>,
pending_requests: Rc<RefCell<HashMap<u32, PendingRequest>>>,
expired_requests: Rc<RefCell<HashMap<u32, f64>>>,
ping_timer: Rc<RefCell<Option<PingTimer>>>,
pending_pings: Rc<RefCell<HashMap<u32, PendingPing>>>,
ping_ms: Rc<Cell<Option<f64>>>,
pending_pipe_creations: client_pipe::PendingPipeCreations,
expired_pipe_creations: Rc<RefCell<HashMap<u32, f64>>>,
pending_pipes: client_pipe::PendingPipes,
connection_client_id: Rc<Cell<u64>>,
on_pipe_request: Rc<RefCell<Option<js_sys::Function>>>,
receive_decode_limits: Rc<RefCell<Option<DecodeLimits>>>,
}
#[wasm_bindgen]
#[allow(deprecated)]
impl WasmClient {
#[wasm_bindgen(constructor)]
pub fn new(
on_state_change: Option<js_sys::Function>,
on_message: Option<js_sys::Function>,
on_error: Option<js_sys::Function>,
) -> 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<dyn FnMut()>);
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<f64> {
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<DecodeLimits> {
*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<u8>) -> 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<u8>,
response_type: Option<String>,
timeout_ms: Option<u32>,
) -> Result<JsValue, JsValue> {
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()
}
}

76
wasm/src/client/pipes.rs Normal file
View file

@ -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<js_sys::Function>) {
*self.on_pipe_request.borrow_mut() = callback;
}
#[wasm_bindgen]
pub async fn create_pipe(
&self,
description: &str,
) -> Result<client_pipe::WasmPipeHandle, 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"))?;
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<PipeReader, 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"))?;
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
}
}

328
wasm/src/client/receive.rs Normal file
View file

@ -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<dyn FnMut()>);
let set_interval =
js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("setInterval"))?
.dyn_into::<js_sys::Function>()?;
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::<js_sys::Function>())
{
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();
}
}

View file

@ -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<Result<bool, JsValue>>,
}
pub(crate) type PendingPipeCreations = Rc<RefCell<HashMap<u32, PendingPipeCreation>>>;
type PipeResponseReceiver = oneshot::Receiver<Result<bool, JsValue>>;
type PipeResponseCell = Rc<RefCell<Option<PipeResponseReceiver>>>;
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<Result<PipeReader, JsValue>>,
@ -114,6 +121,10 @@ pub struct WasmPipeHandle {
description: String,
transport: WasmTransport,
response_rx: PipeResponseCell,
pending: PendingPipeCreations,
expired: Rc<RefCell<HashMap<u32, f64>>>,
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<u32, JsValue> {
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::<js_sys::Function>())
.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<RefCell<HashMap<u32, f64>>>,
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<RefCell<HashMap<u32, f64>>>,
pipe_id: u32,
generation: u32,
token: Rc<()>,
armed: bool,
}
impl PendingPipeCreationGuard {
fn new(
pending: PendingPipeCreations,
expired: Rc<RefCell<HashMap<u32, f64>>>,
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<RefCell<HashMap<u32, f64>>>,
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<RefCell<HashMap<u32, f64>>>, 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<RefCell<HashMap<u32, f64>>>,
generation: u32,
current_generation: &Rc<std::cell::Cell<u32>>,
) -> Result<WasmPipeHandle, JsValue> {
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> {

View file

@ -1,5 +1,6 @@
use wasm_bindgen::prelude::*;
#[derive(Clone)]
#[wasm_bindgen]
pub struct ConnectionConfig {
pub(crate) url: String,

View file

@ -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, JsValue> {
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<u8> {
self.inner.to_bytes().to_vec()
pub fn to_bytes(&self) -> Result<Vec<u8>, JsValue> {
self.try_to_bytes()
}
#[wasm_bindgen]
pub fn try_to_bytes(&self) -> Result<Vec<u8>, 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<u8> {
Keyring::generate().to_bytes().to_vec()
pub fn keyring_generate() -> Result<Vec<u8>, JsValue> {
keyring_generate_checked()
}
/// Generate a full keyring and report serialization failures to JavaScript.
#[wasm_bindgen]
pub fn keyring_generate_checked() -> Result<Vec<u8>, 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<
SignaturePublicKey::new(public_key.to_vec()),
SignaturePrivateKey::new(secret_key.to_vec()),
);
Ok(keyring.to_bytes().to_vec())
keyring
.try_to_bytes()
.map(|bytes| bytes.to_vec())
.map_err(|error| js_error(format!("Keyring serialization failed: {error}")))
}
// ===========================================================================
@ -172,8 +201,15 @@ impl WasmPublicKeyBundle {
}
#[wasm_bindgen]
pub fn to_bytes(&self) -> Vec<u8> {
self.inner.as_bytes()
pub fn to_bytes(&self) -> Result<Vec<u8>, JsValue> {
self.try_to_bytes()
}
#[wasm_bindgen]
pub fn try_to_bytes(&self) -> Result<Vec<u8>, 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<u8> {
// 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>>,
keyring_bytes: &[u8],
signature_suite: u8,
metadata_recipient_public_key_bundles: JsValue,
content_recipient_public_key_bundles: JsValue,
limits: JsValue,
) -> Result<Vec<u8>, 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);
}

View file

@ -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<JsValu
}
const MAX_SAFE_INT: f64 = 9007199254740991.0; // 2^53 - 1
struct JsDataValueEncodeContext {
limits: EncodeLimits,
values: usize,
}
impl JsDataValueEncodeContext {
fn visit(&mut self, depth: usize) -> 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<DataValue, JsValue> {
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<DataValue, JsValue> {
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<DataValue, JsValue> {
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<DataValu
}
if js_sys::Array::is_array(value) {
let array = js_sys::Array::from(value);
if array.length() as usize > 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<DataValu
if value.is_object() {
let object = js_sys::Object::from(value.clone());
let keys = js_sys::Object::keys(&object);
if keys.length() as usize > 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<DataValu
tm.version
))
})?;
entries.push((id, js_to_data_value(&value, tm)?));
entries.push((
id,
js_to_data_value_with_context(&value, tm, context, depth + 1)?,
));
}
return Ok(DataValue::Container(entries));
}
@ -282,15 +341,16 @@ fn apply_frame_options(
}
pub(crate) fn parse_frame_value(frame: &[u8]) -> Result<JsValue, JsValue> {
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<JsValue, JsValue> {
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<JsValue, JsValue> {
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<JsValue, JsValue> {
/// Parse any MTP frame into the human-readable CommunicationValue display form.
#[wasm_bindgen]
pub fn format_frame(frame: &[u8]) -> Result<String, JsValue> {
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<JsValue, JsValue> {
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<JsValue, JsValue> {
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<JsValue, JsValue> {
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<JsValue, JsValue> {
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<JsValue, JsValue> {
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<Vec<u8>, JsValue> {
encode_data_value_with_encode_limits(value, EncodeLimits::default())
}
fn encode_data_value_with_encode_limits(
value: JsValue,
limits: EncodeLimits,
) -> Result<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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 {

View file

@ -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<Option<u64>, JsValue> {
))
}
fn decode_data_value(value: &[u8]) -> Result<DataValue, JsValue> {
DataValue::from_bytes(value)
.ok_or_else(|| structured_error("invalid-data-value", "invalid DataValue"))
fn limit_usize(options: &JsValue, key: &str, default: usize) -> Result<usize, JsValue> {
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<u64>,
signature_purpose: u8,
encryption_purpose: u8,
policy: mtp_codec::ProtectionPolicy,
limits: &JsValue,
) -> Result<ProtectedOpenOptions, JsValue> {
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<Vec<u8>, JsValue> {
})
}
fn serialize_frame(frame: &mtp_codec::CommunicationValue) -> Result<Vec<u8>, 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<Vec<u8>, 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<u32>,
expose_sender: bool,
recipient_public_key_bundles: JsValue,
limits: JsValue,
) -> Result<Vec<u8>, 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<u32>,
expose_sender: bool,
recipient_public_key_bundles: JsValue,
limits: JsValue,
) -> Result<Vec<u8>, 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<u64, JsValue> {
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<u64, JsValue> {
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<u64, JsValue> {
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<WasmVerifiedProtectedMessage, JsValue> {
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<WasmVerifiedProtectedMessage, JsValue> {
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<WasmVerifiedProtectedMessage, JsValue> {
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");

View file

@ -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<String>) -> 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, JsValue> {
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, JsValue> {
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<Option<u64>, JsValue> {
@ -113,6 +140,79 @@ fn optional_u64(value: &JsValue, name: &str) -> Result<Option<u64>, JsValue> {
))
}
fn limit_usize(options: &JsValue, key: &str, default: usize) -> Result<usize, JsValue> {
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<RelayOpenOptions, JsValue> {
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<Vec<u8>, 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<u64, JsValue> {
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<u64, JsValue> {
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<u64, JsValue> {
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<WasmVerifiedRelayMetadata, JsValue> {
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<WasmVerifiedRelayContent, JsValue> {
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 })

View file

@ -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<AsyncMutex<()>>,
type_map: Rc<RefCell<TypeMap>>,
decode_limits: Rc<RefCell<DecodeLimits>>,
}
impl WasmTransport {
@ -140,6 +141,15 @@ impl WasmTransport {
url: &str,
cert_hashes: Option<Vec<String>>,
max_message_size: u32,
) -> Result<Self, JsValue> {
Self::connect_with_limits(url, cert_hashes, max_message_size, None).await
}
pub async fn connect_with_limits(
url: &str,
cert_hashes: Option<Vec<String>>,
max_message_size: u32,
configured_limits: Option<DecodeLimits>,
) -> Result<Self, JsValue> {
let ctor = js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("WebTransport"))?
.dyn_into::<js_sys::Function>()
@ -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),
}
}