fix(sync): secure encrypted snapshot lifecycle
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import type { AuthContext } from "../src/auth.js";
|
||||
import { syncSnapshotUploadDocument } from "../src/sync_snapshot.js";
|
||||
import { type RecordedR2Put, testEnv } from "./devices_test_support.js";
|
||||
import { SqliteD1Database, execute } from "./sqlite_d1_test_support.js";
|
||||
|
||||
const USER_ID = "user-01";
|
||||
const DEVICE_ID = "device-01";
|
||||
const KEY_ID = "1".repeat(64);
|
||||
const CONTENT_HASH = "2".repeat(64);
|
||||
const MIGRATIONS_DIR = join(process.cwd(), "migrations");
|
||||
|
||||
describe("sync snapshot handler real D1 flow", () => {
|
||||
it("commits genesis, same-id child, and exact duplicate through the five-statement batch", async () => {
|
||||
await withDatabase(async (databasePath) => {
|
||||
const d1 = new SqliteD1Database(databasePath);
|
||||
const r2Puts: RecordedR2Put[] = [];
|
||||
const env = testEnv({ d1, r2Puts });
|
||||
const genesisPayload = bytes("genesis ciphertext");
|
||||
const genesisHash = sha256(genesisPayload);
|
||||
const genesis = await syncSnapshotUploadDocument(
|
||||
request(body(genesisPayload, genesisHash, 1, null, 10)),
|
||||
env,
|
||||
authContext(),
|
||||
10,
|
||||
);
|
||||
const base = {
|
||||
revision: genesis.snapshot.head_revision,
|
||||
snapshot_id: genesis.snapshot.snapshot_id,
|
||||
payload_hash: genesis.snapshot.payload_hash,
|
||||
};
|
||||
const childPayload = bytes("child ciphertext");
|
||||
const childHash = sha256(childPayload);
|
||||
const childRequest = request(body(childPayload, childHash, 2, base, 11));
|
||||
const child = await syncSnapshotUploadDocument(childRequest, env, authContext(), 11);
|
||||
const duplicate = await syncSnapshotUploadDocument(
|
||||
request(body(childPayload, childHash, 2, base, 11)),
|
||||
env,
|
||||
authContext(),
|
||||
12,
|
||||
);
|
||||
|
||||
assert.equal(genesis.snapshot.head_revision, 1);
|
||||
assert.equal(child.snapshot.head_revision, 2);
|
||||
assert.deepEqual(child.snapshot.base_head, base);
|
||||
assert.deepEqual(duplicate, child);
|
||||
assert.deepEqual(d1.batches, [5, 5]);
|
||||
assert.deepEqual(d1.sessionConstraints, [
|
||||
"first-primary",
|
||||
"first-primary",
|
||||
"first-primary",
|
||||
"first-primary",
|
||||
"first-primary",
|
||||
"first-primary",
|
||||
]);
|
||||
assert.equal(r2Puts.length, 2);
|
||||
assert.deepEqual(d1.rows(`
|
||||
SELECT state, COUNT(*) AS count
|
||||
FROM sync_r2_gc_candidates
|
||||
WHERE user_id = '${USER_ID}' AND object_kind = 'snapshot'
|
||||
GROUP BY state
|
||||
ORDER BY state ASC
|
||||
`), [
|
||||
{ state: "ready", count: 1 },
|
||||
{ state: "referenced", count: 1 },
|
||||
]);
|
||||
assert.deepEqual(d1.rows(`
|
||||
SELECT head.head_revision, head.snapshot_id, head.payload_hash,
|
||||
snapshot.logical_clock, encryption.content_hash
|
||||
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
|
||||
INNER JOIN sync_snapshot_encryption AS encryption
|
||||
ON encryption.user_id = snapshot.user_id
|
||||
AND encryption.snapshot_id = snapshot.snapshot_id
|
||||
WHERE head.user_id = '${USER_ID}'
|
||||
`), [{
|
||||
head_revision: 2,
|
||||
snapshot_id: DEVICE_ID,
|
||||
payload_hash: childHash,
|
||||
logical_clock: 11,
|
||||
content_hash: CONTENT_HASH,
|
||||
}]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function withDatabase(assertions: (databasePath: string) => Promise<void>): Promise<void> {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "ely-snapshot-handler-"));
|
||||
try {
|
||||
const databasePath = join(tempDir, "ely.db");
|
||||
for (const fileName of readdirSync(MIGRATIONS_DIR).filter((name) => name.endsWith(".sql")).sort()) {
|
||||
execute(databasePath, readFileSync(join(MIGRATIONS_DIR, fileName), "utf8"));
|
||||
}
|
||||
execute(databasePath, `
|
||||
INSERT INTO better_auth_user (
|
||||
id, name, email, emailVerified, createdAt, updatedAt
|
||||
) VALUES (
|
||||
'${USER_ID}', 'User', 'user@example.com', 1,
|
||||
'2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'
|
||||
);
|
||||
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 (
|
||||
'${USER_ID}', '${DEVICE_ID}', '${"d".repeat(64)}', 'Mac', 'macOS',
|
||||
'approved', 1, 1, 1, NULL, 'device-register-0001'
|
||||
);
|
||||
INSERT INTO user_device_keys (
|
||||
user_id, device_id, signing_public_key, wrapping_public_key,
|
||||
key_protocol_version, created_at
|
||||
) VALUES (
|
||||
'${USER_ID}', '${DEVICE_ID}', '${"d".repeat(64)}', '${"e".repeat(64)}', 2, 1
|
||||
);
|
||||
INSERT INTO sync_vault_accounts (
|
||||
user_id, current_key_id, current_generation, created_at, updated_at
|
||||
) VALUES ('${USER_ID}', '${KEY_ID}', 1, 1, 1);
|
||||
`);
|
||||
await assertions(databasePath);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function body(
|
||||
payload: ArrayBuffer,
|
||||
payloadHash: string,
|
||||
headRevision: number,
|
||||
baseHead: Record<string, unknown> | null,
|
||||
logicalClock: number,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
version: 3,
|
||||
snapshot_id: DEVICE_ID,
|
||||
region: "us-east",
|
||||
payload_hash: payloadHash,
|
||||
encryption_version: 2,
|
||||
vault_generation: 1,
|
||||
key_id: KEY_ID,
|
||||
content_hash: CONTENT_HASH,
|
||||
schema_rev: 1,
|
||||
logical_clock: logicalClock,
|
||||
head_revision: headRevision,
|
||||
base_head: baseHead,
|
||||
data_base64: Buffer.from(payload).toString("base64"),
|
||||
};
|
||||
}
|
||||
|
||||
function request(value: Record<string, unknown>): Request {
|
||||
return new Request("https://elydora.test/api/sync/snapshot", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(value),
|
||||
});
|
||||
}
|
||||
|
||||
function authContext(): AuthContext {
|
||||
return {
|
||||
userId: USER_ID,
|
||||
sessionId: "session-01",
|
||||
tokenHash: "f".repeat(64),
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
deviceId: DEVICE_ID,
|
||||
};
|
||||
}
|
||||
|
||||
function bytes(value: string): ArrayBuffer {
|
||||
return new TextEncoder().encode(value).buffer;
|
||||
}
|
||||
|
||||
function sha256(payload: ArrayBuffer): string {
|
||||
return createHash("sha256").update(new Uint8Array(payload)).digest("hex");
|
||||
}
|
||||
Reference in New Issue
Block a user