fix(sync): secure encrypted snapshot lifecycle

This commit is contained in:
2026-07-10 06:24:53 -04:00
parent 556c5ff624
commit 540b901fd6
106 changed files with 18026 additions and 3309 deletions
+97 -83
View File
@@ -1,6 +1,13 @@
import type { AuthContext } from "./auth.js";
import type { Env } from "./bindings.js";
import type { ElyD1Result, Env } from "./bindings.js";
import { primaryD1Session } from "./bindings.js";
import { StorageObjectError, assertSyncObjectType } from "./storage.js";
import type { SnapshotHeadRefDocument, SyncSnapshotRow } from "./sync_snapshot_head.js";
import {
SyncSnapshotHeadSchemaError,
snapshotDocumentFromRow,
} from "./sync_snapshot_head.js";
import { SYNC_SNAPSHOT_HEAD_QUERY } from "./sync_snapshot_sql.js";
const CHANGE_CURSOR_QUERY = `
SELECT
@@ -23,21 +30,11 @@ const OBJECT_STATUS_QUERY = `
`;
const SNAPSHOT_COUNT_QUERY = `
SELECT COUNT(*) AS total_snapshots
FROM sync_snapshots
WHERE user_id = ?
`;
const LATEST_SNAPSHOT_QUERY = `
SELECT
snapshot_id,
payload_hash,
logical_clock,
device_id,
size_bytes,
created_at
FROM sync_snapshots
WHERE user_id = ?
ORDER BY created_at DESC, snapshot_id ASC
LIMIT 1
FROM sync_snapshots AS snapshots
INNER JOIN sync_snapshot_encryption AS encryption
ON encryption.user_id = snapshots.user_id
AND encryption.snapshot_id = snapshots.snapshot_id
WHERE snapshots.user_id = ? AND encryption.encryption_version IN (1, 2)
`;
const APPROVED_DEVICE_COUNT_QUERY = `
SELECT COUNT(*) AS approved_devices
@@ -45,12 +42,8 @@ const APPROVED_DEVICE_COUNT_QUERY = `
WHERE user_id = ? AND approval_status = 'approved' AND revoked_at IS NULL
`;
const SNAPSHOT_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/;
const DEVICE_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,128}$/;
const SHA256_HEX = /^[a-f0-9]{64}$/;
export interface SyncStatusDocument {
version: 1;
version: 2;
user_id: string;
device_id: string;
cursor: SyncCursorStatusDocument;
@@ -74,13 +67,19 @@ export interface SyncObjectStatusDocument {
export interface SyncSnapshotStatusDocument {
total_snapshots: number;
latest: SyncLatestSnapshotDocument | null;
head: SyncSnapshotHeadStatusDocument | null;
}
export interface SyncLatestSnapshotDocument {
export interface SyncSnapshotHeadStatusDocument {
snapshot_id: string;
payload_hash: string;
encryption_version: 1 | 2;
vault_generation: number;
key_id: string;
content_hash: string;
logical_clock: number;
head_revision: number;
base_head: SnapshotHeadRefDocument | null;
device_id: string;
size_bytes: number;
created_at: number;
@@ -109,15 +108,6 @@ interface SnapshotCountRow {
total_snapshots: unknown;
}
interface LatestSnapshotRow {
snapshot_id: unknown;
payload_hash: unknown;
logical_clock: unknown;
device_id: unknown;
size_bytes: unknown;
created_at: unknown;
}
interface DeviceStatusRow {
approved_devices: unknown;
}
@@ -134,33 +124,60 @@ export async function syncStatusDocument(
context: AuthContext,
): Promise<SyncStatusDocument> {
const deviceId = currentDeviceId(context);
const cursorRow = await env.ELY_DB.prepare(CHANGE_CURSOR_QUERY)
.bind(context.userId)
.first<ChangeCursorRow>();
const objectRows = await env.ELY_DB.prepare(OBJECT_STATUS_QUERY)
.bind(context.userId)
.all<ObjectStatusRow>();
const snapshotCountRow = await env.ELY_DB.prepare(SNAPSHOT_COUNT_QUERY)
.bind(context.userId)
.first<SnapshotCountRow>();
const latestSnapshotRow = await env.ELY_DB.prepare(LATEST_SNAPSHOT_QUERY)
.bind(context.userId)
.first<LatestSnapshotRow>();
const deviceStatusRow = await env.ELY_DB.prepare(APPROVED_DEVICE_COUNT_QUERY)
.bind(context.userId)
.first<DeviceStatusRow>();
const database = primaryD1Session(env.ELY_DB);
const results = await database.batch<ElyD1Result>([
database.prepare(CHANGE_CURSOR_QUERY).bind(context.userId),
database.prepare(OBJECT_STATUS_QUERY).bind(context.userId),
database.prepare(SNAPSHOT_COUNT_QUERY).bind(context.userId),
database.prepare(SYNC_SNAPSHOT_HEAD_QUERY).bind(context.userId),
database.prepare(APPROVED_DEVICE_COUNT_QUERY).bind(context.userId),
]);
if (results.length !== 5) {
throw new SyncStatusSchemaError("sync_status_batch_invalid");
}
const cursorRow = oneRow<ChangeCursorRow>(results[0], "sync_cursor_status_missing");
const objectRows = rows<ObjectStatusRow>(results[1]);
const snapshotCountRow = oneRow<SnapshotCountRow>(
results[2],
"sync_snapshot_status_missing",
);
const snapshotHeadRow = optionalRow<SyncSnapshotRow>(results[3]);
const deviceStatusRow = oneRow<DeviceStatusRow>(results[4], "sync_device_status_missing");
return {
version: 1,
version: 2,
user_id: context.userId,
device_id: deviceId,
cursor: cursorStatus(cursorRow),
objects: objectRows.results.map(objectStatus),
snapshots: snapshotStatus(snapshotCountRow, latestSnapshotRow),
objects: objectRows.map(objectStatus),
snapshots: snapshotStatus(snapshotCountRow, snapshotHeadRow),
devices: deviceStatus(deviceStatusRow, deviceId),
};
}
function rows<T>(result: ElyD1Result | undefined): T[] {
if (result === undefined || !Array.isArray(result.results)) {
throw new SyncStatusSchemaError("sync_status_batch_invalid");
}
return result.results as T[];
}
function oneRow<T>(result: ElyD1Result | undefined, message: string): T {
const values = rows<T>(result);
if (values.length !== 1) {
throw new SyncStatusSchemaError(message);
}
return values[0] as T;
}
function optionalRow<T>(result: ElyD1Result | undefined): T | null {
const values = rows<T>(result);
if (values.length > 1) {
throw new SyncStatusSchemaError("sync_snapshot_head_rows_invalid");
}
return values[0] ?? null;
}
function cursorStatus(row: ChangeCursorRow | null): SyncCursorStatusDocument {
if (row === null) {
throw new SyncStatusSchemaError("sync_cursor_status_missing");
@@ -183,26 +200,44 @@ function objectStatus(row: ObjectStatusRow): SyncObjectStatusDocument {
function snapshotStatus(
countRow: SnapshotCountRow | null,
latestRow: LatestSnapshotRow | null,
headRow: SyncSnapshotRow | null,
): SyncSnapshotStatusDocument {
if (countRow === null) {
throw new SyncStatusSchemaError("sync_snapshot_status_missing");
}
const totalSnapshots = integer(countRow.total_snapshots, "total_snapshots");
if ((totalSnapshots === 0) !== (headRow === null)) {
throw new SyncStatusSchemaError("sync_snapshot_head_missing");
}
return {
total_snapshots: integer(countRow.total_snapshots, "total_snapshots"),
latest: latestRow === null ? null : latestSnapshot(latestRow),
total_snapshots: totalSnapshots,
head: headRow === null ? null : snapshotHeadStatus(headRow),
};
}
function latestSnapshot(row: LatestSnapshotRow): SyncLatestSnapshotDocument {
return {
snapshot_id: snapshotId(row.snapshot_id),
payload_hash: payloadHash(row.payload_hash),
logical_clock: integer(row.logical_clock, "logical_clock"),
device_id: deviceId(row.device_id),
size_bytes: integer(row.size_bytes, "size_bytes"),
created_at: integer(row.created_at, "created_at"),
};
function snapshotHeadStatus(row: SyncSnapshotRow): SyncSnapshotHeadStatusDocument {
try {
const snapshot = snapshotDocumentFromRow(row);
return {
snapshot_id: snapshot.snapshot_id,
payload_hash: snapshot.payload_hash,
encryption_version: snapshot.encryption_version,
vault_generation: snapshot.vault_generation,
key_id: snapshot.key_id,
content_hash: snapshot.content_hash,
logical_clock: snapshot.logical_clock,
head_revision: snapshot.head_revision,
base_head: snapshot.base_head,
device_id: snapshot.device_id,
size_bytes: snapshot.size_bytes,
created_at: snapshot.created_at,
};
} catch (error) {
if (error instanceof SyncSnapshotHeadSchemaError) {
throw new SyncStatusSchemaError(error.message);
}
throw error;
}
}
function deviceStatus(
@@ -241,27 +276,6 @@ function objectType(value: unknown): string {
return value;
}
function snapshotId(value: unknown): string {
if (typeof value !== "string" || !SNAPSHOT_ID_PATTERN.test(value)) {
throw new SyncStatusSchemaError("snapshot_id_invalid");
}
return value;
}
function deviceId(value: unknown): string {
if (typeof value !== "string" || !DEVICE_ID_PATTERN.test(value)) {
throw new SyncStatusSchemaError("device_id_invalid");
}
return value;
}
function payloadHash(value: unknown): string {
if (typeof value !== "string" || !SHA256_HEX.test(value)) {
throw new SyncStatusSchemaError("payload_hash_invalid");
}
return value;
}
function integer(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
throw new SyncStatusSchemaError(`${label}_invalid`);