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

222 lines
8.0 KiB
TypeScript

import type { ElyD1PreparedStatement, Env } from "./bindings.js";
import {
type ApprovedDeviceRevocationRequest,
rotationEnvelopeIdempotencyKey,
} from "./device_revocation_schema.js";
import { DevicePersistenceError } from "./device_schema.js";
const ROTATION_R2_COUNT_QUERY = `
SELECT COUNT(*) AS object_count FROM (
SELECT payload_r2_key AS r2_key
FROM sync_objects
WHERE user_id = ? AND payload_r2_key IS NOT NULL
UNION
SELECT r2_key FROM sync_snapshots WHERE user_id = ?
)
`;
const ROTATION_RESULT_QUERY = `
SELECT
rotation.target_device_id, rotation.approver_device_id,
rotation.previous_key_id, rotation.previous_generation,
rotation.new_key_id, rotation.new_generation, rotation.request_hash,
rotation.envelope_count, rotation.r2_object_count, rotation.completed_at,
account.current_key_id, account.current_generation,
target.approval_status AS target_status, target.revoked_at,
(SELECT COUNT(*)
FROM better_auth_session AS session
INNER JOIN better_auth_session_device_context AS context
ON context.session_id = session.id
WHERE context.user_id = rotation.user_id
AND context.device_id = rotation.target_device_id) AS active_session_count,
(SELECT COUNT(*) FROM sync_vault_rotation_envelopes AS item
WHERE item.user_id = rotation.user_id
AND item.rotation_idempotency_key = rotation.idempotency_key) AS item_count,
(SELECT COUNT(*) FROM sync_vault_rotation_r2_objects AS item
WHERE item.user_id = rotation.user_id
AND item.rotation_idempotency_key = rotation.idempotency_key) AS r2_item_count,
(SELECT COUNT(*)
FROM sync_vault_rotation_envelopes AS item
INNER JOIN sync_vault_envelopes AS envelope
ON envelope.user_id = item.user_id
AND envelope.recipient_device_id = item.recipient_device_id
AND envelope.key_id = rotation.new_key_id
AND envelope.generation = rotation.new_generation
AND envelope.approver_device_id = rotation.approver_device_id
AND envelope.envelope_version = item.envelope_version
AND envelope.suite = item.suite
AND envelope.encapped_key = item.encapped_key
AND envelope.ciphertext = item.ciphertext
AND envelope.idempotency_key = item.envelope_idempotency_key
WHERE item.user_id = rotation.user_id
AND item.rotation_idempotency_key = rotation.idempotency_key) AS persisted_count,
(SELECT COUNT(*) FROM audit_events AS audit
WHERE audit.event_id = rotation.audit_event_id
AND audit.user_id = rotation.user_id
AND audit.actor_device_id = rotation.approver_device_id
AND audit.event_type = 'device.revoke'
AND audit.subject_id = rotation.target_device_id
AND audit.outcome = 'success'
AND audit.metadata_hash = rotation.request_hash
AND audit.created_at = rotation.completed_at) AS audit_count
FROM sync_vault_rotations AS rotation
LEFT JOIN sync_vault_accounts AS account ON account.user_id = rotation.user_id
LEFT JOIN user_devices AS target
ON target.user_id = rotation.user_id AND target.device_id = rotation.target_device_id
WHERE rotation.user_id = ? AND rotation.idempotency_key = ?
`;
const ROTATION_INSERT_QUERY = `
INSERT INTO sync_vault_rotations (
user_id, idempotency_key, audit_event_id, target_device_id, approver_device_id,
previous_key_id, previous_generation, new_key_id, new_generation,
request_hash, envelope_count, r2_object_count, created_at, completed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
ON CONFLICT(user_id, idempotency_key) DO NOTHING
`;
const ROTATION_ENVELOPE_INSERT_QUERY = `
INSERT INTO sync_vault_rotation_envelopes (
user_id, rotation_idempotency_key, recipient_device_id,
envelope_idempotency_key, envelope_version, suite, encapped_key, ciphertext
)
SELECT ?, ?, ?, ?, ?, ?, ?, ?
FROM sync_vault_rotations AS rotation
WHERE rotation.user_id = ? AND rotation.idempotency_key = ?
AND rotation.completed_at IS NULL
AND rotation.target_device_id = ? AND rotation.approver_device_id = ?
AND rotation.previous_key_id = ? AND rotation.previous_generation = ?
AND rotation.new_key_id = ? AND rotation.new_generation = ?
AND rotation.request_hash = ?
ON CONFLICT DO NOTHING
`;
const ROTATION_FINALIZE_QUERY = `
UPDATE sync_vault_rotations
SET completed_at = ?
WHERE user_id = ? AND idempotency_key = ? AND completed_at IS NULL
AND target_device_id = ? AND approver_device_id = ?
AND previous_key_id = ? AND previous_generation = ?
AND new_key_id = ? AND new_generation = ?
AND request_hash = ? AND envelope_count = ? AND r2_object_count = ?
`;
interface R2CountRow { object_count: unknown }
export interface RotationResultRow {
target_device_id: unknown;
approver_device_id: unknown;
previous_key_id: unknown;
previous_generation: unknown;
new_key_id: unknown;
new_generation: unknown;
request_hash: unknown;
envelope_count: unknown;
r2_object_count: unknown;
completed_at: unknown;
current_key_id: unknown;
current_generation: unknown;
target_status: unknown;
revoked_at: unknown;
active_session_count: unknown;
item_count: unknown;
r2_item_count: unknown;
persisted_count: unknown;
audit_count: unknown;
}
export async function rotationR2ObjectCount(env: Env, userId: string): Promise<number> {
const rows = await env.ELY_DB.prepare(ROTATION_R2_COUNT_QUERY)
.bind(userId, userId)
.all<R2CountRow>();
const count = rows.results[0]?.object_count;
if (rows.results.length !== 1 || typeof count !== "number" || !Number.isSafeInteger(count) || count < 0) {
throw new DevicePersistenceError("device_revocation_r2_count_invalid");
}
return count;
}
export function rotationResult(
env: Env,
userId: string,
idempotencyKey: string,
): Promise<RotationResultRow | null> {
return env.ELY_DB.prepare(ROTATION_RESULT_QUERY)
.bind(userId, idempotencyKey)
.first<RotationResultRow>();
}
export async function rotationStatements(
env: Env,
userId: string,
approverDeviceId: string,
revocation: ApprovedDeviceRevocationRequest,
requestHash: string,
r2ObjectCount: number,
nowSeconds: number,
): Promise<ElyD1PreparedStatement[]> {
const statements = [env.ELY_DB.prepare(ROTATION_INSERT_QUERY).bind(
userId,
revocation.idempotencyKey,
`device-revoke:${requestHash}`,
revocation.deviceId,
approverDeviceId,
revocation.previousKeyId,
revocation.previousGeneration,
revocation.newKeyId,
revocation.newGeneration,
requestHash,
revocation.envelopes.length,
r2ObjectCount,
nowSeconds,
)];
const envelopeIds = await Promise.all(revocation.envelopes.map((item) =>
rotationEnvelopeIdempotencyKey(userId, revocation.idempotencyKey, item.recipientDeviceId)
));
revocation.envelopes.forEach((item, index) => statements.push(
guardedEnvelopeStatement(
env, userId, approverDeviceId, revocation, requestHash, envelopeIds[index]!, item,
),
));
statements.push(env.ELY_DB.prepare(ROTATION_FINALIZE_QUERY).bind(
nowSeconds,
userId,
revocation.idempotencyKey,
revocation.deviceId,
approverDeviceId,
revocation.previousKeyId,
revocation.previousGeneration,
revocation.newKeyId,
revocation.newGeneration,
requestHash,
revocation.envelopes.length,
r2ObjectCount,
));
return statements;
}
function guardedEnvelopeStatement(
env: Env,
userId: string,
approverDeviceId: string,
revocation: ApprovedDeviceRevocationRequest,
requestHash: string,
envelopeId: string,
item: ApprovedDeviceRevocationRequest["envelopes"][number],
): ElyD1PreparedStatement {
return env.ELY_DB.prepare(ROTATION_ENVELOPE_INSERT_QUERY).bind(
userId,
revocation.idempotencyKey,
item.recipientDeviceId,
envelopeId,
item.envelope.version,
item.envelope.suite,
item.envelope.encapped_key,
item.envelope.ciphertext,
userId,
revocation.idempotencyKey,
revocation.deviceId,
approverDeviceId,
revocation.previousKeyId,
revocation.previousGeneration,
revocation.newKeyId,
revocation.newGeneration,
requestHash,
);
}