import type { AuthContext } from "./auth.js"; import type { Env } from "./bindings.js"; import { assertDeviceApprovalProof, assertFreshDeviceApprovalProof, } from "./device_approval_proof.js"; import { type DeviceApprovalDocument, type DeviceApprovalRequest, type DeviceApprovalRow, type DeviceRow, DeviceConflictError, DevicePermissionError, DevicePersistenceError, DeviceSchemaError, approvedDeviceDocument, currentDeviceId, deviceApprovalRequest, deviceDocument, deviceIdValue, idempotencyKeyValue, keyIdValue, positiveInteger, publicKeyValue, timestamp, wrappedAccountKey, } from "./device_schema.js"; import { syncVaultRecipientEnvelopeStatement } from "./sync_vault.js"; const STORED_APPROVAL_STATUSES = new Set(["pending", "approved", "rejected", "expired"]); const APPROVED_REQUESTER_QUERY = ` SELECT device.device_id, keys.signing_public_key FROM user_devices AS device INNER JOIN user_device_keys AS keys ON keys.user_id = device.user_id AND keys.device_id = device.device_id WHERE device.user_id = ? AND device.device_id = ? AND device.approval_status = 'approved' AND device.revoked_at IS NULL AND keys.key_protocol_version = 2 AND keys.wrapping_public_key IS NOT NULL `; const DEVICE_BY_ID_QUERY = ` SELECT device.device_id, device.public_key, device.device_name, device.platform, device.approval_status, device.created_at, device.approved_at, device.last_active_at, device.revoked_at, keys.wrapping_public_key FROM user_devices AS device LEFT JOIN user_device_keys AS keys ON keys.user_id = device.user_id AND keys.device_id = device.device_id WHERE device.user_id = ? AND device.device_id = ? `; const DEVICE_APPROVAL_BY_IDEMPOTENCY_KEY_QUERY = ` SELECT device_id, requester_device_id, status, decided_at FROM device_approvals WHERE user_id = ? AND idempotency_key = ? `; const DEVICE_APPROVAL_INSERT_QUERY = ` INSERT INTO device_approvals ( user_id, approval_id, device_id, requester_device_id, status, requested_at, decided_at, expires_at, idempotency_key ) SELECT ?, ?, ?, ?, 'approved', ?, ?, ?, ? WHERE EXISTS ( SELECT 1 FROM sync_vault_accounts AS accounts INNER JOIN sync_vault_envelopes AS envelope ON envelope.user_id = accounts.user_id AND envelope.key_id = accounts.current_key_id AND envelope.generation = accounts.current_generation WHERE accounts.user_id = ? AND envelope.recipient_device_id = ? AND envelope.approver_device_id = ? AND envelope.key_id = ? AND envelope.generation = ? AND envelope.envelope_version = ? AND envelope.suite = ? AND envelope.encapped_key = ? AND envelope.ciphertext = ? AND envelope.idempotency_key = ? ) ON CONFLICT(user_id, idempotency_key) DO NOTHING `; const DEVICE_APPROVE_QUERY = ` UPDATE user_devices SET approval_status = 'approved', approved_at = COALESCE(approved_at, ?), last_active_at = ? WHERE user_id = ? AND device_id = ? AND approval_status = 'pending' AND revoked_at IS NULL AND EXISTS ( SELECT 1 FROM sync_vault_accounts AS accounts INNER JOIN sync_vault_envelopes AS envelope ON envelope.user_id = accounts.user_id AND envelope.key_id = accounts.current_key_id AND envelope.generation = accounts.current_generation WHERE accounts.user_id = ? AND envelope.recipient_device_id = ? AND envelope.approver_device_id = ? AND envelope.key_id = ? AND envelope.generation = ? AND envelope.envelope_version = ? AND envelope.suite = ? AND envelope.encapped_key = ? AND envelope.ciphertext = ? AND envelope.idempotency_key = ? ) `; const CURRENT_RECIPIENT_ENVELOPE_QUERY = ` SELECT accounts.current_key_id AS key_id, accounts.current_generation AS generation, envelope.recipient_device_id, envelope.approver_device_id, envelope.envelope_version, envelope.suite, envelope.encapped_key, envelope.ciphertext, envelope.idempotency_key FROM sync_vault_accounts AS accounts INNER JOIN sync_vault_envelopes AS envelope ON envelope.user_id = accounts.user_id AND envelope.key_id = accounts.current_key_id AND envelope.generation = accounts.current_generation WHERE accounts.user_id = ? AND envelope.recipient_device_id = ? `; interface CurrentRecipientEnvelopeRow { key_id: unknown; generation: unknown; recipient_device_id: unknown; approver_device_id: unknown; envelope_version: unknown; suite: unknown; encapped_key: unknown; ciphertext: unknown; idempotency_key: unknown; } interface ApprovedRequesterRow { device_id: unknown; signing_public_key: unknown; } export async function approveDeviceDocument( request: Request, env: Env, context: AuthContext, nowSeconds = Math.floor(Date.now() / 1000), ): Promise { const approval = await deviceApprovalRequest(request); const requesterDeviceId = currentDeviceId(context); if (requesterDeviceId === approval.deviceId) { throw new DevicePermissionError("device_self_approval_forbidden"); } const signingPublicKey = await approvedRequesterSigningKey( env, context.userId, requesterDeviceId, ); await assertDeviceApprovalProof( signingPublicKey, context.userId, requesterDeviceId, approval, ); const existingApproval = await env.ELY_DB.prepare(DEVICE_APPROVAL_BY_IDEMPOTENCY_KEY_QUERY) .bind(context.userId, approval.idempotencyKey) .first(); assertFreshDeviceApprovalProof(approval, nowSeconds, existingApproval === null); if (existingApproval !== null) { return existingApprovalDocument(env, context, approval, requesterDeviceId, existingApproval); } const pendingDevice = await deviceRowById(env, context.userId, approval.deviceId); if (pendingDevice === null) { throw new DevicePermissionError("device_not_found"); } const pendingDocument = storedDeviceDocument(pendingDevice, requesterDeviceId); if ( pendingDocument.approval_status !== "pending" || pendingDocument.revoked_at !== null || pendingDocument.wrapping_public_key === undefined ) { throw new DevicePermissionError("device_not_pending"); } const envelopeBindings = approvalEnvelopeBindings( context.userId, approval, requesterDeviceId, ); await env.ELY_DB.batch([ syncVaultRecipientEnvelopeStatement( env, context.userId, approval.deviceId, requesterDeviceId, approval.keyId, approval.generation, approval.envelope, approval.idempotencyKey, nowSeconds, ), env.ELY_DB.prepare(DEVICE_APPROVAL_INSERT_QUERY).bind( context.userId, approval.idempotencyKey, approval.deviceId, requesterDeviceId, nowSeconds, nowSeconds, nowSeconds, approval.idempotencyKey, ...envelopeBindings, ), env.ELY_DB.prepare(DEVICE_APPROVE_QUERY).bind( nowSeconds, nowSeconds, context.userId, approval.deviceId, ...envelopeBindings, ), ]); return approvedDeviceWithEnvelope(env, context, approval, requesterDeviceId, false); } async function existingApprovalDocument( env: Env, context: AuthContext, approval: DeviceApprovalRequest, requesterDeviceId: string, row: DeviceApprovalRow, ): Promise { const { approvedDeviceId, approvedByDeviceId, status, decidedAt } = storedApproval(row); if ( approvedDeviceId !== approval.deviceId || approvedByDeviceId !== requesterDeviceId || status !== "approved" ) { throw new DevicePermissionError("device_approval_replay_mismatch"); } if (decidedAt === null) { throw new DevicePersistenceError("device_approval_state_invalid"); } const document = await approvedDeviceWithEnvelope( env, context, approval, requesterDeviceId, true, ); return { ...document, approved_at: decidedAt }; } async function approvedDeviceWithEnvelope( env: Env, context: AuthContext, approval: DeviceApprovalRequest, requesterDeviceId: string, replay: boolean, ): Promise { const approvedDevice = await deviceRowById(env, context.userId, approval.deviceId); if (approvedDevice === null) { throw new DevicePersistenceError("device_approval_missing"); } const device = storedDeviceDocument(approvedDevice, requesterDeviceId); if ( device.approval_status !== "approved" || device.approved_at === null || device.wrapping_public_key === undefined ) { if (device.approval_status === "approved" && device.wrapping_public_key === undefined) { throw new DevicePersistenceError("device_approval_state_invalid"); } throw approvalMismatch(replay); } const envelope = await env.ELY_DB.prepare(CURRENT_RECIPIENT_ENVELOPE_QUERY) .bind(context.userId, approval.deviceId) .first(); if ( envelope === null || !approvalEnvelopeMatches(storedApprovalEnvelope(envelope), approval, requesterDeviceId) ) { throw approvalMismatch(replay); } try { return approvedDeviceDocument(context.userId, requesterDeviceId, approvedDevice); } catch (error) { throw storedApprovalError(error); } } function approvalEnvelopeBindings( userId: string, approval: DeviceApprovalRequest, requesterDeviceId: string, ): unknown[] { return [ userId, approval.deviceId, requesterDeviceId, approval.keyId, approval.generation, approval.envelope.version, approval.envelope.suite, approval.envelope.encapped_key, approval.envelope.ciphertext, approval.idempotencyKey, ]; } function approvalEnvelopeMatches( row: CurrentRecipientEnvelopeRow, approval: DeviceApprovalRequest, requesterDeviceId: string, ): boolean { return ( row.key_id === approval.keyId && row.generation === approval.generation && row.recipient_device_id === approval.deviceId && row.approver_device_id === requesterDeviceId && row.envelope_version === approval.envelope.version && row.suite === approval.envelope.suite && row.encapped_key === approval.envelope.encapped_key && row.ciphertext === approval.envelope.ciphertext && row.idempotency_key === approval.idempotencyKey ); } function approvalMismatch(replay: boolean): Error { return replay ? new DevicePermissionError("device_approval_replay_mismatch") : new DeviceConflictError("device_approval_envelope_conflict"); } function storedApproval(row: DeviceApprovalRow): { approvedDeviceId: string; approvedByDeviceId: string; status: string; decidedAt: number | null; } { try { if (typeof row.status !== "string" || !STORED_APPROVAL_STATUSES.has(row.status)) { throw new DeviceSchemaError("approval_status_invalid"); } return { approvedDeviceId: deviceIdValue(row.device_id, "device_id"), approvedByDeviceId: deviceIdValue(row.requester_device_id, "requester_device_id"), status: row.status, decidedAt: row.decided_at === null ? null : timestamp(row.decided_at, "decided_at"), }; } catch (error) { throw storedApprovalError(error); } } function storedDeviceDocument(row: DeviceRow, requesterDeviceId: string) { try { return deviceDocument(row, requesterDeviceId); } catch (error) { throw storedApprovalError(error); } } function storedApprovalEnvelope(row: CurrentRecipientEnvelopeRow): CurrentRecipientEnvelopeRow { try { keyIdValue(row.key_id); positiveInteger(row.generation, "generation"); deviceIdValue(row.recipient_device_id, "recipient_device_id"); deviceIdValue(row.approver_device_id, "approver_device_id"); wrappedAccountKey({ version: row.envelope_version, suite: row.suite, encapped_key: row.encapped_key, ciphertext: row.ciphertext, }); idempotencyKeyValue(row.idempotency_key); return row; } catch (error) { throw storedApprovalError(error); } } function storedApprovalError(error: unknown): Error { return error instanceof DeviceSchemaError ? new DevicePersistenceError("device_approval_state_invalid") : error as Error; } async function approvedRequesterSigningKey( env: Env, userId: string, requesterDeviceId: string, ): Promise { const requester = await env.ELY_DB.prepare(APPROVED_REQUESTER_QUERY) .bind(userId, requesterDeviceId) .first(); if (requester === null) { throw new DevicePermissionError("requester_device_unapproved"); } try { return publicKeyValue(requester.signing_public_key, "signing_public_key"); } catch (error) { if (error instanceof DeviceSchemaError) { throw new DevicePersistenceError("requester_signing_key_invalid"); } throw error; } } function deviceRowById(env: Env, userId: string, deviceId: string): Promise { return env.ELY_DB.prepare(DEVICE_BY_ID_QUERY).bind(userId, deviceId).first(); }