mtp/WASM-CLIENT.md
2026-06-25 12:28:14 +02:00

8 KiB

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:

import init, { WasmClient } from 'mtp-wasm';

await init();

Browser Support

WebTransport is required. Check availability at runtime:

if (!WasmClient.is_supported()) {
  // fall back or show an error
}

Connecting to a Host

ConnectionConfig

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:

config.server_certificate_hashes = [
  "sha256:abcd1234...",  // hex-encoded hash value
];

The hash format is "<algorithm>:<hex-encoded-hash>". 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:

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

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)

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)

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

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

client.disconnect();

Gracefully closes the WebTransport session.

Building Frames

build_ping_frame

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

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

function parse_auth_response(response: Uint8Array): any;

Parses an IdentificationResponse or RegisterResponse frame into a JS object:

{
  connected: boolean,
  clientNonce?: Uint8Array,
  assignedId?: number,
  timestamp?: number,
  signature?: Uint8Array,
}

Crypto Primitives

Key Generation

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:

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:

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

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

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

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.