[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);