General Upgrade, NEW: WebServers, Better Docs
Some checks failed
CI / checks (push) Failing after 5m18s

This commit is contained in:
Alex Emmet 2026-07-18 03:08:03 +02:00
commit 6e5c985719
122 changed files with 10309 additions and 5206 deletions

View file

@ -1,6 +1,7 @@
import * as bindings from "mtp/raw";
import { MTPRatchet } from "./ratchet.js";
import type { MTPSessionState } from "./session";
import { concatBytes, writeU64BE } from "./utils.js";
export const MTP_E2EE_VERSION = 1;
export const FLAG_INIT = 0x01;
@ -37,18 +38,6 @@ export interface SerializedEncryptedMessage {
aeadPayload: Uint8Array;
}
function writeU64BE(value: bigint): Uint8Array {
if (value < 0n || value > 0xffff_ffff_ffff_ffffn) {
throw new Error("u64 value out of range");
}
const buf = new Uint8Array(8);
for (let i = 7; i >= 0; i--) {
buf[i] = Number(value & 0xffn);
value >>= 8n;
}
return buf;
}
function readU64BE(bytes: Uint8Array, offset: number): bigint {
let value = 0n;
for (let i = 0; i < 8; i++) {
@ -69,16 +58,6 @@ function writeU32BE(value: number): Uint8Array {
]);
}
function concatBytes(parts: Uint8Array[]): Uint8Array {
const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
let offset = 0;
for (const part of parts) {
out.set(part, offset);
offset += part.length;
}
return out;
}
function assertSupported(message: ParsedEncryptedMessage): void {
if (message.version !== MTP_E2EE_VERSION) {
throw new Error(
@ -111,10 +90,10 @@ export function serializeEncryptedMessage(
): Uint8Array {
const normalized: ParsedEncryptedMessage =
"header" in message
? {
? ({
...message.header,
ciphertext: message.aeadPayload,
}
} as ParsedEncryptedMessage)
: message;
assertSupported(normalized);
@ -293,29 +272,48 @@ export async function decryptPayload(args: {
if (parsed.senderClientId !== args.session.peerClientId) {
throw new Error("Encrypted message sender mismatch");
}
if (parsed.messageNumber < args.session.recvCount) {
throw new Error("Encrypted message replay or out-of-order message number");
const existingSkippedMessageKeys = args.session.skippedMessageKeys ?? [];
const cachedKeyIndex = existingSkippedMessageKeys.findIndex(
(skipped) => skipped.messageNumber === parsed.messageNumber,
);
if (parsed.messageNumber < args.session.recvCount && cachedKeyIndex < 0) {
throw new Error("Encrypted message replay message number");
}
let chainKey = args.session.recvChainKey;
let messageKey: Uint8Array | undefined;
const gap = parsed.messageNumber - args.session.recvCount;
if (gap > MAX_RATCHET_SKIP) {
throw new Error(
`Encrypted message receive gap exceeds max skip (${MAX_RATCHET_SKIP})`,
);
}
let skippedMessageKeys = existingSkippedMessageKeys.slice();
const newlyDerivedKeys: Uint8Array[] = [];
let nextRecvCount = args.session.recvCount;
const steps = gap + 1;
for (let i = 0; i < steps; i += 1) {
const step = await MTPRatchet.stepRecv(chainKey);
if (i === steps - 1) {
messageKey = step.key;
} else {
step.key.fill(0);
if (cachedKeyIndex >= 0) {
// Work on a copy so an invalid ciphertext cannot consume the cached key.
messageKey = skippedMessageKeys[cachedKeyIndex].key.slice();
} else {
const gap = parsed.messageNumber - args.session.recvCount;
if (gap > MAX_RATCHET_SKIP) {
throw new Error(
`Encrypted message receive gap exceeds max skip (${MAX_RATCHET_SKIP})`,
);
}
if (chainKey !== args.session.recvChainKey) chainKey.fill(0);
chainKey = step.chainKey;
const steps = gap + 1;
for (let i = 0; i < steps; i += 1) {
const step = await MTPRatchet.stepRecv(chainKey);
if (i === steps - 1) {
messageKey = step.key;
} else {
skippedMessageKeys.push({
messageNumber: args.session.recvCount + i,
key: step.key,
});
newlyDerivedKeys.push(step.key);
}
if (chainKey !== args.session.recvChainKey) chainKey.fill(0);
chainKey = step.chainKey;
}
nextRecvCount = parsed.messageNumber + 1;
}
if (!messageKey) {
throw new Error("Failed to derive receive message key");
@ -334,17 +332,31 @@ export async function decryptPayload(args: {
let plaintext: Uint8Array;
try {
plaintext = cipher.decrypt(parsed.ciphertext, aad);
} catch (error) {
for (const key of newlyDerivedKeys) key.fill(0);
if (chainKey !== args.session.recvChainKey) chainKey.fill(0);
throw error;
} finally {
cipher.free();
messageKey.fill(0);
}
if (cachedKeyIndex >= 0) {
const [consumed] = skippedMessageKeys.splice(cachedKeyIndex, 1);
consumed.key.fill(0);
}
while (skippedMessageKeys.length > MAX_RATCHET_SKIP) {
const evicted = skippedMessageKeys.shift();
evicted?.key.fill(0);
}
return {
plaintext,
session: {
...args.session,
recvChainKey: chainKey,
recvCount: parsed.messageNumber + 1,
recvCount: nextRecvCount,
skippedMessageKeys,
updatedAt: Date.now(),
},
};

View file

@ -6,6 +6,7 @@ import initWasm, {
keyring_generate,
} from "mtp/raw";
import * as bindings from "mtp/raw";
import { utf8Encode } from "./utils.js";
import type * as RawBindings from "../raw/index";
import type { MTPCommunicationType } from "../type-map/index";
import type { MTPSessionStorage, MTPSessionState } from "./session";
@ -264,6 +265,7 @@ export interface MTPClientOptions {
serverCertificateHashes?: string[];
maxMessageSize?: number;
authTimeoutMs?: number;
requestTimeoutMs?: number;
pings?: boolean | { intervalMs?: number };
wasm?:
| RawBindings.InitInput
@ -286,6 +288,7 @@ export interface MTPSendOptions {
export interface MTPRequestOptions extends MTPSendOptions {
responseType?: MTPCommunicationType;
timeoutMs?: number;
}
export interface MTPPipeWriter {
@ -328,13 +331,21 @@ type NormalizedMTPClientOptions = Omit<MTPClientOptions, "hostPublicKey"> & {
const DEFAULT_CREDENTIALS_KEY = "mtp:credentials";
let wasmInitPromise: Promise<Awaited<ReturnType<typeof initWasm>>> | undefined;
function emit(logger, event) {
function createMessageId(): string {
const bytes = new Uint8Array(16);
globalThis.crypto.getRandomValues(bytes);
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(
"",
);
}
function emit(logger: MTPClientOptions["logger"] | undefined, event: MTPLogEvent): void {
if (typeof logger === "function") {
logger(event);
}
}
function isErrorType(type) {
function isErrorType(type: string): boolean {
return (
type === "Error" ||
type.startsWith("Error") ||
@ -352,7 +363,7 @@ function isErrorType(type) {
);
}
function errorMessage(frame) {
function errorMessage(frame: Pick<ParsedFrame, "type" | "data"> | null | undefined): string {
const data = frame?.data ?? {};
return String(
data.ErrorMessage ??
@ -362,27 +373,27 @@ function errorMessage(frame) {
);
}
async function storageGet(storage, key) {
async function storageGet(storage: MTPCredentialStorage | undefined, key: string): Promise<StorageValue> {
return storage ? await storage.getItem(key) : null;
}
async function storageSet(storage, key, value) {
async function storageSet(storage: MTPCredentialStorage | undefined, key: string, value: string): Promise<void> {
if (storage) {
await storage.setItem(key, value);
}
}
async function storageRemove(storage, key) {
async function storageRemove(storage: MTPCredentialStorage | undefined, key: string): Promise<void> {
if (storage) {
await storage.removeItem(key);
}
}
function isBytes(value) {
function isBytes(value: unknown): value is MTPBytesInput {
return value instanceof Uint8Array || Array.isArray(value);
}
function bytesFrom(value, name) {
function bytesFrom(value: MTPBytesInput, name: string): Uint8Array {
if (value instanceof Uint8Array) {
return value;
}
@ -392,7 +403,7 @@ function bytesFrom(value, name) {
throw new TypeError(`${name} must be a Uint8Array or number[]`);
}
function bytesFromString(value, name) {
function bytesFromString(value: string, name: string): Uint8Array {
const trimmed = value.trim();
if (!trimmed) {
throw new TypeError(`${name} must not be empty`);
@ -428,7 +439,7 @@ function bytesFromString(value, name) {
const HEX_DIGITS = "0123456789abcdef";
function bytesToHex(bytes) {
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];
@ -436,7 +447,7 @@ function bytesToHex(bytes) {
return out;
}
export function bytesToBase64(bytes) {
export function bytesToBase64(bytes: Uint8Array): string {
if (typeof btoa === "function") {
let binary = "";
for (let i = 0; i < bytes.length; i += 1) {
@ -450,7 +461,7 @@ export function bytesToBase64(bytes) {
throw new TypeError("base64 encoding is not available in this environment");
}
export function base64ToBytes(input) {
export function base64ToBytes(input: string): Uint8Array {
if (typeof atob === "function") {
const binary = atob(input);
const bytes = new Uint8Array(binary.length);
@ -465,38 +476,7 @@ export function base64ToBytes(input) {
throw new TypeError("base64 decoding is not available in this environment");
}
function utf8Encode(text) {
if (typeof TextEncoder !== "undefined") {
return new TextEncoder().encode(text);
}
if (typeof Buffer !== "undefined") {
return new Uint8Array(Buffer.from(text, "utf-8"));
}
const bytes = new Uint8Array(text.length * 4);
let len = 0;
for (let i = 0; i < text.length; i += 1) {
const code = text.codePointAt(i);
if (code < 0x80) {
bytes[len++] = code;
} else if (code < 0x800) {
bytes[len++] = 0xc0 | (code >> 6);
bytes[len++] = 0x80 | (code & 0x3f);
} else if (code < 0x10000) {
bytes[len++] = 0xe0 | (code >> 12);
bytes[len++] = 0x80 | ((code >> 6) & 0x3f);
bytes[len++] = 0x80 | (code & 0x3f);
} else {
bytes[len++] = 0xf0 | (code >> 18);
bytes[len++] = 0x80 | ((code >> 12) & 0x3f);
bytes[len++] = 0x80 | ((code >> 6) & 0x3f);
bytes[len++] = 0x80 | (code & 0x3f);
i += 1;
}
}
return bytes.subarray(0, len);
}
function utf8Decode(bytes) {
function utf8Decode(bytes: Uint8Array): string {
if (typeof TextDecoder !== "undefined") {
return new TextDecoder().decode(bytes);
}
@ -537,7 +517,7 @@ function utf8Decode(bytes) {
const SYMMETRIC_KEY_SALT = utf8Encode("mtp-symmetric-key");
export function secretKeyFromString(secret) {
export function secretKeyFromString(secret: string): Uint8Array {
if (typeof secret !== "string" || !secret.trim()) {
throw new TypeError("secret must be a non-empty string");
}
@ -571,14 +551,14 @@ export function secretKeyFromString(secret) {
);
}
function normalizeBytes(value, name) {
function normalizeBytes(value: string | MTPBytesInput, name: string): Uint8Array {
if (typeof value === "string") {
return bytesFromString(value, name);
}
return bytesFrom(value, name);
}
function normalizeCredentials(value) {
function normalizeCredentials(value: MTPCredentials | string | null): MTPCredentials | null {
if (!value) {
return null;
}
@ -590,7 +570,7 @@ function normalizeCredentials(value) {
return value;
}
function toBigInt(value) {
function toBigInt(value: bigint | string | number | null | undefined): bigint | null {
if (value == null || value === "") {
return null;
}
@ -601,7 +581,7 @@ function generateKeyringBytes() {
return keyring_generate();
}
export function keyringToKeys(keyring) {
export function keyringToKeys(keyring: string | MTPBytesInput): MTPKeyringKeys {
const bytes =
typeof keyring === "string"
? bytesFromString(keyring, "keyring")
@ -630,7 +610,7 @@ export function keyringToKeys(keyring) {
};
}
export function publicKeyBundleToKeys(publicKeyBundle) {
export function publicKeyBundleToKeys(publicKeyBundle: string | MTPBytesInput): MTPPublicKeyBundleKeys {
const bytes =
typeof publicKeyBundle === "string"
? bytesFromString(publicKeyBundle, "publicKeyBundle")
@ -741,6 +721,13 @@ function validateOptions(options) {
) {
throw new TypeError("authTimeoutMs must be a positive safe integer");
}
if (
options.requestTimeoutMs != null &&
(!Number.isSafeInteger(options.requestTimeoutMs) ||
options.requestTimeoutMs <= 0)
) {
throw new TypeError("requestTimeoutMs must be a positive safe integer");
}
}
async function withTimeout(promise, timeoutMs, message) {
@ -1093,6 +1080,10 @@ export class MTPClient {
data?: Record<string, unknown>,
options: MTPRequestOptions = {},
): Promise<ParsedFrame> {
const timeoutMs = options.timeoutMs ?? this.#options.requestTimeoutMs ?? 30_000;
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
throw new TypeError("request timeoutMs must be a positive safe integer");
}
const frame = this.#buildFrame(typeOrFrame, data, options);
try {
const parsed = this.raw.bindings.parse_frame(frame);
@ -1121,7 +1112,11 @@ export class MTPClient {
direction: "send",
});
}
return await this.raw.client.request(frame, options.responseType ?? null);
return await withTimeout(
this.raw.client.request(frame, options.responseType ?? null, timeoutMs),
timeoutMs,
`request timed out after ${timeoutMs}ms`,
);
}
subscribe(
@ -1245,7 +1240,7 @@ export class MTPClient {
await this.sessionManager.saveSession(newSession);
const messageId = String(Date.now());
const messageId = createMessageId();
const createdAt = Date.now();
const senderUserId = (options as { senderUserId?: string }).senderUserId;
const frame = this.raw.bindings.build_frame(
@ -1346,10 +1341,7 @@ export class MTPClient {
const sub = this.raw.client.subscribe(
"EncryptedMessage",
async (frame: ParsedFrame) => {
const raw =
frame.data?.["encryptedPayload"] ??
frame.data?.["EncryptedPayload"] ??
frame.data?.["encrypted_payload"];
const raw = frame.data?.["EncryptedPayload"];
if (!raw) return;
let payloadBytes: Uint8Array;
@ -1457,10 +1449,7 @@ export class MTPClient {
async decryptEncryptedRecord(
frameData: Record<string, unknown>,
): Promise<ParsedFrame> {
const raw =
frameData["encryptedPayload"] ??
frameData["EncryptedPayload"] ??
frameData["encrypted_payload"];
const raw = frameData["EncryptedPayload"];
if (!raw) throw new Error("EncryptedPayload is required");
const payloadBytes =

View file

@ -1,35 +1,5 @@
import * as bindings from "mtp/raw";
function utf8Encode(text: string): Uint8Array {
if (typeof TextEncoder !== "undefined") {
return new TextEncoder().encode(text);
}
if (typeof Buffer !== "undefined") {
return new Uint8Array(Buffer.from(text, "utf-8"));
}
const bytes = new Uint8Array(text.length * 4);
let len = 0;
for (let i = 0; i < text.length; i += 1) {
const code = text.codePointAt(i) as number;
if (code < 0x80) {
bytes[len++] = code;
} else if (code < 0x800) {
bytes[len++] = 0xc0 | (code >> 6);
bytes[len++] = 0x80 | (code & 0x3f);
} else if (code < 0x10000) {
bytes[len++] = 0xe0 | (code >> 12);
bytes[len++] = 0x80 | ((code >> 6) & 0x3f);
bytes[len++] = 0x80 | (code & 0x3f);
} else {
bytes[len++] = 0xf0 | (code >> 18);
bytes[len++] = 0x80 | ((code >> 12) & 0x3f);
bytes[len++] = 0x80 | ((code >> 6) & 0x3f);
bytes[len++] = 0x80 | (code & 0x3f);
i += 1;
}
}
return bytes.subarray(0, len);
}
import { utf8Encode } from "./utils.js";
const HKDF_MESSAGE_KEY = "mtp-e2ee-v1-message-key";
const HKDF_NEXT_CHAIN = "mtp-e2ee-v1-next-chain";

View file

@ -1,35 +1,5 @@
import * as bindings from "mtp/raw";
function utf8Encode(text: string): Uint8Array {
if (typeof TextEncoder !== "undefined") {
return new TextEncoder().encode(text);
}
if (typeof Buffer !== "undefined") {
return new Uint8Array(Buffer.from(text, "utf-8"));
}
const bytes = new Uint8Array(text.length * 4);
let len = 0;
for (let i = 0; i < text.length; i += 1) {
const code = text.codePointAt(i) as number;
if (code < 0x80) {
bytes[len++] = code;
} else if (code < 0x800) {
bytes[len++] = 0xc0 | (code >> 6);
bytes[len++] = 0x80 | (code & 0x3f);
} else if (code < 0x10000) {
bytes[len++] = 0xe0 | (code >> 12);
bytes[len++] = 0x80 | ((code >> 6) & 0x3f);
bytes[len++] = 0x80 | (code & 0x3f);
} else {
bytes[len++] = 0xf0 | (code >> 18);
bytes[len++] = 0x80 | ((code >> 12) & 0x3f);
bytes[len++] = 0x80 | ((code >> 6) & 0x3f);
bytes[len++] = 0x80 | (code & 0x3f);
i += 1;
}
}
return bytes.subarray(0, len);
}
import { concatBytes, utf8Encode, writeU64BE } from "./utils.js";
export const HKDF_SALT_ROOT = "mtp-e2ee-v1-root";
const HKDF_INITIATOR_SEND = "mtp-e2ee-v1-initiator-send";
@ -55,10 +25,17 @@ export interface MTPSessionState {
recvChainKey: Uint8Array;
sendCount: number;
recvCount: number;
/** Derived receive keys retained for bounded out-of-order delivery. */
skippedMessageKeys?: SkippedMessageKey[];
createdAt: number;
updatedAt: number;
}
export interface SkippedMessageKey {
messageNumber: number;
key: Uint8Array;
}
export interface MTPSessionStorage {
getSession(conversationId: string): Promise<MTPSessionState | null>;
setSession(state: MTPSessionState): Promise<void>;
@ -68,15 +45,40 @@ export interface MTPSessionStorage {
export class InMemorySessionStorage implements MTPSessionStorage {
private store = new Map<string, MTPSessionState>();
private cloneSession(state: MTPSessionState): MTPSessionState {
return {
...state,
peerPublicKey: state.peerPublicKey.slice(),
sendChainKey: state.sendChainKey.slice(),
recvChainKey: state.recvChainKey.slice(),
skippedMessageKeys: (state.skippedMessageKeys ?? []).map((skipped) => ({
messageNumber: skipped.messageNumber,
key: skipped.key.slice(),
})),
};
}
private zeroizeSession(state: MTPSessionState): void {
state.sendChainKey.fill(0);
state.recvChainKey.fill(0);
for (const skipped of state.skippedMessageKeys ?? []) skipped.key.fill(0);
}
async getSession(conversationId: string): Promise<MTPSessionState | null> {
return this.store.get(conversationId) ?? null;
const state = this.store.get(conversationId);
return state ? this.cloneSession(state) : null;
}
async setSession(state: MTPSessionState): Promise<void> {
this.store.set(state.conversationId, { ...state });
const replacement = this.cloneSession(state);
const previous = this.store.get(state.conversationId);
if (previous) this.zeroizeSession(previous);
this.store.set(state.conversationId, replacement);
}
async deleteSession(conversationId: string): Promise<void> {
const previous = this.store.get(conversationId);
if (previous) this.zeroizeSession(previous);
this.store.delete(conversationId);
}
}
@ -90,27 +92,6 @@ function writeU32BE(value: number): Uint8Array {
]);
}
function writeU64BE(value: bigint): Uint8Array {
if (value < 0n || value > 0xffff_ffff_ffff_ffffn)
throw new Error("u64 out of range");
const buf = new Uint8Array(8);
for (let i = 7; i >= 0; i -= 1) {
buf[i] = Number(value & 0xffn);
value >>= 8n;
}
return buf;
}
function concatBytes(parts: Uint8Array[]): Uint8Array {
const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
let offset = 0;
for (const part of parts) {
out.set(part, offset);
offset += part.length;
}
return out;
}
function transcriptField(label: string, value: Uint8Array): Uint8Array {
const labelBytes = utf8Encode(label);
return concatBytes([
@ -240,6 +221,7 @@ export class MTPSessionManager {
recvChainKey: args.role === "initiator" ? initiatorRecv : initiatorSend,
sendCount: 0,
recvCount: 0,
skippedMessageKeys: [],
createdAt: now,
updatedAt: now,
};

28
src/sdk/utils.ts Normal file
View file

@ -0,0 +1,28 @@
export function utf8Encode(text: string): Uint8Array {
if (typeof TextEncoder !== "undefined") return new TextEncoder().encode(text);
if (typeof Buffer !== "undefined") return new Uint8Array(Buffer.from(text, "utf-8"));
const bytes = new Uint8Array(text.length * 4);
let len = 0;
for (let i = 0; i < text.length; i += 1) {
const code = text.codePointAt(i) as number;
if (code < 0x80) bytes[len++] = code;
else if (code < 0x800) { bytes[len++] = 0xc0 | (code >> 6); bytes[len++] = 0x80 | (code & 0x3f); }
else if (code < 0x10000) { bytes[len++] = 0xe0 | (code >> 12); bytes[len++] = 0x80 | ((code >> 6) & 0x3f); bytes[len++] = 0x80 | (code & 0x3f); }
else { bytes[len++] = 0xf0 | (code >> 18); bytes[len++] = 0x80 | ((code >> 12) & 0x3f); bytes[len++] = 0x80 | ((code >> 6) & 0x3f); bytes[len++] = 0x80 | (code & 0x3f); i += 1; }
}
return bytes.subarray(0, len);
}
export function writeU64BE(value: bigint): Uint8Array {
if (value < 0n || value > 0xffff_ffff_ffff_ffffn) throw new Error("u64 value out of range");
const out = new Uint8Array(8);
for (let i = 7; i >= 0; i -= 1) { out[i] = Number(value & 0xffn); value >>= 8n; }
return out;
}
export function concatBytes(parts: Uint8Array[]): Uint8Array {
const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
let offset = 0;
for (const part of parts) { out.set(part, offset); offset += part.length; }
return out;
}