fix(sync): secure encrypted snapshot lifecycle

This commit is contained in:
2026-07-10 06:24:53 -04:00
parent 556c5ff624
commit 540b901fd6
106 changed files with 18026 additions and 3309 deletions
+195 -79
View File
@@ -1,12 +1,31 @@
import type { AuthContext } from "./auth.js";
import { authSessionCacheKvKey } from "./auth.js";
import type { ElyD1PreparedStatement, Env } from "./bindings.js";
import { StorageObjectError, deleteKnownObject } from "./storage.js";
import type { ElyD1DatabaseSession, ElyD1PreparedStatement, ElyD1Result, Env } from "./bindings.js";
import { primaryD1Session } from "./bindings.js";
import {
assertDestructiveActionGateResult,
destructiveActionGateIsLive,
destructiveActionGateStatement,
} from "./destructive_action_gate.js";
import { deleteLegacySessionKeys } from "./legacy_auth_kv_cleanup.js";
import {
type RecentDeviceActionProof,
RecentDeviceActionPermissionError,
RecentDeviceActionRequestError,
assertFreshDeviceActionProof,
assertRecentDeviceActionProof,
recentDeviceActionProof,
recentDeviceActionRequestHash,
} from "./recent_device_action_proof.js";
import {
SYNC_R2_ANONYMIZE_USER_QUERY,
SYNC_R2_FENCE_USER_QUERY,
collectSyncR2Garbage,
} from "./sync_r2_gc.js";
const ACCOUNT_DELETION_CONFIRMATION = "delete-elydora-account";
const IDEMPOTENCY_KEY_PATTERN = /^[a-zA-Z0-9._:-]{16,128}$/;
const ACCOUNT_DELETION_EVENT_QUERY = `
SELECT actor_device_id, outcome, subject_id, created_at
SELECT actor_device_id, outcome, subject_id, metadata_hash, created_at
FROM audit_events
WHERE event_id = ? AND event_type = 'account.delete'
`;
@@ -25,19 +44,24 @@ const ACCOUNT_DELETION_COUNTS_QUERY = `
(SELECT COUNT(*) FROM better_auth_user WHERE id = ?) AS users
`;
const ACCOUNT_DELETION_R2_KEYS_QUERY = `
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 = ?
SELECT r2_key FROM sync_r2_gc_candidates
WHERE user_id = ? AND state <> 'deleted'
ORDER BY r2_key ASC
`;
const ACCOUNT_DELETION_SESSION_TOKENS_QUERY = `
SELECT token FROM better_auth_session
WHERE userId = ?
ORDER BY id ASC
`;
const DELETE_SYNC_CHANGE_LOG_QUERY = "DELETE FROM sync_change_log WHERE user_id = ?";
const DELETE_SYNC_TOMBSTONES_QUERY = "DELETE FROM sync_tombstones WHERE user_id = ?";
const DELETE_SYNC_SNAPSHOT_HEADS_QUERY = "DELETE FROM sync_snapshot_heads WHERE user_id = ?";
const DELETE_SYNC_SNAPSHOT_ENCRYPTION_QUERY =
"DELETE FROM sync_snapshot_encryption WHERE user_id = ?";
const DELETE_SYNC_SNAPSHOTS_QUERY = "DELETE FROM sync_snapshots WHERE user_id = ?";
const DELETE_SYNC_OBJECTS_QUERY = "DELETE FROM sync_objects WHERE user_id = ?";
const DELETE_SYNC_VAULT_ENVELOPES_QUERY = "DELETE FROM sync_vault_envelopes WHERE user_id = ?";
const DELETE_SYNC_VAULT_ACCOUNTS_QUERY = "DELETE FROM sync_vault_accounts WHERE user_id = ?";
const DELETE_DEVICE_APPROVALS_QUERY = "DELETE FROM device_approvals WHERE user_id = ?";
const DELETE_USER_DEVICES_QUERY = "DELETE FROM user_devices WHERE user_id = ?";
const DELETE_SESSION_DEVICE_CONTEXTS_QUERY =
@@ -46,20 +70,6 @@ const DELETE_BETTER_AUTH_SESSIONS_QUERY = "DELETE FROM better_auth_session WHERE
const DELETE_BETTER_AUTH_ACCOUNTS_QUERY = "DELETE FROM better_auth_account WHERE userId = ?";
const DELETE_BETTER_AUTH_USER_QUERY = "DELETE FROM better_auth_user WHERE id = ?";
const DELETE_USER_AUDIT_EVENTS_QUERY = "DELETE FROM audit_events WHERE user_id = ?";
const ACCOUNT_DELETION_AUDIT_INSERT_QUERY = `
INSERT INTO audit_events (
event_id,
user_id,
actor_device_id,
event_type,
subject_type,
subject_id,
outcome,
metadata_hash,
created_at
) VALUES (?, NULL, ?, 'account.delete', 'account', ?, 'success', ?, ?)
ON CONFLICT(event_id) DO NOTHING
`;
export interface AccountDeletionDocument {
version: 1;
@@ -86,7 +96,7 @@ export interface AccountDeletionDeletedDocument {
kv_session_cache: number;
}
interface AccountDeletionRequest {
interface AccountDeletionRequest extends RecentDeviceActionProof {
idempotencyKey: string;
}
@@ -94,6 +104,7 @@ interface AccountDeletionEventRow {
actor_device_id: unknown;
outcome: unknown;
subject_id: unknown;
metadata_hash: unknown;
created_at: unknown;
}
@@ -114,6 +125,7 @@ interface AccountDeletionCountsRow {
interface AccountDeletionR2KeyRow {
r2_key: unknown;
}
interface AccountDeletionSessionTokenRow { token: unknown }
type RequestBody = Record<string, unknown>;
@@ -141,32 +153,77 @@ export async function accountDeletionDocument(
const deletion = await accountDeletionRequest(request);
const accountHash = await sha256Hex(textBytes(context.userId));
const idempotencyHash = await sha256Hex(textBytes(deletion.idempotencyKey));
const requestHash = await recentDeviceActionRequestHash({
action: "account.delete",
userId: context.userId,
sessionId: context.sessionId,
deviceId,
confirmation: ACCOUNT_DELETION_CONFIRMATION,
idempotencyKey: deletion.idempotencyKey,
proofCreatedAt: deletion.proofCreatedAt,
actionProof: deletion.actionProof,
});
const eventId = accountDeletionEventId(accountHash, idempotencyHash);
const existingEvent = await env.ELY_DB.prepare(ACCOUNT_DELETION_EVENT_QUERY)
const database = primaryD1Session(env.ELY_DB);
const existingEvent = await database.prepare(ACCOUNT_DELETION_EVENT_QUERY)
.bind(eventId)
.first<AccountDeletionEventRow>();
if (existingEvent !== null) {
return existingDeletionDocument(accountHash, deviceId, deletion, existingEvent);
return existingDeletionDocument(accountHash, deviceId, requestHash, deletion, existingEvent);
}
const signingPublicKey = await assertRecentDeviceActionProof(
database,
context,
"account.delete",
ACCOUNT_DELETION_CONFIRMATION,
deletion.idempotencyKey,
deletion,
);
assertFreshDeviceActionProof(deletion, nowSeconds, true);
const counts = await accountDeletionCounts(env, context.userId);
const r2Keys = await accountDeletionR2Keys(env, context.userId);
for (const key of r2Keys) {
await deleteAccountObject(env, key);
}
await env.ELY_DB.batch(
const counts = await accountDeletionCounts(database, context.userId);
const r2Keys = await accountDeletionR2Keys(database, context.userId);
const sessionTokens = await accountDeletionSessionTokens(database, context.userId);
let results: ElyD1Result[];
try {
results = await database.batch<ElyD1Result>(
accountDeletionStatements(
env,
context.userId,
deviceId,
database,
context,
signingPublicKey,
accountHash,
idempotencyHash,
requestHash,
eventId,
nowSeconds,
),
);
await deleteCurrentSessionCache(env, context.tokenHash);
} catch (error) {
const replayDatabase = primaryD1Session(env.ELY_DB);
const racedEvent = await replayDatabase.prepare(ACCOUNT_DELETION_EVENT_QUERY)
.bind(eventId)
.first<AccountDeletionEventRow>();
if (racedEvent !== null) {
return existingDeletionDocument(accountHash, deviceId, requestHash, deletion, racedEvent);
}
if (!(await destructiveActionGateIsLive(
replayDatabase,
context,
signingPublicKey,
nowSeconds,
))) {
throw new RecentDeviceActionPermissionError("device_action_gate_failed");
}
throw error;
}
assertDestructiveActionGateResult(results[0]);
const kvSessionCache = await cleanupDeletedAccount(
env,
accountHash,
sessionTokens,
context.tokenHash,
nowSeconds,
r2Keys.length,
);
return {
version: 1,
@@ -174,20 +231,22 @@ export async function accountDeletionDocument(
device_id: deviceId,
idempotency_key: deletion.idempotencyKey,
deleted_at: nowSeconds,
deleted: { ...counts, r2_objects: r2Keys.length, kv_session_cache: 1 },
deleted: { ...counts, r2_objects: r2Keys.length, kv_session_cache: kvSessionCache },
};
}
function existingDeletionDocument(
accountHash: string,
deviceId: string,
requestHash: string,
deletion: AccountDeletionRequest,
row: AccountDeletionEventRow,
): AccountDeletionDocument {
if (
row.actor_device_id !== deviceId ||
row.outcome !== "success" ||
row.subject_id !== accountHash
row.subject_id !== accountHash ||
row.metadata_hash !== requestHash
) {
throw new AccountDeletionRequestError("account_deletion_replay_mismatch");
}
@@ -202,10 +261,10 @@ function existingDeletionDocument(
}
async function accountDeletionCounts(
env: Env,
database: ElyD1DatabaseSession,
userId: string,
): Promise<Omit<AccountDeletionDeletedDocument, "r2_objects" | "kv_session_cache">> {
const row = await env.ELY_DB.prepare(ACCOUNT_DELETION_COUNTS_QUERY)
const row = await database.prepare(ACCOUNT_DELETION_COUNTS_QUERY)
.bind(userId, userId, userId, userId, userId, userId, userId, userId, userId, userId, userId)
.first<AccountDeletionCountsRow>();
if (row === null) {
@@ -226,69 +285,126 @@ async function accountDeletionCounts(
};
}
async function accountDeletionR2Keys(env: Env, userId: string): Promise<string[]> {
const result = await env.ELY_DB.prepare(ACCOUNT_DELETION_R2_KEYS_QUERY)
.bind(userId, userId)
async function accountDeletionR2Keys(
database: ElyD1DatabaseSession,
userId: string,
): Promise<string[]> {
const result = await database.prepare(ACCOUNT_DELETION_R2_KEYS_QUERY)
.bind(userId)
.all<AccountDeletionR2KeyRow>();
return result.results.map(r2Key);
}
function accountDeletionStatements(
env: Env,
async function accountDeletionSessionTokens(
database: ElyD1DatabaseSession,
userId: string,
deviceId: string,
): Promise<string[]> {
const result = await database.prepare(ACCOUNT_DELETION_SESSION_TOKENS_QUERY)
.bind(userId)
.all<AccountDeletionSessionTokenRow>();
return result.results.map((row) => {
if (typeof row.token !== "string" || row.token.length === 0) {
throw new AccountDeletionPersistenceError("session_token_invalid");
}
return row.token;
});
}
function accountDeletionStatements(
database: ElyD1DatabaseSession,
context: AuthContext,
signingPublicKey: string,
accountHash: string,
idempotencyHash: string,
requestHash: string,
eventId: string,
nowSeconds: number,
): ElyD1PreparedStatement[] {
const userId = context.userId;
return [
env.ELY_DB.prepare(DELETE_SYNC_CHANGE_LOG_QUERY).bind(userId),
env.ELY_DB.prepare(DELETE_SYNC_TOMBSTONES_QUERY).bind(userId),
env.ELY_DB.prepare(DELETE_SYNC_SNAPSHOTS_QUERY).bind(userId),
env.ELY_DB.prepare(DELETE_SYNC_OBJECTS_QUERY).bind(userId),
env.ELY_DB.prepare(DELETE_DEVICE_APPROVALS_QUERY).bind(userId),
env.ELY_DB.prepare(DELETE_SESSION_DEVICE_CONTEXTS_QUERY).bind(userId),
env.ELY_DB.prepare(DELETE_USER_DEVICES_QUERY).bind(userId),
env.ELY_DB.prepare(DELETE_BETTER_AUTH_SESSIONS_QUERY).bind(userId),
env.ELY_DB.prepare(DELETE_BETTER_AUTH_ACCOUNTS_QUERY).bind(userId),
env.ELY_DB.prepare(DELETE_BETTER_AUTH_USER_QUERY).bind(userId),
env.ELY_DB.prepare(DELETE_USER_AUDIT_EVENTS_QUERY).bind(userId),
env.ELY_DB.prepare(ACCOUNT_DELETION_AUDIT_INSERT_QUERY).bind(
destructiveActionGateStatement(database, context, signingPublicKey, {
eventId,
deviceId,
accountHash,
idempotencyHash,
auditUserId: null,
eventType: "account.delete",
subjectType: "account",
subjectId: accountHash,
metadataHash: requestHash,
}, nowSeconds),
database.prepare(SYNC_R2_FENCE_USER_QUERY).bind(
nowSeconds,
nowSeconds,
nowSeconds,
userId,
),
database.prepare(DELETE_SYNC_CHANGE_LOG_QUERY).bind(userId),
database.prepare(DELETE_SYNC_TOMBSTONES_QUERY).bind(userId),
database.prepare(DELETE_SYNC_SNAPSHOT_HEADS_QUERY).bind(userId),
database.prepare(DELETE_SYNC_SNAPSHOT_ENCRYPTION_QUERY).bind(userId),
database.prepare(DELETE_SYNC_SNAPSHOTS_QUERY).bind(userId),
database.prepare(DELETE_SYNC_OBJECTS_QUERY).bind(userId),
database.prepare(DELETE_SYNC_VAULT_ENVELOPES_QUERY).bind(userId),
database.prepare(DELETE_SYNC_VAULT_ACCOUNTS_QUERY).bind(userId),
database.prepare(DELETE_DEVICE_APPROVALS_QUERY).bind(userId),
database.prepare(DELETE_SESSION_DEVICE_CONTEXTS_QUERY).bind(userId),
database.prepare(DELETE_USER_DEVICES_QUERY).bind(userId),
database.prepare(DELETE_BETTER_AUTH_SESSIONS_QUERY).bind(userId),
database.prepare(DELETE_BETTER_AUTH_ACCOUNTS_QUERY).bind(userId),
database.prepare(DELETE_BETTER_AUTH_USER_QUERY).bind(userId),
database.prepare(DELETE_USER_AUDIT_EVENTS_QUERY).bind(userId),
database.prepare(SYNC_R2_ANONYMIZE_USER_QUERY).bind(nowSeconds, userId, accountHash),
];
}
async function deleteAccountObject(env: Env, key: string): Promise<void> {
async function cleanupDeletedAccount(
env: Env,
accountHash: string,
sessionTokens: string[],
currentTokenHash: string,
nowSeconds: number,
candidateCount: number,
): Promise<number> {
try {
await deleteKnownObject(env.ELY_STORAGE, key);
} catch (error) {
if (error instanceof StorageObjectError) {
throw new AccountDeletionPersistenceError(error.message);
const maxBatches = Math.ceil(candidateCount / 100) + 1;
for (let batch = 0; batch < maxBatches; batch += 1) {
if (await collectSyncR2Garbage(env, nowSeconds, { ownerHash: accountHash, limit: 100 }) < 100) {
break;
}
}
throw error;
} catch {
// Scheduled maintenance drains the durable GC ledger.
}
try {
return await deleteLegacySessionKeys(env, sessionTokens, currentTokenHash);
} catch {
return 0;
}
}
function deleteCurrentSessionCache(env: Env, tokenHash: string): Promise<void> {
return env.ELY_KV.delete(authSessionCacheKvKey(env.ELY_ENVIRONMENT, tokenHash));
}
async function accountDeletionRequest(request: Request): Promise<AccountDeletionRequest> {
const body = await requestBody(request);
assertOnlyFields(body, ["version", "confirmation", "idempotency_key"]);
if (body.version !== 1) {
assertOnlyFields(body, [
"version",
"confirmation",
"idempotency_key",
"proof_created_at",
"action_proof",
]);
if (body.version !== 2) {
throw new AccountDeletionRequestError("version_invalid");
}
if (body.confirmation !== ACCOUNT_DELETION_CONFIRMATION) {
throw new AccountDeletionRequestError("confirmation_invalid");
}
return { idempotencyKey: idempotencyKey(body.idempotency_key) };
try {
return {
idempotencyKey: idempotencyKey(body.idempotency_key),
...recentDeviceActionProof(body.proof_created_at, body.action_proof),
};
} catch (error) {
if (error instanceof RecentDeviceActionRequestError) {
throw new AccountDeletionRequestError(error.message);
}
throw error;
}
}
async function requestBody(request: Request): Promise<RequestBody> {
+7 -3
View File
@@ -14,9 +14,13 @@ export type ApiHandler = () => Promise<Response>;
export type AuthenticatedApiHandler = (context: AuthContext) => Promise<Response>;
const APPROVED_DEVICE_QUERY = `
SELECT device_id
FROM user_devices
WHERE user_id = ? AND device_id = ? AND approval_status = 'approved' AND revoked_at IS NULL
SELECT device.device_id
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
`;
export async function withPublicApiControls(
+4
View File
@@ -10,6 +10,7 @@ const BETTER_AUTH_SESSION_QUERY = `
session.id,
session.userId,
session.expiresAt,
session.createdAt,
device_context.device_id AS deviceId
FROM better_auth_session AS session
LEFT JOIN better_auth_session_device_context AS device_context
@@ -22,6 +23,7 @@ export interface AuthContext {
sessionId: string;
tokenHash: string;
expiresAt: string;
createdAt: string;
deviceId?: string;
}
@@ -29,6 +31,7 @@ interface BetterAuthSessionRow extends Record<string, unknown> {
id: unknown;
userId: unknown;
expiresAt: unknown;
createdAt: unknown;
deviceId?: unknown;
}
@@ -111,6 +114,7 @@ async function readBetterAuthSessionContext(
sessionId: subjectId(stringField(row, "id"), "session_id"),
tokenHash,
expiresAt: timestampField(row, "expiresAt"),
createdAt: timestampField(row, "createdAt"),
};
const deviceId = optionalStringField(row, "deviceId");
if (deviceId !== undefined) {
+9 -1
View File
@@ -35,10 +35,18 @@ export interface ElyD1Result<T = unknown> {
};
}
export interface ElyD1Database {
export interface ElyD1DatabaseSession {
prepare(query: string): ElyD1PreparedStatement;
batch<T = unknown>(statements: ElyD1PreparedStatement[]): Promise<T[]>;
}
export interface ElyD1Database extends ElyD1DatabaseSession {
exec(query: string): Promise<unknown>;
withSession?(constraint: "first-primary"): ElyD1DatabaseSession;
}
export function primaryD1Session(database: ElyD1Database): ElyD1DatabaseSession {
return database.withSession?.("first-primary") ?? database;
}
export interface ElyRateLimit {
+143
View File
@@ -0,0 +1,143 @@
import type { AuthContext } from "./auth.js";
import type { ElyD1DatabaseSession, ElyD1PreparedStatement, ElyD1Result } from "./bindings.js";
const DESTRUCTIVE_ACTION_GATE_INSERT_QUERY = `
INSERT INTO audit_events (
event_id,
user_id,
actor_device_id,
event_type,
subject_type,
subject_id,
outcome,
metadata_hash,
created_at
) VALUES (
?, ?, ?, ?, ?, ?,
CASE WHEN EXISTS (
SELECT 1
FROM better_auth_session AS session
INNER JOIN better_auth_session_device_context AS session_device
ON session_device.session_id = session.id
AND session_device.user_id = session.userId
INNER JOIN user_devices AS device
ON device.user_id = session_device.user_id
AND device.device_id = session_device.device_id
INNER JOIN user_device_keys AS keys
ON keys.user_id = device.user_id
AND keys.device_id = device.device_id
WHERE session.id = ? AND session.userId = ?
AND session_device.device_id = ?
AND (
(typeof(session.expiresAt) = 'text' AND julianday(session.expiresAt) > julianday(?))
OR
(typeof(session.expiresAt) IN ('integer', 'real') AND session.expiresAt > ?)
)
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
AND keys.signing_public_key = ?
) THEN 'success' ELSE NULL END,
?, ?
)
`;
const DESTRUCTIVE_ACTION_LIVE_QUERY = `
SELECT 1 AS authorized
FROM better_auth_session AS session
INNER JOIN better_auth_session_device_context AS session_device
ON session_device.session_id = session.id
AND session_device.user_id = session.userId
INNER JOIN user_devices AS device
ON device.user_id = session_device.user_id
AND device.device_id = session_device.device_id
INNER JOIN user_device_keys AS keys
ON keys.user_id = device.user_id
AND keys.device_id = device.device_id
WHERE session.id = ? AND session.userId = ?
AND session_device.device_id = ?
AND (
(typeof(session.expiresAt) = 'text' AND julianday(session.expiresAt) > julianday(?))
OR
(typeof(session.expiresAt) IN ('integer', 'real') AND session.expiresAt > ?)
)
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
AND keys.signing_public_key = ?
`;
export interface DestructiveActionGate {
eventId: string;
auditUserId: string | null;
eventType: "account.delete" | "sync.reset";
subjectType: "account" | "sync";
subjectId: string;
metadataHash: string | null;
}
export function destructiveActionGateStatement(
database: ElyD1DatabaseSession,
context: AuthContext,
signingPublicKey: string,
gate: DestructiveActionGate,
nowSeconds: number,
): ElyD1PreparedStatement {
if (context.deviceId === undefined) {
throw new DestructiveActionGateError("device_context_required");
}
const now = new Date(nowSeconds * 1000);
if (!Number.isSafeInteger(nowSeconds) || nowSeconds < 0 || !Number.isFinite(now.getTime())) {
throw new DestructiveActionGateError("destructive_action_time_invalid");
}
return database.prepare(DESTRUCTIVE_ACTION_GATE_INSERT_QUERY).bind(
gate.eventId,
gate.auditUserId,
context.deviceId,
gate.eventType,
gate.subjectType,
gate.subjectId,
context.sessionId,
context.userId,
context.deviceId,
now.toISOString(),
now.getTime(),
signingPublicKey,
gate.metadataHash,
nowSeconds,
);
}
export function assertDestructiveActionGateResult(result: unknown): void {
if (changedRows(result) !== 1) {
throw new DestructiveActionGateError("destructive_action_gate_failed");
}
}
export async function destructiveActionGateIsLive(
database: ElyD1DatabaseSession,
context: AuthContext,
signingPublicKey: string,
nowSeconds: number,
): Promise<boolean> {
if (context.deviceId === undefined) return false;
const now = new Date(nowSeconds * 1000);
if (!Number.isSafeInteger(nowSeconds) || nowSeconds < 0 || !Number.isFinite(now.getTime())) {
throw new DestructiveActionGateError("destructive_action_time_invalid");
}
const row = await database.prepare(DESTRUCTIVE_ACTION_LIVE_QUERY).bind(
context.sessionId,
context.userId,
context.deviceId,
now.toISOString(),
now.getTime(),
signingPublicKey,
).first<{ authorized: unknown }>();
return row?.authorized === 1;
}
export class DestructiveActionGateError extends Error {}
function changedRows(result: unknown): number {
if (typeof result !== "object" || result === null || !("meta" in result)) return -1;
const changes = (result as ElyD1Result).meta?.changes;
return typeof changes === "number" && Number.isSafeInteger(changes) ? changes : -1;
}
+406
View File
@@ -0,0 +1,406 @@
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>();
}
+65
View File
@@ -0,0 +1,65 @@
import { verifyEd25519Signature } from "./device_crypto.js";
import {
type DeviceApprovalRequest,
DevicePermissionError,
} from "./device_schema.js";
const PROOF_MAX_AGE_SECONDS = 5 * 60;
const PROOF_CLOCK_SKEW_SECONDS = 30;
export async function assertDeviceApprovalProof(
signingPublicKey: string,
userId: string,
approverDeviceId: string,
approval: DeviceApprovalRequest,
): Promise<void> {
if (!(await verifyEd25519Signature(
signingPublicKey,
approval.approvalProof,
deviceApprovalProofBytes(userId, approverDeviceId, approval),
))) {
throw new DevicePermissionError("device_approval_proof_invalid");
}
}
export function assertFreshDeviceApprovalProof(
approval: DeviceApprovalRequest,
nowSeconds: number,
required: boolean,
): void {
if (required && (
approval.proofCreatedAt < nowSeconds - PROOF_MAX_AGE_SECONDS ||
approval.proofCreatedAt > nowSeconds + PROOF_CLOCK_SKEW_SECONDS
)) {
throw new DevicePermissionError("device_approval_proof_expired");
}
}
export function deviceApprovalProofBytes(
userId: string,
approverDeviceId: string,
approval: Omit<DeviceApprovalRequest, "approvalProof">,
): Uint8Array {
return canonicalBytes([
"elydora-device-approval-v2",
userId,
approverDeviceId,
approval.deviceId,
approval.keyId,
approval.generation,
approval.envelope.version,
approval.envelope.suite,
approval.envelope.encapped_key,
approval.envelope.ciphertext,
approval.idempotencyKey,
approval.proofCreatedAt,
]);
}
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(""));
}
+31
View File
@@ -0,0 +1,31 @@
export async function verifyEd25519Signature(
publicKey: string,
signature: string,
message: Uint8Array,
): Promise<boolean> {
try {
const key = await crypto.subtle.importKey(
"raw",
hexBytes(publicKey),
{ name: "Ed25519" },
false,
["verify"],
);
return crypto.subtle.verify(
{ name: "Ed25519" },
key,
hexBytes(signature),
message,
);
} catch {
return false;
}
}
function hexBytes(value: string): Uint8Array {
const bytes = new Uint8Array(value.length / 2);
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
}
return bytes;
}
+345
View File
@@ -0,0 +1,345 @@
import type { AuthContext } from "./auth.js";
import type { Env } from "./bindings.js";
import { verifyEd25519Signature } from "./device_crypto.js";
import {
DeviceConflictError,
DevicePermissionError,
DevicePersistenceError,
DeviceSchemaError,
assertOnlyFields,
deviceIdValue,
deviceRequestBody,
publicKeyValue,
signatureValue,
timestamp,
} from "./device_schema.js";
const CHALLENGE_TTL_SECONDS = 300;
const CHALLENGE_ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const APPROVED_DEVICE_KEY_QUERY = `
SELECT 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
`;
const CHALLENGE_UPSERT_QUERY = `
INSERT INTO device_rebind_challenges (
challenge_id,
user_id,
session_id,
device_id,
challenge,
created_at,
expires_at,
consumed_at,
consumption_nonce
)
SELECT ?, ?, ?, ?, ?, ?, ?, NULL, NULL
WHERE EXISTS (
SELECT 1 FROM better_auth_session WHERE id = ? AND userId = ?
)
AND NOT EXISTS (
SELECT 1 FROM better_auth_session_device_context WHERE session_id = ?
)
ON CONFLICT(session_id) DO UPDATE SET
challenge_id = excluded.challenge_id,
user_id = excluded.user_id,
device_id = excluded.device_id,
challenge = excluded.challenge,
created_at = excluded.created_at,
expires_at = excluded.expires_at,
consumed_at = NULL,
consumption_nonce = NULL
`;
const CHALLENGE_QUERY = `
SELECT
rebind.challenge,
rebind.expires_at,
keys.signing_public_key
FROM device_rebind_challenges AS rebind
INNER JOIN user_devices AS device
ON device.user_id = rebind.user_id AND device.device_id = rebind.device_id
INNER JOIN user_device_keys AS keys
ON keys.user_id = device.user_id AND keys.device_id = device.device_id
WHERE rebind.challenge_id = ? AND rebind.user_id = ?
AND rebind.session_id = ? AND rebind.device_id = ?
AND rebind.consumed_at IS NULL
AND device.approval_status = 'approved' AND device.revoked_at IS NULL
AND keys.key_protocol_version = 2
`;
const CHALLENGE_CONSUME_QUERY = `
UPDATE device_rebind_challenges
SET consumed_at = ?, consumption_nonce = ?
WHERE challenge_id = ? AND user_id = ? AND session_id = ? AND device_id = ?
AND consumed_at IS NULL AND expires_at > ?
AND NOT EXISTS (
SELECT 1 FROM better_auth_session_device_context WHERE session_id = ?
)
AND EXISTS (
SELECT 1
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
)
`;
const SESSION_BIND_QUERY = `
INSERT INTO better_auth_session_device_context (
session_id,
user_id,
device_id,
updated_at
)
SELECT rebind.session_id, rebind.user_id, rebind.device_id, ?
FROM device_rebind_challenges AS rebind
WHERE rebind.challenge_id = ? AND rebind.consumption_nonce = ?
AND rebind.consumed_at = ?
AND EXISTS (
SELECT 1 FROM better_auth_session
WHERE id = rebind.session_id AND userId = rebind.user_id
)
ON CONFLICT(session_id) DO NOTHING
`;
interface ApprovedDeviceKeyRow {
signing_public_key: unknown;
}
interface RebindChallengeRow extends ApprovedDeviceKeyRow {
challenge: unknown;
expires_at: unknown;
}
export interface DeviceRebindChallengeDocument {
version: 1;
challenge_id: string;
device_id: string;
challenge: string;
expires_at: number;
}
export interface DeviceRebindDocument {
version: 1;
user_id: string;
session_id: string;
device_id: string;
bound_at: number;
}
export async function issueDeviceRebindChallenge(
request: Request,
env: Env,
context: AuthContext,
nowSeconds = Math.floor(Date.now() / 1000),
): Promise<DeviceRebindChallengeDocument> {
const deviceId = await rebindChallengeDeviceId(request);
assertUnboundSession(context);
const keyRow = await env.ELY_DB.prepare(APPROVED_DEVICE_KEY_QUERY)
.bind(context.userId, deviceId)
.first<ApprovedDeviceKeyRow>();
if (keyRow === null) {
throw new DevicePermissionError("device_rebind_unavailable");
}
publicKeyValue(keyRow.signing_public_key, "signing_public_key");
const challengeId = crypto.randomUUID();
const expiresAt = nowSeconds + CHALLENGE_TTL_SECONDS;
const challenge = canonicalChallenge(
challengeId,
context.userId,
context.sessionId,
deviceId,
expiresAt,
randomHex(32),
);
const result = await env.ELY_DB.prepare(CHALLENGE_UPSERT_QUERY)
.bind(
challengeId,
context.userId,
context.sessionId,
deviceId,
challenge,
nowSeconds,
expiresAt,
context.sessionId,
context.userId,
context.sessionId,
)
.run();
if (changedRowCount(result) !== 1) {
throw new DevicePersistenceError("device_rebind_challenge_write_failed");
}
return { version: 1, challenge_id: challengeId, device_id: deviceId, challenge, expires_at: expiresAt };
}
export async function rebindDeviceSession(
request: Request,
env: Env,
context: AuthContext,
nowSeconds = Math.floor(Date.now() / 1000),
): Promise<DeviceRebindDocument> {
const rebind = await rebindRequest(request);
assertUnboundSession(context);
const row = await env.ELY_DB.prepare(CHALLENGE_QUERY)
.bind(rebind.challengeId, context.userId, context.sessionId, rebind.deviceId)
.first<RebindChallengeRow>();
if (row === null) {
throw new DevicePermissionError("device_rebind_forbidden");
}
const expiresAt = timestamp(row.expires_at, "expires_at");
if (expiresAt <= nowSeconds) {
throw new DevicePermissionError("device_rebind_challenge_expired");
}
const challenge = challengeValue(row.challenge);
assertCanonicalChallenge(challenge, rebind.challengeId, context, rebind.deviceId, expiresAt);
const signingPublicKey = publicKeyValue(row.signing_public_key, "signing_public_key");
if (
!(await verifyEd25519Signature(
signingPublicKey,
rebind.signature,
new TextEncoder().encode(challenge),
))
) {
throw new DevicePermissionError("device_rebind_signature_invalid");
}
const consumptionNonce = randomHex(32);
const [consumeResult, bindResult] = await env.ELY_DB.batch([
env.ELY_DB.prepare(CHALLENGE_CONSUME_QUERY).bind(
nowSeconds,
consumptionNonce,
rebind.challengeId,
context.userId,
context.sessionId,
rebind.deviceId,
nowSeconds,
context.sessionId,
context.userId,
rebind.deviceId,
),
env.ELY_DB.prepare(SESSION_BIND_QUERY).bind(
nowSeconds,
rebind.challengeId,
consumptionNonce,
nowSeconds,
),
]);
if (changedRowCount(consumeResult) !== 1 || changedRowCount(bindResult) !== 1) {
throw new DeviceConflictError("device_rebind_challenge_consumed");
}
return {
version: 1,
user_id: context.userId,
session_id: context.sessionId,
device_id: rebind.deviceId,
bound_at: nowSeconds,
};
}
async function rebindChallengeDeviceId(request: Request): Promise<string> {
const value = await deviceRequestBody(request, "device_rebind_challenge");
assertOnlyFields(value, ["version", "device_id"]);
if (value.version !== 1) {
throw new DeviceSchemaError("device_rebind_challenge_version_invalid");
}
return deviceIdValue(value.device_id, "device_id");
}
async function rebindRequest(
request: Request,
): Promise<{ challengeId: string; deviceId: string; signature: string }> {
const value = await deviceRequestBody(request, "device_rebind");
assertOnlyFields(value, ["version", "challenge_id", "device_id", "signature"]);
if (value.version !== 1) {
throw new DeviceSchemaError("device_rebind_version_invalid");
}
if (typeof value.challenge_id !== "string" || !CHALLENGE_ID_PATTERN.test(value.challenge_id)) {
throw new DeviceSchemaError("challenge_id_invalid");
}
return {
challengeId: value.challenge_id,
deviceId: deviceIdValue(value.device_id, "device_id"),
signature: signatureValue(value.signature, "signature"),
};
}
function assertUnboundSession(context: AuthContext): void {
if (context.deviceId !== undefined) {
throw new DevicePermissionError("device_context_already_bound");
}
}
function canonicalChallenge(
challengeId: string,
userId: string,
sessionId: string,
deviceId: string,
expiresAt: number,
nonce: string,
): string {
return [
"elydora-device-rebind-v1",
`challenge_id=${challengeId}`,
`user_id=${userId}`,
`session_id=${sessionId}`,
`device_id=${deviceId}`,
`expires_at=${expiresAt}`,
`nonce=${nonce}`,
].join("\n");
}
function assertCanonicalChallenge(
challenge: string,
challengeId: string,
context: AuthContext,
deviceId: string,
expiresAt: number,
): void {
const prefix = canonicalChallenge(
challengeId,
context.userId,
context.sessionId,
deviceId,
expiresAt,
"",
);
const nonce = challenge.slice(prefix.length);
if (!challenge.startsWith(prefix) || !/^[a-f0-9]{64}$/.test(nonce)) {
throw new DevicePersistenceError("device_rebind_challenge_invalid");
}
}
function challengeValue(value: unknown): string {
if (typeof value !== "string" || value.length < 1 || value.length > 1024) {
throw new DevicePersistenceError("device_rebind_challenge_invalid");
}
return value;
}
function changedRowCount(result: unknown): number {
if (typeof result !== "object" || result === null || !("meta" in result)) {
throw new DevicePersistenceError("device_rebind_write_result_invalid");
}
const meta = result.meta;
if (typeof meta !== "object" || meta === null || !("changes" in meta)) {
throw new DevicePersistenceError("device_rebind_write_result_invalid");
}
const changes = meta.changes;
if (typeof changes !== "number" || !Number.isSafeInteger(changes) || changes < 0) {
throw new DevicePersistenceError("device_rebind_write_result_invalid");
}
return changes;
}
function randomHex(byteLength: number): string {
const bytes = new Uint8Array(byteLength);
crypto.getRandomValues(bytes);
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
@@ -0,0 +1,38 @@
import { verifyEd25519Signature } from "./device_crypto.js";
import {
type DeviceRegistrationRequest,
DevicePermissionError,
} from "./device_schema.js";
const REGISTRATION_DOMAIN = "elydora-device-registration-v2";
export async function assertDeviceRegistrationProof(
registration: DeviceRegistrationRequest,
): Promise<void> {
const valid = await verifyEd25519Signature(
registration.publicKey,
registration.registrationProof,
deviceRegistrationProofBytes(registration),
);
if (!valid) {
throw new DevicePermissionError("device_registration_proof_invalid");
}
}
export function deviceRegistrationProofBytes(
registration: Omit<DeviceRegistrationRequest, "registrationProof">,
): Uint8Array {
const encoder = new TextEncoder();
const payload = [
REGISTRATION_DOMAIN,
registration.deviceId,
registration.publicKey,
registration.wrappingPublicKey,
registration.deviceName,
registration.platform,
registration.idempotencyKey,
]
.map((value) => `${encoder.encode(value).byteLength}:${value}`)
.join("");
return encoder.encode(payload);
}
+339
View File
@@ -0,0 +1,339 @@
import type { AuthContext } from "./auth.js";
import type { ElyD1Result, Env } from "./bindings.js";
import { verifyEd25519Signature } from "./device_crypto.js";
import {
type DeviceRevocationDocument,
type DeviceRow,
DeviceConflictError,
DevicePermissionError,
DevicePersistenceError,
currentDeviceId,
deviceDocument,
deviceIdValue,
publicKeyValue,
} from "./device_schema.js";
import {
type ApprovedDeviceRevocationRequest,
compareDeviceIds,
deviceRevocationRequest,
deviceRevocationRequestHash,
deviceRevocationProofBytes,
pendingDeviceRevocationProofBytes,
pendingDeviceRevocationRequestHash,
} from "./device_revocation_schema.js";
import {
type RotationResultRow,
rotationR2ObjectCount,
rotationResult,
rotationStatements,
} from "./device_revocation_store.js";
import { revokePendingDeviceDocument } from "./pending_device_revocation.js";
const APPROVED_V2_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 CURRENT_VAULT_KEY_QUERY = `
SELECT current_key_id AS key_id, current_generation AS generation
FROM sync_vault_accounts
WHERE user_id = ?
`;
const REMAINING_APPROVED_V2_QUERY = `
SELECT device.device_id
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
ORDER BY device.device_id ASC
`;
interface VaultKeyRow { key_id: unknown; generation: unknown }
interface DeviceIdRow { device_id: unknown }
interface ApprovedV2RequesterRow extends DeviceIdRow { signing_public_key: unknown }
export async function revokeDeviceDocument(
request: Request,
env: Env,
context: AuthContext,
nowSeconds = Math.floor(Date.now() / 1000),
): Promise<DeviceRevocationDocument> {
const revocation = await deviceRevocationRequest(request);
const approverDeviceId = currentDeviceId(context);
if (approverDeviceId === revocation.deviceId) {
throw new DevicePermissionError("device_self_revocation_forbidden");
}
const signingPublicKey = await approvedV2RequesterKey(env, context.userId, approverDeviceId);
if (revocation.mode === "pending_revoke") {
const { pendingRevocationProof, ...unsignedRevocation } = revocation;
if (!(await verifyEd25519Signature(
signingPublicKey,
pendingRevocationProof,
pendingDeviceRevocationProofBytes(context.userId, approverDeviceId, unsignedRevocation),
))) {
throw new DevicePermissionError("device_revocation_proof_invalid");
}
const requestHash = await pendingDeviceRevocationRequestHash(
context.userId,
approverDeviceId,
revocation,
);
return revokePendingDeviceDocument(
env,
context,
approverDeviceId,
revocation,
requestHash,
nowSeconds,
);
}
const { rotationProof, ...unsignedRevocation } = revocation;
if (!(await verifyEd25519Signature(
signingPublicKey,
rotationProof,
deviceRevocationProofBytes(context.userId, approverDeviceId, unsignedRevocation),
))) {
throw new DevicePermissionError("device_revocation_proof_invalid");
}
const requestHash = await deviceRevocationRequestHash(
context.userId,
approverDeviceId,
revocation,
);
const existing = await rotationResult(env, context.userId, revocation.idempotencyKey);
if (existing !== null) {
return completedRevocationDocument(
env,
context.userId,
approverDeviceId,
revocation,
requestHash,
existing,
);
}
await assertCurrentVaultKey(env, context.userId, revocation);
await assertRevocableTarget(env, context.userId, approverDeviceId, revocation.deviceId);
const recipients = await remainingApprovedV2Recipients(env, context.userId, revocation.deviceId);
assertExactRecipients(revocation, recipients);
const r2ObjectCount = await rotationR2ObjectCount(env, context.userId);
const statements = await rotationStatements(
env,
context.userId,
approverDeviceId,
revocation,
requestHash,
r2ObjectCount,
nowSeconds,
);
let results: ElyD1Result[];
try {
results = await env.ELY_DB.batch<ElyD1Result>(statements);
} catch (error) {
if (isRotationConflict(error)) {
throw new DeviceConflictError("device_revocation_race");
}
throw error;
}
const finalizeChanges = changedRowCount(results.at(-1), "device_revocation_finalize");
if (finalizeChanges > 1) {
throw new DevicePersistenceError("device_revocation_write_count_invalid");
}
const completed = await rotationResult(env, context.userId, revocation.idempotencyKey);
if (completed === null) {
throw new DeviceConflictError("device_revocation_race");
}
try {
return await completedRevocationDocument(
env,
context.userId,
approverDeviceId,
revocation,
requestHash,
completed,
);
} catch (error) {
if (finalizeChanges === 0 && error instanceof DeviceConflictError) {
throw new DeviceConflictError("device_revocation_race");
}
throw error;
}
}
async function completedRevocationDocument(
env: Env,
userId: string,
approverDeviceId: string,
revocation: ApprovedDeviceRevocationRequest,
requestHash: string,
result: RotationResultRow,
): Promise<DeviceRevocationDocument> {
assertRotationResult(result, revocation, approverDeviceId, requestHash);
const revokedDevice = await deviceRowById(env, userId, revocation.deviceId);
if (revokedDevice === null) {
throw new DevicePersistenceError("device_revocation_missing");
}
const device = deviceDocument(revokedDevice, approverDeviceId);
const completedAt = storedInteger(result.completed_at, "completed_at");
if (device.approval_status !== "revoked" || device.revoked_at !== completedAt) {
throw new DevicePersistenceError("device_revocation_state_invalid");
}
return {
version: 2,
mode: "approved_rotate",
user_id: userId,
revoked_by_device_id: approverDeviceId,
revoked_at: completedAt,
key_id: revocation.newKeyId,
generation: revocation.newGeneration,
device,
};
}
function assertRotationResult(
row: RotationResultRow,
revocation: ApprovedDeviceRevocationRequest,
approverDeviceId: string,
requestHash: string,
): void {
if (
row.target_device_id !== revocation.deviceId ||
row.approver_device_id !== approverDeviceId ||
row.previous_key_id !== revocation.previousKeyId ||
row.previous_generation !== revocation.previousGeneration ||
row.new_key_id !== revocation.newKeyId ||
row.new_generation !== revocation.newGeneration ||
row.request_hash !== requestHash ||
row.envelope_count !== revocation.envelopes.length
) {
throw new DeviceConflictError("device_revocation_replay_mismatch");
}
const completedAt = storedInteger(row.completed_at, "completed_at");
if (
row.current_key_id !== revocation.newKeyId ||
row.current_generation !== revocation.newGeneration ||
row.target_status !== "revoked" ||
row.revoked_at !== completedAt ||
row.active_session_count !== 0 ||
row.item_count !== revocation.envelopes.length ||
storedInteger(row.r2_object_count, "r2_object_count") !==
storedInteger(row.r2_item_count, "r2_item_count") ||
row.persisted_count !== revocation.envelopes.length ||
row.audit_count !== 1
) {
throw new DevicePersistenceError("device_revocation_result_invalid");
}
}
async function approvedV2RequesterKey(
env: Env,
userId: string,
approverDeviceId: string,
): Promise<string> {
const row = await env.ELY_DB.prepare(APPROVED_V2_REQUESTER_QUERY)
.bind(userId, approverDeviceId)
.first<ApprovedV2RequesterRow>();
if (row === null) {
throw new DevicePermissionError("requester_device_unapproved");
}
return publicKeyValue(row.signing_public_key, "signing_public_key");
}
async function assertCurrentVaultKey(
env: Env,
userId: string,
revocation: ApprovedDeviceRevocationRequest,
): Promise<void> {
const row = await env.ELY_DB.prepare(CURRENT_VAULT_KEY_QUERY).bind(userId).first<VaultKeyRow>();
if (
row === null ||
row.key_id !== revocation.previousKeyId ||
row.generation !== revocation.previousGeneration
) {
throw new DeviceConflictError("device_revocation_vault_conflict");
}
}
async function assertRevocableTarget(
env: Env,
userId: string,
approverDeviceId: string,
targetDeviceId: string,
): Promise<void> {
const row = await deviceRowById(env, userId, targetDeviceId);
if (row === null) {
throw new DevicePermissionError("device_not_found");
}
const device = deviceDocument(row, approverDeviceId);
if (device.revoked_at !== null || device.approval_status !== "approved") {
throw new DevicePermissionError("device_not_revocable");
}
}
async function remainingApprovedV2Recipients(
env: Env,
userId: string,
targetDeviceId: string,
): Promise<string[]> {
const result = await env.ELY_DB.prepare(REMAINING_APPROVED_V2_QUERY)
.bind(userId, targetDeviceId)
.all<DeviceIdRow>();
const recipients = result.results.map((row) => deviceIdValue(row.device_id, "device_id"));
if (new Set(recipients).size !== recipients.length) {
throw new DevicePersistenceError("device_revocation_recipient_rows_invalid");
}
recipients.sort(compareDeviceIds);
return recipients;
}
function assertExactRecipients(revocation: ApprovedDeviceRevocationRequest, expected: string[]): void {
const actual = revocation.envelopes.map((item) => item.recipientDeviceId);
if (actual.length !== expected.length || actual.some((deviceId, index) => deviceId !== expected[index])) {
throw new DeviceConflictError("device_revocation_envelope_set_mismatch");
}
}
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>();
}
function storedInteger(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
throw new DevicePersistenceError(`${label}_invalid`);
}
return value;
}
function changedRowCount(result: ElyD1Result | undefined, label: string): number {
const changes = result?.meta?.changes;
if (typeof changes !== "number" || !Number.isSafeInteger(changes) || changes < 0) {
throw new DevicePersistenceError(`${label}_write_result_invalid`);
}
return changes;
}
function isRotationConflict(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
return error.message.includes("sync_vault_rotation_guard_failed") ||
error.message.includes("UNIQUE constraint failed: sync_vault_envelopes") ||
error.message.includes("FOREIGN KEY constraint failed");
}
+254
View File
@@ -0,0 +1,254 @@
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<DeviceRevocationRequest> {
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<string> {
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<ApprovedDeviceRevocationRequest, "rotationProof">,
): 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<string> {
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<PendingDeviceRevocationRequest, "pendingRevocationProof">,
): Uint8Array {
return canonicalBytes([
"elydora-pending-device-revocation-v2",
userId,
approverDeviceId,
revocation.deviceId,
revocation.idempotencyKey,
]);
}
export function rotationEnvelopeIdempotencyKey(
userId: string,
rotationIdempotencyKey: string,
recipientDeviceId: string,
): Promise<string> {
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<string>();
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<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new DeviceSchemaError(`${label}_invalid`);
}
return value as Record<string, unknown>;
}
async function sha256Hex(value: string): Promise<string> {
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(""));
}
+221
View File
@@ -0,0 +1,221 @@
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,
);
}
+163
View File
@@ -0,0 +1,163 @@
import type { Env } from "./bindings.js";
import { withAuthenticatedApiControls } from "./api_controls.js";
import { issueDeviceRebindChallenge, rebindDeviceSession } from "./device_rebind.js";
import { approveDeviceDocument } from "./device_approval.js";
import { revokeDeviceDocument } from "./device_revocation.js";
import {
DeviceConflictError,
DevicePermissionError,
DevicePersistenceError,
DeviceSchemaError,
deviceListDocument,
registerDeviceDocument,
} from "./devices.js";
import { jsonResponse } from "./responses.js";
const NO_STORE = { "Cache-Control": "no-store" } as const;
export async function handleDeviceRoute(
request: Request,
env: Env,
url: URL,
): Promise<Response | null> {
if (url.pathname === "/api/devices") {
return withAuthenticatedApiControls(request, env, "devices.list", ["GET"], async (context) => {
try {
return jsonResponse(await deviceListDocument(env, context), 200, NO_STORE);
} catch (error) {
if (error instanceof DeviceSchemaError) {
return jsonResponse({ error: "devices_invalid" }, 500, NO_STORE);
}
throw error;
}
});
}
if (url.pathname === "/api/devices/register") {
return withAuthenticatedApiControls(
request,
env,
"devices.register",
["POST"],
async (context) => {
try {
return jsonResponse(await registerDeviceDocument(request, env, context), 201, NO_STORE);
} catch (error) {
if (error instanceof DeviceConflictError) {
return jsonResponse({ error: "device_registration_conflict" }, 409, NO_STORE);
}
if (error instanceof DevicePermissionError) {
return jsonResponse({ error: "device_registration_forbidden" }, 403, NO_STORE);
}
if (error instanceof DeviceSchemaError) {
return jsonResponse({ error: "invalid_device_registration" }, 400, NO_STORE);
}
if (error instanceof DevicePersistenceError) {
return jsonResponse({ error: "device_registration_failed" }, 500, NO_STORE);
}
throw error;
}
},
);
}
if (url.pathname === "/api/devices/rebind/challenge") {
return withAuthenticatedApiControls(
request,
env,
"devices.rebind_challenge",
["POST"],
async (context) => {
try {
return jsonResponse(await issueDeviceRebindChallenge(request, env, context), 201, NO_STORE);
} catch (error) {
return deviceRebindErrorResponse(error, "challenge");
}
},
);
}
if (url.pathname === "/api/devices/rebind") {
return withAuthenticatedApiControls(
request,
env,
"devices.rebind",
["POST"],
async (context) => {
try {
return jsonResponse(await rebindDeviceSession(request, env, context), 200, NO_STORE);
} catch (error) {
return deviceRebindErrorResponse(error, "rebind");
}
},
);
}
if (url.pathname === "/api/devices/approve") {
return withAuthenticatedApiControls(
request,
env,
"devices.approve",
["POST"],
async (context) => {
try {
return jsonResponse(await approveDeviceDocument(request, env, context), 200, NO_STORE);
} catch (error) {
if (error instanceof DeviceConflictError) {
return jsonResponse({ error: "device_approval_conflict" }, 409, NO_STORE);
}
if (error instanceof DevicePermissionError) {
return jsonResponse({ error: "device_approval_forbidden" }, 403, NO_STORE);
}
if (error instanceof DeviceSchemaError) {
return jsonResponse({ error: "invalid_device_approval" }, 400, NO_STORE);
}
if (error instanceof DevicePersistenceError) {
return jsonResponse({ error: "device_approval_failed" }, 500, NO_STORE);
}
throw error;
}
},
);
}
if (url.pathname === "/api/devices/revoke") {
return withAuthenticatedApiControls(
request,
env,
"devices.revoke",
["POST"],
async (context) => {
try {
return jsonResponse(await revokeDeviceDocument(request, env, context), 200, NO_STORE);
} catch (error) {
if (error instanceof DeviceConflictError) {
return jsonResponse({ error: "device_revocation_conflict" }, 409, NO_STORE);
}
if (error instanceof DevicePermissionError) {
return jsonResponse({ error: "device_revocation_forbidden" }, 403, NO_STORE);
}
if (error instanceof DeviceSchemaError) {
return jsonResponse({ error: "invalid_device_revocation" }, 400, NO_STORE);
}
if (error instanceof DevicePersistenceError) {
return jsonResponse({ error: "device_revocation_failed" }, 500, NO_STORE);
}
throw error;
}
},
);
}
return null;
}
function deviceRebindErrorResponse(error: unknown, operation: "challenge" | "rebind"): Response {
if (error instanceof DeviceConflictError) {
return jsonResponse({ error: "device_rebind_conflict" }, 409, NO_STORE);
}
if (error instanceof DevicePermissionError) {
return jsonResponse({ error: "device_rebind_forbidden" }, 403, NO_STORE);
}
if (error instanceof DeviceSchemaError) {
return jsonResponse({ error: `invalid_device_${operation}` }, 400, NO_STORE);
}
if (error instanceof DevicePersistenceError) {
return jsonResponse({ error: "device_rebind_failed" }, 500, NO_STORE);
}
throw error;
}
+115 -56
View File
@@ -1,7 +1,13 @@
import type { AuthContext } from "./auth.js";
import {
type WrappedAccountKeyDocument,
SyncVaultRequestError,
parseWrappedAccountKey,
} from "./sync_vault.js";
const DEVICE_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,128}$/;
const PUBLIC_KEY_PATTERN = /^[a-fA-F0-9]{64,256}$/;
const PUBLIC_KEY_PATTERN = /^[a-f0-9]{64}$/;
const SIGNATURE_PATTERN = /^[a-f0-9]{128}$/;
const DEVICE_TEXT_PATTERN = /^[^\p{Cc}\p{Cs}]{1,128}$/u;
const APPROVAL_STATUS = new Set(["pending", "approved", "revoked"]);
@@ -12,7 +18,7 @@ export interface DeviceListDocument {
}
export interface DeviceRegistrationDocument {
version: 1;
version: 2;
user_id: string;
device: DeviceDocument;
}
@@ -25,17 +31,32 @@ export interface DeviceApprovalDocument {
device: DeviceDocument;
}
export interface DeviceRevocationDocument {
version: 1;
interface DeviceRevocationDocumentBase {
version: 2;
user_id: string;
revoked_by_device_id: string;
revoked_at: number;
device: DeviceDocument;
}
export interface ApprovedDeviceRevocationDocument extends DeviceRevocationDocumentBase {
mode: "approved_rotate";
key_id: string;
generation: number;
}
export interface PendingDeviceRevocationDocument extends DeviceRevocationDocumentBase {
mode: "pending_revoke";
}
export type DeviceRevocationDocument =
| ApprovedDeviceRevocationDocument
| PendingDeviceRevocationDocument;
export interface DeviceDocument {
device_id: string;
public_key: string;
wrapping_public_key?: string;
device_name: string;
platform: string;
approval_status: "pending" | "approved" | "revoked";
@@ -49,6 +70,7 @@ export interface DeviceDocument {
export interface DeviceRow {
device_id: unknown;
public_key: unknown;
wrapping_public_key?: unknown;
device_name: unknown;
platform: unknown;
approval_status: unknown;
@@ -61,14 +83,22 @@ export interface DeviceRow {
export interface DeviceRegistrationRequest {
deviceId: string;
publicKey: string;
wrappingPublicKey: string;
registrationProof: string;
deviceName: string;
platform: string;
idempotencyKey: string;
}
export type DeviceApprovalRequest = { deviceId: string; idempotencyKey: string };
export type DeviceRevocationRequest = { deviceId: string; idempotencyKey: string };
export interface DeviceApprovalRequest {
deviceId: string;
keyId: string;
generation: number;
envelope: WrappedAccountKeyDocument;
idempotencyKey: string;
proofCreatedAt: number;
approvalProof: string;
}
export interface DeviceApprovalRow {
device_id: unknown;
requester_device_id: unknown;
@@ -76,13 +106,6 @@ export interface DeviceApprovalRow {
decided_at: unknown;
}
export interface DeviceRevocationRow {
actor_device_id: unknown;
subject_id: unknown;
outcome: unknown;
created_at: unknown;
}
type DeviceRequestBody = Record<string, unknown>;
export class DeviceSchemaError extends Error {
@@ -121,17 +144,21 @@ export async function deviceRegistrationRequest(
"version",
"device_id",
"public_key",
"wrapping_public_key",
"registration_proof",
"device_name",
"platform",
"idempotency_key",
]);
if (value.version !== 1) {
if (value.version !== 2) {
throw new DeviceSchemaError("device_registration_version_invalid");
}
return {
deviceId: deviceIdValue(value.device_id, "device_id"),
publicKey: publicKeyValue(value.public_key),
publicKey: publicKeyValue(value.public_key, "public_key"),
wrappingPublicKey: publicKeyValue(value.wrapping_public_key, "wrapping_public_key"),
registrationProof: signatureValue(value.registration_proof, "registration_proof"),
deviceName: deviceText(value.device_name, "device_name"),
platform: deviceText(value.platform, "platform"),
idempotencyKey: idempotencyKeyValue(value.idempotency_key),
@@ -140,27 +167,28 @@ export async function deviceRegistrationRequest(
export async function deviceApprovalRequest(request: Request): Promise<DeviceApprovalRequest> {
const value = await deviceRequestBody(request, "device_approval");
assertOnlyFields(value, ["version", "device_id", "idempotency_key"]);
if (value.version !== 1) {
assertOnlyFields(value, [
"version",
"device_id",
"key_id",
"generation",
"envelope",
"idempotency_key",
"proof_created_at",
"approval_proof",
]);
if (value.version !== 2) {
throw new DeviceSchemaError("device_approval_version_invalid");
}
return {
deviceId: deviceIdValue(value.device_id, "device_id"),
keyId: keyIdValue(value.key_id),
generation: positiveInteger(value.generation, "generation"),
envelope: wrappedAccountKey(value.envelope),
idempotencyKey: idempotencyKeyValue(value.idempotency_key),
};
}
export async function deviceRevocationRequest(request: Request): Promise<DeviceRevocationRequest> {
const value = await deviceRequestBody(request, "device_revocation");
assertOnlyFields(value, ["version", "device_id", "idempotency_key"]);
if (value.version !== 1) {
throw new DeviceSchemaError("device_revocation_version_invalid");
}
return {
deviceId: deviceIdValue(value.device_id, "device_id"),
idempotencyKey: idempotencyKeyValue(value.idempotency_key),
proofCreatedAt: positiveInteger(value.proof_created_at, "proof_created_at"),
approvalProof: signatureValue(value.approval_proof, "approval_proof"),
};
}
@@ -182,24 +210,6 @@ export function approvedDeviceDocument(
};
}
export function revokedDeviceDocument(
userId: string,
revokedByDeviceId: string,
row: DeviceRow,
): DeviceRevocationDocument {
const device = deviceDocument(row, revokedByDeviceId);
if (device.approval_status !== "revoked" || device.revoked_at === null) {
throw new DevicePersistenceError("device_revocation_missing");
}
return {
version: 1,
user_id: userId,
revoked_by_device_id: revokedByDeviceId,
revoked_at: device.revoked_at,
device,
};
}
export function currentDeviceId(context: AuthContext): string {
if (context.deviceId === undefined) {
throw new DevicePermissionError("device_context_required");
@@ -212,9 +222,16 @@ export function deviceDocument(
currentDeviceIdValue: string | undefined,
): DeviceDocument {
const deviceId = deviceIdValue(row.device_id, "device_id");
const wrappingPublicKey = optionalPublicKeyValue(
row.wrapping_public_key,
"wrapping_public_key",
);
return {
device_id: deviceId,
public_key: publicKeyValue(row.public_key),
public_key: publicKeyValue(row.public_key, "public_key"),
...(wrappingPublicKey === undefined
? {}
: { wrapping_public_key: wrappingPublicKey }),
device_name: deviceText(row.device_name, "device_name"),
platform: deviceText(row.platform, "platform"),
approval_status: approvalStatus(row.approval_status),
@@ -240,11 +257,25 @@ export function timestamp(value: unknown, label: string): number {
return value;
}
function publicKeyValue(value: unknown): string {
export function publicKeyValue(value: unknown, label: string): string {
if (typeof value !== "string" || !PUBLIC_KEY_PATTERN.test(value)) {
throw new DeviceSchemaError("public_key_invalid");
throw new DeviceSchemaError(`${label}_invalid`);
}
return value.toLowerCase();
return value;
}
export function signatureValue(value: unknown, label: string): string {
if (typeof value !== "string" || !SIGNATURE_PATTERN.test(value)) {
throw new DeviceSchemaError(`${label}_invalid`);
}
return value;
}
function optionalPublicKeyValue(value: unknown, label: string): string | undefined {
if (value === undefined || value === null) {
return undefined;
}
return publicKeyValue(value, label);
}
function deviceText(value: unknown, label: string): string {
@@ -265,13 +296,38 @@ function approvalStatus(value: unknown): DeviceDocument["approval_status"] {
return value as DeviceDocument["approval_status"];
}
function idempotencyKeyValue(value: unknown): string {
export function idempotencyKeyValue(value: unknown): string {
if (typeof value !== "string" || !/^[a-zA-Z0-9._:-]{16,128}$/.test(value)) {
throw new DeviceSchemaError("idempotency_key_invalid");
}
return value;
}
export function keyIdValue(value: unknown): string {
if (typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value)) {
throw new DeviceSchemaError("key_id_invalid");
}
return value;
}
export function positiveInteger(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) {
throw new DeviceSchemaError(`${label}_invalid`);
}
return value;
}
export function wrappedAccountKey(value: unknown): WrappedAccountKeyDocument {
try {
return parseWrappedAccountKey(value);
} catch (error) {
if (error instanceof SyncVaultRequestError) {
throw new DeviceSchemaError("envelope_invalid");
}
throw error;
}
}
function nullableTimestamp(value: unknown, label: string): number | null {
if (value === null) {
return null;
@@ -279,7 +335,7 @@ function nullableTimestamp(value: unknown, label: string): number | null {
return timestamp(value, label);
}
function assertOnlyFields(value: DeviceRequestBody, fields: string[]): void {
export function assertOnlyFields(value: DeviceRequestBody, fields: string[]): void {
const allowed = new Set(fields);
for (const field of Object.keys(value)) {
if (!allowed.has(field)) {
@@ -288,7 +344,10 @@ function assertOnlyFields(value: DeviceRequestBody, fields: string[]): void {
}
}
async function deviceRequestBody(request: Request, label: string): Promise<DeviceRequestBody> {
export async function deviceRequestBody(
request: Request,
label: string,
): Promise<DeviceRequestBody> {
let value: unknown;
try {
value = await request.json();
+122 -339
View File
@@ -1,28 +1,17 @@
import type { AuthContext } from "./auth.js";
import type { Env } from "./bindings.js";
import { assertDeviceRegistrationProof } from "./device_registration_proof.js";
import {
type DeviceApprovalDocument,
type DeviceApprovalRequest,
type DeviceApprovalRow,
type DeviceListDocument,
type DeviceRegistrationDocument,
type DeviceRegistrationRequest,
type DeviceRevocationDocument,
type DeviceRevocationRequest,
type DeviceRevocationRow,
type DeviceRow,
DeviceConflictError,
DevicePermissionError,
DevicePersistenceError,
approvedDeviceDocument,
currentDeviceId,
deviceApprovalRequest,
deviceDocument,
deviceIdValue,
deviceRegistrationRequest,
deviceRevocationRequest,
revokedDeviceDocument,
timestamp,
} from "./device_schema.js";
export {
@@ -32,149 +21,87 @@ export {
DeviceSchemaError,
} from "./device_schema.js";
const UNBOUND_SESSION_MAX_AGE_SECONDS = 10 * 60;
const SESSION_CLOCK_SKEW_SECONDS = 30;
const DEVICE_COLUMNS = `
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
`;
const DEVICE_FROM = `
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
`;
const DEVICE_LIST_QUERY = `
SELECT
device_id,
public_key,
device_name,
platform,
approval_status,
created_at,
approved_at,
last_active_at,
revoked_at
FROM user_devices
WHERE user_id = ?
SELECT ${DEVICE_COLUMNS}
${DEVICE_FROM}
WHERE device.user_id = ?
ORDER BY
revoked_at IS NOT NULL,
COALESCE(last_active_at, approved_at, created_at) DESC,
device_id ASC
device.revoked_at IS NOT NULL,
COALESCE(device.last_active_at, device.approved_at, device.created_at) DESC,
device.device_id ASC
`;
const DEVICE_REGISTER_QUERY = `
WITH registration AS (
SELECT CASE
WHEN NOT EXISTS (SELECT 1 FROM user_devices WHERE user_id = ?) THEN 'approved'
ELSE 'pending'
END AS approval_status
)
INSERT INTO user_devices (
user_id,
device_id,
public_key,
device_name,
platform,
approval_status,
created_at,
approved_at,
last_active_at,
revoked_at,
idempotency_key
) VALUES (?, ?, ?, ?, ?, 'pending', ?, NULL, ?, NULL, ?)
user_id, device_id, public_key, device_name, platform,
approval_status, created_at, approved_at, last_active_at, revoked_at, idempotency_key
)
SELECT ?, ?, ?, ?, ?, approval_status, ?,
CASE WHEN approval_status = 'approved' THEN ? ELSE NULL END,
?, NULL, ?
FROM registration
WHERE EXISTS (
SELECT 1 FROM better_auth_session
WHERE id = ? AND userId = ?
)
ON CONFLICT DO NOTHING
`;
const DEVICE_KEYS_INSERT_QUERY = `
INSERT INTO user_device_keys (
user_id, device_id, signing_public_key,
wrapping_public_key, key_protocol_version, created_at
)
SELECT ?, ?, ?, ?, 2, ?
WHERE EXISTS (
SELECT 1 FROM user_devices
WHERE user_id = ? AND device_id = ? AND public_key = ?
AND device_name = ? AND platform = ? AND idempotency_key = ?
AND revoked_at IS NULL
)
AND EXISTS (
SELECT 1 FROM better_auth_session
WHERE id = ? AND userId = ?
)
ON CONFLICT DO NOTHING
`;
const DEVICE_BY_IDEMPOTENCY_KEY_QUERY = `
SELECT
device_id,
public_key,
device_name,
platform,
approval_status,
created_at,
approved_at,
last_active_at,
revoked_at
FROM user_devices
WHERE user_id = ? AND idempotency_key = ?
SELECT ${DEVICE_COLUMNS}
${DEVICE_FROM}
WHERE device.user_id = ? AND device.idempotency_key = ?
`;
const DEVICE_BY_ID_QUERY = `
SELECT
device_id,
public_key,
device_name,
platform,
approval_status,
created_at,
approved_at,
last_active_at,
revoked_at
FROM user_devices
WHERE user_id = ? AND device_id = ?
`;
const APPROVED_DEVICE_QUERY = `
SELECT
device_id,
public_key,
device_name,
platform,
approval_status,
created_at,
approved_at,
last_active_at,
revoked_at
FROM user_devices
WHERE user_id = ? AND device_id = ? AND approval_status = 'approved' AND revoked_at IS NULL
`;
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
) VALUES (?, ?, ?, ?, 'approved', ?, ?, ?, ?)
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
`;
const DEVICE_REVOCATION_BY_IDEMPOTENCY_KEY_QUERY = `
SELECT
actor_device_id,
subject_id,
outcome,
created_at
FROM audit_events
WHERE user_id = ? AND event_id = ? AND event_type = 'device.revoke'
`;
const DEVICE_REVOCATION_INSERT_QUERY = `
INSERT INTO audit_events (
event_id,
user_id,
actor_device_id,
event_type,
subject_type,
subject_id,
outcome,
metadata_hash,
created_at
) VALUES (?, ?, ?, 'device.revoke', 'device', ?, 'success', NULL, ?)
ON CONFLICT(event_id) DO NOTHING
`;
const DEVICE_REVOKE_QUERY = `
UPDATE user_devices
SET
approval_status = 'revoked',
revoked_at = COALESCE(revoked_at, ?)
WHERE user_id = ? AND device_id = ? AND revoked_at IS NULL
SELECT ${DEVICE_COLUMNS}
${DEVICE_FROM}
WHERE device.user_id = ? AND device.device_id = ?
`;
const SESSION_DEVICE_CONTEXT_UPSERT_QUERY = `
INSERT INTO better_auth_session_device_context (
session_id,
user_id,
device_id,
updated_at
session_id, user_id, device_id, updated_at
)
SELECT ?, ?, ?, ?
WHERE EXISTS (
@@ -182,10 +109,7 @@ const SESSION_DEVICE_CONTEXT_UPSERT_QUERY = `
FROM better_auth_session
WHERE id = ? AND userId = ?
)
ON CONFLICT(session_id) DO UPDATE SET
user_id = excluded.user_id,
device_id = excluded.device_id,
updated_at = excluded.updated_at
ON CONFLICT(session_id) DO NOTHING
`;
export async function deviceListDocument(
@@ -210,9 +134,12 @@ export async function registerDeviceDocument(
if (context.deviceId !== undefined && context.deviceId !== registration.deviceId) {
throw new DevicePermissionError("device_context_mismatch");
}
await assertDeviceRegistrationProof(registration);
assertFreshUnboundSession(context, nowSeconds);
const writeResult = await env.ELY_DB.prepare(DEVICE_REGISTER_QUERY)
.bind(
const [deviceWriteResult, keyWriteResult] = await env.ELY_DB.batch([
env.ELY_DB.prepare(DEVICE_REGISTER_QUERY).bind(
context.userId,
context.userId,
registration.deviceId,
registration.publicKey,
@@ -220,11 +147,30 @@ export async function registerDeviceDocument(
registration.platform,
nowSeconds,
nowSeconds,
nowSeconds,
registration.idempotencyKey,
)
.run();
const insertedRows = changedRowCount(writeResult);
if (insertedRows > 1) {
context.sessionId,
context.userId,
),
env.ELY_DB.prepare(DEVICE_KEYS_INSERT_QUERY).bind(
context.userId,
registration.deviceId,
registration.publicKey,
registration.wrappingPublicKey,
nowSeconds,
context.userId,
registration.deviceId,
registration.publicKey,
registration.deviceName,
registration.platform,
registration.idempotencyKey,
context.sessionId,
context.userId,
),
]);
const insertedRows = changedRowCount(deviceWriteResult, "device_registration");
const insertedKeyRows = changedRowCount(keyWriteResult, "device_key_registration");
if (insertedRows > 1 || insertedKeyRows > 1 || insertedRows !== insertedKeyRows) {
throw new DevicePersistenceError("device_registration_write_count_invalid");
}
const row = await env.ELY_DB.prepare(DEVICE_BY_IDEMPOTENCY_KEY_QUERY)
@@ -249,23 +195,36 @@ export async function registerDeviceDocument(
throw new DeviceConflictError("device_registration_conflict");
}
return {
version: 1,
version: 2,
user_id: context.userId,
device,
};
}
if (device.approval_status !== "pending" || device.revoked_at !== null) {
if (device.revoked_at !== null) {
throw new DevicePersistenceError("device_registration_state_invalid");
}
await bindSessionDeviceContext(env, context, device.device_id, nowSeconds);
return {
version: 1,
version: 2,
user_id: context.userId,
device,
};
}
function assertFreshUnboundSession(context: AuthContext, nowSeconds: number): void {
if (context.deviceId !== undefined) {
return;
}
const createdAtSeconds = Math.floor(Date.parse(context.createdAt) / 1000);
if (
createdAtSeconds < nowSeconds - UNBOUND_SESSION_MAX_AGE_SECONDS ||
createdAtSeconds > nowSeconds + SESSION_CLOCK_SKEW_SECONDS
) {
throw new DevicePermissionError("fresh_session_required");
}
}
function registrationMatches(
device: DeviceRegistrationDocument["device"],
registration: DeviceRegistrationRequest,
@@ -273,22 +232,23 @@ function registrationMatches(
return (
device.device_id === registration.deviceId &&
device.public_key === registration.publicKey &&
device.wrapping_public_key === registration.wrappingPublicKey &&
device.device_name === registration.deviceName &&
device.platform === registration.platform
);
}
function changedRowCount(result: unknown): number {
function changedRowCount(result: unknown, label: string): number {
if (typeof result !== "object" || result === null || !("meta" in result)) {
throw new DevicePersistenceError("device_registration_write_result_invalid");
throw new DevicePersistenceError(`${label}_write_result_invalid`);
}
const meta = result.meta;
if (typeof meta !== "object" || meta === null || !("changes" in meta)) {
throw new DevicePersistenceError("device_registration_write_result_invalid");
throw new DevicePersistenceError(`${label}_write_result_invalid`);
}
const changes = meta.changes;
if (typeof changes !== "number" || !Number.isSafeInteger(changes) || changes < 0) {
throw new DevicePersistenceError("device_registration_write_result_invalid");
throw new DevicePersistenceError(`${label}_write_result_invalid`);
}
return changes;
}
@@ -299,187 +259,10 @@ async function bindSessionDeviceContext(
deviceId: string,
nowSeconds: number,
): Promise<void> {
await env.ELY_DB.prepare(SESSION_DEVICE_CONTEXT_UPSERT_QUERY)
const result = await env.ELY_DB.prepare(SESSION_DEVICE_CONTEXT_UPSERT_QUERY)
.bind(context.sessionId, context.userId, deviceId, nowSeconds, context.sessionId, context.userId)
.run();
}
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");
}
await assertApprovedRequester(env, context.userId, requesterDeviceId);
const existingApproval = await env.ELY_DB.prepare(DEVICE_APPROVAL_BY_IDEMPOTENCY_KEY_QUERY)
.bind(context.userId, approval.idempotencyKey)
.first<DeviceApprovalRow>();
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 = deviceDocument(pendingDevice, requesterDeviceId);
if (pendingDocument.approval_status !== "pending" || pendingDocument.revoked_at !== null) {
throw new DevicePermissionError("device_not_pending");
}
await env.ELY_DB.batch([
env.ELY_DB.prepare(DEVICE_APPROVAL_INSERT_QUERY).bind(
context.userId,
approval.idempotencyKey,
approval.deviceId,
requesterDeviceId,
nowSeconds,
nowSeconds,
nowSeconds,
approval.idempotencyKey,
),
env.ELY_DB.prepare(DEVICE_APPROVE_QUERY).bind(
nowSeconds,
nowSeconds,
context.userId,
approval.deviceId,
),
]);
const approvedDevice = await deviceRowById(env, context.userId, approval.deviceId);
if (approvedDevice === null) {
throw new DevicePersistenceError("device_approval_missing");
}
return approvedDeviceDocument(context.userId, requesterDeviceId, approvedDevice);
}
export async function revokeDeviceDocument(
request: Request,
env: Env,
context: AuthContext,
nowSeconds = Math.floor(Date.now() / 1000),
): Promise<DeviceRevocationDocument> {
const revocation = await deviceRevocationRequest(request);
const requesterDeviceId = currentDeviceId(context);
if (requesterDeviceId === revocation.deviceId) {
throw new DevicePermissionError("device_self_revocation_forbidden");
}
const revocationEventId = deviceRevocationEventId(context.userId, revocation.idempotencyKey);
await assertApprovedRequester(env, context.userId, requesterDeviceId);
const existingRevocation = await env.ELY_DB.prepare(DEVICE_REVOCATION_BY_IDEMPOTENCY_KEY_QUERY)
.bind(context.userId, revocationEventId)
.first<DeviceRevocationRow>();
if (existingRevocation !== null) {
return existingRevocationDocument(env, context, revocation, requesterDeviceId, existingRevocation);
}
const targetDevice = await deviceRowById(env, context.userId, revocation.deviceId);
if (targetDevice === null) {
throw new DevicePermissionError("device_not_found");
}
const targetDocument = deviceDocument(targetDevice, requesterDeviceId);
if (targetDocument.revoked_at !== null) {
throw new DevicePermissionError("device_already_revoked");
}
await env.ELY_DB.batch([
env.ELY_DB.prepare(DEVICE_REVOCATION_INSERT_QUERY).bind(
revocationEventId,
context.userId,
requesterDeviceId,
revocation.deviceId,
nowSeconds,
),
env.ELY_DB.prepare(DEVICE_REVOKE_QUERY).bind(nowSeconds, context.userId, revocation.deviceId),
]);
const revokedDevice = await deviceRowById(env, context.userId, revocation.deviceId);
if (revokedDevice === null) {
throw new DevicePersistenceError("device_revocation_missing");
}
return revokedDeviceDocument(context.userId, requesterDeviceId, revokedDevice);
}
function deviceRevocationEventId(userId: string, idempotencyKey: string): string {
return `device-revoke:${userId}:${idempotencyKey}`;
}
async function existingApprovalDocument(
env: Env,
context: AuthContext,
approval: DeviceApprovalRequest,
requesterDeviceId: string,
row: DeviceApprovalRow,
): Promise<DeviceApprovalDocument> {
const approvedDeviceId = deviceIdValue(row.device_id, "device_id");
const approvedByDeviceId = deviceIdValue(row.requester_device_id, "requester_device_id");
if (
approvedDeviceId !== approval.deviceId ||
approvedByDeviceId !== requesterDeviceId ||
row.status !== "approved"
) {
throw new DevicePermissionError("device_approval_replay_mismatch");
}
const approvedDevice = await deviceRowById(env, context.userId, approvedDeviceId);
if (approvedDevice === null) {
throw new DevicePersistenceError("device_approval_missing");
}
return {
...approvedDeviceDocument(context.userId, requesterDeviceId, approvedDevice),
approved_at: timestamp(row.decided_at, "decided_at"),
};
}
async function existingRevocationDocument(
env: Env,
context: AuthContext,
revocation: DeviceRevocationRequest,
requesterDeviceId: string,
row: DeviceRevocationRow,
): Promise<DeviceRevocationDocument> {
const revokedDeviceId = deviceIdValue(row.subject_id, "subject_id");
const revokedByDeviceId = deviceIdValue(row.actor_device_id, "actor_device_id");
if (
revokedDeviceId !== revocation.deviceId ||
revokedByDeviceId !== requesterDeviceId ||
row.outcome !== "success"
) {
throw new DevicePermissionError("device_revocation_replay_mismatch");
}
const revokedDevice = await deviceRowById(env, context.userId, revokedDeviceId);
if (revokedDevice === null) {
throw new DevicePersistenceError("device_revocation_missing");
}
return {
...revokedDeviceDocument(context.userId, requesterDeviceId, revokedDevice),
revoked_at: timestamp(row.created_at, "created_at"),
};
}
async function assertApprovedRequester(
env: Env,
userId: string,
requesterDeviceId: string,
): Promise<void> {
const requester = await env.ELY_DB.prepare(APPROVED_DEVICE_QUERY)
.bind(userId, requesterDeviceId)
.first<DeviceRow>();
if (requester === null) {
throw new DevicePermissionError("requester_device_unapproved");
if (changedRowCount(result, "device_session_binding") !== 1) {
throw new DeviceConflictError("device_session_binding_conflict");
}
}
async 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>();
}
+25 -146
View File
@@ -1,6 +1,5 @@
import type { Env } from "./bindings.js";
import {
withApprovedDeviceApiControls,
withAuthenticatedApiControls,
withPublicApiControls,
} from "./api_controls.js";
@@ -10,16 +9,12 @@ import {
accountDeletionDocument,
} from "./account_deletion.js";
import { handleBetterAuthRoute } from "./better_auth.js";
import { handleDeviceRoute } from "./device_routes.js";
import { DestructiveActionGateError } from "./destructive_action_gate.js";
import {
DeviceConflictError,
DevicePermissionError,
DevicePersistenceError,
DeviceSchemaError,
approveDeviceDocument,
deviceListDocument,
registerDeviceDocument,
revokeDeviceDocument,
} from "./devices.js";
RecentDeviceActionPermissionError,
RecentDeviceActionPersistenceError,
} from "./recent_device_action_proof.js";
import {
PluginRegistrySchemaError,
parsePluginRegistryDocument,
@@ -43,6 +38,7 @@ import {
publicSigningKeysKvKey,
} from "./signing_keys.js";
import { handleSyncRoute } from "./sync_routes.js";
import { maintainSyncR2Storage } from "./sync_r2_maintenance.js";
import {
TelemetrySchemaError,
telemetryEventAcceptedDocument,
@@ -53,6 +49,9 @@ export default {
fetch(request: Request, env: Env): Promise<Response> {
return handleRequest(request, env);
},
scheduled(controller: { scheduledTime: number }, env: Env): Promise<void> {
return maintainSyncR2Storage(env, Math.floor(controller.scheduledTime / 1000));
},
};
export async function handleRequest(request: Request, env: Env): Promise<Response> {
@@ -60,143 +59,12 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
if (url.pathname === "/api/auth" || url.pathname.startsWith("/api/auth/")) {
return handleBetterAuthRoute(request, env);
}
if (url.pathname === "/api/devices") {
return withAuthenticatedApiControls(request, env, "devices.list", ["GET"], async (context) => {
try {
return jsonResponse(await deviceListDocument(env, context), 200, {
"Cache-Control": "no-store",
});
} catch (error) {
if (error instanceof DeviceSchemaError) {
return jsonResponse({ error: "devices_invalid" }, 500, { "Cache-Control": "no-store" });
}
throw error;
}
});
}
if (url.pathname === "/api/devices/register") {
return withAuthenticatedApiControls(
request,
env,
"devices.register",
["POST"],
async (context) => {
try {
return jsonResponse(await registerDeviceDocument(request, env, context), 201, {
"Cache-Control": "no-store",
});
} catch (error) {
if (error instanceof DeviceConflictError) {
return jsonResponse(
{ error: "device_registration_conflict" },
409,
{ "Cache-Control": "no-store" },
);
}
if (error instanceof DevicePermissionError) {
return jsonResponse(
{ error: "device_context_mismatch" },
403,
{ "Cache-Control": "no-store" },
);
}
if (error instanceof DeviceSchemaError) {
return jsonResponse(
{ error: "invalid_device_registration" },
400,
{ "Cache-Control": "no-store" },
);
}
if (error instanceof DevicePersistenceError) {
return jsonResponse(
{ error: "device_registration_failed" },
500,
{ "Cache-Control": "no-store" },
);
}
throw error;
}
},
);
}
if (url.pathname === "/api/devices/approve") {
return withAuthenticatedApiControls(
request,
env,
"devices.approve",
["POST"],
async (context) => {
try {
return jsonResponse(await approveDeviceDocument(request, env, context), 200, {
"Cache-Control": "no-store",
});
} catch (error) {
if (error instanceof DevicePermissionError) {
return jsonResponse(
{ error: "device_approval_forbidden" },
403,
{ "Cache-Control": "no-store" },
);
}
if (error instanceof DeviceSchemaError) {
return jsonResponse(
{ error: "invalid_device_approval" },
400,
{ "Cache-Control": "no-store" },
);
}
if (error instanceof DevicePersistenceError) {
return jsonResponse(
{ error: "device_approval_failed" },
500,
{ "Cache-Control": "no-store" },
);
}
throw error;
}
},
);
}
if (url.pathname === "/api/devices/revoke") {
return withAuthenticatedApiControls(
request,
env,
"devices.revoke",
["POST"],
async (context) => {
try {
return jsonResponse(await revokeDeviceDocument(request, env, context), 200, {
"Cache-Control": "no-store",
});
} catch (error) {
if (error instanceof DevicePermissionError) {
return jsonResponse(
{ error: "device_revocation_forbidden" },
403,
{ "Cache-Control": "no-store" },
);
}
if (error instanceof DeviceSchemaError) {
return jsonResponse(
{ error: "invalid_device_revocation" },
400,
{ "Cache-Control": "no-store" },
);
}
if (error instanceof DevicePersistenceError) {
return jsonResponse(
{ error: "device_revocation_failed" },
500,
{ "Cache-Control": "no-store" },
);
}
throw error;
}
},
);
const deviceResponse = await handleDeviceRoute(request, env, url);
if (deviceResponse !== null) {
return deviceResponse;
}
if (url.pathname === "/api/account/delete") {
return withApprovedDeviceApiControls(
return withAuthenticatedApiControls(
request,
env,
"account.delete",
@@ -214,7 +82,18 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
{ "Cache-Control": "no-store" },
);
}
if (error instanceof AccountDeletionPersistenceError) {
if (error instanceof RecentDeviceActionPermissionError) {
return jsonResponse(
{ error: "account_deletion_forbidden" },
403,
{ "Cache-Control": "no-store" },
);
}
if (
error instanceof AccountDeletionPersistenceError ||
error instanceof RecentDeviceActionPersistenceError ||
error instanceof DestructiveActionGateError
) {
return jsonResponse(
{ error: "account_deletion_failed" },
500,
+70
View File
@@ -0,0 +1,70 @@
import { authSessionCacheKvKey, authTokenHash } from "./auth.js";
import type { Env } from "./bindings.js";
const LIST_LIMIT = 1000;
const MAX_LIST_PAGES = 10;
interface KvListResult {
keys: { name: string }[];
list_complete: boolean;
cursor?: string;
}
interface ListableKv {
list(options: { prefix: string; cursor?: string; limit: number }): Promise<KvListResult>;
}
export class LegacyAuthKvCleanupError extends Error {}
export async function deleteLegacySessionKeys(
env: Env,
tokens: string[],
currentTokenHash: string,
): Promise<number> {
const keys = new Set<string>([
authSessionCacheKvKey(env.ELY_ENVIRONMENT, currentTokenHash),
]);
for (const token of tokens) {
keys.add(authSessionCacheKvKey(env.ELY_ENVIRONMENT, await authTokenHash(token)));
}
let deleted = 0;
for (const key of keys) {
if (await env.ELY_KV.get(key) === null) continue;
await env.ELY_KV.delete(key);
deleted += 1;
}
return deleted;
}
export async function purgeLegacySessionCache(
env: Env,
maxPages = MAX_LIST_PAGES,
): Promise<number> {
const namespace = env.ELY_KV as Env["ELY_KV"] & Partial<ListableKv>;
if (typeof namespace.list !== "function") {
throw new LegacyAuthKvCleanupError("legacy_auth_kv_list_unavailable");
}
const prefix = authSessionCacheKvKey(env.ELY_ENVIRONMENT, "0".repeat(64)).slice(0, -64);
let cursor: string | undefined;
let deleted = 0;
for (let page = 0; page < maxPages; page += 1) {
const result = await namespace.list({
prefix,
...(cursor === undefined ? {} : { cursor }),
limit: LIST_LIMIT,
});
for (const key of result.keys) {
if (!key.name.startsWith(prefix)) {
throw new LegacyAuthKvCleanupError("legacy_auth_kv_key_invalid");
}
await namespace.delete(key.name);
deleted += 1;
}
if (result.list_complete) break;
if (typeof result.cursor !== "string" || result.cursor.length === 0) {
throw new LegacyAuthKvCleanupError("legacy_auth_kv_cursor_invalid");
}
cursor = result.cursor;
}
return deleted;
}
+228
View File
@@ -0,0 +1,228 @@
import type { AuthContext } from "./auth.js";
import type { ElyD1Result, Env } from "./bindings.js";
import type { PendingDeviceRevocationRequest } from "./device_revocation_schema.js";
import {
type DeviceRevocationDocument,
type DeviceRow,
DeviceConflictError,
DevicePermissionError,
DevicePersistenceError,
deviceDocument,
} from "./device_schema.js";
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 PENDING_RESULT_QUERY = `
SELECT
revocation.target_device_id, revocation.approver_device_id,
revocation.request_hash, revocation.completed_at,
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 = revocation.user_id
AND context.device_id = revocation.target_device_id) AS active_session_count,
(SELECT COUNT(*) FROM audit_events AS audit
WHERE audit.event_id = revocation.audit_event_id
AND audit.user_id = revocation.user_id
AND audit.actor_device_id = revocation.approver_device_id
AND audit.event_type = 'device.revoke'
AND audit.subject_id = revocation.target_device_id
AND audit.outcome = 'success'
AND audit.metadata_hash = revocation.request_hash
AND audit.created_at = revocation.completed_at) AS audit_count
FROM pending_device_revocations AS revocation
LEFT JOIN user_devices AS target
ON target.user_id = revocation.user_id AND target.device_id = revocation.target_device_id
WHERE revocation.user_id = ? AND revocation.idempotency_key = ?
`;
const PENDING_INSERT_QUERY = `
INSERT INTO pending_device_revocations (
user_id, idempotency_key, audit_event_id, target_device_id,
approver_device_id, request_hash, created_at, completed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL)
ON CONFLICT(user_id, idempotency_key) DO NOTHING
`;
const PENDING_FINALIZE_QUERY = `
UPDATE pending_device_revocations
SET completed_at = ?
WHERE user_id = ? AND idempotency_key = ? AND completed_at IS NULL
AND target_device_id = ? AND approver_device_id = ? AND request_hash = ?
`;
interface PendingResultRow {
target_device_id: unknown;
approver_device_id: unknown;
request_hash: unknown;
completed_at: unknown;
target_status: unknown;
revoked_at: unknown;
active_session_count: unknown;
audit_count: unknown;
}
export async function revokePendingDeviceDocument(
env: Env,
context: AuthContext,
approverDeviceId: string,
revocation: PendingDeviceRevocationRequest,
requestHash: string,
nowSeconds: number,
): Promise<DeviceRevocationDocument> {
const existing = await pendingResult(env, context.userId, revocation.idempotencyKey);
if (existing !== null) {
return completedPendingDocument(
env,
context.userId,
approverDeviceId,
revocation,
requestHash,
existing,
);
}
const target = await deviceRowById(env, context.userId, revocation.deviceId);
if (target === null) {
throw new DevicePermissionError("device_not_found");
}
const targetDocument = deviceDocument(target, approverDeviceId);
if (targetDocument.approval_status !== "pending" || targetDocument.revoked_at !== null) {
throw new DeviceConflictError("pending_device_revocation_target_invalid");
}
let results: ElyD1Result[];
try {
results = await env.ELY_DB.batch<ElyD1Result>([
env.ELY_DB.prepare(PENDING_INSERT_QUERY).bind(
context.userId,
revocation.idempotencyKey,
`pending-device-revoke:${requestHash}`,
revocation.deviceId,
approverDeviceId,
requestHash,
nowSeconds,
),
env.ELY_DB.prepare(PENDING_FINALIZE_QUERY).bind(
nowSeconds,
context.userId,
revocation.idempotencyKey,
revocation.deviceId,
approverDeviceId,
requestHash,
),
]);
} catch (error) {
if (pendingConflict(error)) {
throw new DeviceConflictError("pending_device_revocation_race");
}
throw error;
}
const finalizeChanges = changedRowCount(results.at(-1));
if (finalizeChanges > 1) {
throw new DevicePersistenceError("pending_device_revocation_write_count_invalid");
}
const completed = await pendingResult(env, context.userId, revocation.idempotencyKey);
if (completed === null) {
throw new DeviceConflictError("pending_device_revocation_race");
}
try {
return await completedPendingDocument(
env,
context.userId,
approverDeviceId,
revocation,
requestHash,
completed,
);
} catch (error) {
if (finalizeChanges === 0 && error instanceof DeviceConflictError) {
throw new DeviceConflictError("pending_device_revocation_race");
}
throw error;
}
}
async function completedPendingDocument(
env: Env,
userId: string,
approverDeviceId: string,
revocation: PendingDeviceRevocationRequest,
requestHash: string,
result: PendingResultRow,
): Promise<DeviceRevocationDocument> {
if (
result.target_device_id !== revocation.deviceId ||
result.approver_device_id !== approverDeviceId ||
result.request_hash !== requestHash
) {
throw new DeviceConflictError("pending_device_revocation_replay_mismatch");
}
const completedAt = storedInteger(result.completed_at, "completed_at");
if (
result.target_status !== "revoked" ||
result.revoked_at !== completedAt ||
result.active_session_count !== 0 ||
result.audit_count !== 1
) {
throw new DevicePersistenceError("pending_device_revocation_result_invalid");
}
const row = await deviceRowById(env, userId, revocation.deviceId);
if (row === null) {
throw new DevicePersistenceError("pending_device_revocation_missing");
}
const device = deviceDocument(row, approverDeviceId);
if (device.approval_status !== "revoked" || device.revoked_at !== completedAt) {
throw new DevicePersistenceError("pending_device_revocation_state_invalid");
}
return {
version: 2,
mode: "pending_revoke",
user_id: userId,
revoked_by_device_id: approverDeviceId,
revoked_at: completedAt,
device,
};
}
function pendingResult(
env: Env,
userId: string,
idempotencyKey: string,
): Promise<PendingResultRow | null> {
return env.ELY_DB.prepare(PENDING_RESULT_QUERY)
.bind(userId, idempotencyKey)
.first<PendingResultRow>();
}
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>();
}
function storedInteger(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
throw new DevicePersistenceError(`${label}_invalid`);
}
return value;
}
function changedRowCount(result: ElyD1Result | undefined): number {
const changes = result?.meta?.changes;
if (typeof changes !== "number" || !Number.isSafeInteger(changes) || changes < 0) {
throw new DevicePersistenceError("pending_device_revocation_write_result_invalid");
}
return changes;
}
function pendingConflict(error: unknown): boolean {
return error instanceof Error && (
error.message.includes("pending_device_revocation_guard_failed") ||
error.message.includes("FOREIGN KEY constraint failed")
);
}
@@ -0,0 +1,154 @@
import type { AuthContext } from "./auth.js";
import type { ElyD1DatabaseSession } from "./bindings.js";
import { verifyEd25519Signature } from "./device_crypto.js";
const ACTION_PROOF_DOMAIN = "elydora-sensitive-action-v2";
const PROOF_MAX_AGE_SECONDS = 5 * 60;
const PROOF_CLOCK_SKEW_SECONDS = 30;
const PUBLIC_KEY_PATTERN = /^[a-f0-9]{64}$/;
const SIGNATURE_PATTERN = /^[a-f0-9]{128}$/;
const APPROVED_DEVICE_SIGNING_KEY_QUERY = `
SELECT 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
`;
export type SensitiveAction = "account.delete" | "sync.reset";
export interface RecentDeviceActionProof {
proofCreatedAt: number;
actionProof: string;
}
export interface RecentDeviceActionProofFields extends RecentDeviceActionProof {
action: SensitiveAction;
userId: string;
sessionId: string;
deviceId: string;
confirmation: string;
idempotencyKey: string;
}
interface SigningKeyRow { signing_public_key: unknown }
export class RecentDeviceActionRequestError extends Error {}
export class RecentDeviceActionPermissionError extends Error {}
export class RecentDeviceActionPersistenceError extends Error {}
export function recentDeviceActionProof(
proofCreatedAt: unknown,
actionProof: unknown,
): RecentDeviceActionProof {
if (
typeof proofCreatedAt !== "number" ||
!Number.isSafeInteger(proofCreatedAt) ||
proofCreatedAt < 1
) {
throw new RecentDeviceActionRequestError("proof_created_at_invalid");
}
if (typeof actionProof !== "string" || !SIGNATURE_PATTERN.test(actionProof)) {
throw new RecentDeviceActionRequestError("action_proof_invalid");
}
return { proofCreatedAt, actionProof };
}
export async function assertRecentDeviceActionProof(
database: ElyD1DatabaseSession,
context: AuthContext,
action: SensitiveAction,
confirmation: string,
idempotencyKey: string,
proof: RecentDeviceActionProof,
): Promise<string> {
if (context.deviceId === undefined) {
throw new RecentDeviceActionPermissionError("device_context_required");
}
const row = await database.prepare(APPROVED_DEVICE_SIGNING_KEY_QUERY)
.bind(context.userId, context.deviceId)
.first<SigningKeyRow>();
if (row === null) {
throw new RecentDeviceActionPermissionError("device_action_forbidden");
}
if (
typeof row.signing_public_key !== "string" ||
!PUBLIC_KEY_PATTERN.test(row.signing_public_key)
) {
throw new RecentDeviceActionPersistenceError("device_signing_key_invalid");
}
const fields: RecentDeviceActionProofFields = {
action,
userId: context.userId,
sessionId: context.sessionId,
deviceId: context.deviceId,
confirmation,
idempotencyKey,
...proof,
};
if (!(await verifyEd25519Signature(
row.signing_public_key,
proof.actionProof,
recentDeviceActionProofBytes(fields),
))) {
throw new RecentDeviceActionPermissionError("device_action_proof_invalid");
}
return row.signing_public_key;
}
export function assertFreshDeviceActionProof(
proof: RecentDeviceActionProof,
nowSeconds: number,
freshnessRequired: boolean,
): void {
if (freshnessRequired && (
proof.proofCreatedAt < nowSeconds - PROOF_MAX_AGE_SECONDS ||
proof.proofCreatedAt > nowSeconds + PROOF_CLOCK_SKEW_SECONDS
)) {
throw new RecentDeviceActionPermissionError("device_action_proof_expired");
}
}
export function recentDeviceActionProofBytes(
fields: Omit<RecentDeviceActionProofFields, "actionProof">,
): Uint8Array {
return canonicalBytes(deviceActionProofValues(fields));
}
export async function recentDeviceActionRequestHash(
fields: RecentDeviceActionProofFields,
): Promise<string> {
const digest = await crypto.subtle.digest(
"SHA-256",
canonicalBytes([...deviceActionProofValues(fields), fields.actionProof]),
);
return [...new Uint8Array(digest)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
function deviceActionProofValues(
fields: Omit<RecentDeviceActionProofFields, "actionProof">,
): (number | string)[] {
return [
ACTION_PROOF_DOMAIN,
fields.action,
fields.userId,
fields.sessionId,
fields.deviceId,
fields.confirmation,
fields.idempotencyKey,
fields.proofCreatedAt,
];
}
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(""));
}
+20 -9
View File
@@ -54,11 +54,19 @@ export function syncSnapshotKey(params: {
region: string;
userHash: string;
snapshotId: string;
payloadHash: string;
}): string {
assertRegion(params.region);
assertSha256Hex(params.userHash, "user_hash");
assertSegment(params.snapshotId, "snapshot_id");
return ["sync-snapshots", params.region, params.userHash, `${params.snapshotId}.bin`].join("/");
assertSha256Hex(params.payloadHash, "payload_hash");
return [
"sync-snapshots",
params.region,
params.userHash,
params.snapshotId,
`${params.payloadHash}.bin`,
].join("/");
}
export function pluginPackageKey(params: { pluginId: string; packageHash: string }): string {
@@ -101,9 +109,7 @@ export async function putVerifiedObject(
expectedSha256: string,
contentType: string,
): Promise<StoredObject> {
assertKnownObjectKey(key);
assertSha256Hex(expectedSha256, "sha256");
assertKeyChecksum(key, expectedSha256);
assertKnownObjectKeyHash(key, expectedSha256);
const actualSha256 = await sha256Hex(payload);
if (actualSha256 !== expectedSha256) {
throw new StorageObjectError("r2_checksum_mismatch");
@@ -122,9 +128,7 @@ export async function getVerifiedObject(
key: string,
expectedSha256: string,
): Promise<ArrayBuffer | null> {
assertKnownObjectKey(key);
assertSha256Hex(expectedSha256, "sha256");
assertKeyChecksum(key, expectedSha256);
assertKnownObjectKeyHash(key, expectedSha256);
const object = await bucket.get(key);
if (object === null) {
return null;
@@ -143,10 +147,11 @@ export async function deleteKnownObject(bucket: ElyR2Bucket, key: string): Promi
await bucket.delete(key);
}
function assertKnownObjectKey(key: string): void {
export function assertKnownObjectKey(key: string): void {
const matches = [
/^sync-payloads\/[a-z0-9][a-z0-9-]{1,31}\/[a-f0-9]{64}\/[a-z0-9][a-z0-9._-]{0,127}\/[a-z0-9][a-z0-9._-]{0,127}\/[a-f0-9]{64}\.bin$/,
/^sync-snapshots\/[a-z0-9][a-z0-9-]{1,31}\/[a-f0-9]{64}\/[a-z0-9][a-z0-9._-]{0,127}\.bin$/,
/^sync-snapshots\/[a-z0-9][a-z0-9-]{1,31}\/[a-f0-9]{64}\/[a-z0-9][a-z0-9._-]{0,127}\/[a-f0-9]{64}\.bin$/,
/^plugin-packages\/[a-z0-9][a-z0-9._-]{0,127}\/[a-f0-9]{64}\.rplug$/,
/^plugin-assets\/[a-z0-9][a-z0-9._-]{0,127}\/[a-f0-9]{64}$/,
/^user-avatars\/[a-f0-9]{64}\/[a-f0-9]{64}$/,
@@ -161,6 +166,12 @@ function assertKnownObjectKey(key: string): void {
}
}
export function assertKnownObjectKeyHash(key: string, expectedSha256: string): void {
assertKnownObjectKey(key);
assertSha256Hex(expectedSha256, "sha256");
assertKeyChecksum(key, expectedSha256);
}
function assertKeyChecksum(key: string, expectedSha256: string): void {
const keyChecksum = checksumFromKey(key);
if (keyChecksum !== null && keyChecksum !== expectedSha256) {
@@ -176,7 +187,7 @@ function checksumFromKey(key: string): string | null {
return null;
}
if (prefix === "sync-payloads") {
if (prefix === "sync-payloads" || (prefix === "sync-snapshots" && segments.length === 5)) {
return lastSegment.slice(0, -".bin".length);
}
if (prefix === "plugin-packages") {
-201
View File
@@ -1,201 +0,0 @@
import type { AuthContext } from "./auth.js";
import type { Env } from "./bindings.js";
const DEFAULT_SYNC_PULL_LIMIT = 100;
const MAX_SYNC_PULL_LIMIT = 500;
const SYNC_OBJECT_ID_PATTERN = /^[a-zA-Z0-9._:-]{1,128}$/;
const SYNC_OBJECT_TYPE_PATTERN = /^[a-z0-9][a-z0-9._:-]{0,127}$/;
const SHA256_HEX = /^[a-f0-9]{64}$/;
const SYNC_CHANGE_LOG_QUERY = `
SELECT
change_id,
object_id,
object_type,
operation,
payload_hash,
logical_clock,
device_id,
created_at
FROM sync_change_log
WHERE user_id = ? AND change_id > ?
ORDER BY change_id ASC
LIMIT ?
`;
export interface SyncPullDocument {
version: 1;
user_id: string;
device_id: string;
cursor: number;
next_cursor: number;
has_more: boolean;
changes: SyncChangeDocument[];
}
export interface SyncChangeDocument {
change_id: number;
object_id: string;
object_type: string;
operation: "upsert" | "delete";
payload_hash: string;
logical_clock: number;
device_id: string;
created_at: number;
}
interface SyncChangeRow {
change_id: unknown;
object_id: unknown;
object_type: unknown;
operation: unknown;
payload_hash: unknown;
logical_clock: unknown;
device_id: unknown;
created_at: unknown;
}
interface SyncPullQuery {
cursor: number;
limit: number;
}
export class SyncSchemaError extends Error {
constructor(message: string) {
super(message);
this.name = "SyncSchemaError";
}
}
export class SyncRequestError extends Error {
constructor(message: string) {
super(message);
this.name = "SyncRequestError";
}
}
export async function syncPullDocument(
url: URL,
env: Env,
context: AuthContext,
): Promise<SyncPullDocument> {
const deviceId = currentDeviceId(context);
const query = syncPullQuery(url);
const result = await env.ELY_DB.prepare(SYNC_CHANGE_LOG_QUERY)
.bind(context.userId, query.cursor, query.limit + 1)
.all<SyncChangeRow>();
const rows = result.results.slice(0, query.limit);
const changes = rows.map(syncChangeDocument);
return {
version: 1,
user_id: context.userId,
device_id: deviceId,
cursor: query.cursor,
next_cursor: changes.at(-1)?.change_id ?? query.cursor,
has_more: result.results.length > query.limit,
changes,
};
}
function syncPullQuery(url: URL): SyncPullQuery {
assertOnlyQueryParams(url, ["cursor", "limit"]);
const cursor = requiredQueryInteger(url, "cursor", 0, Number.MAX_SAFE_INTEGER);
const limit = optionalQueryInteger(url, "limit", 1, MAX_SYNC_PULL_LIMIT) ?? DEFAULT_SYNC_PULL_LIMIT;
return { cursor, limit };
}
function syncChangeDocument(row: SyncChangeRow): SyncChangeDocument {
return {
change_id: integerValue(row.change_id, "change_id", 0, Number.MAX_SAFE_INTEGER),
object_id: objectId(row.object_id),
object_type: objectType(row.object_type),
operation: operation(row.operation),
payload_hash: payloadHash(row.payload_hash),
logical_clock: integerValue(row.logical_clock, "logical_clock", 0, Number.MAX_SAFE_INTEGER),
device_id: objectId(row.device_id),
created_at: integerValue(row.created_at, "created_at", 0, Number.MAX_SAFE_INTEGER),
};
}
function currentDeviceId(context: AuthContext): string {
if (context.deviceId === undefined) {
throw new SyncSchemaError("device_context_required");
}
return context.deviceId;
}
function assertOnlyQueryParams(url: URL, fields: string[]): void {
const allowed = new Set(fields);
for (const field of url.searchParams.keys()) {
if (!allowed.has(field)) {
throw new SyncRequestError(`unexpected_query:${field}`);
}
}
}
function requiredQueryInteger(url: URL, field: string, min: number, max: number): number {
const value = url.searchParams.get(field);
if (value === null) {
throw new SyncRequestError(`${field}_required`);
}
return queryInteger(value, field, min, max);
}
function optionalQueryInteger(
url: URL,
field: string,
min: number,
max: number,
): number | undefined {
const value = url.searchParams.get(field);
if (value === null) {
return undefined;
}
return queryInteger(value, field, min, max);
}
function queryInteger(value: string, field: string, min: number, max: number): number {
if (!/^[0-9]+$/.test(value)) {
throw new SyncRequestError(`${field}_invalid`);
}
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed < min || parsed > max) {
throw new SyncRequestError(`${field}_invalid`);
}
return parsed;
}
function integerValue(value: unknown, field: string, min: number, max: number): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min || value > max) {
throw new SyncSchemaError(`${field}_invalid`);
}
return value;
}
function objectId(value: unknown): string {
if (typeof value !== "string" || !SYNC_OBJECT_ID_PATTERN.test(value)) {
throw new SyncSchemaError("object_id_invalid");
}
return value;
}
function objectType(value: unknown): string {
if (typeof value !== "string" || !SYNC_OBJECT_TYPE_PATTERN.test(value)) {
throw new SyncSchemaError("object_type_invalid");
}
return value;
}
function operation(value: unknown): SyncChangeDocument["operation"] {
if (value !== "upsert" && value !== "delete") {
throw new SyncSchemaError("operation_invalid");
}
return value;
}
function payloadHash(value: unknown): string {
if (typeof value !== "string" || !SHA256_HEX.test(value)) {
throw new SyncSchemaError("payload_hash_invalid");
}
return value;
}
-281
View File
@@ -1,281 +0,0 @@
import type { AuthContext } from "./auth.js";
import type { Env } from "./bindings.js";
import type { ElyD1PreparedStatement } from "./bindings.js";
import { StorageObjectError, putVerifiedObject } from "./storage.js";
import {
type SyncObjectRow,
SyncPushConflictError,
type SyncPushDocument,
SyncPushPersistenceError,
type SyncPushRequest,
SyncPushRequestError,
type SyncPushedObjectDocument,
currentDeviceId,
syncObjectDocument,
syncPushRequest,
} from "./sync_push_schema.js";
export {
SyncPushConflictError,
SyncPushPersistenceError,
SyncPushRequestError,
} from "./sync_push_schema.js";
const SYNC_OBJECT_BY_ID_QUERY = `
SELECT
object_id,
object_type,
payload_r2_key,
payload_hash,
schema_rev,
logical_clock,
device_id,
created_at,
updated_at,
deleted_at
FROM sync_objects
WHERE user_id = ? AND object_id = ?
`;
const SYNC_OBJECT_UPSERT_QUERY = `
INSERT INTO sync_objects (
user_id,
object_id,
object_type,
payload_inline,
payload_r2_key,
payload_hash,
schema_rev,
logical_clock,
device_id,
created_at,
updated_at,
deleted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, object_id) DO UPDATE SET
object_type = excluded.object_type,
payload_inline = excluded.payload_inline,
payload_r2_key = excluded.payload_r2_key,
payload_hash = excluded.payload_hash,
schema_rev = excluded.schema_rev,
logical_clock = excluded.logical_clock,
device_id = excluded.device_id,
updated_at = excluded.updated_at,
deleted_at = excluded.deleted_at
WHERE excluded.logical_clock > sync_objects.logical_clock
OR (
excluded.logical_clock = sync_objects.logical_clock
AND sync_objects.object_type = excluded.object_type
AND (
(sync_objects.payload_r2_key IS NULL AND excluded.payload_r2_key IS NULL)
OR sync_objects.payload_r2_key = excluded.payload_r2_key
)
AND sync_objects.payload_hash = excluded.payload_hash
AND sync_objects.schema_rev = excluded.schema_rev
AND sync_objects.device_id = excluded.device_id
AND (
(sync_objects.deleted_at IS NULL AND excluded.deleted_at IS NULL)
OR (sync_objects.deleted_at IS NOT NULL AND excluded.deleted_at IS NOT NULL)
)
)
`;
const SYNC_CHANGE_INSERT_QUERY = `
INSERT INTO sync_change_log (
user_id,
object_id,
object_type,
operation,
payload_hash,
logical_clock,
device_id,
created_at
)
SELECT ?, ?, ?, ?, ?, ?, ?, ?
WHERE EXISTS (
SELECT 1
FROM sync_objects
WHERE user_id = ?
AND object_id = ?
AND object_type = ?
AND payload_hash = ?
AND logical_clock = ?
AND device_id = ?
AND ((? = 1 AND deleted_at IS NOT NULL) OR (? = 0 AND deleted_at IS NULL))
)
ON CONFLICT(user_id, object_id, logical_clock, device_id, operation) DO NOTHING
`;
const SYNC_TOMBSTONE_UPSERT_QUERY = `
INSERT INTO sync_tombstones (
user_id,
object_id,
object_type,
logical_clock,
device_id,
deleted_at
)
SELECT user_id, object_id, object_type, logical_clock, device_id, deleted_at
FROM sync_objects
WHERE user_id = ? AND object_id = ? AND logical_clock = ? AND deleted_at IS NOT NULL
ON CONFLICT(user_id, object_id) DO UPDATE SET
object_type = excluded.object_type,
logical_clock = excluded.logical_clock,
device_id = excluded.device_id,
deleted_at = excluded.deleted_at
WHERE excluded.logical_clock >= sync_tombstones.logical_clock
`;
export async function syncPushDocument(
request: Request,
env: Env,
context: AuthContext,
nowSeconds = Math.floor(Date.now() / 1000),
): Promise<SyncPushDocument> {
const deviceId = currentDeviceId(context);
const push = await syncPushRequest(request, context.userId);
const existingRow = await env.ELY_DB.prepare(SYNC_OBJECT_BY_ID_QUERY)
.bind(context.userId, push.objectId)
.first<SyncObjectRow>();
if (existingRow !== null) {
assertPushCanReplaceExisting(push, deviceId, syncObjectDocument(existingRow));
}
await persistR2PayloadIfNeeded(env, push);
await env.ELY_DB.batch(syncPushStatements(env, context.userId, deviceId, push, nowSeconds));
const savedRow = await env.ELY_DB.prepare(SYNC_OBJECT_BY_ID_QUERY)
.bind(context.userId, push.objectId)
.first<SyncObjectRow>();
if (savedRow === null) {
throw new SyncPushPersistenceError("sync_object_missing");
}
const object = syncObjectDocument(savedRow);
assertSavedObjectMatchesPush(push, deviceId, object);
return { version: 1, user_id: context.userId, device_id: deviceId, object };
}
async function persistR2PayloadIfNeeded(env: Env, push: SyncPushRequest): Promise<void> {
if (push.payload.kind !== "r2") {
return;
}
try {
await putVerifiedObject(
env.ELY_STORAGE,
push.payload.r2Key,
push.payload.bytes,
push.payloadHash,
"application/octet-stream",
);
} catch (error) {
if (error instanceof StorageObjectError) {
throw new SyncPushRequestError(error.message);
}
throw error;
}
}
function syncPushStatements(
env: Env,
userId: string,
deviceId: string,
push: SyncPushRequest,
nowSeconds: number,
): ElyD1PreparedStatement[] {
const deletedAt = push.operation === "delete" ? nowSeconds : null;
const isDelete = push.operation === "delete" ? 1 : 0;
const statements = [
env.ELY_DB.prepare(SYNC_OBJECT_UPSERT_QUERY).bind(
userId,
push.objectId,
push.objectType,
push.payload.kind === "inline" ? push.payload.bytes : null,
push.payload.r2Key,
push.payloadHash,
push.schemaRev,
push.logicalClock,
deviceId,
nowSeconds,
nowSeconds,
deletedAt,
),
env.ELY_DB.prepare(SYNC_CHANGE_INSERT_QUERY).bind(
userId,
push.objectId,
push.objectType,
push.operation,
push.payloadHash,
push.logicalClock,
deviceId,
nowSeconds,
userId,
push.objectId,
push.objectType,
push.payloadHash,
push.logicalClock,
deviceId,
isDelete,
isDelete,
),
];
if (push.operation === "delete") {
statements.push(
env.ELY_DB.prepare(SYNC_TOMBSTONE_UPSERT_QUERY).bind(
userId,
push.objectId,
push.logicalClock,
),
);
}
return statements;
}
function assertPushCanReplaceExisting(
push: SyncPushRequest,
deviceId: string,
existing: SyncPushedObjectDocument,
): void {
if (existing.logical_clock > push.logicalClock) {
throw new SyncPushConflictError("logical_clock_stale");
}
if (existing.logical_clock < push.logicalClock) {
return;
}
if (
existing.operation !== push.operation ||
existing.payload_hash !== push.payloadHash ||
existing.device_id !== deviceId
) {
throw new SyncPushConflictError("logical_clock_conflict");
}
}
function assertSavedObjectMatchesPush(
push: SyncPushRequest,
deviceId: string,
object: SyncPushedObjectDocument,
): void {
if (object.logical_clock > push.logicalClock) {
throw new SyncPushConflictError("logical_clock_stale");
}
if (
object.logical_clock === push.logicalClock &&
(object.object_type !== push.objectType ||
object.operation !== push.operation ||
object.payload_hash !== push.payloadHash ||
object.schema_rev !== push.schemaRev ||
object.device_id !== deviceId ||
object.payload_r2_key !== push.payload.r2Key)
) {
throw new SyncPushConflictError("logical_clock_conflict");
}
if (
object.object_id !== push.objectId ||
object.object_type !== push.objectType ||
object.operation !== push.operation ||
object.payload_hash !== push.payloadHash ||
object.schema_rev !== push.schemaRev ||
object.logical_clock !== push.logicalClock ||
object.device_id !== deviceId
) {
throw new SyncPushPersistenceError("sync_object_mismatch");
}
}
-348
View File
@@ -1,348 +0,0 @@
import type { AuthContext } from "./auth.js";
import {
StorageObjectError,
assertSyncObjectType,
syncPayloadKey,
} from "./storage.js";
const MAX_INLINE_PAYLOAD_BYTES = 64 * 1024;
const MAX_R2_PAYLOAD_BYTES = 10 * 1024 * 1024;
const SYNC_OBJECT_ID_PATTERN = /^[a-zA-Z0-9._:-]{1,128}$/;
const SYNC_OBJECT_TYPE_PATTERN = /^[a-z0-9][a-z0-9._:-]{0,127}$/;
const SHA256_HEX = /^[a-f0-9]{64}$/;
const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
const REGION = /^[a-z0-9][a-z0-9-]{1,31}$/;
export interface SyncPushDocument {
version: 1;
user_id: string;
device_id: string;
object: SyncPushedObjectDocument;
}
export interface SyncPushedObjectDocument {
object_id: string;
object_type: string;
operation: "upsert" | "delete";
payload_hash: string;
schema_rev: number;
logical_clock: number;
device_id: string;
created_at: number;
updated_at: number;
deleted_at: number | null;
payload_storage: "inline" | "r2" | "tombstone";
payload_r2_key: string | null;
}
export interface SyncPushRequest {
objectId: string;
objectType: string;
operation: "upsert" | "delete";
payloadHash: string;
schemaRev: number;
logicalClock: number;
payload: SyncPushPayload;
}
export type SyncPushPayload =
| { kind: "inline"; bytes: ArrayBuffer; r2Key: null }
| { kind: "r2"; bytes: ArrayBuffer; region: string; r2Key: string }
| { kind: "tombstone"; bytes: null; r2Key: null };
export interface SyncObjectRow {
object_id: unknown;
object_type: unknown;
payload_r2_key: unknown;
payload_hash: unknown;
schema_rev: unknown;
logical_clock: unknown;
device_id: unknown;
created_at: unknown;
updated_at: unknown;
deleted_at: unknown;
}
type RequestBody = Record<string, unknown>;
export class SyncPushRequestError extends Error {
constructor(message: string) {
super(message);
this.name = "SyncPushRequestError";
}
}
export class SyncPushConflictError extends Error {
constructor(message: string) {
super(message);
this.name = "SyncPushConflictError";
}
}
export class SyncPushPersistenceError extends Error {
constructor(message: string) {
super(message);
this.name = "SyncPushPersistenceError";
}
}
export function currentDeviceId(context: AuthContext): string {
if (context.deviceId === undefined) {
throw new SyncPushRequestError("device_context_required");
}
return context.deviceId;
}
export async function syncPushRequest(
request: Request,
userId: string,
): Promise<SyncPushRequest> {
const body = await requestBody(request);
assertOnlyFields(body, [
"version",
"object_id",
"object_type",
"operation",
"payload_hash",
"schema_rev",
"logical_clock",
"payload",
]);
if (body.version !== 1) {
throw new SyncPushRequestError("version_invalid");
}
const objectId = syncObjectId(body.object_id);
const operation = syncOperation(body.operation);
const objectType = syncObjectType(body.object_type);
const payloadHash = sha256HexValue(body.payload_hash, "payload_hash");
const payload =
operation === "delete"
? tombstonePayload(body.payload)
: await upsertPayload(body.payload, userId, objectType, objectId, payloadHash);
return {
objectId,
objectType,
operation,
payloadHash,
schemaRev: integer(body.schema_rev, "schema_rev", 1, Number.MAX_SAFE_INTEGER),
logicalClock: integer(body.logical_clock, "logical_clock", 0, Number.MAX_SAFE_INTEGER),
payload,
};
}
export function syncObjectDocument(row: SyncObjectRow): SyncPushedObjectDocument {
const deletedAt = nullableInteger(row.deleted_at, "deleted_at", 0, Number.MAX_SAFE_INTEGER);
const payloadR2Key = nullableText(row.payload_r2_key, "payload_r2_key");
return {
object_id: syncObjectId(row.object_id),
object_type: syncObjectType(row.object_type),
operation: deletedAt === null ? "upsert" : "delete",
payload_hash: sha256HexValue(row.payload_hash, "payload_hash"),
schema_rev: integer(row.schema_rev, "schema_rev", 1, Number.MAX_SAFE_INTEGER),
logical_clock: integer(row.logical_clock, "logical_clock", 0, Number.MAX_SAFE_INTEGER),
device_id: syncObjectId(row.device_id),
created_at: integer(row.created_at, "created_at", 0, Number.MAX_SAFE_INTEGER),
updated_at: integer(row.updated_at, "updated_at", 0, Number.MAX_SAFE_INTEGER),
deleted_at: deletedAt,
payload_storage: deletedAt !== null ? "tombstone" : payloadR2Key === null ? "inline" : "r2",
payload_r2_key: payloadR2Key,
};
}
async function upsertPayload(
value: unknown,
userId: string,
objectType: string,
objectId: string,
payloadHash: string,
): Promise<SyncPushPayload> {
const payload = record(value, "payload");
const kind = text(payload.kind, "payload.kind");
if (kind === "inline") {
assertOnlyFields(payload, ["kind", "data_base64"]);
const bytes = payloadBytes(payload.data_base64, "payload.data_base64", MAX_INLINE_PAYLOAD_BYTES);
await assertPayloadHash(bytes, payloadHash);
return { kind, bytes, r2Key: null };
}
if (kind === "r2") {
assertOnlyFields(payload, ["kind", "region", "data_base64"]);
const region = regionValue(payload.region);
const bytes = payloadBytes(payload.data_base64, "payload.data_base64", MAX_R2_PAYLOAD_BYTES);
await assertPayloadHash(bytes, payloadHash);
return {
kind,
bytes,
region,
r2Key: await syncPayloadStorageKey(region, userId, objectType, objectId, payloadHash),
};
}
throw new SyncPushRequestError("payload.kind_invalid");
}
async function syncPayloadStorageKey(
region: string,
userId: string,
objectType: string,
objectId: string,
payloadHash: string,
): Promise<string> {
try {
return syncPayloadKey({
region,
userHash: await sha256Hex(arrayBufferFromBytes(new TextEncoder().encode(userId))),
objectType,
objectId,
payloadHash,
});
} catch (error) {
if (error instanceof StorageObjectError) {
throw new SyncPushRequestError(error.message);
}
throw error;
}
}
function tombstonePayload(value: unknown): SyncPushPayload {
if (value !== undefined) {
throw new SyncPushRequestError("payload_forbidden");
}
return { kind: "tombstone", bytes: null, r2Key: null };
}
async function requestBody(request: Request): Promise<RequestBody> {
let value: unknown;
try {
value = await request.json();
} catch {
throw new SyncPushRequestError("json_invalid");
}
return record(value, "body");
}
function record(value: unknown, label: string): RequestBody {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new SyncPushRequestError(`${label}_invalid`);
}
return value as RequestBody;
}
function assertOnlyFields(value: RequestBody, fields: string[]): void {
const allowed = new Set(fields);
for (const field of Object.keys(value)) {
if (!allowed.has(field)) {
throw new SyncPushRequestError(`unexpected_field:${field}`);
}
}
}
function syncOperation(value: unknown): SyncPushRequest["operation"] {
if (value !== "upsert" && value !== "delete") {
throw new SyncPushRequestError("operation_invalid");
}
return value;
}
function syncObjectId(value: unknown): string {
if (typeof value !== "string" || !SYNC_OBJECT_ID_PATTERN.test(value)) {
throw new SyncPushRequestError("object_id_invalid");
}
return value;
}
function syncObjectType(value: unknown): string {
if (typeof value !== "string" || !SYNC_OBJECT_TYPE_PATTERN.test(value)) {
throw new SyncPushRequestError("object_type_invalid");
}
try {
assertSyncObjectType(value);
} catch (error) {
if (error instanceof StorageObjectError) {
throw new SyncPushRequestError(error.message);
}
throw error;
}
return value;
}
function sha256HexValue(value: unknown, label: string): string {
if (typeof value !== "string" || !SHA256_HEX.test(value)) {
throw new SyncPushRequestError(`${label}_invalid`);
}
return value;
}
function integer(value: unknown, label: string, min: number, max: number): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min || value > max) {
throw new SyncPushRequestError(`${label}_invalid`);
}
return value;
}
function nullableInteger(value: unknown, label: string, min: number, max: number): number | null {
if (value === null) {
return null;
}
return integer(value, label, min, max);
}
function text(value: unknown, label: string): string {
if (typeof value !== "string" || value.length === 0) {
throw new SyncPushRequestError(`${label}_invalid`);
}
return value;
}
function nullableText(value: unknown, label: string): string | null {
if (value === null) {
return null;
}
return text(value, label);
}
function regionValue(value: unknown): string {
if (typeof value !== "string" || !REGION.test(value)) {
throw new SyncPushRequestError("payload.region_invalid");
}
return value;
}
function payloadBytes(value: unknown, label: string, maxBytes: number): ArrayBuffer {
const encoded = text(value, label);
if (!BASE64.test(encoded)) {
throw new SyncPushRequestError(`${label}_invalid`);
}
const bytes = bytesFromBase64(encoded);
if (bytes.byteLength === 0 || bytes.byteLength > maxBytes) {
throw new SyncPushRequestError(`${label}_size_invalid`);
}
return bytes;
}
function bytesFromBase64(value: string): ArrayBuffer {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes.buffer;
}
function arrayBufferFromBytes(bytes: Uint8Array): ArrayBuffer {
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return copy.buffer;
}
async function assertPayloadHash(payload: ArrayBuffer, expectedHash: string): Promise<void> {
const actualHash = await sha256Hex(payload);
if (actualHash !== expectedHash) {
throw new SyncPushRequestError("payload_hash_mismatch");
}
}
async function sha256Hex(payload: ArrayBuffer): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", payload);
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
+342
View File
@@ -0,0 +1,342 @@
import type {
ElyD1DatabaseSession,
ElyD1PreparedStatement,
ElyD1Result,
Env,
} from "./bindings.js";
import { primaryD1Session } from "./bindings.js";
import { StorageObjectError, assertKnownObjectKey, deleteKnownObject } from "./storage.js";
const WRITE_LEASE_SECONDS = 10 * 60;
const DELETE_RETRY_SECONDS = 60;
const DEFAULT_GC_LIMIT = 25;
const CLAIM_NEW_SNAPSHOT_QUERY = `
WITH write (
user_id, device_id, r2_key, owner_hash, key_id, generation,
head_revision, base_revision, base_snapshot_id, base_payload_hash,
write_token, now_seconds, lease_expires_at
) AS (VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?))
INSERT INTO sync_r2_gc_candidates (
r2_key, user_id, owner_hash, object_kind, state, write_token,
lease_expires_at, gc_token, created_at, updated_at, referenced_at,
ready_at, delete_started_at, deleted_at
)
SELECT
write.r2_key, write.user_id, write.owner_hash, 'snapshot', 'pending',
write.write_token, write.lease_expires_at, NULL, write.now_seconds,
write.now_seconds, NULL, NULL, NULL, NULL
FROM write
WHERE EXISTS (
SELECT 1
FROM sync_vault_accounts AS account
INNER JOIN user_devices AS device
ON device.user_id = account.user_id
AND device.device_id = write.device_id
AND device.approval_status = 'approved'
AND device.revoked_at IS NULL
INNER JOIN user_device_keys AS keys
ON keys.user_id = device.user_id
AND keys.device_id = device.device_id
AND keys.key_protocol_version = 2
AND keys.wrapping_public_key IS NOT NULL
WHERE account.user_id = write.user_id
AND account.current_key_id = write.key_id
AND account.current_generation = write.generation
)
AND (
(
write.head_revision = 1
AND write.base_revision IS NULL
AND write.base_snapshot_id IS NULL
AND write.base_payload_hash IS NULL
AND NOT EXISTS (
SELECT 1 FROM sync_snapshot_heads AS head
WHERE head.user_id = write.user_id
)
)
OR
(
write.base_revision IS NOT NULL
AND write.base_snapshot_id IS NOT NULL
AND write.base_payload_hash IS NOT NULL
AND write.head_revision = write.base_revision + 1
AND EXISTS (
SELECT 1 FROM sync_snapshot_heads AS head
WHERE head.user_id = write.user_id
AND head.head_revision = write.base_revision
AND head.snapshot_id = write.base_snapshot_id
AND head.payload_hash = write.base_payload_hash
)
)
)
ON CONFLICT(r2_key) DO UPDATE SET
write_token = excluded.write_token,
lease_expires_at = excluded.lease_expires_at,
updated_at = excluded.updated_at
WHERE sync_r2_gc_candidates.user_id = excluded.user_id
AND sync_r2_gc_candidates.owner_hash = excluded.owner_hash
AND sync_r2_gc_candidates.object_kind = 'snapshot'
AND sync_r2_gc_candidates.state = 'pending'
`;
export const SYNC_R2_MARK_REFERENCED_QUERY = `
UPDATE sync_r2_gc_candidates
SET state = 'referenced', lease_expires_at = ?, updated_at = ?, referenced_at = ?
WHERE r2_key = ? AND user_id = ?
AND object_kind = 'snapshot'
AND state = 'pending'
AND write_token = ?
AND lease_expires_at >= ?
`;
export const SYNC_R2_FENCE_USER_QUERY = `
UPDATE sync_r2_gc_candidates
SET
state = 'ready',
lease_expires_at = CASE WHEN state = 'pending' THEN lease_expires_at ELSE ? END,
updated_at = MAX(updated_at, ?),
ready_at = COALESCE(ready_at, ?)
WHERE user_id = ? AND state IN ('pending', 'referenced')
`;
export const SYNC_R2_ANONYMIZE_USER_QUERY = `
UPDATE sync_r2_gc_candidates
SET user_id = NULL, updated_at = MAX(updated_at, ?)
WHERE user_id = ? AND owner_hash = ?
`;
const ABANDON_WRITE_QUERY = `
UPDATE sync_r2_gc_candidates AS candidate
SET
state = 'ready',
lease_expires_at = ?,
updated_at = MAX(updated_at, ?),
ready_at = COALESCE(ready_at, ?)
WHERE candidate.r2_key = ?
AND candidate.owner_hash = ?
AND (candidate.user_id = ? OR candidate.user_id IS NULL)
AND candidate.state IN ('pending', 'ready')
AND candidate.write_token = ?
AND NOT EXISTS (SELECT 1 FROM sync_objects WHERE payload_r2_key = candidate.r2_key)
AND NOT EXISTS (SELECT 1 FROM sync_snapshots WHERE r2_key = candidate.r2_key)
`;
const CLAIMED_CANDIDATES_QUERY = `
SELECT r2_key
FROM sync_r2_gc_candidates
WHERE state = 'deleting' AND gc_token = ?
ORDER BY r2_key ASC
`;
const MARK_DELETED_QUERY = `
UPDATE sync_r2_gc_candidates
SET state = 'deleted', updated_at = ?, deleted_at = ?
WHERE r2_key = ? AND state = 'deleting' AND gc_token = ?
`;
interface SnapshotHeadRef {
revision: number;
snapshotId: string;
payloadHash: string;
}
export interface SyncR2SnapshotWriteClaim {
userId: string;
deviceId: string;
r2Key: string;
ownerHash: string;
keyId: string;
generation: number;
headRevision: number;
baseHead: SnapshotHeadRef | null;
}
export interface SyncR2WriteLease {
writeToken: string;
leaseExpiresAt: number;
}
interface CandidateRow { r2_key: unknown }
export class SyncR2GcError extends Error {}
export class SyncR2WriteFenceError extends Error {}
export async function claimSyncR2SnapshotWrite(
env: Env,
claim: SyncR2SnapshotWriteClaim,
nowSeconds: number,
writeToken = randomToken(),
database: ElyD1DatabaseSession = primaryD1Session(env.ELY_DB),
): Promise<SyncR2WriteLease> {
const leaseExpiresAt = nowSeconds + WRITE_LEASE_SECONDS;
const result = await database.prepare(CLAIM_NEW_SNAPSHOT_QUERY).bind(
claim.userId,
claim.deviceId,
claim.r2Key,
claim.ownerHash,
claim.keyId,
claim.generation,
claim.headRevision,
claim.baseHead?.revision ?? null,
claim.baseHead?.snapshotId ?? null,
claim.baseHead?.payloadHash ?? null,
writeToken,
nowSeconds,
leaseExpiresAt,
).run();
if (changedRows(result) !== 1) {
throw new SyncR2WriteFenceError("sync_r2_write_fenced");
}
return { writeToken, leaseExpiresAt };
}
export function syncR2MarkReferencedStatement(
database: ElyD1DatabaseSession,
userId: string,
r2Key: string,
lease: SyncR2WriteLease,
nowSeconds: number,
): ElyD1PreparedStatement {
return database.prepare(SYNC_R2_MARK_REFERENCED_QUERY).bind(
nowSeconds,
nowSeconds,
nowSeconds,
r2Key,
userId,
lease.writeToken,
nowSeconds,
);
}
export async function abandonSyncR2Write(
env: Env,
userId: string,
ownerHash: string,
r2Key: string,
writeToken: string,
nowSeconds: number,
database: ElyD1DatabaseSession = primaryD1Session(env.ELY_DB),
): Promise<void> {
await database.prepare(ABANDON_WRITE_QUERY).bind(
nowSeconds,
nowSeconds,
nowSeconds,
r2Key,
ownerHash,
userId,
writeToken,
).run();
}
export async function collectSyncR2Garbage(
env: Env,
nowSeconds: number,
options: {
userId?: string;
ownerHash?: string;
limit?: number;
database?: ElyD1DatabaseSession;
} = {},
): Promise<number> {
const database = options.database ?? primaryD1Session(env.ELY_DB);
const gcToken = randomToken();
const retryBefore = Math.max(0, nowSeconds - DELETE_RETRY_SECONDS);
const scope = options.userId === undefined
? options.ownerHash === undefined ? "global" : "owner"
: "user";
const scopeValue = options.userId ?? options.ownerHash;
await database.prepare(claimGarbageQuery(scope)).bind(
gcToken,
nowSeconds,
nowSeconds,
nowSeconds,
nowSeconds,
retryBefore,
...(scopeValue === undefined ? [] : [scopeValue]),
options.limit ?? DEFAULT_GC_LIMIT,
).run();
const claimed = await database.prepare(CLAIMED_CANDIDATES_QUERY)
.bind(gcToken)
.all<CandidateRow>();
for (const row of claimed.results) {
const key = storedR2Key(row.r2_key);
await deleteKnownObject(env.ELY_STORAGE, key);
const result = await database.prepare(MARK_DELETED_QUERY)
.bind(nowSeconds, nowSeconds, key, gcToken)
.run();
if (changedRows(result) !== 1) {
throw new SyncR2GcError("sync_r2_gc_finalize_failed");
}
}
return claimed.results.length;
}
function claimGarbageQuery(scope: "global" | "user" | "owner"): string {
const scopeSql = scope === "user"
? "AND candidate.user_id = ?"
: scope === "owner" ? "AND candidate.owner_hash = ?" : "";
return `
UPDATE sync_r2_gc_candidates
SET
state = 'deleting',
gc_token = ?,
delete_started_at = ?,
updated_at = MAX(updated_at, ?),
ready_at = COALESCE(ready_at, ?)
WHERE r2_key IN (
SELECT candidate.r2_key
FROM sync_r2_gc_candidates AS candidate
WHERE (
(candidate.state IN ('pending', 'ready') AND candidate.lease_expires_at <= ?)
OR (candidate.state = 'deleting' AND candidate.delete_started_at <= ?)
)
${scopeSql}
AND NOT EXISTS (SELECT 1 FROM sync_objects WHERE payload_r2_key = candidate.r2_key)
AND NOT EXISTS (SELECT 1 FROM sync_snapshots WHERE r2_key = candidate.r2_key)
AND NOT EXISTS (
SELECT 1
FROM sync_snapshot_heads AS head
INNER JOIN sync_snapshots AS snapshot
ON snapshot.user_id = head.user_id
AND snapshot.snapshot_id = head.snapshot_id
AND snapshot.head_revision = head.head_revision
AND snapshot.payload_hash = head.payload_hash
WHERE snapshot.r2_key = candidate.r2_key
)
AND NOT EXISTS (
SELECT 1
FROM sync_vault_rotation_r2_objects AS staged
INNER JOIN sync_vault_rotations AS rotation
ON rotation.user_id = staged.user_id
AND rotation.idempotency_key = staged.rotation_idempotency_key
WHERE staged.r2_key = candidate.r2_key
AND rotation.cleanup_started_at IS NULL
)
ORDER BY candidate.updated_at ASC, candidate.r2_key ASC
LIMIT ?
)
`;
}
function storedR2Key(value: unknown): string {
if (typeof value !== "string") throw new SyncR2GcError("sync_r2_key_invalid");
try {
assertKnownObjectKey(value);
} catch (error) {
if (error instanceof StorageObjectError) throw new SyncR2GcError(error.message);
throw error;
}
return value;
}
function changedRows(result: unknown): number {
if (typeof result !== "object" || result === null || !("meta" in result)) return -1;
const changes = (result as ElyD1Result).meta?.changes;
return typeof changes === "number" && Number.isSafeInteger(changes) ? changes : -1;
}
function randomToken(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32));
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
+154
View File
@@ -0,0 +1,154 @@
import type { Env } from "./bindings.js";
import { primaryD1Session } from "./bindings.js";
import { StorageObjectError, assertKnownObjectKey } from "./storage.js";
const INVENTORY_RESCAN_SECONDS = 24 * 60 * 60;
const DEFAULT_INVENTORY_LIMIT = 100;
const SHA256_HEX = /^[a-f0-9]{64}$/;
const INVENTORY_CURSOR_QUERY = `
SELECT prefix, cursor
FROM sync_r2_inventory_cursors
WHERE next_scan_at <= ?
ORDER BY next_scan_at ASC, updated_at ASC, prefix ASC
LIMIT 1
`;
const INVENTORY_CANDIDATE_QUERY = `
INSERT INTO sync_r2_gc_candidates (
r2_key, user_id, owner_hash, object_kind, state, write_token,
lease_expires_at, gc_token, created_at, updated_at, referenced_at,
ready_at, delete_started_at, deleted_at
)
SELECT ?, NULL, ?, ?, 'ready', NULL, ?, NULL, ?, ?, NULL, ?, NULL, NULL
WHERE NOT EXISTS (SELECT 1 FROM sync_objects WHERE payload_r2_key = ?)
AND NOT EXISTS (SELECT 1 FROM sync_snapshots WHERE r2_key = ?)
AND NOT EXISTS (
SELECT 1
FROM sync_snapshot_heads AS head
INNER JOIN sync_snapshots AS snapshot
ON snapshot.user_id = head.user_id
AND snapshot.snapshot_id = head.snapshot_id
AND snapshot.head_revision = head.head_revision
AND snapshot.payload_hash = head.payload_hash
WHERE snapshot.r2_key = ?
)
AND NOT EXISTS (
SELECT 1
FROM sync_vault_rotation_r2_objects AS staged
INNER JOIN sync_vault_rotations AS rotation
ON rotation.user_id = staged.user_id
AND rotation.idempotency_key = staged.rotation_idempotency_key
WHERE staged.r2_key = ? AND rotation.cleanup_started_at IS NULL
)
ON CONFLICT(r2_key) DO UPDATE SET
state = 'ready',
lease_expires_at = excluded.lease_expires_at,
gc_token = NULL,
updated_at = excluded.updated_at,
ready_at = excluded.ready_at,
delete_started_at = NULL,
deleted_at = NULL
WHERE sync_r2_gc_candidates.state = 'deleted'
`;
const INVENTORY_CURSOR_UPDATE_QUERY = `
UPDATE sync_r2_inventory_cursors
SET cursor = ?, updated_at = ?, next_scan_at = ?
WHERE prefix = ?
`;
interface InventoryCursorRow { prefix: unknown; cursor: unknown }
interface R2ListObject { key: string }
interface R2ListResult {
objects: R2ListObject[];
truncated: boolean;
cursor?: string;
}
interface ListableBucket {
list(options: { prefix: string; cursor?: string; limit: number }): Promise<R2ListResult>;
}
export class SyncR2InventoryError extends Error {}
export async function inventorySyncR2Objects(
env: Env,
nowSeconds: number,
limit = DEFAULT_INVENTORY_LIMIT,
): Promise<number> {
const database = primaryD1Session(env.ELY_DB);
const cursorRow = await database.prepare(INVENTORY_CURSOR_QUERY)
.bind(nowSeconds)
.first<InventoryCursorRow>();
if (cursorRow === null) return 0;
const prefix = inventoryPrefix(cursorRow.prefix);
const cursor = inventoryCursor(cursorRow.cursor);
const bucket = env.ELY_STORAGE as Env["ELY_STORAGE"] & Partial<ListableBucket>;
if (typeof bucket.list !== "function") {
throw new SyncR2InventoryError("sync_r2_inventory_unavailable");
}
const result = await bucket.list({ prefix, ...(cursor === null ? {} : { cursor }), limit });
const statements = result.objects.flatMap((object) => {
const candidate = inventoryCandidate(object.key, prefix);
if (candidate === null) return [];
return [database.prepare(INVENTORY_CANDIDATE_QUERY).bind(
candidate.r2Key,
candidate.ownerHash,
candidate.objectKind,
nowSeconds,
nowSeconds,
nowSeconds,
nowSeconds,
candidate.r2Key,
candidate.r2Key,
candidate.r2Key,
candidate.r2Key,
)];
});
const nextCursor = result.truncated ? result.cursor : null;
if (result.truncated && typeof nextCursor !== "string") {
throw new SyncR2InventoryError("sync_r2_inventory_cursor_invalid");
}
statements.push(database.prepare(INVENTORY_CURSOR_UPDATE_QUERY).bind(
nextCursor,
nowSeconds,
result.truncated ? nowSeconds : nowSeconds + INVENTORY_RESCAN_SECONDS,
prefix,
));
await database.batch(statements);
return statements.length - 1;
}
function inventoryCandidate(
key: string,
prefix: string,
): { r2Key: string; ownerHash: string; objectKind: "payload" | "snapshot" } | null {
try {
assertKnownObjectKey(key);
} catch (error) {
if (error instanceof StorageObjectError) return null;
throw error;
}
if (!key.startsWith(prefix)) return null;
const ownerHash = key.split("/")[2] ?? "";
if (!SHA256_HEX.test(ownerHash)) return null;
return {
r2Key: key,
ownerHash,
objectKind: prefix === "sync-payloads/" ? "payload" : "snapshot",
};
}
function inventoryPrefix(value: unknown): "sync-payloads/" | "sync-snapshots/" {
if (value !== "sync-payloads/" && value !== "sync-snapshots/") {
throw new SyncR2InventoryError("sync_r2_inventory_prefix_invalid");
}
return value;
}
function inventoryCursor(value: unknown): string | null {
if (value !== null && typeof value !== "string") {
throw new SyncR2InventoryError("sync_r2_inventory_cursor_invalid");
}
return value;
}
+22
View File
@@ -0,0 +1,22 @@
import type { Env } from "./bindings.js";
import { purgeLegacySessionCache } from "./legacy_auth_kv_cleanup.js";
import { collectSyncR2Garbage } from "./sync_r2_gc.js";
import { inventorySyncR2Objects } from "./sync_r2_inventory.js";
import { finalizeCleanedVaultRotations } from "./sync_vault_rotation_cleanup.js";
export async function maintainSyncR2Storage(env: Env, nowSeconds: number): Promise<void> {
const errors: unknown[] = [];
for (const task of [
() => purgeLegacySessionCache(env),
() => inventorySyncR2Objects(env, nowSeconds),
() => collectSyncR2Garbage(env, nowSeconds, { limit: 100 }),
() => finalizeCleanedVaultRotations(env, nowSeconds),
]) {
try {
await task();
} catch (error) {
errors.push(error);
}
}
if (errors.length > 0) throw new AggregateError(errors, "sync_storage_maintenance_failed");
}
+130 -55
View File
@@ -1,6 +1,20 @@
import type { AuthContext } from "./auth.js";
import type { ElyD1PreparedStatement, Env } from "./bindings.js";
import { StorageObjectError, deleteKnownObject } from "./storage.js";
import type { ElyD1DatabaseSession, ElyD1PreparedStatement, ElyD1Result, Env } from "./bindings.js";
import { primaryD1Session } from "./bindings.js";
import {
assertDestructiveActionGateResult,
destructiveActionGateIsLive,
destructiveActionGateStatement,
} from "./destructive_action_gate.js";
import {
type RecentDeviceActionProof,
RecentDeviceActionPermissionError,
RecentDeviceActionRequestError,
assertFreshDeviceActionProof,
assertRecentDeviceActionProof,
recentDeviceActionProof,
} from "./recent_device_action_proof.js";
import { SYNC_R2_FENCE_USER_QUERY, collectSyncR2Garbage } from "./sync_r2_gc.js";
const RESET_CONFIRMATION = "delete-cloud-sync-data";
const IDEMPOTENCY_KEY_PATTERN = /^[a-zA-Z0-9._:-]{16,128}$/;
@@ -17,33 +31,23 @@ const SYNC_RESET_COUNTS_QUERY = `
(SELECT COUNT(*) FROM sync_tombstones WHERE user_id = ?) AS tombstones
`;
const SYNC_RESET_R2_KEYS_QUERY = `
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 = ?
SELECT r2_key FROM sync_r2_gc_candidates
WHERE user_id = ? AND state <> 'deleted'
ORDER BY r2_key ASC
`;
const SYNC_RESET_AUDIT_INSERT_QUERY = `
INSERT INTO audit_events (
event_id,
user_id,
actor_device_id,
event_type,
subject_type,
subject_id,
outcome,
metadata_hash,
created_at
) VALUES (?, ?, ?, 'sync.reset', 'sync', ?, 'success', NULL, ?)
ON CONFLICT(event_id) DO NOTHING
`;
const SYNC_OBJECTS_DELETE_QUERY = "DELETE FROM sync_objects WHERE user_id = ?";
const SYNC_CHANGE_LOG_DELETE_QUERY = "DELETE FROM sync_change_log WHERE user_id = ?";
const SYNC_SNAPSHOT_HEADS_DELETE_QUERY = "DELETE FROM sync_snapshot_heads WHERE user_id = ?";
const SYNC_SNAPSHOT_ENCRYPTION_DELETE_QUERY =
"DELETE FROM sync_snapshot_encryption WHERE user_id = ?";
const SYNC_SNAPSHOTS_DELETE_QUERY = "DELETE FROM sync_snapshots WHERE user_id = ?";
const SYNC_TOMBSTONES_DELETE_QUERY = "DELETE FROM sync_tombstones WHERE user_id = ?";
const START_ROTATION_CLEANUP_QUERY = `
UPDATE sync_vault_rotations
SET cleanup_snapshot_id = 'sync-reset', cleanup_started_at = ?
WHERE user_id = ? AND completed_at IS NOT NULL
AND cleanup_started_at IS NULL AND storage_cleaned_at IS NULL
`;
export interface SyncResetDocument {
version: 1;
@@ -62,7 +66,7 @@ export interface SyncResetDeletedDocument {
r2_objects: number;
}
interface SyncResetRequest {
interface SyncResetRequest extends RecentDeviceActionProof {
idempotencyKey: string;
}
@@ -108,19 +112,55 @@ export async function syncResetDocument(
const deviceId = currentDeviceId(context);
const reset = await syncResetRequest(request);
const eventId = syncResetEventId(context.userId, reset.idempotencyKey);
const existingEvent = await env.ELY_DB.prepare(SYNC_RESET_EVENT_QUERY)
const database = primaryD1Session(env.ELY_DB);
const signingPublicKey = await assertRecentDeviceActionProof(
database,
context,
"sync.reset",
RESET_CONFIRMATION,
reset.idempotencyKey,
reset,
);
const existingEvent = await database.prepare(SYNC_RESET_EVENT_QUERY)
.bind(context.userId, eventId)
.first<SyncResetEventRow>();
assertFreshDeviceActionProof(reset, nowSeconds, existingEvent === null);
if (existingEvent !== null) {
await collectResetGarbage(env, context.userId, nowSeconds, 100);
return existingResetDocument(context, deviceId, reset, existingEvent);
}
const counts = await syncResetCounts(env, context.userId);
const r2Keys = await syncResetR2Keys(env, context.userId);
for (const key of r2Keys) {
await deleteResetObject(env, key);
const counts = await syncResetCounts(database, context.userId);
const r2Keys = await syncResetR2Keys(database, context.userId);
let results: ElyD1Result[];
try {
results = await database.batch<ElyD1Result>(syncResetStatements(
database,
context,
signingPublicKey,
eventId,
nowSeconds,
));
} catch (error) {
const replayDatabase = primaryD1Session(env.ELY_DB);
const racedEvent = await replayDatabase.prepare(SYNC_RESET_EVENT_QUERY)
.bind(context.userId, eventId)
.first<SyncResetEventRow>();
if (racedEvent !== null) {
return existingResetDocument(context, deviceId, reset, racedEvent);
}
if (!(await destructiveActionGateIsLive(
replayDatabase,
context,
signingPublicKey,
nowSeconds,
))) {
throw new RecentDeviceActionPermissionError("device_action_gate_failed");
}
throw error;
}
await env.ELY_DB.batch(syncResetStatements(env, context.userId, deviceId, eventId, nowSeconds));
assertDestructiveActionGateResult(results[0]);
await collectResetGarbage(env, context.userId, nowSeconds, r2Keys.length);
return {
version: 1,
@@ -152,10 +192,10 @@ function existingResetDocument(
}
async function syncResetCounts(
env: Env,
database: ElyD1DatabaseSession,
userId: string,
): Promise<Omit<SyncResetDeletedDocument, "r2_objects">> {
const row = await env.ELY_DB.prepare(SYNC_RESET_COUNTS_QUERY)
const row = await database.prepare(SYNC_RESET_COUNTS_QUERY)
.bind(userId, userId, userId, userId)
.first<SyncResetCountsRow>();
if (row === null) {
@@ -169,56 +209,91 @@ async function syncResetCounts(
};
}
async function syncResetR2Keys(env: Env, userId: string): Promise<string[]> {
const result = await env.ELY_DB.prepare(SYNC_RESET_R2_KEYS_QUERY)
.bind(userId, userId)
async function syncResetR2Keys(
database: ElyD1DatabaseSession,
userId: string,
): Promise<string[]> {
const result = await database.prepare(SYNC_RESET_R2_KEYS_QUERY)
.bind(userId)
.all<SyncResetR2KeyRow>();
return result.results.map(r2Key);
}
function syncResetStatements(
env: Env,
userId: string,
deviceId: string,
database: ElyD1DatabaseSession,
context: AuthContext,
signingPublicKey: string,
eventId: string,
nowSeconds: number,
): ElyD1PreparedStatement[] {
const userId = context.userId;
return [
env.ELY_DB.prepare(SYNC_CHANGE_LOG_DELETE_QUERY).bind(userId),
env.ELY_DB.prepare(SYNC_TOMBSTONES_DELETE_QUERY).bind(userId),
env.ELY_DB.prepare(SYNC_SNAPSHOTS_DELETE_QUERY).bind(userId),
env.ELY_DB.prepare(SYNC_OBJECTS_DELETE_QUERY).bind(userId),
env.ELY_DB.prepare(SYNC_RESET_AUDIT_INSERT_QUERY).bind(
destructiveActionGateStatement(database, context, signingPublicKey, {
eventId,
userId,
deviceId,
userId,
auditUserId: userId,
eventType: "sync.reset",
subjectType: "sync",
subjectId: userId,
metadataHash: null,
}, nowSeconds),
database.prepare(SYNC_R2_FENCE_USER_QUERY).bind(
nowSeconds,
nowSeconds,
nowSeconds,
userId,
),
database.prepare(START_ROTATION_CLEANUP_QUERY).bind(nowSeconds, userId),
database.prepare(SYNC_CHANGE_LOG_DELETE_QUERY).bind(userId),
database.prepare(SYNC_TOMBSTONES_DELETE_QUERY).bind(userId),
database.prepare(SYNC_SNAPSHOT_HEADS_DELETE_QUERY).bind(userId),
database.prepare(SYNC_SNAPSHOT_ENCRYPTION_DELETE_QUERY).bind(userId),
database.prepare(SYNC_SNAPSHOTS_DELETE_QUERY).bind(userId),
database.prepare(SYNC_OBJECTS_DELETE_QUERY).bind(userId),
];
}
async function deleteResetObject(env: Env, key: string): Promise<void> {
async function collectResetGarbage(
env: Env,
userId: string,
nowSeconds: number,
candidateCount: number,
): Promise<void> {
try {
await deleteKnownObject(env.ELY_STORAGE, key);
} catch (error) {
if (error instanceof StorageObjectError) {
throw new SyncResetPersistenceError(error.message);
const maxBatches = Math.ceil(candidateCount / 100) + 1;
for (let batch = 0; batch < maxBatches; batch += 1) {
if (await collectSyncR2Garbage(env, nowSeconds, { userId, limit: 100 }) < 100) break;
}
throw error;
} catch {
// Scheduled maintenance drains the durable GC ledger.
}
}
async function syncResetRequest(request: Request): Promise<SyncResetRequest> {
const body = await requestBody(request);
assertOnlyFields(body, ["version", "confirmation", "idempotency_key"]);
if (body.version !== 1) {
assertOnlyFields(body, [
"version",
"confirmation",
"idempotency_key",
"proof_created_at",
"action_proof",
]);
if (body.version !== 2) {
throw new SyncResetRequestError("version_invalid");
}
if (body.confirmation !== RESET_CONFIRMATION) {
throw new SyncResetRequestError("confirmation_invalid");
}
return { idempotencyKey: idempotencyKey(body.idempotency_key) };
try {
return {
idempotencyKey: idempotencyKey(body.idempotency_key),
...recentDeviceActionProof(body.proof_created_at, body.action_proof),
};
} catch (error) {
if (error instanceof RecentDeviceActionRequestError) {
throw new SyncResetRequestError(error.message);
}
throw error;
}
}
async function requestBody(request: Request): Promise<RequestBody> {
+85 -51
View File
@@ -1,13 +1,11 @@
import type { Env } from "./bindings.js";
import { withApprovedDeviceApiControls } from "./api_controls.js";
import { DestructiveActionGateError } from "./destructive_action_gate.js";
import { jsonResponse } from "./responses.js";
import { SyncRequestError, SyncSchemaError, syncPullDocument } from "./sync_pull.js";
import {
SyncPushConflictError,
SyncPushPersistenceError,
SyncPushRequestError,
syncPushDocument,
} from "./sync_push.js";
RecentDeviceActionPermissionError,
RecentDeviceActionPersistenceError,
} from "./recent_device_action_proof.js";
import {
SyncResetPersistenceError,
SyncResetRequestError,
@@ -22,6 +20,15 @@ import {
syncSnapshotUploadDocument,
} from "./sync_snapshot.js";
import { SyncStatusSchemaError, syncStatusDocument } from "./sync_status.js";
import {
SyncVaultConflictError,
SyncVaultNotFoundError,
SyncVaultPermissionError,
SyncVaultPersistenceError,
SyncVaultRequestError,
syncVaultBootstrapDocument,
syncVaultCurrentDeviceDocument,
} from "./sync_vault.js";
export async function handleSyncRoute(
request: Request,
@@ -29,10 +36,10 @@ export async function handleSyncRoute(
url: URL,
): Promise<Response | null> {
if (url.pathname === "/api/sync/pull") {
return handleSyncPull(request, env, url);
return handleRetiredSyncObjectRoute(request, env, "sync.pull", ["GET"]);
}
if (url.pathname === "/api/sync/push") {
return handleSyncPush(request, env);
return handleRetiredSyncObjectRoute(request, env, "sync.push", ["POST"]);
}
if (url.pathname === "/api/sync/snapshot") {
return handleSyncSnapshot(request, env, url);
@@ -40,37 +47,33 @@ export async function handleSyncRoute(
if (url.pathname === "/api/sync/status") {
return handleSyncStatus(request, env);
}
if (url.pathname === "/api/sync/vault/bootstrap") {
return handleSyncVaultBootstrap(request, env);
}
if (url.pathname === "/api/sync/vault") {
return handleSyncVault(request, env, url);
}
if (url.pathname === "/api/sync/reset") {
return handleSyncReset(request, env);
}
return null;
}
function handleSyncPull(request: Request, env: Env, url: URL): Promise<Response> {
function handleSyncVaultBootstrap(request: Request, env: Env): Promise<Response> {
return withApprovedDeviceApiControls(
request,
env,
"sync.pull",
["GET"],
"sync.vault.bootstrap",
["POST"],
async (context) => {
try {
return jsonResponse(await syncPullDocument(url, env, context), 200, {
return jsonResponse(await syncVaultBootstrapDocument(request, env, context), 201, {
"Cache-Control": "no-store",
});
} catch (error) {
if (error instanceof SyncRequestError) {
return jsonResponse(
{ error: "invalid_sync_pull" },
400,
{ "Cache-Control": "no-store" },
);
}
if (error instanceof SyncSchemaError) {
return jsonResponse(
{ error: "sync_pull_invalid" },
500,
{ "Cache-Control": "no-store" },
);
const response = syncVaultErrorResponse(error);
if (response !== null) {
return response;
}
throw error;
}
@@ -78,34 +81,21 @@ function handleSyncPull(request: Request, env: Env, url: URL): Promise<Response>
);
}
function handleSyncPush(request: Request, env: Env): Promise<Response> {
function handleSyncVault(request: Request, env: Env, url: URL): Promise<Response> {
return withApprovedDeviceApiControls(
request,
env,
"sync.push",
["POST"],
"sync.vault",
["GET"],
async (context) => {
try {
return jsonResponse(await syncPushDocument(request, env, context), 201, {
return jsonResponse(await syncVaultCurrentDeviceDocument(url, env, context), 200, {
"Cache-Control": "no-store",
});
} catch (error) {
if (error instanceof SyncPushRequestError) {
return jsonResponse(
{ error: "invalid_sync_push" },
400,
{ "Cache-Control": "no-store" },
);
}
if (error instanceof SyncPushConflictError) {
return jsonResponse({ error: "sync_conflict" }, 409, { "Cache-Control": "no-store" });
}
if (error instanceof SyncPushPersistenceError) {
return jsonResponse(
{ error: "sync_push_failed" },
500,
{ "Cache-Control": "no-store" },
);
const response = syncVaultErrorResponse(error);
if (response !== null) {
return response;
}
throw error;
}
@@ -113,6 +103,43 @@ function handleSyncPush(request: Request, env: Env): Promise<Response> {
);
}
function syncVaultErrorResponse(error: unknown): Response | null {
if (error instanceof SyncVaultPermissionError) {
return jsonResponse({ error: "sync_vault_forbidden" }, 403, { "Cache-Control": "no-store" });
}
if (error instanceof SyncVaultRequestError) {
return jsonResponse({ error: "invalid_sync_vault" }, 400, { "Cache-Control": "no-store" });
}
if (error instanceof SyncVaultNotFoundError) {
return jsonResponse({ error: "sync_vault_not_found" }, 404, { "Cache-Control": "no-store" });
}
if (error instanceof SyncVaultConflictError) {
return jsonResponse({ error: "sync_vault_conflict" }, 409, { "Cache-Control": "no-store" });
}
if (error instanceof SyncVaultPersistenceError) {
return jsonResponse({ error: "sync_vault_failed" }, 500, { "Cache-Control": "no-store" });
}
return null;
}
function handleRetiredSyncObjectRoute(
request: Request,
env: Env,
route: string,
allowedMethods: readonly string[],
): Promise<Response> {
return withApprovedDeviceApiControls(
request,
env,
route,
allowedMethods,
async () =>
jsonResponse({ error: "sync_object_protocol_retired" }, 410, {
"Cache-Control": "no-store",
}),
);
}
function handleSyncSnapshot(request: Request, env: Env, url: URL): Promise<Response> {
return withApprovedDeviceApiControls(
request,
@@ -145,11 +172,7 @@ function handleSyncSnapshot(request: Request, env: Env, url: URL): Promise<Respo
);
}
if (error instanceof SyncSnapshotConflictError) {
return jsonResponse(
{ error: "sync_snapshot_conflict" },
409,
{ "Cache-Control": "no-store" },
);
return jsonResponse(error.document(), 409, { "Cache-Control": "no-store" });
}
if (error instanceof SyncSnapshotPersistenceError) {
return jsonResponse(
@@ -208,7 +231,18 @@ function handleSyncReset(request: Request, env: Env): Promise<Response> {
{ "Cache-Control": "no-store" },
);
}
if (error instanceof SyncResetPersistenceError) {
if (error instanceof RecentDeviceActionPermissionError) {
return jsonResponse(
{ error: "sync_reset_forbidden" },
403,
{ "Cache-Control": "no-store" },
);
}
if (
error instanceof SyncResetPersistenceError ||
error instanceof RecentDeviceActionPersistenceError ||
error instanceof DestructiveActionGateError
) {
return jsonResponse(
{ error: "sync_reset_failed" },
500,
+350 -349
View File
@@ -1,59 +1,66 @@
import type { AuthContext } from "./auth.js";
import type { Env } from "./bindings.js";
import { StorageObjectError, getVerifiedObject, putVerifiedObject, syncSnapshotKey } from "./storage.js";
import type { ElyD1DatabaseSession, ElyD1Result, Env } from "./bindings.js";
import { primaryD1Session } from "./bindings.js";
import { StorageObjectError, getVerifiedObject } from "./storage.js";
import {
SyncSnapshotRequestError,
assertOnlyFields,
assertOnlyQueryParams,
assertPayloadHash,
base64FromBytes,
deviceIdValue,
exactInteger,
integer,
payloadBytes,
regionValue,
requestBody,
sha256HexValue,
snapshotIdValue,
} from "./sync_snapshot_codec.js";
import {
type SnapshotHeadRefDocument,
type SyncSnapshotDocument,
type SyncSnapshotRow,
SyncSnapshotConflictError,
SyncSnapshotHeadSchemaError,
currentSyncSnapshotHead,
sameSnapshotHead,
snapshotDocumentFromResult,
snapshotHeadRef,
snapshotHeadRefValue,
syncSnapshotByToken,
} from "./sync_snapshot_head.js";
import {
syncSnapshotStatements,
} from "./sync_snapshot_sql.js";
import {
SyncR2WriteFenceError,
} from "./sync_r2_gc.js";
import {
type SyncR2WriteLease,
claimSnapshotStorageWrite,
persistClaimedSnapshot,
releaseFailedSnapshotWrite,
snapshotStorageKey,
} from "./sync_snapshot_write.js";
import {
SyncVaultConflictError,
SyncVaultNotFoundError,
assertCurrentSyncVaultKey,
} from "./sync_vault.js";
import {
SyncVaultRotationCleanupError,
cleanupRotatedVaultStorage,
} from "./sync_vault_rotation_cleanup.js";
export { SyncSnapshotRequestError } from "./sync_snapshot_codec.js";
export { SyncSnapshotConflictError } from "./sync_snapshot_head.js";
export type { SyncSnapshotDocument } from "./sync_snapshot_head.js";
const MAX_SNAPSHOT_BYTES = 10 * 1024 * 1024;
const SNAPSHOT_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/;
const DEVICE_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,128}$/;
const SHA256_HEX = /^[a-f0-9]{64}$/;
const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
const REGION = /^[a-z0-9][a-z0-9-]{1,31}$/;
const SYNC_SNAPSHOT_BY_ID_QUERY = `
SELECT
snapshot_id,
r2_key,
payload_hash,
schema_rev,
logical_clock,
device_id,
size_bytes,
created_at
FROM sync_snapshots
WHERE user_id = ? AND snapshot_id = ?
`;
const SYNC_SNAPSHOT_UPSERT_QUERY = `
INSERT INTO sync_snapshots (
user_id,
snapshot_id,
r2_key,
payload_hash,
schema_rev,
logical_clock,
device_id,
size_bytes,
created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, snapshot_id) DO UPDATE SET
r2_key = excluded.r2_key,
payload_hash = excluded.payload_hash,
schema_rev = excluded.schema_rev,
logical_clock = excluded.logical_clock,
device_id = excluded.device_id,
size_bytes = excluded.size_bytes,
created_at = excluded.created_at
WHERE excluded.logical_clock > sync_snapshots.logical_clock
OR (
excluded.logical_clock = sync_snapshots.logical_clock
AND sync_snapshots.r2_key = excluded.r2_key
AND sync_snapshots.payload_hash = excluded.payload_hash
AND sync_snapshots.schema_rev = excluded.schema_rev
AND sync_snapshots.device_id = excluded.device_id
AND sync_snapshots.size_bytes = excluded.size_bytes
)
`;
export interface SyncSnapshotUploadDocument {
version: 1;
version: 3;
user_id: string;
device_id: string;
snapshot: SyncSnapshotDocument;
@@ -63,53 +70,21 @@ export interface SyncSnapshotDownloadDocument extends SyncSnapshotUploadDocument
data_base64: string;
}
export interface SyncSnapshotDocument {
snapshot_id: string;
r2_key: string;
payload_hash: string;
schema_rev: number;
logical_clock: number;
device_id: string;
size_bytes: number;
created_at: number;
}
interface SyncSnapshotUploadRequest {
snapshotId: string;
r2Key: string;
payloadHash: string;
encryptionVersion: 2;
vaultGeneration: number;
keyId: string;
contentHash: string;
schemaRev: number;
logicalClock: number;
headRevision: number;
baseHead: SnapshotHeadRefDocument | null;
bytes: ArrayBuffer;
}
interface SyncSnapshotRow {
snapshot_id: unknown;
r2_key: unknown;
payload_hash: unknown;
schema_rev: unknown;
logical_clock: unknown;
device_id: unknown;
size_bytes: unknown;
created_at: unknown;
}
type RequestBody = Record<string, unknown>;
export class SyncSnapshotRequestError extends Error {
constructor(message: string) {
super(message);
this.name = "SyncSnapshotRequestError";
}
}
export class SyncSnapshotConflictError extends Error {
constructor(message: string) {
super(message);
this.name = "SyncSnapshotConflictError";
}
}
export class SyncSnapshotNotFoundError extends Error {
constructor(message: string) {
super(message);
@@ -131,40 +106,127 @@ export async function syncSnapshotUploadDocument(
nowSeconds = Math.floor(Date.now() / 1000),
): Promise<SyncSnapshotUploadDocument> {
const deviceId = currentDeviceId(context);
const snapshot = await syncSnapshotUploadRequest(request, context.userId);
const existingRow = await snapshotRow(env, context.userId, snapshot.snapshotId);
if (existingRow !== null) {
assertSnapshotCanReplaceExisting(snapshot, deviceId, syncSnapshotDocumentFromRow(existingRow));
const upload = await syncSnapshotUploadRequest(request, context.userId);
const database = primaryD1Session(env.ELY_DB);
const currentHead = await readCurrentHead(database, context.userId);
if (currentHead !== null && snapshotMatchesUpload(upload, deviceId, currentHead)) {
try {
await assertCurrentSyncVaultKey(
env,
context.userId,
upload.keyId,
upload.vaultGeneration,
database,
);
} catch (error) {
if (error instanceof SyncVaultConflictError || error instanceof SyncVaultNotFoundError) {
return uploadDocument(context.userId, deviceId, currentHead);
}
throw error;
}
await cleanupAfterSnapshot(env, context.userId, currentHead, nowSeconds);
return uploadDocument(context.userId, deviceId, currentHead);
}
await persistSnapshot(env, snapshot);
await env.ELY_DB.batch([
env.ELY_DB.prepare(SYNC_SNAPSHOT_UPSERT_QUERY).bind(
try {
await assertCurrentSyncVaultKey(
env,
context.userId,
snapshot.snapshotId,
snapshot.r2Key,
snapshot.payloadHash,
snapshot.schemaRev,
snapshot.logicalClock,
deviceId,
snapshot.bytes.byteLength,
nowSeconds,
),
]);
const savedRow = await snapshotRow(env, context.userId, snapshot.snapshotId);
if (savedRow === null) {
throw new SyncSnapshotPersistenceError("sync_snapshot_missing");
upload.keyId,
upload.vaultGeneration,
database,
);
} catch (error) {
if (error instanceof SyncVaultConflictError || error instanceof SyncVaultNotFoundError) {
throw new SyncSnapshotConflictError("sync_vault_key_not_current", currentHead);
}
throw error;
}
const savedSnapshot = syncSnapshotDocumentFromRow(savedRow);
assertSavedSnapshotMatchesUpload(snapshot, deviceId, savedSnapshot);
return {
version: 1,
user_id: context.userId,
device_id: deviceId,
snapshot: savedSnapshot,
};
assertUploadBase(upload, currentHead);
let writeLease: SyncR2WriteLease;
try {
writeLease = await claimSnapshotStorageWrite(
env,
database,
context.userId,
deviceId,
upload,
nowSeconds,
);
} catch (error) {
if (error instanceof SyncR2WriteFenceError) {
const head = await readCurrentHead(database, context.userId);
throw new SyncSnapshotConflictError("sync_snapshot_head_conflict", head);
}
throw error;
}
try {
await persistClaimedSnapshot(env, upload);
} catch (error) {
await releaseFailedSnapshotWrite(
env,
database,
context.userId,
upload.r2Key,
writeLease,
nowSeconds,
);
throw error;
}
let results: ElyD1Result<SyncSnapshotRow>[];
try {
results = await database.batch<ElyD1Result<SyncSnapshotRow>>(syncSnapshotStatements(
database,
context.userId,
deviceId,
{ ...upload, sizeBytes: upload.bytes.byteLength },
writeLease,
nowSeconds,
));
} catch (error) {
await releaseFailedSnapshotWrite(
env,
database,
context.userId,
upload.r2Key,
writeLease,
nowSeconds,
);
if (!isSnapshotHeadConflict(error)) {
throw error;
}
return concurrentUploadResult(env, database, context.userId, deviceId, upload, nowSeconds);
}
if (
results.length !== 5 ||
results.slice(0, 4).some((result) => changedRowCount(result) !== 1)
) {
await releaseFailedSnapshotWrite(
env,
database,
context.userId,
upload.r2Key,
writeLease,
nowSeconds,
);
return concurrentUploadResult(env, database, context.userId, deviceId, upload, nowSeconds);
}
let saved: SyncSnapshotDocument;
try {
saved = snapshotDocumentFromResult(results[4]);
} catch (error) {
if (error instanceof SyncSnapshotHeadSchemaError) {
throw new SyncSnapshotPersistenceError(error.message);
}
throw error;
}
if (!snapshotMatchesUpload(upload, deviceId, saved)) {
throw new SyncSnapshotPersistenceError("sync_snapshot_mismatch");
}
await cleanupAfterSnapshot(env, context.userId, saved, nowSeconds);
return uploadDocument(context.userId, deviceId, saved);
}
export async function syncSnapshotDownloadDocument(
@@ -173,31 +235,36 @@ export async function syncSnapshotDownloadDocument(
context: AuthContext,
): Promise<SyncSnapshotDownloadDocument> {
const deviceId = currentDeviceId(context);
const snapshotId = syncSnapshotDownloadQuery(url);
const row = await snapshotRow(env, context.userId, snapshotId);
if (row === null) {
throw new SyncSnapshotNotFoundError("sync_snapshot_missing");
const database = primaryD1Session(env.ELY_DB);
const token = syncSnapshotDownloadQuery(url);
const snapshot = await readSnapshotByToken(database, context.userId, token);
if (snapshot === null) {
const currentHead = await readCurrentHead(database, context.userId);
if (currentHead === null) {
throw new SyncSnapshotNotFoundError("sync_snapshot_missing");
}
throw new SyncSnapshotConflictError("sync_snapshot_download_token_stale", currentHead);
}
const snapshot = syncSnapshotDocumentFromRow(row);
let payload: ArrayBuffer | null;
try {
payload = await getVerifiedObject(env.ELY_STORAGE, snapshot.r2_key, snapshot.payload_hash);
} catch (error) {
if (error instanceof StorageObjectError) {
throw new SyncSnapshotPersistenceError(error.message);
await throwDownloadStorageFailure(env, context.userId, token, error.message);
}
throw error;
}
if (payload === null) {
throw new SyncSnapshotPersistenceError("sync_snapshot_payload_missing");
return throwDownloadStorageFailure(
env,
context.userId,
token,
"sync_snapshot_payload_missing",
);
}
return {
version: 1,
user_id: context.userId,
device_id: deviceId,
snapshot,
...uploadDocument(context.userId, deviceId, snapshot),
data_base64: base64FromBytes(payload),
};
}
@@ -212,282 +279,216 @@ async function syncSnapshotUploadRequest(
"snapshot_id",
"region",
"payload_hash",
"encryption_version",
"vault_generation",
"key_id",
"content_hash",
"schema_rev",
"logical_clock",
"head_revision",
"base_head",
"data_base64",
]);
if (body.version !== 1) {
if (body.version !== 3) {
throw new SyncSnapshotRequestError("version_invalid");
}
const snapshotId = snapshotIdValue(body.snapshot_id);
const region = regionValue(body.region);
const payloadHash = sha256HexValue(body.payload_hash, "payload_hash");
const headRevision = integer(
body.head_revision,
"head_revision",
1,
Number.MAX_SAFE_INTEGER,
);
const baseHead = snapshotHeadRefValue(body.base_head, headRevision);
const bytes = payloadBytes(body.data_base64, "data_base64", MAX_SNAPSHOT_BYTES);
await assertPayloadHash(bytes, payloadHash);
return {
snapshotId,
r2Key: await snapshotStorageKey(region, userId, snapshotId),
r2Key: await snapshotStorageKey(
regionValue(body.region),
userId,
snapshotId,
payloadHash,
),
payloadHash,
encryptionVersion: exactInteger(body.encryption_version, "encryption_version", 2),
vaultGeneration: integer(
body.vault_generation,
"vault_generation",
1,
Number.MAX_SAFE_INTEGER,
),
keyId: sha256HexValue(body.key_id, "key_id"),
contentHash: sha256HexValue(body.content_hash, "content_hash"),
schemaRev: integer(body.schema_rev, "schema_rev", 1, Number.MAX_SAFE_INTEGER),
logicalClock: integer(body.logical_clock, "logical_clock", 0, Number.MAX_SAFE_INTEGER),
headRevision,
baseHead,
bytes,
};
}
function syncSnapshotDownloadQuery(url: URL): string {
assertOnlyQueryParams(url, ["snapshot_id"]);
return snapshotIdValue(url.searchParams.get("snapshot_id"));
function syncSnapshotDownloadQuery(url: URL): SnapshotHeadRefDocument {
assertOnlyQueryParams(url, ["snapshot_id", "head_revision", "payload_hash"]);
return {
snapshot_id: snapshotIdValue(url.searchParams.get("snapshot_id")),
revision: integer(
numberQueryValue(url.searchParams.get("head_revision")),
"head_revision",
1,
Number.MAX_SAFE_INTEGER,
),
payload_hash: sha256HexValue(url.searchParams.get("payload_hash"), "payload_hash"),
};
}
async function snapshotStorageKey(
region: string,
userId: string,
snapshotId: string,
): Promise<string> {
try {
return syncSnapshotKey({
region,
userHash: await sha256Hex(arrayBufferFromBytes(new TextEncoder().encode(userId))),
snapshotId,
});
} catch (error) {
if (error instanceof StorageObjectError) {
throw new SyncSnapshotRequestError(error.message);
}
throw error;
function assertUploadBase(
upload: SyncSnapshotUploadRequest,
currentHead: SyncSnapshotDocument | null,
): void {
const currentRef = currentHead === null ? null : snapshotHeadRef(currentHead);
if (!sameSnapshotHead(upload.baseHead, currentRef)) {
throw new SyncSnapshotConflictError("sync_snapshot_head_conflict", currentHead);
}
if (currentHead !== null && upload.logicalClock <= currentHead.logical_clock) {
throw new SyncSnapshotConflictError("logical_clock_stale", currentHead);
}
}
async function snapshotRow(
env: Env,
userId: string,
snapshotId: string,
): Promise<SyncSnapshotRow | null> {
return env.ELY_DB.prepare(SYNC_SNAPSHOT_BY_ID_QUERY).bind(userId, snapshotId).first();
function snapshotMatchesUpload(
upload: SyncSnapshotUploadRequest,
deviceId: string,
snapshot: SyncSnapshotDocument,
): boolean {
return snapshot.snapshot_id === upload.snapshotId &&
snapshot.r2_key === upload.r2Key &&
snapshot.payload_hash === upload.payloadHash &&
snapshot.encryption_version === upload.encryptionVersion &&
snapshot.vault_generation === upload.vaultGeneration &&
snapshot.key_id === upload.keyId &&
snapshot.content_hash === upload.contentHash &&
snapshot.schema_rev === upload.schemaRev &&
snapshot.logical_clock === upload.logicalClock &&
snapshot.head_revision === upload.headRevision &&
sameSnapshotHead(snapshot.base_head, upload.baseHead) &&
snapshot.device_id === deviceId &&
snapshot.size_bytes === upload.bytes.byteLength;
}
function syncSnapshotDocumentFromRow(row: SyncSnapshotRow): SyncSnapshotDocument {
async function readCurrentHead(
database: ElyD1DatabaseSession,
userId: string,
): Promise<SyncSnapshotDocument | null> {
try {
return syncSnapshotDocument(row);
return await currentSyncSnapshotHead(database, userId);
} catch (error) {
if (error instanceof SyncSnapshotRequestError) {
if (error instanceof SyncSnapshotHeadSchemaError) {
throw new SyncSnapshotPersistenceError(error.message);
}
throw error;
}
}
function syncSnapshotDocument(row: SyncSnapshotRow): SyncSnapshotDocument {
return {
snapshot_id: snapshotIdValue(row.snapshot_id),
r2_key: text(row.r2_key, "r2_key"),
payload_hash: sha256HexValue(row.payload_hash, "payload_hash"),
schema_rev: integer(row.schema_rev, "schema_rev", 1, Number.MAX_SAFE_INTEGER),
logical_clock: integer(row.logical_clock, "logical_clock", 0, Number.MAX_SAFE_INTEGER),
device_id: deviceIdValue(row.device_id),
size_bytes: integer(row.size_bytes, "size_bytes", 1, MAX_SNAPSHOT_BYTES),
created_at: integer(row.created_at, "created_at", 0, Number.MAX_SAFE_INTEGER),
};
}
function assertSnapshotCanReplaceExisting(
snapshot: SyncSnapshotUploadRequest,
deviceId: string,
existing: SyncSnapshotDocument,
): void {
if (existing.logical_clock > snapshot.logicalClock) {
throw new SyncSnapshotConflictError("logical_clock_stale");
}
if (existing.logical_clock < snapshot.logicalClock) {
return;
}
if (
existing.payload_hash !== snapshot.payloadHash ||
existing.schema_rev !== snapshot.schemaRev ||
existing.device_id !== deviceId ||
existing.size_bytes !== snapshot.bytes.byteLength
) {
throw new SyncSnapshotConflictError("logical_clock_conflict");
}
}
function assertSavedSnapshotMatchesUpload(
upload: SyncSnapshotUploadRequest,
deviceId: string,
snapshot: SyncSnapshotDocument,
): void {
if (snapshot.logical_clock > upload.logicalClock) {
throw new SyncSnapshotConflictError("logical_clock_stale");
}
if (
snapshot.logical_clock === upload.logicalClock &&
(snapshot.r2_key !== upload.r2Key ||
snapshot.payload_hash !== upload.payloadHash ||
snapshot.schema_rev !== upload.schemaRev ||
snapshot.device_id !== deviceId ||
snapshot.size_bytes !== upload.bytes.byteLength)
) {
throw new SyncSnapshotConflictError("logical_clock_conflict");
}
if (
snapshot.snapshot_id !== upload.snapshotId ||
snapshot.r2_key !== upload.r2Key ||
snapshot.payload_hash !== upload.payloadHash ||
snapshot.schema_rev !== upload.schemaRev ||
snapshot.logical_clock !== upload.logicalClock ||
snapshot.device_id !== deviceId ||
snapshot.size_bytes !== upload.bytes.byteLength
) {
throw new SyncSnapshotPersistenceError("sync_snapshot_mismatch");
}
}
async function persistSnapshot(env: Env, snapshot: SyncSnapshotUploadRequest): Promise<void> {
async function readSnapshotByToken(
database: ElyD1DatabaseSession,
userId: string,
token: SnapshotHeadRefDocument,
): Promise<SyncSnapshotDocument | null> {
try {
await putVerifiedObject(
env.ELY_STORAGE,
snapshot.r2Key,
snapshot.bytes,
snapshot.payloadHash,
"application/octet-stream",
);
return await syncSnapshotByToken(database, userId, token);
} catch (error) {
if (error instanceof StorageObjectError) {
throw new SyncSnapshotRequestError(error.message);
if (error instanceof SyncSnapshotHeadSchemaError) {
throw new SyncSnapshotPersistenceError(error.message);
}
throw error;
}
}
function currentDeviceId(context: AuthContext): string {
if (context.deviceId === undefined) {
throw new SyncSnapshotRequestError("device_context_required");
async function concurrentUploadResult(
env: Env,
database: ElyD1DatabaseSession,
userId: string,
deviceId: string,
upload: SyncSnapshotUploadRequest,
nowSeconds: number,
): Promise<SyncSnapshotUploadDocument> {
const currentHead = await readCurrentHead(database, userId);
if (currentHead !== null && snapshotMatchesUpload(upload, deviceId, currentHead)) {
await cleanupAfterSnapshot(env, userId, currentHead, nowSeconds);
return uploadDocument(userId, deviceId, currentHead);
}
return context.deviceId;
throw new SyncSnapshotConflictError("sync_snapshot_head_conflict", currentHead);
}
async function requestBody(request: Request): Promise<RequestBody> {
let value: unknown;
async function throwDownloadStorageFailure(
env: Env,
userId: string,
token: SnapshotHeadRefDocument,
message: string,
): Promise<never> {
const currentHead = await readCurrentHead(primaryD1Session(env.ELY_DB), userId);
if (currentHead === null || !sameSnapshotHead(token, snapshotHeadRef(currentHead))) {
throw new SyncSnapshotConflictError("sync_snapshot_download_token_stale", currentHead);
}
throw new SyncSnapshotPersistenceError(message);
}
async function cleanupAfterSnapshot(
env: Env,
userId: string,
snapshot: SyncSnapshotDocument,
nowSeconds: number,
): Promise<void> {
try {
value = await request.json();
} catch {
throw new SyncSnapshotRequestError("json_invalid");
}
return record(value, "body");
}
function record(value: unknown, label: string): RequestBody {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new SyncSnapshotRequestError(`${label}_invalid`);
}
return value as RequestBody;
}
function assertOnlyFields(value: RequestBody, fields: string[]): void {
const allowed = new Set(fields);
for (const field of Object.keys(value)) {
if (!allowed.has(field)) {
throw new SyncSnapshotRequestError(`unexpected_field:${field}`);
await cleanupRotatedVaultStorage(
env,
userId,
snapshot.snapshot_id,
snapshot.key_id,
snapshot.vault_generation,
nowSeconds,
);
} catch (error) {
if (error instanceof SyncVaultRotationCleanupError) {
throw new SyncSnapshotPersistenceError(error.message);
}
throw error;
}
}
function assertOnlyQueryParams(url: URL, fields: string[]): void {
const allowed = new Set(fields);
for (const field of url.searchParams.keys()) {
if (!allowed.has(field)) {
throw new SyncSnapshotRequestError(`unexpected_query:${field}`);
}
}
function uploadDocument(
userId: string,
deviceId: string,
snapshot: SyncSnapshotDocument,
): SyncSnapshotUploadDocument {
return { version: 3, user_id: userId, device_id: deviceId, snapshot };
}
function snapshotIdValue(value: unknown): string {
if (typeof value !== "string" || !SNAPSHOT_ID_PATTERN.test(value)) {
throw new SyncSnapshotRequestError("snapshot_id_invalid");
}
return value;
function currentDeviceId(context: AuthContext): string {
return deviceIdValue(context.deviceId);
}
function deviceIdValue(value: unknown): string {
if (typeof value !== "string" || !DEVICE_ID_PATTERN.test(value)) {
throw new SyncSnapshotRequestError("device_id_invalid");
function numberQueryValue(value: string | null): number {
if (value === null || !/^[1-9][0-9]*$/.test(value)) {
throw new SyncSnapshotRequestError("head_revision_invalid");
}
return value;
return Number(value);
}
function regionValue(value: unknown): string {
if (typeof value !== "string" || !REGION.test(value)) {
throw new SyncSnapshotRequestError("region_invalid");
}
return value;
function changedRowCount(result: ElyD1Result): number {
const changes = result.meta?.changes;
return typeof changes === "number" && Number.isSafeInteger(changes) && changes >= 0 ? changes : -1;
}
function sha256HexValue(value: unknown, label: string): string {
if (typeof value !== "string" || !SHA256_HEX.test(value)) {
throw new SyncSnapshotRequestError(`${label}_invalid`);
function isSnapshotHeadConflict(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
return value;
}
function integer(value: unknown, label: string, min: number, max: number): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min || value > max) {
throw new SyncSnapshotRequestError(`${label}_invalid`);
}
return value;
}
function text(value: unknown, label: string): string {
if (typeof value !== "string" || value.length === 0) {
throw new SyncSnapshotRequestError(`${label}_invalid`);
}
return value;
}
function payloadBytes(value: unknown, label: string, maxBytes: number): ArrayBuffer {
const encoded = text(value, label);
if (!BASE64.test(encoded)) {
throw new SyncSnapshotRequestError(`${label}_invalid`);
}
const bytes = bytesFromBase64(encoded);
if (bytes.byteLength === 0 || bytes.byteLength > maxBytes) {
throw new SyncSnapshotRequestError(`${label}_size_invalid`);
}
return bytes;
}
function bytesFromBase64(value: string): ArrayBuffer {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes.buffer;
}
function base64FromBytes(payload: ArrayBuffer): string {
const bytes = new Uint8Array(payload);
const parts: string[] = [];
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
parts.push(String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)));
}
return btoa(parts.join(""));
}
function arrayBufferFromBytes(bytes: Uint8Array): ArrayBuffer {
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return copy.buffer;
}
async function assertPayloadHash(payload: ArrayBuffer, expectedHash: string): Promise<void> {
const actualHash = await sha256Hex(payload);
if (actualHash !== expectedHash) {
throw new SyncSnapshotRequestError("payload_hash_mismatch");
}
}
async function sha256Hex(payload: ArrayBuffer): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", payload);
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
return error.message.includes("sync_snapshot_head_cas_failed") ||
error.message.includes("sync_r2_write_fenced") ||
error.message.includes("sync_r2_reference_commit_invalid") ||
error.message.includes("UNIQUE constraint failed: sync_snapshot_heads.user_id") ||
error.message.includes("sync_snapshots.user_id, sync_snapshots.head_revision");
}
+155
View File
@@ -0,0 +1,155 @@
const SNAPSHOT_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/;
const DEVICE_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,128}$/;
const SHA256_HEX = /^[a-f0-9]{64}$/;
const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
const REGION = /^[a-z0-9][a-z0-9-]{1,31}$/;
export type SnapshotRequestBody = Record<string, unknown>;
export class SyncSnapshotRequestError extends Error {
constructor(message: string) {
super(message);
this.name = "SyncSnapshotRequestError";
}
}
export async function requestBody(request: Request): Promise<SnapshotRequestBody> {
let value: unknown;
try {
value = await request.json();
} catch {
throw new SyncSnapshotRequestError("json_invalid");
}
return record(value, "body");
}
export function assertOnlyFields(value: SnapshotRequestBody, fields: string[]): void {
const allowed = new Set(fields);
for (const field of Object.keys(value)) {
if (!allowed.has(field)) {
throw new SyncSnapshotRequestError(`unexpected_field:${field}`);
}
}
}
export function assertOnlyQueryParams(url: URL, fields: string[]): void {
const allowed = new Set(fields);
for (const field of url.searchParams.keys()) {
if (!allowed.has(field)) {
throw new SyncSnapshotRequestError(`unexpected_query:${field}`);
}
}
}
export function snapshotIdValue(value: unknown): string {
if (typeof value !== "string" || !SNAPSHOT_ID_PATTERN.test(value)) {
throw new SyncSnapshotRequestError("snapshot_id_invalid");
}
return value;
}
export function deviceIdValue(value: unknown): string {
if (typeof value !== "string" || !DEVICE_ID_PATTERN.test(value)) {
throw new SyncSnapshotRequestError("device_id_invalid");
}
return value;
}
export function regionValue(value: unknown): string {
if (typeof value !== "string" || !REGION.test(value)) {
throw new SyncSnapshotRequestError("region_invalid");
}
return value;
}
export function sha256HexValue(value: unknown, label: string): string {
if (typeof value !== "string" || !SHA256_HEX.test(value)) {
throw new SyncSnapshotRequestError(`${label}_invalid`);
}
return value;
}
export function integer(value: unknown, label: string, min: number, max: number): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min || value > max) {
throw new SyncSnapshotRequestError(`${label}_invalid`);
}
return value;
}
export function exactInteger<T extends number>(value: unknown, label: string, expected: T): T {
if (value !== expected) {
throw new SyncSnapshotRequestError(`${label}_invalid`);
}
return expected;
}
export function text(value: unknown, label: string): string {
if (typeof value !== "string" || value.length === 0) {
throw new SyncSnapshotRequestError(`${label}_invalid`);
}
return value;
}
export function payloadBytes(value: unknown, label: string, maxBytes: number): ArrayBuffer {
const encoded = text(value, label);
const maxEncodedLength = 4 * Math.ceil(maxBytes / 3);
if (encoded.length > maxEncodedLength) {
throw new SyncSnapshotRequestError(`${label}_size_invalid`);
}
if (!BASE64.test(encoded)) {
throw new SyncSnapshotRequestError(`${label}_invalid`);
}
const bytes = bytesFromBase64(encoded);
if (bytes.byteLength === 0 || bytes.byteLength > maxBytes) {
throw new SyncSnapshotRequestError(`${label}_size_invalid`);
}
return bytes;
}
export function base64FromBytes(payload: ArrayBuffer): string {
const bytes = new Uint8Array(payload);
const parts: string[] = [];
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
parts.push(String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)));
}
return btoa(parts.join(""));
}
export function arrayBufferFromBytes(bytes: Uint8Array): ArrayBuffer {
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return copy.buffer;
}
export async function assertPayloadHash(
payload: ArrayBuffer,
expectedHash: string,
): Promise<void> {
const actualHash = await sha256Hex(payload);
if (actualHash !== expectedHash) {
throw new SyncSnapshotRequestError("payload_hash_mismatch");
}
}
export async function sha256Hex(payload: ArrayBuffer): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", payload);
return [...new Uint8Array(digest)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
function record(value: unknown, label: string): SnapshotRequestBody {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new SyncSnapshotRequestError(`${label}_invalid`);
}
return value as SnapshotRequestBody;
}
function bytesFromBase64(value: string): ArrayBuffer {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes.buffer;
}
+264
View File
@@ -0,0 +1,264 @@
import type { ElyD1DatabaseSession, ElyD1Result } from "./bindings.js";
import { StorageObjectError, assertKnownObjectKeyHash } from "./storage.js";
import {
SyncSnapshotRequestError,
assertOnlyFields,
deviceIdValue,
integer,
sha256HexValue,
snapshotIdValue,
text,
} from "./sync_snapshot_codec.js";
import {
SYNC_SNAPSHOT_BY_TOKEN_QUERY,
SYNC_SNAPSHOT_HEAD_QUERY,
} from "./sync_snapshot_sql.js";
const MAX_SNAPSHOT_BYTES = 10 * 1024 * 1024;
export interface SnapshotHeadRefDocument {
revision: number;
snapshot_id: string;
payload_hash: string;
}
export interface SyncSnapshotDocument {
snapshot_id: string;
r2_key: string;
payload_hash: string;
encryption_version: 1 | 2;
vault_generation: number;
key_id: string;
content_hash: string;
schema_rev: number;
logical_clock: number;
head_revision: number;
base_head: SnapshotHeadRefDocument | null;
device_id: string;
size_bytes: number;
created_at: number;
}
export interface SyncSnapshotRow {
snapshot_id: unknown;
r2_key: unknown;
payload_hash: unknown;
encryption_version: unknown;
vault_generation: unknown;
key_id: unknown;
content_hash: unknown;
schema_rev: unknown;
logical_clock: unknown;
head_revision: unknown;
base_head_revision: unknown;
base_snapshot_id: unknown;
base_payload_hash: unknown;
device_id: unknown;
size_bytes: unknown;
created_at: unknown;
}
export interface SyncSnapshotHeadConflictDocument {
version: 1;
error: "sync_snapshot_head_conflict";
current_head: SyncSnapshotDocument | null;
}
export class SyncSnapshotHeadSchemaError extends Error {
constructor(message: string) {
super(message);
this.name = "SyncSnapshotHeadSchemaError";
}
}
export class SyncSnapshotConflictError extends Error {
readonly currentHead: SyncSnapshotDocument | null;
constructor(message: string, currentHead: SyncSnapshotDocument | null) {
super(message);
this.name = "SyncSnapshotConflictError";
this.currentHead = currentHead;
}
document(): SyncSnapshotHeadConflictDocument {
return {
version: 1,
error: "sync_snapshot_head_conflict",
current_head: this.currentHead,
};
}
}
export function snapshotHeadRefValue(
value: unknown,
headRevision: number,
): SnapshotHeadRefDocument | null {
if (value === null) {
if (headRevision !== 1) {
throw new SyncSnapshotRequestError("base_head_invalid");
}
return null;
}
const row = record(value, "base_head");
assertOnlyFields(row, ["revision", "snapshot_id", "payload_hash"]);
const revision = integer(row.revision, "base_head.revision", 1, Number.MAX_SAFE_INTEGER);
if (revision >= Number.MAX_SAFE_INTEGER || revision + 1 !== headRevision) {
throw new SyncSnapshotRequestError("base_head_invalid");
}
return {
revision,
snapshot_id: snapshotIdValue(row.snapshot_id),
payload_hash: sha256HexValue(row.payload_hash, "base_head.payload_hash"),
};
}
export function snapshotDocumentFromRow(row: SyncSnapshotRow): SyncSnapshotDocument {
try {
const headRevision = integer(
row.head_revision,
"head_revision",
1,
Number.MAX_SAFE_INTEGER,
);
const payloadHash = sha256HexValue(row.payload_hash, "payload_hash");
return {
snapshot_id: snapshotIdValue(row.snapshot_id),
r2_key: storedR2Key(row.r2_key, payloadHash),
payload_hash: payloadHash,
encryption_version: storedEncryptionVersion(row.encryption_version),
vault_generation: integer(
row.vault_generation,
"vault_generation",
1,
Number.MAX_SAFE_INTEGER,
),
key_id: sha256HexValue(row.key_id, "key_id"),
content_hash: sha256HexValue(row.content_hash, "content_hash"),
schema_rev: integer(row.schema_rev, "schema_rev", 1, Number.MAX_SAFE_INTEGER),
logical_clock: integer(
row.logical_clock,
"logical_clock",
0,
Number.MAX_SAFE_INTEGER,
),
head_revision: headRevision,
base_head: storedBaseHead(row, headRevision),
device_id: deviceIdValue(row.device_id),
size_bytes: integer(row.size_bytes, "size_bytes", 1, MAX_SNAPSHOT_BYTES),
created_at: integer(row.created_at, "created_at", 0, Number.MAX_SAFE_INTEGER),
};
} catch (error) {
if (error instanceof SyncSnapshotRequestError) {
throw new SyncSnapshotHeadSchemaError(error.message);
}
throw error;
}
}
export function snapshotDocumentFromResult(
result: ElyD1Result<SyncSnapshotRow> | undefined,
): SyncSnapshotDocument {
const row = result?.results.length === 1 ? result.results[0] : undefined;
if (row === undefined) {
throw new SyncSnapshotHeadSchemaError("sync_snapshot_missing");
}
return snapshotDocumentFromRow(row);
}
export function snapshotHeadRef(snapshot: SyncSnapshotDocument): SnapshotHeadRefDocument {
return {
revision: snapshot.head_revision,
snapshot_id: snapshot.snapshot_id,
payload_hash: snapshot.payload_hash,
};
}
export function sameSnapshotHead(
left: SnapshotHeadRefDocument | null,
right: SnapshotHeadRefDocument | null,
): boolean {
if (left === null || right === null) {
return left === right;
}
return left.revision === right.revision &&
left.snapshot_id === right.snapshot_id &&
left.payload_hash === right.payload_hash;
}
export async function currentSyncSnapshotHead(
database: ElyD1DatabaseSession,
userId: string,
): Promise<SyncSnapshotDocument | null> {
const row = await database.prepare(SYNC_SNAPSHOT_HEAD_QUERY)
.bind(userId)
.first<SyncSnapshotRow>();
return row === null ? null : snapshotDocumentFromRow(row);
}
export async function syncSnapshotByToken(
database: ElyD1DatabaseSession,
userId: string,
head: SnapshotHeadRefDocument,
): Promise<SyncSnapshotDocument | null> {
const row = await database.prepare(SYNC_SNAPSHOT_BY_TOKEN_QUERY)
.bind(userId, head.snapshot_id, head.revision, head.payload_hash)
.first<SyncSnapshotRow>();
return row === null ? null : snapshotDocumentFromRow(row);
}
function storedBaseHead(
row: SyncSnapshotRow,
headRevision: number,
): SnapshotHeadRefDocument | null {
const values = [row.base_head_revision, row.base_snapshot_id, row.base_payload_hash];
if (values.every((value) => value === null)) {
if (headRevision !== 1) {
throw new SyncSnapshotRequestError("base_head_invalid");
}
return null;
}
if (values.some((value) => value === null)) {
throw new SyncSnapshotRequestError("base_head_invalid");
}
const revision = integer(
row.base_head_revision,
"base_head.revision",
1,
Number.MAX_SAFE_INTEGER,
);
if (revision >= Number.MAX_SAFE_INTEGER || revision + 1 !== headRevision) {
throw new SyncSnapshotRequestError("base_head_invalid");
}
return {
revision,
snapshot_id: snapshotIdValue(row.base_snapshot_id),
payload_hash: sha256HexValue(row.base_payload_hash, "base_head.payload_hash"),
};
}
function storedEncryptionVersion(value: unknown): 1 | 2 {
if (value !== 1 && value !== 2) {
throw new SyncSnapshotRequestError("encryption_version_invalid");
}
return value;
}
function storedR2Key(value: unknown, payloadHash: string): string {
const key = text(value, "r2_key");
try {
assertKnownObjectKeyHash(key, payloadHash);
} catch (error) {
if (error instanceof StorageObjectError) {
throw new SyncSnapshotRequestError(error.message);
}
throw error;
}
return key;
}
function record(value: unknown, label: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new SyncSnapshotRequestError(`${label}_invalid`);
}
return value as Record<string, unknown>;
}
+334
View File
@@ -0,0 +1,334 @@
const SNAPSHOT_COLUMNS = `
snapshots.snapshot_id AS snapshot_id,
snapshots.r2_key AS r2_key,
snapshots.payload_hash AS payload_hash,
encryption.encryption_version AS encryption_version,
encryption.vault_generation AS vault_generation,
encryption.key_id AS key_id,
encryption.content_hash AS content_hash,
snapshots.schema_rev AS schema_rev,
snapshots.logical_clock AS logical_clock,
snapshots.head_revision AS head_revision,
snapshots.base_head_revision AS base_head_revision,
snapshots.base_snapshot_id AS base_snapshot_id,
snapshots.base_payload_hash AS base_payload_hash,
snapshots.device_id AS device_id,
snapshots.size_bytes AS size_bytes,
snapshots.created_at AS created_at
`;
export const SYNC_SNAPSHOT_HEAD_QUERY = `
SELECT ${SNAPSHOT_COLUMNS}
FROM sync_snapshot_heads AS head
INNER JOIN sync_snapshots AS snapshots
ON snapshots.user_id = head.user_id
AND snapshots.snapshot_id = head.snapshot_id
AND snapshots.head_revision = head.head_revision
AND snapshots.payload_hash = head.payload_hash
LEFT JOIN sync_snapshot_encryption AS encryption
ON encryption.user_id = snapshots.user_id
AND encryption.snapshot_id = snapshots.snapshot_id
WHERE head.user_id = ?
`;
export const SYNC_SNAPSHOT_BY_TOKEN_QUERY = `
SELECT ${SNAPSHOT_COLUMNS}
FROM sync_snapshot_heads AS head
INNER JOIN sync_snapshots AS snapshots
ON snapshots.user_id = head.user_id
AND snapshots.snapshot_id = head.snapshot_id
AND snapshots.head_revision = head.head_revision
AND snapshots.payload_hash = head.payload_hash
INNER JOIN sync_snapshot_encryption AS encryption
ON encryption.user_id = snapshots.user_id
AND encryption.snapshot_id = snapshots.snapshot_id
WHERE head.user_id = ?
AND head.snapshot_id = ?
AND head.head_revision = ?
AND head.payload_hash = ?
AND encryption.encryption_version IN (1, 2)
`;
export const SYNC_SNAPSHOT_CANDIDATE_UPSERT_QUERY = `
WITH candidate (
user_id, snapshot_id, r2_key, payload_hash, schema_rev,
logical_clock, device_id, size_bytes, created_at, head_revision,
base_head_revision, base_snapshot_id, base_payload_hash, key_id, vault_generation,
write_token, lease_now
) AS (VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?))
INSERT INTO sync_snapshots (
user_id, snapshot_id, r2_key, payload_hash, schema_rev,
logical_clock, device_id, size_bytes, created_at, head_revision,
base_head_revision, base_snapshot_id, base_payload_hash
)
SELECT
user_id, snapshot_id, r2_key, payload_hash, schema_rev,
logical_clock, device_id, size_bytes, created_at, head_revision,
base_head_revision, base_snapshot_id, base_payload_hash
FROM candidate
WHERE (
(
candidate.head_revision = 1
AND candidate.base_head_revision IS NULL
AND candidate.base_snapshot_id IS NULL
AND candidate.base_payload_hash IS NULL
AND NOT EXISTS (
SELECT 1 FROM sync_snapshot_heads AS head
WHERE head.user_id = candidate.user_id
)
)
OR
(
candidate.base_head_revision IS NOT NULL
AND candidate.base_snapshot_id IS NOT NULL
AND candidate.base_payload_hash IS NOT NULL
AND candidate.head_revision = candidate.base_head_revision + 1
AND EXISTS (
SELECT 1
FROM sync_snapshot_heads AS head
INNER JOIN sync_snapshots AS base
ON base.user_id = head.user_id
AND base.snapshot_id = head.snapshot_id
AND base.payload_hash = head.payload_hash
WHERE head.user_id = candidate.user_id
AND head.head_revision = candidate.base_head_revision
AND head.snapshot_id = candidate.base_snapshot_id
AND head.payload_hash = candidate.base_payload_hash
AND candidate.logical_clock > base.logical_clock
)
)
)
AND EXISTS (
SELECT 1 FROM sync_vault_accounts AS account
WHERE account.user_id = candidate.user_id
AND account.current_key_id = candidate.key_id
AND account.current_generation = candidate.vault_generation
)
AND EXISTS (
SELECT 1 FROM sync_r2_gc_candidates AS ledger
WHERE ledger.r2_key = candidate.r2_key
AND ledger.user_id = candidate.user_id
AND ledger.object_kind = 'snapshot'
AND ledger.state = 'pending'
AND ledger.write_token = candidate.write_token
AND ledger.lease_expires_at >= candidate.lease_now
)
AND EXISTS (
SELECT 1
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 = candidate.user_id
AND device.device_id = candidate.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
)
AND NOT EXISTS (
SELECT 1
FROM sync_vault_rotation_r2_objects AS staged
INNER JOIN sync_vault_rotations AS rotation
ON rotation.user_id = staged.user_id
AND rotation.idempotency_key = staged.rotation_idempotency_key
WHERE staged.user_id = candidate.user_id
AND staged.r2_key = candidate.r2_key
AND rotation.cleanup_started_at IS NOT NULL
)
ON CONFLICT(user_id, snapshot_id) DO UPDATE SET
r2_key = excluded.r2_key,
payload_hash = excluded.payload_hash,
schema_rev = excluded.schema_rev,
logical_clock = excluded.logical_clock,
device_id = excluded.device_id,
size_bytes = excluded.size_bytes,
created_at = excluded.created_at,
head_revision = excluded.head_revision,
base_head_revision = excluded.base_head_revision,
base_snapshot_id = excluded.base_snapshot_id,
base_payload_hash = excluded.base_payload_hash
`;
export const SYNC_SNAPSHOT_ENCRYPTION_UPSERT_QUERY = `
WITH candidate (
user_id, snapshot_id, r2_key, payload_hash, schema_rev,
logical_clock, device_id, size_bytes, created_at, head_revision,
base_head_revision, base_snapshot_id, base_payload_hash,
encryption_version, vault_generation, key_id, content_hash, write_token, lease_now
) AS (VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?))
INSERT INTO sync_snapshot_encryption (
user_id, snapshot_id, encryption_version, vault_generation, key_id, content_hash
)
SELECT
candidate.user_id,
candidate.snapshot_id,
candidate.encryption_version,
candidate.vault_generation,
candidate.key_id,
candidate.content_hash
FROM candidate
INNER JOIN sync_snapshots AS snapshot
ON snapshot.user_id = candidate.user_id
AND snapshot.snapshot_id = candidate.snapshot_id
AND snapshot.r2_key = candidate.r2_key
AND snapshot.payload_hash = candidate.payload_hash
AND snapshot.schema_rev = candidate.schema_rev
AND snapshot.logical_clock = candidate.logical_clock
AND snapshot.device_id = candidate.device_id
AND snapshot.size_bytes = candidate.size_bytes
AND snapshot.created_at = candidate.created_at
AND snapshot.head_revision = candidate.head_revision
AND snapshot.base_head_revision IS candidate.base_head_revision
AND snapshot.base_snapshot_id IS candidate.base_snapshot_id
AND snapshot.base_payload_hash IS candidate.base_payload_hash
INNER JOIN sync_vault_accounts AS account
ON account.user_id = candidate.user_id
AND account.current_key_id = candidate.key_id
AND account.current_generation = candidate.vault_generation
INNER JOIN user_devices AS device
ON device.user_id = candidate.user_id
AND device.device_id = candidate.device_id
AND device.approval_status = 'approved'
AND device.revoked_at IS NULL
INNER JOIN user_device_keys AS keys
ON keys.user_id = device.user_id
AND keys.device_id = device.device_id
AND keys.key_protocol_version = 2
AND keys.wrapping_public_key IS NOT NULL
WHERE candidate.encryption_version = 2
AND EXISTS (
SELECT 1 FROM sync_r2_gc_candidates AS ledger
WHERE ledger.r2_key = candidate.r2_key
AND ledger.user_id = candidate.user_id
AND ledger.object_kind = 'snapshot'
AND ledger.state = 'pending'
AND ledger.write_token = candidate.write_token
AND ledger.lease_expires_at >= candidate.lease_now
)
AND NOT EXISTS (
SELECT 1
FROM sync_vault_rotation_r2_objects AS staged
INNER JOIN sync_vault_rotations AS rotation
ON rotation.user_id = staged.user_id
AND rotation.idempotency_key = staged.rotation_idempotency_key
WHERE staged.user_id = candidate.user_id
AND staged.r2_key = candidate.r2_key
AND rotation.cleanup_started_at IS NOT NULL
)
ON CONFLICT(user_id, snapshot_id) DO UPDATE SET
encryption_version = excluded.encryption_version,
vault_generation = excluded.vault_generation,
key_id = excluded.key_id,
content_hash = excluded.content_hash
`;
export const SYNC_SNAPSHOT_HEAD_INSERT_QUERY = `
INSERT INTO sync_snapshot_heads (
user_id, head_revision, snapshot_id, payload_hash, updated_at
)
SELECT ?, ?, ?, ?, ?
WHERE EXISTS (
SELECT 1 FROM sync_r2_gc_candidates
WHERE r2_key = ? AND user_id = ? AND object_kind = 'snapshot'
AND state = 'pending' AND write_token = ? AND lease_expires_at >= ?
)
`;
export const SYNC_SNAPSHOT_HEAD_UPDATE_QUERY = `
UPDATE sync_snapshot_heads
SET head_revision = ?, snapshot_id = ?, payload_hash = ?, updated_at = ?
WHERE user_id = ?
AND EXISTS (
SELECT 1 FROM sync_r2_gc_candidates
WHERE r2_key = ? AND user_id = ? AND object_kind = 'snapshot'
AND state = 'pending' AND write_token = ? AND lease_expires_at >= ?
)
`;
export function syncSnapshotStatements(
database: ElyD1DatabaseSession,
userId: string,
deviceId: string,
upload: SyncSnapshotWrite,
writeLease: SyncR2WriteLease,
nowSeconds: number,
): ElyD1PreparedStatement[] {
const snapshotValues = [
userId,
upload.snapshotId,
upload.r2Key,
upload.payloadHash,
upload.schemaRev,
upload.logicalClock,
deviceId,
upload.sizeBytes,
nowSeconds,
upload.headRevision,
upload.baseHead?.revision ?? null,
upload.baseHead?.snapshot_id ?? null,
upload.baseHead?.payload_hash ?? null,
];
const headStatement = upload.baseHead === null
? database.prepare(SYNC_SNAPSHOT_HEAD_INSERT_QUERY).bind(
userId,
upload.headRevision,
upload.snapshotId,
upload.payloadHash,
nowSeconds,
upload.r2Key,
userId,
writeLease.writeToken,
nowSeconds,
)
: database.prepare(SYNC_SNAPSHOT_HEAD_UPDATE_QUERY).bind(
upload.headRevision,
upload.snapshotId,
upload.payloadHash,
nowSeconds,
userId,
upload.r2Key,
userId,
writeLease.writeToken,
nowSeconds,
);
return [
database.prepare(SYNC_SNAPSHOT_CANDIDATE_UPSERT_QUERY).bind(
...snapshotValues,
upload.keyId,
upload.vaultGeneration,
writeLease.writeToken,
nowSeconds,
),
database.prepare(SYNC_SNAPSHOT_ENCRYPTION_UPSERT_QUERY).bind(
...snapshotValues,
upload.encryptionVersion,
upload.vaultGeneration,
upload.keyId,
upload.contentHash,
writeLease.writeToken,
nowSeconds,
),
headStatement,
syncR2MarkReferencedStatement(database, userId, upload.r2Key, writeLease, nowSeconds),
database.prepare(SYNC_SNAPSHOT_HEAD_QUERY).bind(userId),
];
}
import type { ElyD1DatabaseSession, ElyD1PreparedStatement } from "./bindings.js";
import { type SyncR2WriteLease, syncR2MarkReferencedStatement } from "./sync_r2_gc.js";
import type { SnapshotHeadRefDocument } from "./sync_snapshot_head.js";
export interface SyncSnapshotWrite {
snapshotId: string;
r2Key: string;
payloadHash: string;
encryptionVersion: 2;
vaultGeneration: number;
keyId: string;
contentHash: string;
schemaRev: number;
logicalClock: number;
headRevision: number;
baseHead: SnapshotHeadRefDocument | null;
sizeBytes: number;
}
+122
View File
@@ -0,0 +1,122 @@
import type { ElyD1DatabaseSession, Env } from "./bindings.js";
import {
type SyncR2WriteLease,
abandonSyncR2Write,
claimSyncR2SnapshotWrite,
collectSyncR2Garbage,
} from "./sync_r2_gc.js";
import {
SyncSnapshotRequestError,
arrayBufferFromBytes,
sha256Hex,
} from "./sync_snapshot_codec.js";
import type { SnapshotHeadRefDocument } from "./sync_snapshot_head.js";
import { StorageObjectError, putVerifiedObject, syncSnapshotKey } from "./storage.js";
interface SnapshotStorageWrite {
r2Key: string;
payloadHash: string;
keyId: string;
vaultGeneration: number;
headRevision: number;
baseHead: SnapshotHeadRefDocument | null;
bytes: ArrayBuffer;
}
export type { SyncR2WriteLease } from "./sync_r2_gc.js";
export function claimSnapshotStorageWrite(
env: Env,
database: ElyD1DatabaseSession,
userId: string,
deviceId: string,
upload: SnapshotStorageWrite,
nowSeconds: number,
): Promise<SyncR2WriteLease> {
return syncOwnerHash(userId).then((ownerHash) => claimSyncR2SnapshotWrite(
env,
{
userId,
deviceId,
r2Key: upload.r2Key,
ownerHash,
keyId: upload.keyId,
generation: upload.vaultGeneration,
headRevision: upload.headRevision,
baseHead: upload.baseHead === null ? null : {
revision: upload.baseHead.revision,
snapshotId: upload.baseHead.snapshot_id,
payloadHash: upload.baseHead.payload_hash,
},
},
nowSeconds,
undefined,
database,
));
}
export async function persistClaimedSnapshot(
env: Env,
upload: SnapshotStorageWrite,
): Promise<void> {
try {
await putVerifiedObject(
env.ELY_STORAGE,
upload.r2Key,
upload.bytes,
upload.payloadHash,
"application/octet-stream",
);
} catch (error) {
if (error instanceof StorageObjectError) throw new SyncSnapshotRequestError(error.message);
throw error;
}
}
export async function releaseFailedSnapshotWrite(
env: Env,
database: ElyD1DatabaseSession,
userId: string,
r2Key: string,
lease: SyncR2WriteLease,
nowSeconds: number,
): Promise<void> {
const ownerHash = await syncOwnerHash(userId);
await abandonSyncR2Write(
env,
userId,
ownerHash,
r2Key,
lease.writeToken,
nowSeconds,
database,
);
try {
await collectSyncR2Garbage(env, nowSeconds, { ownerHash, limit: 5, database });
} catch {
// The durable candidate remains available to scheduled GC.
}
}
export async function snapshotStorageKey(
region: string,
userId: string,
snapshotId: string,
payloadHash: string,
): Promise<string> {
try {
return syncSnapshotKey({
region,
userHash: await syncOwnerHash(userId),
snapshotId,
payloadHash,
});
} catch (error) {
if (error instanceof StorageObjectError) throw new SyncSnapshotRequestError(error.message);
throw error;
}
}
function syncOwnerHash(userId: string): Promise<string> {
return sha256Hex(arrayBufferFromBytes(new TextEncoder().encode(userId)));
}
+97 -83
View File
@@ -1,6 +1,13 @@
import type { AuthContext } from "./auth.js";
import type { Env } from "./bindings.js";
import type { ElyD1Result, Env } from "./bindings.js";
import { primaryD1Session } from "./bindings.js";
import { StorageObjectError, assertSyncObjectType } from "./storage.js";
import type { SnapshotHeadRefDocument, SyncSnapshotRow } from "./sync_snapshot_head.js";
import {
SyncSnapshotHeadSchemaError,
snapshotDocumentFromRow,
} from "./sync_snapshot_head.js";
import { SYNC_SNAPSHOT_HEAD_QUERY } from "./sync_snapshot_sql.js";
const CHANGE_CURSOR_QUERY = `
SELECT
@@ -23,21 +30,11 @@ const OBJECT_STATUS_QUERY = `
`;
const SNAPSHOT_COUNT_QUERY = `
SELECT COUNT(*) AS total_snapshots
FROM sync_snapshots
WHERE user_id = ?
`;
const LATEST_SNAPSHOT_QUERY = `
SELECT
snapshot_id,
payload_hash,
logical_clock,
device_id,
size_bytes,
created_at
FROM sync_snapshots
WHERE user_id = ?
ORDER BY created_at DESC, snapshot_id ASC
LIMIT 1
FROM sync_snapshots AS snapshots
INNER JOIN sync_snapshot_encryption AS encryption
ON encryption.user_id = snapshots.user_id
AND encryption.snapshot_id = snapshots.snapshot_id
WHERE snapshots.user_id = ? AND encryption.encryption_version IN (1, 2)
`;
const APPROVED_DEVICE_COUNT_QUERY = `
SELECT COUNT(*) AS approved_devices
@@ -45,12 +42,8 @@ const APPROVED_DEVICE_COUNT_QUERY = `
WHERE user_id = ? AND approval_status = 'approved' AND revoked_at IS NULL
`;
const SNAPSHOT_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/;
const DEVICE_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,128}$/;
const SHA256_HEX = /^[a-f0-9]{64}$/;
export interface SyncStatusDocument {
version: 1;
version: 2;
user_id: string;
device_id: string;
cursor: SyncCursorStatusDocument;
@@ -74,13 +67,19 @@ export interface SyncObjectStatusDocument {
export interface SyncSnapshotStatusDocument {
total_snapshots: number;
latest: SyncLatestSnapshotDocument | null;
head: SyncSnapshotHeadStatusDocument | null;
}
export interface SyncLatestSnapshotDocument {
export interface SyncSnapshotHeadStatusDocument {
snapshot_id: string;
payload_hash: string;
encryption_version: 1 | 2;
vault_generation: number;
key_id: string;
content_hash: string;
logical_clock: number;
head_revision: number;
base_head: SnapshotHeadRefDocument | null;
device_id: string;
size_bytes: number;
created_at: number;
@@ -109,15 +108,6 @@ interface SnapshotCountRow {
total_snapshots: unknown;
}
interface LatestSnapshotRow {
snapshot_id: unknown;
payload_hash: unknown;
logical_clock: unknown;
device_id: unknown;
size_bytes: unknown;
created_at: unknown;
}
interface DeviceStatusRow {
approved_devices: unknown;
}
@@ -134,33 +124,60 @@ export async function syncStatusDocument(
context: AuthContext,
): Promise<SyncStatusDocument> {
const deviceId = currentDeviceId(context);
const cursorRow = await env.ELY_DB.prepare(CHANGE_CURSOR_QUERY)
.bind(context.userId)
.first<ChangeCursorRow>();
const objectRows = await env.ELY_DB.prepare(OBJECT_STATUS_QUERY)
.bind(context.userId)
.all<ObjectStatusRow>();
const snapshotCountRow = await env.ELY_DB.prepare(SNAPSHOT_COUNT_QUERY)
.bind(context.userId)
.first<SnapshotCountRow>();
const latestSnapshotRow = await env.ELY_DB.prepare(LATEST_SNAPSHOT_QUERY)
.bind(context.userId)
.first<LatestSnapshotRow>();
const deviceStatusRow = await env.ELY_DB.prepare(APPROVED_DEVICE_COUNT_QUERY)
.bind(context.userId)
.first<DeviceStatusRow>();
const database = primaryD1Session(env.ELY_DB);
const results = await database.batch<ElyD1Result>([
database.prepare(CHANGE_CURSOR_QUERY).bind(context.userId),
database.prepare(OBJECT_STATUS_QUERY).bind(context.userId),
database.prepare(SNAPSHOT_COUNT_QUERY).bind(context.userId),
database.prepare(SYNC_SNAPSHOT_HEAD_QUERY).bind(context.userId),
database.prepare(APPROVED_DEVICE_COUNT_QUERY).bind(context.userId),
]);
if (results.length !== 5) {
throw new SyncStatusSchemaError("sync_status_batch_invalid");
}
const cursorRow = oneRow<ChangeCursorRow>(results[0], "sync_cursor_status_missing");
const objectRows = rows<ObjectStatusRow>(results[1]);
const snapshotCountRow = oneRow<SnapshotCountRow>(
results[2],
"sync_snapshot_status_missing",
);
const snapshotHeadRow = optionalRow<SyncSnapshotRow>(results[3]);
const deviceStatusRow = oneRow<DeviceStatusRow>(results[4], "sync_device_status_missing");
return {
version: 1,
version: 2,
user_id: context.userId,
device_id: deviceId,
cursor: cursorStatus(cursorRow),
objects: objectRows.results.map(objectStatus),
snapshots: snapshotStatus(snapshotCountRow, latestSnapshotRow),
objects: objectRows.map(objectStatus),
snapshots: snapshotStatus(snapshotCountRow, snapshotHeadRow),
devices: deviceStatus(deviceStatusRow, deviceId),
};
}
function rows<T>(result: ElyD1Result | undefined): T[] {
if (result === undefined || !Array.isArray(result.results)) {
throw new SyncStatusSchemaError("sync_status_batch_invalid");
}
return result.results as T[];
}
function oneRow<T>(result: ElyD1Result | undefined, message: string): T {
const values = rows<T>(result);
if (values.length !== 1) {
throw new SyncStatusSchemaError(message);
}
return values[0] as T;
}
function optionalRow<T>(result: ElyD1Result | undefined): T | null {
const values = rows<T>(result);
if (values.length > 1) {
throw new SyncStatusSchemaError("sync_snapshot_head_rows_invalid");
}
return values[0] ?? null;
}
function cursorStatus(row: ChangeCursorRow | null): SyncCursorStatusDocument {
if (row === null) {
throw new SyncStatusSchemaError("sync_cursor_status_missing");
@@ -183,26 +200,44 @@ function objectStatus(row: ObjectStatusRow): SyncObjectStatusDocument {
function snapshotStatus(
countRow: SnapshotCountRow | null,
latestRow: LatestSnapshotRow | null,
headRow: SyncSnapshotRow | null,
): SyncSnapshotStatusDocument {
if (countRow === null) {
throw new SyncStatusSchemaError("sync_snapshot_status_missing");
}
const totalSnapshots = integer(countRow.total_snapshots, "total_snapshots");
if ((totalSnapshots === 0) !== (headRow === null)) {
throw new SyncStatusSchemaError("sync_snapshot_head_missing");
}
return {
total_snapshots: integer(countRow.total_snapshots, "total_snapshots"),
latest: latestRow === null ? null : latestSnapshot(latestRow),
total_snapshots: totalSnapshots,
head: headRow === null ? null : snapshotHeadStatus(headRow),
};
}
function latestSnapshot(row: LatestSnapshotRow): SyncLatestSnapshotDocument {
return {
snapshot_id: snapshotId(row.snapshot_id),
payload_hash: payloadHash(row.payload_hash),
logical_clock: integer(row.logical_clock, "logical_clock"),
device_id: deviceId(row.device_id),
size_bytes: integer(row.size_bytes, "size_bytes"),
created_at: integer(row.created_at, "created_at"),
};
function snapshotHeadStatus(row: SyncSnapshotRow): SyncSnapshotHeadStatusDocument {
try {
const snapshot = snapshotDocumentFromRow(row);
return {
snapshot_id: snapshot.snapshot_id,
payload_hash: snapshot.payload_hash,
encryption_version: snapshot.encryption_version,
vault_generation: snapshot.vault_generation,
key_id: snapshot.key_id,
content_hash: snapshot.content_hash,
logical_clock: snapshot.logical_clock,
head_revision: snapshot.head_revision,
base_head: snapshot.base_head,
device_id: snapshot.device_id,
size_bytes: snapshot.size_bytes,
created_at: snapshot.created_at,
};
} catch (error) {
if (error instanceof SyncSnapshotHeadSchemaError) {
throw new SyncStatusSchemaError(error.message);
}
throw error;
}
}
function deviceStatus(
@@ -241,27 +276,6 @@ function objectType(value: unknown): string {
return value;
}
function snapshotId(value: unknown): string {
if (typeof value !== "string" || !SNAPSHOT_ID_PATTERN.test(value)) {
throw new SyncStatusSchemaError("snapshot_id_invalid");
}
return value;
}
function deviceId(value: unknown): string {
if (typeof value !== "string" || !DEVICE_ID_PATTERN.test(value)) {
throw new SyncStatusSchemaError("device_id_invalid");
}
return value;
}
function payloadHash(value: unknown): string {
if (typeof value !== "string" || !SHA256_HEX.test(value)) {
throw new SyncStatusSchemaError("payload_hash_invalid");
}
return value;
}
function integer(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
throw new SyncStatusSchemaError(`${label}_invalid`);
+499
View File
@@ -0,0 +1,499 @@
import type { AuthContext } from "./auth.js";
import type { ElyD1DatabaseSession, ElyD1PreparedStatement, Env } from "./bindings.js";
import { syncVaultBootstrapProofValid } from "./sync_vault_bootstrap_proof.js";
import {
CURRENT_DEVICE_ENVELOPE_QUERY,
CURRENT_SYNC_VAULT_KEY_QUERY,
HISTORICAL_DEVICE_ENVELOPE_QUERY,
SYNC_VAULT_ACCOUNT_INSERT_QUERY,
SYNC_VAULT_ENVELOPE_INSERT_QUERY,
} from "./sync_vault_sql.js";
const HPKE_SUITE = "HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305";
const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/;
const DEVICE_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,128}$/;
const IDEMPOTENCY_KEY_PATTERN = /^[a-zA-Z0-9._:-]{16,128}$/;
const SIGNATURE_PATTERN = /^[a-f0-9]{128}$/;
const BASE64URL_32_BYTES_PATTERN = /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/;
const BASE64URL_48_BYTES_PATTERN = /^[A-Za-z0-9_-]{64}$/;
export interface CurrentSyncVaultKey {
keyId: string;
generation: number;
}
export interface SyncVaultDocument {
version: 1;
user_id: string;
key_id: string;
generation: number;
recipient_device_id: string;
approver_device_id: string;
envelope: WrappedAccountKeyDocument;
created_at: number;
}
export interface WrappedAccountKeyDocument {
version: 1;
suite: typeof HPKE_SUITE;
encapped_key: string;
ciphertext: string;
}
interface SyncVaultBootstrapRequest {
keyId: string;
generation: number;
envelope: WrappedAccountKeyDocument;
idempotencyKey: string;
bootstrapProof: string;
}
interface SyncVaultEnvelopeLookup {
keyId: string;
generation: number;
}
interface SyncVaultKeyRow {
key_id: unknown;
generation: unknown;
}
interface SyncVaultEnvelopeRow extends SyncVaultKeyRow {
recipient_device_id: unknown;
approver_device_id: unknown;
envelope_version: unknown;
suite: unknown;
encapped_key: unknown;
ciphertext: unknown;
idempotency_key: unknown;
created_at: unknown;
}
interface StoredSyncVaultEnvelope {
document: SyncVaultDocument;
idempotencyKey: string;
}
type RequestBody = Record<string, unknown>;
export class SyncVaultRequestError extends Error {}
export class SyncVaultPermissionError extends Error {}
export class SyncVaultNotFoundError extends Error {}
export class SyncVaultConflictError extends Error {}
export class SyncVaultPersistenceError extends Error {}
export async function syncVaultBootstrapDocument(
request: Request,
env: Env,
context: AuthContext,
nowSeconds = Math.floor(Date.now() / 1000),
): Promise<SyncVaultDocument> {
const deviceId = currentDeviceId(context);
const bootstrap = await syncVaultBootstrapRequest(request);
const { bootstrapProof, ...unsignedBootstrap } = bootstrap;
if (!(await syncVaultBootstrapProofValid(
env,
context.userId,
deviceId,
unsignedBootstrap,
bootstrapProof,
))) {
throw new SyncVaultPermissionError("sync_vault_bootstrap_proof_invalid");
}
await env.ELY_DB.batch(syncVaultBootstrapStatements(env, context.userId, deviceId, bootstrap, nowSeconds));
const stored = await currentDeviceEnvelope(env, context.userId, deviceId);
if (stored === null) {
const currentKey = await currentSyncVaultKey(env, context.userId);
if (currentKey === null) {
throw new SyncVaultPersistenceError("sync_vault_account_missing");
}
if (currentKey.keyId !== bootstrap.keyId || currentKey.generation !== bootstrap.generation) {
throw new SyncVaultConflictError("sync_vault_key_conflict");
}
throw new SyncVaultPersistenceError("sync_vault_envelope_missing");
}
assertBootstrapMatches(stored, bootstrap, deviceId);
return stored.document;
}
export async function syncVaultCurrentDeviceDocument(
url: URL,
env: Env,
context: AuthContext,
): Promise<SyncVaultDocument> {
const deviceId = currentDeviceId(context);
const stored = await currentDeviceEnvelope(
env,
context.userId,
deviceId,
syncVaultEnvelopeLookup(url),
);
if (stored === null) {
throw new SyncVaultNotFoundError("sync_vault_envelope_not_found");
}
return stored.document;
}
export async function currentSyncVaultKey(
env: Env,
userId: string,
database: ElyD1DatabaseSession = env.ELY_DB,
): Promise<CurrentSyncVaultKey | null> {
const row = await database.prepare(CURRENT_SYNC_VAULT_KEY_QUERY)
.bind(userId)
.first<SyncVaultKeyRow>();
if (row === null) {
return null;
}
return {
keyId: storedKeyId(row.key_id),
generation: storedInteger(row.generation, "generation", 1),
};
}
export async function assertCurrentSyncVaultKey(
env: Env,
userId: string,
keyId: string,
generation: number,
database: ElyD1DatabaseSession = env.ELY_DB,
): Promise<void> {
const current = await currentSyncVaultKey(env, userId, database);
if (current === null) {
throw new SyncVaultNotFoundError("sync_vault_not_initialized");
}
if (current.keyId !== keyId || current.generation !== generation) {
throw new SyncVaultConflictError("sync_vault_key_not_current");
}
}
export function parseWrappedAccountKey(value: unknown): WrappedAccountKeyDocument { return requestEnvelope(value); }
export function syncVaultRecipientEnvelopeStatement(
env: Env,
userId: string,
recipientDeviceId: string,
approverDeviceId: string,
keyId: string,
generation: number,
envelope: WrappedAccountKeyDocument,
idempotencyKey: string,
nowSeconds: number,
): ElyD1PreparedStatement {
return syncVaultEnvelopeStatement(
env,
userId,
recipientDeviceId,
approverDeviceId,
keyId,
generation,
envelope,
idempotencyKey,
nowSeconds,
"pending",
);
}
function syncVaultBootstrapStatements(
env: Env,
userId: string,
deviceId: string,
bootstrap: SyncVaultBootstrapRequest,
nowSeconds: number,
): ElyD1PreparedStatement[] {
return [
env.ELY_DB.prepare(SYNC_VAULT_ACCOUNT_INSERT_QUERY).bind(
userId,
bootstrap.keyId,
bootstrap.generation,
nowSeconds,
nowSeconds,
userId,
deviceId,
),
syncVaultEnvelopeStatement(
env,
userId,
deviceId,
deviceId,
bootstrap.keyId,
bootstrap.generation,
bootstrap.envelope,
bootstrap.idempotencyKey,
nowSeconds,
"approved",
),
];
}
function syncVaultEnvelopeStatement(
env: Env,
userId: string,
recipientDeviceId: string,
approverDeviceId: string,
keyId: string,
generation: number,
envelope: WrappedAccountKeyDocument,
idempotencyKey: string,
nowSeconds: number,
recipientStatus: "pending" | "approved",
): ElyD1PreparedStatement {
const wrapped = requestEnvelope(envelope);
requestDeviceId(recipientDeviceId, "recipient_device_id");
requestDeviceId(approverDeviceId, "approver_device_id");
requestKeyId(keyId);
requestInteger(generation, "generation", 1);
requestIdempotencyKey(idempotencyKey);
requestInteger(nowSeconds, "created_at", 0);
return env.ELY_DB.prepare(SYNC_VAULT_ENVELOPE_INSERT_QUERY).bind(
userId,
recipientDeviceId,
approverDeviceId,
keyId,
generation,
wrapped.version,
wrapped.suite,
wrapped.encapped_key,
wrapped.ciphertext,
idempotencyKey,
nowSeconds,
recipientDeviceId,
recipientStatus,
approverDeviceId,
userId,
keyId,
generation,
);
}
async function currentDeviceEnvelope(
env: Env,
userId: string,
deviceId: string,
lookup?: SyncVaultEnvelopeLookup,
): Promise<StoredSyncVaultEnvelope | null> {
const statement = lookup === undefined
? env.ELY_DB.prepare(CURRENT_DEVICE_ENVELOPE_QUERY).bind(userId, deviceId)
: env.ELY_DB.prepare(HISTORICAL_DEVICE_ENVELOPE_QUERY)
.bind(userId, deviceId, lookup.keyId, lookup.generation);
const row = await statement.first<SyncVaultEnvelopeRow>();
if (row === null) {
return null;
}
const recipientDeviceId = storedDeviceId(row.recipient_device_id, "recipient_device_id");
if (recipientDeviceId !== deviceId) {
throw new SyncVaultPersistenceError("recipient_device_id_mismatch");
}
return {
document: {
version: 1,
user_id: userId,
key_id: storedKeyId(row.key_id),
generation: storedInteger(row.generation, "generation", 1),
recipient_device_id: recipientDeviceId,
approver_device_id: storedDeviceId(row.approver_device_id, "approver_device_id"),
envelope: storedEnvelope(row),
created_at: storedInteger(row.created_at, "created_at", 0),
},
idempotencyKey: storedIdempotencyKey(row.idempotency_key),
};
}
function syncVaultEnvelopeLookup(url: URL): SyncVaultEnvelopeLookup | undefined {
if ([...url.searchParams].length === 0) {
return undefined;
}
for (const field of url.searchParams.keys()) {
if (field !== "key_id" && field !== "generation") {
throw new SyncVaultRequestError(`unexpected_query:${field}`);
}
}
const keyIds = url.searchParams.getAll("key_id");
const generations = url.searchParams.getAll("generation");
if (keyIds.length !== 1 || generations.length !== 1) {
throw new SyncVaultRequestError("vault_query_pair_required");
}
if (!/^[1-9][0-9]*$/.test(generations[0] ?? "")) {
throw new SyncVaultRequestError("generation_invalid");
}
return {
keyId: requestKeyId(keyIds[0]),
generation: requestInteger(Number(generations[0]), "generation", 1),
};
}
async function syncVaultBootstrapRequest(request: Request): Promise<SyncVaultBootstrapRequest> {
const body = await requestBody(request);
assertOnlyFields(body, [
"version",
"key_id",
"generation",
"envelope",
"idempotency_key",
"bootstrap_proof",
]);
if (body.version !== 2) {
throw new SyncVaultRequestError("version_invalid");
}
if (body.generation !== 1) {
throw new SyncVaultRequestError("generation_invalid");
}
return {
keyId: requestKeyId(body.key_id),
generation: 1,
envelope: requestEnvelope(body.envelope),
idempotencyKey: requestIdempotencyKey(body.idempotency_key),
bootstrapProof: requestSignature(body.bootstrap_proof),
};
}
function requestEnvelope(value: unknown): WrappedAccountKeyDocument {
const envelope = record(value, "envelope");
assertOnlyFields(envelope, ["version", "suite", "encapped_key", "ciphertext"]);
if (envelope.version !== 1 || envelope.suite !== HPKE_SUITE) {
throw new SyncVaultRequestError("envelope_metadata_invalid");
}
if (typeof envelope.encapped_key !== "string" || !BASE64URL_32_BYTES_PATTERN.test(envelope.encapped_key)) {
throw new SyncVaultRequestError("encapped_key_invalid");
}
if (typeof envelope.ciphertext !== "string" || !BASE64URL_48_BYTES_PATTERN.test(envelope.ciphertext)) {
throw new SyncVaultRequestError("ciphertext_invalid");
}
return {
version: 1,
suite: HPKE_SUITE,
encapped_key: envelope.encapped_key,
ciphertext: envelope.ciphertext,
};
}
function storedEnvelope(row: SyncVaultEnvelopeRow): WrappedAccountKeyDocument {
if (row.envelope_version !== 1 || row.suite !== HPKE_SUITE) {
throw new SyncVaultPersistenceError("envelope_metadata_invalid");
}
if (typeof row.encapped_key !== "string" || !BASE64URL_32_BYTES_PATTERN.test(row.encapped_key)) {
throw new SyncVaultPersistenceError("encapped_key_invalid");
}
if (typeof row.ciphertext !== "string" || !BASE64URL_48_BYTES_PATTERN.test(row.ciphertext)) {
throw new SyncVaultPersistenceError("ciphertext_invalid");
}
return {
version: 1,
suite: HPKE_SUITE,
encapped_key: row.encapped_key,
ciphertext: row.ciphertext,
};
}
function assertBootstrapMatches(
stored: StoredSyncVaultEnvelope,
bootstrap: SyncVaultBootstrapRequest,
deviceId: string,
): void {
const { document } = stored;
if (
document.key_id !== bootstrap.keyId ||
document.generation !== bootstrap.generation ||
document.recipient_device_id !== deviceId ||
document.approver_device_id !== deviceId ||
document.envelope.version !== bootstrap.envelope.version ||
document.envelope.suite !== bootstrap.envelope.suite ||
document.envelope.encapped_key !== bootstrap.envelope.encapped_key ||
document.envelope.ciphertext !== bootstrap.envelope.ciphertext ||
stored.idempotencyKey !== bootstrap.idempotencyKey
) {
throw new SyncVaultConflictError("sync_vault_bootstrap_replay_mismatch");
}
}
async function requestBody(request: Request): Promise<RequestBody> {
let value: unknown;
try {
value = await request.json();
} catch {
throw new SyncVaultRequestError("json_invalid");
}
return record(value, "body");
}
function record(value: unknown, label: string): RequestBody {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new SyncVaultRequestError(`${label}_invalid`);
}
return value as RequestBody;
}
function assertOnlyFields(value: RequestBody, fields: string[]): void {
const allowed = new Set(fields);
for (const field of Object.keys(value)) {
if (!allowed.has(field)) {
throw new SyncVaultRequestError(`unexpected_field:${field}`);
}
}
}
function currentDeviceId(context: AuthContext): string {
if (context.deviceId === undefined) {
throw new SyncVaultRequestError("device_context_required");
}
return context.deviceId;
}
function requestKeyId(value: unknown): string {
if (typeof value !== "string" || !SHA256_HEX_PATTERN.test(value)) {
throw new SyncVaultRequestError("key_id_invalid");
}
return value;
}
function storedKeyId(value: unknown): string {
if (typeof value !== "string" || !SHA256_HEX_PATTERN.test(value)) {
throw new SyncVaultPersistenceError("key_id_invalid");
}
return value;
}
function requestIdempotencyKey(value: unknown): string {
if (typeof value !== "string" || !IDEMPOTENCY_KEY_PATTERN.test(value)) {
throw new SyncVaultRequestError("idempotency_key_invalid");
}
return value;
}
function requestSignature(value: unknown): string {
if (typeof value !== "string" || !SIGNATURE_PATTERN.test(value)) {
throw new SyncVaultRequestError("bootstrap_proof_invalid");
}
return value;
}
function requestDeviceId(value: unknown, label: string): string {
if (typeof value !== "string" || !DEVICE_ID_PATTERN.test(value)) {
throw new SyncVaultRequestError(`${label}_invalid`);
}
return value;
}
function requestInteger(value: unknown, label: string, min: number): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min) {
throw new SyncVaultRequestError(`${label}_invalid`);
}
return value;
}
function storedIdempotencyKey(value: unknown): string {
if (typeof value !== "string" || !IDEMPOTENCY_KEY_PATTERN.test(value)) {
throw new SyncVaultPersistenceError("idempotency_key_invalid");
}
return value;
}
function storedDeviceId(value: unknown, label: string): string {
if (typeof value !== "string" || !DEVICE_ID_PATTERN.test(value)) {
throw new SyncVaultPersistenceError(`${label}_invalid`);
}
return value;
}
function storedInteger(value: unknown, label: string, min: number): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min) {
throw new SyncVaultPersistenceError(`${label}_invalid`);
}
return value;
}
@@ -0,0 +1,75 @@
import type { Env } from "./bindings.js";
import { verifyEd25519Signature } from "./device_crypto.js";
import type { WrappedAccountKeyDocument } from "./sync_vault.js";
const BOOTSTRAP_DOMAIN = "elydora-sync-vault-bootstrap-v2";
const PUBLIC_KEY_PATTERN = /^[a-f0-9]{64}$/;
const APPROVED_V2_SIGNING_KEY_QUERY = `
SELECT 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.signing_public_key IS NOT NULL
`;
export interface SyncVaultBootstrapProofInput {
keyId: string;
generation: number;
envelope: WrappedAccountKeyDocument;
idempotencyKey: string;
}
interface SigningKeyRow {
signing_public_key: unknown;
}
export async function syncVaultBootstrapProofValid(
env: Env,
userId: string,
deviceId: string,
bootstrap: SyncVaultBootstrapProofInput,
proof: string,
): Promise<boolean> {
const row = await env.ELY_DB.prepare(APPROVED_V2_SIGNING_KEY_QUERY)
.bind(userId, deviceId)
.first<SigningKeyRow>();
if (
row === null ||
typeof row.signing_public_key !== "string" ||
!PUBLIC_KEY_PATTERN.test(row.signing_public_key)
) {
return false;
}
return verifyEd25519Signature(
row.signing_public_key,
proof,
syncVaultBootstrapProofBytes(userId, deviceId, bootstrap),
);
}
export function syncVaultBootstrapProofBytes(
userId: string,
deviceId: string,
bootstrap: SyncVaultBootstrapProofInput,
): Uint8Array {
const values: (number | string)[] = [
BOOTSTRAP_DOMAIN,
userId,
deviceId,
bootstrap.keyId,
bootstrap.generation,
bootstrap.envelope.version,
bootstrap.envelope.suite,
bootstrap.envelope.encapped_key,
bootstrap.envelope.ciphertext,
bootstrap.idempotencyKey,
];
const encoder = new TextEncoder();
return encoder.encode(values.map((value) => {
const text = value.toString();
return `${encoder.encode(text).byteLength}:${text}`;
}).join(""));
}
@@ -0,0 +1,282 @@
import type {
ElyD1DatabaseSession,
ElyD1PreparedStatement,
ElyD1Result,
Env,
} from "./bindings.js";
import { primaryD1Session } from "./bindings.js";
import { collectSyncR2Garbage } from "./sync_r2_gc.js";
const MARK_CLEANUP_READY_QUERY = `
UPDATE sync_vault_rotations
SET cleanup_snapshot_id = ?, cleanup_started_at = ?
WHERE user_id = ? AND completed_at IS NOT NULL AND storage_cleaned_at IS NULL
AND cleanup_snapshot_id IS NULL AND new_generation <= ?
AND EXISTS (
SELECT 1 FROM sync_vault_accounts AS account
WHERE account.user_id = sync_vault_rotations.user_id
AND account.current_key_id = ? AND account.current_generation = ?
)
AND EXISTS (
SELECT 1
FROM sync_snapshots AS snapshot
INNER JOIN sync_snapshot_encryption AS encryption
ON encryption.user_id = snapshot.user_id
AND encryption.snapshot_id = snapshot.snapshot_id
INNER JOIN sync_snapshot_heads AS head
ON head.user_id = snapshot.user_id
AND head.snapshot_id = snapshot.snapshot_id
AND head.head_revision = snapshot.head_revision
AND head.payload_hash = snapshot.payload_hash
WHERE snapshot.user_id = sync_vault_rotations.user_id
AND snapshot.snapshot_id = ?
AND encryption.key_id = ? AND encryption.vault_generation = ?
)
`;
const READY_ROTATIONS_QUERY = `
SELECT idempotency_key, new_key_id, new_generation
FROM sync_vault_rotations
WHERE user_id = ? AND cleanup_snapshot_id IS NOT NULL AND storage_cleaned_at IS NULL
ORDER BY new_generation ASC, idempotency_key ASC
`;
const DELETE_CHANGE_LOG_QUERY = `
DELETE FROM sync_change_log
WHERE user_id = ? AND object_id IN (
SELECT object.object_id
FROM sync_objects AS object
INNER JOIN sync_vault_rotation_r2_objects AS staged
ON staged.user_id = object.user_id AND staged.r2_key = object.payload_r2_key
WHERE object.user_id = ? AND staged.rotation_idempotency_key = ?
)
`;
const FENCE_ROTATION_R2_QUERY = `
UPDATE sync_r2_gc_candidates
SET
state = 'ready',
lease_expires_at = ?,
updated_at = MAX(updated_at, ?),
ready_at = COALESCE(ready_at, ?)
WHERE state = 'referenced' AND r2_key IN (
SELECT r2_key FROM sync_vault_rotation_r2_objects
WHERE user_id = ? AND rotation_idempotency_key = ?
)
`;
const DELETE_TOMBSTONES_QUERY = `
DELETE FROM sync_tombstones
WHERE user_id = ? AND object_id IN (
SELECT object.object_id
FROM sync_objects AS object
INNER JOIN sync_vault_rotation_r2_objects AS staged
ON staged.user_id = object.user_id AND staged.r2_key = object.payload_r2_key
WHERE object.user_id = ? AND staged.rotation_idempotency_key = ?
)
`;
const DELETE_OBJECTS_QUERY = `
DELETE FROM sync_objects
WHERE user_id = ? AND payload_r2_key IN (
SELECT r2_key FROM sync_vault_rotation_r2_objects
WHERE user_id = ? AND rotation_idempotency_key = ?
)
`;
const DELETE_SNAPSHOT_ENCRYPTION_QUERY = `
DELETE FROM sync_snapshot_encryption
WHERE user_id = ?
AND NOT (key_id = ? AND vault_generation = ?)
AND NOT EXISTS (
SELECT 1 FROM sync_snapshot_heads AS head
WHERE head.user_id = sync_snapshot_encryption.user_id
AND head.snapshot_id = sync_snapshot_encryption.snapshot_id
)
AND snapshot_id IN (
SELECT snapshot.snapshot_id
FROM sync_snapshots AS snapshot
INNER JOIN sync_vault_rotation_r2_objects AS staged
ON staged.user_id = snapshot.user_id AND staged.r2_key = snapshot.r2_key
WHERE snapshot.user_id = ? AND staged.rotation_idempotency_key = ?
)
`;
const DELETE_SNAPSHOTS_QUERY = `
DELETE FROM sync_snapshots
WHERE user_id = ?
AND NOT EXISTS (
SELECT 1 FROM sync_snapshot_heads AS head
WHERE head.user_id = sync_snapshots.user_id
AND head.snapshot_id = sync_snapshots.snapshot_id
)
AND r2_key IN (
SELECT r2_key FROM sync_vault_rotation_r2_objects
WHERE user_id = ? AND rotation_idempotency_key = ?
)
AND NOT EXISTS (
SELECT 1 FROM sync_snapshot_encryption AS encryption
WHERE encryption.user_id = sync_snapshots.user_id
AND encryption.snapshot_id = sync_snapshots.snapshot_id
)
`;
const MARK_STORAGE_CLEAN_QUERY = `
UPDATE sync_vault_rotations
SET storage_cleaned_at = ?
WHERE user_id = ? AND idempotency_key = ?
AND cleanup_snapshot_id IS NOT NULL AND storage_cleaned_at IS NULL
AND NOT EXISTS (
SELECT 1
FROM sync_vault_rotation_r2_objects AS staged
WHERE staged.user_id = sync_vault_rotations.user_id
AND staged.rotation_idempotency_key = sync_vault_rotations.idempotency_key
AND NOT EXISTS (
SELECT 1 FROM sync_objects AS object
WHERE object.payload_r2_key = staged.r2_key
)
AND NOT EXISTS (
SELECT 1 FROM sync_snapshots AS snapshot
WHERE snapshot.r2_key = staged.r2_key
)
AND NOT EXISTS (
SELECT 1 FROM sync_r2_gc_candidates AS candidate
WHERE candidate.r2_key = staged.r2_key AND candidate.state = 'deleted'
)
)
`;
const FINALIZE_STORAGE_CLEAN_QUERY = `
UPDATE sync_vault_rotations
SET storage_cleaned_at = MAX(cleanup_started_at, ?)
WHERE cleanup_snapshot_id IS NOT NULL AND storage_cleaned_at IS NULL
AND NOT EXISTS (
SELECT 1
FROM sync_vault_rotation_r2_objects AS staged
WHERE staged.user_id = sync_vault_rotations.user_id
AND staged.rotation_idempotency_key = sync_vault_rotations.idempotency_key
AND NOT EXISTS (
SELECT 1 FROM sync_objects AS object
WHERE object.payload_r2_key = staged.r2_key
)
AND NOT EXISTS (
SELECT 1 FROM sync_snapshots AS snapshot
WHERE snapshot.r2_key = staged.r2_key
)
AND NOT EXISTS (
SELECT 1 FROM sync_r2_gc_candidates AS candidate
WHERE candidate.r2_key = staged.r2_key AND candidate.state = 'deleted'
)
)
`;
interface RotationRow {
idempotency_key: unknown;
new_key_id: unknown;
new_generation: unknown;
}
export class SyncVaultRotationCleanupError extends Error {}
export async function cleanupRotatedVaultStorage(
env: Env,
userId: string,
snapshotId: string,
keyId: string,
generation: number,
nowSeconds: number,
): Promise<void> {
const database = primaryD1Session(env.ELY_DB);
await database.prepare(MARK_CLEANUP_READY_QUERY).bind(
snapshotId,
nowSeconds,
userId,
generation,
keyId,
generation,
snapshotId,
keyId,
generation,
).run();
const ready = await database.prepare(READY_ROTATIONS_QUERY)
.bind(userId)
.all<RotationRow>();
for (const row of ready.results) {
const idempotencyKey = storedIdempotencyKey(row.idempotency_key);
const rotationKeyId = storedKeyId(row.new_key_id);
const rotationGeneration = storedGeneration(row.new_generation);
await database.batch(cleanupMetadataStatements(
database,
userId,
idempotencyKey,
rotationKeyId,
rotationGeneration,
nowSeconds,
));
await collectSyncR2Garbage(env, nowSeconds, { userId, limit: 100, database });
const result = await database.prepare(MARK_STORAGE_CLEAN_QUERY)
.bind(nowSeconds, userId, idempotencyKey)
.run() as ElyD1Result;
const changes = result.meta?.changes;
if (typeof changes !== "number" || !Number.isSafeInteger(changes) || changes > 1 || changes < 0) {
throw new SyncVaultRotationCleanupError("sync_vault_rotation_cleanup_write_invalid");
}
}
}
export async function finalizeCleanedVaultRotations(
env: Env,
nowSeconds: number,
): Promise<number> {
const result = await primaryD1Session(env.ELY_DB)
.prepare(FINALIZE_STORAGE_CLEAN_QUERY)
.bind(nowSeconds)
.run() as ElyD1Result;
const changes = result.meta?.changes;
if (typeof changes !== "number" || !Number.isSafeInteger(changes) || changes < 0) {
throw new SyncVaultRotationCleanupError("sync_vault_rotation_cleanup_write_invalid");
}
return changes;
}
function cleanupMetadataStatements(
database: ElyD1DatabaseSession,
userId: string,
idempotencyKey: string,
keyId: string,
generation: number,
nowSeconds: number,
): ElyD1PreparedStatement[] {
return [
database.prepare(FENCE_ROTATION_R2_QUERY).bind(
nowSeconds,
nowSeconds,
nowSeconds,
userId,
idempotencyKey,
),
database.prepare(DELETE_CHANGE_LOG_QUERY).bind(userId, userId, idempotencyKey),
database.prepare(DELETE_TOMBSTONES_QUERY).bind(userId, userId, idempotencyKey),
database.prepare(DELETE_OBJECTS_QUERY).bind(userId, userId, idempotencyKey),
database.prepare(DELETE_SNAPSHOT_ENCRYPTION_QUERY).bind(
userId,
keyId,
generation,
userId,
idempotencyKey,
),
database.prepare(DELETE_SNAPSHOTS_QUERY).bind(userId, userId, idempotencyKey),
];
}
function storedIdempotencyKey(value: unknown): string {
if (typeof value !== "string" || !/^[a-zA-Z0-9._:-]{16,128}$/.test(value)) {
throw new SyncVaultRotationCleanupError("sync_vault_rotation_idempotency_key_invalid");
}
return value;
}
function storedKeyId(value: unknown): string {
if (typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value)) {
throw new SyncVaultRotationCleanupError("sync_vault_rotation_key_id_invalid");
}
return value;
}
function storedGeneration(value: unknown): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 2) {
throw new SyncVaultRotationCleanupError("sync_vault_rotation_generation_invalid");
}
return value;
}
+92
View File
@@ -0,0 +1,92 @@
export const CURRENT_SYNC_VAULT_KEY_QUERY = `
SELECT current_key_id AS key_id, current_generation AS generation
FROM sync_vault_accounts
WHERE user_id = ?
`;
export const CURRENT_DEVICE_ENVELOPE_QUERY = `
SELECT
accounts.current_key_id AS key_id,
accounts.current_generation AS generation,
envelopes.recipient_device_id AS recipient_device_id,
envelopes.approver_device_id AS approver_device_id,
envelopes.envelope_version AS envelope_version,
envelopes.suite AS suite,
envelopes.encapped_key AS encapped_key,
envelopes.ciphertext AS ciphertext,
envelopes.idempotency_key AS idempotency_key,
envelopes.created_at AS created_at
FROM sync_vault_accounts AS accounts
INNER JOIN sync_vault_envelopes AS envelopes
ON envelopes.user_id = accounts.user_id
AND envelopes.key_id = accounts.current_key_id
AND envelopes.generation = accounts.current_generation
WHERE accounts.user_id = ? AND envelopes.recipient_device_id = ?
`;
export const HISTORICAL_DEVICE_ENVELOPE_QUERY = `
SELECT
key_id,
generation,
recipient_device_id,
approver_device_id,
envelope_version,
suite,
encapped_key,
ciphertext,
idempotency_key,
created_at
FROM sync_vault_envelopes
WHERE user_id = ? AND recipient_device_id = ? AND key_id = ? AND generation = ?
`;
export const SYNC_VAULT_ACCOUNT_INSERT_QUERY = `
INSERT INTO sync_vault_accounts (user_id, current_key_id, current_generation, created_at, updated_at)
SELECT ?, ?, ?, ?, ?
WHERE EXISTS (
SELECT 1
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
)
ON CONFLICT(user_id) DO NOTHING
`;
export const SYNC_VAULT_ENVELOPE_INSERT_QUERY = `
INSERT INTO sync_vault_envelopes (
user_id, recipient_device_id, approver_device_id, key_id, generation,
envelope_version, suite, encapped_key, ciphertext, idempotency_key, created_at
)
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
FROM sync_vault_accounts AS accounts
INNER JOIN user_devices AS recipient
ON recipient.user_id = accounts.user_id
AND recipient.device_id = ?
AND recipient.approval_status = ?
AND recipient.revoked_at IS NULL
INNER JOIN user_device_keys AS recipient_keys
ON recipient_keys.user_id = recipient.user_id
AND recipient_keys.device_id = recipient.device_id
AND recipient_keys.key_protocol_version = 2
AND recipient_keys.wrapping_public_key IS NOT NULL
INNER JOIN user_devices AS approver
ON approver.user_id = accounts.user_id
AND approver.device_id = ?
AND approver.approval_status = 'approved'
AND approver.revoked_at IS NULL
INNER JOIN user_device_keys AS approver_keys
ON approver_keys.user_id = approver.user_id
AND approver_keys.device_id = approver.device_id
AND approver_keys.key_protocol_version = 2
AND approver_keys.wrapping_public_key IS NOT NULL
WHERE accounts.user_id = ?
AND accounts.current_key_id = ?
AND accounts.current_generation = ?
ON CONFLICT DO NOTHING
`;