(feat): redesign WASM module, add TypeScript SDK, migrate to pnpm
Some checks failed
CI / rustfmt (push) Successful in 17s
CI / wasm build (push) Successful in 1m16s
CI / clippy (push) Successful in 1m28s
CI / test (push) Successful in 1m48s
CI / example (push) Successful in 1m31s
CI / duplicate code (push) Failing after 33s
CI / web client (push) Failing after 34s
CI / cargo-machete (push) Successful in 1m18s
CI / cargo-deny (push) Failing after 3m2s
Some checks failed
CI / rustfmt (push) Successful in 17s
CI / wasm build (push) Successful in 1m16s
CI / clippy (push) Successful in 1m28s
CI / test (push) Successful in 1m48s
CI / example (push) Successful in 1m31s
CI / duplicate code (push) Failing after 33s
CI / web client (push) Failing after 34s
CI / cargo-machete (push) Successful in 1m18s
CI / cargo-deny (push) Failing after 3m2s
This commit is contained in:
parent
89a20044a5
commit
5caa1c9d5f
49 changed files with 3717 additions and 1501 deletions
|
|
@ -33,16 +33,17 @@ The host creates a QUIC server, manages the registry, and handles version negoti
|
|||
|
||||
### Initialization
|
||||
|
||||
The host binds to the address from the `mtp_BIND` environment variable (defaults to `::`) on the specified port:
|
||||
The host binds to the address and port supplied in `HostConfig`:
|
||||
|
||||
```rust
|
||||
use mtp::host::{MTPHost, HostConfig};
|
||||
use mtp::host::{HostConfig, MTPHost};
|
||||
|
||||
let config = HostConfig {
|
||||
port: 4433,
|
||||
tls_fullchain: std::fs::read("cert.pem")?,
|
||||
tls_key: std::fs::read("key.pem")?,
|
||||
};
|
||||
let config = HostConfig::new(
|
||||
"0.0.0.0".parse()?,
|
||||
4433,
|
||||
std::fs::read("cert.pem")?,
|
||||
std::fs::read("key.pem")?,
|
||||
);
|
||||
|
||||
let mut host = MTPHost::new(config).await?;
|
||||
```
|
||||
|
|
@ -50,7 +51,7 @@ let mut host = MTPHost::new(config).await?;
|
|||
### Accepting Connections with Version Negotiation
|
||||
|
||||
```rust
|
||||
while let Some(conn) = host.accept().await {
|
||||
while let Some(conn) = host.accept().await? {
|
||||
// conn.version is the negotiated version
|
||||
// conn.codec is a VersionedCodec scoped to that version
|
||||
// conn.sender / conn.receiver for raw CommunicationValue I/O
|
||||
|
|
@ -63,23 +64,23 @@ The host's `accept()` method:
|
|||
1. Accepts a QUIC connection
|
||||
2. If authentication is required (crypto feature): performs login/register handshake
|
||||
3. Reads the first `CommunicationValue` (always encoded with reserved type IDs)
|
||||
4. Extracts the client's protocol version from `DataType::Version` (wire ID 3)
|
||||
4. Extracts the client's protocol version from `DataType::Version` (reserved data type ID 0)
|
||||
5. Calls `registry.negotiate(&[client_version])`
|
||||
6. Returns `None` if the version is unsupported
|
||||
7. Returns an `MTPConnection` with the negotiated version otherwise
|
||||
6. Returns an `AcceptError` if the version is unsupported
|
||||
7. Returns `Ok(Some(MTPConnection))` with the negotiated version otherwise
|
||||
|
||||
### Login/Register Handshake
|
||||
|
||||
When `require_authentication` is set, the parties run a mutually-authenticated
|
||||
**challenge-response**. The client speaks first with an *unsigned* hello:
|
||||
|
||||
- **Login** (`CommunicationType::Identification`, ID 15): version, client ID
|
||||
- **Register** (`CommunicationType::Register`, ID 17): version, public keys
|
||||
- **Login** (`CommunicationType::Identification`, reserved ID 0): version, client ID
|
||||
- **Register** (`CommunicationType::Register`, reserved ID 2): version, public keys
|
||||
|
||||
The host then issues a fresh random `server_challenge` in a signed `Challenge`
|
||||
(`CommunicationType::Challenge`, ID 21, carrying `ServerNonce`). The client signs
|
||||
(`CommunicationType::Challenge`, reserved ID 4, carrying `ServerNonce`). The client signs
|
||||
that challenge, binding its id (login) or public keys (register), and returns a
|
||||
`ChallengeResponse` (ID 22). The host verifies the proof against the challenge it
|
||||
`ChallengeResponse` (reserved ID 5). The host verifies the proof against the challenge it
|
||||
issued and sends a signed final response, which the client verifies.
|
||||
|
||||
Because the client's proof covers the host-issued `server_challenge` (a one-time
|
||||
|
|
@ -94,21 +95,19 @@ replayed on another connection. All signed payloads are domain-separated; see
|
|||
The client connects to a host and uses a single compiled-in protocol version.
|
||||
|
||||
```rust
|
||||
use mtp::client::{MTPClient, ClientConfig};
|
||||
use mtp::client::{ClientConfig, MTPClient};
|
||||
|
||||
let config = ClientConfig {
|
||||
url: "https://host.example.com:4433".into(),
|
||||
server_cert: None, // or Some(cert_pem_bytes)
|
||||
};
|
||||
let config = ClientConfig::new("https://host.example.com:4433");
|
||||
let pinned = config.clone().with_pinned_pem(cert_pem_bytes);
|
||||
|
||||
// Connect (unauthenticated, existing client)
|
||||
let conn = MTPClient::connect(config, 8765).await?;
|
||||
let conn = MTPClient::connect(config.clone().with_client_id(8765)).await?;
|
||||
|
||||
// Authenticated login
|
||||
let conn = MTPClient::auth_connect(config, 8765, keys, host_pk).await?;
|
||||
let conn = MTPClient::auth_connect(pinned.with_client_id(8765), &keys, &host_pk).await?;
|
||||
|
||||
// Registration (new client)
|
||||
let conn = MTPClient::auth_register(config, keys, host_pk).await?;
|
||||
let conn = MTPClient::auth_register(config, &keys, &host_pk).await?;
|
||||
```
|
||||
|
||||
The client's `PROTOCOL_VERSION` constant is set by `protocol_version` in `type-maps.yaml` and baked in at compile time. The client never imports the `registry` crate; it only uses `mtp::type_map` for enum types and `mtp::codec` for encoding.
|
||||
|
|
|
|||
|
|
@ -17,24 +17,22 @@ mtp = { path = "/path/to/mtp", features = ["client", "crypto"] }
|
|||
## ClientConfig
|
||||
|
||||
```rust
|
||||
use mtp::client::ClientConfig;
|
||||
use mtp::client::{ClientConfig, ClientTlsConfig};
|
||||
|
||||
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
|
||||
};
|
||||
let config = ClientConfig::new("https://host.example.com:4433")
|
||||
.with_tls(ClientTlsConfig::SystemRoots)
|
||||
.with_client_id(0);
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|--------------|--------------------|-----------------------------------------------------|
|
||||
| `url` | `String` | `https://host:port` address of the MTP host |
|
||||
| `server_cert`| `Option<Vec<u8>>` | `None` to use system roots, `Some(pem_bytes)` to pin |
|
||||
| `tls` | `ClientTlsConfig` | `SystemRoots` or `PinnedPem(pem_bytes)` |
|
||||
| `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
|
||||
When `tls` is `ClientTlsConfig::SystemRoots` (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).
|
||||
|
|
@ -44,10 +42,7 @@ certificates:
|
|||
|
||||
```rust
|
||||
let pem = std::fs::read("my-server-cert.pem")?;
|
||||
let config = ClientConfig {
|
||||
server_cert: Some(pem),
|
||||
// ...
|
||||
};
|
||||
let config = ClientConfig::new("https://host.example.com:4433").with_pinned_pem(pem);
|
||||
```
|
||||
|
||||
When pinned, **only** the given certificate(s) are trusted for the TLS
|
||||
|
|
@ -78,13 +73,9 @@ pub struct MTPConnection {
|
|||
### Unauthenticated Connect
|
||||
|
||||
```rust
|
||||
use mtp::client::{MTPClient, ClientConfig};
|
||||
use mtp::client::{ClientConfig, MTPClient};
|
||||
|
||||
let config = ClientConfig {
|
||||
url: "https://host.example.com:4433".into(),
|
||||
server_cert: None,
|
||||
client_id: 42,
|
||||
};
|
||||
let config = ClientConfig::new("https://host.example.com:4433").with_client_id(42);
|
||||
|
||||
let conn = MTPClient::connect(config).await?;
|
||||
```
|
||||
|
|
@ -101,10 +92,8 @@ 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 config = ClientConfig::new("https://host.example.com:4433")
|
||||
.with_client_id(42); // must match the keyring's identity
|
||||
|
||||
let conn = MTPClient::auth_connect(config, &keys, &host_pk).await?;
|
||||
```
|
||||
|
|
@ -315,7 +304,11 @@ using `MTPClient`:
|
|||
```rust
|
||||
use mtp_transport::{connect, Policy};
|
||||
|
||||
let (sender, receiver) = connect(&config.url, config.server_cert, policy).await?;
|
||||
let server_cert = match &config.tls {
|
||||
ClientTlsConfig::SystemRoots => None,
|
||||
ClientTlsConfig::PinnedPem(pem) => Some(pem.clone()),
|
||||
};
|
||||
let (sender, receiver) = connect(&config.url, server_cert, policy).await?;
|
||||
```
|
||||
|
||||
Then build and send the initial `Identification` frame manually to complete
|
||||
|
|
@ -324,7 +317,7 @@ 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` 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
|
||||
|
|
|
|||
|
|
@ -21,25 +21,23 @@ mtp = { path = "/path/to/mtp", features = ["host", "crypto"] }
|
|||
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<PublicKeyBundle> {
|
||||
let config = HostConfig::new(
|
||||
IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
||||
4433,
|
||||
std::fs::read("cert.pem")?,
|
||||
std::fs::read("key.pem")?,
|
||||
)
|
||||
.with_authentication(
|
||||
/* Keyring */,
|
||||
|client_id: u64| -> Option<PublicKeyBundle> {
|
||||
CLIENT_DB.lock().unwrap().get(&client_id).cloned()
|
||||
}),
|
||||
complete_register: Box::new(|bundle: PublicKeyBundle| -> u64 {
|
||||
},
|
||||
|bundle: PublicKeyBundle| -> u64 {
|
||||
let id = next_id();
|
||||
CLIENT_DB.lock().unwrap().insert(id, bundle);
|
||||
id
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|
|
@ -49,10 +47,9 @@ let config = HostConfig {
|
|||
| `tls_fullchain` | `Vec<u8>` | PEM-encoded TLS certificate chain |
|
||||
| `tls_key` | `Vec<u8>` | 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<dyn Fn(u64) -> Option<PublicKeyBundle> + Send>` (crypto) | Lookup callback for login |
|
||||
| `complete_register` | `Box<dyn Fn(PublicKeyBundle) -> u64 + Send>` (crypto) | Registration callback, returns new client ID |
|
||||
| `get_existing_user` | `Fn(u64) -> Option<PublicKeyBundle> + Send + Sync` (crypto) | Lookup callback for login |
|
||||
| `complete_register` | `Fn(PublicKeyBundle) -> u64 + Send + Sync` (crypto) | Registration callback, returns new client ID |
|
||||
|
||||
### TLS
|
||||
|
||||
|
|
@ -67,7 +64,7 @@ use mtp::host::MTPHost;
|
|||
let mut host = MTPHost::new(config).await?;
|
||||
println!("Listening on {}", host.local_addr());
|
||||
|
||||
while let Some(conn) = host.accept().await {
|
||||
while let Some(conn) = host.accept().await? {
|
||||
// conn is an MTPConnection ready for I/O
|
||||
}
|
||||
```
|
||||
|
|
@ -107,12 +104,12 @@ 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
|
||||
3. Extract the protocol version from `DataType::Version` (reserved data type ID 0) 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
|
||||
5. Return an `AcceptError` (closing the connection) if no compatible version exists
|
||||
6. Return `Ok(Some(MTPConnection))` with the negotiated version
|
||||
|
||||
The `Registry` is built automatically from all type maps defined in your
|
||||
`type-maps.yaml` via `Registry::builtin()`.
|
||||
|
|
@ -221,14 +218,14 @@ available for verifying subsequent signed messages from the client.
|
|||
|
||||
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()`.
|
||||
`AcceptError::AuthenticationFailed` from `accept()`.
|
||||
|
||||
## Handling Messages
|
||||
|
||||
Use `conn.sender` and `conn.receiver` for bidirectional message exchange:
|
||||
|
||||
```rust
|
||||
while let Some(conn) = host.accept().await {
|
||||
while let Some(conn) = host.accept().await? {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match conn.receiver.receive().await {
|
||||
|
|
@ -265,9 +262,9 @@ 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<PublicKeyBundle> {
|
||||
let get_existing_user = |id: u64| -> Option<PublicKeyBundle> {
|
||||
db.lock().unwrap().get(&id).cloned()
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### complete_register
|
||||
|
|
@ -277,16 +274,16 @@ assign a client ID. The returned `u64` becomes the client's permanent
|
|||
identifier.
|
||||
|
||||
```rust
|
||||
let complete_register = Box::new(|bundle: PublicKeyBundle| -> u64 {
|
||||
let complete_register = |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
|
||||
Both callbacks are called from within `accept()` and must be `Send + Sync`. They are
|
||||
invoked synchronously, so avoid long-running operations (or use `spawn_blocking`
|
||||
if needed, though the callbacks are `Fn`, not `AsyncFn`).
|
||||
|
||||
|
|
@ -338,5 +335,3 @@ let transport = host(ip, port, cert, key, custom_policy).await?;
|
|||
|
||||
Drop the `MTPHost` to stop accepting new connections. Active connections
|
||||
continue until their `Sender`/`Receiver` are dropped or the peer disconnects.
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,18 @@ Type names are defined in a YAML config and turned into Rust enums at **compile
|
|||
|
||||
### Defining Type Maps
|
||||
|
||||
An example `type-maps.yaml` is provided in the [`example-type-maps.yaml`](./example-type-maps.yaml) file. Place your own `type-maps.yaml` in your project root and set the `MTP_TYPE_MAPS` environment variable (see [Customizing Type Maps in Downstream Projects](#customizing-type-maps-in-downstream-projects)).
|
||||
Place your own `type-maps.yaml` in your project root. Browser apps should use the Vite plugin so the app-specific WASM package is generated into Vite's cache during dev/build:
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from "vite";
|
||||
import { mtp } from "mtp/vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [mtp({ typeMaps: "./type-maps.yaml" })],
|
||||
});
|
||||
```
|
||||
|
||||
Rust and manual WASM builds can set `MTP_TYPE_MAPS` directly (see [Customizing Type Maps in Downstream Projects](#customizing-type-maps-in-downstream-projects)).
|
||||
|
||||
### Using Generated Enums
|
||||
|
||||
|
|
@ -84,7 +95,18 @@ let decoded = codec.decode(&bytes, Version(2, 0)).unwrap();
|
|||
|
||||
## Customizing Type Maps in Downstream Projects
|
||||
|
||||
External projects must provide their own type map configuration via the `MTP_TYPE_MAPS` environment variable. There is no bundled default; the build script will error if the variable is not set or points to an invalid file.
|
||||
External projects must provide their own type map configuration. Browser projects should install `mtp` and configure `mtp/vite`; they do not need to publish, fork, or copy a generated WASM package.
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from "vite";
|
||||
import { mtp } from "mtp/vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [mtp({ typeMaps: "./type-maps.yaml" })],
|
||||
});
|
||||
```
|
||||
|
||||
For Rust builds, or when invoking `wasm-pack` manually, set the `MTP_TYPE_MAPS` environment variable. If the variable points to an invalid file, the build fails. If `MTP_TYPE_MAPS` is not set, the build script emits a warning and generates reserved protocol types only; application-specific communication and data types will not be available.
|
||||
|
||||
1. Create a `type-maps.yaml` in your project root
|
||||
2. Set the `MTP_TYPE_MAPS` environment variable in `.cargo/config.toml`:
|
||||
|
|
|
|||
|
|
@ -1,291 +1,282 @@
|
|||
# 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.
|
||||
The browser client is exposed through the `mtp` npm package. Most applications should use the SDK-first `MTPClient` API; direct generated WASM bindings remain available from `mtp/raw` for advanced integrations.
|
||||
|
||||
## 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:
|
||||
## Package Entry Points
|
||||
|
||||
```typescript
|
||||
import init, { WasmClient } from 'mtp-wasm';
|
||||
|
||||
await init();
|
||||
import { MTPClient } from "mtp";
|
||||
import init, { WasmClient } from "mtp/raw";
|
||||
import { mtp } from "mtp/vite";
|
||||
```
|
||||
|
||||
## Browser Support
|
||||
- `mtp` exports the SDK-first `MTPClient` wrapper.
|
||||
- `mtp/raw` exports the generated `wasm-bindgen` module and raw classes/functions.
|
||||
- `mtp/vite` exports the Vite plugin that builds app-specific WASM bindings from your `type-maps.yaml`.
|
||||
- `mtp/type-map` exports generated TypeScript unions for communication and data type names.
|
||||
|
||||
WebTransport is required. Check availability at runtime:
|
||||
## Vite Type-Map Workflow
|
||||
|
||||
Browser apps provide their own type map. The Vite plugin runs `wasm-pack` during dev/build with `MTP_TYPE_MAPS` set, writes generated output under `node_modules/.vite/mtp/` by default, and aliases `mtp/raw` plus `mtp/type-map` to that generated output.
|
||||
|
||||
```typescript
|
||||
if (!WasmClient.is_supported()) {
|
||||
// fall back or show an error
|
||||
}
|
||||
// vite.config.ts
|
||||
import { defineConfig } from "vite";
|
||||
import { mtp } from "mtp/vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [mtp({ typeMaps: "./type-maps.yaml" })],
|
||||
});
|
||||
```
|
||||
|
||||
## Connecting to a Host
|
||||
You do not need to publish, fork, or copy an app-specific generated WASM package.
|
||||
|
||||
### ConnectionConfig
|
||||
## SDK Quick Start
|
||||
|
||||
```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
|
||||
"sha-256:abc123...",
|
||||
];
|
||||
```
|
||||
import { MTPClient } from "mtp";
|
||||
|
||||
`client_id` is only needed for authenticated login (`auth_connect`). For registration (`auth_register`) it is ignored.
|
||||
const client = await MTPClient.create({
|
||||
url: "https://host.example.com:4433",
|
||||
hostPublicKey,
|
||||
credentials,
|
||||
storage,
|
||||
serverCertificateHashes: ["sha-256:abcd1234..."],
|
||||
pings: true,
|
||||
logger: (event) => console.log(event),
|
||||
});
|
||||
|
||||
### TLS Certificate Handling
|
||||
const unsubscribe = client.subscribe("SomeType", (message) => {
|
||||
console.log(message.type, message.data);
|
||||
});
|
||||
|
||||
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.
|
||||
await client.connectOrRegister();
|
||||
await client.send("SomeType", { value: "hello" });
|
||||
|
||||
For development or self-signed certificates, pin the server certificate by
|
||||
providing its hash:
|
||||
|
||||
```typescript
|
||||
config.server_certificate_hashes = [
|
||||
"sha-256:abcd1234...", // hex-encoded hash value
|
||||
];
|
||||
```
|
||||
|
||||
The hash format is `"<algorithm>:<hex-encoded-hash>"`, where the only algorithm
|
||||
the browser's WebTransport API currently accepts is `sha-256`. 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
|
||||
const response = await client.request(
|
||||
"SomeRequestType",
|
||||
{ id: "abc" },
|
||||
{ responseType: "SomeResponseType" },
|
||||
);
|
||||
```
|
||||
|
||||
### Connection States
|
||||
client.raw.client; // underlying WasmClient instance
|
||||
client.raw.bindings; // generated raw WASM module exports
|
||||
|
||||
| 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
|
||||
|
||||
```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 (challenge-response): the client sends an unsigned `Identification`
|
||||
hello, the host replies with a signed `Challenge` carrying a fresh
|
||||
`server_challenge`, the client signs that challenge in a `ChallengeResponse`, and
|
||||
the host verifies it and replies with a signed `IdentificationResponse`. Signing
|
||||
over the host-issued challenge is what prevents a captured proof from being
|
||||
replayed on another connection. Returns the confirmed client ID.
|
||||
|
||||
### Registration
|
||||
|
||||
```typescript
|
||||
const newId = await client.auth_register(
|
||||
config,
|
||||
hostPublicKeyBytes, // Uint8Array: serialized PublicKeyBundle from the host
|
||||
keyringBytes, // Uint8Array: serialized Keyring for the new identity
|
||||
);
|
||||
```
|
||||
|
||||
Exchange (challenge-response): the client sends an unsigned `Register` hello with
|
||||
its public keys, the host replies with a signed `Challenge`, the client signs it
|
||||
(binding the public-key bundle) in a `ChallengeResponse`, and the host verifies
|
||||
it, 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
|
||||
unsubscribe();
|
||||
client.disconnect();
|
||||
```
|
||||
|
||||
Gracefully closes the WebTransport session.
|
||||
|
||||
## Building Frames
|
||||
|
||||
### `build_ping_frame`
|
||||
`MTPClient.isSupported()` checks whether the current browser exposes WebTransport:
|
||||
|
||||
```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,
|
||||
hostBundleBytes: Uint8Array,
|
||||
): Uint8Array;
|
||||
```
|
||||
|
||||
Constructs a `Ping` frame that demonstrates encrypted, signed, and
|
||||
signed+encrypted containers. The containers are ML-KEM-encrypted to the host's
|
||||
`PublicKeyBundle` (`hostBundleBytes`, the same bytes passed to `auth_connect` /
|
||||
`auth_register`), so the host decrypts them with its own keyring; signatures use
|
||||
the client keyring's Ed25519 key. The client keyring only needs its Ed25519
|
||||
signing key for this demo.
|
||||
|
||||
### `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,
|
||||
if (!MTPClient.isSupported()) {
|
||||
throw new Error("WebTransport is not available in this browser");
|
||||
}
|
||||
```
|
||||
|
||||
## Crypto Primitives
|
||||
## Credentials And Storage
|
||||
|
||||
### Key Generation
|
||||
Authenticated connections need stable key material. Pass `credentials` when you already have a client ID and serialized keyring, or pass a small `storage` object and let the SDK persist credentials after registration.
|
||||
|
||||
```typescript
|
||||
const result = ed25519_generate();
|
||||
// result.signer -> WasmEd25519Signer
|
||||
// result.secretKey -> Uint8Array (32 bytes)
|
||||
// result.publicKey -> Uint8Array (32 bytes)
|
||||
const storage = {
|
||||
getItem: (key: string) => localStorage.getItem(key),
|
||||
setItem: (key: string, value: string) => localStorage.setItem(key, value),
|
||||
removeItem: (key: string) => localStorage.removeItem(key),
|
||||
};
|
||||
|
||||
const client = await MTPClient.create({
|
||||
url: "https://host.example.com:4433",
|
||||
hostPublicKey,
|
||||
storage,
|
||||
});
|
||||
|
||||
const clientId = await client.connectOrRegister();
|
||||
```
|
||||
|
||||
### Keyring
|
||||
|
||||
A `Keyring` bundles all key material for an identity. For Ed25519-only setups:
|
||||
The storage contract is intentionally small and may be sync or async:
|
||||
|
||||
```typescript
|
||||
const keyringBytes = keyring_from_ed25519(secretKey, publicKey);
|
||||
// keyringBytes is ready for WasmClient.auth_register or WasmClient.auth_connect
|
||||
interface MTPCredentialStorage {
|
||||
getItem(key: string): string | null | Promise<string | null>;
|
||||
setItem(key: string, value: string): void | Promise<void>;
|
||||
removeItem(key: string): void | Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
Full keyring with KEM + ML-DSA requires constructing on the Rust side. The
|
||||
serialized bytes are portable:
|
||||
`credentials` can also be supplied directly:
|
||||
|
||||
```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
|
||||
const client = await MTPClient.create({
|
||||
url: "https://host.example.com:4433",
|
||||
hostPublicKey,
|
||||
credentials: {
|
||||
clientId: 42n,
|
||||
keyring: savedKeyringBytes,
|
||||
},
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
```
|
||||
|
||||
### Signing and Verification
|
||||
`client.credentials` returns the current public credential object. Call `clearCredentials()` to remove in-memory credentials and delete the configured storage key.
|
||||
|
||||
## Connection Methods
|
||||
|
||||
- `connect()` opens a connection. If credentials include a `clientId` and `hostPublicKey` is available, it uses authenticated login; otherwise it uses unauthenticated connect.
|
||||
- `register()` performs authenticated registration and persists the assigned client ID when storage is configured.
|
||||
- `connectOrRegister()` registers when no client ID is present, otherwise performs authenticated login.
|
||||
- `disconnect()` stops protocol pings and closes the underlying WebTransport session.
|
||||
|
||||
For certificate pinning, pass WebTransport certificate hashes:
|
||||
|
||||
```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);
|
||||
await MTPClient.create({
|
||||
url: "https://host.example.com:4433",
|
||||
serverCertificateHashes: ["sha-256:abcd1234..."],
|
||||
});
|
||||
```
|
||||
|
||||
### Symmetric Encryption
|
||||
If hashes are omitted, the browser uses its normal TLS root store.
|
||||
|
||||
## Sending, Requests, Subscriptions, And Pings
|
||||
|
||||
`send` accepts either a typed message or a prebuilt raw frame:
|
||||
|
||||
```typescript
|
||||
const cipher = new WasmChaCha20Poly1305(key); // 32-byte key
|
||||
const encrypted = cipher.encrypt(plaintext, aad); // nonce || ciphertext
|
||||
const decrypted = cipher.decrypt(encrypted, aad);
|
||||
await client.send("SomeType", { value: "hello" });
|
||||
await client.send(rawFrameBytes);
|
||||
```
|
||||
|
||||
### Hashing and KDF
|
||||
Typed sends are encoded by the generated WASM binding using the app type map. Optional frame metadata can be passed as the third argument:
|
||||
|
||||
```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
|
||||
await client.send("SomeType", { value: "hello" }, {
|
||||
id: 7,
|
||||
sender: client.credentials?.clientId ?? 0n,
|
||||
});
|
||||
```
|
||||
|
||||
## Lifecycle and Best Practices
|
||||
`request` sends one frame and resolves with the matching parsed response from the WASM layer:
|
||||
|
||||
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.
|
||||
```typescript
|
||||
const response = await client.request(
|
||||
"SomeRequestType",
|
||||
{ id: "abc" },
|
||||
{ responseType: "SomeResponseType" },
|
||||
);
|
||||
```
|
||||
|
||||
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.
|
||||
`subscribe` registers a message-type handler and returns an unsubscribe function:
|
||||
|
||||
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.
|
||||
```typescript
|
||||
const unsubscribe = client.subscribe("SomeType", (message) => {
|
||||
console.log(message.id, message.sender, message.data);
|
||||
});
|
||||
|
||||
4. **Single active client** -- a `WasmClient` manages one WebTransport session.
|
||||
Create a new instance for each connection.
|
||||
unsubscribe();
|
||||
```
|
||||
|
||||
5. **State transitions** -- after `disconnect()` the client transitions to
|
||||
`Disconnected`. The instance is reusable; call a connect method again to
|
||||
open a new session.
|
||||
Protocol pings are real MTP `Ping` frames sent by the WASM client, not just transport keepalives:
|
||||
|
||||
```typescript
|
||||
await MTPClient.create({
|
||||
url: "https://host.example.com:4433",
|
||||
pings: { intervalMs: 30_000 },
|
||||
});
|
||||
```
|
||||
|
||||
Use `pings: true` for the default interval.
|
||||
|
||||
## Logger Events
|
||||
|
||||
The SDK logger receives parsed events:
|
||||
|
||||
```typescript
|
||||
type MTPLogEvent =
|
||||
| { hint: "info" | "warning"; type: string; data: unknown }
|
||||
| { hint: "error"; type: string | "error"; error: string };
|
||||
```
|
||||
|
||||
Incoming non-error frames and sent frames are logged as `info`. Error frames and transport errors are logged as `error`.
|
||||
|
||||
## Advanced Raw Bindings
|
||||
|
||||
Use `mtp/raw` when you need direct access to the generated `wasm-bindgen` API:
|
||||
|
||||
```typescript
|
||||
import init, {
|
||||
ConnectionConfig,
|
||||
WasmClient,
|
||||
ed25519_generate,
|
||||
keyring_from_ed25519,
|
||||
} from "mtp/raw";
|
||||
|
||||
await init();
|
||||
|
||||
const rawClient = new WasmClient(
|
||||
(state) => console.log("state", state),
|
||||
(frame) => console.log("message", frame),
|
||||
(error) => console.error(error),
|
||||
);
|
||||
|
||||
const config = new ConnectionConfig("https://host.example.com:4433");
|
||||
config.client_id = 42n;
|
||||
|
||||
await rawClient.connect(config);
|
||||
config.free();
|
||||
```
|
||||
|
||||
Raw callbacks receive parsed frames, not application-specific SDK objects:
|
||||
|
||||
```typescript
|
||||
interface ParsedFrame {
|
||||
id?: number;
|
||||
type: string;
|
||||
sender?: bigint;
|
||||
receiver?: bigint;
|
||||
data: Record<string, unknown>;
|
||||
raw: Uint8Array;
|
||||
}
|
||||
```
|
||||
|
||||
Raw message helpers that remain available include:
|
||||
|
||||
- `build_frame(messageType, data, options?)`
|
||||
- `build_ping_frame(clientId, description, timestamp, data)`
|
||||
- `parse_frame(frame)`
|
||||
- `format_frame(frame)`
|
||||
- `parse_auth_response(frame)`
|
||||
|
||||
Raw crypto and key helpers include:
|
||||
|
||||
- `ed25519_generate()`
|
||||
- `ed25519_verify(publicKey, message, signature)`
|
||||
- `keyring_from_ed25519(secretKey, publicKey)`
|
||||
- `WasmKeyring.from_bytes(bytes)` and `keyring.to_bytes()`
|
||||
- `WasmPublicKeyBundle.from_bytes(bytes)` and `bundle.to_bytes()`
|
||||
- `WasmEd25519Signer`
|
||||
- `WasmChaCha20Poly1305`
|
||||
- `wasm_sha256`, `wasm_sha256_double`, `wasm_hkdf_expand`, and `wasm_derive_encryption_key`
|
||||
|
||||
Raw authenticated login and registration map directly to the Rust WASM layer:
|
||||
|
||||
```typescript
|
||||
const generated = ed25519_generate();
|
||||
const keyringBytes = keyring_from_ed25519(generated.secretKey, generated.publicKey);
|
||||
|
||||
const registeredId = await rawClient.auth_register(
|
||||
config,
|
||||
hostPublicKeyBytes,
|
||||
keyringBytes,
|
||||
);
|
||||
|
||||
const confirmedId = await rawClient.auth_connect(
|
||||
config,
|
||||
hostPublicKeyBytes,
|
||||
keyringBytes,
|
||||
registeredId,
|
||||
);
|
||||
```
|
||||
|
||||
A `WasmClient` manages one active WebTransport session. Create a new instance for independent connections, and call `free()` or `[Symbol.dispose]()` on raw WASM objects when you want to release memory eagerly.
|
||||
|
|
|
|||
Loading…
Reference in a new issue