mtp/src/sdk/index.ts

3149 lines
92 KiB
TypeScript

import initWasm, {
ConnectionConfig,
ConnectionState,
WasmClient,
WasmPipeHandle,
keyring_generate,
} from "mtp/raw";
import * as bindings from "mtp/raw";
import { unixTimeMillis, utf8Encode } from "./utils.js";
import type * as RawBindings from "../raw/index";
import type { MTPCommunicationType } from "../type-map/index";
import { RESERVED_COMMUNICATION_TYPE_IDS } from "../type-map/reserved.js";
import type { MTPSessionStorage, MTPSessionState } from "./session";
import { MTPSessionManager } from "./session.js";
import type {
MTPEncryptedSecretRecord,
MTPEncryptedSecretProvider,
} from "./encrypted-secret";
import { InMemoryEncryptedSecretProvider } from "./encrypted-secret.js";
import { InMemorySessionStorage } from "./session.js";
import {
acceptMTPPipeSession,
acceptMTPPipeSessionAuto,
acceptMTPForwardSecurePipeSession,
initiateMTPForwardSecurePipeSession,
initiateMTPPipeSession,
MTPEncryptedPipeReader,
MTPEncryptedPipeWriter,
validateApplicationProtectionPurpose,
} from "./encrypted-pipe.js";
import {
DEFAULT_SIGNATURE_VERIFICATION_POLICY,
MTPSignatureVerificationError,
resolveSignatureVerificationPolicy,
signatureVerificationPolicyValue,
signerKeysUnavailable,
} from "./signature-policy.js";
import type { MTPSignatureVerificationPolicy } from "./signature-policy.js";
export type {
MTPSignatureVerificationErrorCode,
MTPSignatureVerificationPolicy,
} from "./signature-policy.js";
export {
DEFAULT_SIGNATURE_VERIFICATION_POLICY,
MTPSignatureVerificationError,
resolveSignatureVerificationPolicy,
} from "./signature-policy.js";
export type StorageValue = string | null;
export interface MTPCredentialStorage {
getItem(key: string): StorageValue | Promise<StorageValue>;
setItem(key: string, value: string): void | Promise<void>;
removeItem(key: string): void | Promise<void>;
}
export type MTPStorage = MTPCredentialStorage;
export type MTPLogEvent =
| {
hint: "info" | "warning";
type: string;
data: unknown;
direction?: "send" | "recv";
}
| {
hint: "error";
type: string | "error";
error: string;
data?: unknown;
direction?: "send" | "recv";
};
export type ParsedFrame = RawBindings.ParsedFrame;
export type Ed25519GenerateResult = ReturnType<
typeof bindings.ed25519_generate
>;
export type WasmEncapsulated = RawBindings.WasmEncapsulated;
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;
keyringToKeys(keyring: string | MTPBytesInput): MTPKeyringKeys;
publicKeyBundleToKeys(
publicKeyBundle: string | MTPBytesInput,
): MTPPublicKeyBundleKeys;
encrypt(key: Uint8Array, input: Uint8Array): Promise<Uint8Array>;
decrypt(key: Uint8Array, input: Uint8Array): Promise<Uint8Array>;
encryptText(key: Uint8Array, plaintext: string): Promise<string>;
decryptText(key: Uint8Array, ciphertext: string): Promise<string>;
encapsulate(otherPublicKey: Uint8Array): WasmEncapsulated;
decapsulate(ownPrivateKey: Uint8Array, ciphertext: 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),
keyringToKeys: (keyring) => keyringToKeys(keyring),
publicKeyBundleToKeys: (publicKeyBundle) =>
publicKeyBundleToKeys(publicKeyBundle),
encrypt: async (key, input) => {
const cipher = new bindings.WasmChaCha20Poly1305(key);
try {
return cipher.encrypt(input, new Uint8Array(0));
} finally {
cipher.free();
}
},
decrypt: async (key, input) => {
const cipher = new bindings.WasmChaCha20Poly1305(key);
try {
return cipher.decrypt(input, new Uint8Array(0));
} finally {
cipher.free();
}
},
encryptText: async (key, plaintext) => {
const cipher = new bindings.WasmChaCha20Poly1305(key);
try {
const ciphertext = cipher.encrypt(
utf8Encode(plaintext),
new Uint8Array(0),
);
return bytesToBase64(ciphertext);
} finally {
cipher.free();
}
},
decryptText: async (key, ciphertext) => {
const cipher = new bindings.WasmChaCha20Poly1305(key);
try {
const decoded = base64ToBytes(ciphertext);
const plaintext = cipher.decrypt(decoded, new Uint8Array(0));
return utf8Decode(plaintext);
} finally {
cipher.free();
}
},
encapsulate: (otherPublicKey) =>
bindings.wasm_kem_encapsulate(otherPublicKey),
decapsulate: (ownPrivateKey, ciphertext) =>
bindings.wasm_kem_decapsulate(ownPrivateKey, ciphertext),
};
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 type MTPBytesInput = Uint8Array | number[];
export interface MTPCodecOptions {
id?: number;
sender?: bigint | number;
receiver?: bigint | number;
}
export interface MTPCodec {
encode(
type: MTPCommunicationType,
data: Record<string, unknown>,
options?: MTPCodecOptions,
): Uint8Array;
decode(frame: MTPBytesInput): ParsedFrame;
format(frame: MTPBytesInput): string;
}
export function encode(
type: MTPCommunicationType,
data: Record<string, unknown>,
options?: MTPCodecOptions,
): Uint8Array {
return bindings.build_frame(type, data, options ?? {});
}
export function decode(frame: MTPBytesInput): ParsedFrame {
return bindings.parse_frame(bytesFrom(frame, "frame"));
}
export function format(frame: MTPBytesInput): string {
return bindings.format_frame(bytesFrom(frame, "frame"));
}
export const codec: MTPCodec = {
encode,
decode,
format,
};
export interface MTPCredentials {
clientId: bigint | string | number | null;
keyring: MTPBytesInput;
hostPublicKey?: MTPBytesInput | string;
}
export interface MTPClientCredentials {
clientId: bigint | null;
keyring: Uint8Array;
hostPublicKey?: Uint8Array;
}
export interface MTPKeyringKeys {
kemPublicKey: Uint8Array;
kemSecretKey: Uint8Array;
sigPqPublicKey: Uint8Array;
sigPqSecretKey: Uint8Array;
sigClPublicKey: Uint8Array;
sigClSecretKey: Uint8Array;
}
export interface MTPPublicKeyBundleKeys {
kemPublicKey: Uint8Array;
sigPqPublicKey: Uint8Array;
sigClPublicKey: Uint8Array;
}
export interface MTPClientOptions {
url: string;
descriptor?: string;
hostPublicKey?: MTPBytesInput | string;
credentials?: MTPCredentials | string | null;
credentialsStorageKey?: string;
storage?: MTPCredentialStorage;
serverCertificateHashes?: string[];
maxMessageSize?: number;
authTimeoutMs?: number;
requestTimeoutMs?: number;
pings?: boolean | { intervalMs?: number };
wasm?:
| RawBindings.InitInput
| Promise<RawBindings.InitInput>
| {
module_or_path: RawBindings.InitInput | Promise<RawBindings.InitInput>;
};
logger?: (event: MTPLogEvent) => void;
sessionStorage?: MTPSessionStorage;
/**
* Independent caller-managed encrypted-secret storage. Session state is not
* routed through this provider automatically.
*/
encryptedSecretProvider?: MTPEncryptedSecretProvider;
/** Default receiver policy for protected signatures. */
defaultSignatureVerificationPolicy?: MTPSignatureVerificationPolicy;
}
export type Unsubscribe = () => void;
export interface MTPFrameIdOptions {
id?: number;
}
export interface MTPAddressedFrameOptions extends MTPFrameIdOptions {
sender?: bigint | number;
receiver?: bigint | number;
}
export interface MTPSendOptions extends MTPAddressedFrameOptions {}
export interface MTPProtectionIdentity {
signerId: bigint | number | string;
keyring: string | MTPBytesInput;
}
/**
* Key material used to open protected MTP values.
*
* This identity is independent from transport authentication. Its optional
* ID is used only for structural destination checks when a receive operation
* supports one.
*/
export interface MTPDecryptionIdentity {
id?: bigint | number | string;
keyring: string | MTPBytesInput;
/**
* Previously used recipient keyrings, ordered newest to oldest. The
* current keyring is always attempted first.
*/
keyringHistory?: Array<string | MTPBytesInput>;
}
/** Decoded value returned by the MTP DataValue codec. */
export type MTPDataValue = RawBindings.ParsedDataValue;
/** JavaScript values accepted by the MTP DataValue encoder. */
export type MTPDataValueInput =
| null
| boolean
| number
| bigint
| string
| Uint8Array
| MTPDataValueInput[]
| { [key: string]: MTPDataValueInput };
/** Public-key material trusted for one protected signer identity. */
export type MTPResolvedSignerKeys = Array<string | MTPBytesInput>;
/**
* Resolve trusted public-key bundles for a claimed, unverified signer ID.
* The ID is used only as a trusted-key lookup key and becomes authenticated
* after the native protected codec verifies the signature.
*/
export type MTPSignerKeyResolver = (
signerId: bigint,
) => MTPResolvedSignerKeys | Promise<MTPResolvedSignerKeys>;
export interface MTPRelayPlan {
nextHopId: bigint | number | string;
finalRecipientId: bigint | number | string;
metadataRecipients: Array<string | MTPBytesInput>;
contentRecipients: Array<string | MTPBytesInput>;
metadata?: MTPDataValueInput;
}
export interface MTPSendProtectedOptions extends MTPFrameIdOptions {
receiverId: bigint | number | string;
identity?: MTPProtectionIdentity;
recipients: Array<string | MTPBytesInput>;
signaturePurpose: number;
encryptionPurpose: number;
signatureSuite?: MTPProtectionSignatureSuite;
exposeSender?: boolean;
}
export interface MTPSendSealedRelayOptions extends MTPRelayPlan {
identity?: MTPProtectionIdentity;
signatureSuite?: MTPProtectionSignatureSuite;
}
export interface MTPRelayVerificationOptions {
/** Key material used for protected opening, independent from transport auth. */
recipient?: MTPDecryptionIdentity;
/** Require the protected signer to be this MTP identity. */
expectedSignerId?: bigint | number | string;
/** Resolve trusted keys for a claimed, unverified signer lookup ID. */
resolveSignerPublicKeys?: MTPSignerKeyResolver;
/** Receiver policy applied to relay metadata and content signatures. */
signaturePolicy?: MTPSignatureVerificationPolicy;
}
export interface MTPOpenRelayMetadataOptions extends MTPRelayVerificationOptions {
/** Consume one authenticated relay ID from a caller-owned store. */
replayGuard?: MTPReplayGuard;
}
export interface MTPOpenRelayContentOptions extends MTPRelayVerificationOptions {
/** Validate the authenticated final recipient when supplied. */
expectedFinalRecipientId?: bigint | number | string;
}
export interface MTPOpenProtectedOptions {
recipient?: MTPDecryptionIdentity;
expectedSignerId?: bigint | number | string;
expectedReceiverId?: bigint | number | string;
resolveSignerPublicKeys: MTPSignerKeyResolver;
signaturePolicy?: MTPSignatureVerificationPolicy;
signaturePurpose: number;
encryptionPurpose: number;
/** Override the default process-local replay guard for durable storage. */
replayGuard?: MTPReplayGuard;
}
export interface MTPVerifiedProtectedMessage<T = MTPDataValue> {
type: string;
/** Authenticated direct-message envelope schema version. */
protectedVersion: number;
signerId: bigint;
/** Authenticated destination from the protected envelope. */
finalRecipientId: bigint;
/** Authenticated application message identifier. */
messageId: string;
/** Authenticated Unix epoch timestamp in milliseconds. */
createdAt: bigint;
receiver?: bigint;
outerSender?: bigint;
data: T;
}
export type MTPProtectedFrameInput = ParsedFrame | MTPBytesInput;
/** Options shared by the sealed-relay subscription APIs. */
export interface MTPEncryptedSubscriptionOptions
extends MTPOpenRelayContentOptions {
/** Consume authenticated relay IDs while dispatching the subscription. */
replayGuard?: MTPReplayGuard;
}
export type MTPProtectionSignatureSuite = "ed25519" | "dual";
export interface MTPReplayGuard {
/**
* Return true and atomically record the pair when it has not been seen.
* `createdAt` is authenticated metadata for retention/observability; the
* replay identity is only `(signerId, messageId)`.
*/
accept(
signerId: bigint,
messageId: string,
createdAt: bigint,
): boolean | Promise<boolean>;
}
/**
* Bounded process-local duplicate-suppression guard used by high-level direct
* protected and relay receives when the caller does not provide durable
* storage. Once the fixed cache is full, the oldest entry is evicted and may
* be accepted again; use a durable `MTPReplayGuard` for security-sensitive
* replay protection that must survive cache eviction, reloads, or multiple
* receiver processes.
*/
export class InMemoryReplayGuard implements MTPReplayGuard {
#accepted = new Set<string>();
readonly #capacity = 10_000;
accept(signerId: bigint, messageId: string, _createdAt: bigint): boolean {
const key = `${signerId}:${messageId}`;
if (this.#accepted.has(key)) return false;
this.#accepted.add(key);
if (this.#accepted.size > this.#capacity) {
const oldest = this.#accepted.values().next().value;
if (oldest !== undefined) this.#accepted.delete(oldest);
}
return true;
}
}
export class MTPReplayError extends Error {
readonly signerId: bigint;
readonly messageId: string;
constructor(signerId: bigint, messageId: string) {
super(`message ${messageId} from signer ${signerId} was already accepted`);
this.name = "MTPReplayError";
this.signerId = signerId;
this.messageId = messageId;
}
}
export class MTPMissingRelayVersionError extends Error {
constructor() {
super("relay frame does not declare a relay version");
this.name = "MTPMissingRelayVersionError";
}
}
export class MTPUnsupportedRelayVersionError extends Error {
readonly relayVersion: bigint;
constructor(relayVersion: bigint) {
super(`unsupported relay version ${relayVersion}`);
this.name = "MTPUnsupportedRelayVersionError";
this.relayVersion = relayVersion;
}
}
export class MTPMissingProtectedVersionError extends Error {
constructor() {
super("protected message does not declare a protected version");
this.name = "MTPMissingProtectedVersionError";
}
}
export class MTPUnsupportedProtectedVersionError extends Error {
readonly protectedVersion: bigint;
constructor(protectedVersion: bigint) {
super(`unsupported protected message version ${protectedVersion}`);
this.name = "MTPUnsupportedProtectedVersionError";
this.protectedVersion = protectedVersion;
}
}
function relayOpeningError(error: unknown, signerId?: bigint): Error {
if (error !== null && typeof error === "object") {
const structured = error as {
code?: unknown;
relayVersion?: unknown;
};
if (typeof structured.code === "string") {
switch (structured.code) {
case "missing-relay-version":
return new MTPMissingRelayVersionError();
case "unsupported-relay-version":
if (
typeof structured.relayVersion === "bigint" ||
typeof structured.relayVersion === "number" ||
typeof structured.relayVersion === "string"
) {
return new MTPUnsupportedRelayVersionError(
inputU64(structured.relayVersion, "relayVersion"),
);
}
break;
case "no-matching-recipient":
return new Error(
"Unable to decrypt protected value with supplied recipient keyrings",
);
case "not-final-recipient":
return new Error(
"relay content is addressed to a different final recipient",
);
case "reserved-application-type":
return new Error(
"relay application message type is reserved for MTP control",
);
case "signature-policy-mismatch":
return new MTPSignatureVerificationError(
"policy-rejected",
signerId,
);
case "unsupported-signature-suite":
return new MTPSignatureVerificationError(
"unsupported-suite",
signerId,
);
case "invalid-signature":
return new MTPSignatureVerificationError(
"invalid-signature",
signerId,
);
case "signer-id-mismatch":
return new Error("relay signer ID mismatch");
case "purpose-mismatch":
return new Error("relay protection purpose mismatch");
case "signer-key-not-found":
return signerKeysUnavailable(signerId);
case "replay":
return new Error("relay message was already accepted");
}
}
}
return error instanceof Error ? error : new Error(String(error));
}
function protectedOpeningError(error: unknown, signerId?: bigint): Error {
if (error !== null && typeof error === "object") {
const structured = error as {
code?: unknown;
protectedVersion?: unknown;
};
if (typeof structured.code === "string") {
switch (structured.code) {
case "missing-protected-version":
return new MTPMissingProtectedVersionError();
case "unsupported-protected-version":
if (
typeof structured.protectedVersion === "bigint" ||
typeof structured.protectedVersion === "number" ||
typeof structured.protectedVersion === "string"
) {
return new MTPUnsupportedProtectedVersionError(
inputU64(structured.protectedVersion, "protectedVersion"),
);
}
break;
case "no-matching-recipient":
return new Error(
"Unable to decrypt protected value with supplied recipient keyrings",
);
case "reserved-application-type":
return new Error(
"MTP control communication types cannot be used as application content",
);
case "signature-policy-mismatch":
return new MTPSignatureVerificationError(
"policy-rejected",
signerId,
);
case "unsupported-signature-suite":
return new MTPSignatureVerificationError(
"unsupported-suite",
signerId,
);
case "invalid-signature":
return new MTPSignatureVerificationError(
"invalid-signature",
signerId,
);
case "signer-id-mismatch":
return new Error("protected signer ID mismatch");
case "receiver-id-mismatch":
return new Error("protected frame receiver ID mismatch");
case "message-type-mismatch":
return new Error(
"protected message type does not match outer routing",
);
case "final-recipient-mismatch":
return new Error(
"protected final recipient does not match outer routing receiver",
);
case "sender-id-mismatch":
return new Error(
"protected frame sender does not match authenticated signer",
);
case "signer-key-not-found":
return signerKeysUnavailable(signerId);
case "replay":
return new Error("protected message was already accepted");
}
}
}
return error instanceof Error ? error : new Error(String(error));
}
interface MTPRelayMetadataState {
frame: ParsedFrame;
native: RawBindings.WasmVerifiedRelayMetadata;
relayVersion: number;
signerId: bigint;
finalRecipientId: bigint;
messageId: string;
createdAt: bigint;
hasMetadata: boolean;
metadata?: MTPDataValue;
encryptedContent: Uint8Array;
signerPublicKeys: Uint8Array[];
matchedSignerKeyIndex: number;
signaturePolicy: MTPSignatureVerificationPolicy;
disposed: boolean;
finalizerToken: object;
}
const relayMetadataState = new WeakMap<
MTPVerifiedRelayMetadata,
MTPRelayMetadataState
>();
const relayMetadataFinalizer = new FinalizationRegistry<
RawBindings.WasmVerifiedRelayMetadata
>((native) => {
try {
native.free();
} catch {
// The WASM instance may already have been torn down during page unload.
}
});
const RELAY_METADATA_TOKEN = Symbol("mtp-authenticated-relay-metadata");
/**
* Authenticated relay metadata produced only by `openRelayMetadata()`.
*
* The internal state is intentionally kept out of the public structural type
* so `openRelayContent()` cannot be fed a caller-fabricated "verified"
* object. Getters return immutable primitives or defensive byte copies.
*/
export class MTPVerifiedRelayMetadata {
constructor(
token: typeof RELAY_METADATA_TOKEN,
state: MTPRelayMetadataState,
) {
if (token !== RELAY_METADATA_TOKEN) {
throw new Error(
"relay metadata must be created by authenticated opening",
);
}
relayMetadataState.set(this, state);
}
private get state(): MTPRelayMetadataState {
const state = relayMetadataState.get(this);
if (!state)
throw new Error("relay metadata authentication state is missing");
if (state.disposed) throw new Error("relay metadata has been disposed");
return state;
}
/** Release the native verified metadata handle immediately. */
dispose(): void {
const state = relayMetadataState.get(this);
if (!state || state.disposed) return;
state.disposed = true;
relayMetadataFinalizer.unregister(state.finalizerToken);
try {
state.native.free();
} catch {
// The WASM instance may already have been torn down during page unload.
}
}
/** Alias for callers that use the WASM resource naming convention. */
free(): void {
this.dispose();
}
[Symbol.dispose](): void {
this.dispose();
}
get frame(): ParsedFrame {
return cloneParsedFrame(this.state.frame);
}
get signerId(): bigint {
return this.state.signerId;
}
/** Authenticated MTP relay metadata schema version. */
get relayVersion(): number {
return this.state.relayVersion;
}
get finalRecipientId(): bigint {
return this.state.finalRecipientId;
}
get messageId(): string {
return this.state.messageId;
}
/** Unix epoch milliseconds from the authenticated relay metadata. */
get createdAt(): bigint {
return this.state.createdAt;
}
get metadata(): MTPDataValue | undefined {
return this.state.hasMetadata
? (cloneParsedValue(this.state.metadata) as MTPDataValue)
: undefined;
}
get encryptedContent(): Uint8Array {
return this.state.encryptedContent.slice();
}
/** Trusted public-key history used for this verification. */
get signerPublicKeys(): Uint8Array[] {
return this.state.signerPublicKeys.map((bundle) => bundle.slice());
}
/** Index of the trusted key that authenticated this metadata. */
get matchedSignerKeyIndex(): number {
return this.state.matchedSignerKeyIndex;
}
/** The exact trusted public-key bundle that authenticated this metadata. */
get matchedSignerPublicKey(): Uint8Array {
const key = this.state.signerPublicKeys[this.state.matchedSignerKeyIndex];
if (!key) {
throw new Error("relay verification matched an unavailable signer key");
}
return key.slice();
}
get signaturePolicy(): MTPSignatureVerificationPolicy {
return this.state.signaturePolicy;
}
}
export interface MTPVerifiedRelayContent {
type: string;
data: MTPDataValue;
signerId: bigint;
finalRecipientId: bigint;
messageId: string;
/** Unix epoch milliseconds from the authenticated relay metadata. */
createdAt: bigint;
metadata?: MTPDataValue;
}
export interface MTPRequestOptions extends MTPSendOptions {
responseType?: MTPCommunicationType;
timeoutMs?: number;
}
export interface MTPPipeWriter {
write(data: Uint8Array): Promise<void>;
close(): Promise<void>;
abort(): void;
readonly pipeId: number;
}
export interface MTPPipeReader {
read(): Promise<Uint8Array | null>;
readonly pipeId: number;
readonly description: string;
}
export interface MTPPipeRequest {
pipeId: number;
description: string;
}
export interface MTPOutgoingPipeHandle {
readonly pipeId: number;
readonly description: string;
wait(): Promise<MTPPipeWriter | null>;
}
export interface MTPCreateEncryptedPipeOptions {
recipientId: bigint | number | string;
recipientPublicKey?: string | MTPBytesInput;
recipientPublicKeys?: Array<string | MTPBytesInput>;
description?: string;
purpose?: number;
direction?: number;
signatureSuite?: MTPProtectionSignatureSuite;
}
export interface MTPAcceptEncryptedPipeOptions {
senderId: bigint | number | string;
senderPublicKey?: string | MTPBytesInput;
senderPublicKeys?: Array<string | MTPBytesInput>;
purpose?: number;
direction?: number;
signaturePolicy?: MTPSignatureVerificationPolicy;
}
type InternalCredentials = {
clientId: bigint | null;
keyring: Uint8Array;
hostPublicKey?: Uint8Array;
};
type NormalizedMTPClientOptions = Omit<MTPClientOptions, "hostPublicKey"> & {
hostPublicKey?: Uint8Array;
};
const DEFAULT_CREDENTIALS_KEY = "mtp:credentials";
let wasmInitPromise: Promise<Awaited<ReturnType<typeof initWasm>>> | undefined;
function createMessageId(): string {
const bytes = new Uint8Array(16);
globalThis.crypto.getRandomValues(bytes);
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(
"",
);
}
function protectionSignatureSuiteValue(
suite: MTPProtectionSignatureSuite,
): number {
return suite === "dual"
? bindings.mtp_protection_signature_suite_dual()
: bindings.mtp_protection_signature_suite_ed25519();
}
function effectiveProtectionSignatureSuite(
keyring: Uint8Array,
requested?: MTPProtectionSignatureSuite,
): MTPProtectionSignatureSuite {
const keys = keyringToKeys(keyring);
const hasPqPublicKey = keys.sigPqPublicKey.length > 0;
const hasPqSecretKey = keys.sigPqSecretKey.length > 0;
const suite = requested ?? "ed25519";
if (suite !== "ed25519" && suite !== "dual") {
throw new Error("signatureSuite must be 'ed25519' or 'dual'");
}
if (suite === "dual" && !(hasPqPublicKey && hasPqSecretKey)) {
throw new Error(
"dual protected signatures require a complete ML-DSA key pair; choose 'ed25519' for a partial keyring",
);
}
return suite;
}
interface ResolvedProtectionIdentity {
signerId: bigint;
keyring: Uint8Array;
}
interface ResolvedDecryptionIdentity {
id?: bigint;
keyrings: Uint8Array[];
}
interface SignerResolutionOptions {
expectedSignerId?: bigint | number | string;
resolveSignerPublicKeys?: MTPSignerKeyResolver;
}
function sameBytes(left: Uint8Array, right: Uint8Array): boolean {
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) return false;
}
return true;
}
/**
* Normalize one recipient identity into current-first, duplicate-free keyring
* bytes. The returned arrays are owned by the SDK and never alias caller
* input.
*/
function normalizeDecryptionKeyrings(
identity: MTPDecryptionIdentity,
): Uint8Array[] {
const current = normalizeBytes(identity.keyring, "recipient.keyring");
if (current.length === 0) {
throw new Error("recipient.keyring must not be empty");
}
if (
identity.keyringHistory !== undefined &&
!Array.isArray(identity.keyringHistory)
) {
throw new TypeError("recipient.keyringHistory must be an array");
}
const keyrings: Uint8Array[] = [];
const add = (value: string | MTPBytesInput, name: string): void => {
const bytes = normalizeBytes(value, name);
if (bytes.length === 0) {
throw new Error(`${name} must not be empty`);
}
if (!keyrings.some((existing) => sameBytes(existing, bytes))) {
keyrings.push(bytes.slice());
}
};
add(current, "recipient.keyring");
for (const [index, history] of (
identity.keyringHistory ?? []
).entries()) {
add(history, `recipient.keyringHistory[${index}]`);
}
if (keyrings.length === 0) {
throw new Error("recipient must contain at least one keyring");
}
return keyrings;
}
function normalizeRecipientBundles(
recipients: Array<string | MTPBytesInput>,
name: string,
): Uint8Array[] {
if (!Array.isArray(recipients) || recipients.length === 0) {
throw new TypeError(`${name} must contain at least one public key bundle`);
}
return recipients.map((value, index) => {
const bundle = normalizeBytes(value, `${name}[${index}]`);
publicKeyBundleToKeys(bundle);
return bundle.slice();
});
}
function resolveProtectionIdentity(
explicit: MTPProtectionIdentity | undefined,
stored: InternalCredentials | null,
): ResolvedProtectionIdentity {
if (explicit) {
return {
signerId: inputU64(explicit.signerId, "identity.signerId"),
keyring: normalizeBytes(explicit.keyring, "identity.keyring").slice(),
};
}
if (stored?.clientId != null && stored.keyring.length > 0) {
return {
signerId: stored.clientId,
keyring: stored.keyring.slice(),
};
}
throw new Error(
"protected send requires an explicit protection identity or stored registered credentials",
);
}
function resolveDecryptionIdentity(
explicit: MTPDecryptionIdentity | undefined,
stored: InternalCredentials | null,
): ResolvedDecryptionIdentity {
if (explicit) {
return {
id:
explicit.id == null
? undefined
: inputU64(explicit.id, "recipient.id"),
keyrings: normalizeDecryptionKeyrings(explicit),
};
}
if (stored?.clientId != null && stored.keyring.length > 0) {
return {
id: stored.clientId,
keyrings: normalizeDecryptionKeyrings({
id: stored.clientId,
keyring: stored.keyring,
}),
};
}
throw new Error(
"protected receive requires an explicit decryption identity or stored registered credentials",
);
}
const KEM_PUBLIC_KEY_LEN = 1216;
const SIG_PQ_PUBLIC_KEY_LEN = 1952;
const SIG_CL_PUBLIC_KEY_LEN = 32;
function emit(
logger: MTPClientOptions["logger"] | undefined,
event: MTPLogEvent,
): void {
if (typeof logger === "function") {
logger(event);
}
}
function isErrorType(type: string): boolean {
return (
type === "Error" ||
type.startsWith("Error") ||
[
"BadRequest",
"Unauthorized",
"Forbidden",
"NotFound",
"TooManyRequests",
"InternalServerError",
"BadGateway",
"ServiceUnavailable",
"GatewayTimeout",
].includes(type)
);
}
function parsedDataObject(
data: ParsedFrame["data"] | null | undefined,
): Record<string, unknown> {
if (
data === null ||
typeof data !== "object" ||
Array.isArray(data) ||
data instanceof Uint8Array
) {
return {};
}
const object = data as Record<string, unknown>;
if (object.kind === "encrypted" || object.kind === "signed") {
return {};
}
return object;
}
function parseProtectedFrame(frame: MTPProtectedFrameInput): ParsedFrame {
if (isBytes(frame)) {
return bindings.parse_frame(bytesFrom(frame, "frame"));
}
if (
frame === null ||
typeof frame !== "object" ||
typeof frame.type !== "string"
) {
throw new TypeError("frame must be a parsed MTP frame or serialized bytes");
}
if (frame.raw instanceof Uint8Array) {
return bindings.parse_frame(frame.raw);
}
return frame;
}
function assertKnownCommunicationType(frame: ParsedFrame): void {
if (!frame.type || /^[0-9]+$/.test(frame.type)) {
throw new Error(`Unknown communication type: ${frame.type || "unknown"}`);
}
try {
bindings.build_frame(frame.type, null, {});
} catch (error) {
throw new Error(`Unknown communication type: ${frame.type}`, {
cause: error,
});
}
}
function protectedFrameBytes(frame: ParsedFrame): Uint8Array {
if (frame.raw instanceof Uint8Array) return frame.raw.slice();
const data =
frame.data !== null &&
typeof frame.data === "object" &&
!Array.isArray(frame.data) &&
!(frame.data instanceof Uint8Array)
? (frame.data as Record<string, unknown>)
: null;
const encoded = data?.encoded;
if (data?.kind !== "encrypted" || !(encoded instanceof Uint8Array)) {
throw new Error("protected frame payload is not encrypted");
}
return bindings.build_frame_with_payload(frame.type, encoded, {
id: frame.id,
...(frame.sender == null ? {} : { sender: frame.sender }),
...(frame.receiver == null ? {} : { receiver: frame.receiver }),
});
}
function assertApplicationCommunicationType(type: string): string {
if (typeof type !== "string" || !type.trim() || /^[0-9]+$/.test(type)) {
throw new Error(`Unknown communication type: ${type || "unknown"}`);
}
if (Object.prototype.hasOwnProperty.call(RESERVED_COMMUNICATION_TYPE_IDS, type)) {
throw new Error(
`MTP control communication type ${type} cannot be used as application content`,
);
}
try {
bindings.build_frame(type, null, {});
} catch (error) {
throw new Error(`Unknown communication type: ${type}`, { cause: error });
}
return type;
}
function cloneParsedValue(value: unknown): unknown {
if (value instanceof Uint8Array) return value.slice();
if (Array.isArray(value)) return value.map(cloneParsedValue);
if (value !== null && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [
key,
cloneParsedValue(entry),
]),
);
}
return value;
}
function cloneParsedFrame(frame: ParsedFrame): ParsedFrame {
return cloneParsedValue(frame) as ParsedFrame;
}
function encodeMTPDataValue(value: unknown): Uint8Array {
const ancestors = new WeakSet<object>();
const validate = (candidate: unknown): void => {
if (
candidate === null ||
typeof candidate === "boolean" ||
typeof candidate === "string" ||
typeof candidate === "bigint" ||
candidate instanceof Uint8Array
) {
return;
}
if (typeof candidate === "number") {
if (Number.isInteger(candidate) && !Number.isSafeInteger(candidate)) {
throw new TypeError(
"unsafe integral MTP DataValue inputs must use bigint",
);
}
return;
}
if (typeof candidate !== "object") {
throw new TypeError(`unsupported MTP DataValue input: ${typeof candidate}`);
}
const object = candidate as object;
if (ancestors.has(object)) {
throw new TypeError("MTP DataValue input must not be cyclic");
}
if (
!Array.isArray(candidate) &&
Object.getPrototypeOf(candidate) !== Object.prototype &&
Object.getPrototypeOf(candidate) !== null
) {
throw new TypeError("MTP DataValue containers must be plain objects");
}
ancestors.add(object);
const entries = Array.isArray(candidate)
? candidate
: Object.values(candidate as Record<string, unknown>);
for (const entry of entries) validate(entry);
ancestors.delete(object);
};
validate(value);
return bindings.encode_data_value(value);
}
function valueAsBigInt(value: unknown, name: string): bigint {
try {
if (typeof value === "bigint") return value;
if (typeof value === "number" && Number.isSafeInteger(value)) {
return BigInt(value);
}
if (typeof value === "string" && value.length > 0) {
return BigInt(value);
}
} catch {
// Normalize all malformed protected metadata to one caller-facing error.
}
throw new Error(`protected metadata field ${name} is not an integer`);
}
function valueAsUnsignedBigInt(value: unknown, name: string): bigint {
const result = valueAsBigInt(value, name);
if (result < 0n) {
throw new Error(`protected metadata field ${name} must be unsigned`);
}
return result;
}
function valueAsU64BigInt(value: unknown, name: string): bigint {
const result = valueAsUnsignedBigInt(value, name);
if (result > 0xffff_ffff_ffff_ffffn) {
throw new Error(`protected metadata field ${name} is outside u64 range`);
}
return result;
}
function inputU64(value: bigint | number | string, name: string): bigint {
if (typeof value === "number" && !Number.isSafeInteger(value)) {
throw new RangeError(
`${name} must be a safe integer number, bigint, or integer string`,
);
}
let result: bigint;
try {
result = BigInt(value);
} catch (error) {
throw new RangeError(`${name} must be an integer`, { cause: error });
}
if (result < 0n || result > 0xffff_ffff_ffff_ffffn) {
throw new RangeError(`${name} must be a u64`);
}
return result;
}
function valueAsString(value: unknown, name: string): string {
if (typeof value === "string" && value.length > 0) return value;
throw new Error(`protected metadata field ${name} is not a non-empty string`);
}
function errorMessage(
frame: Pick<ParsedFrame, "type" | "data"> | null | undefined,
): string {
const data = parsedDataObject(frame?.data);
return String(
data.ErrorMessage ??
data.Error ??
data.Description ??
`Received ${frame?.type ?? "error"} frame`,
);
}
async function storageGet(
storage: MTPCredentialStorage | undefined,
key: string,
): Promise<StorageValue> {
return storage ? await storage.getItem(key) : null;
}
async function storageSet(
storage: MTPCredentialStorage | undefined,
key: string,
value: string,
): Promise<void> {
if (storage) {
await storage.setItem(key, value);
}
}
async function storageRemove(
storage: MTPCredentialStorage | undefined,
key: string,
): Promise<void> {
if (storage) {
await storage.removeItem(key);
}
}
function isBytes(value: unknown): value is MTPBytesInput {
return value instanceof Uint8Array || Array.isArray(value);
}
function bytesFrom(value: MTPBytesInput, name: string): Uint8Array {
if (value instanceof Uint8Array) {
return value;
}
if (Array.isArray(value)) {
return new Uint8Array(value);
}
throw new TypeError(`${name} must be a Uint8Array or number[]`);
}
function bytesFromString(value: string, name: string): Uint8Array {
const trimmed = value.trim();
if (!trimmed) {
throw new TypeError(`${name} must not be empty`);
}
const hex = trimmed.replace(/^(0x)/i, "").replace(/[\s:_-]/g, "");
if (/^[0-9a-fA-F]+$/.test(hex)) {
if (hex.length % 2 !== 0) {
throw new TypeError(`${name} hex string has an odd length`);
}
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i += 1) {
bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return bytes;
}
if (typeof atob === "function") {
const binary = atob(trimmed);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
if (typeof Buffer !== "undefined") {
return new Uint8Array(Buffer.from(trimmed, "base64"));
}
throw new TypeError(`${name} must be bytes, hex, or base64`);
}
const HEX_DIGITS = "0123456789abcdef";
function bytesToHex(bytes: Uint8Array): string {
let out = "";
for (let i = 0; i < bytes.length; i += 1) {
out += HEX_DIGITS[(bytes[i] >> 4) & 0xf] + HEX_DIGITS[bytes[i] & 0xf];
}
return out;
}
export function bytesToBase64(bytes: Uint8Array): string {
if (typeof btoa === "function") {
let binary = "";
for (let i = 0; i < bytes.length; i += 1) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
if (typeof Buffer !== "undefined") {
return Buffer.from(bytes).toString("base64");
}
throw new TypeError("base64 encoding is not available in this environment");
}
export function base64ToBytes(input: string): Uint8Array {
if (typeof atob === "function") {
const binary = atob(input);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
if (typeof Buffer !== "undefined") {
return new Uint8Array(Buffer.from(input, "base64"));
}
throw new TypeError("base64 decoding is not available in this environment");
}
function utf8Decode(bytes: Uint8Array): string {
if (typeof TextDecoder !== "undefined") {
return new TextDecoder().decode(bytes);
}
if (typeof Buffer !== "undefined") {
return Buffer.from(bytes).toString("utf-8");
}
let out = "";
let i = 0;
while (i < bytes.length) {
const b = bytes[i];
if (b < 0x80) {
out += String.fromCharCode(b);
i += 1;
} else if (b < 0xc0) {
i += 1;
} else if (b < 0xe0) {
out += String.fromCharCode(((b & 0x1f) << 6) | (bytes[i + 1] & 0x3f));
i += 2;
} else if (b < 0xf0) {
out += String.fromCharCode(
((b & 0x0f) << 12) |
((bytes[i + 1] & 0x3f) << 6) |
(bytes[i + 2] & 0x3f),
);
i += 3;
} else {
const cp =
((b & 0x07) << 18) |
((bytes[i + 1] & 0x3f) << 12) |
((bytes[i + 2] & 0x3f) << 6) |
(bytes[i + 3] & 0x3f);
out += String.fromCodePoint(cp);
i += 4;
}
}
return out;
}
const SYMMETRIC_KEY_SALT = utf8Encode("mtp-symmetric-key");
export function secretKeyFromString(secret: string): Uint8Array {
if (typeof secret !== "string" || !secret.trim()) {
throw new TypeError("secret must be a non-empty string");
}
const trimmed = secret.trim();
const hex = trimmed.replace(/^(0x)/i, "").replace(/[\s:_-]/g, "");
if (/^[0-9a-fA-F]+$/.test(hex) && hex.length === 64) {
const bytes = new Uint8Array(32);
for (let i = 0; i < 32; i += 1) {
bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return bytes;
}
if (typeof atob === "function" || typeof Buffer !== "undefined") {
try {
const decoded = bytesFromString(trimmed, "secret");
if (decoded.length === 32) {
return decoded;
}
} catch {
// fall through to HKDF derivation
}
}
const ikm = utf8Encode(trimmed);
return bindings.wasm_derive_encryption_key(
ikm,
SYMMETRIC_KEY_SALT,
SYMMETRIC_KEY_SALT,
);
}
function normalizeBytes(
value: string | MTPBytesInput,
name: string,
): Uint8Array {
if (typeof value === "string") {
return bytesFromString(value, name);
}
return bytesFrom(value, name);
}
function normalizeCredentials(
value: MTPCredentials | string | null,
): MTPCredentials | null {
if (!value) {
return null;
}
if (typeof value === "string") {
return JSON.parse(value);
}
return value;
}
function toBigInt(
value: bigint | string | number | null | undefined,
): bigint | null {
if (value == null || value === "") {
return null;
}
return inputU64(value, "clientId");
}
function generateKeyringBytes() {
return keyring_generate();
}
export function keyringToKeys(keyring: string | MTPBytesInput): MTPKeyringKeys {
const bytes =
typeof keyring === "string"
? bytesFromString(keyring, "keyring")
: bytesFrom(keyring, "keyring");
if (bytes.length < 12) {
throw new TypeError("keyring data is too short to contain 6 keys");
}
let offset = 0;
const readKey = () => {
if (offset + 2 > bytes.length) {
throw new TypeError("keyring is truncated");
}
const len = (bytes[offset] << 8) | bytes[offset + 1];
offset += 2;
if (offset + len > bytes.length) {
throw new TypeError("keyring is truncated");
}
const key = bytes.slice(offset, offset + len);
offset += len;
return key;
};
const result = {
kemPublicKey: readKey(),
kemSecretKey: readKey(),
sigPqPublicKey: readKey(),
sigPqSecretKey: readKey(),
sigClPublicKey: readKey(),
sigClSecretKey: readKey(),
};
if (offset !== bytes.length) {
throw new TypeError("keyring has trailing data");
}
return result;
}
export function publicKeyBundleToKeys(
publicKeyBundle: string | MTPBytesInput,
): MTPPublicKeyBundleKeys {
const bytes =
typeof publicKeyBundle === "string"
? bytesFromString(publicKeyBundle, "publicKeyBundle")
: bytesFrom(publicKeyBundle, "publicKeyBundle");
if (bytes.length < 6) {
throw new TypeError(
"public key bundle data is too short to contain 3 keys",
);
}
let offset = 0;
const readKey = () => {
if (offset + 2 > bytes.length) {
throw new TypeError("public key bundle is truncated");
}
const len = (bytes[offset] << 8) | bytes[offset + 1];
offset += 2;
if (offset + len > bytes.length) {
throw new TypeError("public key bundle is truncated");
}
const key = bytes.slice(offset, offset + len);
offset += len;
return key;
};
const result = {
kemPublicKey: readKey(),
sigPqPublicKey: readKey(),
sigClPublicKey: readKey(),
};
if (offset !== bytes.length) {
throw new TypeError("public key bundle has trailing data");
}
if (
result.kemPublicKey.length !== KEM_PUBLIC_KEY_LEN ||
result.sigPqPublicKey.length !== SIG_PQ_PUBLIC_KEY_LEN ||
result.sigClPublicKey.length !== SIG_CL_PUBLIC_KEY_LEN
) {
throw new TypeError("public key bundle contains invalid suite key lengths");
}
return result;
}
function serializeCredentials(credentials) {
return JSON.stringify({
clientId: credentials.clientId?.toString() ?? null,
keyring: Array.from(credentials.keyring ?? []),
hostPublicKey: credentials.hostPublicKey
? Array.from(credentials.hostPublicKey)
: undefined,
});
}
function deserializeCredentials(credentials) {
const normalized = normalizeCredentials(credentials);
if (!normalized) {
return null;
}
const keyring = normalized.keyring;
if (!isBytes(keyring)) {
throw new TypeError("credentials.keyring must be a Uint8Array or number[]");
}
return {
clientId: toBigInt(normalized.clientId),
keyring: bytesFrom(keyring, "credentials.keyring"),
hostPublicKey:
normalized.hostPublicKey == null
? undefined
: normalizeBytes(normalized.hostPublicKey, "credentials.hostPublicKey"),
};
}
function publicCredentials(credentials) {
if (!credentials) {
return null;
}
return {
clientId: credentials.clientId,
keyring: credentials.keyring,
hostPublicKey: credentials.hostPublicKey,
};
}
function validateOptions(options) {
if (!options || typeof options !== "object") {
throw new TypeError("MTPClient.create requires an options object");
}
if (typeof options.url !== "string" || !options.url.trim()) {
throw new TypeError("MTPClient.create requires a non-empty url");
}
if (options.descriptor != null && typeof options.descriptor !== "string") {
throw new TypeError("descriptor must be a string");
}
resolveSignatureVerificationPolicy(
undefined,
options.defaultSignatureVerificationPolicy,
);
if (options.storage) {
for (const method of ["getItem", "setItem", "removeItem"]) {
if (typeof options.storage[method] !== "function") {
throw new TypeError(`storage.${method} must be a function`);
}
}
}
if (
options.maxMessageSize != null &&
(!Number.isSafeInteger(options.maxMessageSize) ||
options.maxMessageSize <= 0)
) {
throw new TypeError("maxMessageSize must be a positive safe integer");
}
if (
options.authTimeoutMs != null &&
(!Number.isSafeInteger(options.authTimeoutMs) || options.authTimeoutMs <= 0)
) {
throw new TypeError("authTimeoutMs must be a positive safe integer");
}
if (
options.requestTimeoutMs != null &&
(!Number.isSafeInteger(options.requestTimeoutMs) ||
options.requestTimeoutMs <= 0)
) {
throw new TypeError("requestTimeoutMs must be a positive safe integer");
}
}
async function withTimeout(promise, timeoutMs, message, cancel?: () => void) {
if (!timeoutMs) {
return await promise;
}
let timeoutId;
let timedOut = false;
try {
return await Promise.race([
promise,
new Promise((_resolve, reject) => {
timeoutId = setTimeout(() => {
timedOut = true;
cancel?.();
reject(new Error(message));
}, timeoutMs);
}),
]);
} finally {
clearTimeout(timeoutId);
// A JS Promise.race cannot cancel the losing WASM future. Wait for the
// raw attempt to observe disconnect() before its borrowed wasm arguments
// are released by the caller's finally block.
if (timedOut) {
await promise.catch(() => undefined);
}
}
}
export class MTPClient {
static readonly crypto = crypto;
static readonly codec = codec;
#credentials: InternalCredentials | null;
#options: NormalizedMTPClientOptions;
readonly #protectedReplayGuard = new InMemoryReplayGuard();
readonly raw: MTPRaw;
readonly crypto = MTPClient.crypto;
readonly codec = MTPClient.codec;
readonly sessionManager: MTPSessionManager;
/** Independent encrypted-secret storage selected by the caller. */
readonly encryptedSecretProvider: MTPEncryptedSecretProvider;
private constructor(
options: NormalizedMTPClientOptions,
client: RawBindings.WasmClient,
) {
this.#options = options;
this.#credentials = deserializeCredentials(options.credentials);
this.raw = { client, bindings };
this.encryptedSecretProvider =
options.encryptedSecretProvider ?? new InMemoryEncryptedSecretProvider();
this.sessionManager = new MTPSessionManager(
options.sessionStorage ?? new InMemorySessionStorage(),
);
}
static async create(options: MTPClientOptions): Promise<MTPClient> {
validateOptions(options);
await MTPClient.init(options.wasm);
const normalizedOptions = {
...options,
hostPublicKey:
options.hostPublicKey == null
? undefined
: normalizeBytes(options.hostPublicKey, "hostPublicKey"),
};
let sdk: MTPClient | undefined;
const client = new WasmClient(
(state) =>
emit(normalizedOptions.logger, {
hint: "info",
type: "state",
data: ConnectionState[state] ?? state,
}),
(frame) => {
if (sdk) {
sdk.#handleFrame(frame);
}
},
(error) =>
emit(normalizedOptions.logger, {
hint: "error",
type: "Error",
error: String(error),
}),
);
sdk = new MTPClient(normalizedOptions, client);
await sdk.#loadStoredCredentials();
if (!sdk.#credentials) {
sdk.#credentials = {
clientId: null,
keyring: generateKeyringBytes(),
hostPublicKey: normalizedOptions.hostPublicKey,
};
} else if (
!sdk.#credentials.hostPublicKey &&
normalizedOptions.hostPublicKey
) {
sdk.#credentials = {
...sdk.#credentials,
hostPublicKey: normalizedOptions.hostPublicKey,
};
} else if (
!normalizedOptions.hostPublicKey &&
sdk.#credentials.hostPublicKey
) {
sdk.#options = {
...sdk.#options,
hostPublicKey: sdk.#credentials.hostPublicKey,
};
}
return sdk;
}
static isSupported(): boolean {
return WasmClient.is_supported();
}
static async init(
wasm?: MTPClientOptions["wasm"],
): Promise<Awaited<ReturnType<typeof initWasm>>> {
wasmInitPromise ??= initWasm(wasm);
return await wasmInitPromise;
}
get credentials(): MTPClientCredentials | null {
return publicCredentials(this.#credentials);
}
get defaultSignatureVerificationPolicy(): MTPSignatureVerificationPolicy {
return resolveSignatureVerificationPolicy(
undefined,
this.#options.defaultSignatureVerificationPolicy,
);
}
get state(): RawBindings.ConnectionState {
return this.raw.client.state;
}
get pingMs(): number | null {
return this.raw.client.ping_ms ?? null;
}
async #loadStoredCredentials() {
if (this.#credentials || !this.#options.storage) {
return;
}
const stored = await storageGet(
this.#options.storage,
this.#options.credentialsStorageKey ?? DEFAULT_CREDENTIALS_KEY,
);
this.#credentials = deserializeCredentials(stored);
}
#connectionConfig() {
const config = new ConnectionConfig(this.#options.url);
if (this.#options.serverCertificateHashes) {
config.server_certificate_hashes = this.#options.serverCertificateHashes;
}
if (this.#options.maxMessageSize != null) {
config.max_message_size = this.#options.maxMessageSize;
}
if (this.#options.descriptor != null) {
config.description = this.#options.descriptor;
}
return config;
}
async connect(): Promise<void> {
if (this.#credentials?.clientId != null && this.#options.hostPublicKey) {
await this.auth();
return;
}
await this.connectUnauthenticated();
}
async connectUnauthenticated(): Promise<void> {
const config = this.#connectionConfig();
try {
await withTimeout(
this.raw.client.connect(config),
this.#options.authTimeoutMs,
"connection timed out",
() => this.raw.client.disconnect(),
);
const clientId = (
this.raw.client as RawBindings.WasmClient & { readonly client_id: bigint }
).client_id;
this.#startPings(clientId);
} finally {
config.free();
}
}
async auth(): Promise<bigint> {
if (!this.#options.hostPublicKey) {
throw new Error("MTPClient.auth requires hostPublicKey");
}
return this.#credentials?.clientId == null
? await this.register()
: await this.#connectAuthenticated();
}
async #connectAuthenticated() {
if (!this.#options.hostPublicKey) {
throw new Error(
"MTPClient.connect requires hostPublicKey for authenticated connections",
);
}
if (
!this.#credentials?.keyring?.length ||
this.#credentials.clientId == null
) {
throw new Error(
"MTPClient.connect requires credentials with clientId and keyring",
);
}
const config = this.#connectionConfig();
try {
const clientId = await withTimeout(
this.raw.client.auth_connect(
config,
this.#options.hostPublicKey,
this.#credentials.keyring,
this.#credentials.clientId,
),
this.#options.authTimeoutMs,
"authentication timed out",
() => this.raw.client.disconnect(),
);
this.#credentials = { ...this.#credentials, clientId };
await this.#persistCredentials();
this.#startPings(clientId);
return clientId;
} finally {
config.free();
}
}
async register(): Promise<bigint> {
if (!this.#options.hostPublicKey) {
throw new Error("MTPClient.register requires hostPublicKey");
}
if (!this.#credentials?.keyring?.length) {
this.#credentials = {
clientId: null,
keyring: generateKeyringBytes(),
hostPublicKey: this.#options.hostPublicKey,
};
}
const config = this.#connectionConfig();
try {
const clientId = await withTimeout(
this.raw.client.auth_register(
config,
this.#options.hostPublicKey,
this.#credentials.keyring,
),
this.#options.authTimeoutMs,
"authentication timed out",
() => this.raw.client.disconnect(),
);
this.#credentials = { ...this.#credentials, clientId };
await this.#persistCredentials();
this.#startPings(clientId);
return clientId;
} finally {
config.free();
}
}
async #persistCredentials() {
await storageSet(
this.#options.storage,
this.#options.credentialsStorageKey ?? DEFAULT_CREDENTIALS_KEY,
serializeCredentials(this.#credentials),
);
}
async clearCredentials(): Promise<void> {
this.#credentials = null;
await storageRemove(
this.#options.storage,
this.#options.credentialsStorageKey ?? DEFAULT_CREDENTIALS_KEY,
);
}
#startPings(clientId) {
const pings = this.#options.pings;
if (!pings) {
this.raw.client.stop_protocol_pings();
return;
}
const intervalMs =
typeof pings === "object" ? (pings.intervalMs ?? 30_000) : 30_000;
this.raw.client.start_protocol_pings(intervalMs, clientId);
}
#buildFrame(typeOrFrame, data, options) {
if (typeOrFrame instanceof Uint8Array) {
return typeOrFrame;
}
if (typeof typeOrFrame !== "string" || !typeOrFrame) {
throw new TypeError(
"message type must be a non-empty string or Uint8Array frame",
);
}
if (data == null || typeof data !== "object" || Array.isArray(data)) {
throw new TypeError("message data must be an object");
}
return this.raw.bindings.build_frame(typeOrFrame, data, options ?? {});
}
async send(message: Uint8Array): Promise<void>;
async send(
type: MTPCommunicationType,
data: Record<string, unknown>,
options?: MTPSendOptions,
): Promise<void>;
async send(
typeOrFrame: Uint8Array | MTPCommunicationType,
data?: Record<string, unknown>,
options?: MTPSendOptions,
): Promise<void> {
const message = this.#buildFrame(typeOrFrame, data, options);
try {
const frame = this.raw.bindings.parse_frame(message);
emit(
this.#options.logger,
isErrorType(frame.type)
? {
hint: "error",
type: frame.type,
error: errorMessage(frame),
data: frame.data,
direction: "send",
}
: {
hint: "info",
type: frame.type,
data: frame.data,
direction: "send",
},
);
} catch (error) {
emit(this.#options.logger, {
hint: "error",
type: "Error",
error: String(error),
direction: "send",
});
}
await this.raw.client.send(message);
}
/** Re-route an opaque sealed relay payload without opening it. */
forwardRelayFrame(
frame: Uint8Array | ParsedFrame,
nextHopId: bigint | number | string,
): Uint8Array {
const raw = frame instanceof Uint8Array ? frame : frame.raw;
const nextHop = inputU64(nextHopId, "nextHopId");
return bindings.forward_encrypted_relay_frame(raw, nextHop);
}
/** Forward an opaque sealed relay frame through this client connection. */
async forwardRelay(
frame: Uint8Array | ParsedFrame,
nextHopId: bigint | number | string,
): Promise<void> {
await this.send(this.forwardRelayFrame(frame, nextHopId));
}
async request(
message: Uint8Array,
data?: never,
options?: MTPRequestOptions,
): Promise<ParsedFrame>;
async request(
type: MTPCommunicationType,
data: Record<string, unknown>,
options?: MTPRequestOptions,
): Promise<ParsedFrame>;
async request(
typeOrFrame: Uint8Array | MTPCommunicationType,
data?: Record<string, unknown>,
options: MTPRequestOptions = {},
): Promise<ParsedFrame> {
const timeoutMs =
options.timeoutMs ?? this.#options.requestTimeoutMs ?? 30_000;
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
throw new TypeError("request timeoutMs must be a positive safe integer");
}
const frame = this.#buildFrame(typeOrFrame, data, options);
try {
const parsed = this.raw.bindings.parse_frame(frame);
emit(
this.#options.logger,
isErrorType(parsed.type)
? {
hint: "error",
type: parsed.type,
error: errorMessage(parsed),
data: parsed.data,
direction: "send",
}
: {
hint: "info",
type: parsed.type,
data: parsed.data,
direction: "send",
},
);
} catch (error) {
emit(this.#options.logger, {
hint: "error",
type: "Error",
error: String(error),
direction: "send",
});
}
// The WASM client owns request expiry and its late-response tombstones.
// Keeping a second Promise timer here can reject the SDK call while the
// protocol request is still allowed to complete successfully.
return await this.raw.client.request(
frame,
options.responseType ?? null,
timeoutMs,
);
}
subscribe(
type: MTPCommunicationType,
handler: (message: ParsedFrame) => void,
): Unsubscribe {
if (typeof type !== "string" || !type) {
throw new TypeError("subscription type must be a non-empty string");
}
if (typeof handler !== "function") {
throw new TypeError("subscription handler must be a function");
}
const id = this.raw.client.subscribe(type, handler);
return () => this.raw.client.unsubscribe(id);
}
#handleFrame(frame) {
if (isErrorType(frame.type)) {
emit(this.#options.logger, {
hint: "error",
type: frame.type,
error: errorMessage(frame),
data: frame.data,
direction: "recv",
});
} else {
emit(this.#options.logger, {
hint: "info",
type: frame.type,
data: frame.data,
direction: "recv",
});
}
}
#getKemPublicKey(): Uint8Array {
if (!this.#credentials?.keyring?.length) {
throw new Error("No keyring available");
}
const keys = keyringToKeys(this.#credentials.keyring);
return keys.kemPublicKey;
}
#getKemSecretKey(): Uint8Array {
if (!this.#credentials?.keyring?.length) {
throw new Error("No keyring available");
}
const keys = keyringToKeys(this.#credentials.keyring);
return keys.kemSecretKey;
}
#getPublicKeyBundleBytes(): Uint8Array {
if (!this.#credentials?.keyring?.length) {
throw new Error("No keyring available");
}
const keyring = bindings.WasmKeyring.from_bytes(
this.#credentials.keyring,
);
try {
const bundle = keyring.public_key_bundle();
try {
return bundle.to_bytes();
} finally {
bundle.free();
}
} finally {
keyring.free();
}
}
#resolveDecryptionIdentity(
explicit?: MTPDecryptionIdentity,
): ResolvedDecryptionIdentity {
return resolveDecryptionIdentity(explicit, this.#credentials);
}
#assertExpectedSignerId(
signerId: bigint,
expectedSignerId: bigint | number | string | undefined,
): void {
if (
expectedSignerId != null &&
inputU64(expectedSignerId, "expectedSignerId") !== signerId
) {
throw new Error("protected signer ID mismatch");
}
}
async #resolveSignerPublicKeys(
signerId: bigint,
options: SignerResolutionOptions,
): Promise<Uint8Array[] | null> {
if (options.resolveSignerPublicKeys) {
let resolved: MTPResolvedSignerKeys;
try {
resolved = await options.resolveSignerPublicKeys(signerId);
} catch {
throw signerKeysUnavailable(signerId);
}
if (!Array.isArray(resolved) || resolved.length === 0) {
throw signerKeysUnavailable(signerId);
}
return resolved.map((value, index) => {
try {
const bytes = normalizeBytes(value, `senderPublicKeys[${index}]`);
publicKeyBundleToKeys(bytes);
return bytes;
} catch {
throw signerKeysUnavailable(signerId);
}
});
}
if (this.#credentials?.clientId === signerId) {
try {
const bytes = this.#getPublicKeyBundleBytes();
publicKeyBundleToKeys(bytes);
return [bytes];
} catch {
throw signerKeysUnavailable(signerId);
}
}
return null;
}
/**
* Decrypt and verify only relay metadata. The content remains opaque so a
* metadata-only relay participant can index or forward it without possessing
* a content-recipient key.
*/
async openRelayMetadata(
frame: ParsedFrame,
options: MTPOpenRelayMetadataOptions = {},
): Promise<MTPVerifiedRelayMetadata> {
// The resolver is asynchronous. Keep an immutable byte snapshot so the
// signer claim and the later native verification refer to one frame.
const frameBytes = frame.raw.slice();
const frameSnapshot = bindings.parse_frame(frameBytes);
const signaturePolicy = resolveSignatureVerificationPolicy(
options.signaturePolicy,
this.#options.defaultSignatureVerificationPolicy,
);
const recipient = this.#resolveDecryptionIdentity(options.recipient);
let signerId: bigint;
try {
signerId = BigInt(
bindings.relay_metadata_claimed_signer_id(
frameBytes,
recipient.keyrings,
),
);
} catch (error) {
throw relayOpeningError(error);
}
this.#assertExpectedSignerId(signerId, options.expectedSignerId);
const signerBundles = await this.#resolveSignerPublicKeys(
signerId,
options,
);
if (!signerBundles) {
throw signerKeysUnavailable(signerId);
}
let native: RawBindings.WasmVerifiedRelayMetadata | undefined;
let ownershipTransferred = false;
try {
native = bindings.open_relay_metadata_with_keyrings(
frameBytes,
recipient.keyrings,
signerId,
signerBundles,
signatureVerificationPolicyValue(signaturePolicy),
);
} catch (error) {
throw relayOpeningError(error, signerId);
}
try {
if (!native) throw new Error("relay metadata opening returned no handle");
const metadataBytes = native.metadata();
const hasApplicationMetadata = metadataBytes != null;
const applicationMetadata = hasApplicationMetadata
? (cloneParsedValue(
bindings.parse_data_value(metadataBytes),
) as MTPDataValue)
: undefined;
const nativeSignerId = BigInt(native.signer_id());
const messageId = native.message_id();
const createdAt = BigInt(native.created_at());
if (options.replayGuard) {
const accepted = await options.replayGuard.accept(
nativeSignerId,
messageId,
createdAt,
);
if (!accepted) throw new MTPReplayError(nativeSignerId, messageId);
}
const matchedSignerKeyIndex = Number(native.matched_signer_key_index());
if (
!Number.isSafeInteger(matchedSignerKeyIndex) ||
matchedSignerKeyIndex < 0 ||
matchedSignerKeyIndex >= signerBundles.length
) {
throw new Error("relay verification returned an invalid key index");
}
const verified = new MTPVerifiedRelayMetadata(RELAY_METADATA_TOKEN, {
frame: frameSnapshot,
native,
relayVersion: Number(native.relay_version()),
signerId: nativeSignerId,
finalRecipientId: BigInt(native.final_recipient_id()),
messageId,
createdAt,
hasMetadata: hasApplicationMetadata,
metadata: applicationMetadata,
encryptedContent: native.encrypted_content().slice(),
signerPublicKeys: signerBundles.map((bundle) => bundle.slice()),
matchedSignerKeyIndex,
signaturePolicy,
disposed: false,
finalizerToken: {},
});
// Register only after the JS wrapper owns the native handle. The
// finally block below handles every failure before this transfer.
const state = relayMetadataState.get(verified);
if (!state) throw new Error("relay metadata state was not initialized");
relayMetadataFinalizer.register(verified, native, state.finalizerToken);
ownershipTransferred = true;
return verified;
} finally {
if (!ownershipTransferred) {
try {
native?.free();
} catch {
// Preserve the original opening or conversion failure.
}
}
}
}
/** Open and verify content after relay metadata has been authenticated. */
async openRelayContent(
metadata: MTPVerifiedRelayMetadata,
options: MTPOpenRelayContentOptions = {},
): Promise<MTPVerifiedRelayContent> {
const recipient = this.#resolveDecryptionIdentity(options.recipient);
const state = relayMetadataState.get(metadata);
if (!state) {
throw new Error(
"relay metadata was not produced by authenticated opening",
);
}
if (state.disposed) {
throw new Error("relay metadata has been disposed");
}
const expectedFinalRecipientId =
options.expectedFinalRecipientId == null
? recipient.id
: inputU64(
options.expectedFinalRecipientId,
"expectedFinalRecipientId",
);
// Content belongs to the authenticated metadata operation. Inherit its
// policy when no content override is supplied so a client default cannot
// split the two relay verification layers.
const signaturePolicy = resolveSignatureVerificationPolicy(
options.signaturePolicy,
state.signaturePolicy,
);
if (signaturePolicy !== state.signaturePolicy) {
throw new MTPSignatureVerificationError(
"policy-rejected",
state.signerId,
);
}
let signerBundles: Uint8Array[] = state.signerPublicKeys.map((bundle) =>
bundle.slice(),
);
this.#assertExpectedSignerId(state.signerId, options.expectedSignerId);
if (options.resolveSignerPublicKeys != null) {
const resolved = await this.#resolveSignerPublicKeys(
state.signerId,
options,
);
if (state.disposed) {
throw new Error("relay metadata has been disposed");
}
if (!resolved) {
throw signerKeysUnavailable(state.signerId);
}
signerBundles = resolved;
}
// A caller may dispose the capability while asynchronous signer-key
// resolution is in flight. Never hand a freed native handle back to
// wasm-bindgen after that await, even if the resolver returned no keys.
if (state.disposed) {
throw new Error("relay metadata has been disposed");
}
let nativeContent: RawBindings.WasmVerifiedRelayContent;
try {
nativeContent = bindings.open_relay_content_with_keyrings(
state.native,
recipient.keyrings,
signerBundles,
expectedFinalRecipientId == null
? null
: BigInt(expectedFinalRecipientId),
signatureVerificationPolicyValue(signaturePolicy),
);
} catch (error) {
throw relayOpeningError(error, state.signerId);
}
try {
return this.#formatRelayContent(nativeContent, state);
} finally {
nativeContent.free();
}
}
#formatRelayContent(
nativeContent: RawBindings.WasmVerifiedRelayContent,
state: MTPRelayMetadataState,
): MTPVerifiedRelayContent {
const data = bindings.parse_data_value(nativeContent.content()) as MTPDataValue;
return {
type: nativeContent.message_type(),
data: cloneParsedValue(data) as MTPDataValue,
signerId: BigInt(nativeContent.signer_id()),
finalRecipientId: BigInt(nativeContent.final_recipient_id()),
messageId: state.messageId,
createdAt: state.createdAt,
metadata:
state.hasMetadata
? (cloneParsedValue(state.metadata) as MTPDataValue)
: undefined,
};
}
async #openProtectedFrame(
frameInput: MTPProtectedFrameInput,
options: MTPOpenProtectedOptions,
): Promise<{
frame: ParsedFrame;
message: MTPVerifiedProtectedMessage;
}> {
if (!options || typeof options !== "object") {
throw new TypeError("openProtected requires an options object");
}
if (typeof options.resolveSignerPublicKeys !== "function") {
throw new TypeError("openProtected requires resolveSignerPublicKeys");
}
// Detach parsed-object inputs before awaiting signer-key resolution. This
// keeps the authenticated payload and routing fields bound to one
// snapshot even when a caller reuses or mutates its frame object.
const frame = cloneParsedFrame(parseProtectedFrame(frameInput));
assertKnownCommunicationType(frame);
const frameBytes = protectedFrameBytes(frame);
validateApplicationProtectionPurpose(options.signaturePurpose);
validateApplicationProtectionPurpose(options.encryptionPurpose);
const recipient = this.#resolveDecryptionIdentity(options.recipient);
const signaturePolicy = resolveSignatureVerificationPolicy(
options.signaturePolicy,
this.#options.defaultSignatureVerificationPolicy,
);
const expectedReceiverId =
options.expectedReceiverId == null
? recipient.id
: inputU64(options.expectedReceiverId, "expectedReceiverId");
let signerId: bigint;
try {
signerId = BigInt(
bindings.protected_claimed_signer_id(
frameBytes,
recipient.keyrings,
options.encryptionPurpose,
),
);
} catch (error) {
throw protectedOpeningError(error);
}
this.#assertExpectedSignerId(signerId, options.expectedSignerId);
const signerBundles = await this.#resolveSignerPublicKeys(signerId, options);
if (!signerBundles) {
throw signerKeysUnavailable(signerId);
}
let native: RawBindings.WasmVerifiedProtectedMessage;
try {
native = bindings.open_protected_with_keyrings(
frameBytes,
recipient.keyrings,
signerId,
signerBundles,
expectedReceiverId == null ? null : expectedReceiverId,
options.signaturePurpose,
options.encryptionPurpose,
signatureVerificationPolicyValue(signaturePolicy),
);
} catch (error) {
throw protectedOpeningError(error, signerId);
}
const replayGuard = options.replayGuard ?? this.#protectedReplayGuard;
const nativeSignerId = BigInt(native.signer_id());
const messageId = native.message_id();
const createdAt = BigInt(native.created_at());
try {
const accepted = await replayGuard.accept(
nativeSignerId,
messageId,
createdAt,
);
if (!accepted) throw new MTPReplayError(nativeSignerId, messageId);
const data = bindings.parse_data_value(native.content()) as MTPDataValue;
const receiver = frame.receiver;
const outerSender = frame.sender;
return {
frame,
message: {
type: native.message_type(),
protectedVersion: Number(native.protected_version()),
signerId: nativeSignerId,
finalRecipientId: BigInt(native.final_recipient_id()),
messageId,
createdAt,
...(receiver == null ? {} : { receiver }),
...(outerSender == null ? {} : { outerSender }),
data: cloneParsedValue(data) as MTPDataValue,
},
};
} finally {
native.free();
}
}
async openProtected<T = MTPDataValue>(
frame: MTPProtectedFrameInput,
options: MTPOpenProtectedOptions,
): Promise<MTPVerifiedProtectedMessage<T>> {
const opened = await this.#openProtectedFrame(frame, options);
return opened.message as MTPVerifiedProtectedMessage<T>;
}
subscribeProtected<T = MTPDataValue>(
type: MTPCommunicationType,
handler: (
message: MTPVerifiedProtectedMessage<T>,
frame: ParsedFrame,
) => void | Promise<void>,
options: MTPOpenProtectedOptions,
): Unsubscribe {
if (typeof type !== "string" || !type) {
throw new TypeError(
"protected subscription type must be a non-empty string",
);
}
const applicationType = assertApplicationCommunicationType(type);
if (typeof handler !== "function") {
throw new TypeError("protected handler must be a function");
}
const replayGuard = options.replayGuard ?? new InMemoryReplayGuard();
const subscriptionOptions = { ...options, replayGuard };
const sub = this.raw.client.subscribe(
applicationType,
async (frame: ParsedFrame) => {
try {
const opened = await this.#openProtectedFrame(
frame,
subscriptionOptions,
);
if (opened.message.type !== applicationType) return;
await handler(
opened.message as MTPVerifiedProtectedMessage<T>,
opened.frame,
);
} catch (error) {
emit(this.#options.logger, {
hint: "error",
type: "E2EE",
error: String(error),
direction: "recv",
});
}
},
);
return () => this.raw.client.unsubscribe(sub);
}
async sendProtected(
type: MTPCommunicationType,
data: MTPDataValueInput,
options: MTPSendProtectedOptions,
): Promise<void> {
const messageType = assertApplicationCommunicationType(type);
const identity = resolveProtectionIdentity(
options.identity,
this.#credentials,
);
const receiverId = inputU64(options.receiverId, "receiverId");
const recipients = normalizeRecipientBundles(options.recipients, "recipients");
const messageId = createMessageId();
const createdAt = unixTimeMillis();
validateApplicationProtectionPurpose(options.signaturePurpose);
validateApplicationProtectionPurpose(options.encryptionPurpose);
const signatureSuite = effectiveProtectionSignatureSuite(
identity.keyring,
options.signatureSuite,
);
const encodedContent = encodeMTPDataValue(data);
let frame: Uint8Array;
try {
frame = bindings.build_protected_frame_with_keyring(
messageType,
encodedContent,
identity.signerId,
receiverId,
messageId,
createdAt,
options.signaturePurpose,
options.encryptionPurpose,
identity.keyring,
protectionSignatureSuiteValue(signatureSuite),
options.id ?? null,
options.exposeSender ?? false,
recipients,
);
} catch (error) {
throw protectedOpeningError(error, identity.signerId);
}
await this.send(frame);
}
async sendSealedRelay(
type: MTPCommunicationType,
data: MTPDataValueInput,
options: MTPSendSealedRelayOptions,
): Promise<void> {
const messageType = assertApplicationCommunicationType(type);
const identity = resolveProtectionIdentity(
options.identity,
this.#credentials,
);
const finalRecipientId = inputU64(
options.finalRecipientId,
"finalRecipientId",
);
const nextHopId = inputU64(options.nextHopId, "nextHopId");
const metadataRecipients = normalizeRecipientBundles(
options.metadataRecipients,
"metadataRecipients",
);
const contentRecipients = normalizeRecipientBundles(
options.contentRecipients,
"contentRecipients",
);
const signatureSuite = effectiveProtectionSignatureSuite(
identity.keyring,
options.signatureSuite,
);
const frame = bindings.build_encrypted_relay_frame_with_keyring(
messageType,
data,
identity.signerId,
finalRecipientId,
nextHopId,
createMessageId(),
unixTimeMillis(),
options.metadata === undefined
? undefined
: encodeMTPDataValue(options.metadata),
identity.keyring,
protectionSignatureSuiteValue(signatureSuite),
metadataRecipients,
contentRecipients,
);
await this.send(frame);
}
subscribeSealedRelay(
type: MTPCommunicationType,
handler: (
content: MTPVerifiedRelayContent,
frame: ParsedFrame,
) => void | Promise<void>,
options?: MTPEncryptedSubscriptionOptions,
): Unsubscribe;
subscribeSealedRelay(
handler: (
content: MTPVerifiedRelayContent,
frame: ParsedFrame,
) => void | Promise<void>,
options?: MTPEncryptedSubscriptionOptions,
): Unsubscribe;
subscribeSealedRelay(
typeOrHandler:
| MTPCommunicationType
| ((
content: MTPVerifiedRelayContent,
frame: ParsedFrame,
) => void | Promise<void>),
maybeHandlerOrOptions?:
| ((
content: MTPVerifiedRelayContent,
frame: ParsedFrame,
) => void | Promise<void>)
| MTPEncryptedSubscriptionOptions,
maybeOptions?: MTPEncryptedSubscriptionOptions,
): Unsubscribe {
const expectedInnerType =
typeof typeOrHandler === "function" ? null : typeOrHandler;
if (expectedInnerType != null) {
assertApplicationCommunicationType(expectedInnerType);
}
const handler =
typeof typeOrHandler === "function"
? typeOrHandler
: typeof maybeHandlerOrOptions === "function"
? maybeHandlerOrOptions
: null;
if (!handler) {
throw new TypeError("sealed relay handler must be a function");
}
const options: MTPEncryptedSubscriptionOptions =
typeof typeOrHandler === "function"
? typeof maybeHandlerOrOptions === "function" ||
maybeHandlerOrOptions == null
? {}
: maybeHandlerOrOptions
: (maybeOptions ?? {});
const replayGuard = options.replayGuard ?? new InMemoryReplayGuard();
const subscriptionOptions = { ...options, replayGuard };
const sub = this.raw.client.subscribe(
"Relay",
async (frame: ParsedFrame) => {
let metadata: MTPVerifiedRelayMetadata | undefined;
try {
metadata = await this.openRelayMetadata(frame, subscriptionOptions);
const opened = await this.openRelayContent(
metadata,
subscriptionOptions,
);
if (
!opened ||
(expectedInnerType && opened.type !== expectedInnerType)
) {
return;
}
const parsedFrame: ParsedFrame = {
...frame,
type: opened.type,
data: opened.data as ParsedFrame["data"],
};
await handler(opened, parsedFrame);
} catch (e) {
emit(this.#options.logger, {
hint: "error",
type: "E2EE",
error: String(e),
direction: "recv",
});
} finally {
metadata?.dispose();
}
},
);
return () => this.raw.client.unsubscribe(sub);
}
/**
* Subscribe to verified relay metadata without attempting content
* decryption. The metadata capability is callback-scoped: it is disposed
* after the handler resolves, so do not retain it for later content opening.
* Call `openRelayMetadata()` directly when a longer-lived capability is
* required and dispose it when finished.
*/
subscribeRelayMetadata(
handler: (
metadata: MTPVerifiedRelayMetadata,
frame: ParsedFrame,
) => void | Promise<void>,
options: MTPOpenRelayMetadataOptions = {},
): Unsubscribe {
if (typeof handler !== "function") {
throw new TypeError("relay metadata handler must be a function");
}
const replayGuard = options.replayGuard ?? new InMemoryReplayGuard();
const subscriptionOptions = { ...options, replayGuard };
const sub = this.raw.client.subscribe(
"Relay",
async (frame: ParsedFrame) => {
let metadata: MTPVerifiedRelayMetadata | undefined;
try {
metadata = await this.openRelayMetadata(frame, subscriptionOptions);
await handler(metadata, frame);
} catch (e) {
emit(this.#options.logger, {
hint: "error",
type: "E2EE",
error: String(e),
direction: "recv",
});
} finally {
metadata?.dispose();
}
},
);
return () => this.raw.client.unsubscribe(sub);
}
/** Explicit alias for callers that want to emphasize encrypted metadata. */
subscribeEncryptedMetadata(
handler: (
metadata: MTPVerifiedRelayMetadata,
frame: ParsedFrame,
) => void | Promise<void>,
options: MTPOpenRelayMetadataOptions = {},
): Unsubscribe {
return this.subscribeRelayMetadata(handler, options);
}
async setEncryptedSecret(record: MTPEncryptedSecretRecord): Promise<void> {
await this.encryptedSecretProvider.set(record);
}
async getEncryptedSecret(id: string): Promise<MTPEncryptedSecretRecord | null> {
return this.encryptedSecretProvider.get(id);
}
async deleteEncryptedSecret(id: string): Promise<void> {
await this.encryptedSecretProvider.delete(id);
}
setOnPipeRequest(handler: ((request: MTPPipeRequest) => void) | null): void {
if (handler == null) {
this.raw.client.set_on_pipe_request(null);
return;
}
this.raw.client.set_on_pipe_request(
(event: { pipeId: number; description: string }) => {
emit(this.#options.logger, {
hint: "info",
type: "PipeRequest",
data: event,
direction: "recv",
});
handler({ pipeId: event.pipeId, description: event.description });
},
);
}
async createPipe(description: string): Promise<MTPOutgoingPipeHandle> {
if (typeof description !== "string") {
throw new TypeError("description must be a string");
}
const handle: WasmPipeHandle =
await this.raw.client.create_pipe(description);
const sdk = this;
return {
pipeId: handle.pipeId,
description: handle.description,
async wait(): Promise<MTPPipeWriter | null> {
const result = await handle.wait();
if (result == null) {
return null;
}
emit(sdk.#options.logger, {
hint: "info",
type: "PipeCreated",
data: { pipeId: result.pipeId },
direction: "send",
});
return result as unknown as MTPPipeWriter;
},
};
}
/**
* Create and negotiate an encrypted pipe with the actual MTP pipe ID and
* local identity bound automatically. A recipient array creates a group
* bootstrap; membership changes should create a fresh session with the new
* array.
*/
async createEncryptedPipe(
options: MTPCreateEncryptedPipeOptions,
): Promise<MTPEncryptedPipeWriter | null> {
const credentials = this.#credentials;
if (!credentials || credentials.clientId == null) {
throw new Error("Client not registered");
}
const recipientId = inputU64(options.recipientId, "recipientId");
if (
options.recipientPublicKey != null &&
options.recipientPublicKeys != null
) {
throw new Error(
"provide recipientPublicKey or recipientPublicKeys, not both",
);
}
const rawRecipients =
options.recipientPublicKeys ??
(options.recipientPublicKey != null ? [options.recipientPublicKey] : []);
if (rawRecipients.length === 0) {
throw new Error("at least one recipient public key is required");
}
const recipients = rawRecipients.map((value, index) =>
normalizeBytes(value, `recipientPublicKeys[${index}]`),
);
recipients.forEach((value) => publicKeyBundleToKeys(value));
const purpose = options.purpose ?? 0x40;
const direction = options.direction ?? 0;
validateApplicationProtectionPurpose(purpose);
if (!Number.isInteger(direction) || direction < 0 || direction > 0xff) {
throw new RangeError("direction must be a u8");
}
const handle = await this.createPipe(
options.description ?? "encrypted pipe",
);
const writer = await handle.wait();
if (!writer) return null;
if (writer.pipeId !== handle.pipeId) {
throw new Error("created pipe ID does not match the accepted writer");
}
const sessionId = new Uint8Array(32);
globalThis.crypto.getRandomValues(sessionId);
return initiateMTPPipeSession(
writer,
{
sessionId,
pipeId: writer.pipeId,
senderId: credentials.clientId,
recipientId,
purpose,
direction,
},
credentials.keyring,
recipients,
options.signatureSuite,
);
}
async acceptPipe(pipeId: number): Promise<MTPPipeReader> {
if (typeof pipeId !== "number" || !Number.isFinite(pipeId)) {
throw new TypeError("pipeId must be a finite number");
}
const reader = await this.raw.client.accept_pipe(pipeId);
emit(this.#options.logger, {
hint: "info",
type: "PipeAccepted",
data: { pipeId: reader.pipeId, description: reader.description },
direction: "send",
});
return reader as unknown as MTPPipeReader;
}
/** Accept a pipe and learn its authenticated session ID from the offer. */
async acceptEncryptedPipe(
request: MTPPipeRequest,
options: MTPAcceptEncryptedPipeOptions,
): Promise<MTPEncryptedPipeReader> {
const credentials = this.#credentials;
if (!credentials || credentials.clientId == null) {
throw new Error("Client not registered");
}
if (!Number.isSafeInteger(request.pipeId) || request.pipeId <= 0) {
throw new TypeError("request.pipeId must be a non-zero safe integer");
}
const senderId = inputU64(options.senderId, "senderId");
const purpose = options.purpose ?? 0x40;
const direction = options.direction ?? 0;
validateApplicationProtectionPurpose(purpose);
if (!Number.isInteger(direction) || direction < 0 || direction > 0xff) {
throw new RangeError("direction must be a u8");
}
if (
options.senderPublicKeys != null &&
options.senderPublicKeys.length === 0
) {
throw new Error("senderPublicKeys must contain at least one key");
}
const singleSenderPublicKey = options.senderPublicKey;
if (options.senderPublicKeys == null && singleSenderPublicKey == null) {
throw new Error("senderPublicKey or senderPublicKeys is required");
}
const senderPublicKey =
options.senderPublicKeys != null
? options.senderPublicKeys.map((value, index) =>
normalizeBytes(value, `senderPublicKeys[${index}]`),
)
: normalizeBytes(singleSenderPublicKey!, "senderPublicKey");
const reader = await this.acceptPipe(request.pipeId);
if (reader.pipeId !== request.pipeId) {
throw new Error("accepted pipe ID does not match the requested pipe");
}
const signaturePolicy = resolveSignatureVerificationPolicy(
options.signaturePolicy,
this.#options.defaultSignatureVerificationPolicy,
);
return acceptMTPPipeSessionAuto(
reader,
{
pipeId: reader.pipeId,
senderId,
recipientId: credentials.clientId,
purpose,
direction,
},
credentials.keyring,
senderPublicKey,
signaturePolicy,
);
}
async denyPipe(pipeId: number): Promise<void> {
if (typeof pipeId !== "number" || !Number.isFinite(pipeId)) {
throw new TypeError("pipeId must be a finite number");
}
await this.raw.client.deny_pipe(pipeId);
emit(this.#options.logger, {
hint: "info",
type: "PipeDenied",
data: { pipeId },
direction: "send",
});
}
disconnect(): void {
this.raw.client.stop_protocol_pings();
this.raw.client.disconnect();
}
}
export { ConnectionState, bindings as raw };
// E2EE exports
export type {
MTPSessionState,
MTPSessionStorage,
MTPSessionTranscriptContext,
SkippedMessageKey,
} from "./session";
export {
MTPSessionManager,
InMemorySessionStorage,
derivePeerSessionId,
deriveSessionKeys,
buildSessionTranscript,
} from "./session.js";
export { MTPRatchet } from "./ratchet.js";
export type { RatchetStep } from "./ratchet.js";
export {
MTPEncryptedPipeError,
MTPEncryptedPipeReader,
MTPEncryptedPipeWriter,
MTPPipeProtectionContext,
MAX_ENCRYPTED_PIPE_RECORD,
pipeSessionSignaturePurpose,
pipeSessionEncryptionPurpose,
validateApplicationProtectionPurpose,
MAX_PIPE_SESSION_OFFER,
initiateMTPPipeSession,
acceptMTPPipeSession,
acceptMTPPipeSessionAuto,
initiateMTPForwardSecurePipeSession,
acceptMTPForwardSecurePipeSession,
} from "./encrypted-pipe.js";
export type {
MTPReadablePipe,
MTPWritablePipe,
MTPPipeSessionParameters,
MTPPipeSessionExpectation,
MTPDuplexPipe,
MTPEncryptedPipeReaderSource,
MTPEncryptedPipeWriterSource,
} from "./encrypted-pipe.js";
export {
serializeEncryptedMessage,
parseEncryptedMessage,
encryptPayload,
decryptPayload,
MTP_E2EE_VERSION,
FLAG_INIT,
MAX_RATCHET_SKIP,
} from "./encrypted-message.js";
export type {
EncryptedMessageHeader,
SerializedEncryptedMessage,
} from "./encrypted-message";
export type {
MTPEncryptedSecretRecord,
MTPEncryptedSecretProvider,
} from "./encrypted-secret";
export { InMemoryEncryptedSecretProvider } from "./encrypted-secret.js";