Files
ELY-Browser/cloudflare/src/device_approval.ts
T

407 lines
13 KiB
TypeScript

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<DeviceApprovalDocument> {
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<DeviceApprovalRow>();
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<DeviceApprovalDocument> {
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<DeviceApprovalDocument> {
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<CurrentRecipientEnvelopeRow>();
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<string> {
const requester = await env.ELY_DB.prepare(APPROVED_REQUESTER_QUERY)
.bind(userId, requesterDeviceId)
.first<ApprovedRequesterRow>();
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<DeviceRow | null> {
return env.ELY_DB.prepare(DEVICE_BY_ID_QUERY).bind(userId, deviceId).first<DeviceRow>();
}