From be4b76dcd56027c8050e3034f1a663e738056011 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:28:14 +0200 Subject: [PATCH] Docs & wasm --- NATIVE-CLIENT.md | 325 ++++++++++++++++++++++++++++++++++++++++++ NATIVE-HOST.md | 317 ++++++++++++++++++++++++++++++++++++++++ README.md | 2 + WASM-CLIENT.md | 281 ++++++++++++++++++++++++++++++++++++ wasm/Cargo.toml | 2 + wasm/src/client.rs | 6 +- wasm/src/transport.rs | 26 +++- 7 files changed, 953 insertions(+), 6 deletions(-) create mode 100644 NATIVE-CLIENT.md create mode 100644 NATIVE-HOST.md create mode 100644 WASM-CLIENT.md diff --git a/NATIVE-CLIENT.md b/NATIVE-CLIENT.md new file mode 100644 index 0000000..8f09062 --- /dev/null +++ b/NATIVE-CLIENT.md @@ -0,0 +1,325 @@ +# MTP Native Client + +The native client is a Rust library (`mtp-client`) for connecting to an MTP host over QUIC. It uses `wtransport` under the hood and provides both unauthenticated and authenticated (crypto handshake) connection modes. + +## Cargo Dependency + +Add the `mtp` umbrella crate with the `client` feature (and optionally `crypto` for authentication): + +```toml +[dependencies] +mtp = { path = "/path/to/mtp", features = ["client"] } + +# Add crypto for auth_connect / auth_register: +mtp = { path = "/path/to/mtp", features = ["client", "crypto"] } +``` + +## ClientConfig + +```rust +use mtp::client::ClientConfig; + +let config = ClientConfig { + url: "https://host.example.com:4433".into(), + server_cert: None, // None = use system root certificates + client_id: 0, // previously assigned ID or 0 +}; +``` + +| Field | Type | Description | +|--------------|--------------------|-----------------------------------------------------| +| `url` | `String` | `https://host:port` address of the MTP host | +| `server_cert`| `Option>` | `None` to use system roots, `Some(pem_bytes)` to pin | +| `client_id` | `u64` | Client identifier (ignored during `auth_register`) | + +### TLS Certificate Handling + +When `server_cert` is `None` (the default), the client loads the **system's +native root certificate store** via `rustls_native_certs`. This works with +publicly-trusted CAs out of the box on Linux (using `openssl-probe`), macOS +(Keychain), and Windows (Root Store). + +For development or self-signed certificates, provide one or more PEM-encoded +certificates: + +```rust +let pem = std::fs::read("my-server-cert.pem")?; +let config = ClientConfig { + server_cert: Some(pem), + // ... +}; +``` + +When pinned, **only** the given certificate(s) are trusted for the TLS +handshake. + +## Connection Methods + +All methods return a `Result`. + +### MTPConnection + +```rust +pub struct MTPConnection { + pub version: Version, + pub sender: Sender, + pub receiver: Receiver, + #[cfg(feature = "crypto")] + pub auth_state: AuthState, + #[cfg(feature = "crypto")] + pub client_id: u64, +} +``` + +- `version` -- the negotiated protocol version +- `sender` / `receiver` -- for message I/O +- `client_id` -- the confirmed/assigned client identifier (crypto only) + +### Unauthenticated Connect + +```rust +use mtp::client::{MTPClient, ClientConfig}; + +let config = ClientConfig { + url: "https://host.example.com:4433".into(), + server_cert: None, + client_id: 42, +}; + +let conn = MTPClient::connect(config).await?; +``` + +Sends an `Identification` frame with the compiled-in protocol version and +client ID. No cryptographic handshake is performed. + +### Authenticated Login + +```rust +use mtp::client::MTPClient; +use mtp::crypto::{Keyring, PublicKeyBundle}; + +let keys = Keyring::from_bytes(&saved_keyring_bytes)?; +let host_pk = PublicKeyBundle::from_bytes(&saved_host_pk_bytes)?; + +let config = ClientConfig { + client_id: 42, // must match the keyring's identity + // ... +}; + +let conn = MTPClient::auth_connect(config, &keys, &host_pk).await?; +``` + +Protocol: +1. Client generates a random nonce +2. Builds a signature payload: `version || client_id || client_nonce` +3. Signs with Ed25519 (and optionally ML-DSA-65) +4. Sends `Identification` frame containing version, client ID, nonce, signature(s) +5. Host responds with `IdentificationResponse` containing echoed nonce, host + nonce, and host signature +6. Client verifies the host signature and nonce echo + +### Registration + +```rust +let (ed_signer, sig_sk, sig_pk) = mtp::crypto::Ed25519Signer::generate(); +let (pq_signer, sig_pq_sk, sig_pq_pk) = mtp::crypto::MlDsaSigner::generate(); +let (kem_sk, kem_pk) = mtp::crypto::HybridKem::generate_keypair(); + +let keyring = Keyring::new(kem_pk, kem_sk, sig_pq_pk, sig_pq_sk, sig_pk, sig_sk); + +let conn = MTPClient::auth_register(config, &keyring, &host_pk).await?; + +// Save for next session +let id = conn.client_id; +let keyring_bytes = keyring.to_bytes(); +``` + +Protocol: +1. Client generates a random nonce +2. Builds a signature payload: `version || client_nonce || public_key_bytes` +3. Signs with Ed25519 (and optionally ML-DSA-65) +4. Sends `Register` frame containing version, nonce, public key bundle, signature(s) +5. Host assigns a new client ID, responds with `RegisterResponse` containing + the ID, echoed nonce, host nonce, and host signature +6. Client verifies the host signature and nonce echo + +## Key Material + +### Keyring + +A `Keyring` bundles all secret and public key material for one identity: + +```rust +pub struct Keyring { + pub kem_secret_key: KemPrivateKey, + pub kem_public_key: KemPublicKey, + pub sig_cl_secret_key: SignaturePrivateKey, // Ed25519 + pub sig_cl_public_key: SignaturePublicKey, // Ed25519 + pub sig_pq_secret_key: SignaturePqPrivateKey, // ML-DSA-65 + pub sig_pq_public_key: SignaturePqPublicKey, // ML-DSA-65 +} +``` + +- Serialise: `keyring.to_bytes()` -> `Vec` +- Deserialise: `Keyring::from_bytes(&bytes)` -> `Result` +- Get public half: `keyring.public_key_bundle()` -> `PublicKeyBundle` + +### PublicKeyBundle + +The public half of a keyring, used by the host for signature verification and +by the client for host signature verification: + +```rust +pub struct PublicKeyBundle { + pub kem_public_key: KemPublicKey, + pub sig_cl_public_key: SignaturePublicKey, + pub sig_pq_public_key: SignaturePqPublicKey, +} +``` + +Obtain the host's `PublicKeyBundle` out of band (e.g. from files exported by +the host, or from a trusted directory). + +## Sending and Receiving Messages + +### CommunicationValue + +Messages are `CommunicationValue` frames. Construct them with the builder API: + +```rust +use mtp::codec::{CommunicationValue, CommunicationType, DataType, DataValue}; +use mtp::type_map::TypeMap; + +let msg = CommunicationValue::new(CommunicationType::Ping) + .with_sender(conn.client_id) + .add_typed_default(DataType::Description, DataValue::Str("hello".into())) + .add_typed_default(DataType::Timestamp, DataValue::UnsignedNumber(now)) + .to_bytes(); +``` + +When the `registry` feature is enabled (via the `host` feature), you can also +use `add_typed` with a `TypeMap` to resolve data type names from your project's +type-map configuration. + +### Send + +```rust +conn.sender.send(&msg).await?; +``` + +Two send modes (configured via `mtp::transport::Policy`): +- `PersistentStream` (default) -- reuses one QUIC uni-directional stream +- `SingleStreamPerMessage` -- opens a new stream per message + +### Receive + +```rust +match conn.receiver.receive().await { + Ok(msg) => { /* handle CommunicationValue */ } + Err(e) => { /* connection closed or error */ } +} +``` + +Inbound frames are queued internally. The `receive()` method returns the next +available message. + +### Close + +```rust +conn.sender.close(); +// or +conn.receiver.close(); +``` + +Sends a close frame and signals the peer. The `Sender::close()` spawns an async +task that sends the frame, waits for `force_close_delay` (default 300ms), then +force-closes the QUIC connection if the peer has not already done so. + +## Crypto Containers + +With the `crypto` feature, `DataValue` supports encrypted, signed, and +signed+encrypted containers: + +```rust +use mtp::crypto::{ChaCha20Poly1305, Ed25519Signer, SigAlgorithm}; + +let cipher = ChaCha20Poly1305::new(derive_encryption_key(...)); +let signer = Ed25519Signer::new(&keyring.sig_cl_secret_key)?; + +// Encrypted container +let mut enc = DataValue::Container(vec![ + (DataTypeId(1), DataValue::Str("secret".into())), +]); +enc.encrypt_container(&cipher, b"aad"); + +// Signed container +let mut sig = DataValue::Container(vec![ + (DataTypeId(1), DataValue::Str("signed".into())), +]); +sig.sign_container(SigAlgorithm::ED25519, &signer); + +// Signed + encrypted +let mut sec = DataValue::Container(vec![ + (DataTypeId(1), DataValue::Str("both".into())), +]); +sec.sign_and_encrypt_container(SigAlgorithm::ED25519, &signer, &cipher, b"aad"); +``` + +On the receiving side, use the corresponding `decrypt_into_container`, +`verify_into_container`, or `decrypt_signed_encrypted_container` methods. + +## Policy Configuration + +The `Policy` struct controls transport behaviour: + +```rust +use mtp::transport::{Policy, SendMode}; + +let policy = Policy { + send_mode: SendMode::PersistentStream, + max_message_size: 1_000_000_000, + open_stream_timeout: Duration::from_millis(2000), + write_timeout: Duration::from_millis(2000), + read_timeout: Duration::from_millis(30_000), + keep_alive_interval: Some(Duration::from_secs(3)), + max_idle_timeout: Some(Duration::from_secs(30)), + ..Default::default() +}; +``` + +To apply a custom policy, call `mtp_transport::connect()` directly instead of +using `MTPClient`: + +```rust +use mtp_transport::{connect, Policy}; + +let (sender, receiver) = connect(&config.url, config.server_cert, policy).await?; +``` + +Then build and send the initial `Identification` frame manually to complete +version negotiation. + +## Version + +The client's protocol version is baked in at compile time via the +`PROTOCOL_VERSION` constant from `mtp_codec`. The version is set by the +`protocol_version` field in your `type-maps.yaml`. + +The client never imports the `registry` module; it uses a single compiled-in +version and expects the host to negotiate a compatible version. + +## Error Handling + +`CommunicationError` covers transport errors: + +| Variant | Meaning | +|-------------------------|--------------------------------------------| +| `StreamClosed` | Connection was closed by peer or timed out | +| `StreamError` | Transport-level I/O error | +| `MessageTooLarge` | Frame exceeds `max_message_size` | +| `ParseCommunicationValue` | Failed to deserialize incoming frame | +| `AuthenticationFailed` | Nonce mismatch or invalid host signature | +| `ConnectionError` | QUIC connection failure | +| `UseAfterClosed` | Attempted send/receive after close | + + diff --git a/NATIVE-HOST.md b/NATIVE-HOST.md new file mode 100644 index 0000000..94d095b --- /dev/null +++ b/NATIVE-HOST.md @@ -0,0 +1,317 @@ +# MTP Native Host + +The native host is a Rust library (`mtp-host`) that runs a QUIC server, accepts +MTP client connections, negotiates protocol versions, and optionally performs a +mutual-authentication handshake (login/register) using Ed25519 and ML-DSA-65 +signatures. + +## Cargo Dependency + +```toml +[dependencies] +mtp = { path = "/path/to/mtp", features = ["host"] } + +# Add crypto for authenticated connections: +mtp = { path = "/path/to/mtp", features = ["host", "crypto"] } +``` + +## HostConfig + +```rust +use mtp::host::HostConfig; +use std::net::{IpAddr, Ipv4Addr}; + +let config = HostConfig { + ip: IpAddr::V4(Ipv4Addr::UNSPECIFIED), + port: 4433, + tls_fullchain: std::fs::read("cert.pem")?, + tls_key: std::fs::read("key.pem")?, + + // Crypto fields (required when feature = "crypto"): + require_authentication: true, + host_id: 1, + host_keyring: /* Keyring */, + get_existing_user: Box::new(|client_id: u64| -> Option { + CLIENT_DB.lock().unwrap().get(&client_id).cloned() + }), + complete_register: Box::new(|bundle: PublicKeyBundle| -> u64 { + let id = next_id(); + CLIENT_DB.lock().unwrap().insert(id, bundle); + id + }), +}; +``` + +| Field | Type | Description | +|------------------------|-----------------------------------------|------------------------------------------------| +| `ip` | `IpAddr` | Bind address | +| `port` | `u16` | Listen port | +| `tls_fullchain` | `Vec` | PEM-encoded TLS certificate chain | +| `tls_key` | `Vec` | PEM-encoded TLS private key | +| `require_authentication` | `bool` (crypto) | Enable login/register handshake | +| `host_id` | `u64` (crypto) | Host identifier | +| `host_keyring` | `Keyring` (crypto) | Host's signing and KEM keys | +| `get_existing_user` | `Box Option + Send>` (crypto) | Lookup callback for login | +| `complete_register` | `Box u64 + Send>` (crypto) | Registration callback, returns new client ID | + +### TLS + +The host requires a TLS certificate. For development, generate a self-signed +certificate using `rcgen`. For production, use a CA-signed certificate. + +## Accepting Connections + +```rust +use mtp::host::MTPHost; + +let mut host = MTPHost::new(config).await?; +println!("Listening on {}", host.local_addr()); + +while let Some(conn) = host.accept().await { + // conn is an MTPConnection ready for I/O +} +``` + +### MTPConnection + +Returned by `accept()` after version negotiation (and authentication if +enabled): + +```rust +pub struct MTPConnection { + pub version: Version, + pub codec: VersionedCodec, + pub sender: Sender, + pub receiver: Receiver, + #[cfg(feature = "crypto")] + pub auth_state: AuthState, + #[cfg(feature = "crypto")] + pub client_id: u64, + #[cfg(feature = "crypto")] + pub client_public_key: Option, +} +``` + +- `version` -- the negotiated protocol version +- `codec` -- a `VersionedCodec` scoped to the negotiated version (use for + version-aware encode/decode) +- `sender` / `receiver` -- for message I/O +- `client_id` -- the authenticated client's ID +- `client_public_key` -- the client's public key bundle (for signature + verification of subsequent messages) + +## Version Negotiation + +When a client connects, `accept()` performs the following sequence: + +1. Accept the QUIC connection +2. Read the client's first `CommunicationValue` (always encoded with reserved + type IDs) +3. Extract the protocol version from `DataType::Version` (wire ID 3) as a + `DataValue::Str("major.minor")` +4. Call `registry.negotiate(&[client_version])` to find the highest mutually + supported version +5. Return `None` (closing the connection) if no compatible version exists +6. Return an `MTPConnection` with the negotiated version + +The `Registry` is built automatically from all type maps defined in your +`type-maps.yaml` via `Registry::builtin()`. + +### Registry + +```rust +use mtp::codec::registry::Registry; + +let registry = host.registry(); +assert!(registry.supports(&Version(2, 0))); + +let negotiated = registry.negotiate(&[Version(1, 0), Version(2, 0)]); +// -> Some(Version(2, 0)) if both versions are registered +``` + +## Authentication Flow (crypto feature) + +When `require_authentication` is `true`, `accept()` runs an authenticated +handshake before returning the connection. The flow is: + +### Login (existing client) + +``` +Client Host + | | + | QUIC connect | + |---------------------------------------->| + | | + | Identification { | + | Version, Id, ClientNonce, | + | Signature, [PqSignature] | + | } | + |---------------------------------------->| + | | lookup get_existing_user(client_id) + | | verify Ed25519 (and optional ML-DSA) sig + | IdentificationResponse { | + | Connected=true, ClientNonce(echoed), | + | Id, Timestamp(new_nonce), | + | Signature, [PqSignature] | + | } | + |<----------------------------------------| +``` + +The client signature payload is: `version_string || client_id (8 bytes, big-endian) || client_nonce (16 bytes, big-endian)` + +The host signs: `0x01 || assigned_id (8 bytes, big-endian) || client_nonce (16 bytes) || host_new_nonce (16 bytes)` + +### Register (new client) + +``` +Client Host + | | + | QUIC connect | + |---------------------------------------->| + | | + | Register { | + | Version, ClientNonce, | + | PublicKeys (serialized PublicKeyBundle), + | Signature, [PqSignature] | + | } | + |---------------------------------------->| + | | extract PublicKeyBundle from frame + | | verify Ed25519 (and optional ML-DSA) sig + | | call complete_register(bundle) -> new_id + | RegisterResponse { | + | Connected=true, ClientNonce(echoed), | + | Id, Timestamp(new_nonce), | + | Signature, [PqSignature] | + | } | + |<----------------------------------------| +``` + +The client signature payload is: `version_string || client_nonce (16 bytes) || public_key_bytes` + +After a successful handshake, `accept()` returns an `MTPConnection` with +`auth_state = Authenticated`, `client_id` set, and `client_public_key` +available for verifying subsequent signed messages from the client. + +### Rejection + +If verification fails or the client is not found (login), the host sends a +rejection response with `Connected=false` and closes the send stream, returning +`None` from `accept()`. + +## Handling Messages + +Use `conn.sender` and `conn.receiver` for bidirectional message exchange: + +```rust +while let Some(conn) = host.accept().await { + tokio::spawn(async move { + loop { + match conn.receiver.receive().await { + Ok(msg) => { + let response = process_message(&msg, &conn); + conn.sender.send(&response).await.ok(); + } + Err(_) => break, + } + } + }); +} +``` + +### Versioned Codec + +The `conn.codec` is a `VersionedCodec` pre-configured with the negotiated +version. Use it to encode/decode with version-specific type maps: + +```rust +let tm = conn.codec.registry().get(&conn.version).unwrap(); + +// Look up type IDs for the negotiated version +let desc_id = DataTypeId(tm.data_id_enum(DataType::Description).unwrap()); +let value = msg.get_data(desc_id); +``` + +## Host Callbacks + +### get_existing_user + +Called during login to retrieve a client's public key bundle for signature +verification. Must return `Some(PublicKeyBundle)` if the client ID is known, +or `None` to reject. + +```rust +let get_existing_user = Box::new(|id: u64| -> Option { + db.lock().unwrap().get(&id).cloned() +}); +``` + +### complete_register + +Called during registration to persist a new client's public key bundle and +assign a client ID. The returned `u64` becomes the client's permanent +identifier. + +```rust +let complete_register = Box::new(|bundle: PublicKeyBundle| -> u64 { + let mut db = db.lock().unwrap(); + let id = next_id; + next_id += 1; + db.insert(id, bundle); + id +}); +``` + +Both callbacks are called from within `accept()` and must be `Send`. They are +invoked synchronously, so avoid long-running operations (or use `spawn_blocking` +if needed, though the callbacks are `Fn`, not `AsyncFn`). + +## Host Key Generation + +Generate a host keyring once and persist it: + +```rust +use mtp::crypto::{Ed25519Signer, Keyring, MlDsaSigner}; +use mtp::crypto::kem::HybridKem; + +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 host_keyring = Keyring::new(kem_pk, kem_sk, sig_pq_pk, sig_pq_sk, sig_pk, sig_sk); + +// Save to disk +let bytes = host_keyring.to_bytes(); +std::fs::write("host_keys.bin", bytes)?; +``` + +Export the public key bundle so clients can verify the host identity: + +```rust +let bundle = host_keyring.public_key_bundle(); +std::fs::write("host_enc_kem_pk.bin", bundle.kem_public_key.as_bytes())?; +std::fs::write("host_sig_pk.bin", bundle.sig_cl_public_key.as_bytes())?; +std::fs::write("host_sig_pq_pk.bin", bundle.sig_pq_public_key.as_bytes())?; +``` + +## Policy + +The transport `Policy` is set to defaults internally. To customise (timeouts, +send mode, etc.), use `mtp_transport::host()` directly instead of `MTPHost`: + +```rust +use mtp_transport::{host, Policy}; + +let transport = host(ip, port, cert, key, custom_policy).await?; +// Then build version negotiation on top: +// - accept transport.next() +// - read first frame +// - registry.negotiate() +// - return MTPConnection +``` + +## Graceful Shutdown + +Drop the `MTPHost` to stop accepting new connections. Active connections +continue until their `Sender`/`Receiver` are dropped or the peer disconnects. + + diff --git a/README.md b/README.md index 676b44e..fa3bbca 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ MTP is a modular transport protocol built on QUIC. It provides version-negotiable type maps, a binary codec, cryptographic primitives (classical and post-quantum), and host/client connection management with mutual authentication. +There are Area specific docs when working with seperate concerns for the [Native-Client](./NATIVE-CLIENT.md), [WASM-Client](./WASM-CLIENT.md) & [Host](./NATIVE-HOST.md) + ## Getting Started Add the `mtp` crate with your desired features: diff --git a/WASM-CLIENT.md b/WASM-CLIENT.md new file mode 100644 index 0000000..71a8295 --- /dev/null +++ b/WASM-CLIENT.md @@ -0,0 +1,281 @@ +# MTP WASM Client + +The WASM client is a browser-compatible MTP implementation that uses the **WebTransport** API to communicate with an MTP host over QUIC (HTTP/3). It compiles from Rust to WebAssembly via `wasm-bindgen` and exposes a JavaScript/TypeScript API through the `mtp-wasm` npm package. + +## Package + +The compiled package lives in `wasm/pkg/` and contains: + +- `mtp_wasm.js` -- generated JS glue +- `mtp_wasm_bg.wasm` -- the WebAssembly binary +- `mtp_wasm.d.ts` -- TypeScript type declarations +- `package.json` -- npm package definition + +Install or copy these files into your web project. Then initialise the module: + +```typescript +import init, { WasmClient } from 'mtp-wasm'; + +await init(); +``` + +## Browser Support + +WebTransport is required. Check availability at runtime: + +```typescript +if (!WasmClient.is_supported()) { + // fall back or show an error +} +``` + +## Connecting to a Host + +### ConnectionConfig + +```typescript +const config = new ConnectionConfig("https://host.example.com:4433"); +config.client_id = 12345n; // optional, for re-authentication +config.server_certificate_hashes = [ // optional, for certificate pinning + "sha256:abc123...", +]; +``` + +`client_id` is only needed for authenticated login (`auth_connect`). For registration (`auth_register`) it is ignored. + +### TLS Certificate Handling + +By default — when `server_certificate_hashes` is not set — the browser uses its +**built-in root certificate store** to verify the server's TLS certificate, +just like any other HTTPS/WebSocket connection. This works with publicly-trusted +certificate authorities automatically. + +For development or self-signed certificates, pin the server certificate by +providing its hash: + +```typescript +config.server_certificate_hashes = [ + "sha256:abcd1234...", // hex-encoded hash value +]; +``` + +The hash format is `":"`. When hashes are +provided, the browser **only** trusts certificates matching one of the given +hashes and ignores its root store for this connection. + +### Callbacks + +The client uses three callbacks for state, messages, and errors: + +```typescript +const client = new WasmClient( + (state: number) => console.log("state", state), // ConnectionState enum + (data: Uint8Array) => console.log("msg", data), // raw frame bytes + (err: any) => console.error("err", err), // error description +); +``` + +### Connection States + +| Value | Name | +|-------|--------------| +| 0 | Disconnected | +| 1 | Connecting | +| 2 | Connected | +| 3 | Failed | + +Poll `client.state` at any time. + +## Connection Methods + +### Unauthenticated Connect + +```typescript +await client.connect(config); +``` + +Sends an `Identification` frame with the protocol version and client ID. The host may accept or reject. No cryptographic handshake occurs. + +### Authenticated Login (existing client ID) + +```typescript +const confirmedId = await client.auth_connect( + config, + hostPublicKeyBytes, // Uint8Array: serialized PublicKeyBundle from the host + keyringBytes, // Uint8Array: serialized Keyring matching the client ID + clientId, // bigint: previously assigned client ID +); +``` + +Exchange: client sends a signed `Identification` frame, the host verifies it and +responds with a signed `IdentificationResponse`. Returns the confirmed client ID. + +### Registration (new client) + +```typescript +const newId = await client.auth_register( + config, + hostPublicKeyBytes, // Uint8Array: serialized PublicKeyBundle from the host + keyringBytes, // Uint8Array: serialized Keyring for the new identity +); +``` + +Exchange: client sends a signed `Register` frame with public keys, the host +assigns a new ID and responds with a signed `RegisterResponse`. Returns the +newly assigned client ID. + +## Sending and Receiving Messages + +### Send + +```typescript +const frame = build_ping_frame(clientId, "hello", timestamp, data); +await client.send(frame); +``` + +`send()` takes raw frame bytes (a serialized `CommunicationValue`). Build frames +with the provided helper functions or construct them manually. + +### Receive + +Incoming frames arrive on the `on_message` callback registered in the constructor. +The callback receives a `Uint8Array` of raw frame bytes. Parse with +`CommunicationValue.from_bytes()` on the Rust side or handle the bytes in JS. + +### Disconnect + +```typescript +client.disconnect(); +``` + +Gracefully closes the WebTransport session. + +## Building Frames + +### `build_ping_frame` + +```typescript +function build_ping_frame( + clientId: bigint, + description: string, + timestamp: bigint, + data: Uint8Array, +): Uint8Array; +``` + +Constructs a basic `Ping` message with description, timestamp, and optional +binary payload. Useful for health checks and simple messaging. + +### `build_demo_message` + +```typescript +function build_demo_message( + clientId: bigint, + keyringBytes: Uint8Array, +): Uint8Array; +``` + +Constructs a `Ping` frame that demonstrates encrypted, signed, and +signed+encrypted containers using a deterministic demo key. The paired host +handler can decrypt and verify these containers if it knows the same shared +secret. + +### `parse_auth_response` + +```typescript +function parse_auth_response(response: Uint8Array): any; +``` + +Parses an `IdentificationResponse` or `RegisterResponse` frame into a JS object: + +```typescript +{ + connected: boolean, + clientNonce?: Uint8Array, + assignedId?: number, + timestamp?: number, + signature?: Uint8Array, +} +``` + +## Crypto Primitives + +### Key Generation + +```typescript +const result = ed25519_generate(); +// result.signer -> WasmEd25519Signer +// result.secretKey -> Uint8Array (32 bytes) +// result.publicKey -> Uint8Array (32 bytes) +``` + +### Keyring + +A `Keyring` bundles all key material for an identity. For Ed25519-only setups: + +```typescript +const keyringBytes = keyring_from_ed25519(secretKey, publicKey); +// keyringBytes is ready for WasmClient.auth_register or WasmClient.auth_connect +``` + +Full keyring with KEM + ML-DSA requires constructing on the Rust side. The +serialized bytes are portable: + +```typescript +const keyring = WasmKeyring.from_bytes(keyringBytes); +const bundle = keyring.public_key_bundle(); +// bundle.kem_public_key -> Uint8Array +// bundle.sig_cl_public_key -> Uint8Array +// bundle.sig_pq_public_key -> Uint8Array +``` + +### Signing and Verification + +```typescript +const signer = new WasmEd25519Signer(secretKey); +const sig = signer.sign(message); // Uint8Array +signer.verify(message, sig); // throws on mismatch + +// Standalone verification (no signer object needed): +ed25519_verify(publicKey, message, signature); +``` + +### Symmetric Encryption + +```typescript +const cipher = new WasmChaCha20Poly1305(key); // 32-byte key +const encrypted = cipher.encrypt(plaintext, aad); // nonce || ciphertext +const decrypted = cipher.decrypt(encrypted, aad); +``` + +### Hashing and KDF + +```typescript +const hash = wasm_sha256(data); // 32 bytes +const double = wasm_sha256_double(data); // SHA-256(SHA-256(data)) + +const derived = wasm_hkdf_expand(ikm, salt, info, len); +const encKey = wasm_derive_encryption_key(ikm, salt, context); // 32 bytes +``` + +## Lifecycle and Best Practices + +1. **Key persistence** -- serialise keyring bytes after registration and store + them (e.g. in `localStorage`). On next visit, load the saved keyring and + call `auth_connect` instead of registering again. + +2. **Ownership** -- call `config.free()` after connecting if the config object + is no longer needed. WASM objects (`WasmClient`, `WasmKeyring`, etc.) are + garbage-collected, but explicit `free()` or `dispose()` reclaims memory + sooner. + +3. **Receive loop** -- once `connect`, `auth_connect`, or `auth_register` + resolves, the receive loop is running in the background. Incoming frames + arrive on the `on_message` callback. There is no need to poll. + +4. **Single active client** -- a `WasmClient` manages one WebTransport session. + Create a new instance for each connection. + +5. **State transitions** -- after `disconnect()` the client transitions to + `Disconnected`. The instance is reusable; call a connect method again to + open a new session. diff --git a/wasm/Cargo.toml b/wasm/Cargo.toml index 3ac76f7..c76b05e 100644 --- a/wasm/Cargo.toml +++ b/wasm/Cargo.toml @@ -28,6 +28,8 @@ web-sys = { version = "0.3", features = [ ] } console_error_panic_hook = "0.1" +hex = "0.4" + serde = { version = "1", features = ["derive"] } serde-wasm-bindgen = "0.6" diff --git a/wasm/src/client.rs b/wasm/src/client.rs index 0d77dcb..18951af 100644 --- a/wasm/src/client.rs +++ b/wasm/src/client.rs @@ -90,7 +90,7 @@ impl WasmClient { #[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 transport = WasmTransport::connect(&config.url, config.server_certificate_hashes.clone()).await?; let inner = transport.inner().clone(); let version_str = format!("{}", PROTOCOL_VERSION); @@ -157,7 +157,7 @@ impl WasmClient { .add_typed_default(DataType::Signature, DataValue::Bytes(signature)) .to_bytes(); - let transport = WasmTransport::connect(&config.url).await?; + let transport = WasmTransport::connect(&config.url, config.server_certificate_hashes.clone()).await?; let inner = transport.inner().clone(); transport.send_frame(&frame).await?; @@ -249,7 +249,7 @@ impl WasmClient { .add_typed_default(DataType::Signature, DataValue::Bytes(signature)) .to_bytes(); - let transport = WasmTransport::connect(&config.url).await?; + let transport = WasmTransport::connect(&config.url, config.server_certificate_hashes.clone()).await?; let inner = transport.inner().clone(); transport.send_frame(&frame).await?; diff --git a/wasm/src/transport.rs b/wasm/src/transport.rs index 5323cc9..39fa400 100644 --- a/wasm/src/transport.rs +++ b/wasm/src/transport.rs @@ -1,7 +1,7 @@ use wasm_bindgen::prelude::*; use wasm_bindgen::JsCast; use wasm_bindgen_futures::JsFuture; -use web_sys::WebTransport; +use web_sys::{WebTransport, WebTransportHash, WebTransportOptions}; use crate::error::js_error; @@ -11,8 +11,28 @@ pub struct WasmTransport { } impl WasmTransport { - pub async fn connect(url: &str) -> Result { - let transport = WebTransport::new(url)?; + pub async fn connect(url: &str, cert_hashes: Option>) -> Result { + let transport = match cert_hashes { + Some(hashes) => { + let opts = WebTransportOptions::new(); + let mut wt_hashes = Vec::new(); + for h in hashes { + if let Some((algo, hex_val)) = h.split_once(':') { + if let Ok(bytes) = hex::decode(hex_val) { + let hash = WebTransportHash::new(); + hash.set_algorithm(algo); + hash.set_value_u8_array(&js_sys::Uint8Array::from(&bytes[..])); + wt_hashes.push(hash); + } + } + } + if !wt_hashes.is_empty() { + opts.set_server_certificate_hashes(&wt_hashes); + } + WebTransport::new_with_options(url, &opts)? + } + None => WebTransport::new(url)?, + }; JsFuture::from(transport.ready()).await .map_err(|e| js_error(&format!("WebTransport ready failed: {:?}", e)))?; Ok(Self { inner: transport })