[WIP] Security work While on holiday
This commit is contained in:
parent
a81ac4efca
commit
7f0231e3f1
109 changed files with 19694 additions and 5210 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
1523
src/sdk/encrypted-pipe.ts
Normal file
File diff suppressed because it is too large
Load diff
90
src/sdk/encrypted-secret.ts
Normal file
90
src/sdk/encrypted-secret.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
2334
src/sdk/index.ts
2334
src/sdk/index.ts
File diff suppressed because it is too large
Load diff
|
|
@ -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
170
src/sdk/signature-policy.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue