[Fix] WASM

This commit is contained in:
Alois 2026-06-25 19:38:40 +02:00
commit cfc9cebf6a
14 changed files with 461 additions and 204 deletions

View file

@ -3,9 +3,7 @@ use std::rc::Rc;
use wasm_bindgen::prelude::*;
use mtp_codec::{
CommunicationType, CommunicationValue, DataType, DataValue, PROTOCOL_VERSION,
};
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue, PROTOCOL_VERSION};
use mtp_type_map::{CommunicationTypeId, DataTypeId};
use mtp_crypto::SignatureScheme;
@ -13,6 +11,31 @@ use mtp_crypto::SignatureScheme;
use crate::error::js_error;
use crate::transport::WasmTransport;
fn raw_frame_preview(bytes: &[u8]) -> String {
let shown = bytes.len().min(256);
let mut preview = hex::encode(&bytes[..shown]);
if bytes.len() > shown {
preview.push_str("...");
}
format!("{} bytes, hex={preview}", bytes.len())
}
fn unexpected_response_type_error(
context: &str,
expected_type: CommunicationTypeId,
response_type: CommunicationTypeId,
response: &[u8],
parsed: &CommunicationValue,
) -> JsValue {
js_error(&format!(
"unexpected response type during {context}: expected {:?}, got {:?}; raw {}; parsed {}",
expected_type,
response_type,
raw_frame_preview(response),
parsed
))
}
#[wasm_bindgen]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConnectionState {
@ -33,17 +56,27 @@ pub struct ConnectionConfig {
impl ConnectionConfig {
#[wasm_bindgen(constructor)]
pub fn new(url: String) -> Self {
Self { url, server_certificate_hashes: None, client_id: 0 }
Self {
url,
server_certificate_hashes: None,
client_id: 0,
}
}
#[wasm_bindgen(getter)]
pub fn url(&self) -> String { self.url.clone() }
pub fn url(&self) -> String {
self.url.clone()
}
#[wasm_bindgen(setter)]
pub fn set_client_id(&mut self, id: u64) { self.client_id = id; }
pub fn set_client_id(&mut self, id: u64) {
self.client_id = id;
}
#[wasm_bindgen(getter)]
pub fn client_id(&self) -> u64 { self.client_id }
pub fn client_id(&self) -> u64 {
self.client_id
}
#[wasm_bindgen(setter)]
pub fn set_server_certificate_hashes(&mut self, hashes: Vec<String>) {
@ -79,24 +112,29 @@ impl WasmClient {
#[wasm_bindgen]
pub fn is_supported() -> bool {
js_sys::Reflect::has(&js_sys::global(), &JsValue::from_str("WebTransport"))
.unwrap_or(false)
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 }
pub fn state(&self) -> u8 {
self.state.get() as u8
}
/// Unauthenticated connect (sends basic Identification, enables receive loop).
#[wasm_bindgen]
pub async fn connect(&mut self, config: &ConnectionConfig) -> Result<(), JsValue> {
self.set_state(ConnectionState::Connecting);
let transport = WasmTransport::connect(&config.url, config.server_certificate_hashes.clone()).await?;
let transport =
WasmTransport::connect(&config.url, config.server_certificate_hashes.clone()).await?;
let inner = transport.inner().clone();
let version_str = format!("{}", PROTOCOL_VERSION);
let ident = CommunicationValue::new(CommunicationType::Identification)
.add_typed_default(DataType::Version, DataValue::Str(version_str))
.add_typed_default(DataType::Id, DataValue::UnsignedNumber(config.client_id as u128));
.add_typed_default(
DataType::Id,
DataValue::UnsignedNumber(config.client_id as u128),
);
transport.send_frame(&ident.to_bytes()).await?;
self.transport = Some(transport);
@ -106,7 +144,9 @@ impl WasmClient {
let on_msg = self.on_message.clone();
let on_err = self.on_error.clone();
wasm_bindgen_futures::spawn_local(async move {
WasmTransport::from_inner(inner).receive_loop(on_msg, on_err).await;
WasmTransport::from_inner(inner)
.receive_loop(on_msg, on_err)
.await;
state.set(ConnectionState::Disconnected);
});
Ok(())
@ -137,8 +177,7 @@ impl WasmClient {
let version_str = format!("{}", PROTOCOL_VERSION);
let mut nonce_bytes = [0u8; 16];
getrandom::fill(&mut nonce_bytes)
.map_err(|_| js_error("rng failed"))?;
getrandom::fill(&mut nonce_bytes).map_err(|_| js_error("rng failed"))?;
let client_nonce = u128::from_be_bytes(nonce_bytes);
// Build signature payload: version || client_id || client_nonce
@ -149,17 +188,22 @@ impl WasmClient {
let signer = mtp_crypto::Ed25519Signer::new(&keyring.sig_cl_secret_key)
.map_err(|e| js_error(&format!("signer creation failed: {}", e)))?;
let signature = signer.sign(&sig_payload)
let signature = signer
.sign(&sig_payload)
.map_err(|e| js_error(&format!("signature failed: {}", e)))?;
let frame = CommunicationValue::new(CommunicationType::Identification)
.add_typed_default(DataType::Version, DataValue::Str(version_str))
.add_typed_default(DataType::Id, DataValue::UnsignedNumber(client_id as u128))
.add_typed_default(DataType::ClientNonce, DataValue::UnsignedNumber(client_nonce))
.add_typed_default(
DataType::ClientNonce,
DataValue::UnsignedNumber(client_nonce),
)
.add_typed_default(DataType::Signature, DataValue::Bytes(signature))
.to_bytes();
let transport = WasmTransport::connect(&config.url, config.server_certificate_hashes.clone()).await?;
let transport =
WasmTransport::connect(&config.url, config.server_certificate_hashes.clone()).await?;
let inner = transport.inner().clone();
transport.send_frame(&frame).await?;
@ -171,7 +215,13 @@ impl WasmClient {
let resp_type = resp_comm.get_type();
let expected_type = CommunicationTypeId(16); // IdentificationResponse
if resp_type != expected_type {
return Err(js_error("unexpected response type"));
return Err(unexpected_response_type_error(
"auth_connect",
expected_type,
resp_type,
&response,
&resp_comm,
));
}
if resp_comm.get_data(DataTypeId(11)) != &DataValue::BoolTrue {
@ -197,7 +247,9 @@ impl WasmClient {
let on_msg = self.on_message.clone();
let on_err = self.on_error.clone();
wasm_bindgen_futures::spawn_local(async move {
WasmTransport::from_inner(inner).receive_loop(on_msg, on_err).await;
WasmTransport::from_inner(inner)
.receive_loop(on_msg, on_err)
.await;
state.set(ConnectionState::Disconnected);
});
@ -227,8 +279,7 @@ impl WasmClient {
let version_str = format!("{}", PROTOCOL_VERSION);
let mut nonce_bytes = [0u8; 16];
getrandom::fill(&mut nonce_bytes)
.map_err(|_| js_error("rng failed"))?;
getrandom::fill(&mut nonce_bytes).map_err(|_| js_error("rng failed"))?;
let client_nonce = u128::from_be_bytes(nonce_bytes);
let pk_bytes = keyring.public_key_bundle().as_bytes();
@ -241,17 +292,22 @@ impl WasmClient {
let signer = mtp_crypto::Ed25519Signer::new(&keyring.sig_cl_secret_key)
.map_err(|e| js_error(&format!("signer creation failed: {}", e)))?;
let signature = signer.sign(&sig_payload)
let signature = signer
.sign(&sig_payload)
.map_err(|e| js_error(&format!("signature failed: {}", e)))?;
let frame = CommunicationValue::new(CommunicationType::Register)
.add_typed_default(DataType::Version, DataValue::Str(version_str))
.add_typed_default(DataType::ClientNonce, DataValue::UnsignedNumber(client_nonce))
.add_typed_default(
DataType::ClientNonce,
DataValue::UnsignedNumber(client_nonce),
)
.add_typed_default(DataType::PublicKeys, DataValue::Bytes(pk_bytes))
.add_typed_default(DataType::Signature, DataValue::Bytes(signature))
.to_bytes();
let transport = WasmTransport::connect(&config.url, config.server_certificate_hashes.clone()).await?;
let transport =
WasmTransport::connect(&config.url, config.server_certificate_hashes.clone()).await?;
let inner = transport.inner().clone();
transport.send_frame(&frame).await?;
@ -262,7 +318,13 @@ impl WasmClient {
let resp_type = resp_comm.get_type();
let expected_type = CommunicationTypeId(18); // RegisterResponse
if resp_type != expected_type {
return Err(js_error("unexpected response type"));
return Err(unexpected_response_type_error(
"auth_register",
expected_type,
resp_type,
&response,
&resp_comm,
));
}
if resp_comm.get_data(DataTypeId(11)) != &DataValue::BoolTrue {
@ -286,7 +348,9 @@ impl WasmClient {
let on_msg = self.on_message.clone();
let on_err = self.on_error.clone();
wasm_bindgen_futures::spawn_local(async move {
WasmTransport::from_inner(inner).receive_loop(on_msg, on_err).await;
WasmTransport::from_inner(inner)
.receive_loop(on_msg, on_err)
.await;
state.set(ConnectionState::Disconnected);
});
@ -303,16 +367,17 @@ impl WasmClient {
#[wasm_bindgen]
pub fn disconnect(&mut self) {
if let Some(t) = &self.transport { t.close(); }
if let Some(t) = &self.transport {
t.close();
}
self.transport = None;
self.set_state(ConnectionState::Disconnected);
}
fn set_state(&self, new_state: ConnectionState) {
self.state.set(new_state);
let _ = self.on_state_change.call1(
&JsValue::NULL,
&JsValue::from(new_state as u8),
);
let _ = self
.on_state_change
.call1(&JsValue::NULL, &JsValue::from(new_state as u8));
}
}

View file

@ -1,27 +1,23 @@
use wasm_bindgen::prelude::*;
use mtp_codec::{
CommunicationType, CommunicationValue, DataType, DataTypeId, DataValue,
};
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataTypeId, DataValue};
use mtp_crypto::{ChaCha20Poly1305, Ed25519Signer, Keyring, SigAlgorithm, derive_encryption_key};
use mtp_type_map::communication_type_name;
use mtp_crypto::{
ChaCha20Poly1305, Ed25519Signer, Keyring, SigAlgorithm,
derive_encryption_key,
};
use crate::error::js_error;
/// Build a simple Ping frame with description, timestamp, and optional data.
#[wasm_bindgen]
pub fn build_ping_frame(
client_id: u64,
description: &str,
timestamp: u64,
data: &[u8],
) -> Vec<u8> {
pub fn build_ping_frame(client_id: u64, description: &str, timestamp: u64, data: &[u8]) -> Vec<u8> {
let mut msg = CommunicationValue::new(CommunicationType::Ping)
.add_typed_default(DataType::Description, DataValue::Str(description.to_string()))
.add_typed_default(DataType::Timestamp, DataValue::UnsignedNumber(timestamp as u128))
.add_typed_default(
DataType::Description,
DataValue::Str(description.to_string()),
)
.add_typed_default(
DataType::Timestamp,
DataValue::UnsignedNumber(timestamp as u128),
)
.with_sender(client_id);
if !data.is_empty() {
@ -55,7 +51,8 @@ pub fn build_demo_message(client_id: u64, keyring_bytes: &[u8]) -> Result<Vec<u8
(DataTypeId(2), DataValue::UnsignedNumber(42)),
]);
let mut dv_enc = inner_enc;
dv_enc.encrypt_container(&cipher, b"demo-aad")
dv_enc
.encrypt_container(&cipher, b"demo-aad")
.ok_or_else(|| js_error("encryption failed"))?;
// Signed container
@ -64,23 +61,34 @@ pub fn build_demo_message(client_id: u64, keyring_bytes: &[u8]) -> Result<Vec<u8
(DataTypeId(2), DataValue::UnsignedNumber(99)),
]);
let mut dv_sig = inner_sig;
dv_sig.sign_container(SigAlgorithm::ED25519, &signer)
dv_sig
.sign_container(SigAlgorithm::ED25519, &signer)
.ok_or_else(|| js_error("signing failed"))?;
// Signed + encrypted container
let inner_sec = DataValue::Container(vec![
(DataTypeId(1), DataValue::Str("signed+encrypted payload".into())),
(
DataTypeId(1),
DataValue::Str("signed+encrypted payload".into()),
),
(DataTypeId(2), DataValue::UnsignedNumber(7)),
]);
let mut dv_sec = inner_sec;
dv_sec.sign_and_encrypt_container(SigAlgorithm::ED25519, &signer, &cipher, b"demo-aad")
dv_sec
.sign_and_encrypt_container(SigAlgorithm::ED25519, &signer, &cipher, b"demo-aad")
.ok_or_else(|| js_error("sign+encrypt failed"))?;
let timestamp = js_sys::Date::now() as u64;
let msg = CommunicationValue::new(CommunicationType::Ping)
.add_typed_default(DataType::Description, DataValue::Str("MTP WASM Demo".into()))
.add_typed_default(DataType::Timestamp, DataValue::UnsignedNumber(timestamp as u128))
.add_typed_default(
DataType::Description,
DataValue::Str("MTP WASM Demo".into()),
)
.add_typed_default(
DataType::Timestamp,
DataValue::UnsignedNumber(timestamp as u128),
)
.add_typed_default(DataType::Version, DataValue::Str("demo-wasm".into()))
.with_sender(client_id);
@ -174,7 +182,11 @@ pub fn parse_response_frame(frame: &[u8]) -> Result<String, JsValue> {
let obj = js_sys::Object::new();
let _ = js_sys::Reflect::set(&obj, &JsValue::from_str("_id"), &JsValue::from(comm.get_id()));
let _ = js_sys::Reflect::set(
&obj,
&JsValue::from_str("_id"),
&JsValue::from(comm.get_id()),
);
let type_name = communication_type_name(comm.get_type().0).unwrap_or("Unknown");
let _ = js_sys::Reflect::set(
@ -200,12 +212,21 @@ pub fn parse_response_frame(frame: &[u8]) -> Result<String, JsValue> {
}
}
let stringified = js_sys::JSON::stringify(&obj)
.map_err(|_| js_error("JSON stringify failed"))?;
stringified.as_string()
let stringified =
js_sys::JSON::stringify(&obj).map_err(|_| js_error("JSON stringify failed"))?;
stringified
.as_string()
.ok_or_else(|| js_error("JSON stringify result not a string"))
}
/// 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)))?;
Ok(comm.to_string())
}
#[cfg(test)]
#[cfg(target_arch = "wasm32")]
mod tests {
@ -219,8 +240,14 @@ mod tests {
assert_eq!(cv.get_type(), CommunicationTypeId(19)); // Ping
assert_eq!(cv.get_sender(), 42);
assert_eq!(cv.get_data(DataTypeId(4)), &DataValue::Str("test-ping".into()));
assert_eq!(cv.get_data(DataTypeId(5)), &DataValue::UnsignedNumber(1234567890));
assert_eq!(
cv.get_data(DataTypeId(4)),
&DataValue::Str("test-ping".into())
);
assert_eq!(
cv.get_data(DataTypeId(5)),
&DataValue::UnsignedNumber(1234567890)
);
}
#[wasm_bindgen_test]
@ -231,9 +258,15 @@ mod tests {
assert_eq!(cv.get_type(), CommunicationTypeId(19));
assert_eq!(cv.get_sender(), 99);
assert_eq!(cv.get_data(DataTypeId(4)), &DataValue::Str("with-data".into()));
assert_eq!(
cv.get_data(DataTypeId(4)),
&DataValue::Str("with-data".into())
);
assert_eq!(cv.get_data(DataTypeId(5)), &DataValue::UnsignedNumber(555));
assert_eq!(cv.get_data(DataTypeId(6)), &DataValue::Bytes(payload.to_vec()));
assert_eq!(
cv.get_data(DataTypeId(6)),
&DataValue::Bytes(payload.to_vec())
);
}
#[wasm_bindgen_test]
@ -264,7 +297,10 @@ mod tests {
assert_eq!(cv.get_type(), CommunicationTypeId(19)); // Ping
assert_eq!(cv.get_sender(), 7);
assert_eq!(cv.get_data(DataTypeId(4)), &DataValue::Str("MTP WASM Demo".into()));
assert_eq!(
cv.get_data(DataTypeId(4)),
&DataValue::Str("MTP WASM Demo".into())
);
}
#[wasm_bindgen_test]
@ -287,11 +323,13 @@ mod tests {
let result = parse_auth_response(&resp).expect("parse failed");
let connected = js_sys::Reflect::get(&result, &"connected".into())
.ok().and_then(|v| v.as_bool());
.ok()
.and_then(|v| v.as_bool());
assert_eq!(connected, Some(true));
let id = js_sys::Reflect::get(&result, &"assignedId".into())
.ok().and_then(|v| v.as_f64());
.ok()
.and_then(|v| v.as_f64());
assert_eq!(id, Some(42.0));
}
@ -304,7 +342,8 @@ mod tests {
let result = parse_auth_response(&resp).expect("parse failed");
let connected = js_sys::Reflect::get(&result, &"connected".into())
.ok().and_then(|v| v.as_bool());
.ok()
.and_then(|v| v.as_bool());
assert_eq!(connected, Some(false));
// rejected should have no assignedId

View file

@ -5,6 +5,8 @@ use web_sys::{WebTransport, WebTransportHash, WebTransportOptions};
use crate::error::js_error;
const CLOSE_FRAME_LEN: u32 = u32::MAX;
/// Given a `SendStream` (old API with `.writable` or new API where stream IS a WritableStream),
/// return the object to call `.getWriter()` on.
fn resolve_stream_writable(send_stream: &JsValue) -> Result<JsValue, JsValue> {
@ -199,10 +201,17 @@ impl WasmTransport {
}
if buffer.len() >= 4 {
let frame_len =
u32::from_be_bytes([buffer[0], buffer[1], buffer[2], buffer[3]]) as usize;
if 4 + frame_len <= buffer.len() {
return Ok(buffer[4..4 + frame_len].to_vec());
let frame_len = u32::from_be_bytes([buffer[0], buffer[1], buffer[2], buffer[3]]);
if frame_len == CLOSE_FRAME_LEN {
return Err(js_error("connection closed before frame"));
}
let frame_len = frame_len as usize;
let Some(frame_end) = 4usize.checked_add(frame_len) else {
return Err(js_error("invalid frame length"));
};
if frame_end <= buffer.len() {
return Ok(buffer[4..frame_end].to_vec());
}
}
}
@ -230,13 +239,7 @@ impl WasmTransport {
let result = match read_fn.call0(&reader_val) {
Ok(p) => match JsFuture::from(p.unchecked_into::<js_sys::Promise>()).await {
Ok(v) => v,
Err(e) => {
let _ = on_error.call1(
&JsValue::NULL,
&JsValue::from_str(&format!("read stream failed: {:?}", e)),
);
break;
}
Err(_) => break,
},
Err(_) => break,
};
@ -302,29 +305,43 @@ impl WasmTransport {
// Extract all complete frames from the buffer
while buffer.len() >= 4 {
let frame_len =
u32::from_be_bytes([buffer[0], buffer[1], buffer[2], buffer[3]]) as usize;
if 4 + frame_len > buffer.len() {
let frame_len = u32::from_be_bytes([buffer[0], buffer[1], buffer[2], buffer[3]]);
if frame_len == CLOSE_FRAME_LEN {
return Ok(());
}
let frame_len = frame_len as usize;
let Some(frame_end) = 4usize.checked_add(frame_len) else {
return Err(js_error("invalid frame length"));
};
if frame_end > buffer.len() {
break;
}
let frame = buffer[4..4 + frame_len].to_vec();
let frame = buffer[4..frame_end].to_vec();
let arr = js_sys::Uint8Array::from(&frame[..]);
let _ = on_message.call1(&JsValue::NULL, &arr);
buffer.drain(..4 + frame_len);
buffer.drain(..frame_end);
}
}
// Process any remaining complete frames after stream closes
while buffer.len() >= 4 {
let frame_len =
u32::from_be_bytes([buffer[0], buffer[1], buffer[2], buffer[3]]) as usize;
if 4 + frame_len > buffer.len() {
let frame_len = u32::from_be_bytes([buffer[0], buffer[1], buffer[2], buffer[3]]);
if frame_len == CLOSE_FRAME_LEN {
return Ok(());
}
let frame_len = frame_len as usize;
let Some(frame_end) = 4usize.checked_add(frame_len) else {
return Err(js_error("invalid frame length"));
};
if frame_end > buffer.len() {
break;
}
let frame = buffer[4..4 + frame_len].to_vec();
let frame = buffer[4..frame_end].to_vec();
let arr = js_sys::Uint8Array::from(&frame[..]);
let _ = on_message.call1(&JsValue::NULL, &arr);
buffer.drain(..4 + frame_len);
buffer.drain(..frame_end);
}
Ok(())