[Fix] Harden MTP codec, transport, and SDK security
This commit is contained in:
parent
188caf56cc
commit
a7e804c603
73 changed files with 11892 additions and 5756 deletions
1389
wasm/src/client.rs
1389
wasm/src/client.rs
File diff suppressed because it is too large
Load diff
630
wasm/src/client/authentication.rs
Normal file
630
wasm/src/client/authentication.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
102
wasm/src/client/connection.rs
Normal file
102
wasm/src/client/connection.rs
Normal 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
184
wasm/src/client/dispatch.rs
Normal 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
299
wasm/src/client/mod.rs
Normal 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
76
wasm/src/client/pipes.rs
Normal 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
328
wasm/src/client/receive.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[derive(Clone)]
|
||||
#[wasm_bindgen]
|
||||
pub struct ConnectionConfig {
|
||||
pub(crate) url: String,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue