import { DeviceSchemaError, assertOnlyFields, deviceIdValue, deviceRequestBody, idempotencyKeyValue, keyIdValue, positiveInteger, signatureValue, wrappedAccountKey, } from "./device_schema.js"; import type { WrappedAccountKeyDocument } from "./sync_vault.js"; const MAX_ROTATION_ENVELOPES = 128; export function compareDeviceIds(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0; } export interface DeviceRevocationEnvelopeRequest { recipientDeviceId: string; envelope: WrappedAccountKeyDocument; } interface DeviceRevocationBaseRequest { deviceId: string; idempotencyKey: string; } export interface ApprovedDeviceRevocationRequest extends DeviceRevocationBaseRequest { mode: "approved_rotate"; previousKeyId: string; previousGeneration: number; newKeyId: string; newGeneration: number; envelopes: DeviceRevocationEnvelopeRequest[]; rotationProof: string; } export interface PendingDeviceRevocationRequest extends DeviceRevocationBaseRequest { mode: "pending_revoke"; pendingRevocationProof: string; } export type DeviceRevocationRequest = | ApprovedDeviceRevocationRequest | PendingDeviceRevocationRequest; export async function deviceRevocationRequest(request: Request): Promise { const value = await deviceRequestBody(request, "device_revocation"); if (value.version !== 2) { throw new DeviceSchemaError("device_revocation_version_invalid"); } if (value.mode === "pending_revoke") { assertOnlyFields(value, [ "version", "mode", "device_id", "idempotency_key", "pending_revocation_proof", ]); return { mode: "pending_revoke", deviceId: deviceIdValue(value.device_id, "device_id"), idempotencyKey: idempotencyKeyValue(value.idempotency_key), pendingRevocationProof: signatureValue( value.pending_revocation_proof, "pending_revocation_proof", ), }; } if (value.mode !== "approved_rotate") { throw new DeviceSchemaError("device_revocation_mode_invalid"); } assertOnlyFields(value, [ "version", "mode", "device_id", "previous_key_id", "previous_generation", "new_key_id", "new_generation", "envelopes", "idempotency_key", "rotation_proof", ]); const deviceId = deviceIdValue(value.device_id, "device_id"); const previousKeyId = keyIdValue(value.previous_key_id); const previousGeneration = positiveInteger(value.previous_generation, "previous_generation"); const newKeyId = keyIdValue(value.new_key_id); const newGeneration = positiveInteger(value.new_generation, "new_generation"); if ( previousGeneration === Number.MAX_SAFE_INTEGER || newGeneration !== previousGeneration + 1 ) { throw new DeviceSchemaError("new_generation_invalid"); } if (newKeyId === previousKeyId) { throw new DeviceSchemaError("new_key_id_invalid"); } return { mode: "approved_rotate", deviceId, previousKeyId, previousGeneration, newKeyId, newGeneration, envelopes: revocationEnvelopes(value.envelopes, deviceId), idempotencyKey: idempotencyKeyValue(value.idempotency_key), rotationProof: signatureValue(value.rotation_proof, "rotation_proof"), }; } export async function deviceRevocationRequestHash( userId: string, approverDeviceId: string, revocation: ApprovedDeviceRevocationRequest, ): Promise { return sha256Hex(JSON.stringify({ version: 2, mode: revocation.mode, user_id: userId, approver_device_id: approverDeviceId, device_id: revocation.deviceId, previous_key_id: revocation.previousKeyId, previous_generation: revocation.previousGeneration, new_key_id: revocation.newKeyId, new_generation: revocation.newGeneration, envelopes: revocation.envelopes.map((item) => ({ recipient_device_id: item.recipientDeviceId, envelope: item.envelope, })), idempotency_key: revocation.idempotencyKey, rotation_proof: revocation.rotationProof, })); } export function deviceRevocationProofBytes( userId: string, approverDeviceId: string, revocation: Omit, ): Uint8Array { const values: (number | string)[] = [ "elydora-device-revocation-v2", userId, approverDeviceId, revocation.deviceId, revocation.previousKeyId, revocation.previousGeneration, revocation.newKeyId, revocation.newGeneration, revocation.idempotencyKey, revocation.envelopes.length, ]; for (const item of revocation.envelopes) { values.push( item.recipientDeviceId, item.envelope.version, item.envelope.suite, item.envelope.encapped_key, item.envelope.ciphertext, ); } return canonicalBytes(values); } export async function pendingDeviceRevocationRequestHash( userId: string, approverDeviceId: string, revocation: PendingDeviceRevocationRequest, ): Promise { return sha256Hex(JSON.stringify({ version: 2, mode: revocation.mode, user_id: userId, approver_device_id: approverDeviceId, device_id: revocation.deviceId, idempotency_key: revocation.idempotencyKey, pending_revocation_proof: revocation.pendingRevocationProof, })); } export function pendingDeviceRevocationProofBytes( userId: string, approverDeviceId: string, revocation: Omit, ): Uint8Array { return canonicalBytes([ "elydora-pending-device-revocation-v2", userId, approverDeviceId, revocation.deviceId, revocation.idempotencyKey, ]); } export function rotationEnvelopeIdempotencyKey( userId: string, rotationIdempotencyKey: string, recipientDeviceId: string, ): Promise { const encoder = new TextEncoder(); return sha256Hex([userId, rotationIdempotencyKey, recipientDeviceId] .map((value) => `${encoder.encode(value).byteLength}:${value}`) .join("")); } function revocationEnvelopes(value: unknown, targetDeviceId: string): DeviceRevocationEnvelopeRequest[] { if (!Array.isArray(value) || value.length < 1 || value.length > MAX_ROTATION_ENVELOPES) { throw new DeviceSchemaError("envelopes_invalid"); } const seen = new Set(); const envelopes = value.map((item, index) => { const record = requestRecord(item, `envelopes[${index}]`); assertOnlyFields(record, ["recipient_device_id", "envelope"]); const recipientDeviceId = deviceIdValue( record.recipient_device_id, `envelopes[${index}].recipient_device_id`, ); if (recipientDeviceId === targetDeviceId || seen.has(recipientDeviceId)) { throw new DeviceSchemaError("envelope_recipient_invalid"); } seen.add(recipientDeviceId); return { recipientDeviceId, envelope: wrappedAccountKey(record.envelope), }; }); envelopes.sort((left, right) => compareDeviceIds(left.recipientDeviceId, right.recipientDeviceId)); return envelopes; } function requestRecord(value: unknown, label: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) { throw new DeviceSchemaError(`${label}_invalid`); } return value as Record; } async function sha256Hex(value: string): Promise { const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); return [...new Uint8Array(digest)] .map((byte) => byte.toString(16).padStart(2, "0")) .join(""); } function canonicalBytes(values: (number | string)[]): Uint8Array { const encoder = new TextEncoder(); return encoder.encode(values.map((value) => { const text = value.toString(); return `${encoder.encode(text).byteLength}:${text}`; }).join("")); }