[Fix] Harden MTP codec, transport, and SDK security
This commit is contained in:
parent
188caf56cc
commit
a7e804c603
73 changed files with 11892 additions and 5756 deletions
2635
src/sdk/client.ts
Normal file
2635
src/sdk/client.ts
Normal file
File diff suppressed because it is too large
Load diff
959
src/sdk/codec.ts
Normal file
959
src/sdk/codec.ts
Normal 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
26
src/sdk/credentials.ts
Normal 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);
|
||||
}
|
||||
3150
src/sdk/index.ts
3150
src/sdk/index.ts
File diff suppressed because it is too large
Load diff
33
src/sdk/passphrase-worker.ts
Normal file
33
src/sdk/passphrase-worker.ts
Normal 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
258
src/sdk/protection.ts
Normal 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
204
src/sdk/relay.ts
Normal 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 };
|
||||
|
|
@ -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
27
src/sdk/timeout.ts
Normal 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
27
src/sdk/wasm-init.ts
Normal 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();
|
||||
Loading…
Reference in a new issue