[WIP] Security work While on holiday

This commit is contained in:
Alex 2026-08-12 22:45:28 +02:00
commit 7f0231e3f1
Signed by: alex
SSH key fingerprint: SHA256:D1+Ub8o0v4K5y1JNivW8IxEOelqLSvPmUzBbDIoZkRQ
109 changed files with 19694 additions and 5210 deletions

View file

@ -1,74 +0,0 @@
export interface EncryptedDeviceSecretRecord {
userId: string;
deviceId: string;
secretId: string;
version: number;
encryptedSecret: Uint8Array;
wrappingPublicKeyId?: string;
wrappingScheme: string;
createdAt: number;
updatedAt: number;
}
export interface MTPEncryptedDeviceSecretProvider {
setEncryptedDeviceSecret(record: EncryptedDeviceSecretRecord): Promise<void>;
getEncryptedDeviceSecret(query: {
userId: string;
deviceId?: string;
secretId?: string;
}): Promise<EncryptedDeviceSecretRecord | null>;
}
function keyFor(record: Pick<EncryptedDeviceSecretRecord, "userId" | "deviceId" | "secretId">): string {
return `${record.userId}\0${record.deviceId}\0${record.secretId}`;
}
function cloneRecord(record: EncryptedDeviceSecretRecord): EncryptedDeviceSecretRecord {
return {
...record,
encryptedSecret: new Uint8Array(record.encryptedSecret),
};
}
function validateEncryptedRecord(record: EncryptedDeviceSecretRecord): void {
if (!record.userId || !record.deviceId || !record.secretId) {
throw new Error("encrypted device secret requires userId, deviceId, and secretId");
}
if (!(record.encryptedSecret instanceof Uint8Array) || record.encryptedSecret.length === 0) {
throw new Error("encrypted device secret requires non-empty encryptedSecret bytes");
}
if (!record.wrappingScheme) {
throw new Error("encrypted device secret requires wrappingScheme");
}
}
export class InMemoryEncryptedDeviceSecretProvider implements MTPEncryptedDeviceSecretProvider {
private store = new Map<string, EncryptedDeviceSecretRecord>();
async setEncryptedDeviceSecret(record: EncryptedDeviceSecretRecord): Promise<void> {
validateEncryptedRecord(record);
const now = Date.now();
this.store.set(keyFor(record), cloneRecord({ ...record, updatedAt: record.updatedAt || now }));
}
async getEncryptedDeviceSecret(query: {
userId: string;
deviceId?: string;
secretId?: string;
}): Promise<EncryptedDeviceSecretRecord | null> {
if (!query.userId) {
throw new Error("userId is required");
}
if (query.deviceId && query.secretId) {
const found = this.store.get(`${query.userId}\0${query.deviceId}\0${query.secretId}`);
return found ? cloneRecord(found) : null;
}
for (const record of this.store.values()) {
if (record.userId !== query.userId) continue;
if (query.deviceId && record.deviceId !== query.deviceId) continue;
if (query.secretId && record.secretId !== query.secretId) continue;
return cloneRecord(record);
}
return null;
}
}

View file

@ -14,8 +14,8 @@ const HEADER_FIXED_LEN = 1 + 1 + 8 + 8 + 4 + 2 + 4;
export interface ParsedEncryptedMessage {
version: 1;
flags: number;
senderClientId: bigint;
recipientClientId: bigint;
senderId: bigint;
recipientId: bigint;
messageNumber: number;
kemCiphertext?: Uint8Array;
ciphertext: Uint8Array;
@ -28,8 +28,8 @@ export interface ParsedEncryptedMessage {
export interface EncryptedMessageHeader {
version: 1;
flags: number;
senderClientId: bigint;
recipientClientId: bigint;
senderId: bigint;
recipientId: bigint;
messageNumber: number;
kemCiphertext?: Uint8Array;
}
@ -105,8 +105,8 @@ export function serializeEncryptedMessage(
return concatBytes([
new Uint8Array([normalized.version]),
new Uint8Array([normalized.flags]),
writeU64BE(normalized.senderClientId),
writeU64BE(normalized.recipientClientId),
writeU64BE(normalized.senderId),
writeU64BE(normalized.recipientId),
writeU32BE(normalized.messageNumber),
new Uint8Array([
(kemCiphertext.length >>> 8) & 0xff,
@ -131,9 +131,9 @@ export function parseEncryptedMessage(
const version = bytes[offset++];
const flags = bytes[offset++];
const senderClientId = readU64BE(bytes, offset);
const senderId = readU64BE(bytes, offset);
offset += 8;
const recipientClientId = readU64BE(bytes, offset);
const recipientId = readU64BE(bytes, offset);
offset += 8;
const messageNumber =
((bytes[offset] << 24) |
@ -176,8 +176,8 @@ export function parseEncryptedMessage(
const parsed: ParsedEncryptedMessage = {
version: version as 1,
flags,
senderClientId,
recipientClientId,
senderId,
recipientId,
messageNumber,
kemCiphertext,
ciphertext,
@ -185,8 +185,8 @@ export function parseEncryptedMessage(
parsed.header = {
version: parsed.version,
flags: parsed.flags,
senderClientId: parsed.senderClientId,
recipientClientId: parsed.recipientClientId,
senderId: parsed.senderId,
recipientId: parsed.recipientId,
messageNumber: parsed.messageNumber,
kemCiphertext: parsed.kemCiphertext,
};
@ -199,8 +199,8 @@ function buildAAD(header: EncryptedMessageHeader): Uint8Array {
return concatBytes([
new Uint8Array([header.version]),
new Uint8Array([header.flags]),
writeU64BE(header.senderClientId),
writeU64BE(header.recipientClientId),
writeU64BE(header.senderId),
writeU64BE(header.recipientId),
writeU32BE(header.messageNumber),
]);
}
@ -227,8 +227,8 @@ export async function encryptPayload(args: {
const header: EncryptedMessageHeader = {
version: 1,
flags: args.kemCiphertext ? FLAG_INIT : 0,
senderClientId: args.session.ownClientId,
recipientClientId: args.session.peerClientId,
senderId: args.session.localId,
recipientId: args.session.remoteId,
messageNumber: args.session.sendCount,
kemCiphertext: args.kemCiphertext,
};
@ -257,19 +257,18 @@ export async function encryptPayload(args: {
export async function decryptPayload(args: {
payload: Uint8Array;
session: MTPSessionState;
expectedRecipientClientId?: bigint;
expectedRecipientId?: bigint;
aad?: Uint8Array;
}): Promise<{
plaintext: Uint8Array;
session: MTPSessionState;
}> {
const parsed = parseEncryptedMessage(args.payload);
const expectedRecipientClientId =
args.expectedRecipientClientId ?? args.session.ownClientId;
if (parsed.recipientClientId !== expectedRecipientClientId) {
const expectedRecipientId = args.expectedRecipientId ?? args.session.localId;
if (parsed.recipientId !== expectedRecipientId) {
throw new Error("Encrypted message recipient mismatch");
}
if (parsed.senderClientId !== args.session.peerClientId) {
if (parsed.senderId !== args.session.remoteId) {
throw new Error("Encrypted message sender mismatch");
}
@ -322,8 +321,8 @@ export async function decryptPayload(args: {
const header: EncryptedMessageHeader = {
version: parsed.version,
flags: parsed.flags,
senderClientId: parsed.senderClientId,
recipientClientId: parsed.recipientClientId,
senderId: parsed.senderId,
recipientId: parsed.recipientId,
messageNumber: parsed.messageNumber,
kemCiphertext: parsed.kemCiphertext,
};

1523
src/sdk/encrypted-pipe.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,90 @@
/**
* Encrypted secret material persisted for MTP cryptographic facilities.
*
* `id` is an opaque, MTP-owned or caller-derived identifier. The provider
* does not interpret it or infer an identity hierarchy from it.
*/
export interface MTPEncryptedSecretRecord {
id: string;
encryptedSecret: Uint8Array;
formatVersion: number;
wrappingScheme: string;
wrappingKeyId?: string;
createdAt: number;
updatedAt: number;
}
export interface MTPEncryptedSecretProvider {
get(id: string): Promise<MTPEncryptedSecretRecord | null>;
set(record: MTPEncryptedSecretRecord): Promise<void>;
delete(id: string): Promise<void>;
}
function requireId(id: string): string {
if (typeof id !== "string" || id.length === 0) {
throw new TypeError("encrypted secret id must be a non-empty string");
}
return id;
}
function validateTimestamp(value: number, name: string): void {
if (!Number.isSafeInteger(value) || value < 0) {
throw new TypeError(`${name} must be a non-negative safe integer`);
}
}
function cloneRecord(record: MTPEncryptedSecretRecord): MTPEncryptedSecretRecord {
return {
...record,
encryptedSecret: new Uint8Array(record.encryptedSecret),
};
}
function validateRecord(record: MTPEncryptedSecretRecord): void {
requireId(record.id);
if (
!(record.encryptedSecret instanceof Uint8Array) ||
record.encryptedSecret.length === 0
) {
throw new TypeError(
"encrypted secret requires non-empty encryptedSecret bytes",
);
}
if (!Number.isSafeInteger(record.formatVersion) || record.formatVersion < 0) {
throw new TypeError(
"encrypted secret formatVersion must be a non-negative safe integer",
);
}
if (typeof record.wrappingScheme !== "string" || !record.wrappingScheme) {
throw new TypeError("encrypted secret requires wrappingScheme");
}
validateTimestamp(record.createdAt, "encrypted secret createdAt");
validateTimestamp(record.updatedAt, "encrypted secret updatedAt");
if (
record.wrappingKeyId !== undefined &&
(typeof record.wrappingKeyId !== "string" || !record.wrappingKeyId)
) {
throw new TypeError("encrypted secret wrappingKeyId must be non-empty");
}
}
/** A small reference implementation for callers that need local persistence. */
export class InMemoryEncryptedSecretProvider
implements MTPEncryptedSecretProvider
{
private store = new Map<string, MTPEncryptedSecretRecord>();
async get(id: string): Promise<MTPEncryptedSecretRecord | null> {
const record = this.store.get(requireId(id));
return record ? cloneRecord(record) : null;
}
async set(record: MTPEncryptedSecretRecord): Promise<void> {
validateRecord(record);
this.store.set(record.id, cloneRecord(record));
}
async delete(id: string): Promise<void> {
this.store.delete(requireId(id));
}
}

File diff suppressed because it is too large Load diff

View file

@ -4,23 +4,29 @@ 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";
const HKDF_INITIATOR_RECV = "mtp-e2ee-v1-initiator-recv";
const SESSION_TRANSCRIPT_DOMAIN = "mtp-e2ee-session-transcript-v1";
export interface MTPSessionTranscriptContext {
senderUserId?: string;
senderClientId: bigint;
recipientUserId?: string;
recipientClientId: bigint;
/** Application-selected identity for this stateful MTP session. */
sessionId: string;
/** MTP identity that initiated key establishment. */
initiatorId: bigint;
/** MTP identity that receives the key-establishment message. */
recipientId: bigint;
/** Public key used by the recipient for this key establishment. */
recipientPublicKey: Uint8Array;
/** KEM ciphertext used by the key establishment. */
kemCiphertext: Uint8Array;
conversationId: string;
/** Opaque, application-owned context included by hash only. */
applicationContext?: Uint8Array;
}
export interface MTPSessionState {
version: 1;
conversationId: string;
ownClientId: bigint;
peerClientId: bigint;
peerPublicKey: Uint8Array;
sessionId: string;
localId: bigint;
remoteId: bigint;
remotePublicKey: Uint8Array;
sendChainKey: Uint8Array;
recvChainKey: Uint8Array;
sendCount: number;
@ -37,9 +43,26 @@ export interface SkippedMessageKey {
}
export interface MTPSessionStorage {
getSession(conversationId: string): Promise<MTPSessionState | null>;
getSession(sessionId: string): Promise<MTPSessionState | null>;
setSession(state: MTPSessionState): Promise<void>;
deleteSession(conversationId: string): Promise<void>;
deleteSession(sessionId: string): Promise<void>;
}
function requireSessionId(sessionId: string): string {
if (typeof sessionId !== "string" || sessionId.length === 0) {
throw new TypeError("sessionId must be a non-empty string");
}
return sessionId;
}
function requireMtpId(id: bigint, name: string): bigint {
if (typeof id !== "bigint") {
throw new TypeError(`${name} must be a bigint`);
}
// Validate the range once at the API boundary. The returned value is still
// the original bigint so callers do not observe a representation change.
writeU64BE(id);
return id;
}
export class InMemorySessionStorage implements MTPSessionStorage {
@ -48,7 +71,7 @@ export class InMemorySessionStorage implements MTPSessionStorage {
private cloneSession(state: MTPSessionState): MTPSessionState {
return {
...state,
peerPublicKey: state.peerPublicKey.slice(),
remotePublicKey: state.remotePublicKey.slice(),
sendChainKey: state.sendChainKey.slice(),
recvChainKey: state.recvChainKey.slice(),
skippedMessageKeys: (state.skippedMessageKeys ?? []).map((skipped) => ({
@ -64,26 +87,31 @@ export class InMemorySessionStorage implements MTPSessionStorage {
for (const skipped of state.skippedMessageKeys ?? []) skipped.key.fill(0);
}
async getSession(conversationId: string): Promise<MTPSessionState | null> {
const state = this.store.get(conversationId);
async getSession(sessionId: string): Promise<MTPSessionState | null> {
const state = this.store.get(requireSessionId(sessionId));
return state ? this.cloneSession(state) : null;
}
async setSession(state: MTPSessionState): Promise<void> {
const sessionId = requireSessionId(state.sessionId);
const replacement = this.cloneSession(state);
const previous = this.store.get(state.conversationId);
const previous = this.store.get(sessionId);
if (previous) this.zeroizeSession(previous);
this.store.set(state.conversationId, replacement);
this.store.set(sessionId, replacement);
}
async deleteSession(conversationId: string): Promise<void> {
const previous = this.store.get(conversationId);
async deleteSession(sessionId: string): Promise<void> {
const key = requireSessionId(sessionId);
const previous = this.store.get(key);
if (previous) this.zeroizeSession(previous);
this.store.delete(conversationId);
this.store.delete(key);
}
}
function writeU32BE(value: number): Uint8Array {
if (!Number.isSafeInteger(value) || value < 0 || value > 0xffff_ffff) {
throw new Error("u32 value out of range");
}
return new Uint8Array([
(value >>> 24) & 0xff,
(value >>> 16) & 0xff,
@ -102,21 +130,34 @@ function transcriptField(label: string, value: Uint8Array): Uint8Array {
]);
}
/**
* Build a length-delimited transcript for one MTP session.
*
* Application context is intentionally hashed as an opaque byte string. MTP
* therefore provides domain separation without parsing or naming any fields
* owned by the consuming application.
*/
export function buildSessionTranscript(
args: MTPSessionTranscriptContext,
): Uint8Array {
const sessionId = requireSessionId(args.sessionId);
const initiatorId = requireMtpId(args.initiatorId, "initiatorId");
const recipientId = requireMtpId(args.recipientId, "recipientId");
const recipientPublicKeyHash = bindings.wasm_sha256(args.recipientPublicKey);
const kemHash = bindings.wasm_sha256(args.kemCiphertext);
const applicationContextHash = bindings.wasm_sha256(
args.applicationContext ?? new Uint8Array(0),
);
return concatBytes([
transcriptField("domain", utf8Encode("mtp-e2ee-session-transcript-v1")),
transcriptField("domain", utf8Encode(SESSION_TRANSCRIPT_DOMAIN)),
transcriptField("version", utf8Encode("1")),
transcriptField("senderUserId", utf8Encode(args.senderUserId ?? "")),
transcriptField("senderClientId", writeU64BE(args.senderClientId)),
transcriptField("recipientUserId", utf8Encode(args.recipientUserId ?? "")),
transcriptField("recipientClientId", writeU64BE(args.recipientClientId)),
transcriptField("sessionId", utf8Encode(sessionId)),
transcriptField("initiatorId", writeU64BE(initiatorId)),
transcriptField("recipientId", writeU64BE(recipientId)),
transcriptField("recipientPublicKeyHash", recipientPublicKeyHash),
transcriptField("kemCiphertextHash", kemHash),
transcriptField("conversationId", utf8Encode(args.conversationId)),
transcriptField("applicationContextHash", applicationContextHash),
]);
}
@ -150,62 +191,65 @@ export async function deriveSessionKeys(
return { root, initiatorSend, initiatorRecv };
}
export function getConversationId(
ownClientId: bigint,
peerClientId: bigint,
/**
* Derive a stable ID for the unordered pair of MTP identities.
*
* This helper is only a convenience. Session storage and the manager accept
* caller-selected IDs directly, so applications can keep multiple sessions
* between the same pair of identities.
*/
export function derivePeerSessionId(
localId: bigint,
remoteId: bigint,
): string {
const ids = [ownClientId, peerClientId].sort((a, b) =>
a < b ? -1 : a > b ? 1 : 0,
);
const ids = [
requireMtpId(localId, "localId"),
requireMtpId(remoteId, "remoteId"),
].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
return `${ids[0].toString(16)}:${ids[1].toString(16)}`;
}
export class MTPSessionManager {
constructor(private storage: MTPSessionStorage) {}
getConversationId(
ownClientId: bigint,
peerClientId: bigint,
): Promise<string> {
return Promise.resolve(getConversationId(ownClientId, peerClientId));
}
async getSession(
ownClientId: bigint,
peerClientId: bigint,
): Promise<MTPSessionState | null> {
return this.storage.getSession(
getConversationId(ownClientId, peerClientId),
);
getSession(sessionId: string): Promise<MTPSessionState | null> {
return this.storage.getSession(requireSessionId(sessionId));
}
async saveSession(state: MTPSessionState): Promise<void> {
await this.storage.setSession({ ...state, updatedAt: Date.now() });
await this.storage.setSession({
...state,
updatedAt: Date.now(),
});
}
async deleteSession(
ownClientId: bigint,
peerClientId: bigint,
): Promise<void> {
await this.storage.deleteSession(
getConversationId(ownClientId, peerClientId),
);
async deleteSession(sessionId: string): Promise<void> {
await this.storage.deleteSession(requireSessionId(sessionId));
}
async createSession(args: {
ownClientId: bigint;
peerClientId: bigint;
peerPublicKey: Uint8Array;
sessionId: string;
localId: bigint;
remoteId: bigint;
remotePublicKey: Uint8Array;
sharedSecret: Uint8Array;
role: "initiator" | "receiver";
transcript?: Uint8Array;
transcriptContext?: MTPSessionTranscriptContext;
}): Promise<MTPSessionState> {
const transcript =
args.transcript ??
(args.transcriptContext
const sessionId = requireSessionId(args.sessionId);
const localId = requireMtpId(args.localId, "localId");
const remoteId = requireMtpId(args.remoteId, "remoteId");
const transcript = args.transcript
? args.transcript.slice()
: args.transcriptContext
? buildSessionTranscript(args.transcriptContext)
: undefined);
: null;
if (!transcript || transcript.length === 0) {
throw new Error(
"session creation requires a non-empty authenticated transcript or transcriptContext",
);
}
const { root, initiatorSend, initiatorRecv } = await deriveSessionKeys(
args.sharedSecret,
transcript,
@ -213,10 +257,10 @@ export class MTPSessionManager {
const now = Date.now();
const state: MTPSessionState = {
version: 1,
conversationId: getConversationId(args.ownClientId, args.peerClientId),
ownClientId: args.ownClientId,
peerClientId: args.peerClientId,
peerPublicKey: args.peerPublicKey,
sessionId,
localId,
remoteId,
remotePublicKey: args.remotePublicKey.slice(),
sendChainKey: args.role === "initiator" ? initiatorSend : initiatorRecv,
recvChainKey: args.role === "initiator" ? initiatorRecv : initiatorSend,
sendCount: 0,

170
src/sdk/signature-policy.ts Normal file
View file

@ -0,0 +1,170 @@
import * as bindings from "mtp/raw";
export type MTPSignatureVerificationPolicy =
| "ed25519"
| "dual"
| "any-supported";
/**
* The SDK default is deliberately fixed. Senders also default to the
* interoperable Ed25519 suite; dual signatures require an explicit sender
* suite and receiver policy.
*/
export const DEFAULT_SIGNATURE_VERIFICATION_POLICY: MTPSignatureVerificationPolicy =
"ed25519";
export type MTPSignatureVerificationErrorCode =
| "unsupported-suite"
| "policy-rejected"
| "invalid-signature"
| "signer-keys-unavailable";
const POLICY_NAMES: Record<
MTPSignatureVerificationErrorCode,
string
> = {
"unsupported-suite": "unsupported signature suite",
"policy-rejected": "signature rejected by policy",
"invalid-signature": "signature cryptographically invalid",
"signer-keys-unavailable": "signer public keys unavailable",
};
/** Caller-facing signature verification failure without cryptographic detail. */
export class MTPSignatureVerificationError extends Error {
readonly code: MTPSignatureVerificationErrorCode;
readonly signerId?: bigint;
constructor(
code: MTPSignatureVerificationErrorCode,
signerId?: bigint,
) {
super(
signerId == null
? POLICY_NAMES[code]
: `${POLICY_NAMES[code]} for signer ${signerId}`,
);
this.name = "MTPSignatureVerificationError";
this.code = code;
this.signerId = signerId;
}
}
function validPolicy(
value: unknown,
): value is MTPSignatureVerificationPolicy {
return (
value === "ed25519" ||
value === "dual" ||
value === "any-supported"
);
}
/**
* Resolve receiver policy in operation, client, library order.
*/
export function resolveSignatureVerificationPolicy(
operationPolicy: MTPSignatureVerificationPolicy | undefined,
clientDefaultPolicy?: MTPSignatureVerificationPolicy,
): MTPSignatureVerificationPolicy {
if (operationPolicy != null && !validPolicy(operationPolicy)) {
throw new TypeError(
"signaturePolicy must be 'ed25519', 'dual', or 'any-supported'",
);
}
if (clientDefaultPolicy != null && !validPolicy(clientDefaultPolicy)) {
throw new TypeError(
"defaultSignatureVerificationPolicy must be 'ed25519', 'dual', or 'any-supported'",
);
}
return (
operationPolicy ??
clientDefaultPolicy ??
DEFAULT_SIGNATURE_VERIFICATION_POLICY
);
}
/** Convert the SDK policy into the raw WASM verifier's policy value. */
export function signatureVerificationPolicyValue(
policy: MTPSignatureVerificationPolicy,
): number {
switch (policy) {
case "ed25519":
return bindings.mtp_protection_signature_suite_ed25519();
case "dual":
return bindings.mtp_protection_signature_suite_dual();
case "any-supported":
return 0;
}
}
/** Verify one protected value and sanitize raw WASM failure details. */
export function verifyDataValueWithPolicy(
value: Uint8Array,
publicKeyBundle: Uint8Array,
expectedSignerId: bigint,
expectedPurpose: number,
policy: MTPSignatureVerificationPolicy,
): void {
try {
bindings.verify_data_value_with_policy(
value,
publicKeyBundle,
expectedSignerId,
expectedPurpose,
signatureVerificationPolicyValue(policy),
);
} catch (error) {
throw classifySignatureVerificationFailure(error, expectedSignerId);
}
}
function rawErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/** Classify a raw verifier failure without returning its cryptographic cause. */
export function classifySignatureVerificationFailure(
error: unknown,
signerId?: bigint,
): MTPSignatureVerificationError {
const message = rawErrorMessage(error).toLowerCase();
if (message.includes("unknown") && message.includes("signature suite")) {
return new MTPSignatureVerificationError("unsupported-suite", signerId);
}
if (message.includes("policy")) {
return new MTPSignatureVerificationError("policy-rejected", signerId);
}
return new MTPSignatureVerificationError("invalid-signature", signerId);
}
/** Select the most useful sanitized error after trying key history. */
export function signatureVerificationFailure(
errors: readonly unknown[],
signerId?: bigint,
): MTPSignatureVerificationError {
const classified = errors.map((error) =>
error instanceof MTPSignatureVerificationError
? error
: classifySignatureVerificationFailure(error, signerId),
);
const preferredCode = [
"unsupported-suite",
"policy-rejected",
"invalid-signature",
].find((code) =>
classified.some((error) => error.code === code),
) as MTPSignatureVerificationErrorCode | undefined;
return new MTPSignatureVerificationError(
preferredCode ?? "invalid-signature",
signerId,
);
}
export function signerKeysUnavailable(
signerId?: bigint,
): MTPSignatureVerificationError {
return new MTPSignatureVerificationError(
"signer-keys-unavailable",
signerId,
);
}

View file

@ -13,6 +13,11 @@ export function utf8Encode(text: string): Uint8Array {
return bytes.subarray(0, len);
}
/** Return the current Unix time in milliseconds for MTP protocol fields. */
export function unixTimeMillis(): bigint {
return BigInt(Date.now());
}
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);

View file

@ -1,16 +1,13 @@
export const RESERVED_COMMUNICATION_TYPES = [
"Identification", "IdentificationResponse", "Register", "RegisterResponse",
"Challenge", "ChallengeResponse", "Ping", "Pong", "Disconnect", "Redirect",
"Shutdown", "Error", "ErrorParsing", "ErrorBadVersion", "BadRequest",
"Unauthorized", "Forbidden", "NotFound", "TooManyRequests", "InternalServerError",
"BadGateway", "ServiceUnavailable", "GatewayTimeout", "PipeRequest", "PipeResponse",
"PipeAbort",
] as const;
import reserved from "../../type-map/reserved.json" with { type: "json" };
export const RESERVED_DATA_TYPES = [
"Version", "Id", "ClientNonce", "ServerNonce", "PublicKeys", "Signature",
"PqSignature", "Description", "Connected", "Timestamp", "Error", "ErrorParsing",
"ErrorMessage", "Accepted", "RequirePq",
] as const;
export const FIRST_USER_TYPE_ID = 32;
export const FIRST_USER_TYPE_ID = reserved.firstUserTypeId;
export const RESERVED_COMMUNICATION_TYPES = reserved.communication.map(
({ name }) => name,
);
export const RESERVED_DATA_TYPES = reserved.data.map(({ name }) => name);
export const RESERVED_COMMUNICATION_TYPE_IDS = Object.fromEntries(
reserved.communication.map(({ name, id }) => [name, id]),
);
export const RESERVED_DATA_TYPE_IDS = Object.fromEntries(
reserved.data.map(({ name, id }) => [name, id]),
);

View file

@ -61,7 +61,7 @@ function devServerPath(root: string, filePath: string) {
return `/${relativePath.split(path.sep).join("/")}`;
}
async function hashPackageInputs() {
export async function hashPackageInputs(root = packageRoot) {
const hash = crypto.createHash("sha256");
const inputs = [
"wasm/Cargo.toml",
@ -74,11 +74,12 @@ async function hashPackageInputs() {
"crypto/src",
"type-map/Cargo.toml",
"type-map/build.rs",
"type-map/reserved.json",
"type-map/src",
];
async function addPath(relativePath) {
const absolutePath = path.join(packageRoot, relativePath);
const absolutePath = path.join(root, relativePath);
const stat = await fs.stat(absolutePath).catch(() => null);
if (!stat) {
return;
@ -109,7 +110,7 @@ function quoteList(values: string[]): string {
: values.map((value) => JSON.stringify(value)).join(" | ");
}
function parseTypeMapYaml(source: string, filePath: string) {
export function parseTypeMapYaml(source: string, filePath: string) {
const document = YAML.parseDocument(source, { prettyErrors: false });
if (document.errors.length) {
const error = document.errors[0];
@ -120,27 +121,80 @@ function parseTypeMapYaml(source: string, filePath: string) {
throw new Error(`${filePath}:${line}: ${error.message}`);
}
const root = document.toJS() as {
type_maps?: Record<
string,
{
CommunicationTypes?: Record<string, unknown>;
DataTypes?: Record<string, unknown>;
}
>;
protocol_version?: unknown;
type_maps?: unknown;
};
if (
root === null ||
typeof root !== "object" ||
Array.isArray(root) ||
typeof root.protocol_version !== "string" ||
!/^\d+\.\d+$/.test(root.protocol_version)
) {
throw new Error(
`${filePath}: protocol_version must be a string matching '<major>.<minor>'`,
);
}
if (
root.type_maps === null ||
typeof root.type_maps !== "object" ||
Array.isArray(root.type_maps)
) {
throw new Error(`${filePath}: type_maps must be a mapping`);
}
const typeMaps = root.type_maps as Record<string, unknown>;
if (!Object.prototype.hasOwnProperty.call(typeMaps, root.protocol_version)) {
throw new Error(
`${filePath}: protocol_version '${root.protocol_version}' is not defined in type_maps`,
);
}
const reservedCommunicationTypes = new Set(RESERVED_COMMUNICATION_TYPES);
const reservedDataTypes = new Set(RESERVED_DATA_TYPES);
const communicationTypes = new Set<string>(RESERVED_COMMUNICATION_TYPES);
const dataTypes = new Set<string>(RESERVED_DATA_TYPES);
for (const [version, map] of Object.entries(root.type_maps ?? {})) {
for (const [version, rawMap] of Object.entries(typeMaps)) {
if (!/^\d+\.\d+$/.test(version))
throw new Error(`${filePath}: unparseable type-map version '${version}'`);
for (const [section, target] of [
["CommunicationTypes", communicationTypes],
["DataTypes", dataTypes],
] as const) {
if (
rawMap === null ||
typeof rawMap !== "object" ||
Array.isArray(rawMap)
) {
throw new Error(`${filePath}: ${version} must be a mapping`);
}
const map = rawMap as {
CommunicationTypes?: unknown;
DataTypes?: unknown;
};
for (const { section, reserved, selected } of [
{
section: "CommunicationTypes",
reserved: reservedCommunicationTypes,
selected: communicationTypes,
},
{
section: "DataTypes",
reserved: reservedDataTypes,
selected: dataTypes,
},
]) {
const sectionValue = map[section as "CommunicationTypes" | "DataTypes"];
if (
sectionValue !== undefined &&
(sectionValue === null ||
typeof sectionValue !== "object" ||
Array.isArray(sectionValue))
) {
throw new Error(`${filePath}: ${version}.${section} must be a mapping`);
}
const ids = new Map<number, string>();
for (const [name, value] of Object.entries(
map[section as "CommunicationTypes" | "DataTypes"] ?? {},
)) {
for (const [name, value] of Object.entries(sectionValue ?? {})) {
if (reserved.has(name)) {
throw new Error(
`${filePath}: ${version}.${section}.${name} uses a reserved type name`,
);
}
if (!Number.isInteger(value) || (value as number) < FIRST_USER_TYPE_ID)
throw new Error(
`${filePath}: ${version}.${section}.${name} must use an integer id >= ${FIRST_USER_TYPE_ID}`,
@ -152,7 +206,9 @@ function parseTypeMapYaml(source: string, filePath: string) {
`${filePath}: duplicate type id ${id} in ${section} (${previous} and ${name})`,
);
ids.set(id, name);
target.add(name);
if (version === root.protocol_version) {
selected.add(name);
}
}
}
}
@ -162,7 +218,7 @@ function parseTypeMapYaml(source: string, filePath: string) {
};
}
function generateTypeMapModule(metadata: {
export function generateTypeMapModule(metadata: {
communicationTypes: string[];
dataTypes: string[];
}) {
@ -171,7 +227,7 @@ function generateTypeMapModule(metadata: {
return { js, dts };
}
async function writeTypeMapModule(outDir, typeMapsPath) {
export async function writeTypeMapModule(outDir, typeMapsPath) {
const source = await fs.readFile(typeMapsPath, "utf8").catch((error) => {
throw new Error(
`Failed to read type map '${typeMapsPath}': ${error.message}`,
@ -186,7 +242,7 @@ async function writeTypeMapModule(outDir, typeMapsPath) {
return source;
}
async function copyWasmBuildInputs(buildRoot) {
export async function copyWasmBuildInputs(buildRoot) {
const inputs = [
"Cargo.lock",
"wasm",
@ -416,18 +472,19 @@ export function mtp(options: MTPVitePluginOptions): VitePlugin {
});
}
const sourceWatchDirs = [
const sourceWatchPaths = [
"wasm/src",
"common/src",
"codec/src",
"crypto/src",
"type-map/src",
"type-map/reserved.json",
].map((rel) => path.join(packageRoot, rel));
server.watcher.add(state.typeMapsPath);
for (const dir of sourceWatchDirs) {
if (await pathExists(dir)) {
server.watcher.add(dir);
for (const sourcePath of sourceWatchPaths) {
if (await pathExists(sourcePath)) {
server.watcher.add(sourcePath);
}
}
@ -435,8 +492,10 @@ export function mtp(options: MTPVitePluginOptions): VitePlugin {
const scheduleRebuild = (changedPath: string) => {
const resolved = path.resolve(changedPath);
const isTypeMap = resolved === state.typeMapsPath;
const isSource = sourceWatchDirs.some((dir) =>
resolved.startsWith(`${dir}${path.sep}`),
const isSource = sourceWatchPaths.some(
(sourcePath) =>
resolved === sourcePath ||
resolved.startsWith(`${sourcePath}${path.sep}`),
);
if (!isTypeMap && !isSource) {
return;