From 298253d6fa0f8a06a24d970eaece852205f102dc Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:19:55 +0200 Subject: [PATCH] WASM --- Cargo.toml | 1 + README.md | 13 - common/Cargo.toml | 2 + common/src/lib.rs | 169 +++- crypto/Cargo.toml | 1 + example-usage/.gitignore | 1 + example-usage/client/src/auth.rs | 52 + example-usage/client/src/main.rs | 106 +- example-usage/client/src/messages.rs | 81 ++ example-usage/server/src/clients.rs | 21 + example-usage/server/src/handlers.rs | 139 +++ example-usage/server/src/keys.rs | 40 + example-usage/server/src/main.rs | 142 +-- example-usage/server/src/tls.rs | 25 + example-usage/type-maps.yaml | 14 + example-usage/web-client/index.html | 20 + example-usage/web-client/package-lock.json | 1041 ++++++++++++++++++++ example-usage/web-client/package.json | 15 + example-usage/web-client/src/main.ts | 110 +++ example-usage/web-client/src/messages.ts | 26 + example-usage/web-client/tsconfig.json | 18 + example-usage/web-client/vite.config.ts | 10 + flake.nix | 68 ++ wasm/.cargo/config.toml | 2 + wasm/Cargo.toml | 40 + wasm/src/client.rs | 316 ++++++ wasm/src/crypto.rs | 263 +++++ wasm/src/error.rs | 17 + wasm/src/lib.rs | 13 + wasm/src/message.rs | 135 +++ wasm/src/transport.rs | 274 ++++++ 31 files changed, 2899 insertions(+), 276 deletions(-) create mode 100644 example-usage/client/src/auth.rs create mode 100644 example-usage/client/src/messages.rs create mode 100644 example-usage/server/src/clients.rs create mode 100644 example-usage/server/src/handlers.rs create mode 100644 example-usage/server/src/keys.rs create mode 100644 example-usage/server/src/tls.rs create mode 100644 example-usage/web-client/index.html create mode 100644 example-usage/web-client/package-lock.json create mode 100644 example-usage/web-client/package.json create mode 100644 example-usage/web-client/src/main.ts create mode 100644 example-usage/web-client/src/messages.ts create mode 100644 example-usage/web-client/tsconfig.json create mode 100644 example-usage/web-client/vite.config.ts create mode 100644 flake.nix create mode 100644 wasm/.cargo/config.toml create mode 100644 wasm/Cargo.toml create mode 100644 wasm/src/client.rs create mode 100644 wasm/src/crypto.rs create mode 100644 wasm/src/error.rs create mode 100644 wasm/src/lib.rs create mode 100644 wasm/src/message.rs create mode 100644 wasm/src/transport.rs diff --git a/Cargo.toml b/Cargo.toml index b09bee7..255d8e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "transport", "host", "client", + "wasm", ] resolver = "3" diff --git a/README.md b/README.md index 3c4b139..676b44e 100644 --- a/README.md +++ b/README.md @@ -86,16 +86,3 @@ The type-map build script reads a YAML configuration to generate `CommunicationT | sha2 | No | Standard construction; widely reviewed | | zeroize | No | Simple; widely used | | mlkem-tls | No | mlkem-rs backend unaudited | - -## Test Coverage - -| Crate | Tests | Notes | -| -------------- | ----- | ------------------------------------------ | -| common | 7 | Error type Display, Clone, format | -| type-map | 3 | Registry builtin, negotiate, latest | -| codec | 32 | DataValue roundtrip, accessors, Display, Hash, base64; CommunicationValue frame encode/decode | -| crypto | 20 | AEAD, signatures, KEM, KDF, hash, key types, multi-encrypt | -| transport | 12 | ConnectionHandle state transitions; Policy defaults; SendMode | -| host | 3 | Version extraction from messages | -| client | 7 | ClientConfig; AuthState derives | -| **Total** | **84** | | diff --git a/common/Cargo.toml b/common/Cargo.toml index ee28f47..fe1a703 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -5,6 +5,8 @@ edition = "2024" [dependencies] thiserror = "2.0.18" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] wtransport = { version = "0.7.1", default-features = false, features = [ "aws-lc-rs", "quinn", diff --git a/common/src/lib.rs b/common/src/lib.rs index 5cc7acc..7637d37 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -1,7 +1,5 @@ use thiserror::Error; - - #[derive(Clone, Debug, Error, PartialEq, Eq)] pub enum CodecError { #[error("Unknown version")] @@ -40,43 +38,19 @@ mod tests { let b = a.clone(); assert_eq!(a, b); } - - #[test] - fn test_communication_error_display() { - assert_eq!(format!("{}", CommunicationError::UseAfterClosed), "Use after Closed"); - assert_eq!(format!("{}", CommunicationError::StreamClosed), "Stream Closed"); - assert_eq!(format!("{}", CommunicationError::StreamError), "Stream Error"); - } - - #[test] - fn test_communication_error_clone() { - let a = CommunicationError::UseAfterClosed; - let b = a.clone(); - assert_eq!(format!("{:?}", a), format!("{:?}", b)); - } - - #[test] - fn test_communication_error_authentication_failed() { - let e = CommunicationError::AuthenticationFailed("bad key".into()); - assert!(format!("{}", e).contains("bad key")); - } - - #[test] - fn test_communication_error_other() { - let e = CommunicationError::Other("custom error".into()); - assert!(format!("{}", e).contains("custom error")); - } } +// =========================================================================== +// CommunicationError +// +// On native targets the full variant set (including quinn / wtransport +// wrappers) is available. On WASM only the transport-independent subset is +// compiled. +// =========================================================================== + +#[cfg(not(target_arch = "wasm32"))] #[derive(Debug, Error, Clone)] pub enum CommunicationError { - /* ====== NOTE on PartialEq ====== - * Manual PartialEq implementation below compares some inner - * values (String, Vec) structurally and others only by - * variant discriminant (types from quinn / wtransport that - * don't themselves implement PartialEq). Keep this impl in - * sync if variants are added or changed. - */ #[error("Use after Closed")] UseAfterClosed, @@ -147,12 +121,58 @@ pub enum CommunicationError { Other(String), } -/* ===== Manual PartialEq ===== - * - * Compare by variant, and structurally for String-holding variants. - * Third-party error types (quinn, wtransport) are compared only by - * discriminant since they don't implement PartialEq themselves. - */ +#[cfg(target_arch = "wasm32")] +#[derive(Debug, Error, Clone)] +pub enum CommunicationError { + #[error("Use after Closed")] + UseAfterClosed, + + #[error("Connection closed by local shutdown")] + ClosedLocally, + + #[error("Connection closed by peer")] + ClosedByPeer, + + #[error("Connection terminated unexpectedly")] + ConnectionLost, + + #[error("ParseCommunicationValue error")] + ParseCommunicationValue, + + #[error("Parse Certificate error")] + CertificateParseFailed, + + #[error("Loading Certificate error")] + CertificateLoadFailed, + + #[error("Parse error: {0}")] + ParseError(String), + + #[error("Connecting error: {0}")] + ConnectingError(String), + + #[error("Message too large")] + MessageTooLarge, + + #[error("Stream Closed")] + StreamClosed, + + #[error("Stream Error")] + StreamError, + + #[error("Crypto Provider Install Error")] + CryptoProviderInstallFailed, + + #[error("Authentication failed: {0}")] + AuthenticationFailed(String), + + #[error("Other: {0}")] + Other(String), +} + +// ---- manual PartialEq (quinn / wtransport types don't impl PartialEq) ---- + +#[cfg(not(target_arch = "wasm32"))] impl PartialEq for CommunicationError { fn eq(&self, other: &Self) -> bool { match (self, other) { @@ -184,4 +204,71 @@ impl PartialEq for CommunicationError { } } +#[cfg(target_arch = "wasm32")] +impl PartialEq for CommunicationError { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::UseAfterClosed, Self::UseAfterClosed) => true, + (Self::ClosedLocally, Self::ClosedLocally) => true, + (Self::ClosedByPeer, Self::ClosedByPeer) => true, + (Self::ConnectionLost, Self::ConnectionLost) => true, + (Self::ParseCommunicationValue, Self::ParseCommunicationValue) => true, + (Self::CertificateParseFailed, Self::CertificateParseFailed) => true, + (Self::CertificateLoadFailed, Self::CertificateLoadFailed) => true, + (Self::ParseError(a), Self::ParseError(b)) => a == b, + (Self::ConnectingError(a), Self::ConnectingError(b)) => a == b, + (Self::MessageTooLarge, Self::MessageTooLarge) => true, + (Self::StreamClosed, Self::StreamClosed) => true, + (Self::StreamError, Self::StreamError) => true, + (Self::CryptoProviderInstallFailed, Self::CryptoProviderInstallFailed) => true, + (Self::AuthenticationFailed(a), Self::AuthenticationFailed(b)) => a == b, + (Self::Other(a), Self::Other(b)) => a == b, + _ => false, + } + } +} + +#[cfg(not(target_arch = "wasm32"))] impl Eq for CommunicationError {} + +#[cfg(target_arch = "wasm32")] +impl Eq for CommunicationError {} + +/* ================================ TESTS ================================ */ +#[cfg(test)] +mod communication_error_tests { + use super::*; + + #[test] + fn test_communication_error_display() { + assert_eq!(format!("{}", CommunicationError::UseAfterClosed), "Use after Closed"); + assert_eq!(format!("{}", CommunicationError::StreamClosed), "Stream Closed"); + assert_eq!(format!("{}", CommunicationError::StreamError), "Stream Error"); + } + + #[test] + fn test_communication_error_clone() { + let a = CommunicationError::UseAfterClosed; + let b = a.clone(); + assert_eq!(format!("{:?}", a), format!("{:?}", b)); + } + + #[test] + fn test_communication_error_authentication_failed() { + let e = CommunicationError::AuthenticationFailed("bad key".into()); + assert!(format!("{}", e).contains("bad key")); + } + + #[test] + fn test_communication_error_other() { + let e = CommunicationError::Other("custom error".into()); + assert!(format!("{}", e).contains("custom error")); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn test_connecting_error_display() { + let e = CommunicationError::ConnectingError("refused".into()); + assert!(format!("{}", e).contains("refused")); + } +} diff --git a/crypto/Cargo.toml b/crypto/Cargo.toml index 6a6cbd4..c46618c 100644 --- a/crypto/Cargo.toml +++ b/crypto/Cargo.toml @@ -26,3 +26,4 @@ default = ["chacha20poly1305", "ed25519-dalek", "hkdf", "sha2", "ml-dsa"] full = ["default", "aes-gcm"] pqc = ["mlkem-tls", "ml-dsa"] serde = ["dep:serde"] +wasm = ["getrandom/wasm_js"] diff --git a/example-usage/.gitignore b/example-usage/.gitignore index 11b7871..006bccb 100644 --- a/example-usage/.gitignore +++ b/example-usage/.gitignore @@ -4,3 +4,4 @@ host_sig_pk.bin host_sig_pq_pk.bin host_enc_kem_pk.bin clients.json +web-client/node_modules diff --git a/example-usage/client/src/auth.rs b/example-usage/client/src/auth.rs new file mode 100644 index 0000000..5a4cf35 --- /dev/null +++ b/example-usage/client/src/auth.rs @@ -0,0 +1,52 @@ +use std::fs; + +use mtp::client::{ClientConfig, MTPClient, MTPConnection}; +use mtp::crypto::{Ed25519Signer, Keyring, MlDsaSigner, PublicKeyBundle}; + +pub async fn connect_or_register( + mut config: ClientConfig, + host_public_key: PublicKeyBundle, + client_key_path: &str, +) -> Result<(MTPConnection, Keyring), Box> { + if let Ok(data) = fs::read_to_string(client_key_path) { + let json: serde_json::Value = serde_json::from_str(&data)?; + let client_id = json["client_id"].as_u64().expect("Invalid client_id"); + let keyring = Keyring::from_bytes(&hex::decode( + json["keyring"].as_str().expect("Missing keyring"), + )?)?; + + println!("Loaded client keys (ID: {})", client_id); + + config.client_id = client_id; + let conn = MTPClient::auth_connect(config, &keyring, &host_public_key).await?; + println!("Authenticated (version {})", conn.version); + Ok((conn, keyring)) + } else { + println!("No existing keys found: registering new client"); + + let (_ed_signer, sig_sk, sig_pk) = Ed25519Signer::generate(); + let (_pq_signer, sig_pq_sk, sig_pq_pk) = MlDsaSigner::generate(); + let keyring = Keyring::new( + mtp::crypto::KemPublicKey::new(vec![]), + mtp::crypto::KemPrivateKey::new(vec![]), + sig_pq_pk, + sig_pq_sk, + sig_pk, + sig_sk, + ); + + let keyring_bytes = keyring.to_bytes(); + let conn = MTPClient::auth_register(config, &keyring, &host_public_key).await?; + println!("Registered with ID: {}", conn.client_id); + + let json = serde_json::json!({ + "client_id": conn.client_id, + "keyring": hex::encode(&keyring_bytes), + }); + fs::write(client_key_path, serde_json::to_string_pretty(&json)?)?; + println!("Saved client keys -> {client_key_path}"); + + let keyring = Keyring::from_bytes(&keyring_bytes)?; + Ok((conn, keyring)) + } +} diff --git a/example-usage/client/src/main.rs b/example-usage/client/src/main.rs index fff8006..5f3c9b4 100644 --- a/example-usage/client/src/main.rs +++ b/example-usage/client/src/main.rs @@ -1,105 +1,22 @@ +mod auth; +mod messages; + use std::fs; -use mtp::client::{ClientConfig, MTPClient, MTPConnection}; -use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; -use mtp::crypto::{ - Ed25519Signer, KemPublicKey, Keyring, MlDsaSigner, PublicKeyBundle, SignaturePqPublicKey, - SignaturePublicKey, -}; - -/* - * Either load a saved keyring from disk (login) or generate a fresh - * keypair and register with the server. Returns the established - * connection and the local keyring. - */ -async fn connect_or_register( - mut config: ClientConfig, - host_public_key: PublicKeyBundle, - client_key_path: &str, -) -> Result<(MTPConnection, Keyring), Box> { - if let Ok(data) = fs::read_to_string(client_key_path) { - let json: serde_json::Value = serde_json::from_str(&data)?; - let client_id = json["client_id"].as_u64().expect("Invalid client_id"); - let keyring = Keyring::from_bytes(&hex::decode( - json["keyring"].as_str().expect("Missing keyring"), - )?)?; - - println!("Loaded client keys (ID: {})", client_id); - - config.client_id = client_id; - let conn = MTPClient::auth_connect(config, &keyring, &host_public_key).await?; - println!("Authenticated (version {})", conn.version); - Ok((conn, keyring)) - } else { - println!("No existing keys found: registering new client"); - - let (_ed_signer, sig_sk, sig_pk) = Ed25519Signer::generate(); - let (_pq_signer, sig_pq_sk, sig_pq_pk) = MlDsaSigner::generate(); - let keyring = Keyring::new( - mtp::crypto::KemPublicKey::new(vec![]), - mtp::crypto::KemPrivateKey::new(vec![]), - sig_pq_pk, - sig_pq_sk, - sig_pk, - sig_sk, - ); - - let keyring_bytes = keyring.to_bytes(); - let conn = MTPClient::auth_register(config, &keyring, &host_public_key).await?; - println!("Registered with ID: {}", conn.client_id); - - let json = serde_json::json!({ - "client_id": conn.client_id, - "keyring": hex::encode(&keyring_bytes), - }); - fs::write(client_key_path, serde_json::to_string_pretty(&json)?)?; - println!("Saved client keys -> {client_key_path}"); - - let keyring = Keyring::from_bytes(&keyring_bytes)?; - Ok((conn, keyring)) - } -} - -/* - * Send a Ping greeting with description, timestamp, and data fields, - * then print the server's Pong response. - */ -async fn send_ping_and_receive(conn: &MTPConnection) -> Result<(), Box> { - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - - let greeting = CommunicationValue::new(CommunicationType::Ping) - .add_typed_default(DataType::Description, DataValue::Str("Hello MTP!".into())) - .add_typed_default( - DataType::Timestamp, - DataValue::UnsignedNumber(timestamp as u128), - ) - .add_typed_default(DataType::Data, DataValue::UnsignedNumber(42)) - .with_sender(conn.client_id); - - println!("Sending: {greeting}"); - conn.sender.send(&greeting).await?; - - match conn.receiver.receive().await { - Ok(msg) => println!("Received: {msg}"), - Err(e) => eprintln!("Receive error: {e}"), - } - - conn.sender.close(); - Ok(()) -} +use mtp::client::ClientConfig; +use mtp::crypto::{KemPublicKey, PublicKeyBundle, SignaturePqPublicKey, SignaturePublicKey}; #[tokio::main] async fn main() -> Result<(), Box> { let cert_pem = fs::read("server.pem").expect("Missing server.pem: run server first"); let host_public_key = PublicKeyBundle::new( KemPublicKey::new( - fs::read("host_enc_kem_pk.bin").expect("Missing host_enc_kem_pk.bin: run server first"), + fs::read("host_enc_kem_pk.bin") + .expect("Missing host_enc_kem_pk.bin: run server first"), ), SignaturePqPublicKey::new( - fs::read("host_sig_pq_pk.bin").expect("Missing host_sig_pq_pk.bin: run server first"), + fs::read("host_sig_pq_pk.bin") + .expect("Missing host_sig_pq_pk.bin: run server first"), ), SignaturePublicKey::new( fs::read("host_sig_pk.bin").expect("Missing host_sig_pk.bin: run server first"), @@ -114,8 +31,9 @@ async fn main() -> Result<(), Box> { client_id: 0, }; - let (conn, _keyring) = connect_or_register(config, host_public_key, "client_keys.json").await?; - send_ping_and_receive(&conn).await?; + let (conn, keyring) = + auth::connect_or_register(config, host_public_key, "client_keys.json").await?; + messages::send_and_receive(&conn, &keyring).await?; println!("\nDone"); Ok(()) diff --git a/example-usage/client/src/messages.rs b/example-usage/client/src/messages.rs new file mode 100644 index 0000000..1c17e5a --- /dev/null +++ b/example-usage/client/src/messages.rs @@ -0,0 +1,81 @@ +use mtp::client::MTPConnection; +use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataTypeId, DataValue}; +use mtp::crypto::{ChaCha20Poly1305, Ed25519Signer, Keyring, SigAlgorithm}; + +fn derive_demo_key() -> [u8; 32] { + mtp::crypto::derive_encryption_key( + b"MTP-demo-shared-secret", + b"MTP-demo-salt", + b"encrypted-container-demo", + ) + .expect("key derivation must succeed") +} + +pub fn build_demo_message(client_id: u64, keyring: &Keyring) -> CommunicationValue { + let cipher = ChaCha20Poly1305::new(derive_demo_key()); + let signer = Ed25519Signer::new(&keyring.sig_cl_secret_key) + .expect("Ed25519 signer from keyring"); + + let inner_enc = DataValue::Container(vec![ + (DataTypeId(1), DataValue::Str("secret inner data".into())), + (DataTypeId(2), DataValue::UnsignedNumber(42)), + ]); + let mut dv_enc = inner_enc; + dv_enc.encrypt_container(&cipher, b"demo-aad"); + + let inner_sig = DataValue::Container(vec![ + (DataTypeId(1), DataValue::Str("signed by client".into())), + (DataTypeId(2), DataValue::UnsignedNumber(99)), + ]); + let mut dv_sig = inner_sig; + dv_sig.sign_container(SigAlgorithm::ED25519, &signer); + + let inner_sec = DataValue::Container(vec![ + (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"); + + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + CommunicationValue::new(CommunicationType::Ping) + .add_typed_default(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::Flags, DataValue::BoolTrue) + .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::Items, + DataValue::Array(vec![ + DataValue::Str("alpha".into()), + DataValue::Str("beta".into()), + DataValue::Str("gamma".into()), + ]), + ) + .add_typed_default(DataType::EncryptedPayload, dv_enc) + .add_typed_default(DataType::SignedPayload, dv_sig) + .add_typed_default(DataType::SecurePayload, dv_sec) + .with_sender(client_id) +} + +pub async fn send_and_receive( + conn: &MTPConnection, + keyring: &Keyring, +) -> Result<(), Box> { + let msg = build_demo_message(conn.client_id, keyring); + println!("Sending: {msg}"); + conn.sender.send(&msg).await?; + + match conn.receiver.receive().await { + Ok(resp) => println!("Received: {resp}"), + Err(e) => eprintln!("Receive error: {e}"), + } + + conn.sender.close(); + Ok(()) +} diff --git a/example-usage/server/src/clients.rs b/example-usage/server/src/clients.rs new file mode 100644 index 0000000..3417604 --- /dev/null +++ b/example-usage/server/src/clients.rs @@ -0,0 +1,21 @@ +use std::collections::HashMap; +use std::fs; +use std::sync::{Arc, Mutex}; + +use mtp::crypto::PublicKeyBundle; + +pub fn load_client_db( + path: &str, +) -> Result<(Arc>>, Arc>), Box> +{ + let clients: Arc>> = + Arc::new(Mutex::new(if let Ok(data) = fs::read_to_string(path) { + serde_json::from_str(&data).unwrap_or_default() + } else { + HashMap::new() + })); + let next_id = Arc::new(Mutex::new( + clients.lock().unwrap().keys().max().unwrap_or(&999) + 1, + )); + Ok((clients, next_id)) +} diff --git a/example-usage/server/src/handlers.rs b/example-usage/server/src/handlers.rs new file mode 100644 index 0000000..768293b --- /dev/null +++ b/example-usage/server/src/handlers.rs @@ -0,0 +1,139 @@ +use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataTypeId, DataValue, TypeMap}; +use mtp::crypto::{ChaCha20Poly1305, CryptoError, SignatureScheme, SignaturePublicKey, verify_ed25519}; + +struct Ed25519Verifier(SignaturePublicKey); + +impl SignatureScheme for Ed25519Verifier { + fn sign(&self, _msg: &[u8]) -> Result, CryptoError> { + Err(CryptoError::SigningFailed) + } + fn verify(&self, msg: &[u8], signature: &[u8]) -> Result<(), CryptoError> { + verify_ed25519(&self.0, msg, signature) + } +} + +fn derive_demo_key() -> [u8; 32] { + mtp::crypto::derive_encryption_key( + b"MTP-demo-shared-secret", + b"MTP-demo-salt", + b"encrypted-container-demo", + ) + .expect("key derivation must succeed") +} + +pub fn process_and_respond( + msg: &CommunicationValue, + tm: &TypeMap, + client_pk: Option<&mtp::crypto::PublicKeyBundle>, +) -> CommunicationValue { + let desc_id = DataTypeId(tm.data_id_enum(DataType::Description).unwrap()); + let ts_id = DataTypeId(tm.data_id_enum(DataType::Timestamp).unwrap()); + let data_id = DataTypeId(tm.data_id_enum(DataType::Data).unwrap()); + let flags_id = DataTypeId(tm.data_id_enum(DataType::Flags).unwrap()); + let value_id = DataTypeId(tm.data_id_enum(DataType::Value).unwrap()); + let bin_id = DataTypeId(tm.data_id_enum(DataType::BinaryData).unwrap()); + let items_id = DataTypeId(tm.data_id_enum(DataType::Items).unwrap()); + let enc_id = DataTypeId(tm.data_id_enum(DataType::EncryptedPayload).unwrap()); + let sig_id = DataTypeId(tm.data_id_enum(DataType::SignedPayload).unwrap()); + let secure_id = DataTypeId(tm.data_id_enum(DataType::SecurePayload).unwrap()); + + let description = msg.get_data(desc_id); + let timestamp = msg.get_data(ts_id); + let data = msg.get_data(data_id); + let flags = msg.get_data(flags_id); + let value = msg.get_data(value_id); + let binary = msg.get_data(bin_id); + let items = msg.get_data(items_id); + + println!( + " Description: {}", + description.as_str().unwrap_or("(missing)") + ); + println!(" Timestamp: {:?}", timestamp.as_unsigned_number()); + println!(" Data: {}", data.as_str().unwrap_or("(missing)")); + println!(" Flags: {:?}", flags.as_bool()); + println!(" Value: {:?}", value.as_float()); + println!(" Binary: {:?}", binary.as_bytes()); + println!(" Items: {:?}", items.as_array()); + + let cipher = ChaCha20Poly1305::new(derive_demo_key()); + + let mut enc_status = String::from("EncryptedPayload: not present"); + let mut sig_status = String::from("SignedPayload: not present"); + let mut secure_status = String::from("SecurePayload: not present"); + + let enc = msg.get_data(enc_id); + if matches!(enc, DataValue::EncryptedContainer(_)) { + let mut dv = enc.clone(); + if dv.decrypt_into_container(&cipher, b"demo-aad").is_some() { + if let Some(entries) = dv.as_container() { + println!(" Decrypted EncryptedPayload: {:?}", entries); + enc_status = format!("EncryptedPayload decrypted OK ({} entries)", entries.len()); + } + } else { + enc_status = String::from("EncryptedPayload: decryption FAILED"); + } + } + + let sig = msg.get_data(sig_id); + if matches!(sig, DataValue::SignedContainer(_)) { + if let Some(pk_bundle) = client_pk { + let verifier = Ed25519Verifier(pk_bundle.sig_cl_public_key.clone()); + let mut dv = sig.clone(); + if dv.verify_into_container(&verifier).is_some() { + if let Some(entries) = dv.as_container() { + println!(" Verified SignedPayload: {:?}", entries); + sig_status = + format!("SignedPayload verified OK ({} entries)", entries.len()); + } + } else { + sig_status = String::from("SignedPayload: verification FAILED"); + } + } else { + sig_status = String::from("SignedPayload: no client public key available"); + } + } + + let secure = msg.get_data(secure_id); + if matches!(secure, DataValue::SignedEncryptedContainer(_)) { + if let Some(pk_bundle) = client_pk { + let verifier = Ed25519Verifier(pk_bundle.sig_cl_public_key.clone()); + let mut dv = secure.clone(); + if dv.decrypt_signed_encrypted_container(&cipher, b"demo-aad").is_some() + && dv.verify_into_container(&verifier).is_some() + { + if let Some(entries) = dv.as_container() { + println!(" Verified SecurePayload: {:?}", entries); + secure_status = format!( + "SecurePayload decrypted+verified OK ({} entries)", + entries.len() + ); + } + } else { + secure_status = String::from("SecurePayload: decryption/verification FAILED"); + } + } else { + secure_status = String::from("SecurePayload: no client public key available"); + } + } + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + CommunicationValue::from_comm(CommunicationType::Pong, tm) + .add_data(desc_id, description.clone()) + .add_data(ts_id, DataValue::UnsignedNumber(now as u128)) + .add_data( + data_id, + DataValue::Str(format!( + "{}. {}. {}.", + enc_status, sig_status, secure_status + )), + ) + .add_data(flags_id, flags.clone()) + .add_data(value_id, value.clone()) + .add_data(bin_id, binary.clone()) + .add_data(items_id, items.clone()) +} diff --git a/example-usage/server/src/keys.rs b/example-usage/server/src/keys.rs new file mode 100644 index 0000000..86ac9c2 --- /dev/null +++ b/example-usage/server/src/keys.rs @@ -0,0 +1,40 @@ +use std::fs; + +use mtp::crypto::{Ed25519Signer, Keyring, MlDsaSigner}; +use mtp::crypto::kem::HybridKem; + +pub fn load_or_generate_host_keys(path: &str) -> Result<(u64, Keyring), Box> { + if let Ok(data) = fs::read_to_string(path) { + let json: serde_json::Value = serde_json::from_str(&data)?; + let hid = json["host_id"].as_u64().unwrap_or(1); + let keyring = Keyring::from_bytes(&hex::decode(json["keyring"].as_str().unwrap())?)?; + println!("Loaded host keys (ID: {})", hid); + return Ok((hid, keyring)); + } + + let (_ed_signer, sig_sk, sig_pk) = Ed25519Signer::generate(); + let (_pq_signer, sig_pq_sk, sig_pq_pk) = MlDsaSigner::generate(); + let (kem_sk, kem_pk) = HybridKem::generate_keypair(); + let keyring = Keyring::new(kem_pk, kem_sk, sig_pq_pk, sig_pq_sk, sig_pk, sig_sk); + + let json = serde_json::json!({ + "host_id": 1, + "keyring": hex::encode(keyring.to_bytes()), + }); + fs::write(path, serde_json::to_string_pretty(&json)?)?; + println!("Generated host keys -> {path}"); + Ok((1u64, keyring)) +} + +pub fn export_host_public_keys(host_keyring: &Keyring) -> Result<(), Box> { + fs::write( + "host_enc_kem_pk.bin", + host_keyring.kem_public_key.as_bytes(), + )?; + fs::write("host_sig_pk.bin", host_keyring.sig_cl_public_key.as_bytes())?; + fs::write( + "host_sig_pq_pk.bin", + host_keyring.sig_pq_public_key.as_bytes(), + )?; + Ok(()) +} diff --git a/example-usage/server/src/main.rs b/example-usage/server/src/main.rs index 2d3b7a7..414bbe1 100644 --- a/example-usage/server/src/main.rs +++ b/example-usage/server/src/main.rs @@ -1,149 +1,34 @@ -use std::collections::HashMap; -use std::fs; -use std::sync::{Arc, Mutex}; +mod clients; +mod handlers; +mod keys; +mod tls; -use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataTypeId, DataValue, TypeMap}; -use mtp::crypto::{Ed25519Signer, Keyring, MlDsaSigner, PublicKeyBundle, kem::HybridKem}; use mtp::host::{HostConfig, MTPHost}; - -/* - * Load an existing TLS certificate and key pair from disk. If neither - * file exists, generate a self-signed certificate so the server is - * immediately usable without external tooling. - */ -fn load_or_generate_tls( - cert_path: &str, - key_path: &str, -) -> Result<(Vec, Vec), Box> { - if let (Ok(c), Ok(k)) = (fs::read(cert_path), fs::read(key_path)) { - println!("Using existing TLS cert from {cert_path}"); - return Ok((c, k)); - } - - println!("Generating self-signed TLS certificate ..."); - let key_pair = rcgen::KeyPair::generate()?; - let params = rcgen::CertificateParams::new(vec!["localhost".into(), "127.0.0.1".into()])?; - let cert = params.self_signed(&key_pair)?; - - let cert_str = cert.pem(); - let key_str = key_pair.serialize_pem(); - - fs::write(cert_path, cert_str.as_bytes())?; - fs::write(key_path, key_str.as_bytes())?; - println!("Wrote {cert_path} and {key_path}"); - - Ok((cert_str.into_bytes(), key_str.into_bytes())) -} - -/* - * Load the host's Ed25519 identity from a JSON file, or generate a - * fresh one and persist it. Clients need the corresponding public key - * (exported separately as host_sig_pk.bin) to authenticate the host - * during the handshake. - */ -fn load_or_generate_host_keys(path: &str) -> Result<(u64, Keyring), Box> { - if let Ok(data) = fs::read_to_string(path) { - let json: serde_json::Value = serde_json::from_str(&data)?; - let hid = json["host_id"].as_u64().unwrap_or(1); - let keyring = Keyring::from_bytes(&hex::decode(json["keyring"].as_str().unwrap())?)?; - println!("Loaded host keys (ID: {})", hid); - return Ok((hid, keyring)); - } - - let (_ed_signer, sig_sk, sig_pk) = Ed25519Signer::generate(); - let (_pq_signer, sig_pq_sk, sig_pq_pk) = MlDsaSigner::generate(); - let (kem_sk, kem_pk) = HybridKem::generate_keypair(); - let keyring = Keyring::new(kem_pk, kem_sk, sig_pq_pk, sig_pq_sk, sig_pk, sig_sk); - - let json = serde_json::json!({ - "host_id": 1, - "keyring": hex::encode(keyring.to_bytes()), - }); - fs::write(path, serde_json::to_string_pretty(&json)?)?; - println!("Generated host keys -> {path}"); - Ok((1u64, keyring)) -} - -/* - * Load the client database from disk. Each entry maps a numeric - * client ID to its PublicKeyBundle. next_id starts one past the - * highest known ID (or 1000 if the DB is empty). - */ -fn load_client_db( - path: &str, -) -> Result<(Arc>>, Arc>), Box> -{ - let clients: Arc>> = - Arc::new(Mutex::new(if let Ok(data) = fs::read_to_string(path) { - serde_json::from_str(&data).unwrap_or_default() - } else { - HashMap::new() - })); - let next_id = Arc::new(Mutex::new( - clients.lock().unwrap().keys().max().unwrap_or(&999) + 1, - )); - Ok((clients, next_id)) -} - -/* - * Build a Pong response carrying a description, a Unix timestamp, and - * a custom payload according to the negotiated type map. - */ -fn build_pong_response(tm: &TypeMap) -> CommunicationValue { - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - - CommunicationValue::from_comm(CommunicationType::Pong, tm) - .add_data( - DataTypeId(tm.data_id_enum(DataType::Description).unwrap()), - DataValue::Str("Hello from server!".into()), - ) - .add_data( - DataTypeId(tm.data_id_enum(DataType::Timestamp).unwrap()), - DataValue::UnsignedNumber(timestamp as u128), - ) - .add_data( - DataTypeId(tm.data_id_enum(DataType::Data).unwrap()), - DataValue::Str("custom payload".into()), - ) -} +use mtp::type_map::TypeMap; #[tokio::main] async fn main() -> Result<(), Box> { - let (cert_pem, key_pem) = load_or_generate_tls("server.pem", "server.key")?; - let (host_id, host_keyring) = load_or_generate_host_keys("host_keys.json")?; + let (cert_pem, key_pem) = tls::load_or_generate_tls("server.pem", "server.key")?; + let (host_id, host_keyring) = keys::load_or_generate_host_keys("host_keys.json")?; + keys::export_host_public_keys(&host_keyring)?; - // Export the host's public keys so clients can verify it - fs::write( - "host_enc_kem_pk.bin", - host_keyring.kem_public_key.as_bytes(), - )?; - fs::write("host_sig_pk.bin", host_keyring.sig_cl_public_key.as_bytes())?; - fs::write( - "host_sig_pq_pk.bin", - host_keyring.sig_pq_public_key.as_bytes(), - )?; + let (clients, next_id) = clients::load_client_db("clients.json")?; - let (clients, next_id) = load_client_db("clients.json")?; - - // Clone the Arc so each closure owns its own reference let clients_for_get = clients.clone(); - let get_existing_user = Box::new(move |id: u64| -> Option { + let get_existing_user = Box::new(move |id: u64| -> Option { clients_for_get.lock().unwrap().get(&id).cloned() }); let clients_for_register = clients.clone(); let next_id_for_register = next_id.clone(); let clients_path = "clients.json".to_string(); - let complete_register = Box::new(move |bundle: PublicKeyBundle| -> u64 { + let complete_register = Box::new(move |bundle: mtp::crypto::PublicKeyBundle| -> u64 { let mut db = clients_for_register.lock().unwrap(); let mut nid = next_id_for_register.lock().unwrap(); let id = *nid; *nid += 1; db.insert(id, bundle); - fs::write(&clients_path, serde_json::to_string_pretty(&*db).unwrap()).ok(); + std::fs::write(&clients_path, serde_json::to_string_pretty(&*db).unwrap()).ok(); println!("Registered new client with ID: {}", id); id }); @@ -177,7 +62,8 @@ async fn main() -> Result<(), Box> { match conn.receiver.receive().await { Ok(msg) => { println!("Received: {msg}"); - let response = build_pong_response(tm); + let response = + handlers::process_and_respond(&msg, tm, conn.client_public_key.as_ref()); println!("Sending: {response}"); conn.sender.send(&response).await?; } diff --git a/example-usage/server/src/tls.rs b/example-usage/server/src/tls.rs new file mode 100644 index 0000000..80439f9 --- /dev/null +++ b/example-usage/server/src/tls.rs @@ -0,0 +1,25 @@ +use std::fs; + +pub fn load_or_generate_tls( + cert_path: &str, + key_path: &str, +) -> Result<(Vec, Vec), Box> { + if let (Ok(c), Ok(k)) = (fs::read(cert_path), fs::read(key_path)) { + println!("Using existing TLS cert from {cert_path}"); + return Ok((c, k)); + } + + println!("Generating self-signed TLS certificate ..."); + let key_pair = rcgen::KeyPair::generate()?; + let params = rcgen::CertificateParams::new(vec!["localhost".into(), "127.0.0.1".into()])?; + let cert = params.self_signed(&key_pair)?; + + let cert_str = cert26.pem(); + let key_str = key_pair.serialize_pem(); + + fs::write(cert_path, cert_str.as_bytes())?; + fs::write(key_path, key_str.as_bytes())?; + println!("Wrote {cert_path} and {key_path}"); + + Ok((cert_str.into_bytes(), key_str.into_bytes())) +} diff --git a/example-usage/type-maps.yaml b/example-usage/type-maps.yaml index 863d7f5..0289011 100644 --- a/example-usage/type-maps.yaml +++ b/example-usage/type-maps.yaml @@ -8,7 +8,21 @@ type_maps: CommunicationTypes: DataTypes: Data: 32 + Flags: 33 + Value: 34 + BinaryData: 35 + Items: 36 + EncryptedPayload: 37 + SignedPayload: 38 + SecurePayload: 39 "2.0": CommunicationTypes: DataTypes: Data: 34 + Flags: 33 + Value: 35 + BinaryData: 36 + Items: 37 + EncryptedPayload: 38 + SignedPayload: 39 + SecurePayload: 40 diff --git a/example-usage/web-client/index.html b/example-usage/web-client/index.html new file mode 100644 index 0000000..904cc81 --- /dev/null +++ b/example-usage/web-client/index.html @@ -0,0 +1,20 @@ + + + + + + MTP Web Client + + + +

MTP WebTransport Client

+
Initializing...
+ + + diff --git a/example-usage/web-client/package-lock.json b/example-usage/web-client/package-lock.json new file mode 100644 index 0000000..f83ec9b --- /dev/null +++ b/example-usage/web-client/package-lock.json @@ -0,0 +1,1041 @@ +{ + "name": "mtp-web-client", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mtp-web-client", + "version": "0.1.0", + "devDependencies": { + "typescript": "^5.4.0", + "vite": "^5.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/example-usage/web-client/package.json b/example-usage/web-client/package.json new file mode 100644 index 0000000..8c64fc8 --- /dev/null +++ b/example-usage/web-client/package.json @@ -0,0 +1,15 @@ +{ + "name": "mtp-web-client", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "typescript": "^5.4.0", + "vite": "^5.4.0" + } +} diff --git a/example-usage/web-client/src/main.ts b/example-usage/web-client/src/main.ts new file mode 100644 index 0000000..fc49670 --- /dev/null +++ b/example-usage/web-client/src/main.ts @@ -0,0 +1,110 @@ +import init, { + WasmClient, + ConnectionConfig, + ConnectionState, + WasmEd25519Signer, + ed25519_generate, + keyring_from_ed25519, + build_demo_message, +} from 'mtp-wasm'; + +const STATUS = document.getElementById('status')!; +const STORAGE_KEY = 'mtp-web-client-keys'; + +function log(msg: string, cls = '') { + const line = document.createElement('div'); + line.textContent = msg; + if (cls) line.className = cls; + STATUS.appendChild(line); +} + +function saveKeys(clientId: bigint, keyringBytes: Uint8Array) { + const data = { + clientId: clientId.toString(), + keyring: Array.from(keyringBytes), + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); +} + +function loadKeys(): { clientId: bigint; keyringBytes: Uint8Array } | null { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const data = JSON.parse(raw); + return { + clientId: BigInt(data.clientId), + keyringBytes: new Uint8Array(data.keyring), + }; +} + +async function initWasm() { + log('Loading WASM module...'); + await init(); + log(`WASM loaded. WebTransport supported: ${WasmClient.is_supported()}`); +} + +function createClient(): WasmClient { + return new WasmClient( + (state: number) => log(`[state] ${ConnectionState[state] ?? state}`, 'state'), + (data: Uint8Array) => { + const decoder = new TextDecoder(); + log(`[message] ${data.length} bytes: ${decoder.decode(data)}`, 'received'); + }, + (err: any) => log(`[error] ${err}`, 'error'), + ); +} + +function generateKeyringBytes(): Uint8Array { + const gen = ed25519_generate(); + const sk = gen.secretKey as Uint8Array; + const pk = gen.publicKey as Uint8Array; + gen.signer.free(); + return keyring_from_ed25519(sk, pk); +} + +async function run() { + await initWasm(); + + if (!WasmClient.is_supported()) { + log('WebTransport is not supported in this browser.', 'error'); + return; + } + + const serverUrl = 'https://127.0.0.1:8080'; + const saved = loadKeys(); + + const client = createClient(); + const config = new ConnectionConfig(serverUrl); + + let clientId: bigint; + let keyringBytes: Uint8Array; + + if (saved) { + log(`Found saved client keys (ID: ${saved.clientId})`); + const hostPk = new Uint8Array(0); + clientId = await client.auth_connect(config, hostPk, saved.keyringBytes, saved.clientId); + log(`Authenticated as client ${clientId}`); + keyringBytes = saved.keyringBytes; + } else { + log('No saved keys — registering new client...'); + const hostPk = new Uint8Array(0); + keyringBytes = generateKeyringBytes(); + clientId = await client.auth_register(config, hostPk, keyringBytes); + log(`Registered with ID: ${clientId}`); + saveKeys(clientId, keyringBytes); + log('Saved client keys to localStorage'); + } + + config.free(); + + log('\nSending demo message...'); + const frame = build_demo_message(clientId, keyringBytes); + await client.send(frame); + log(`Sent ${frame.length} bytes`); + + log('\nClient running. Waiting for incoming messages...'); +} + +run().catch((e) => { + log(`Fatal error: ${e}`, 'error'); + console.error(e); +}); diff --git a/example-usage/web-client/src/messages.ts b/example-usage/web-client/src/messages.ts new file mode 100644 index 0000000..60f9539 --- /dev/null +++ b/example-usage/web-client/src/messages.ts @@ -0,0 +1,26 @@ +import { build_demo_message, build_ping_frame, parse_auth_response } from 'mtp-wasm'; + +export function buildAuthResponse( + response: Uint8Array, +): { + connected: boolean; + clientNonce: Uint8Array; + assignedId: bigint; + timestamp: bigint; + signature: Uint8Array; +} { + return parse_auth_response(response); +} + +export function buildDemoMessage(clientId: bigint, keyringBytes: Uint8Array): Uint8Array { + return build_demo_message(clientId, keyringBytes); +} + +export function buildPingFrame( + clientId: bigint, + description: string, + timestamp: bigint, + data?: Uint8Array, +): Uint8Array { + return build_ping_frame(clientId, description, timestamp, data ?? new Uint8Array()); +} diff --git a/example-usage/web-client/tsconfig.json b/example-usage/web-client/tsconfig.json new file mode 100644 index 0000000..6b0f16b --- /dev/null +++ b/example-usage/web-client/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "isolatedModules": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "paths": { + "mtp-wasm": ["../../wasm/pkg"] + }, + "rootDir": "../.." + }, + "include": ["src", "../../wasm/pkg"] +} diff --git a/example-usage/web-client/vite.config.ts b/example-usage/web-client/vite.config.ts new file mode 100644 index 0000000..e4c1eee --- /dev/null +++ b/example-usage/web-client/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite'; +import path from 'path'; + +export default defineConfig({ + resolve: { + alias: { + 'mtp-wasm': path.resolve(__dirname, '../../wasm/pkg'), + }, + }, +}); diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..5321c57 --- /dev/null +++ b/flake.nix @@ -0,0 +1,68 @@ +{ + description = "MTP - Methanium Transport Protocol"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + rust-overlay.url = "github:oxalica/rust-overlay"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = + { + self, + nixpkgs, + rust-overlay, + flake-utils, + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + overlays = [ rust-overlay.overlays.default ]; + pkgs = import nixpkgs { inherit system overlays; }; + + rustToolchain = pkgs.rust-bin.stable.latest.default.override { + extensions = [ + "rust-src" + "clippy" + "rustfmt" + ]; + targets = [ "wasm32-unknown-unknown" ]; + }; + in + { + devShells.default = pkgs.mkShell { + name = "mtp-dev"; + + buildInputs = with pkgs; [ + rustToolchain + wasm-pack + pkg-config + openssl + ]; + + MTP_TYPE_MAPS = "${toString ./example-usage/type-maps.yaml}"; + + shellHook = '' + echo "MTP dev shell" + echo " rustc : $(rustc --version)" + echo " cargo : $(cargo --version)" + echo " wasm-pack : $(wasm-pack --version 2>/dev/null || echo 'not found')" + echo " targets: $(rustc --print target-list | grep wasm32 | tr '\n' ' ')" + echo " MTP_TYPE_MAPS = $MTP_TYPE_MAPS" + ''; + }; + + # Ad-hoc WASM build using wasm-pack + apps.wasm-build = { + type = "app"; + program = "${pkgs.wasm-pack}/bin/wasm-pack"; + args = [ + "build" + "wasm" + "--target" + "web" + ]; + }; + } + ); +} diff --git a/wasm/.cargo/config.toml b/wasm/.cargo/config.toml new file mode 100644 index 0000000..f5d9120 --- /dev/null +++ b/wasm/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.wasm32-unknown-unknown] +rustflags = ["--cfg=web_sys_unstable_apis"] diff --git a/wasm/Cargo.toml b/wasm/Cargo.toml new file mode 100644 index 0000000..f83278c --- /dev/null +++ b/wasm/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "mtp-wasm" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +web-sys = { version = "0.3", features = [ + "console", + "WebTransport", + "WebTransportOptions", + "WebTransportHash", + "WebTransportBidirectionalStream", + "WebTransportCloseInfo", + "WebTransportDatagramDuplexStream", + "WebTransportError", + "WebTransportReceiveStream", + "WebTransportSendStream", + "ReadableStream", + "ReadableStreamDefaultReader", + "WritableStream", + "WritableStreamDefaultWriter", +] } +console_error_panic_hook = "0.1" + +serde = { version = "1", features = ["derive"] } +serde-wasm-bindgen = "0.6" + +getrandom = { version = "0.4", features = ["wasm_js"] } +getrandom-v02 = { package = "getrandom", version = "0.2", features = ["js"] } + +mtp-common = { path = "../common" } +mtp-type-map = { path = "../type-map" } +mtp-codec = { path = "../codec", features = ["crypto"] } +mtp-crypto = { path = "../crypto", features = ["wasm"] } diff --git a/wasm/src/client.rs b/wasm/src/client.rs new file mode 100644 index 0000000..0d77dcb --- /dev/null +++ b/wasm/src/client.rs @@ -0,0 +1,316 @@ +use std::cell::Cell; +use std::rc::Rc; + +use wasm_bindgen::prelude::*; + +use mtp_codec::{ + CommunicationType, CommunicationValue, DataType, DataValue, PROTOCOL_VERSION, +}; +use mtp_type_map::{CommunicationTypeId, DataTypeId}; + +use mtp_crypto::SignatureScheme; + +use crate::error::js_error; +use crate::transport::WasmTransport; + +#[wasm_bindgen] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConnectionState { + Disconnected = 0, + Connecting = 1, + Connected = 2, + Failed = 3, +} + +#[wasm_bindgen] +pub struct ConnectionConfig { + url: String, + server_certificate_hashes: Option>, + client_id: u64, +} + +#[wasm_bindgen] +impl ConnectionConfig { + #[wasm_bindgen(constructor)] + pub fn new(url: String) -> Self { + Self { url, server_certificate_hashes: None, client_id: 0 } + } + + #[wasm_bindgen(getter)] + pub fn url(&self) -> String { self.url.clone() } + + #[wasm_bindgen(setter)] + 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 } + + #[wasm_bindgen(setter)] + pub fn set_server_certificate_hashes(&mut self, hashes: Vec) { + self.server_certificate_hashes = Some(hashes); + } +} + +#[wasm_bindgen] +pub struct WasmClient { + transport: Option, + state: Rc>, + on_state_change: js_sys::Function, + pub(crate) on_message: js_sys::Function, + pub(crate) on_error: js_sys::Function, +} + +#[wasm_bindgen] +impl WasmClient { + #[wasm_bindgen(constructor)] + pub fn new( + on_state_change: &js_sys::Function, + on_message: &js_sys::Function, + on_error: &js_sys::Function, + ) -> Self { + Self { + transport: None, + state: Rc::new(Cell::new(ConnectionState::Disconnected)), + on_state_change: on_state_change.clone(), + on_message: on_message.clone(), + on_error: on_error.clone(), + } + } + + #[wasm_bindgen] + pub fn is_supported() -> bool { + js_sys::Reflect::has(&js_sys::global(), &JsValue::from_str("WebTransport")) + .unwrap_or(false) + } + + #[wasm_bindgen(getter)] + pub fn state(&self) -> u8 { self.state.get() as u8 } + + /// 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).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)); + transport.send_frame(&ident.to_bytes()).await?; + + self.transport = Some(transport); + self.set_state(ConnectionState::Connected); + + let state = self.state.clone(); + 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; + state.set(ConnectionState::Disconnected); + }); + Ok(()) + } + + /// Authenticated login with an existing client ID. + /// Exchanges Identification + signatures and verifies the host response. + /// + /// - `host_public_key_bytes`: serialized PublicKeyBundle from the server + /// - `keyring_bytes`: serialized Keyring of this client (must match `client_id`) + /// - `client_id`: previously assigned client ID + /// + /// Returns the confirmed (same) client ID on success. + #[wasm_bindgen] + pub async fn auth_connect( + &mut self, + config: &ConnectionConfig, + _host_public_key_bytes: &[u8], + keyring_bytes: &[u8], + client_id: u64, + ) -> Result { + self.set_state(ConnectionState::Connecting); + + let keyring = mtp_crypto::Keyring::from_bytes(keyring_bytes) + .map_err(|e| js_error(&format!("invalid keyring: {}", e)))?; + + let version_str = format!("{}", PROTOCOL_VERSION); + let mut nonce_bytes = [0u8; 16]; + 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 + let mut sig_payload = Vec::new(); + sig_payload.extend_from_slice(version_str.as_bytes()); + sig_payload.extend_from_slice(&client_id.to_be_bytes()); + sig_payload.extend_from_slice(&client_nonce.to_be_bytes()); + + 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) + .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::Signature, DataValue::Bytes(signature)) + .to_bytes(); + + let transport = WasmTransport::connect(&config.url).await?; + let inner = transport.inner().clone(); + transport.send_frame(&frame).await?; + + // Read and verify the host's IdentificationResponse + let response = transport.read_one_frame().await?; + let resp_comm = CommunicationValue::from_bytes(&response) + .map_err(|e| js_error(&format!("parse response: {}", e)))?; + + 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")); + } + + if resp_comm.get_data(DataTypeId(11)) != &DataValue::BoolTrue { + return Err(js_error("host rejected authentication")); + } + + // Verify echoed nonce + let echo_nonce = resp_comm.get_data(DataTypeId(7)); + if *echo_nonce != DataValue::UnsignedNumber(client_nonce) { + return Err(js_error("nonce mismatch")); + } + + // Extract assigned ID + let assigned_id = match resp_comm.get_data(DataTypeId(6)) { + DataValue::UnsignedNumber(n) => *n as u64, + _ => return Err(js_error("missing assigned ID")), + }; + + self.transport = Some(transport); + self.set_state(ConnectionState::Connected); + + let state = self.state.clone(); + 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; + state.set(ConnectionState::Disconnected); + }); + + Ok(assigned_id) + } + + /// Authenticated registration with a fresh keyring. + /// The server assigns a new client ID. + /// + /// - `host_public_key_bytes`: serialized PublicKeyBundle from the server + /// - `keyring_bytes`: serialized Keyring (must include ed25519 secret key) + /// + /// Returns the newly assigned client ID. + #[wasm_bindgen] + pub async fn auth_register( + &mut self, + config: &ConnectionConfig, + host_public_key_bytes: &[u8], + keyring_bytes: &[u8], + ) -> Result { + self.set_state(ConnectionState::Connecting); + + let _host_pk = mtp_crypto::PublicKeyBundle::from_bytes(host_public_key_bytes) + .map_err(|e| js_error(&format!("invalid host public key: {}", e)))?; + let keyring = mtp_crypto::Keyring::from_bytes(keyring_bytes) + .map_err(|e| js_error(&format!("invalid keyring: {}", e)))?; + + let version_str = format!("{}", PROTOCOL_VERSION); + let mut nonce_bytes = [0u8; 16]; + 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(); + + // Build signature payload: version || client_nonce || pk_bytes + let mut sig_payload = Vec::new(); + sig_payload.extend_from_slice(version_str.as_bytes()); + sig_payload.extend_from_slice(&client_nonce.to_be_bytes()); + sig_payload.extend_from_slice(&pk_bytes); + + 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) + .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::PublicKeys, DataValue::Bytes(pk_bytes)) + .add_typed_default(DataType::Signature, DataValue::Bytes(signature)) + .to_bytes(); + + let transport = WasmTransport::connect(&config.url).await?; + let inner = transport.inner().clone(); + transport.send_frame(&frame).await?; + + let response = transport.read_one_frame().await?; + let resp_comm = CommunicationValue::from_bytes(&response) + .map_err(|e| js_error(&format!("parse response: {}", e)))?; + + 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")); + } + + if resp_comm.get_data(DataTypeId(11)) != &DataValue::BoolTrue { + return Err(js_error("host rejected registration")); + } + + let echo = resp_comm.get_data(DataTypeId(7)); + if *echo != DataValue::UnsignedNumber(client_nonce) { + return Err(js_error("nonce mismatch")); + } + + let assigned_id = match resp_comm.get_data(DataTypeId(6)) { + DataValue::UnsignedNumber(n) => *n as u64, + _ => return Err(js_error("missing assigned ID")), + }; + + self.transport = Some(transport); + self.set_state(ConnectionState::Connected); + + let state = self.state.clone(); + 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; + state.set(ConnectionState::Disconnected); + }); + + Ok(assigned_id) + } + + #[wasm_bindgen] + pub async fn send(&self, frame: Vec) -> Result<(), JsValue> { + match &self.transport { + Some(t) => t.send_frame(&frame).await, + None => Err(js_error("not connected")), + } + } + + #[wasm_bindgen] + pub fn disconnect(&mut self) { + 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), + ); + } +} diff --git a/wasm/src/crypto.rs b/wasm/src/crypto.rs new file mode 100644 index 0000000..21f4082 --- /dev/null +++ b/wasm/src/crypto.rs @@ -0,0 +1,263 @@ +use wasm_bindgen::prelude::*; + +use mtp_crypto::{ + AeadDecrypt, AeadEncrypt, Ed25519Signer, KemPrivateKey, KemPublicKey, Keyring, + PublicKeyBundle, SignaturePqPrivateKey, SignaturePqPublicKey, SignaturePrivateKey, + SignaturePublicKey, SignatureScheme, ChaCha20Poly1305, sha256, sha256_double, +}; + +use crate::error::js_error; + +// =========================================================================== +// Keyring +// =========================================================================== + +#[wasm_bindgen] +pub struct WasmKeyring { + inner: Keyring, +} + +#[wasm_bindgen] +impl WasmKeyring { + /// Serialise the keyring to bytes. + #[wasm_bindgen] + pub fn to_bytes(&self) -> Vec { + self.inner.to_bytes() + } + + /// Deserialise a keyring from bytes. + #[wasm_bindgen] + pub fn from_bytes(bytes: &[u8]) -> Result { + let inner = + Keyring::from_bytes(bytes).map_err(|e| js_error(&format!("Keyring::from_bytes: {}", e)))?; + Ok(Self { inner }) + } + + /// Return the public half of this keyring as a bundle. + #[wasm_bindgen] + pub fn public_key_bundle(&self) -> WasmPublicKeyBundle { + WasmPublicKeyBundle { + inner: self.inner.public_key_bundle(), + } + } +} + +/// Build a [`Keyring`] containing only an Ed25519 keypair (no KEM, no ML-DSA). +/// +/// Takes the Ed25519 secret key and public key, each 32 bytes. +/// Returns the serialised keyring bytes, suitable for passing to `WasmClient.auth_register`. +#[wasm_bindgen] +pub fn keyring_from_ed25519(secret_key: &[u8], public_key: &[u8]) -> Result, JsValue> { + if secret_key.len() != 32 { + return Err(js_error("ed25519 secret key must be 32 bytes")); + } + if public_key.len() != 32 { + return Err(js_error("ed25519 public key must be 32 bytes")); + } + let keyring = Keyring::new( + KemPublicKey::new(vec![]), + KemPrivateKey::new(vec![]), + SignaturePqPublicKey::new(vec![]), + SignaturePqPrivateKey::new(vec![]), + SignaturePublicKey::new(public_key.to_vec()), + SignaturePrivateKey::new(secret_key.to_vec()), + ); + Ok(keyring.to_bytes()) +} + +// =========================================================================== +// PublicKeyBundle +// =========================================================================== + +#[wasm_bindgen] +pub struct WasmPublicKeyBundle { + inner: PublicKeyBundle, +} + +#[wasm_bindgen] +impl WasmPublicKeyBundle { + #[wasm_bindgen(getter)] + pub fn kem_public_key(&self) -> Vec { + self.inner.kem_public_key.as_bytes().to_vec() + } + + #[wasm_bindgen(getter)] + pub fn sig_cl_public_key(&self) -> Vec { + self.inner.sig_cl_public_key.as_bytes().to_vec() + } + + #[wasm_bindgen(getter)] + pub fn sig_pq_public_key(&self) -> Vec { + self.inner.sig_pq_public_key.as_bytes().to_vec() + } + + #[wasm_bindgen] + pub fn to_bytes(&self) -> Vec { + self.inner.as_bytes() + } + + #[wasm_bindgen] + pub fn from_bytes(bytes: &[u8]) -> Result { + let inner = PublicKeyBundle::from_bytes(bytes) + .map_err(|e| js_error(&format!("PublicKeyBundle::from_bytes: {}", e)))?; + Ok(Self { inner }) + } +} + +// =========================================================================== +// ChaCha20-Poly1305 AEAD +// =========================================================================== + +#[wasm_bindgen] +pub struct WasmChaCha20Poly1305 { + inner: ChaCha20Poly1305, +} + +#[wasm_bindgen] +impl WasmChaCha20Poly1305 { + /// Create a new cipher with a 32-byte key. + #[wasm_bindgen(constructor)] + pub fn new(key: Vec) -> Result { + if key.len() != 32 { + return Err(js_error("ChaCha20Poly1305 key must be 32 bytes")); + } + let mut k = [0u8; 32]; + k.copy_from_slice(&key); + Ok(Self { + inner: ChaCha20Poly1305::new(k), + }) + } + + /// Encrypt `plaintext` with `aad`. + /// Returns `nonce || ciphertext`. + #[wasm_bindgen] + pub fn encrypt(&self, plaintext: &[u8], aad: &[u8]) -> Result, JsValue> { + self.inner + .encrypt(plaintext, aad) + .map_err(|e| js_error(&format!("encrypt failed: {}", e))) + } + + /// Decrypt `nonce || ciphertext` with `aad`. + #[wasm_bindgen] + pub fn decrypt(&self, ciphertext: &[u8], aad: &[u8]) -> Result, JsValue> { + self.inner + .decrypt(ciphertext, aad) + .map_err(|e| js_error(&format!("decrypt failed: {}", e))) + } +} + +// =========================================================================== +// Ed25519 signatures +// =========================================================================== + +#[wasm_bindgen] +pub struct WasmEd25519Signer { + inner: Ed25519Signer, +} + +#[wasm_bindgen] +impl WasmEd25519Signer { + /// Load a signer from its 32-byte secret key. + #[wasm_bindgen(constructor)] + pub fn new(secret_key: Vec) -> Result { + let sk = SignaturePrivateKey::new(secret_key); + let inner = + Ed25519Signer::new(&sk).map_err(|e| js_error(&format!("Ed25519Signer::new: {}", e)))?; + Ok(Self { inner }) + } + + /// Sign `message` and return the signature bytes. + #[wasm_bindgen] + pub fn sign(&self, message: &[u8]) -> Result, JsValue> { + self.inner + .sign(message) + .map_err(|e| js_error(&format!("sign failed: {}", e))) + } + + /// Verify `signature` against `message`. + #[wasm_bindgen] + pub fn verify(&self, message: &[u8], signature: &[u8]) -> Result<(), JsValue> { + self.inner + .verify(message, signature) + .map_err(|e| js_error(&format!("verify failed: {}", e))) + } +} + +// =========================================================================== +// Ed25519 key generation helper +// =========================================================================== + +/// Generate a fresh Ed25519 keypair. +/// +/// Returns `{ signer: WasmEd25519Signer, secretKey: Uint8Array, publicKey: Uint8Array }`. +#[wasm_bindgen] +pub fn ed25519_generate() -> Result { + let (_signer, sk, pk) = Ed25519Signer::generate(); + let obj = js_sys::Object::new(); + js_sys::Reflect::set( + &obj, + &JsValue::from_str("signer"), + &WasmEd25519Signer::new(sk.as_bytes().to_vec())?.into(), + ) + .map_err(|_| js_error("failed to set signer"))?; + js_sys::Reflect::set( + &obj, + &JsValue::from_str("secretKey"), + &js_sys::Uint8Array::from(sk.as_bytes()), + ) + .map_err(|_| js_error("failed to set secretKey"))?; + js_sys::Reflect::set( + &obj, + &JsValue::from_str("publicKey"), + &js_sys::Uint8Array::from(pk.as_bytes()), + ) + .map_err(|_| js_error("failed to set publicKey"))?; + Ok(obj.into()) +} + +/// Standalone Ed25519 signature verification. +#[wasm_bindgen] +pub fn ed25519_verify(public_key: Vec, message: &[u8], signature: &[u8]) -> Result<(), JsValue> { + let pk = SignaturePublicKey::new(public_key); + mtp_crypto::verify_ed25519(&pk, message, signature) + .map_err(|e| js_error(&format!("verify_ed25519 failed: {}", e))) +} + +// =========================================================================== +// Hashing +// =========================================================================== + +/// SHA-256 digest. +#[wasm_bindgen] +pub fn wasm_sha256(data: &[u8]) -> Vec { + sha256(data).to_vec() +} + +/// Double SHA-256 (SHA-256 applied twice). +#[wasm_bindgen] +pub fn wasm_sha256_double(data: &[u8]) -> Vec { + sha256_double(data).to_vec() +} + +// =========================================================================== +// KDF +// =========================================================================== + +/// HKDF-expand: derive `len` bytes from `ikm` with `salt` and `info`. +#[wasm_bindgen] +pub fn wasm_hkdf_expand(ikm: &[u8], salt: &[u8], info: &[u8], len: usize) -> Result, JsValue> { + mtp_crypto::hkdf_expand(ikm, salt, info, len) + .map_err(|e| js_error(&format!("hkdf_expand failed: {}", e))) +} + +/// Derive a 32-byte encryption key from `ikm` with `salt` and `context`. +#[wasm_bindgen] +pub fn wasm_derive_encryption_key( + ikm: &[u8], + salt: &[u8], + context: &[u8], +) -> Result, JsValue> { + mtp_crypto::derive_encryption_key(ikm, salt, context) + .map(|key| key.to_vec()) + .map_err(|e| js_error(&format!("derive_encryption_key failed: {}", e))) +} diff --git a/wasm/src/error.rs b/wasm/src/error.rs new file mode 100644 index 0000000..c4dc93f --- /dev/null +++ b/wasm/src/error.rs @@ -0,0 +1,17 @@ +use wasm_bindgen::JsValue; + +pub fn js_error(msg: impl Into) -> JsValue { + JsValue::from_str(&msg.into()) +} + +pub fn from_codec_error(e: mtp_common::CodecError) -> JsValue { + js_error(e.to_string()) +} + +pub fn from_communication_error(e: mtp_common::CommunicationError) -> JsValue { + js_error(e.to_string()) +} + +pub fn from_crypto_error(e: mtp_crypto::CryptoError) -> JsValue { + js_error(e.to_string()) +} diff --git a/wasm/src/lib.rs b/wasm/src/lib.rs new file mode 100644 index 0000000..bb4ef3d --- /dev/null +++ b/wasm/src/lib.rs @@ -0,0 +1,13 @@ +pub mod error; +pub mod transport; +pub mod client; +pub mod crypto; +pub mod message; + +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(start)] +pub fn main() { + console_error_panic_hook::set_once(); + web_sys::console::log_1(&"mtp-wasm: module loaded".into()); +} diff --git a/wasm/src/message.rs b/wasm/src/message.rs new file mode 100644 index 0000000..0d17646 --- /dev/null +++ b/wasm/src/message.rs @@ -0,0 +1,135 @@ +use wasm_bindgen::prelude::*; + +use mtp_codec::{ + CommunicationType, CommunicationValue, DataType, DataTypeId, DataValue, +}; +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 { + 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)) + .with_sender(client_id); + + if !data.is_empty() { + msg = msg.add_typed_default(DataType::Id, DataValue::Bytes(data.to_vec())); + } + + msg.to_bytes() +} + +/// Build a demo Ping frame with encrypted and signed containers +/// (mirrors the Rust client example but uses only reserved data types). +#[wasm_bindgen] +pub fn build_demo_message(client_id: u64, keyring_bytes: &[u8]) -> Result, JsValue> { + let keyring = Keyring::from_bytes(keyring_bytes) + .map_err(|e| js_error(&format!("invalid keyring: {}", e)))?; + + let enc_key = derive_encryption_key( + b"MTP-demo-shared-secret", + b"MTP-demo-salt", + b"encrypted-container-demo", + ) + .map_err(|e| js_error(&format!("key derivation failed: {}", e)))?; + + let cipher = ChaCha20Poly1305::new(enc_key); + let signer = Ed25519Signer::new(&keyring.sig_cl_secret_key) + .map_err(|e| js_error(&format!("signer creation failed: {}", e)))?; + + // Encrypted container (DataTypeId 1 = arbitrary custom) + let inner_enc = DataValue::Container(vec![ + (DataTypeId(1), DataValue::Str("secret inner data".into())), + (DataTypeId(2), DataValue::UnsignedNumber(42)), + ]); + let mut dv_enc = inner_enc; + dv_enc.encrypt_container(&cipher, b"demo-aad") + .ok_or_else(|| js_error("encryption failed"))?; + + // Signed container + let inner_sig = DataValue::Container(vec![ + (DataTypeId(1), DataValue::Str("signed by client".into())), + (DataTypeId(2), DataValue::UnsignedNumber(99)), + ]); + let mut dv_sig = inner_sig; + 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(2), DataValue::UnsignedNumber(7)), + ]); + let mut dv_sec = inner_sec; + 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::Version, DataValue::Str("demo-wasm".into())) + .with_sender(client_id); + + Ok(msg.to_bytes()) +} + +/// Parse an auth response frame into a JS object. +#[wasm_bindgen] +pub fn parse_auth_response(response: &[u8]) -> Result { + let comm = CommunicationValue::from_bytes(response) + .map_err(|e| js_error(&format!("parse failed: {}", e)))?; + + let connected = matches!(comm.get_data(DataTypeId(11)), DataValue::BoolTrue); + + let client_nonce = match comm.get_data(DataTypeId(7)) { + DataValue::UnsignedNumber(n) => Some(*n), + _ => None, + }; + + let assigned_id = match comm.get_data(DataTypeId(6)) { + DataValue::UnsignedNumber(n) => Some(*n as u64), + _ => None, + }; + + let timestamp = match comm.get_data(DataTypeId(5)) { + DataValue::UnsignedNumber(n) => Some(*n), + _ => None, + }; + + let signature = match comm.get_data(DataTypeId(10)) { + DataValue::Bytes(b) => Some(b.clone()), + _ => None, + }; + + let obj = js_sys::Object::new(); + js_sys::Reflect::set(&obj, &"connected".into(), &JsValue::from(connected)).ok(); + if let Some(n) = client_nonce { + let arr = js_sys::Uint8Array::from(&n.to_be_bytes()[..]); + js_sys::Reflect::set(&obj, &"clientNonce".into(), &arr).ok(); + } + if let Some(id) = assigned_id { + js_sys::Reflect::set(&obj, &"assignedId".into(), &JsValue::from(id as f64)).ok(); + } + if let Some(ts) = timestamp { + js_sys::Reflect::set(&obj, &"timestamp".into(), &JsValue::from(ts as f64)).ok(); + } + if let Some(sig) = signature { + let arr = js_sys::Uint8Array::from(&sig[..]); + js_sys::Reflect::set(&obj, &"signature".into(), &arr).ok(); + } + + Ok(obj.into()) +} diff --git a/wasm/src/transport.rs b/wasm/src/transport.rs new file mode 100644 index 0000000..5323cc9 --- /dev/null +++ b/wasm/src/transport.rs @@ -0,0 +1,274 @@ +use wasm_bindgen::prelude::*; +use wasm_bindgen::JsCast; +use wasm_bindgen_futures::JsFuture; +use web_sys::WebTransport; + +use crate::error::js_error; + +#[derive(Clone)] +pub struct WasmTransport { + inner: WebTransport, +} + +impl WasmTransport { + pub async fn connect(url: &str) -> Result { + let transport = WebTransport::new(url)?; + JsFuture::from(transport.ready()).await + .map_err(|e| js_error(&format!("WebTransport ready failed: {:?}", e)))?; + Ok(Self { inner: transport }) + } + + pub fn inner(&self) -> &WebTransport { + &self.inner + } + + pub fn from_inner(inner: WebTransport) -> Self { + Self { inner } + } + + pub async fn send_frame(&self, frame: &[u8]) -> Result<(), JsValue> { + let stream_promise = self.inner.create_unidirectional_stream(); + let stream = JsFuture::from(stream_promise).await?; + + let writable = js_sys::Reflect::get(&stream, &JsValue::from_str("writable")) + .map_err(|_| js_error("missing writable"))?; + + let writer_val = js_sys::Reflect::get(&writable, &JsValue::from_str("getWriter")) + .map_err(|_| js_error("missing getWriter"))? + .dyn_into::() + .map_err(|_| js_error("getWriter not a function"))? + .call0(&writable) + .map_err(|_| js_error("getWriter call failed"))?; + + let len = frame.len() as u32; + let mut wire = Vec::with_capacity(4 + frame.len()); + wire.extend_from_slice(&len.to_be_bytes()); + wire.extend_from_slice(frame); + + let chunk = js_sys::Uint8Array::from(&wire[..]); + + let write_fn = js_sys::Reflect::get(&writer_val, &JsValue::from_str("write")) + .map_err(|_| js_error("missing write"))? + .dyn_into::() + .map_err(|_| js_error("write not a function"))?; + let write_promise = write_fn.call1(&writer_val, &chunk) + .map_err(|e| js_error(&format!("write failed: {:?}", e)))?; + JsFuture::from(write_promise.unchecked_into::()).await?; + + let close_fn = js_sys::Reflect::get(&writer_val, &JsValue::from_str("close")) + .map_err(|_| js_error("missing close"))? + .dyn_into::() + .map_err(|_| js_error("close not a function"))?; + let close_promise = close_fn.call0(&writer_val) + .map_err(|e| js_error(&format!("close failed: {:?}", e)))?; + JsFuture::from(close_promise.unchecked_into::()).await?; + + Ok(()) + } + + /// Read exactly one frame from incoming uni streams, then release the reader + /// so `receive_loop` can pick up from where we left off. + pub async fn read_one_frame(&self) -> Result, JsValue> { + let incoming = self.inner.incoming_unidirectional_streams(); + + let reader_fn = js_sys::Reflect::get(&incoming, &JsValue::from_str("getReader")) + .map_err(|_| js_error("missing getReader"))? + .dyn_into::() + .map_err(|_| js_error("getReader not a function"))?; + let reader_val = reader_fn.call0(&incoming) + .map_err(|_| js_error("getReader call failed"))?; + + let read_fn = js_sys::Reflect::get(&reader_val, &JsValue::from_str("read")) + .map_err(|_| js_error("missing read"))? + .dyn_into::() + .map_err(|_| js_error("read not a function"))?; + let result_promise = read_fn.call0(&reader_val) + .map_err(|_| js_error("read call failed"))?; + let result = JsFuture::from(result_promise.unchecked_into::()).await + .map_err(|e| js_error(&format!("read failed: {:?}", e)))?; + + // Release the reader lock so receive_loop can create its own reader + if let Some(release_fn) = js_sys::Reflect::get(&reader_val, &JsValue::from_str("releaseLock")) + .ok().and_then(|f| f.dyn_into::().ok()) + { + let _ = release_fn.call0(&reader_val); + } + + let done = js_sys::Reflect::get(&result, &JsValue::from_str("done")) + .ok().and_then(|v| v.as_bool()).unwrap_or(false); + if done { + return Err(js_error("stream ended before frame")); + } + + let recv_stream = js_sys::Reflect::get(&result, &JsValue::from_str("value")) + .map_err(|_| js_error("missing value"))?; + + let readable = js_sys::Reflect::get(&recv_stream, &JsValue::from_str("readable")) + .map_err(|_| js_error("missing readable"))?; + + let stream_reader_fn = js_sys::Reflect::get(&readable, &JsValue::from_str("getReader")) + .map_err(|_| js_error("missing stream getReader"))? + .dyn_into::() + .map_err(|_| js_error("stream getReader not a function"))?; + let stream_reader = stream_reader_fn.call0(&readable) + .map_err(|_| js_error("stream getReader call failed"))?; + + let mut chunks: Vec> = Vec::new(); + loop { + let stream_read_fn = match js_sys::Reflect::get(&stream_reader, &JsValue::from_str("read")) + .ok().and_then(|f| f.dyn_into::().ok()) + { + Some(f) => f, + None => break, + }; + let chunk_promise = match stream_read_fn.call0(&stream_reader) { + Ok(p) => p, + Err(_) => break, + }; + let chunk_result = match JsFuture::from(chunk_promise.unchecked_into::()).await { + Ok(v) => v, + Err(_) => break, + }; + + let chunk_done = js_sys::Reflect::get(&chunk_result, &JsValue::from_str("done")) + .ok().and_then(|v| v.as_bool()).unwrap_or(true); + if chunk_done { + break; + } + + if let Ok(chunk_val) = js_sys::Reflect::get(&chunk_result, &JsValue::from_str("value")) { + let arr = js_sys::Uint8Array::new(&chunk_val).to_vec(); + if !arr.is_empty() { + chunks.push(arr); + } + } + } + + if chunks.is_empty() { + return Err(js_error("empty stream")); + } + + let total: usize = chunks.iter().map(|c| c.len()).sum(); + let mut buf = Vec::with_capacity(total); + for c in &chunks { + buf.extend_from_slice(c); + } + + if buf.len() < 4 { + return Err(js_error("frame too short")); + } + let frame_len = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]) as usize; + if 4 + frame_len > buf.len() { + return Err(js_error("frame truncated")); + } + + Ok(buf[4..4 + frame_len].to_vec()) + } + + pub async fn receive_loop( + &self, + on_message: js_sys::Function, + on_error: js_sys::Function, + ) { + let incoming = self.inner.incoming_unidirectional_streams(); + + let reader_fn = match js_sys::Reflect::get(&incoming, &JsValue::from_str("getReader")) { + Ok(f) => f.dyn_into::().unwrap(), + Err(_) => return, + }; + let reader_val = match reader_fn.call0(&incoming) { + Ok(v) => v, + Err(_) => return, + }; + + loop { + let read_fn = match js_sys::Reflect::get(&reader_val, &JsValue::from_str("read")) { + Ok(f) => f.dyn_into::().unwrap(), + Err(_) => break, + }; + let result = match read_fn.call0(&reader_val) { + Ok(p) => match JsFuture::from(p.unchecked_into::()).await { + Ok(v) => v, + Err(e) => { + let _ = on_error.call1(&JsValue::NULL, &JsValue::from_str( + &format!("read stream failed: {:?}", e) + )); + break; + } + }, + Err(_) => break, + }; + + let done = js_sys::Reflect::get(&result, &JsValue::from_str("done")) + .ok().and_then(|v| v.as_bool()).unwrap_or(false); + if done { break; } + + let recv_stream = match js_sys::Reflect::get(&result, &JsValue::from_str("value")) { + Ok(v) => v, + Err(_) => continue, + }; + + let readable = match js_sys::Reflect::get(&recv_stream, &JsValue::from_str("readable")) { + Ok(v) => v, + Err(_) => continue, + }; + + let stream_reader_fn = match js_sys::Reflect::get(&readable, &JsValue::from_str("getReader")) { + Ok(f) => f.dyn_into::().unwrap(), + Err(_) => continue, + }; + let stream_reader = match stream_reader_fn.call0(&readable) { + Ok(v) => v, + Err(_) => continue, + }; + + let mut chunks: Vec> = Vec::new(); + loop { + let stream_read_fn = match js_sys::Reflect::get(&stream_reader, &JsValue::from_str("read")) { + Ok(f) => f.dyn_into::().unwrap(), + Err(_) => break, + }; + let chunk_result = match stream_read_fn.call0(&stream_reader) { + Ok(p) => match JsFuture::from(p.unchecked_into::()).await { + Ok(v) => v, + Err(_) => break, + }, + Err(_) => break, + }; + + let chunk_done = js_sys::Reflect::get(&chunk_result, &JsValue::from_str("done")) + .ok().and_then(|v| v.as_bool()).unwrap_or(true); + if chunk_done { break; } + + if let Ok(chunk_val) = js_sys::Reflect::get(&chunk_result, &JsValue::from_str("value")) { + let arr = js_sys::Uint8Array::new(&chunk_val).to_vec(); + if !arr.is_empty() { chunks.push(arr); } + } + } + + if chunks.is_empty() { continue; } + + let total: usize = chunks.iter().map(|c| c.len()).sum(); + let mut buf = Vec::with_capacity(total); + for c in &chunks { buf.extend_from_slice(c); } + + if buf.len() < 4 { + let _ = on_error.call1(&JsValue::NULL, &JsValue::from_str("frame too short")); + continue; + } + let frame_len = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]) as usize; + if 4 + frame_len > buf.len() { + let _ = on_error.call1(&JsValue::NULL, &JsValue::from_str("frame truncated")); + continue; + } + let frame = buf[4..4 + frame_len].to_vec(); + let arr = js_sys::Uint8Array::from(&frame[..]); + let _ = on_message.call1(&JsValue::NULL, &arr); + } + } + + pub fn close(&self) { + let info = web_sys::WebTransportCloseInfo::new(); + let _ = self.inner.close_with_close_info(&info); + } +}