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