1523 lines
45 KiB
TypeScript
1523 lines
45 KiB
TypeScript
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<void>;
|
|
close(): Promise<void>;
|
|
abort(): void;
|
|
}
|
|
|
|
export interface MTPReadablePipe {
|
|
read(): Promise<Uint8Array | null>;
|
|
}
|
|
|
|
/** 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<typeof serializedKeyring>,
|
|
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<ArrayBufferLike> = new Uint8Array(0);
|
|
const ensure = async (length: number): Promise<void> => {
|
|
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<ArrayBufferLike> = new Uint8Array(0);
|
|
|
|
constructor(private readonly reader: MTPReadablePipe) {}
|
|
|
|
async read(): Promise<Uint8Array> {
|
|
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<ArrayBufferLike> = initialBuffer.slice();
|
|
const ensure = async (length: number): Promise<void> => {
|
|
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<void> {
|
|
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<typeof serializedKeyring>,
|
|
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<string, unknown>).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<string, unknown>).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<string, unknown>).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<MTPEncryptedPipeWriter> {
|
|
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<MTPEncryptedPipeReader> {
|
|
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<MTPEncryptedPipeReader> {
|
|
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<MTPEncryptedPipeWriter> {
|
|
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<typeof bindings.wasm_kem_encapsulate> | 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<MTPEncryptedPipeReader> {
|
|
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<void> = 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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> = 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<boolean> {
|
|
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<Uint8Array | null> {
|
|
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<Uint8Array | null> {
|
|
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;
|