fix(sync): secure encrypted snapshot lifecycle
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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(""));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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(""));
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(""));
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
`;
|
||||
Reference in New Issue
Block a user