[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

@ -4,6 +4,20 @@ use mtp_codec::{CommunicationValue, DataTypeId, DataValue, PROTOCOL_VERSION, Ver
use mtp_common::CommunicationError; use mtp_common::CommunicationError;
use mtp_transport::{Policy, Receiver, Sender}; use mtp_transport::{Policy, Receiver, Sender};
#[cfg(feature = "crypto")]
fn unexpected_response_type_error(
context: &str,
expected_type: mtp_codec::CommunicationTypeId,
response: &CommunicationValue,
) -> CommunicationError {
CommunicationError::AuthenticationFailed(format!(
"unexpected response type during {context}: expected {:?}, got {:?}; parsed {}",
expected_type,
response.get_type(),
response
))
}
pub struct ClientConfig { pub struct ClientConfig {
pub url: String, pub url: String,
pub server_cert: Option<Vec<u8>>, pub server_cert: Option<Vec<u8>>,
@ -124,6 +138,15 @@ impl MTPClient {
// 2. Receive host response (single message) // 2. Receive host response (single message)
let response = receiver.receive().await?; let response = receiver.receive().await?;
let expected_type = mtp_codec::CommunicationTypeId(16); // IdentificationResponse
if response.get_type() != expected_type {
return Err(unexpected_response_type_error(
"auth_connect",
expected_type,
&response,
));
}
let connected = response.get_data(DataTypeId(11)); let connected = response.get_data(DataTypeId(11));
match connected { match connected {
DataValue::BoolTrue => {} DataValue::BoolTrue => {}
@ -264,6 +287,15 @@ impl MTPClient {
// 2. Receive host response (single message) // 2. Receive host response (single message)
let response = receiver.receive().await?; let response = receiver.receive().await?;
let expected_type = mtp_codec::CommunicationTypeId(18); // RegisterResponse
if response.get_type() != expected_type {
return Err(unexpected_response_type_error(
"auth_register",
expected_type,
&response,
));
}
let connected = response.get_data(DataTypeId(11)); let connected = response.get_data(DataTypeId(11));
match connected { match connected {
DataValue::BoolTrue => {} DataValue::BoolTrue => {}

View file

@ -2,13 +2,29 @@ mod auth;
mod messages; mod messages;
use std::fs; use std::fs;
use std::path::Path;
use mtp::client::ClientConfig; use mtp::client::ClientConfig;
use mtp::crypto::{KemPublicKey, PublicKeyBundle, SignaturePqPublicKey, SignaturePublicKey}; use mtp::crypto::{KemPublicKey, PublicKeyBundle, SignaturePqPublicKey, SignaturePublicKey};
fn dev_cert_path() -> String {
std::env::var("MTP_DEV_CERT").unwrap_or_else(|_| {
if Path::new("example-usage/dev-cert/cert.pem").exists() {
"example-usage/dev-cert/cert.pem".to_string()
} else {
"dev-cert/cert.pem".to_string()
}
})
}
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
let cert_pem = fs::read("server.pem").expect("Missing server.pem: run server first"); let cert_path = dev_cert_path();
let cert_pem = fs::read(&cert_path).unwrap_or_else(|e| {
panic!(
"Missing TLS certificate at {cert_path}: enter the Nix shell first or run the server to generate it: {e}"
)
});
let host_public_key = PublicKeyBundle::new( let host_public_key = PublicKeyBundle::new(
KemPublicKey::new( KemPublicKey::new(
fs::read("host_enc_kem_pk.bin") fs::read("host_enc_kem_pk.bin")

View file

@ -13,8 +13,8 @@ fn derive_demo_key() -> [u8; 32] {
pub fn build_demo_message(client_id: u64, keyring: &Keyring) -> CommunicationValue { pub fn build_demo_message(client_id: u64, keyring: &Keyring) -> CommunicationValue {
let cipher = ChaCha20Poly1305::new(derive_demo_key()); let cipher = ChaCha20Poly1305::new(derive_demo_key());
let signer = Ed25519Signer::new(&keyring.sig_cl_secret_key) let signer =
.expect("Ed25519 signer from keyring"); Ed25519Signer::new(&keyring.sig_cl_secret_key).expect("Ed25519 signer from keyring");
let inner_enc = DataValue::Container(vec![ let inner_enc = DataValue::Container(vec![
(DataTypeId(1), DataValue::Str("secret inner data".into())), (DataTypeId(1), DataValue::Str("secret inner data".into())),
@ -31,7 +31,10 @@ pub fn build_demo_message(client_id: u64, keyring: &Keyring) -> CommunicationVal
dv_sig.sign_container(SigAlgorithm::ED25519, &signer); dv_sig.sign_container(SigAlgorithm::ED25519, &signer);
let inner_sec = DataValue::Container(vec![ 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)), (DataTypeId(2), DataValue::UnsignedNumber(7)),
]); ]);
let mut dv_sec = inner_sec; let mut dv_sec = inner_sec;
@ -42,13 +45,22 @@ pub fn build_demo_message(client_id: u64, keyring: &Keyring) -> CommunicationVal
.unwrap() .unwrap()
.as_secs(); .as_secs();
CommunicationValue::new(CommunicationType::Ping) let msg = CommunicationValue::new(CommunicationType::Ping)
.add_typed_default(DataType::Description, DataValue::Str("MTP Data Type Demo".into())) .add_typed_default(
.add_typed_default(DataType::Timestamp, DataValue::UnsignedNumber(timestamp as u128)) DataType::Description,
DataValue::Str("MTP Data Type Demo".into()),
)
.add_typed_default(
DataType::Timestamp,
DataValue::UnsignedNumber(timestamp as u128),
)
.add_typed_default(DataType::Data, DataValue::Str("Hello, MTP!".into())) .add_typed_default(DataType::Data, DataValue::Str("Hello, MTP!".into()))
.add_typed_default(DataType::Flags, DataValue::BoolTrue) .add_typed_default(DataType::Flags, DataValue::BoolTrue)
.add_typed_default(DataType::Value, DataValue::Float(2, 12345)) .add_typed_default(DataType::Value, DataValue::Float(2, 12345))
.add_typed_default(DataType::BinaryData, DataValue::Bytes(vec![0xDE, 0xAD, 0xBE, 0xEF, 0x42])) .add_typed_default(
DataType::BinaryData,
DataValue::Bytes(vec![0xDE, 0xAD, 0xBE, 0xEF, 0x42]),
)
.add_typed_default( .add_typed_default(
DataType::Items, DataType::Items,
DataValue::Array(vec![ DataValue::Array(vec![
@ -60,7 +72,8 @@ pub fn build_demo_message(client_id: u64, keyring: &Keyring) -> CommunicationVal
.add_typed_default(DataType::EncryptedPayload, dv_enc) .add_typed_default(DataType::EncryptedPayload, dv_enc)
.add_typed_default(DataType::SignedPayload, dv_sig) .add_typed_default(DataType::SignedPayload, dv_sig)
.add_typed_default(DataType::SecurePayload, dv_sec) .add_typed_default(DataType::SecurePayload, dv_sec)
.with_sender(client_id) .with_sender(client_id);
msg
} }
pub async fn send_and_receive( pub async fn send_and_receive(
@ -72,7 +85,9 @@ pub async fn send_and_receive(
conn.sender.send(&msg).await?; conn.sender.send(&msg).await?;
match conn.receiver.receive().await { match conn.receiver.receive().await {
Ok(resp) => println!("Received: {resp}"), Ok(resp) => {
println!("Received: {resp}");
}
Err(e) => eprintln!("Receive error: {e}"), Err(e) => eprintln!("Receive error: {e}"),
} }

View file

@ -8,14 +8,18 @@ pub fn load_client_db(
path: &str, path: &str,
) -> Result<(Arc<Mutex<HashMap<u64, PublicKeyBundle>>>, Arc<Mutex<u64>>), Box<dyn std::error::Error>> ) -> Result<(Arc<Mutex<HashMap<u64, PublicKeyBundle>>>, Arc<Mutex<u64>>), Box<dyn std::error::Error>>
{ {
let clients: Arc<Mutex<HashMap<u64, PublicKeyBundle>>> = let clients_map = match fs::read_to_string(path) {
Arc::new(Mutex::new(if let Ok(data) = fs::read_to_string(path) { Ok(data) => match serde_json::from_str(&data) {
serde_json::from_str(&data).unwrap_or_default() Ok(clients) => clients,
} else { Err(e) => {
HashMap::new() eprintln!("Failed to parse {path}; starting with empty client database: {e}");
})); HashMap::new()
let next_id = Arc::new(Mutex::new( }
clients.lock().unwrap().keys().max().unwrap_or(&999) + 1, },
)); Err(_) => HashMap::new(),
};
let clients: Arc<Mutex<HashMap<u64, PublicKeyBundle>>> = Arc::new(Mutex::new(clients_map));
let next_value = clients.lock().unwrap().keys().max().unwrap_or(&999) + 1;
let next_id = Arc::new(Mutex::new(next_value));
Ok((clients, next_id)) Ok((clients, next_id))
} }

View file

@ -1,5 +1,7 @@
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataTypeId, DataValue, TypeMap}; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataTypeId, DataValue, TypeMap};
use mtp::crypto::{ChaCha20Poly1305, CryptoError, SignatureScheme, SignaturePublicKey, verify_ed25519}; use mtp::crypto::{
ChaCha20Poly1305, CryptoError, SignaturePublicKey, SignatureScheme, verify_ed25519,
};
struct Ed25519Verifier(SignaturePublicKey); struct Ed25519Verifier(SignaturePublicKey);
@ -71,6 +73,7 @@ pub fn process_and_respond(
enc_status = format!("EncryptedPayload decrypted OK ({} entries)", entries.len()); enc_status = format!("EncryptedPayload decrypted OK ({} entries)", entries.len());
} }
} else { } else {
eprintln!(" EncryptedPayload decryption failed");
enc_status = String::from("EncryptedPayload: decryption FAILED"); enc_status = String::from("EncryptedPayload: decryption FAILED");
} }
} }
@ -83,13 +86,14 @@ pub fn process_and_respond(
if dv.verify_into_container(&verifier).is_some() { if dv.verify_into_container(&verifier).is_some() {
if let Some(entries) = dv.as_container() { if let Some(entries) = dv.as_container() {
println!(" Verified SignedPayload: {:?}", entries); println!(" Verified SignedPayload: {:?}", entries);
sig_status = sig_status = format!("SignedPayload verified OK ({} entries)", entries.len());
format!("SignedPayload verified OK ({} entries)", entries.len());
} }
} else { } else {
eprintln!(" SignedPayload verification failed");
sig_status = String::from("SignedPayload: verification FAILED"); sig_status = String::from("SignedPayload: verification FAILED");
} }
} else { } else {
eprintln!(" SignedPayload cannot be verified; no client public key available");
sig_status = String::from("SignedPayload: no client public key available"); sig_status = String::from("SignedPayload: no client public key available");
} }
} }
@ -99,7 +103,9 @@ pub fn process_and_respond(
if let Some(pk_bundle) = client_pk { if let Some(pk_bundle) = client_pk {
let verifier = Ed25519Verifier(pk_bundle.sig_cl_public_key.clone()); let verifier = Ed25519Verifier(pk_bundle.sig_cl_public_key.clone());
let mut dv = secure.clone(); let mut dv = secure.clone();
if dv.decrypt_signed_encrypted_container(&cipher, b"demo-aad").is_some() if dv
.decrypt_signed_encrypted_container(&cipher, b"demo-aad")
.is_some()
&& dv.verify_into_container(&verifier).is_some() && dv.verify_into_container(&verifier).is_some()
{ {
if let Some(entries) = dv.as_container() { if let Some(entries) = dv.as_container() {
@ -110,9 +116,11 @@ pub fn process_and_respond(
); );
} }
} else { } else {
eprintln!(" SecurePayload decryption/verification failed");
secure_status = String::from("SecurePayload: decryption/verification FAILED"); secure_status = String::from("SecurePayload: decryption/verification FAILED");
} }
} else { } else {
eprintln!(" SecurePayload cannot be verified; no client public key available");
secure_status = String::from("SecurePayload: no client public key available"); secure_status = String::from("SecurePayload: no client public key available");
} }
} }

View file

@ -1,9 +1,11 @@
use std::fs; use std::fs;
use mtp::crypto::{Ed25519Signer, Keyring, MlDsaSigner};
use mtp::crypto::kem::HybridKem; use mtp::crypto::kem::HybridKem;
use mtp::crypto::{Ed25519Signer, Keyring, MlDsaSigner};
pub fn load_or_generate_host_keys(path: &str) -> Result<(u64, Keyring), Box<dyn std::error::Error>> { pub fn load_or_generate_host_keys(
path: &str,
) -> Result<(u64, Keyring), Box<dyn std::error::Error>> {
if let Ok(data) = fs::read_to_string(path) { if let Ok(data) = fs::read_to_string(path) {
let json: serde_json::Value = serde_json::from_str(&data)?; let json: serde_json::Value = serde_json::from_str(&data)?;
let hid = json["host_id"].as_u64().unwrap_or(1); let hid = json["host_id"].as_u64().unwrap_or(1);
@ -29,10 +31,7 @@ pub fn load_or_generate_host_keys(path: &str) -> Result<(u64, Keyring), Box<dyn
pub fn export_host_public_keys(host_keyring: &Keyring) -> Result<(), Box<dyn std::error::Error>> { pub fn export_host_public_keys(host_keyring: &Keyring) -> Result<(), Box<dyn std::error::Error>> {
let public_key_bundle_hex = hex::encode(host_keyring.public_key_bundle().as_bytes()); let public_key_bundle_hex = hex::encode(host_keyring.public_key_bundle().as_bytes());
fs::write( fs::write("host_public_key_bundle.hex", &public_key_bundle_hex)?;
"host_public_key_bundle.hex",
&public_key_bundle_hex,
)?;
fs::create_dir_all("web-client/public")?; fs::create_dir_all("web-client/public")?;
fs::write( fs::write(
"web-client/public/host_public_key_bundle.hex", "web-client/public/host_public_key_bundle.hex",

View file

@ -40,7 +40,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let clients_for_get = clients.clone(); let clients_for_get = clients.clone();
let get_existing_user = Box::new(move |id: u64| -> Option<mtp::crypto::PublicKeyBundle> { let get_existing_user = Box::new(move |id: u64| -> Option<mtp::crypto::PublicKeyBundle> {
clients_for_get.lock().unwrap().get(&id).cloned() let result = clients_for_get.lock().unwrap().get(&id).cloned();
if result.is_some() {
println!("Auth lookup: client ID {id} found");
} else {
eprintln!("Auth lookup: unknown client ID {id}");
}
result
}); });
let clients_for_register = clients.clone(); let clients_for_register = clients.clone();
@ -52,7 +58,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let id = *nid; let id = *nid;
*nid += 1; *nid += 1;
db.insert(id, bundle); db.insert(id, bundle);
std::fs::write(&clients_path, serde_json::to_string_pretty(&*db).unwrap()).ok(); match serde_json::to_string_pretty(&*db) {
Ok(json) => match std::fs::write(&clients_path, json) {
Ok(()) => {}
Err(e) => eprintln!("Failed to persist client database to {clients_path}: {e}"),
},
Err(e) => eprintln!("Failed to serialize client database after registering {id}: {e}"),
}
println!("Registered new client with ID: {}", id); println!("Registered new client with ID: {}", id);
id id
}); });

View file

@ -6,6 +6,7 @@ import init, {
ed25519_generate, ed25519_generate,
keyring_from_ed25519, keyring_from_ed25519,
build_demo_message, build_demo_message,
format_frame,
} from "mtp-wasm"; } from "mtp-wasm";
const STATUS = document.getElementById("status")!; const STATUS = document.getElementById("status")!;
@ -57,7 +58,9 @@ function hexToBytes(value: string): Uint8Array {
} }
function saveKeys() { function saveKeys() {
if (!keyringBytes) return; if (!keyringBytes) {
return;
}
let hostPublicKey: number[] | undefined; let hostPublicKey: number[] | undefined;
try { try {
@ -106,7 +109,7 @@ async function loadHostPublicKey() {
HOST_PUBLIC_KEY.value = hostPublicKey; HOST_PUBLIC_KEY.value = hostPublicKey;
saveKeys(); saveKeys();
log("Loaded host public key bundle from public file."); log(`Loaded host public key bundle (${hostPublicKey.length / 2} bytes).`);
} catch { } catch {
// Manual paste still works when the server has not exported the file yet. // Manual paste still works when the server has not exported the file yet.
} }
@ -138,11 +141,11 @@ function createClient(): WasmClient {
(state: number) => (state: number) =>
log(`[state] ${ConnectionState[state] ?? state}`, "state"), log(`[state] ${ConnectionState[state] ?? state}`, "state"),
(data: Uint8Array) => { (data: Uint8Array) => {
const decoder = new TextDecoder(); try {
log( log(`Received: ${format_frame(data)}`, "received");
`[message] ${data.length} bytes: ${decoder.decode(data)}`, } catch (e) {
"received", log(`[message parse error] ${e}`, "error");
); }
}, },
(err: any) => log(`[error] ${err}`, "error"), (err: any) => log(`[error] ${err}`, "error"),
); );
@ -181,7 +184,8 @@ async function connect() {
await loadDevCertHash(); await loadDevCertHash();
const client = createClient(); const client = createClient();
const config = new ConnectionConfig(SERVER_URL.value.trim()); const serverUrl = SERVER_URL.value.trim();
const config = new ConnectionConfig(serverUrl);
if (devCertHash) { if (devCertHash) {
log(`Pinning WebTransport certificate hash: sha-256:${devCertHash}`); log(`Pinning WebTransport certificate hash: sha-256:${devCertHash}`);
config.server_certificate_hashes = [`sha-256:${devCertHash}`]; config.server_certificate_hashes = [`sha-256:${devCertHash}`];
@ -210,6 +214,7 @@ async function connect() {
log("\nSending demo message..."); log("\nSending demo message...");
const frame = build_demo_message(activeClientId, keyringBytes); const frame = build_demo_message(activeClientId, keyringBytes);
log(`Sending: ${format_frame(frame)}`, "state");
await client.send(frame); await client.send(frame);
log(`Sent ${frame.length} bytes`); log(`Sent ${frame.length} bytes`);
@ -236,6 +241,10 @@ GENERATE_KEYPAIR.addEventListener("click", () => {
CONNECT.addEventListener("click", () => { CONNECT.addEventListener("click", () => {
connect().catch((e) => { connect().catch((e) => {
log(`Fatal error: ${e}`, "error"); log(`Fatal error: ${e}`, "error");
log(
`[fatal context] clientId=${clientId?.toString() ?? "unregistered"}, server=${SERVER_URL.value.trim()}, hostPkChars=${HOST_PUBLIC_KEY.value.replace(/[^0-9a-fA-F]/g, "").length}, keyringBytes=${keyringBytes?.length ?? 0}, certHash=${devCertHash || "none"}`,
"error",
);
console.error(e); console.error(e);
}); });
}); });

138
flake.nix
View file

@ -7,18 +7,16 @@
flake-utils.url = "github:numtide/flake-utils"; flake-utils.url = "github:numtide/flake-utils";
}; };
outputs = outputs = {
{ self,
self, nixpkgs,
nixpkgs, rust-overlay,
rust-overlay, flake-utils,
flake-utils, }:
}:
flake-utils.lib.eachDefaultSystem ( flake-utils.lib.eachDefaultSystem (
system: system: let
let overlays = [rust-overlay.overlays.default];
overlays = [ rust-overlay.overlays.default ]; pkgs = import nixpkgs {inherit system overlays;};
pkgs = import nixpkgs { inherit system overlays; };
rustToolchain = pkgs.rust-bin.stable.latest.default.override { rustToolchain = pkgs.rust-bin.stable.latest.default.override {
extensions = [ extensions = [
@ -26,68 +24,80 @@
"clippy" "clippy"
"rustfmt" "rustfmt"
]; ];
targets = [ "wasm32-unknown-unknown" ]; targets = ["wasm32-unknown-unknown"];
}; };
in in {
{ devShells = {
devShells.default = pkgs.mkShell { default = pkgs.mkShell {
name = "mtp-dev"; name = "mtp-dev";
buildInputs = with pkgs; [ buildInputs = with pkgs; [
rustToolchain rustToolchain
wasm-pack wasm-pack
pkg-config pkg-config
openssl openssl
]; ];
MTP_TYPE_MAPS = "${toString ./example-usage/type-maps.yaml}"; MTP_TYPE_MAPS = "${toString ./example-usage/type-maps.yaml}";
shellHook = '' shellHook = ''
cert_dir="$PWD/example-usage/dev-cert" repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
public_dir="$PWD/example-usage/web-client/public" cert_dir="$repo_root/example-usage/dev-cert"
cert_key="$cert_dir/key.pem" public_dir="$repo_root/example-usage/web-client/public"
cert_pem="$cert_dir/cert.pem" cert_key="$cert_dir/key.pem"
cert_hash="$cert_dir/sha256.txt" cert_pem="$cert_dir/cert.pem"
cert_hash="$cert_dir/sha256.txt"
mkdir -p "$cert_dir" mkdir -p "$cert_dir"
mkdir -p "$public_dir" mkdir -p "$public_dir"
if [ ! -f "$cert_key" ] || [ ! -f "$cert_pem" ]; then if [ ! -f "$cert_key" ] || [ ! -f "$cert_pem" ]; then
openssl ecparam -name prime256v1 -genkey -noout -out "$cert_key" openssl ecparam -name prime256v1 -genkey -noout -out "$cert_key"
openssl req -new -x509 \ openssl req -new -x509 \
-sha256 \ -sha256 \
-key "$cert_key" \ -key "$cert_key" \
-out "$cert_pem" \ -out "$cert_pem" \
-days 13 \ -days 13 \
-subj "/CN=localhost" \ -subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1" \ -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" \
-addext "basicConstraints=critical,CA:FALSE" \ -addext "basicConstraints=critical,CA:FALSE" \
-addext "keyUsage=critical,digitalSignature" \ -addext "keyUsage=critical,digitalSignature" \
-addext "extendedKeyUsage=serverAuth" -addext "extendedKeyUsage=serverAuth"
cert_status="generated" cert_status="generated"
else else
cert_status="cached" cert_status="cached"
fi fi
openssl x509 -in "$cert_pem" -outform der \ openssl x509 -in "$cert_pem" -outform der \
| openssl dgst -sha256 -binary \ | openssl dgst -sha256 -binary \
| od -An -tx1 -v \ | od -An -tx1 -v \
| tr -d ' \n' > "$cert_hash" | tr -d ' \n' > "$cert_hash"
cp "$cert_hash" "$public_dir/mtp_dev_cert_hash.txt" cp "$cert_hash" "$public_dir/mtp_dev_cert_hash.txt"
export MTP_DEV_CERT="$cert_pem" export MTP_DEV_CERT="$cert_pem"
export MTP_DEV_KEY="$cert_key" export MTP_DEV_KEY="$cert_key"
export MTP_DEV_CERT_HASH="$(cat "$cert_hash")" export MTP_DEV_CERT_HASH="$(cat "$cert_hash")"
echo "MTP dev shell" echo "MTP dev shell"
echo " rustc : $(rustc --version)" echo " rustc : $(rustc --version)"
echo " cargo : $(cargo --version)" echo " cargo : $(cargo --version)"
echo " wasm-pack : $(wasm-pack --version 2>/dev/null || echo 'not found')" echo " wasm-pack : $(wasm-pack --version 2>/dev/null || echo 'not found')"
echo " targets: $(rustc --print target-list | grep wasm32 | tr '\n' ' ')" echo " targets: $(rustc --print target-list | grep wasm32 | tr '\n' ' ')"
echo " MTP_TYPE_MAPS = $MTP_TYPE_MAPS" echo " MTP_TYPE_MAPS = $MTP_TYPE_MAPS"
echo " dev cert: $MTP_DEV_CERT ($cert_status)" echo " dev cert: $MTP_DEV_CERT ($cert_status)"
echo " cert sha256: $MTP_DEV_CERT_HASH" echo " cert sha256: $MTP_DEV_CERT_HASH"
''; '';
};
autoStart = pkgs.mkShell {
name = "autoStart";
buildInputs = with pkgs; [
mprocs
];
shellHook = ''
nix develop --command bash -c "mprocs 'cd wasm && wasm-pack build --target web --out-dir pkg && cd ../example-usage/web-client && bun dev' 'cargo b && cd example-usage && cargo r --bin server'"
exit
'';
};
}; };
# Ad-hoc WASM build using wasm-pack # Ad-hoc WASM build using wasm-pack

View file

@ -176,7 +176,9 @@ impl MTPHost {
_ => vec![], _ => vec![],
}; };
let (assigned_id, client_bundle) = if msg.get_type() == mtp_codec::CommunicationTypeId(15) { let (assigned_id, client_bundle, response_type) = if msg.get_type()
== mtp_codec::CommunicationTypeId(15)
{
// LOGIN // LOGIN
let cid = match msg.get_data(DataTypeId(6)) { let cid = match msg.get_data(DataTypeId(6)) {
DataValue::UnsignedNumber(n) => *n as u64, DataValue::UnsignedNumber(n) => *n as u64,
@ -237,7 +239,11 @@ impl MTPHost {
} }
/* ===== End Signature ===== */ /* ===== End Signature ===== */
(cid, bundle) (
cid,
bundle,
mtp_codec::CommunicationType::IdentificationResponse,
)
} else if msg.get_type() == mtp_codec::CommunicationTypeId(17) { } else if msg.get_type() == mtp_codec::CommunicationTypeId(17) {
// REGISTER // REGISTER
let bundle = match msg.get_data(DataTypeId(9)) { let bundle = match msg.get_data(DataTypeId(9)) {
@ -284,7 +290,11 @@ impl MTPHost {
/* ===== End Signature ===== */ /* ===== End Signature ===== */
let new_id = (self.config.complete_register)(bundle.clone()); let new_id = (self.config.complete_register)(bundle.clone());
(new_id, bundle) (
new_id,
bundle,
mtp_codec::CommunicationType::RegisterResponse,
)
} else { } else {
sender.close(); sender.close();
return None; return None;
@ -304,16 +314,15 @@ impl MTPHost {
/* ===== Signature ===== */ /* ===== Signature ===== */
let host_sig = host_signer.sign(&host_sig_payload).ok()?; let host_sig = host_signer.sign(&host_sig_payload).ok()?;
let mut response = let mut response = CommunicationValue::new(response_type)
CommunicationValue::new(mtp_codec::CommunicationType::IdentificationResponse) .add_typed_default(DataType::Connected, DataValue::BoolTrue)
.add_typed_default(DataType::Connected, DataValue::BoolTrue) .add_typed_default(
.add_typed_default( DataType::ClientNonce,
DataType::ClientNonce, DataValue::UnsignedNumber(client_nonce),
DataValue::UnsignedNumber(client_nonce), )
) .add_typed_default(DataType::Timestamp, DataValue::UnsignedNumber(new_nonce))
.add_typed_default(DataType::Timestamp, DataValue::UnsignedNumber(new_nonce)) .add_typed_default(DataType::Signature, DataValue::Bytes(host_sig))
.add_typed_default(DataType::Signature, DataValue::Bytes(host_sig)) .add_typed_default(DataType::Id, DataValue::UnsignedNumber(assigned_id as u128));
.add_typed_default(DataType::Id, DataValue::UnsignedNumber(assigned_id as u128));
if !self if !self
.config .config
@ -335,6 +344,7 @@ impl MTPHost {
/* ===== End Signature ===== */ /* ===== End Signature ===== */
sender.send(&response).await.ok()?; sender.send(&response).await.ok()?;
sender.finish_stream().await.ok()?;
// 3. Version negotiation // 3. Version negotiation
let negotiated = self.registry.negotiate(&[client_version])?; let negotiated = self.registry.negotiate(&[client_version])?;

View file

@ -61,7 +61,7 @@ enum ReceivedFrame {
pub struct Sender { pub struct Sender {
send_guard: Mutex<()>, send_guard: Mutex<()>,
stream_guard: Mutex<Option<wtransport::SendStream>>, stream_guard: Arc<Mutex<Option<wtransport::SendStream>>>,
handle: Arc<ConnectionHandle>, handle: Arc<ConnectionHandle>,
connection: Connection, connection: Connection,
policy: Arc<Policy>, policy: Arc<Policy>,
@ -71,7 +71,7 @@ impl Sender {
pub fn new(connection: Connection, handle: Arc<ConnectionHandle>, policy: Arc<Policy>) -> Self { pub fn new(connection: Connection, handle: Arc<ConnectionHandle>, policy: Arc<Policy>) -> Self {
Self { Self {
send_guard: Mutex::new(()), send_guard: Mutex::new(()),
stream_guard: Mutex::new(None), stream_guard: Arc::new(Mutex::new(None)),
handle, handle,
connection, connection,
policy, policy,
@ -275,6 +275,18 @@ impl Sender {
} }
} }
pub async fn finish_stream(&self) -> Result<(), CommunicationError> {
let _send_lock = self.send_guard.lock().await;
let mut stream_opt = self.stream_guard.lock().await;
if let Some(mut stream) = stream_opt.take() {
timeout(self.policy.write_timeout, stream.finish())
.await
.map_err(|_| CommunicationError::StreamError)?
.map_err(|_| CommunicationError::StreamError)?;
}
Ok(())
}
pub fn handle(&self) -> &Arc<ConnectionHandle> { pub fn handle(&self) -> &Arc<ConnectionHandle> {
&self.handle &self.handle
} }
@ -283,6 +295,7 @@ impl Sender {
let connection = self.connection.clone(); let connection = self.connection.clone();
let handle = self.handle.clone(); let handle = self.handle.clone();
let policy = self.policy.clone(); let policy = self.policy.clone();
let stream_guard = self.stream_guard.clone();
tokio::spawn(async move { tokio::spawn(async move {
if connection.quic_connection().close_reason().is_some() || handle.is_closed() { if connection.quic_connection().close_reason().is_some() || handle.is_closed() {
@ -290,6 +303,14 @@ impl Sender {
return; return;
} }
if let Some(mut stream) = stream_guard.lock().await.take() {
match timeout(policy.write_timeout, stream.finish()).await {
Ok(Ok(())) => {}
Ok(Err(e)) => log::warn!("[Sender] persistent stream finish failed: {e}"),
Err(_) => log::warn!("[Sender] persistent stream finish timed out"),
}
}
let _ = Self::send_close_frame(&connection, &policy).await; let _ = Self::send_close_frame(&connection, &policy).await;
handle.close(Some(CommunicationError::StreamClosed)); handle.close(Some(CommunicationError::StreamClosed));

View file

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

View file

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

View file

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