From 6b9bbb6ebf89491796ccc41dba622c3c5a9b447d Mon Sep 17 00:00:00 2001 From: Alois Date: Sun, 28 Jun 2026 16:04:03 +0200 Subject: [PATCH] (feat): add crypto interface for MTPClient in wasm (qol): update readme (fix): small bug in keypair.rs --- README.md | 42 +++++++++++++++++-- crypto/src/keypair.rs | 2 +- docs/WASM-CLIENT.md | 11 +++-- example/Cargo.lock | 37 +++++++++++++---- example/web-client/src/main.ts | 5 ++- src/sdk/index.ts | 76 ++++++++++++++++++++++++++++------ 6 files changed, 144 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 492824e..a5df4d2 100644 --- a/README.md +++ b/README.md @@ -26,20 +26,54 @@ export default defineConfig({ ``` ```typescript +import { MTPClient, type MTPCredentials } from "mtp"; + +const credentialsStorageKey = "mtpCredentialsForMyApp"; + +function loadCredentials(): MTPCredentials { + const saved = localStorage.getItem(credentialsStorageKey); + if (saved) { + return JSON.parse(saved) as MTPCredentials; + } + + return { + clientId: null, + keyring: MTPClient.crypto.generateKeyring(), + }; +} + +await MTPClient.init(); + +// Example-looking host public key bundle bytes. Replace this with the public +// key bundle published by your MTP host before connecting to a real service. +const hostPublicKey = Uint8Array.from({ length: 128 }, (_, index) => ( + [0xb6, 0x4f, 0x7d, 0x9a, 0x3c, 0x21, 0xe8, 0x05][index % 8] ^ index +)); + const client = await MTPClient.create({ url: "https://localhost:4433", hostPublicKey, - credentials, - storage, + credentials: loadCredentials(), + storage: window.localStorage, // Same API as localStorage for convenience + credentialsStorageKey, pings: true, - logger: (event) => console.log(event), + logger: (event) => console.log("[MTP]: " + event), }); client.subscribe("SomeType", (message) => console.log(message)); -await client.connectOrRegister(); + +const clientId = client.credentials?.clientId == null + ? await client.register() + : (await client.connect(), client.credentials.clientId); + await client.send("SomeType", { value: "hello" }); +console.log("Connected MTP client", clientId, client.state); ``` +`client.raw` exposes the lower-level WASM client and generated binding module for advanced integrations. Prefer the SDK methods unless you specifically need an API the wrapper does not expose; raw calls bypass SDK validation, credential persistence, logging, timeout handling, frame helpers, and lifecycle safeguards. + +Use `MTPClient.crypto` for SDK-level crypto helpers such as `generateKeyring()`, `generateEd25519()`, `keyringFromEd25519()`, `verifyEd25519()`, `sha256()`, `sha256Double()`, `hkdfExpand()`, and `deriveEncryptionKey()`. + ## Getting Started Add the `mtp` crate with your desired features: diff --git a/crypto/src/keypair.rs b/crypto/src/keypair.rs index 7e0d922..169eaa8 100644 --- a/crypto/src/keypair.rs +++ b/crypto/src/keypair.rs @@ -186,7 +186,7 @@ fn bytes_to_hex(bytes: &[u8]) -> String { } fn hex_to_bytes(s: &str) -> Result, crate::error::CryptoError> { - if s.len() % 2 != 0 { + if !s.len().is_multiple_of(2) { return Err(crate::error::CryptoError::InvalidHex); } (0..s.len()) diff --git a/docs/WASM-CLIENT.md b/docs/WASM-CLIENT.md index 7686f63..b70ab3a 100644 --- a/docs/WASM-CLIENT.md +++ b/docs/WASM-CLIENT.md @@ -52,7 +52,11 @@ const unsubscribe = client.subscribe("SomeType", (message) => { console.log(message.type, message.data); }); -await client.connectOrRegister(); +if (client.credentials?.clientId == null) { + await client.register(); +} else { + await client.connect(); +} await client.send("SomeType", { value: "hello" }); const response = await client.request( @@ -93,7 +97,9 @@ const client = await MTPClient.create({ storage, }); -const clientId = await client.connectOrRegister(); +const clientId = client.credentials?.clientId == null + ? await client.register() + : (await client.connect(), client.credentials.clientId); ``` The storage contract is intentionally small and may be sync or async: @@ -127,7 +133,6 @@ await client.connect(); - `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: diff --git a/example/Cargo.lock b/example/Cargo.lock index 3ca21d9..49da683 100644 --- a/example/Cargo.lock +++ b/example/Cargo.lock @@ -24,7 +24,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror", + "thiserror 2.0.18", "time", ] @@ -929,7 +929,7 @@ version = "0.1.0" dependencies = [ "quinn", "rustls", - "thiserror", + "thiserror 2.0.18", "wtransport", ] @@ -946,6 +946,7 @@ dependencies = [ "rand_core 0.6.4", "serde", "sha2 0.11.0", + "thiserror 1.0.69", "zeroize", ] @@ -1191,7 +1192,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -1213,7 +1214,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -1715,13 +1716,33 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -2172,7 +2193,7 @@ dependencies = [ "rustls-pki-types", "sha2 0.11.0", "socket2", - "thiserror", + "thiserror 2.0.18", "time", "tokio", "tracing", @@ -2189,7 +2210,7 @@ checksum = "d5867c629e4252f7439d82315923daaf27f4fa442410d51b78ab93ef4c432a11" dependencies = [ "httlib-huffman", "octets", - "thiserror", + "thiserror 2.0.18", "url", ] @@ -2220,7 +2241,7 @@ dependencies = [ "oid-registry", "ring", "rusticata-macros", - "thiserror", + "thiserror 2.0.18", "time", ] diff --git a/example/web-client/src/main.ts b/example/web-client/src/main.ts index 364860a..07f24bd 100644 --- a/example/web-client/src/main.ts +++ b/example/web-client/src/main.ts @@ -199,7 +199,10 @@ async function connect() { log(`Subscribed Pong: ${formatParsedFrame(frame)}`, "received"); }); - const activeClientId = await client.connectOrRegister(); + const existingClientId = client.credentials?.clientId; + const activeClientId = existingClientId == null + ? await client.register() + : (await client.connect(), BigInt(existingClientId)); clientId = activeClientId; loadKeys(); log(`Connected as client ${activeClientId}`); diff --git a/src/sdk/index.ts b/src/sdk/index.ts index 5b2dde7..1ba75bb 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -24,6 +24,55 @@ export type MTPLogEvent = export type ParsedFrame = RawBindings.ParsedFrame; +export type Ed25519GenerateResult = ReturnType; + +export interface MTPCrypto { + generateKeyring(): Uint8Array; + generateEd25519(): Ed25519GenerateResult; + keyringFromEd25519(secretKey: Uint8Array, publicKey: Uint8Array): Uint8Array; + verifyEd25519(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): void; + deriveEncryptionKey(ikm: Uint8Array, salt: Uint8Array, context: Uint8Array): Uint8Array; + hkdfExpand(ikm: Uint8Array, salt: Uint8Array, info: Uint8Array, len: number): Uint8Array; + sha256(data: Uint8Array): Uint8Array; + sha256Double(data: Uint8Array): Uint8Array; +} + +export const crypto: MTPCrypto = { + generateKeyring: () => bindings.keyring_generate(), + generateEd25519: () => bindings.ed25519_generate(), + keyringFromEd25519: (secretKey, publicKey) => bindings.keyring_from_ed25519(secretKey, publicKey), + verifyEd25519: (publicKey, message, signature) => bindings.ed25519_verify(publicKey, message, signature), + deriveEncryptionKey: (ikm, salt, context) => bindings.wasm_derive_encryption_key(ikm, salt, context), + hkdfExpand: (ikm, salt, info, len) => bindings.wasm_hkdf_expand(ikm, salt, info, len), + sha256: (data) => bindings.wasm_sha256(data), + sha256Double: (data) => bindings.wasm_sha256_double(data), +}; + +export type MTPRawBindings = typeof bindings; + +export interface MTPRaw { + /** + * Underlying generated WASM client instance. + * + * Prefer the `MTPClient` methods for application code. Calling the raw client + * bypasses SDK-level validation, credential persistence, logging, timeout + * handling, frame parsing helpers, and ping lifecycle management. Use this + * escape hatch only when integrating a feature that the SDK wrapper does not + * expose yet. + */ + client: RawBindings.WasmClient; + + /** + * Generated WASM binding module exported by `mtp/raw`. + * + * These bindings mirror the lower-level WASM API and can change shape as the + * generated interface evolves. Prefer the SDK wrapper where possible so your + * code keeps the safer, typed MTPClient flow instead of depending directly on + * transport internals. + */ + bindings: MTPRawBindings; +} + export interface MTPCredentials { clientId: bigint | string | number | null; keyring: Uint8Array | number[]; @@ -270,12 +319,13 @@ async function withTimeout(promise, timeoutMs, message) { } export class MTPClient { + static readonly crypto = crypto; + #credentials: InternalCredentials | null; #options: NormalizedMTPClientOptions; - readonly raw: { - client: RawBindings.WasmClient; - bindings: typeof RawBindings; - }; + readonly raw: MTPRaw; + + readonly crypto = MTPClient.crypto; private constructor(options: NormalizedMTPClientOptions, client: RawBindings.WasmClient) { this.#options = options; @@ -285,7 +335,7 @@ export class MTPClient { static async create(options: MTPClientOptions = {} as MTPClientOptions): Promise { validateOptions(options); - await initWasm(options.wasm); + await MTPClient.init(options.wasm); const normalizedOptions = { ...options, @@ -333,10 +383,18 @@ export class MTPClient { return WasmClient.is_supported(); } + static async init(wasm?: MTPClientOptions["wasm"]): Promise>> { + return await initWasm(wasm); + } + get credentials(): MTPCredentials | null { return publicCredentials(this.#credentials); } + get state(): RawBindings.ConnectionState { + return this.raw.client.state; + } + async #loadStoredCredentials() { if (this.#credentials || !this.#options.storage) { return; @@ -440,12 +498,6 @@ export class MTPClient { } } - async connectOrRegister(): Promise { - return this.#credentials?.clientId == null - ? await this.register() - : await this.#connectAuthenticated(); - } - async #persistCredentials() { await storageSet( this.#options.storage, @@ -546,4 +598,4 @@ export class MTPClient { } } -export { bindings as raw }; +export { ConnectionState, bindings as raw };