[Fix] Harden MTP codec, transport, and SDK security

This commit is contained in:
Alex Emmet 2026-08-18 20:57:45 +02:00
commit a7e804c603
No known key found for this signature in database
73 changed files with 11892 additions and 5756 deletions

2635
src/sdk/client.ts Normal file

File diff suppressed because it is too large Load diff

959
src/sdk/codec.ts Normal file
View file

@ -0,0 +1,959 @@
import * as bindings from "mtp/raw";
import type { MTPCommunicationType } from "../type-map/index";
import { RESERVED_COMMUNICATION_TYPE_IDS } from "../type-map/reserved.js";
import { utf8Encode } from "./utils.js";
import { initWasmOnce } from "./wasm-init.js";
import type {
MTPBytesInput,
MTPCodec,
MTPCodecOptions,
MTPDataValue,
MTPDataValueInput,
MTPEncodeLimits,
MTPEncodedBytesInput,
MTPKeyringKeys,
MTPKeyMaterialInput,
MTPReceiveLimits,
MTPCrypto,
MTPPublicKeyBundleKeys,
MTPProtectedFrameInput,
MTPProtectionSignatureSuite,
ParsedFrame,
} from "./client.js";
const checkedKeyringGenerator = (
bindings as typeof bindings & {
keyring_generate_checked?: () => Uint8Array;
}
).keyring_generate_checked;
export const crypto: MTPCrypto = {
generateKeyring: () =>
checkedKeyringGenerator?.() ?? 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 function encode(
type: MTPCommunicationType,
data: Record<string, unknown>,
options?: MTPCodecOptions,
): Uint8Array {
const limits: MTPEncodeLimits = {
maxDepth: MAX_DATA_VALUE_DEPTH,
maxValues: MAX_DATA_VALUE_VALUES,
maxOutputSize: 16 * 1024 * 1024,
};
const maxOutputSize = limits.maxOutputSize ?? 16 * 1024 * 1024;
validateMTPDataValue(data as MTPDataValueInput, limits);
const bounded = (
bindings as typeof bindings & {
build_frame_with_limits?: (
type: string,
data: Record<string, unknown>,
options: MTPCodecOptions,
limits: MTPEncodeLimits,
) => Uint8Array;
}
).build_frame_with_limits;
if (!bounded) {
throw new Error("bounded WASM frame encoding is unavailable; rebuild mtp-wasm");
}
const frame = bounded(type, data, options ?? {}, limits);
if (frame.length > maxOutputSize) {
throw new RangeError("MTP frame encoded output limit exceeded");
}
return frame;
}
export function decode(frame: MTPBytesInput): ParsedFrame {
return bindings.parse_frame(bytesFrom(frame, "frame"));
}
export function decodeWithLimits(
frame: MTPBytesInput,
limits: MTPReceiveLimits,
): ParsedFrame {
const parse = (
bindings as typeof bindings & {
parse_frame_with_limits?: (
frame: Uint8Array,
limits: MTPReceiveLimits,
) => ParsedFrame;
}
).parse_frame_with_limits;
if (!parse) {
throw new Error(
"configured receive limits require a rebuilt bounded WASM package",
);
}
return parse(bytesFrom(frame, "frame"), limits);
}
export function decodeDataValueWithLimits(
value: MTPBytesInput,
limits: MTPReceiveLimits,
): MTPDataValue {
const parse = (
bindings as typeof bindings & {
parse_data_value_with_limits?: (
value: Uint8Array,
limits: MTPReceiveLimits,
) => MTPDataValue;
}
).parse_data_value_with_limits;
if (!parse) {
throw new Error(
"configured receive limits require a rebuilt bounded WASM package",
);
}
return parse(bytesFrom(value, "data value"), limits);
}
export function format(frame: MTPBytesInput): string {
return bindings.format_frame(bytesFrom(frame, "frame"));
}
export const codec: MTPCodec = { encode, decode, format };
export function isBytes(value: unknown): value is MTPBytesInput {
return value instanceof Uint8Array || Array.isArray(value);
}
export function bytesFrom(value: MTPBytesInput, name: string): Uint8Array {
if (value instanceof Uint8Array) return value.slice();
if (Array.isArray(value)) {
for (const byte of value) {
if (!Number.isInteger(byte) || byte < 0 || byte > 255) {
throw new RangeError(`${name} contains a non-byte value`);
}
}
return Uint8Array.from(value);
}
throw new TypeError(`${name} must be a Uint8Array or number[]`);
}
export function strictHexDecode(value: string, name = "value"): Uint8Array {
if (typeof value !== "string") throw new TypeError(`${name} must be a string`);
const text = value.replace(/^0x/i, "");
if (text.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(text)) {
throw new TypeError(`${name} must be an even-length hexadecimal string`);
}
const bytes = new Uint8Array(text.length / 2);
for (let i = 0; i < bytes.length; i += 1) {
bytes[i] = Number.parseInt(text.slice(i * 2, i * 2 + 2), 16);
}
return bytes;
}
export function strictBase64Decode(value: string, name = "value"): Uint8Array {
if (typeof value !== "string") throw new TypeError(`${name} must be a string`);
if (value.length === 0) return new Uint8Array(0);
if (
value.length % 4 !== 0 ||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(
value,
)
) {
throw new TypeError(`${name} is not valid padded base64`);
}
let bytes: Uint8Array;
try {
if (typeof atob === "function") {
const binary = atob(value);
bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
} else if (typeof Buffer !== "undefined") {
bytes = new Uint8Array(Buffer.from(value, "base64"));
} else {
throw new TypeError("base64 decoding is not available in this environment");
}
} catch (error) {
throw new TypeError(`${name} is not valid base64`, { cause: error });
}
if (bytesToBase64(bytes) !== value) {
throw new TypeError(`${name} is not canonical padded base64`);
}
return bytes;
}
export function bytesFromEncodedString(
value: string,
encoding: "hex" | "base64",
name: string,
): Uint8Array {
return encoding === "hex"
? strictHexDecode(value, name)
: strictBase64Decode(value, name);
}
/*
* Compatibility parser for the historical format-detecting API. New callers
* should select `bytesFromEncodedString` explicitly so a value cannot change
* meaning when it happens to contain only hexadecimal characters.
*/
/** @deprecated Use `bytesFromEncodedString(value, encoding, name)`. */
export 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)) {
return strictHexDecode(hex, name);
}
return strictBase64Decode(trimmed, name);
}
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 {
return strictBase64Decode(input, "base64");
}
function utf8Decode(bytes: Uint8Array): string {
if (typeof TextDecoder !== "undefined") {
try {
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch (error) {
throw new TypeError("invalid UTF-8", { cause: error });
}
}
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 >= 0xc2 && b <= 0xdf) {
if (i + 1 >= bytes.length || (bytes[i + 1] & 0xc0) !== 0x80) {
throw new TypeError("invalid UTF-8");
}
out += String.fromCharCode(((b & 0x1f) << 6) | (bytes[i + 1] & 0x3f));
i += 2;
} else if (b >= 0xe0 && b <= 0xef) {
if (
i + 2 >= bytes.length ||
(bytes[i + 1] & 0xc0) !== 0x80 ||
(bytes[i + 2] & 0xc0) !== 0x80 ||
(b === 0xe0 && bytes[i + 1] < 0xa0) ||
(b === 0xed && bytes[i + 1] >= 0xa0)
) {
throw new TypeError("invalid UTF-8");
}
out += String.fromCharCode(
((b & 0x0f) << 12) |
((bytes[i + 1] & 0x3f) << 6) |
(bytes[i + 2] & 0x3f),
);
i += 3;
} else if (b >= 0xf0 && b <= 0xf4) {
if (
i + 3 >= bytes.length ||
(bytes[i + 1] & 0xc0) !== 0x80 ||
(bytes[i + 2] & 0xc0) !== 0x80 ||
(bytes[i + 3] & 0xc0) !== 0x80 ||
(b === 0xf0 && bytes[i + 1] < 0x90) ||
(b === 0xf4 && bytes[i + 1] >= 0x90)
) {
throw new TypeError("invalid UTF-8");
}
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;
} else {
throw new TypeError("invalid UTF-8");
}
}
return out;
}
function requiredSecretKeyLength(): number {
const lengthBinding = (
bindings as typeof bindings & {
mtp_symmetric_key_length?: () => number;
}
).mtp_symmetric_key_length;
if (!lengthBinding) return 32;
try {
return lengthBinding();
} catch {
// The generated WASM wrapper is callable only after initialization. Keep
// the historical size as a pre-initialization validation fallback.
return 32;
}
}
export function secretKeyFromBytes(value: MTPBytesInput): Uint8Array {
const bytes = bytesFrom(value, "secret key");
const requiredLength = requiredSecretKeyLength();
if (bytes.length !== requiredLength) {
throw new RangeError(`secret key must be exactly ${requiredLength} bytes`);
}
return bytes;
}
export function secretKeyFromHex(value: string): Uint8Array {
return secretKeyFromBytes(strictHexDecode(value, "secret key"));
}
export function secretKeyFromBase64(value: string): Uint8Array {
return secretKeyFromBytes(strictBase64Decode(value, "secret key"));
}
/*
* Compatibility entry point. It now accepts only explicitly encoded key
* material; arbitrary strings are no longer silently treated as passphrases.
*/
/** @deprecated Use `secretKeyFromBytes`, `secretKeyFromHex`, or `secretKeyFromBase64`. */
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, "");
if (/^[0-9a-fA-F]+$/.test(hex)) return secretKeyFromHex(hex);
return secretKeyFromBase64(trimmed);
}
/**
* Reproduce the pre-v1 implicit-HKDF derivation for data migration only.
*
* @deprecated Do not use for new secrets. Replace this with explicit key
* material or `deriveKeyFromPassphrase` and persist a password-KDF salt.
*/
export function legacySecretKeyFromStringV1(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) {
return strictHexDecode(hex, "legacy secret key");
}
try {
const decoded = bytesFromString(trimmed, "legacy secret key");
if (decoded.length === requiredSecretKeyLength()) return decoded;
} catch {
// Preserve the historical fallback to HKDF for non-encoded strings.
}
const context = utf8Encode("mtp-symmetric-key");
return bindings.wasm_derive_encryption_key(
utf8Encode(trimmed),
context,
context,
);
}
export interface PasswordKdfParameters {
memoryKiB: number;
iterations: number;
lanes: number;
}
function validatePasswordKdfInput(
passphrase: string,
salt: MTPBytesInput,
parameters: PasswordKdfParameters,
): { passphrase: string; salt: Uint8Array; parameters: PasswordKdfParameters } {
if (typeof passphrase !== "string" || passphrase.length === 0) {
throw new TypeError("passphrase must not be empty");
}
const saltBytes = bytesFrom(salt, "passphrase salt");
if (saltBytes.length < 16) {
throw new RangeError("passphrase salt must be at least 16 bytes");
}
if (
!Number.isInteger(parameters.memoryKiB) ||
parameters.memoryKiB < 8 * 1024 ||
parameters.memoryKiB > 256 * 1024 ||
!Number.isInteger(parameters.iterations) ||
parameters.iterations < 1 ||
parameters.iterations > 10 ||
!Number.isInteger(parameters.lanes) ||
parameters.lanes < 1 ||
parameters.lanes > 8
) {
throw new RangeError("invalid Argon2id password-KDF parameters");
}
return { passphrase, salt: saltBytes, parameters };
}
function deriveKeyFromPassphraseSyncImpl(
passphrase: string,
salt: MTPBytesInput,
parameters: PasswordKdfParameters,
): Uint8Array {
const validated = validatePasswordKdfInput(passphrase, salt, parameters);
const kdf = (bindings as unknown as {
wasm_argon2id?: (
passphrase: Uint8Array,
salt: Uint8Array,
memoryKiB: number,
iterations: number,
lanes: number,
) => Uint8Array;
}).wasm_argon2id;
if (!kdf) {
throw new Error("Argon2id password derivation is unavailable in this WASM build");
}
return kdf(
utf8Encode(validated.passphrase),
validated.salt,
validated.parameters.memoryKiB,
validated.parameters.iterations,
validated.parameters.lanes,
);
}
/**
* Derive a passphrase key without yielding. Prefer the asynchronous API in
* browser applications; this form is retained for workers and synchronous
* command-line migrations.
*/
/** @deprecated Use `deriveKeyFromPassphrase` in browser-facing code. */
export function deriveKeyFromPassphraseSync(
passphrase: string,
salt: MTPBytesInput,
parameters: PasswordKdfParameters,
): Uint8Array {
return deriveKeyFromPassphraseSyncImpl(passphrase, salt, parameters);
}
/**
* Derive a passphrase key off the browser main thread when workers are
* available. The worker imports the same generated WASM binding, so the
* Argon2id computation does not block UI/event-loop work.
*/
export function deriveKeyFromPassphrase(
passphrase: string,
salt: MTPBytesInput,
parameters: PasswordKdfParameters,
): Promise<Uint8Array> {
const validated = validatePasswordKdfInput(passphrase, salt, parameters);
if (typeof Worker === "undefined") {
return initWasmOnce().then(
() =>
new Promise((resolve) => {
setTimeout(
() =>
resolve(
deriveKeyFromPassphraseSyncImpl(
validated.passphrase,
validated.salt,
validated.parameters,
),
),
0,
);
}),
);
}
const worker = new Worker(new URL("./passphrase-worker.js", import.meta.url), {
type: "module",
});
return new Promise<Uint8Array>((resolve, reject) => {
const cleanup = () => worker.terminate();
worker.onmessage = (event: MessageEvent<Uint8Array | { error: string }>) => {
cleanup();
if (event.data && "error" in event.data) {
reject(new Error(event.data.error));
} else {
resolve(new Uint8Array(event.data));
}
};
worker.onerror = (event) => {
cleanup();
reject(new Error(event.message || "Argon2id worker failed"));
};
const passphraseBytes = utf8Encode(validated.passphrase);
const saltBytes = validated.salt.slice();
worker.postMessage(
{
passphrase: passphraseBytes,
salt: saltBytes,
parameters: validated.parameters,
},
[passphraseBytes.buffer, saltBytes.buffer],
);
});
}
export function normalizeBytes(
value: string | MTPBytesInput | MTPEncodedBytesInput,
name: string,
encoding?: "hex" | "base64",
): Uint8Array {
if (typeof value === "string") {
if (!encoding) {
throw new TypeError(
`${name} string input requires an explicit 'hex' or 'base64' encoding`,
);
}
return bytesFromEncodedString(value, encoding, name);
}
if (
value !== null &&
typeof value === "object" &&
!(value instanceof Uint8Array) &&
!Array.isArray(value)
) {
const encoded = value as Partial<MTPEncodedBytesInput>;
if (
typeof encoded.value !== "string" ||
(encoded.encoding !== "hex" && encoded.encoding !== "base64")
) {
throw new TypeError(
`${name} must be bytes or { value: string, encoding: 'hex' | 'base64' }`,
);
}
return bytesFromEncodedString(encoded.value, encoded.encoding, name);
}
return bytesFrom(value, name);
}
export 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;
}
export function toBigInt(
value: bigint | string | number | null | undefined,
): bigint | null {
if (value == null || value === "") return null;
return inputU64(value, "clientId");
}
const KEM_PUBLIC_KEY_LEN = 1216;
const SIG_PQ_PUBLIC_KEY_LEN = 1952;
const SIG_CL_PUBLIC_KEY_LEN = 32;
export function keyringToKeys(keyring: MTPKeyMaterialInput): MTPKeyringKeys {
const bytes = normalizeBytes(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: MTPKeyMaterialInput,
): MTPPublicKeyBundleKeys {
const bytes = normalizeBytes(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;
}
export 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;
}
export function cloneParsedFrame(frame: ParsedFrame): ParsedFrame {
return cloneParsedValue(frame) as ParsedFrame;
}
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;
}
export 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`,
);
}
export function parseProtectedFrame(
frame: MTPProtectedFrameInput,
limits?: MTPReceiveLimits,
): ParsedFrame {
const parse = (bytes: Uint8Array): ParsedFrame =>
limits ? decodeWithLimits(bytes, limits) : bindings.parse_frame(bytes);
if (isBytes(frame)) return parse(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 parse(frame.raw);
return frame;
}
export 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 });
}
}
export function protectedFrameBytes(
frame: ParsedFrame,
limits?: MTPReceiveLimits,
): 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");
}
const options = {
id: frame.id,
...(frame.sender == null ? {} : { sender: frame.sender }),
...(frame.receiver == null ? {} : { receiver: frame.receiver }),
};
const bounded = (
bindings as typeof bindings & {
build_frame_with_payload_with_limits?: (
type: string,
payload: Uint8Array,
options: MTPCodecOptions,
limits: MTPReceiveLimits,
) => Uint8Array;
}
).build_frame_with_payload_with_limits;
if (!bounded) {
throw new Error("bounded WASM frame encoding is unavailable; rebuild mtp-wasm");
}
return bounded(frame.type, encoded, options, limits ?? {});
}
export 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;
}
export const MAX_DATA_VALUE_DEPTH = 64;
export const MAX_DATA_VALUE_VALUES = 65_536;
const DEFAULT_ENCODE_LIMITS: Required<MTPEncodeLimits> = {
maxDepth: MAX_DATA_VALUE_DEPTH,
maxValues: MAX_DATA_VALUE_VALUES,
maxOutputSize: 16 * 1024 * 1024,
};
function normalizedEncodeLimits(
limits: MTPEncodeLimits | undefined,
): Required<MTPEncodeLimits> {
const result = { ...DEFAULT_ENCODE_LIMITS, ...(limits ?? {}) };
for (const [key, value] of Object.entries(result)) {
if (!Number.isSafeInteger(value) || value < 0) {
throw new TypeError(`encode limits ${key} must be a non-negative safe integer`);
}
}
return result as Required<MTPEncodeLimits>;
}
/** Validate a JS DataValue before crossing into the recursive WASM parser. */
export function validateMTPDataValue(
value: MTPDataValueInput,
limits?: MTPEncodeLimits,
): void {
const effective = normalizedEncodeLimits(limits);
const ancestors = new WeakSet<object>();
let values = 0;
const validate = (candidate: unknown, depth: number): void => {
values += 1;
if (values > effective.maxValues) {
throw new RangeError("MTP DataValue value-count limit exceeded");
}
if (depth > effective.maxDepth) {
throw new RangeError("MTP DataValue nesting-depth limit exceeded");
}
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>);
try {
for (const entry of entries) validate(entry, depth + 1);
} finally {
ancestors.delete(object);
}
};
validate(value, 0);
}
export function encodeMTPDataValue(
value: MTPDataValueInput,
limits?: MTPEncodeLimits,
): Uint8Array {
const effective = normalizedEncodeLimits(limits);
validateMTPDataValue(value, effective);
const bounded = (
bindings as typeof bindings & {
encode_data_value_with_limits?: (
value: MTPDataValueInput,
limits: MTPEncodeLimits,
) => Uint8Array;
}
).encode_data_value_with_limits;
if (!bounded) {
throw new Error("bounded WASM DataValue encoding is unavailable; rebuild mtp-wasm");
}
const encoded = bounded(value, effective);
if (encoded.length > effective.maxOutputSize) {
throw new RangeError("MTP DataValue encoded output limit exceeded");
}
return encoded;
}
export function inputDataValueBigInt(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 malformed protected metadata below.
}
throw new Error(`protected metadata field ${name} is not an integer`);
}
export function inputDataValueString(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`);
}
export function signatureSuiteValue(
suite: MTPProtectionSignatureSuite,
): number {
return suite === "dual"
? bindings.mtp_protection_signature_suite_dual()
: bindings.mtp_protection_signature_suite_ed25519();
}
export function formatDataValue(value: MTPDataValue): MTPDataValue {
return cloneParsedValue(value) as MTPDataValue;
}

26
src/sdk/credentials.ts Normal file
View file

@ -0,0 +1,26 @@
import type { MTPClientCredentials } from "./index.js";
export type InternalCredentials = {
clientId: bigint | null;
keyring: Uint8Array;
hostPublicKey?: Uint8Array;
};
export function publicCredentials(
credentials: InternalCredentials | null,
): MTPClientCredentials | null {
if (!credentials) {
return null;
}
return {
clientId: credentials.clientId,
keyring: credentials.keyring.slice(),
hostPublicKey: credentials.hostPublicKey?.slice(),
};
}
export function zeroCredentials(credentials: InternalCredentials | null): void {
// The host public key is intentionally not wiped: it is public configuration
// and may also be retained by the connection options.
credentials?.keyring.fill(0);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,33 @@
import initWasm, * as bindings from "mtp/raw";
interface PasswordKdfWorkerRequest {
passphrase: Uint8Array;
salt: Uint8Array;
parameters: {
memoryKiB: number;
iterations: number;
lanes: number;
};
}
const scope = globalThis as unknown as {
onmessage: ((event: MessageEvent<PasswordKdfWorkerRequest>) => void) | null;
postMessage(message: Uint8Array | { error: string }, transfer?: Transferable[]): void;
};
scope.onmessage = async (event) => {
try {
await initWasm();
const { passphrase, salt, parameters } = event.data;
const key = bindings.wasm_argon2id(
passphrase,
salt,
parameters.memoryKiB,
parameters.iterations,
parameters.lanes,
);
scope.postMessage(key, [key.buffer]);
} catch (error) {
scope.postMessage({ error: String(error) });
}
};

258
src/sdk/protection.ts Normal file
View file

@ -0,0 +1,258 @@
import type { InternalCredentials } from "./credentials.js";
import {
inputU64,
keyringToKeys,
normalizeBytes,
publicKeyBundleToKeys,
signatureSuiteValue,
} from "./codec.js";
import {
MTPSignatureVerificationError,
signerKeysUnavailable,
} from "./signature-policy.js";
import type { MTPSignatureVerificationPolicy } from "./signature-policy.js";
import type {
MTPDecryptionIdentity,
MTPProtectionIdentity,
MTPProtectionSignatureSuite,
MTPReplayGuard,
MTPSignerKeyResolver,
MTPBytesInput,
MTPKeyMaterialInput,
} from "./client.js";
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 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;
}
}
export class MTPResourceLimitError extends Error {
constructor(message = "MTP receive resource limit exceeded") {
super(message);
this.name = "MTPResourceLimitError";
}
}
export interface ResolvedProtectionIdentity {
signerId: bigint;
keyring: Uint8Array;
}
export interface ResolvedDecryptionIdentity {
id?: bigint;
keyrings: Uint8Array[];
}
export interface SignerResolutionOptions {
expectedSignerId?: bigint | number | string;
resolveSignerPublicKeys?: MTPSignerKeyResolver;
}
export function protectionSignatureSuiteValue(
suite: MTPProtectionSignatureSuite,
): number {
return signatureSuiteValue(suite);
}
export 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;
}
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;
}
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: MTPKeyMaterialInput, 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;
}
export function normalizeRecipientBundles(
recipients: MTPKeyMaterialInput[],
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();
});
}
export 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",
);
}
export 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",
);
}
export 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");
case "resource-limit":
return new MTPResourceLimitError();
}
}
}
return error instanceof Error ? error : new Error(String(error));
}
export type { MTPSignatureVerificationPolicy };

204
src/sdk/relay.ts Normal file
View file

@ -0,0 +1,204 @@
import type * as RawBindings from "../raw/index";
import { cloneParsedFrame, cloneParsedValue, inputU64 } from "./codec.js";
import {
MTPSignatureVerificationError,
signerKeysUnavailable,
} from "./signature-policy.js";
import { MTPResourceLimitError } from "./protection.js";
import type { MTPSignatureVerificationPolicy } from "./signature-policy.js";
import type {
MTPDataValue,
MTPReceiveLimits,
MTPVerifiedRelayContent,
ParsedFrame,
} from "./client.js";
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 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");
case "resource-limit":
return new MTPResourceLimitError();
}
}
}
return error instanceof Error ? error : new Error(String(error));
}
export 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;
receiveLimits?: MTPReceiveLimits;
receiveLimitsExplicit: boolean;
disposed: boolean;
finalizerToken: object;
}
export 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.
}
});
export const RELAY_METADATA_TOKEN = Symbol("mtp-authenticated-relay-metadata");
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;
}
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.
}
}
free(): void {
this.dispose();
}
[Symbol.dispose](): void {
this.dispose();
}
get frame(): ParsedFrame {
return cloneParsedFrame(this.state.frame);
}
get signerId(): bigint {
return this.state.signerId;
}
get relayVersion(): number {
return this.state.relayVersion;
}
get finalRecipientId(): bigint {
return this.state.finalRecipientId;
}
get messageId(): string {
return this.state.messageId;
}
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();
}
get signerPublicKeys(): Uint8Array[] {
return this.state.signerPublicKeys.map((bundle) => bundle.slice());
}
get matchedSignerKeyIndex(): number {
return this.state.matchedSignerKeyIndex;
}
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 function registerRelayMetadata(
metadata: MTPVerifiedRelayMetadata,
native: RawBindings.WasmVerifiedRelayMetadata,
finalizerToken: object,
): void {
relayMetadataFinalizer.register(metadata, native, finalizerToken);
}
export type { MTPVerifiedRelayContent };

View file

@ -92,8 +92,14 @@ export function signatureVerificationPolicyValue(
return bindings.mtp_protection_signature_suite_ed25519();
case "dual":
return bindings.mtp_protection_signature_suite_dual();
case "any-supported":
return 0;
case "any-supported": {
const compatibility = (
bindings as typeof bindings & {
mtp_protection_signature_suite_any_supported?: () => number;
}
).mtp_protection_signature_suite_any_supported;
return compatibility?.() ?? 0;
}
}
}

27
src/sdk/timeout.ts Normal file
View file

@ -0,0 +1,27 @@
export async function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number | undefined,
message: string,
cancel?: () => void,
): Promise<T> {
if (!timeoutMs) {
return await promise;
}
let timeoutId: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_resolve, reject) => {
timeoutId = setTimeout(() => {
cancel?.();
reject(new Error(message));
}, timeoutMs);
}),
]);
} finally {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
}
}

27
src/sdk/wasm-init.ts Normal file
View file

@ -0,0 +1,27 @@
import initWasm from "mtp/raw";
type WasmInitInput = Parameters<typeof initWasm>[0];
type WasmExports = Awaited<ReturnType<typeof initWasm>>;
type WasmInitializer = (input?: WasmInitInput) => Promise<WasmExports>;
export function createWasmInitializer(
initialize: WasmInitializer = initWasm,
): WasmInitializer {
let wasmInitPromise: Promise<WasmExports> | undefined;
/**
* Keep the successful WASM singleton, but make a failed attempt retryable.
* A rejected promise is never retained in the module cache.
*/
return (input?: WasmInitInput): Promise<WasmExports> => {
if (!wasmInitPromise) {
wasmInitPromise = initialize(input).catch((error) => {
wasmInitPromise = undefined;
throw error;
});
}
return wasmInitPromise;
}
}
export const initWasmOnce = createWasmInitializer();