(feat): add crypto interface for MTPClient in wasm
Some checks failed
CI / rustfmt (push) Successful in 17s
CI / wasm build (push) Successful in 1m18s
CI / clippy (push) Successful in 1m32s
CI / test (push) Successful in 1m53s
CI / example (push) Successful in 1m35s
CI / duplicate code (push) Failing after 31s
CI / web client (push) Failing after 32s
CI / cargo-machete (push) Successful in 1m9s
CI / cargo-deny (push) Failing after 2m31s

(qol): update readme
(fix): small bug in keypair.rs
This commit is contained in:
Alois 2026-06-28 16:04:03 +02:00
commit 6b9bbb6ebf
6 changed files with 144 additions and 29 deletions

View file

@ -26,20 +26,54 @@ export default defineConfig({
``` ```
```typescript ```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({ const client = await MTPClient.create({
url: "https://localhost:4433", url: "https://localhost:4433",
hostPublicKey, hostPublicKey,
credentials, credentials: loadCredentials(),
storage, storage: window.localStorage, // Same API as localStorage for convenience
credentialsStorageKey,
pings: true, pings: true,
logger: (event) => console.log(event), logger: (event) => console.log("[MTP]: " + event),
}); });
client.subscribe("SomeType", (message) => console.log(message)); 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" }); 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 ## Getting Started
Add the `mtp` crate with your desired features: Add the `mtp` crate with your desired features:

View file

@ -186,7 +186,7 @@ fn bytes_to_hex(bytes: &[u8]) -> String {
} }
fn hex_to_bytes(s: &str) -> Result<Vec<u8>, crate::error::CryptoError> { fn hex_to_bytes(s: &str) -> Result<Vec<u8>, crate::error::CryptoError> {
if s.len() % 2 != 0 { if !s.len().is_multiple_of(2) {
return Err(crate::error::CryptoError::InvalidHex); return Err(crate::error::CryptoError::InvalidHex);
} }
(0..s.len()) (0..s.len())

View file

@ -52,7 +52,11 @@ const unsubscribe = client.subscribe("SomeType", (message) => {
console.log(message.type, message.data); 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" }); await client.send("SomeType", { value: "hello" });
const response = await client.request( const response = await client.request(
@ -93,7 +97,9 @@ const client = await MTPClient.create({
storage, 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: 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. - `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. - `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. - `disconnect()` stops protocol pings and closes the underlying WebTransport session.
For certificate pinning, pass WebTransport certificate hashes: For certificate pinning, pass WebTransport certificate hashes:

37
example/Cargo.lock generated
View file

@ -24,7 +24,7 @@ dependencies = [
"nom", "nom",
"num-traits", "num-traits",
"rusticata-macros", "rusticata-macros",
"thiserror", "thiserror 2.0.18",
"time", "time",
] ]
@ -929,7 +929,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"quinn", "quinn",
"rustls", "rustls",
"thiserror", "thiserror 2.0.18",
"wtransport", "wtransport",
] ]
@ -946,6 +946,7 @@ dependencies = [
"rand_core 0.6.4", "rand_core 0.6.4",
"serde", "serde",
"sha2 0.11.0", "sha2 0.11.0",
"thiserror 1.0.69",
"zeroize", "zeroize",
] ]
@ -1191,7 +1192,7 @@ dependencies = [
"rustc-hash", "rustc-hash",
"rustls", "rustls",
"socket2", "socket2",
"thiserror", "thiserror 2.0.18",
"tokio", "tokio",
"tracing", "tracing",
"web-time", "web-time",
@ -1213,7 +1214,7 @@ dependencies = [
"rustls", "rustls",
"rustls-pki-types", "rustls-pki-types",
"slab", "slab",
"thiserror", "thiserror 2.0.18",
"tinyvec", "tinyvec",
"tracing", "tracing",
"web-time", "web-time",
@ -1715,13 +1716,33 @@ dependencies = [
"syn", "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]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.18" version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [ 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]] [[package]]
@ -2172,7 +2193,7 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
"sha2 0.11.0", "sha2 0.11.0",
"socket2", "socket2",
"thiserror", "thiserror 2.0.18",
"time", "time",
"tokio", "tokio",
"tracing", "tracing",
@ -2189,7 +2210,7 @@ checksum = "d5867c629e4252f7439d82315923daaf27f4fa442410d51b78ab93ef4c432a11"
dependencies = [ dependencies = [
"httlib-huffman", "httlib-huffman",
"octets", "octets",
"thiserror", "thiserror 2.0.18",
"url", "url",
] ]
@ -2220,7 +2241,7 @@ dependencies = [
"oid-registry", "oid-registry",
"ring", "ring",
"rusticata-macros", "rusticata-macros",
"thiserror", "thiserror 2.0.18",
"time", "time",
] ]

View file

@ -199,7 +199,10 @@ async function connect() {
log(`Subscribed Pong: ${formatParsedFrame(frame)}`, "received"); 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; clientId = activeClientId;
loadKeys(); loadKeys();
log(`Connected as client ${activeClientId}`); log(`Connected as client ${activeClientId}`);

View file

@ -24,6 +24,55 @@ export type MTPLogEvent =
export type ParsedFrame = RawBindings.ParsedFrame; export type ParsedFrame = RawBindings.ParsedFrame;
export type Ed25519GenerateResult = ReturnType<typeof bindings.ed25519_generate>;
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 { export interface MTPCredentials {
clientId: bigint | string | number | null; clientId: bigint | string | number | null;
keyring: Uint8Array | number[]; keyring: Uint8Array | number[];
@ -270,12 +319,13 @@ async function withTimeout(promise, timeoutMs, message) {
} }
export class MTPClient { export class MTPClient {
static readonly crypto = crypto;
#credentials: InternalCredentials | null; #credentials: InternalCredentials | null;
#options: NormalizedMTPClientOptions; #options: NormalizedMTPClientOptions;
readonly raw: { readonly raw: MTPRaw;
client: RawBindings.WasmClient;
bindings: typeof RawBindings; readonly crypto = MTPClient.crypto;
};
private constructor(options: NormalizedMTPClientOptions, client: RawBindings.WasmClient) { private constructor(options: NormalizedMTPClientOptions, client: RawBindings.WasmClient) {
this.#options = options; this.#options = options;
@ -285,7 +335,7 @@ export class MTPClient {
static async create(options: MTPClientOptions = {} as MTPClientOptions): Promise<MTPClient> { static async create(options: MTPClientOptions = {} as MTPClientOptions): Promise<MTPClient> {
validateOptions(options); validateOptions(options);
await initWasm(options.wasm); await MTPClient.init(options.wasm);
const normalizedOptions = { const normalizedOptions = {
...options, ...options,
@ -333,10 +383,18 @@ export class MTPClient {
return WasmClient.is_supported(); return WasmClient.is_supported();
} }
static async init(wasm?: MTPClientOptions["wasm"]): Promise<Awaited<ReturnType<typeof initWasm>>> {
return await initWasm(wasm);
}
get credentials(): MTPCredentials | null { get credentials(): MTPCredentials | null {
return publicCredentials(this.#credentials); return publicCredentials(this.#credentials);
} }
get state(): RawBindings.ConnectionState {
return this.raw.client.state;
}
async #loadStoredCredentials() { async #loadStoredCredentials() {
if (this.#credentials || !this.#options.storage) { if (this.#credentials || !this.#options.storage) {
return; return;
@ -440,12 +498,6 @@ export class MTPClient {
} }
} }
async connectOrRegister(): Promise<bigint> {
return this.#credentials?.clientId == null
? await this.register()
: await this.#connectAuthenticated();
}
async #persistCredentials() { async #persistCredentials() {
await storageSet( await storageSet(
this.#options.storage, this.#options.storage,
@ -546,4 +598,4 @@ export class MTPClient {
} }
} }
export { bindings as raw }; export { ConnectionState, bindings as raw };