import * as bindings from "mtp/raw"; import type { MTPBytesInput, MTPPipeReader, MTPPipeWriter, MTPProtectionSignatureSuite, } from "./index.js"; import { MTPSignatureVerificationError, resolveSignatureVerificationPolicy, signatureVerificationFailure, verifyDataValueWithPolicy, } from "./signature-policy.js"; import type { MTPSignatureVerificationPolicy } from "./signature-policy.js"; import { concatBytes, utf8Encode, writeU64BE } from "./utils.js"; const PIPE_E2EE_DOMAIN = utf8Encode("MTP-PIPE-E2EE-1"); const PIPE_RECORD_KDF_DOMAIN = utf8Encode("MTP-PIPE-E2EE-1/KEY"); const PIPE_TRANSCRIPT_DOMAIN = utf8Encode("MTP-PIPE-TRANSCRIPT-1"); const PIPE_RECORD_MESSAGE_LABEL = utf8Encode("/message"); const PIPE_RECORD_NEXT_LABEL = utf8Encode("/next"); const XCHACHA_OVERHEAD = 24 + 16; const MAX_SESSION_ID = 1024; export const MAX_ENCRYPTED_PIPE_RECORD = 16 * 1024 * 1024; export const MAX_PIPE_SESSION_OFFER = 64 * 1024; const PIPE_SESSION_OFFER_DOMAIN = "MTP-PIPE-SESSION-1"; const FS_INIT_DOMAIN = "MTP-PIPE-FS-INIT-1"; const FS_RESPONSE_DOMAIN = "MTP-PIPE-FS-RESPONSE-1"; const FS_FINISH_DOMAIN = "MTP-PIPE-FS-FINISH-1"; const FS_ROOT_INFO = utf8Encode("MTP-PIPE-FS-ROOT-1"); const RECORD_TYPE_DATA = 0; const RECORD_TYPE_FINAL = 1; const MAX_PIPE_BUFFER = MAX_ENCRYPTED_PIPE_RECORD + 5; // Session setup may leave one encrypted record in the same transport chunk // after an offer. Bound that carry-over buffer before concatenating attacker- // controlled chunks, just as the record reader bounds its input buffer. const MAX_SESSION_BUFFER = MAX_PIPE_BUFFER + MAX_PIPE_SESSION_OFFER + 4; const KEM_PUBLIC_KEY_LEN = 1216; const SIG_PQ_PUBLIC_KEY_LEN = 1952; const SIG_CL_PUBLIC_KEY_LEN = 32; export function pipeSessionSignaturePurpose(): number { return bindings.mtp_pipe_session_signature_purpose(); } export function pipeSessionEncryptionPurpose(): number { return bindings.mtp_pipe_session_encryption_purpose(); } /** * Validate a purpose supplied for application pipe records. MTP-owned * purpose bytes come from the WASM protocol registry so browser callers do * not have to duplicate the numeric allocation. */ export function validateApplicationProtectionPurpose(purpose: number): number { if (!Number.isInteger(purpose) || purpose < 0 || purpose > 0xff) { throw new MTPEncryptedPipeError("context", "purpose must be a u8"); } const reserved = new Set([ bindings.mtp_relay_metadata_encryption_purpose(), bindings.mtp_relay_content_signature_purpose(), bindings.mtp_relay_content_encryption_purpose(), bindings.mtp_relay_metadata_signature_purpose(), bindings.mtp_pipe_session_signature_purpose(), bindings.mtp_pipe_session_encryption_purpose(), ]); if (reserved.has(purpose)) { throw new MTPEncryptedPipeError( "context", "purpose is reserved for an MTP protocol operation", ); } return purpose; } export class MTPEncryptedPipeError extends Error { readonly code: | "context" | "record-length" | "sequence" | "truncated" | "authentication" | "io" | "setup" | "state"; constructor( code: MTPEncryptedPipeError["code"], message: string, options?: ErrorOptions, ) { super(message, options); this.name = "MTPEncryptedPipeError"; this.code = code; } } export class MTPPipeProtectionContext { readonly sessionId: Uint8Array; readonly purpose: number; readonly direction: number; readonly transcriptHash: Uint8Array; constructor( sessionId: Uint8Array, purpose: number, direction: number, transcriptHash?: Uint8Array, ) { if ( !(sessionId instanceof Uint8Array) || sessionId.length === 0 || sessionId.length > MAX_SESSION_ID ) { throw new MTPEncryptedPipeError( "context", "sessionId must contain between 1 and 1024 bytes", ); } validateApplicationProtectionPurpose(purpose); if (!Number.isInteger(direction) || direction < 0 || direction > 0xff) { throw new MTPEncryptedPipeError("context", "direction must be a u8"); } this.sessionId = sessionId.slice(); this.purpose = purpose; this.direction = direction; if (transcriptHash != null) { if ( !(transcriptHash instanceof Uint8Array) || transcriptHash.length !== 32 ) { throw new MTPEncryptedPipeError( "context", "transcriptHash must be 32 bytes", ); } this.transcriptHash = transcriptHash.slice(); } else { this.transcriptHash = baseTranscriptHash( this.sessionId, purpose, direction, ); } } } function u32(value: number): Uint8Array { const result = new Uint8Array(4); new DataView(result.buffer).setUint32(0, value, false); return result; } function sessionTranscriptHash(params: MTPPipeSessionParameters): Uint8Array { return bindings.wasm_sha256( concatBytes([ PIPE_TRANSCRIPT_DOMAIN, u32(params.sessionId.length), params.sessionId, u32(params.pipeId), writeU64BE(params.senderId), writeU64BE(params.recipientId), new Uint8Array([params.purpose, params.direction]), ]), ); } function baseTranscriptHash( sessionId: Uint8Array, purpose: number, direction: number, ): Uint8Array { return bindings.wasm_sha256( concatBytes([ PIPE_TRANSCRIPT_DOMAIN, u32(sessionId.length), sessionId, new Uint8Array([purpose, direction]), ]), ); } function recordLength(plaintextLength: number): number { const length = plaintextLength + XCHACHA_OVERHEAD; if ( !Number.isSafeInteger(length) || length < XCHACHA_OVERHEAD || length > MAX_ENCRYPTED_PIPE_RECORD || length > 0xffff_ffff ) { throw new MTPEncryptedPipeError( "record-length", `invalid encrypted pipe record length: ${length}`, ); } return length; } function aad( context: MTPPipeProtectionContext, sequence: bigint, encodedLength: number, recordType: number, ): Uint8Array { return concatBytes([ PIPE_E2EE_DOMAIN, new Uint8Array([context.purpose, context.direction]), context.transcriptHash, writeU64BE(sequence), u32(encodedLength), new Uint8Array([recordType]), ]); } function keyBytes(key: Uint8Array): Uint8Array { if (!(key instanceof Uint8Array) || key.length !== 32) { throw new MTPEncryptedPipeError( "context", "pipe session key must be 32 bytes", ); } return key.slice(); } function recordKeyInfo( context: MTPPipeProtectionContext, sequence: bigint, label: Uint8Array, ): Uint8Array { return concatBytes([ PIPE_RECORD_KDF_DOMAIN, new Uint8Array([context.purpose, context.direction]), context.transcriptHash, writeU64BE(sequence), label, ]); } function deriveRecordKeys( chainKey: Uint8Array, context: MTPPipeProtectionContext, sequence: bigint, ): { messageKey: Uint8Array; nextChainKey: Uint8Array } { try { return { messageKey: bindings.wasm_hkdf_expand( chainKey, context.transcriptHash, recordKeyInfo(context, sequence, PIPE_RECORD_MESSAGE_LABEL), 32, ), nextChainKey: bindings.wasm_hkdf_expand( chainKey, context.transcriptHash, recordKeyInfo(context, sequence, PIPE_RECORD_NEXT_LABEL), 32, ), }; } catch (error) { throw new MTPEncryptedPipeError( "authentication", "encrypted pipe record key derivation failed", { cause: error }, ); } } export interface MTPWritablePipe { write(data: Uint8Array): Promise; close(): Promise; abort(): void; } export interface MTPReadablePipe { read(): Promise; } /** Advanced duplex transport required by the forward-secure handshake. */ export interface MTPDuplexPipe extends MTPWritablePipe, MTPReadablePipe { readonly pipeId?: number; } export interface MTPPipeSessionParameters { sessionId: Uint8Array; pipeId: number; senderId: bigint; recipientId: bigint; purpose: number; direction: number; } /** Session fields the receiver can know before decrypting the offer. */ export type MTPPipeSessionExpectation = Omit< MTPPipeSessionParameters, "sessionId" >; function contextForSessionParameters( params: MTPPipeSessionParameters, ): MTPPipeProtectionContext { return new MTPPipeProtectionContext( params.sessionId, params.purpose, params.direction, sessionTranscriptHash(params), ); } function sessionError(message: string, cause?: unknown): MTPEncryptedPipeError { return new MTPEncryptedPipeError("setup", message, { cause }); } function bytesInput(value: MTPBytesInput, name: string): Uint8Array { if (value instanceof Uint8Array) return value.slice(); if (Array.isArray(value)) return new Uint8Array(value); throw sessionError(`${name} must be a Uint8Array or number[]`); } function publicKeyBundleInputs(value: Uint8Array): void { let offset = 0; const lengths: number[] = []; for (let index = 0; index < 3; index += 1) { if (offset + 2 > value.length) throw sessionError("public key bundle is truncated"); const length = new DataView( value.buffer, value.byteOffset, value.byteLength, ).getUint16(offset, false); offset += 2; if (offset + length > value.length) { throw sessionError("public key bundle is truncated"); } lengths.push(length); offset += length; } if ( offset !== value.length || lengths[0] !== KEM_PUBLIC_KEY_LEN || lengths[1] !== SIG_PQ_PUBLIC_KEY_LEN || lengths[2] !== SIG_CL_PUBLIC_KEY_LEN ) { throw sessionError("public key bundle contains invalid suite key lengths"); } } function recipientBundleInputs( value: MTPBytesInput | MTPBytesInput[], ): Uint8Array[] { // A number[] is one serialized bundle; an array whose first element is a // byte array is the multi-recipient form. if (value instanceof Uint8Array) return [value.slice()]; if ( Array.isArray(value) && (value.length === 0 || typeof value[0] === "number") ) { return [bytesInput(value as MTPBytesInput, "recipientPublicKey")]; } if (!Array.isArray(value)) { throw sessionError( "recipientPublicKey must be bytes or an array of bundles", ); } const result = value.map((entry, index) => bytesInput(entry, `recipientPublicKeys[${index}]`), ); if (result.length === 0) { throw sessionError("at least one recipient public key is required"); } return result; } function validateSessionParameters( params: MTPPipeSessionParameters, ): MTPPipeSessionParameters { if ( !(params.sessionId instanceof Uint8Array) || params.sessionId.length === 0 || params.sessionId.length > MAX_SESSION_ID ) { throw sessionError("sessionId must contain between 1 and 1024 bytes"); } if ( !Number.isInteger(params.pipeId) || params.pipeId <= 0 || params.pipeId > 0xffff_ffff ) { throw sessionError("pipeId must be a non-zero u32"); } if (params.senderId < 0n || params.senderId > 0xffff_ffff_ffff_ffffn) { throw sessionError("senderId must be a u64"); } if (params.recipientId < 0n || params.recipientId > 0xffff_ffff_ffff_ffffn) { throw sessionError("recipientId must be a u64"); } validateApplicationProtectionPurpose(params.purpose); if ( !Number.isInteger(params.direction) || params.direction < 0 || params.direction > 0xff ) { throw sessionError("direction must be a u8"); } return { ...params, sessionId: params.sessionId.slice() }; } function serializedKeyring(keyring: MTPBytesInput): { bytes: Uint8Array; hasPqSigningKey: boolean; } { const bytes = bytesInput(keyring, "keyring"); let offset = 0; const fields: Uint8Array[] = []; for (let index = 0; index < 6; index += 1) { if (offset + 2 > bytes.length) throw sessionError("keyring is truncated"); const length = new DataView( bytes.buffer, bytes.byteOffset, bytes.byteLength, ).getUint16(offset, false); offset += 2; if (offset + length > bytes.length) throw sessionError("keyring is truncated"); fields.push(bytes.slice(offset, offset + length)); offset += length; } if (offset !== bytes.length || fields[5].length !== 32) { throw sessionError("keyring does not contain a valid Ed25519 secret key"); } const hasPqPublicKey = fields[2].length > 0; const hasPqSecretKey = fields[3].length > 0; return { bytes, hasPqSigningKey: hasPqPublicKey && hasPqSecretKey, }; } function selectedSignatureSuite( keyring: ReturnType, requested: MTPProtectionSignatureSuite | undefined, ): MTPProtectionSignatureSuite { const suite = requested ?? "ed25519"; if (suite !== "dual" && suite !== "ed25519") { throw sessionError("signatureSuite must be 'dual' or 'ed25519'"); } if (suite === "dual" && !keyring.hasPqSigningKey) { throw sessionError( "dual protected signatures require a complete ML-DSA key pair; choose 'ed25519' for a partial keyring", ); } return suite; } function asBigInt(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 (error) { throw sessionError(`${name} is not an integer`, error); } throw sessionError(`${name} is not an integer`); } function asBytes(value: unknown, name: string): Uint8Array { if (value instanceof Uint8Array) return value; throw sessionError(`${name} is not binary`); } function appendChunk(buffer: Uint8Array, chunk: Uint8Array): Uint8Array { if ( buffer.length > MAX_SESSION_BUFFER || chunk.length > MAX_SESSION_BUFFER - buffer.length ) { throw sessionError("encrypted pipe session input buffer is too large"); } const combined = new Uint8Array(buffer.length + chunk.length); combined.set(buffer); combined.set(chunk, buffer.length); return combined; } async function readSessionOffer(reader: MTPReadablePipe): Promise<{ offer: Uint8Array; remainder: Uint8Array; }> { let buffer: Uint8Array = new Uint8Array(0); const ensure = async (length: number): Promise => { while (buffer.length < length) { const chunk = await reader.read(); if (chunk == null) throw sessionError("pipe ended before session setup completed"); if (!(chunk instanceof Uint8Array)) throw sessionError("pipe reader returned non-byte data"); if (chunk.length > 0) buffer = appendChunk(buffer, chunk); } }; await ensure(4); const offerLength = new DataView( buffer.buffer, buffer.byteOffset, buffer.byteLength, ).getUint32(0, false); if (offerLength === 0 || offerLength > MAX_PIPE_SESSION_OFFER) { throw sessionError(`invalid pipe session offer length: ${offerLength}`); } await ensure(4 + offerLength); return { offer: buffer.slice(4, 4 + offerLength), remainder: buffer.slice(4 + offerLength), }; } class MTPSessionOfferReader { private buffered: Uint8Array = new Uint8Array(0); constructor(private readonly reader: MTPReadablePipe) {} async read(): Promise { const result = await readSessionOfferWithBuffer(this.reader, this.buffered); this.buffered = result.remainder; return result.offer; } remainder(): Uint8Array { return this.buffered.slice(); } } async function readSessionOfferWithBuffer( reader: MTPReadablePipe, initialBuffer: Uint8Array, ): Promise<{ offer: Uint8Array; remainder: Uint8Array }> { let buffer: Uint8Array = initialBuffer.slice(); const ensure = async (length: number): Promise => { while (buffer.length < length) { const chunk = await reader.read(); if (chunk == null) throw sessionError("pipe ended before session setup completed"); if (!(chunk instanceof Uint8Array)) { throw sessionError("pipe reader returned non-byte data"); } if (chunk.length > 0) buffer = appendChunk(buffer, chunk); } }; await ensure(4); const offerLength = new DataView( buffer.buffer, buffer.byteOffset, buffer.byteLength, ).getUint32(0, false); if (offerLength === 0 || offerLength > MAX_PIPE_SESSION_OFFER) { throw sessionError(`invalid pipe session offer length: ${offerLength}`); } await ensure(4 + offerLength); return { offer: buffer.slice(4, 4 + offerLength), remainder: buffer.slice(4 + offerLength), }; } async function writeSessionOffer( writer: MTPWritablePipe, offer: Uint8Array, ): Promise { if (offer.length === 0 || offer.length > MAX_PIPE_SESSION_OFFER) { throw sessionError(`invalid pipe session offer length: ${offer.length}`); } const prefix = new Uint8Array(4); new DataView(prefix.buffer).setUint32(0, offer.length, false); await writer.write(concatBytes([prefix, offer])); } function handshakeHash(parts: readonly Uint8Array[]): Uint8Array { return bindings.wasm_sha256( concatBytes(parts.flatMap((part) => [u32(part.length), part])), ); } function fsCommonFields(params: MTPPipeSessionParameters): unknown[] { return [ params.sessionId, BigInt(params.pipeId), params.senderId, params.recipientId, BigInt(params.purpose), BigInt(params.direction), ]; } function fsInitValue( params: MTPPipeSessionParameters, nonce: Uint8Array, ): unknown[] { return [FS_INIT_DOMAIN, ...fsCommonFields(params), nonce]; } function fsResponseValue( params: MTPPipeSessionParameters, initHash: Uint8Array, ephemeralPublicKey: Uint8Array, ): unknown[] { return [ FS_RESPONSE_DOMAIN, ...fsCommonFields(params), initHash, ephemeralPublicKey, ]; } function fsFinishValue( params: MTPPipeSessionParameters, responseHash: Uint8Array, ciphertext: Uint8Array, ): unknown[] { return [ FS_FINISH_DOMAIN, ...fsCommonFields(params), responseHash, ciphertext, ]; } function signHandshakeValue( value: unknown, signerId: bigint, keyring: ReturnType, suite: MTPProtectionSignatureSuite, ): Uint8Array { return bindings.sign_data_value_with_keyring( bindings.encode_data_value(value), signerId, pipeSessionSignaturePurpose(), keyring.bytes, suite === "dual" ? bindings.mtp_protection_signature_suite_dual() : bindings.mtp_protection_signature_suite_ed25519(), ); } function verifiedHandshakeValue( encoded: Uint8Array, expectedSignerId: bigint, senderPublicKey: Uint8Array, policy: MTPSignatureVerificationPolicy, ): unknown[] { return verifiedHandshakeValueWithKeys( encoded, expectedSignerId, [senderPublicKey], policy, ); } function verifiedHandshakeValueWithKeys( encoded: Uint8Array, expectedSignerId: bigint, senderPublicKeys: Uint8Array[], policy: MTPSignatureVerificationPolicy, ): unknown[] { const errors: unknown[] = []; let verified = false; for (const senderPublicKey of senderPublicKeys) { try { verifyDataValueWithPolicy( encoded, senderPublicKey, expectedSignerId, pipeSessionSignaturePurpose(), policy, ); verified = true; break; } catch (error) { errors.push(error); } } if (!verified) throw signatureVerificationFailure(errors, expectedSignerId); const parsed = bindings.parse_data_value(encoded); if ( parsed === null || typeof parsed !== "object" || Array.isArray(parsed) || (parsed as Record).kind !== "signed" ) { throw sessionError("forward-secure handshake value is not signed"); } const fields = (parsed as { value?: unknown }).value; if (!Array.isArray(fields)) { throw sessionError("forward-secure handshake value is not an array"); } return fields; } function validateFsCommon( fields: unknown[], expected: MTPPipeSessionParameters, domain: string, length: number, ): void { if (fields.length !== length || fields[0] !== domain) { throw sessionError("forward-secure handshake domain or length mismatch"); } const sessionId = asBytes(fields[1], "sessionId"); if ( sessionId.length !== expected.sessionId.length || sessionId.some((byte, index) => byte !== expected.sessionId[index]) ) { throw sessionError("forward-secure handshake session mismatch"); } if (asBigInt(fields[2], "pipeId") !== BigInt(expected.pipeId)) { throw sessionError("forward-secure handshake pipe mismatch"); } if (asBigInt(fields[3], "senderId") !== expected.senderId) { throw sessionError("forward-secure handshake sender mismatch"); } if (asBigInt(fields[4], "recipientId") !== expected.recipientId) { throw sessionError("forward-secure handshake recipient mismatch"); } if (asBigInt(fields[5], "purpose") !== BigInt(expected.purpose)) { throw sessionError("forward-secure handshake purpose mismatch"); } if (asBigInt(fields[6], "direction") !== BigInt(expected.direction)) { throw sessionError("forward-secure handshake direction mismatch"); } } function forwardSecureContext( params: MTPPipeSessionParameters, handshakeTranscript: Uint8Array, ): MTPPipeProtectionContext { return new MTPPipeProtectionContext( params.sessionId, params.purpose, params.direction, bindings.wasm_sha256( concatBytes([ PIPE_TRANSCRIPT_DOMAIN, sessionTranscriptHash(params), handshakeTranscript, ]), ), ); } function validateOfferFields( signedValue: unknown, expected: MTPPipeSessionParameters, ): Uint8Array { if ( signedValue === null || typeof signedValue !== "object" || Array.isArray(signedValue) || (signedValue as Record).kind !== "signed" ) { throw sessionError("pipe session offer is not signed"); } const fields = (signedValue as { value?: unknown }).value; if (!Array.isArray(fields) || fields.length !== 8) { throw sessionError("pipe session offer has invalid fields"); } if (fields[0] !== PIPE_SESSION_OFFER_DOMAIN) { throw sessionError("pipe session offer domain mismatch"); } if (asBytes(fields[1], "sessionId").length !== expected.sessionId.length) { throw sessionError("pipe session offer session mismatch"); } const sessionId = asBytes(fields[1], "sessionId"); if (sessionId.some((byte, index) => byte !== expected.sessionId[index])) { throw sessionError("pipe session offer session mismatch"); } if (asBigInt(fields[2], "pipeId") !== BigInt(expected.pipeId)) { throw sessionError("pipe session offer pipe mismatch"); } if (asBigInt(fields[3], "senderId") !== expected.senderId) { throw sessionError("pipe session offer sender mismatch"); } if (asBigInt(fields[4], "recipientId") !== expected.recipientId) { throw sessionError("pipe session offer recipient mismatch"); } if (asBigInt(fields[5], "purpose") !== BigInt(expected.purpose)) { throw sessionError("pipe session offer purpose mismatch"); } if (asBigInt(fields[6], "direction") !== BigInt(expected.direction)) { throw sessionError("pipe session offer direction mismatch"); } const key = asBytes(fields[7], "session key"); if (key.length !== 32) throw sessionError("pipe session offer key is not 32 bytes"); return key.slice(); } function senderBundleInputs( value: MTPBytesInput | MTPBytesInput[], ): Uint8Array[] { const bundles = recipientBundleInputs(value); bundles.forEach((bundle) => publicKeyBundleInputs(bundle)); return bundles; } function verifyPipeSessionValue( signed: Uint8Array, senderBundles: Uint8Array[], expectedSignerId: bigint, policy: MTPSignatureVerificationPolicy, ): void { const errors: unknown[] = []; for (const senderBundle of senderBundles) { try { verifyDataValueWithPolicy( signed, senderBundle, expectedSignerId, pipeSessionSignaturePurpose(), policy, ); return; } catch (error) { errors.push(error); } } throw signatureVerificationFailure(errors, expectedSignerId); } function sessionIdFromOffer(parsed: unknown): Uint8Array { if ( parsed === null || typeof parsed !== "object" || Array.isArray(parsed) || (parsed as Record).kind !== "signed" ) { throw sessionError("pipe session offer is not signed"); } const fields = (parsed as { value?: unknown }).value; if (!Array.isArray(fields) || fields.length !== 8) { throw sessionError("pipe session offer has invalid fields"); } const sessionId = asBytes(fields[1], "sessionId"); if (sessionId.length === 0 || sessionId.length > MAX_SESSION_ID) { throw sessionError("pipe session offer session ID is invalid"); } return sessionId.slice(); } /** Send a signed/KEM-protected pipe session offer and return its record writer. */ export async function initiateMTPPipeSession( writer: MTPPipeWriter & MTPWritablePipe, params: MTPPipeSessionParameters, senderKeyring: MTPBytesInput, recipientPublicKey: MTPBytesInput | MTPBytesInput[], signatureSuite?: MTPProtectionSignatureSuite, ): Promise { const checked = validateSessionParameters(params); if (writer.pipeId != null && writer.pipeId !== checked.pipeId) { throw sessionError("pipeId does not match the actual writer pipe"); } const recipientBundles = recipientBundleInputs(recipientPublicKey); const keyring = serializedKeyring(senderKeyring); const suite = selectedSignatureSuite(keyring, signatureSuite); const key = new Uint8Array(32); globalThis.crypto.getRandomValues(key); const payload = bindings.encode_data_value([ PIPE_SESSION_OFFER_DOMAIN, checked.sessionId, BigInt(checked.pipeId), checked.senderId, checked.recipientId, checked.purpose, checked.direction, key, ]); const signed = bindings.sign_data_value_with_keyring( payload, checked.senderId, pipeSessionSignaturePurpose(), keyring.bytes, suite === "dual" ? bindings.mtp_protection_signature_suite_dual() : bindings.mtp_protection_signature_suite_ed25519(), ); const encrypted = bindings.encrypt_data_value_for_recipients( signed, recipientBundles, pipeSessionEncryptionPurpose(), ); if (encrypted.length > MAX_PIPE_SESSION_OFFER) { throw sessionError(`pipe session offer is too large: ${encrypted.length}`); } const prefix = new Uint8Array(4); new DataView(prefix.buffer).setUint32(0, encrypted.length, false); try { await writer.write(concatBytes([prefix, encrypted])); return new MTPEncryptedPipeWriter( writer, key, contextForSessionParameters(checked), ); } catch (error) { key.fill(0); throw sessionError("failed to write pipe session offer", error); } finally { key.fill(0); } } /** Read and verify a pipe session offer, then return its record reader. */ export async function acceptMTPPipeSession( reader: MTPPipeReader & MTPReadablePipe, params: MTPPipeSessionParameters, recipientKeyring: MTPBytesInput, senderPublicKey: MTPBytesInput | MTPBytesInput[], signaturePolicy?: MTPSignatureVerificationPolicy, ): Promise { const checked = validateSessionParameters(params); if (reader.pipeId != null && reader.pipeId !== checked.pipeId) { throw sessionError("pipeId does not match the actual reader pipe"); } const { offer, remainder } = await readSessionOffer(reader); const signed = bindings.decrypt_data_value( offer, bytesInput(recipientKeyring, "recipientKeyring"), pipeSessionEncryptionPurpose(), ); const senderBundles = senderBundleInputs(senderPublicKey); const policy = resolveSignatureVerificationPolicy(signaturePolicy); verifyPipeSessionValue(signed, senderBundles, checked.senderId, policy); const parsed = bindings.parse_data_value(signed); const key = validateOfferFields(parsed, checked); const result = new MTPEncryptedPipeReader( reader, key, contextForSessionParameters(checked), remainder, ); key.fill(0); return result; } /** * Accept a pipe session without making the caller copy the sender's random * session ID out of band. The ID is learned only after recipient decryption * and signature verification, then all record context uses that ID. */ export async function acceptMTPPipeSessionAuto( reader: MTPPipeReader & MTPReadablePipe, expected: MTPPipeSessionExpectation, recipientKeyring: MTPBytesInput, senderPublicKey: MTPBytesInput | MTPBytesInput[], signaturePolicy?: MTPSignatureVerificationPolicy, ): Promise { if (reader.pipeId != null && reader.pipeId !== expected.pipeId) { throw sessionError("pipeId does not match the actual reader pipe"); } const { offer, remainder } = await readSessionOffer(reader); const signed = bindings.decrypt_data_value( offer, bytesInput(recipientKeyring, "recipientKeyring"), pipeSessionEncryptionPurpose(), ); const senderBundles = senderBundleInputs(senderPublicKey); const policy = resolveSignatureVerificationPolicy(signaturePolicy); verifyPipeSessionValue(signed, senderBundles, expected.senderId, policy); const parsed = bindings.parse_data_value(signed); const sessionId = sessionIdFromOffer(parsed); const checked = validateSessionParameters({ ...expected, sessionId }); const key = validateOfferFields(parsed, checked); const result = new MTPEncryptedPipeReader( reader, key, contextForSessionParameters(checked), remainder, ); key.fill(0); return result; } /** * Establish a forward-secure encrypted pipe over a bidirectional transport. * * The responder contributes a fresh ephemeral hybrid-KEM key. Long-term * identity keys authenticate the three-message exchange, but are not used to * encrypt the resulting record chain, so later compromise of a long-term KEM * key does not recover recorded sessions. */ export async function initiateMTPForwardSecurePipeSession( stream: MTPDuplexPipe, params: MTPPipeSessionParameters, senderKeyring: MTPBytesInput, recipientPublicKey: MTPBytesInput, signatureSuite?: MTPProtectionSignatureSuite, signaturePolicy?: MTPSignatureVerificationPolicy, ): Promise { const checked = validateSessionParameters(params); if (stream.pipeId != null && stream.pipeId !== checked.pipeId) { throw sessionError("pipeId does not match the actual duplex pipe"); } const recipientBundle = bytesInput(recipientPublicKey, "recipientPublicKey"); publicKeyBundleInputs(recipientBundle); const keyring = serializedKeyring(senderKeyring); const suite = selectedSignatureSuite(keyring, signatureSuite); const policy = resolveSignatureVerificationPolicy(signaturePolicy); const nonce = new Uint8Array(32); globalThis.crypto.getRandomValues(nonce); const initBytes = signHandshakeValue( fsInitValue(checked, nonce), checked.senderId, keyring, suite, ); await writeSessionOffer(stream, initBytes); const offerReader = new MTPSessionOfferReader(stream); const responseBytes = await offerReader.read(); const responseFields = verifiedHandshakeValue( responseBytes, checked.recipientId, recipientBundle, policy, ); validateFsCommon(responseFields, checked, FS_RESPONSE_DOMAIN, 9); const initHash = handshakeHash([initBytes]); const receivedInitHash = asBytes(responseFields[7], "initHash"); if ( receivedInitHash.length !== initHash.length || receivedInitHash.some((byte, index) => byte !== initHash[index]) ) { throw sessionError("forward-secure handshake init transcript mismatch"); } const ephemeralPublicKey = asBytes(responseFields[8], "ephemeralPublicKey"); let encapsulated: ReturnType | undefined; try { encapsulated = bindings.wasm_kem_encapsulate(ephemeralPublicKey); const ciphertext = encapsulated.ciphertext; const finishBytes = signHandshakeValue( fsFinishValue(checked, handshakeHash([responseBytes]), ciphertext), checked.senderId, keyring, suite, ); await writeSessionOffer(stream, finishBytes); const handshakeTranscript = handshakeHash([ initBytes, responseBytes, finishBytes, ]); const chainKey = encapsulated.shared_secret; const recordKey = bindings.wasm_hkdf_expand( chainKey, handshakeTranscript, FS_ROOT_INFO, 32, ); try { return new MTPEncryptedPipeWriter( stream, recordKey, forwardSecureContext(checked, handshakeTranscript), ); } finally { chainKey.fill(0); recordKey.fill(0); } } catch (error) { if (error instanceof MTPSignatureVerificationError) throw error; throw sessionError("forward-secure pipe handshake failed", error); } finally { encapsulated?.free(); nonce.fill(0); } } /** * Accept the forward-secure handshake. The session ID is learned from the * authenticated initiator message; the remaining endpoint and pipe fields * are supplied as the pre-decryption expectation. */ export async function acceptMTPForwardSecurePipeSession( stream: MTPDuplexPipe, expected: MTPPipeSessionExpectation, recipientKeyring: MTPBytesInput, senderPublicKey: MTPBytesInput | MTPBytesInput[], signatureSuite?: MTPProtectionSignatureSuite, signaturePolicy?: MTPSignatureVerificationPolicy, ): Promise { if (stream.pipeId != null && stream.pipeId !== expected.pipeId) { throw sessionError("pipeId does not match the actual duplex pipe"); } const recipientKeys = serializedKeyring(recipientKeyring); const suite = selectedSignatureSuite(recipientKeys, signatureSuite); const policy = resolveSignatureVerificationPolicy(signaturePolicy); const senderBundles = senderBundleInputs(senderPublicKey); const offerReader = new MTPSessionOfferReader(stream); const initBytes = await offerReader.read(); const initFields = verifiedHandshakeValueWithKeys( initBytes, expected.senderId, senderBundles, policy, ); if (initFields.length !== 8 || initFields[0] !== FS_INIT_DOMAIN) { throw sessionError("forward-secure init message is malformed"); } const sessionId = asBytes(initFields[1], "sessionId"); const checked = validateSessionParameters({ ...expected, sessionId }); validateFsCommon(initFields, checked, FS_INIT_DOMAIN, 8); const nonce = asBytes(initFields[7], "nonce"); if (nonce.length !== 32) throw sessionError("forward-secure nonce is not 32 bytes"); const ephemeral = bindings.wasm_kem_generate_keypair(); try { const responseBytes = signHandshakeValue( fsResponseValue( checked, handshakeHash([initBytes]), ephemeral.public_key, ), checked.recipientId, recipientKeys, suite, ); await writeSessionOffer(stream, responseBytes); const finishBytes = await offerReader.read(); const finishFields = verifiedHandshakeValueWithKeys( finishBytes, checked.senderId, senderBundles, policy, ); validateFsCommon(finishFields, checked, FS_FINISH_DOMAIN, 9); const responseHash = handshakeHash([responseBytes]); const receivedResponseHash = asBytes(finishFields[7], "responseHash"); if ( receivedResponseHash.length !== responseHash.length || receivedResponseHash.some((byte, index) => byte !== responseHash[index]) ) { throw sessionError( "forward-secure handshake response transcript mismatch", ); } const sharedSecret = bindings.wasm_kem_decapsulate( ephemeral.secret_key, asBytes(finishFields[8], "ciphertext"), ); const handshakeTranscript = handshakeHash([ initBytes, responseBytes, finishBytes, ]); const recordKey = bindings.wasm_hkdf_expand( sharedSecret, handshakeTranscript, FS_ROOT_INFO, 32, ); try { return new MTPEncryptedPipeReader( stream, recordKey, forwardSecureContext(checked, handshakeTranscript), offerReader.remainder(), ); } finally { sharedSecret.fill(0); recordKey.fill(0); } } catch (error) { if (error instanceof MTPSignatureVerificationError) throw error; throw sessionError("forward-secure pipe handshake failed", error); } finally { ephemeral.free(); } } /** Encrypts ordered records on top of a negotiated MTP pipe. */ export class MTPEncryptedPipeWriter { readonly pipeId?: number; private chainKey: Uint8Array; private readonly context: MTPPipeProtectionContext; private sequence = 0n; private writeChain: Promise = Promise.resolve(); private state: "open" | "finalized" | "failed" = "open"; constructor( private readonly writer: MTPWritablePipe, key: Uint8Array, context: MTPPipeProtectionContext, ) { this.chainKey = keyBytes(key); this.context = context; this.pipeId = (writer as MTPPipeWriter).pipeId; } get sequenceNumber(): bigint { return this.sequence; } writeRecord(plaintext: Uint8Array): Promise { if (!(plaintext instanceof Uint8Array)) { return Promise.reject(new TypeError("pipe record must be a Uint8Array")); } if (this.state !== "open") { return Promise.reject( new MTPEncryptedPipeError( "state", "encrypted pipe is no longer writable", ), ); } const input = plaintext.slice(); const operation = this.writeChain.then(() => this.writeRecordInternal(input), ); this.writeChain = operation .catch((error) => { this.poison(); throw error; }) .catch(() => undefined); return operation; } private async writeRecordInternal( plaintext: Uint8Array, recordType = RECORD_TYPE_DATA, ): Promise { if (this.state !== "open") { throw new MTPEncryptedPipeError( "state", "encrypted pipe is no longer writable", ); } if (this.sequence === 0xffff_ffff_ffff_ffffn) { throw new MTPEncryptedPipeError( "sequence", "encrypted pipe sequence exhausted", ); } const encodedLength = recordLength(plaintext.length); const { messageKey, nextChainKey } = deriveRecordKeys( this.chainKey, this.context, this.sequence, ); let committed = false; let ciphertext: Uint8Array; try { try { const cipher = new bindings.WasmChaCha20Poly1305(messageKey); try { ciphertext = cipher.encrypt( plaintext, aad(this.context, this.sequence, encodedLength, recordType), ); } finally { cipher.free(); } } catch (error) { throw new MTPEncryptedPipeError( "authentication", "encrypted pipe record encryption failed", { cause: error }, ); } if (ciphertext.length !== encodedLength) { throw new MTPEncryptedPipeError( "record-length", `encrypted pipe cipher returned ${ciphertext.length} bytes, expected ${encodedLength}`, ); } const prefix = new Uint8Array(4); new DataView(prefix.buffer).setUint32(0, encodedLength, false); try { await this.writer.write( concatBytes([prefix, new Uint8Array([recordType]), ciphertext]), ); } catch (error) { throw new MTPEncryptedPipeError("io", "encrypted pipe write failed", { cause: error, }); } this.chainKey.fill(0); this.chainKey = nextChainKey; this.sequence += 1n; committed = true; } finally { messageKey.fill(0); if (!committed) nextChainKey.fill(0); } } async close(): Promise { if (this.state !== "open") { throw new MTPEncryptedPipeError( "state", "encrypted pipe is no longer open", ); } const operation = this.writeChain.then(async () => { await this.writeRecordInternal(new Uint8Array(0), RECORD_TYPE_FINAL); this.state = "finalized"; try { await this.writer.close(); } catch (error) { throw new MTPEncryptedPipeError("io", "encrypted pipe close failed", { cause: error, }); } }); this.writeChain = operation .catch((error) => { this.poison(); throw error; }) .catch(() => undefined); await operation; } abort(): void { this.poison(); this.writer.abort(); } private poison(): void { this.chainKey.fill(0); this.state = "failed"; } } /** Reads and authenticates ordered records on top of a negotiated MTP pipe. */ export class MTPEncryptedPipeReader { private chainKey: Uint8Array; private readonly context: MTPPipeProtectionContext; private sequence = 0n; private buffered = new Uint8Array(0); private ended = false; private readChain: Promise = Promise.resolve(); private state: "open" | "finalized" | "failed" = "open"; constructor( private readonly reader: MTPReadablePipe, key: Uint8Array, context: MTPPipeProtectionContext, initialBuffer: Uint8Array = new Uint8Array(0), ) { this.chainKey = keyBytes(key); this.context = context; this.buffered = initialBuffer.slice(); } get sequenceNumber(): bigint { return this.sequence; } private append(chunk: Uint8Array): void { if (this.buffered.length + chunk.length > MAX_PIPE_BUFFER) { throw new MTPEncryptedPipeError( "record-length", "encrypted pipe input buffer is too large", ); } const combined = new Uint8Array(this.buffered.length + chunk.length); combined.set(this.buffered); combined.set(chunk, this.buffered.length); this.buffered = combined; } private async ensure(length: number): Promise { while (this.buffered.length < length && !this.ended) { let chunk: Uint8Array | null; try { chunk = await this.reader.read(); } catch (error) { throw new MTPEncryptedPipeError("io", "encrypted pipe read failed", { cause: error, }); } if (chunk == null) { this.ended = true; break; } if (!(chunk instanceof Uint8Array)) { throw new MTPEncryptedPipeError( "io", "pipe reader returned a non-byte chunk", ); } if (chunk.length > 0) this.append(chunk); } return this.buffered.length >= length; } readRecord(): Promise { if (this.state === "finalized") return Promise.resolve(null); if (this.state === "failed") { return Promise.reject( new MTPEncryptedPipeError( "state", "encrypted pipe is no longer readable", ), ); } const operation = this.readChain.then(() => this.readRecordInternal()); this.readChain = operation .catch((error) => { this.poison(); throw error; }) .then( () => undefined, () => undefined, ); return operation; } private async readRecordInternal(): Promise { if (this.state !== "open") { if (this.state === "finalized") return null; throw new MTPEncryptedPipeError( "state", "encrypted pipe is no longer readable", ); } if (this.sequence === 0xffff_ffff_ffff_ffffn) { throw new MTPEncryptedPipeError( "sequence", "encrypted pipe sequence exhausted", ); } if (!(await this.ensure(4))) { throw new MTPEncryptedPipeError( "truncated", "encrypted pipe ended without an authenticated final record", ); } const encodedLength = new DataView( this.buffered.buffer, this.buffered.byteOffset, this.buffered.byteLength, ).getUint32(0, false); if ( encodedLength < XCHACHA_OVERHEAD || encodedLength > MAX_ENCRYPTED_PIPE_RECORD ) { throw new MTPEncryptedPipeError( "record-length", `invalid encrypted pipe record length: ${encodedLength}`, ); } const totalLength = 5 + encodedLength; if (!(await this.ensure(totalLength))) { throw new MTPEncryptedPipeError( "truncated", "truncated encrypted pipe record", ); } const recordType = this.buffered[4]; if (recordType !== RECORD_TYPE_DATA && recordType !== RECORD_TYPE_FINAL) { throw new MTPEncryptedPipeError( "record-length", `invalid encrypted pipe record type: ${recordType}`, ); } const ciphertext = this.buffered.slice(5, totalLength); this.buffered = this.buffered.slice(totalLength); const { messageKey, nextChainKey } = deriveRecordKeys( this.chainKey, this.context, this.sequence, ); let committed = false; let plaintext: Uint8Array; try { try { const cipher = new bindings.WasmChaCha20Poly1305(messageKey); try { plaintext = cipher.decrypt( ciphertext, aad(this.context, this.sequence, encodedLength, recordType), ); } finally { cipher.free(); } } catch (error) { throw new MTPEncryptedPipeError( "authentication", "encrypted pipe record authentication failed", { cause: error }, ); } this.chainKey.fill(0); this.chainKey = nextChainKey; this.sequence += 1n; committed = true; if (recordType === RECORD_TYPE_FINAL) { if (plaintext.length !== 0) { throw new MTPEncryptedPipeError( "record-length", "encrypted pipe final record must be empty", ); } this.state = "finalized"; return null; } return plaintext; } finally { messageKey.fill(0); if (!committed) nextChainKey.fill(0); } } private poison(): void { this.chainKey.fill(0); this.state = "failed"; } } export type MTPEncryptedPipeWriterSource = MTPPipeWriter & MTPWritablePipe; export type MTPEncryptedPipeReaderSource = MTPPipeReader & MTPReadablePipe;