fix(sync): secure encrypted snapshot lifecycle
This commit is contained in:
@@ -4,7 +4,18 @@ import { describe, it } from "node:test";
|
||||
|
||||
import { authSessionCacheKvKey, authTokenHash } from "../src/auth.js";
|
||||
import { handleRequest } from "../src/index.js";
|
||||
import { ACCESS_TOKEN, sessionDocument, testD1Database, testEnv } from "./devices_test_support.js";
|
||||
import {
|
||||
recentDeviceActionProofBytes,
|
||||
recentDeviceActionRequestHash,
|
||||
} from "../src/recent_device_action_proof.js";
|
||||
import {
|
||||
ACCESS_TOKEN,
|
||||
PUBLIC_KEY,
|
||||
sessionDocument,
|
||||
signDeviceMessage,
|
||||
testD1Database,
|
||||
testEnv,
|
||||
} from "./devices_test_support.js";
|
||||
|
||||
const USER_ID = "user-01";
|
||||
const DEVICE_ID = "device-01";
|
||||
@@ -21,13 +32,22 @@ describe("account deletion routes", () => {
|
||||
const r2Deletes: string[] = [];
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const sessionCacheKey = authSessionCacheKvKey("local", tokenHash);
|
||||
const requestBody = await accountDeleteBody();
|
||||
const d1 = testD1Database({
|
||||
firstRows: [{ device_id: DEVICE_ID }, null, deletionCountsRow()],
|
||||
allRows: [{ r2_key: PAYLOAD_KEY }, { r2_key: SNAPSHOT_KEY }],
|
||||
firstRows: [
|
||||
null,
|
||||
{ signing_public_key: PUBLIC_KEY },
|
||||
deletionCountsRow(),
|
||||
],
|
||||
allRowSets: [
|
||||
[{ r2_key: PAYLOAD_KEY }, { r2_key: SNAPSHOT_KEY }],
|
||||
[{ token: ACCESS_TOKEN }],
|
||||
[{ r2_key: PAYLOAD_KEY }, { r2_key: SNAPSHOT_KEY }],
|
||||
],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
accountDeleteRequest(accountDeleteBody()),
|
||||
accountDeleteRequest(requestBody),
|
||||
testEnv({
|
||||
d1,
|
||||
kvDeletes,
|
||||
@@ -68,36 +88,49 @@ describe("account deletion routes", () => {
|
||||
});
|
||||
assert.deepEqual(r2Deletes, [PAYLOAD_KEY, SNAPSHOT_KEY]);
|
||||
assert.deepEqual(kvDeletes, [sessionCacheKey]);
|
||||
assert.equal(d1.batches[0], 12);
|
||||
assert.ok(d1.queries[1]?.includes("FROM audit_events"));
|
||||
assert.equal(d1.batches[0], 18);
|
||||
assert.ok(d1.queries[0]?.includes("FROM audit_events"));
|
||||
assert.ok(d1.queries[1]?.includes("signing_public_key"));
|
||||
assert.ok(d1.queries[2]?.includes("FROM user_devices"));
|
||||
assert.ok(d1.queries[3]?.includes("UNION"));
|
||||
assert.ok(d1.queries[4]?.includes("DELETE FROM sync_change_log"));
|
||||
assert.ok(d1.queries[9]?.includes("DELETE FROM better_auth_session_device_context"));
|
||||
assert.ok(d1.queries[10]?.includes("DELETE FROM user_devices"));
|
||||
assert.ok(d1.queries[15]?.includes("INSERT INTO audit_events"));
|
||||
assert.deepEqual(d1.binds[1], [accountDeletionEventId()]);
|
||||
assert.deepEqual(d1.binds[3], [USER_ID, USER_ID]);
|
||||
assert.deepEqual(d1.binds[15], [
|
||||
assert.ok(d1.queries[3]?.includes("FROM sync_r2_gc_candidates"));
|
||||
assert.ok(d1.queries[4]?.includes("FROM better_auth_session"));
|
||||
assert.ok(d1.queries[5]?.includes("CASE WHEN EXISTS"));
|
||||
assert.ok(d1.queries[6]?.includes("UPDATE sync_r2_gc_candidates"));
|
||||
assert.ok(d1.queries[7]?.includes("DELETE FROM sync_change_log"));
|
||||
assert.ok(d1.queries[9]?.includes("DELETE FROM sync_snapshot_heads"));
|
||||
assert.ok(d1.queries[10]?.includes("DELETE FROM sync_snapshot_encryption"));
|
||||
assert.ok(d1.queries[11]?.includes("DELETE FROM sync_snapshots"));
|
||||
assert.ok(d1.queries[14]?.includes("DELETE FROM sync_vault_accounts"));
|
||||
assert.ok(d1.queries[16]?.includes("DELETE FROM better_auth_session_device_context"));
|
||||
assert.ok(d1.queries[17]?.includes("DELETE FROM user_devices"));
|
||||
assert.deepEqual(d1.binds[0], [accountDeletionEventId()]);
|
||||
assert.deepEqual(d1.binds[3], [USER_ID]);
|
||||
assert.ok(d1.queries[22]?.includes("SET user_id = NULL"));
|
||||
assert.deepEqual(d1.binds[5]?.slice(0, 6), [
|
||||
accountDeletionEventId(),
|
||||
null,
|
||||
DEVICE_ID,
|
||||
"account.delete",
|
||||
"account",
|
||||
USER_HASH,
|
||||
IDEMPOTENCY_HASH,
|
||||
body.deleted_at,
|
||||
]);
|
||||
assert.equal(d1.binds[5]?.[11], PUBLIC_KEY);
|
||||
assert.equal(d1.binds[5]?.[12], await requestHash(requestBody));
|
||||
assert.equal(d1.binds[5]?.[13], body.deleted_at);
|
||||
});
|
||||
|
||||
it("returns an idempotent deletion document for existing audit events", async () => {
|
||||
const kvDeletes: string[] = [];
|
||||
const r2Deletes: string[] = [];
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const requestBody = await accountDeleteBody();
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
{ device_id: DEVICE_ID },
|
||||
{
|
||||
actor_device_id: DEVICE_ID,
|
||||
outcome: "success",
|
||||
subject_id: USER_HASH,
|
||||
metadata_hash: await requestHash(requestBody),
|
||||
created_at: 1_780_001_000,
|
||||
},
|
||||
],
|
||||
@@ -105,7 +138,7 @@ describe("account deletion routes", () => {
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
accountDeleteRequest(accountDeleteBody()),
|
||||
accountDeleteRequest(requestBody),
|
||||
testEnv({
|
||||
d1,
|
||||
kvDeletes,
|
||||
@@ -139,28 +172,63 @@ describe("account deletion routes", () => {
|
||||
});
|
||||
assert.deepEqual(r2Deletes, []);
|
||||
assert.deepEqual(kvDeletes, []);
|
||||
assert.equal(d1.queries.length, 2);
|
||||
assert.equal(d1.queries.length, 1);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("deletes every legacy KV session key for the account", async () => {
|
||||
const secondToken = "second-session-token-0000000000000000";
|
||||
const currentHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const secondHash = await authTokenHash(secondToken);
|
||||
const currentKey = authSessionCacheKvKey("local", currentHash);
|
||||
const secondKey = authSessionCacheKvKey("local", secondHash);
|
||||
const kvDeletes: string[] = [];
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
null,
|
||||
{ signing_public_key: PUBLIC_KEY },
|
||||
deletionCountsRow(),
|
||||
],
|
||||
allRowSets: [[], [{ token: ACCESS_TOKEN }, { token: secondToken }], []],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
accountDeleteRequest(await accountDeleteBody()),
|
||||
testEnv({
|
||||
d1,
|
||||
kvDeletes,
|
||||
kvEntries: [
|
||||
[currentKey, sessionDocument(DEVICE_ID)],
|
||||
[secondKey, sessionDocument("device-02")],
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const body = await response.json() as { deleted: { kv_session_cache: number } };
|
||||
assert.equal(body.deleted.kv_session_cache, 2);
|
||||
assert.deepEqual(kvDeletes.sort(), [currentKey, secondKey].sort());
|
||||
});
|
||||
|
||||
it("rejects replay mismatches before deleting account data", async () => {
|
||||
const kvDeletes: string[] = [];
|
||||
const r2Deletes: string[] = [];
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const requestBody = await accountDeleteBody();
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
{ device_id: DEVICE_ID },
|
||||
{
|
||||
actor_device_id: "device-02",
|
||||
outcome: "success",
|
||||
subject_id: USER_HASH,
|
||||
metadata_hash: await requestHash(requestBody),
|
||||
created_at: 1_780_001_000,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
accountDeleteRequest(accountDeleteBody()),
|
||||
accountDeleteRequest(requestBody),
|
||||
testEnv({
|
||||
d1,
|
||||
kvDeletes,
|
||||
@@ -180,10 +248,10 @@ describe("account deletion routes", () => {
|
||||
const kvDeletes: string[] = [];
|
||||
const r2Deletes: string[] = [];
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }] });
|
||||
const d1 = testD1Database([]);
|
||||
|
||||
const response = await handleRequest(
|
||||
accountDeleteRequest(accountDeleteBody({ confirmation: "delete" })),
|
||||
accountDeleteRequest(await accountDeleteBody({ confirmation: "delete" })),
|
||||
testEnv({
|
||||
d1,
|
||||
kvDeletes,
|
||||
@@ -196,16 +264,16 @@ describe("account deletion routes", () => {
|
||||
assert.deepEqual(await response.json(), { error: "invalid_account_deletion" });
|
||||
assert.deepEqual(r2Deletes, []);
|
||||
assert.deepEqual(kvDeletes, []);
|
||||
assert.equal(d1.queries.length, 1);
|
||||
assert.equal(d1.queries.length, 0);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("rejects revoked devices before reading account deletion bodies", async () => {
|
||||
it("requires an approved device key for a new account deletion", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({ firstRows: [null] });
|
||||
const d1 = testD1Database({ firstRows: [null, null] });
|
||||
|
||||
const response = await handleRequest(
|
||||
accountDeleteRequest(accountDeleteBody()),
|
||||
accountDeleteRequest(await accountDeleteBody()),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
@@ -213,22 +281,30 @@ describe("account deletion routes", () => {
|
||||
);
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(await response.json(), { error: "device_not_approved" });
|
||||
assert.equal(d1.queries.length, 1);
|
||||
assert.deepEqual(await response.json(), { error: "account_deletion_forbidden" });
|
||||
assert.equal(d1.queries.length, 2);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("fails closed when stored R2 keys are malformed", async () => {
|
||||
it("keeps account deletion successful when scheduled GC must handle a malformed key", async () => {
|
||||
const kvDeletes: string[] = [];
|
||||
const r2Deletes: string[] = [];
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [{ device_id: DEVICE_ID }, null, deletionCountsRow()],
|
||||
allRows: [{ r2_key: "sync-snapshots/../bad.bin" }],
|
||||
firstRows: [
|
||||
null,
|
||||
{ signing_public_key: PUBLIC_KEY },
|
||||
deletionCountsRow(),
|
||||
],
|
||||
allRowSets: [
|
||||
[{ r2_key: "sync-snapshots/../bad.bin" }],
|
||||
[],
|
||||
[{ r2_key: "sync-snapshots/../bad.bin" }],
|
||||
],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
accountDeleteRequest(accountDeleteBody()),
|
||||
accountDeleteRequest(await accountDeleteBody()),
|
||||
testEnv({
|
||||
d1,
|
||||
kvDeletes,
|
||||
@@ -237,11 +313,10 @@ describe("account deletion routes", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 500);
|
||||
assert.deepEqual(await response.json(), { error: "account_deletion_failed" });
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(r2Deletes, []);
|
||||
assert.deepEqual(kvDeletes, []);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
assert.deepEqual(kvDeletes, [authSessionCacheKvKey("local", tokenHash)]);
|
||||
assert.deepEqual(d1.batches, [18]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -256,13 +331,26 @@ function accountDeleteRequest(body: Record<string, unknown>): Request {
|
||||
});
|
||||
}
|
||||
|
||||
function accountDeleteBody(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
async function accountDeleteBody(
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const body: Record<string, unknown> = {
|
||||
version: 2,
|
||||
confirmation: "delete-elydora-account",
|
||||
idempotency_key: IDEMPOTENCY_KEY,
|
||||
proof_created_at: Math.floor(Date.now() / 1000),
|
||||
...overrides,
|
||||
};
|
||||
body.action_proof = await signDeviceMessage(recentDeviceActionProofBytes({
|
||||
action: "account.delete",
|
||||
userId: USER_ID,
|
||||
sessionId: "session-01",
|
||||
deviceId: DEVICE_ID,
|
||||
confirmation: String(body.confirmation),
|
||||
idempotencyKey: String(body.idempotency_key),
|
||||
proofCreatedAt: Number(body.proof_created_at),
|
||||
}));
|
||||
return body;
|
||||
}
|
||||
|
||||
function deletionCountsRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
@@ -286,6 +374,19 @@ function accountDeletionEventId(): string {
|
||||
return `account-delete:${USER_HASH}:${IDEMPOTENCY_HASH}`;
|
||||
}
|
||||
|
||||
function requestHash(body: Record<string, unknown>): Promise<string> {
|
||||
return recentDeviceActionRequestHash({
|
||||
action: "account.delete",
|
||||
userId: USER_ID,
|
||||
sessionId: "session-01",
|
||||
deviceId: DEVICE_ID,
|
||||
confirmation: String(body.confirmation),
|
||||
idempotencyKey: String(body.idempotency_key),
|
||||
proofCreatedAt: Number(body.proof_created_at),
|
||||
actionProof: String(body.action_proof),
|
||||
});
|
||||
}
|
||||
|
||||
function bytes(value: string): Uint8Array {
|
||||
return new TextEncoder().encode(value);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
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 { ElyR2Object, ElyR2PutOptions, Env } from "../src/bindings.js";
|
||||
import { accountDeletionDocument } from "../src/account_deletion.js";
|
||||
import { authSessionCacheKvKey } from "../src/auth.js";
|
||||
import { purgeLegacySessionCache } from "../src/legacy_auth_kv_cleanup.js";
|
||||
import {
|
||||
recentDeviceActionProofBytes,
|
||||
type SensitiveAction,
|
||||
} from "../src/recent_device_action_proof.js";
|
||||
import { collectSyncR2Garbage } from "../src/sync_r2_gc.js";
|
||||
import { maintainSyncR2Storage } from "../src/sync_r2_maintenance.js";
|
||||
import { syncResetDocument } from "../src/sync_reset.js";
|
||||
import { PUBLIC_KEY, signDeviceMessage } from "./devices_test_support.js";
|
||||
import { SqliteD1Database, execute, query } from "./sqlite_d1_test_support.js";
|
||||
|
||||
const USER_ID = "user-01";
|
||||
const DEVICE_ID = "device-01";
|
||||
const KEY_ID = "1".repeat(64);
|
||||
const TOKEN_HASH = "2".repeat(64);
|
||||
const OWNER_HASH = createHash("sha256").update(USER_ID).digest("hex");
|
||||
const NOW = 1_800_000_000;
|
||||
const MIGRATIONS_DIR = join(process.cwd(), "migrations");
|
||||
|
||||
describe("account deletion and reset GC drains", () => {
|
||||
it("drains 101 reset candidates through bounded batches", async () => {
|
||||
await withDatabase(async (databasePath, bucket, _kv, env) => {
|
||||
const keys = seedReadyCandidates(databasePath, bucket, 101);
|
||||
|
||||
const document = await syncResetDocument(
|
||||
await resetRequest("sync-reset-101-items", NOW),
|
||||
env,
|
||||
authContext(),
|
||||
NOW,
|
||||
);
|
||||
const replay = await syncResetDocument(
|
||||
await resetRequest("sync-reset-101-items", NOW),
|
||||
env,
|
||||
authContext(),
|
||||
NOW + 1_000,
|
||||
);
|
||||
|
||||
assert.equal(document.deleted.r2_objects, 101);
|
||||
assert.equal(replay.reset_at, NOW);
|
||||
assert.equal(replay.deleted.r2_objects, 0);
|
||||
assert.equal(deletedCandidateCount(databasePath), 101);
|
||||
assert.equal(bucket.size, 0);
|
||||
assert.deepEqual(bucket.deletes.sort(), keys.sort());
|
||||
});
|
||||
});
|
||||
|
||||
it("releases rotation staging on reset and finalizes cleanup during maintenance", async () => {
|
||||
await withDatabase(async (databasePath, bucket, _kv, env) => {
|
||||
const [key] = seedReadyCandidates(databasePath, bucket, 1);
|
||||
assert.ok(key !== undefined);
|
||||
seedCompletedRotation(databasePath, key);
|
||||
|
||||
const document = await syncResetDocument(
|
||||
await resetRequest("sync-reset-rotation", NOW),
|
||||
env,
|
||||
authContext(),
|
||||
NOW,
|
||||
);
|
||||
|
||||
assert.equal(document.deleted.r2_objects, 1);
|
||||
assert.equal(candidateState(databasePath, key), "deleted");
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT cleanup_snapshot_id, storage_cleaned_at
|
||||
FROM sync_vault_rotations
|
||||
WHERE user_id = '${USER_ID}' AND idempotency_key = 'rotation-reset-0001'
|
||||
`), [{ cleanup_snapshot_id: "sync-reset", storage_cleaned_at: null }]);
|
||||
|
||||
await maintainSyncR2Storage(env, NOW + 1);
|
||||
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT storage_cleaned_at
|
||||
FROM sync_vault_rotations
|
||||
WHERE user_id = '${USER_ID}' AND idempotency_key = 'rotation-reset-0001'
|
||||
`), [{ storage_cleaned_at: NOW + 1 }]);
|
||||
});
|
||||
});
|
||||
|
||||
it("drains 101 account candidates after anonymizing their owner", async () => {
|
||||
await withDatabase(async (databasePath, bucket, _kv, env) => {
|
||||
seedReadyCandidates(databasePath, bucket, 101);
|
||||
const context = authContext();
|
||||
const request = await accountDeleteRequest("account-delete-101-items", NOW);
|
||||
const replayRequest = request.clone();
|
||||
const document = await accountDeletionDocument(
|
||||
request,
|
||||
env,
|
||||
context,
|
||||
NOW,
|
||||
);
|
||||
const replay = await accountDeletionDocument(replayRequest, env, context, NOW + 1_000);
|
||||
|
||||
assert.equal(document.deleted.r2_objects, 101);
|
||||
assert.equal(replay.account_hash, document.account_hash);
|
||||
assert.equal(replay.deleted_at, NOW);
|
||||
assert.equal(replay.deleted.users, 0);
|
||||
assert.equal(deletedCandidateCount(databasePath), 101);
|
||||
assert.equal(bucket.size, 0);
|
||||
assert.equal(query(databasePath, `
|
||||
SELECT COUNT(*) AS count FROM sync_r2_gc_candidates WHERE user_id IS NOT NULL
|
||||
`)[0]?.count, 0);
|
||||
assert.equal(query(databasePath, `
|
||||
SELECT COUNT(*) AS count FROM better_auth_user WHERE id = '${USER_ID}'
|
||||
`)[0]?.count, 0);
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT user_id, outcome FROM audit_events WHERE event_type = 'account.delete'
|
||||
`), [{ user_id: null, outcome: "success" }]);
|
||||
});
|
||||
});
|
||||
|
||||
it("returns account deletion success while scheduled cleanup retries R2 and KV failures", async () => {
|
||||
await withDatabase(async (databasePath, bucket, kv, env) => {
|
||||
const [key] = seedReadyCandidates(databasePath, bucket, 1);
|
||||
assert.ok(key !== undefined);
|
||||
const legacyKey = authSessionCacheKvKey("local", TOKEN_HASH);
|
||||
kv.values.set(legacyKey, "legacy-session");
|
||||
bucket.failDeletes = true;
|
||||
kv.failDeletes = true;
|
||||
|
||||
const document = await accountDeletionDocument(
|
||||
await accountDeleteRequest("account-delete-cleanup-failure", NOW),
|
||||
env,
|
||||
authContext(),
|
||||
NOW,
|
||||
);
|
||||
|
||||
assert.equal(document.deleted.kv_session_cache, 0);
|
||||
assert.equal(candidateState(databasePath, key), "deleting");
|
||||
assert.equal(query(databasePath, `
|
||||
SELECT COUNT(*) AS count FROM better_auth_user WHERE id = '${USER_ID}'
|
||||
`)[0]?.count, 0);
|
||||
|
||||
bucket.failDeletes = false;
|
||||
kv.failDeletes = false;
|
||||
assert.equal(await collectSyncR2Garbage(env, NOW + 61, { ownerHash: OWNER_HASH }), 1);
|
||||
assert.equal(await purgeLegacySessionCache(env), 1);
|
||||
assert.equal(candidateState(databasePath, key), "deleted");
|
||||
assert.equal(bucket.size, 0);
|
||||
assert.equal(kv.values.size, 0);
|
||||
});
|
||||
});
|
||||
|
||||
it("rolls back account deletion when its authenticated authority changes before the batch", async () => {
|
||||
for (const beforeBatchSql of [
|
||||
"DELETE FROM better_auth_session WHERE id = 'session-01';",
|
||||
`UPDATE user_device_keys SET signing_public_key = '${"9".repeat(64)}'
|
||||
WHERE user_id = '${USER_ID}' AND device_id = '${DEVICE_ID}';`,
|
||||
]) {
|
||||
await withDatabase(async (databasePath, bucket, _kv, env) => {
|
||||
const [key] = seedReadyCandidates(databasePath, bucket, 1);
|
||||
assert.ok(key !== undefined);
|
||||
const request = await accountDeleteRequest("account-delete-authority-race", NOW);
|
||||
const racedEnv = {
|
||||
...env,
|
||||
ELY_DB: new SqliteD1Database(databasePath, beforeBatchSql),
|
||||
} as Env;
|
||||
|
||||
await assert.rejects(
|
||||
() => accountDeletionDocument(request, racedEnv, authContext(), NOW),
|
||||
/device_action_gate_failed/,
|
||||
);
|
||||
|
||||
assert.deepEqual(query(databasePath, `SELECT
|
||||
(SELECT COUNT(*) FROM better_auth_user WHERE id = '${USER_ID}') AS users,
|
||||
(SELECT COUNT(*) FROM user_devices WHERE user_id = '${USER_ID}') AS devices,
|
||||
(SELECT COUNT(*) FROM user_device_keys WHERE user_id = '${USER_ID}') AS device_keys,
|
||||
(SELECT COUNT(*) FROM sync_vault_accounts WHERE user_id = '${USER_ID}') AS vaults,
|
||||
(SELECT COUNT(*) FROM audit_events WHERE event_type = 'account.delete') AS audits
|
||||
`), [{ users: 1, devices: 1, device_keys: 1, vaults: 1, audits: 0 }]);
|
||||
assert.equal(candidateState(databasePath, key), "ready");
|
||||
assert.equal(bucket.size, 1);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("continues R2 inventory and GC when legacy KV purge fails", async () => {
|
||||
await withDatabase(async (databasePath, bucket, kv, env) => {
|
||||
const hash = "f".repeat(64);
|
||||
const key = `sync-payloads/us-east/${OWNER_HASH}/bookmarks/object-01/${hash}.bin`;
|
||||
bucket.values.set(key, new Uint8Array([1]).buffer);
|
||||
kv.failLists = true;
|
||||
|
||||
await assert.rejects(() => maintainSyncR2Storage(env, NOW), AggregateError);
|
||||
|
||||
assert.equal(bucket.size, 0);
|
||||
assert.equal(candidateState(databasePath, key), "deleted");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function withDatabase(
|
||||
run: (databasePath: string, bucket: TestBucket, kv: TestKv, env: Env) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "ely-account-reset-gc-"));
|
||||
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"));
|
||||
}
|
||||
seedAuthority(databasePath);
|
||||
const bucket = new TestBucket();
|
||||
const kv = new TestKv();
|
||||
const env = {
|
||||
ELY_DB: new SqliteD1Database(databasePath),
|
||||
ELY_STORAGE: bucket,
|
||||
ELY_KV: kv,
|
||||
ELY_ENVIRONMENT: "local",
|
||||
} as unknown as Env;
|
||||
await run(databasePath, bucket, kv, env);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function seedAuthority(databasePath: string): void {
|
||||
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}', '${PUBLIC_KEY}', '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}', '${PUBLIC_KEY}', '${"4".repeat(64)}', 2, 1
|
||||
);
|
||||
INSERT INTO better_auth_session (
|
||||
id, expiresAt, token, createdAt, updatedAt, userId
|
||||
) VALUES (
|
||||
'session-01', '2099-01-01T00:00:00Z', 'session-token-01',
|
||||
'2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', '${USER_ID}'
|
||||
);
|
||||
INSERT INTO better_auth_session_device_context (
|
||||
session_id, user_id, device_id, updated_at
|
||||
) VALUES ('session-01', '${USER_ID}', '${DEVICE_ID}', 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);
|
||||
`);
|
||||
}
|
||||
|
||||
function seedReadyCandidates(databasePath: string, bucket: TestBucket, count: number): string[] {
|
||||
const keys = Array.from({ length: count }, (_, index) => snapshotKey(index + 1));
|
||||
execute(databasePath, keys.map((key, index) => `
|
||||
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
|
||||
) VALUES (
|
||||
'${key}', '${USER_ID}', '${OWNER_HASH}', 'snapshot', 'ready', NULL,
|
||||
0, NULL, 1, 1, NULL, 1, NULL, NULL
|
||||
);
|
||||
`).join("\n"));
|
||||
for (const [index, key] of keys.entries()) {
|
||||
bucket.values.set(key, new Uint8Array([index % 256]).buffer);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function seedCompletedRotation(databasePath: string, r2Key: string): void {
|
||||
execute(databasePath, `
|
||||
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-02', '${"5".repeat(64)}', 'Old Mac', 'macOS',
|
||||
'revoked', 1, 1, 1, 2, 'device-register-0002'
|
||||
);
|
||||
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 (
|
||||
'${USER_ID}', 'rotation-reset-0001', 'rotation-reset-audit', 'device-02', '${DEVICE_ID}',
|
||||
'${KEY_ID}', 1, '${"6".repeat(64)}', 2, '${"7".repeat(64)}', 1, 1, 1, 2
|
||||
);
|
||||
INSERT INTO sync_vault_rotation_r2_objects (
|
||||
user_id, rotation_idempotency_key, r2_key
|
||||
) VALUES ('${USER_ID}', 'rotation-reset-0001', '${r2Key}');
|
||||
`);
|
||||
}
|
||||
|
||||
function resetRequest(idempotencyKey: string, proofCreatedAt: number): Promise<Request> {
|
||||
return actionRequest(
|
||||
"sync.reset",
|
||||
"delete-cloud-sync-data",
|
||||
idempotencyKey,
|
||||
proofCreatedAt,
|
||||
"/api/sync/reset",
|
||||
);
|
||||
}
|
||||
|
||||
function accountDeleteRequest(idempotencyKey: string, proofCreatedAt: number): Promise<Request> {
|
||||
return actionRequest(
|
||||
"account.delete",
|
||||
"delete-elydora-account",
|
||||
idempotencyKey,
|
||||
proofCreatedAt,
|
||||
"/api/account/delete",
|
||||
);
|
||||
}
|
||||
|
||||
async function actionRequest(
|
||||
action: SensitiveAction,
|
||||
confirmation: string,
|
||||
idempotencyKey: string,
|
||||
proofCreatedAt: number,
|
||||
path: string,
|
||||
): Promise<Request> {
|
||||
const actionProof = await signDeviceMessage(recentDeviceActionProofBytes({
|
||||
action,
|
||||
userId: USER_ID,
|
||||
sessionId: authContext().sessionId,
|
||||
deviceId: DEVICE_ID,
|
||||
confirmation,
|
||||
idempotencyKey,
|
||||
proofCreatedAt,
|
||||
}));
|
||||
return new Request(`https://elydora.test${path}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
version: 2,
|
||||
confirmation,
|
||||
idempotency_key: idempotencyKey,
|
||||
proof_created_at: proofCreatedAt,
|
||||
action_proof: actionProof,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function authContext() {
|
||||
return {
|
||||
userId: USER_ID,
|
||||
deviceId: DEVICE_ID,
|
||||
sessionId: "session-01",
|
||||
tokenHash: TOKEN_HASH,
|
||||
expiresAt: "2099-01-01T00:00:00Z",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
} as const;
|
||||
}
|
||||
|
||||
function snapshotKey(index: number): string {
|
||||
const hash = index.toString(16).padStart(64, "0");
|
||||
return `sync-snapshots/us-east/${OWNER_HASH}/snapshot-${index}/${hash}.bin`;
|
||||
}
|
||||
|
||||
function deletedCandidateCount(databasePath: string): unknown {
|
||||
return query(databasePath, `
|
||||
SELECT COUNT(*) AS count FROM sync_r2_gc_candidates WHERE state = 'deleted'
|
||||
`)[0]?.count;
|
||||
}
|
||||
|
||||
function candidateState(databasePath: string, key: string): unknown {
|
||||
return query(databasePath, `
|
||||
SELECT state FROM sync_r2_gc_candidates WHERE r2_key = '${key}'
|
||||
`)[0]?.state;
|
||||
}
|
||||
|
||||
class TestBucket {
|
||||
readonly deletes: string[] = [];
|
||||
readonly values = new Map<string, ArrayBuffer>();
|
||||
failDeletes = false;
|
||||
|
||||
get(key: string): Promise<ElyR2Object | null> {
|
||||
const value = this.values.get(key);
|
||||
return Promise.resolve(value === undefined ? null : object(value));
|
||||
}
|
||||
|
||||
put(key: string, value: ArrayBuffer, _options?: ElyR2PutOptions): Promise<ElyR2Object> {
|
||||
this.values.set(key, value);
|
||||
return Promise.resolve(object(value));
|
||||
}
|
||||
|
||||
delete(key: string): Promise<void> {
|
||||
if (this.failDeletes) return Promise.reject(new Error("r2_delete_failed"));
|
||||
this.deletes.push(key);
|
||||
this.values.delete(key);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
list(options: { prefix: string; cursor?: string; limit: number }) {
|
||||
const objects = [...this.values.keys()]
|
||||
.filter((key) => key.startsWith(options.prefix))
|
||||
.slice(0, options.limit)
|
||||
.map((key) => ({ key }));
|
||||
return Promise.resolve({ objects, truncated: false as const });
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.values.size;
|
||||
}
|
||||
}
|
||||
|
||||
class TestKv {
|
||||
readonly values = new Map<string, string>();
|
||||
failDeletes = false;
|
||||
failLists = false;
|
||||
|
||||
get(key: string): Promise<string | null> {
|
||||
return Promise.resolve(this.values.get(key) ?? null);
|
||||
}
|
||||
|
||||
put(key: string, value: string): Promise<void> {
|
||||
this.values.set(key, value);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
delete(key: string): Promise<void> {
|
||||
if (this.failDeletes) return Promise.reject(new Error("kv_delete_failed"));
|
||||
this.values.delete(key);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
list(options: { prefix: string; cursor?: string; limit: number }) {
|
||||
if (this.failLists) return Promise.reject(new Error("kv_list_failed"));
|
||||
const keys = [...this.values.keys()]
|
||||
.filter((key) => key.startsWith(options.prefix))
|
||||
.slice(0, options.limit)
|
||||
.map((name) => ({ name }));
|
||||
return Promise.resolve({ keys, list_complete: true as const });
|
||||
}
|
||||
}
|
||||
|
||||
function object(value: ArrayBuffer): ElyR2Object {
|
||||
return { arrayBuffer: () => Promise.resolve(value) };
|
||||
}
|
||||
@@ -216,6 +216,7 @@ describe("api controls", () => {
|
||||
id: "session-01",
|
||||
userId: "user-01",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
createdAt: new Date().toISOString(),
|
||||
deviceId: "device-01",
|
||||
},
|
||||
],
|
||||
@@ -300,6 +301,7 @@ describe("api controls", () => {
|
||||
id: "session-01",
|
||||
userId: "user-01",
|
||||
expiresAt: "2026-01-01T00:00:00.000Z",
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
deviceId: "device-01",
|
||||
},
|
||||
],
|
||||
@@ -419,6 +421,7 @@ function testD1Database(options: TestD1DatabaseOptions = {}): Env["ELY_DB"] {
|
||||
id: "session-01",
|
||||
userId: "user-01",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
createdAt: new Date().toISOString(),
|
||||
deviceId: "device-01",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { handleRequest } from "../src/index.js";
|
||||
import {
|
||||
type SensitiveAction,
|
||||
recentDeviceActionProofBytes,
|
||||
recentDeviceActionRequestHash,
|
||||
} from "../src/recent_device_action_proof.js";
|
||||
import {
|
||||
ACCESS_TOKEN,
|
||||
PUBLIC_KEY,
|
||||
signDeviceMessage,
|
||||
testD1Database,
|
||||
testEnv,
|
||||
} from "./devices_test_support.js";
|
||||
|
||||
const NOW = 1_780_001_000;
|
||||
|
||||
interface ActionCase {
|
||||
action: SensitiveAction;
|
||||
path: string;
|
||||
confirmation: string;
|
||||
idempotencyKey: string;
|
||||
forbiddenError: string;
|
||||
failedError: string;
|
||||
existingEvent: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const ACTIONS: ActionCase[] = [
|
||||
{
|
||||
action: "sync.reset",
|
||||
path: "/api/sync/reset",
|
||||
confirmation: "delete-cloud-sync-data",
|
||||
idempotencyKey: "sync-reset-security-0001",
|
||||
forbiddenError: "sync_reset_forbidden",
|
||||
failedError: "sync_reset_failed",
|
||||
existingEvent: {
|
||||
actor_device_id: "device-01",
|
||||
outcome: "success",
|
||||
created_at: NOW,
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "account.delete",
|
||||
path: "/api/account/delete",
|
||||
confirmation: "delete-elydora-account",
|
||||
idempotencyKey: "account-delete-security-0001",
|
||||
forbiddenError: "account_deletion_forbidden",
|
||||
failedError: "account_deletion_failed",
|
||||
existingEvent: {
|
||||
actor_device_id: "device-01",
|
||||
outcome: "success",
|
||||
subject_id: "2fb6b7445391dae3bf4fb63927132e773d8d00e5963b5270dddecc84e99811fa",
|
||||
created_at: NOW,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("destructive action proofs", () => {
|
||||
it("blocks a stolen bearer without the device private key", async () => {
|
||||
for (const action of ACTIONS) {
|
||||
const body = await actionBody(action, NOW);
|
||||
body.action_proof = "0".repeat(128);
|
||||
const response = await actionRequest(action, body, newActionRows(action));
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(await response.json(), { error: action.forbiddenError });
|
||||
}
|
||||
});
|
||||
|
||||
it("binds the action, session, and idempotency key", async () => {
|
||||
for (const action of ACTIONS) {
|
||||
for (const changed of ["action", "session", "idempotency"] as const) {
|
||||
const signedAction = changed === "action"
|
||||
? action.action === "sync.reset" ? "account.delete" : "sync.reset"
|
||||
: action.action;
|
||||
const body = await actionBody(
|
||||
action,
|
||||
NOW,
|
||||
signedAction,
|
||||
changed === "session" ? "session-02" : "session-01",
|
||||
);
|
||||
if (changed === "idempotency") {
|
||||
body.idempotency_key = `${action.idempotencyKey}-changed`;
|
||||
}
|
||||
const response = await actionRequest(action, body, newActionRows(action));
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(await response.json(), { error: action.forbiddenError });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("requires freshness for a new destructive action", async () => {
|
||||
for (const action of ACTIONS) {
|
||||
const response = await actionRequest(
|
||||
action,
|
||||
await actionBody(action, NOW - 301),
|
||||
newActionRows(action, true),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(await response.json(), { error: action.forbiddenError });
|
||||
}
|
||||
});
|
||||
|
||||
it("verifies old proofs for exact idempotent replays without requiring freshness", async () => {
|
||||
for (const action of ACTIONS) {
|
||||
const body = await actionBody(action, NOW - 301);
|
||||
const event = {
|
||||
...action.existingEvent,
|
||||
...(action.action === "account.delete"
|
||||
? { metadata_hash: await requestHash(action, body) }
|
||||
: {}),
|
||||
};
|
||||
const response = await actionRequest(action, body, replayRows(action, event));
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
}
|
||||
});
|
||||
|
||||
it("maps a malformed stored signing key to a persistence failure", async () => {
|
||||
for (const action of ACTIONS) {
|
||||
const rows = newActionRows(action);
|
||||
rows[rows.length - 1] = { signing_public_key: "invalid" };
|
||||
const response = await actionRequest(action, await actionBody(action, NOW), rows);
|
||||
|
||||
assert.equal(response.status, 500);
|
||||
assert.deepEqual(await response.json(), { error: action.failedError });
|
||||
}
|
||||
});
|
||||
|
||||
it("opens a fresh primary session after a concurrent replay abort", async () => {
|
||||
const action = ACTIONS[0];
|
||||
assert.ok(action !== undefined);
|
||||
const proofCreatedAt = Math.floor(Date.now() / 1000);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
{ device_id: "device-01" },
|
||||
{ signing_public_key: PUBLIC_KEY },
|
||||
null,
|
||||
{ objects: 0, changes: 0, snapshots: 0, tombstones: 0 },
|
||||
action.existingEvent,
|
||||
],
|
||||
allRows: [],
|
||||
batchError: new Error("UNIQUE constraint failed: audit_events.event_id"),
|
||||
});
|
||||
const response = await handleRequest(
|
||||
new Request(`https://elydora.test${action.path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(await actionBody(action, proofCreatedAt)),
|
||||
}),
|
||||
testEnv({ d1 }),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(d1.sessionConstraints, ["first-primary", "first-primary"]);
|
||||
});
|
||||
|
||||
it("requires the exact timestamp and session for an account deletion replay", async () => {
|
||||
const action = ACTIONS[1];
|
||||
assert.ok(action !== undefined);
|
||||
const body = await actionBody(action, NOW - 301);
|
||||
const event = {
|
||||
...action.existingEvent,
|
||||
metadata_hash: await requestHash(action, body),
|
||||
};
|
||||
|
||||
const changedTimestamp = { ...body, proof_created_at: NOW - 300 };
|
||||
const timestampResponse = await actionRequest(action, changedTimestamp, [event]);
|
||||
assert.equal(timestampResponse.status, 400);
|
||||
|
||||
const d1 = testD1Database({
|
||||
firstRows: [event],
|
||||
sessionRow: {
|
||||
id: "session-02",
|
||||
userId: "user-01",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
deviceId: "device-01",
|
||||
},
|
||||
});
|
||||
const sessionResponse = await handleRequest(
|
||||
new Request(`https://elydora.test${action.path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
testEnv({ d1 }),
|
||||
);
|
||||
assert.equal(sessionResponse.status, 400);
|
||||
});
|
||||
});
|
||||
|
||||
async function actionRequest(
|
||||
action: ActionCase,
|
||||
body: Record<string, unknown>,
|
||||
firstRows: unknown[],
|
||||
): Promise<Response> {
|
||||
return handleRequest(
|
||||
new Request(`https://elydora.test${action.path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
testEnv({ d1: testD1Database({ firstRows }) }),
|
||||
);
|
||||
}
|
||||
|
||||
function newActionRows(action: ActionCase, includeEventMiss = false): unknown[] {
|
||||
if (action.action === "account.delete") {
|
||||
return [null, { signing_public_key: PUBLIC_KEY }];
|
||||
}
|
||||
return [
|
||||
{ device_id: "device-01" },
|
||||
{ signing_public_key: PUBLIC_KEY },
|
||||
...(includeEventMiss ? [null] : []),
|
||||
];
|
||||
}
|
||||
|
||||
function replayRows(action: ActionCase, event: Record<string, unknown>): unknown[] {
|
||||
return action.action === "account.delete"
|
||||
? [event]
|
||||
: [{ device_id: "device-01" }, { signing_public_key: PUBLIC_KEY }, event];
|
||||
}
|
||||
|
||||
async function actionBody(
|
||||
action: ActionCase,
|
||||
proofCreatedAt: number,
|
||||
signedAction = action.action,
|
||||
signedSessionId = "session-01",
|
||||
): Promise<Record<string, unknown>> {
|
||||
const body = {
|
||||
version: 2,
|
||||
confirmation: action.confirmation,
|
||||
idempotency_key: action.idempotencyKey,
|
||||
proof_created_at: proofCreatedAt,
|
||||
action_proof: "",
|
||||
};
|
||||
body.action_proof = await signDeviceMessage(recentDeviceActionProofBytes({
|
||||
action: signedAction,
|
||||
userId: "user-01",
|
||||
sessionId: signedSessionId,
|
||||
deviceId: "device-01",
|
||||
confirmation: body.confirmation,
|
||||
idempotencyKey: body.idempotency_key,
|
||||
proofCreatedAt,
|
||||
}));
|
||||
return body;
|
||||
}
|
||||
|
||||
function requestHash(
|
||||
action: ActionCase,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<string> {
|
||||
return recentDeviceActionRequestHash({
|
||||
action: action.action,
|
||||
userId: "user-01",
|
||||
sessionId: "session-01",
|
||||
deviceId: "device-01",
|
||||
confirmation: String(body.confirmation),
|
||||
idempotencyKey: String(body.idempotency_key),
|
||||
proofCreatedAt: Number(body.proof_created_at),
|
||||
actionProof: String(body.action_proof),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
import assert from "node:assert/strict";
|
||||
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 { DeviceConflictError } from "../src/device_schema.js";
|
||||
import { revokeDeviceDocument } from "../src/device_revocation.js";
|
||||
import {
|
||||
type ApprovedDeviceRevocationRequest,
|
||||
type PendingDeviceRevocationRequest,
|
||||
deviceRevocationProofBytes,
|
||||
pendingDeviceRevocationProofBytes,
|
||||
} from "../src/device_revocation_schema.js";
|
||||
import {
|
||||
PUBLIC_KEY,
|
||||
WRAPPING_PUBLIC_KEY,
|
||||
signDeviceMessage,
|
||||
testEnv,
|
||||
} from "./devices_test_support.js";
|
||||
import { SqliteD1Database, execute, query } from "./sqlite_d1_test_support.js";
|
||||
|
||||
const MIGRATIONS_DIR = join(process.cwd(), "migrations");
|
||||
const USER_ID = "user-01", APPROVER_ID = "device-01";
|
||||
const TARGET_ID = "device-02", REMAINING_ID = "device-03";
|
||||
const OLD_KEY = "a".repeat(64), NEW_KEY = "b".repeat(64);
|
||||
const HASH = "c".repeat(64), USER_HASH = "d".repeat(64);
|
||||
const IDEMPOTENCY_KEY = "rotation-key-0001", NOW = 200;
|
||||
const SUITE = "HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305";
|
||||
const PAYLOAD_R2_KEY = `sync-payloads/us/${USER_HASH}/bookmarks/object-01/${HASH}.bin`;
|
||||
const SNAPSHOT_R2_KEY = `sync-snapshots/us/${USER_HASH}/snapshot-01/${HASH}.bin`;
|
||||
describe("device revocation real D1 flow", () => {
|
||||
it("executes the handler queries and trigger atomically", async () => {
|
||||
await withDatabase(async (databasePath) => {
|
||||
const database = new SqliteD1Database(databasePath);
|
||||
const document = await revokeDeviceDocument(
|
||||
await revocationRequest(),
|
||||
testEnv({ d1: database }),
|
||||
authContext(),
|
||||
NOW,
|
||||
);
|
||||
|
||||
assert.equal(document.mode, "approved_rotate");
|
||||
if (document.mode !== "approved_rotate") throw new Error("approved rotation expected");
|
||||
assert.equal(document.generation, 2);
|
||||
assert.equal(document.key_id, NEW_KEY);
|
||||
assert.equal(document.device.approval_status, "revoked");
|
||||
assert.equal(document.device.revoked_at, NOW);
|
||||
assert.deepEqual(database.batches, [4]);
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT current_key_id, current_generation
|
||||
FROM sync_vault_accounts WHERE user_id = '${USER_ID}'
|
||||
`), [{ current_key_id: NEW_KEY, current_generation: 2 }]);
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT recipient_device_id, approver_device_id, key_id, generation,
|
||||
envelope_version, suite, encapped_key, ciphertext, created_at
|
||||
FROM sync_vault_envelopes
|
||||
WHERE user_id = '${USER_ID}' AND key_id = '${NEW_KEY}'
|
||||
ORDER BY recipient_device_id
|
||||
`), [
|
||||
envelopeRow(APPROVER_ID, "A".repeat(43), "B".repeat(64)),
|
||||
envelopeRow(REMAINING_ID, `${"C".repeat(42)}E`, "D".repeat(64)),
|
||||
]);
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT target.approval_status, target.revoked_at,
|
||||
rotation.previous_generation, rotation.new_generation,
|
||||
rotation.envelope_count, rotation.r2_object_count,
|
||||
rotation.completed_at
|
||||
FROM user_devices AS target
|
||||
INNER JOIN sync_vault_rotations AS rotation
|
||||
ON rotation.user_id = target.user_id
|
||||
AND rotation.target_device_id = target.device_id
|
||||
WHERE target.user_id = '${USER_ID}' AND target.device_id = '${TARGET_ID}'
|
||||
`), [{
|
||||
approval_status: "revoked",
|
||||
revoked_at: NOW,
|
||||
previous_generation: 1,
|
||||
new_generation: 2,
|
||||
envelope_count: 2,
|
||||
r2_object_count: 2,
|
||||
completed_at: NOW,
|
||||
}]);
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT actor_device_id, event_type, subject_type, subject_id, outcome, created_at,
|
||||
event_id = 'device-revoke:' || (SELECT request_hash FROM sync_vault_rotations
|
||||
WHERE user_id = '${USER_ID}' AND idempotency_key = '${IDEMPOTENCY_KEY}')
|
||||
AS event_id_matches,
|
||||
metadata_hash = (SELECT request_hash FROM sync_vault_rotations
|
||||
WHERE user_id = '${USER_ID}' AND idempotency_key = '${IDEMPOTENCY_KEY}')
|
||||
AS request_hash_matches
|
||||
FROM audit_events WHERE user_id = '${USER_ID}'
|
||||
`), [{
|
||||
actor_device_id: APPROVER_ID,
|
||||
event_type: "device.revoke",
|
||||
subject_type: "device",
|
||||
subject_id: TARGET_ID,
|
||||
outcome: "success",
|
||||
created_at: NOW,
|
||||
event_id_matches: 1,
|
||||
request_hash_matches: 1,
|
||||
}]);
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM sync_objects WHERE user_id = '${USER_ID}') AS objects,
|
||||
(SELECT COUNT(*) FROM sync_snapshots WHERE user_id = '${USER_ID}') AS snapshots,
|
||||
(SELECT COUNT(*) FROM sync_snapshot_encryption WHERE user_id = '${USER_ID}') AS encryption,
|
||||
(SELECT COUNT(*) FROM sync_vault_rotation_r2_objects
|
||||
WHERE user_id = '${USER_ID}') AS staged_r2,
|
||||
(SELECT COUNT(*) FROM better_auth_session
|
||||
WHERE id = 'target-session') AS target_sessions
|
||||
`), [{ objects: 1, snapshots: 1, encryption: 1, staged_r2: 2, target_sessions: 0 }]);
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT r2_key FROM sync_vault_rotation_r2_objects
|
||||
WHERE user_id = '${USER_ID}' AND rotation_idempotency_key = '${IDEMPOTENCY_KEY}'
|
||||
ORDER BY r2_key
|
||||
`), [{ r2_key: PAYLOAD_R2_KEY }, { r2_key: SNAPSHOT_R2_KEY }]);
|
||||
});
|
||||
});
|
||||
|
||||
it("rolls back the rotation when the recipient set changes before batch", async () => {
|
||||
await withDatabase(async (databasePath) => {
|
||||
const database = new SqliteD1Database(databasePath, raceDeviceSql());
|
||||
await assert.rejects(
|
||||
revokeDeviceDocument(
|
||||
await revocationRequest(),
|
||||
testEnv({ d1: database }),
|
||||
authContext(),
|
||||
NOW,
|
||||
),
|
||||
(error: unknown) =>
|
||||
error instanceof DeviceConflictError && error.message === "device_revocation_race",
|
||||
);
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT
|
||||
(SELECT current_generation FROM sync_vault_accounts
|
||||
WHERE user_id = '${USER_ID}') AS generation,
|
||||
(SELECT approval_status FROM user_devices
|
||||
WHERE user_id = '${USER_ID}' AND device_id = '${TARGET_ID}') AS target_status,
|
||||
(SELECT COUNT(*) FROM sync_vault_rotations WHERE user_id = '${USER_ID}') AS rotations,
|
||||
(SELECT COUNT(*) FROM sync_vault_envelopes
|
||||
WHERE user_id = '${USER_ID}' AND key_id = '${NEW_KEY}') AS envelopes,
|
||||
(SELECT COUNT(*) FROM audit_events WHERE user_id = '${USER_ID}') AS audits
|
||||
`), [{
|
||||
generation: 1,
|
||||
target_status: "approved",
|
||||
rotations: 0,
|
||||
envelopes: 0,
|
||||
audits: 0,
|
||||
}]);
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT COUNT(*) AS target_sessions FROM better_auth_session
|
||||
WHERE id = 'target-session'
|
||||
`), [{ target_sessions: 1 }]);
|
||||
});
|
||||
});
|
||||
|
||||
it("revokes a pending device without changing vault or sync state", async () => {
|
||||
await withDatabase(async (databasePath) => {
|
||||
execute(databasePath, `
|
||||
UPDATE user_devices SET approval_status = 'pending', approved_at = NULL
|
||||
WHERE user_id = '${USER_ID}' AND device_id = '${TARGET_ID}';
|
||||
`);
|
||||
const database = new SqliteD1Database(databasePath);
|
||||
const document = await revokeDeviceDocument(
|
||||
await pendingRevocationRequest(),
|
||||
testEnv({ d1: database }),
|
||||
authContext(),
|
||||
NOW,
|
||||
);
|
||||
assert.equal(document.mode, "pending_revoke");
|
||||
assert.equal(document.device.approval_status, "revoked");
|
||||
assert.deepEqual(database.batches, [2]);
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT
|
||||
(SELECT current_generation FROM sync_vault_accounts
|
||||
WHERE user_id = '${USER_ID}') AS generation,
|
||||
(SELECT COUNT(*) FROM sync_vault_rotations WHERE user_id = '${USER_ID}') AS rotations,
|
||||
(SELECT COUNT(*) FROM pending_device_revocations
|
||||
WHERE user_id = '${USER_ID}') AS pending_revocations,
|
||||
(SELECT COUNT(*) FROM sync_objects WHERE user_id = '${USER_ID}') AS objects,
|
||||
(SELECT COUNT(*) FROM sync_snapshots WHERE user_id = '${USER_ID}') AS snapshots,
|
||||
(SELECT COUNT(*) FROM better_auth_session
|
||||
WHERE id = 'target-session') AS target_sessions
|
||||
`), [{
|
||||
generation: 1,
|
||||
rotations: 0,
|
||||
pending_revocations: 1,
|
||||
objects: 1,
|
||||
snapshots: 1,
|
||||
target_sessions: 0,
|
||||
}]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function revocationRequest(): Promise<Request> {
|
||||
const envelopes: ApprovedDeviceRevocationRequest["envelopes"] = [
|
||||
rotationEnvelope(APPROVER_ID, "A".repeat(43), "B".repeat(64)),
|
||||
rotationEnvelope(REMAINING_ID, `${"C".repeat(42)}E`, "D".repeat(64)),
|
||||
];
|
||||
const unsigned: Omit<ApprovedDeviceRevocationRequest, "rotationProof"> = {
|
||||
mode: "approved_rotate",
|
||||
deviceId: TARGET_ID,
|
||||
previousKeyId: OLD_KEY,
|
||||
previousGeneration: 1,
|
||||
newKeyId: NEW_KEY,
|
||||
newGeneration: 2,
|
||||
envelopes,
|
||||
idempotencyKey: IDEMPOTENCY_KEY,
|
||||
};
|
||||
return new Request("https://elydora.test/api/devices/revoke", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
version: 2,
|
||||
mode: "approved_rotate",
|
||||
device_id: TARGET_ID,
|
||||
previous_key_id: OLD_KEY,
|
||||
previous_generation: 1,
|
||||
new_key_id: NEW_KEY,
|
||||
new_generation: 2,
|
||||
envelopes: envelopes.map((item) => ({
|
||||
recipient_device_id: item.recipientDeviceId,
|
||||
envelope: item.envelope,
|
||||
})),
|
||||
idempotency_key: IDEMPOTENCY_KEY,
|
||||
rotation_proof: await signDeviceMessage(
|
||||
deviceRevocationProofBytes(USER_ID, APPROVER_ID, unsigned),
|
||||
),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function pendingRevocationRequest(): Promise<Request> {
|
||||
const unsigned: Omit<PendingDeviceRevocationRequest, "pendingRevocationProof"> = {
|
||||
mode: "pending_revoke",
|
||||
deviceId: TARGET_ID,
|
||||
idempotencyKey: IDEMPOTENCY_KEY,
|
||||
};
|
||||
return new Request("https://elydora.test/api/devices/revoke", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
version: 2,
|
||||
mode: "pending_revoke",
|
||||
device_id: TARGET_ID,
|
||||
idempotency_key: IDEMPOTENCY_KEY,
|
||||
pending_revocation_proof: await signDeviceMessage(
|
||||
pendingDeviceRevocationProofBytes(USER_ID, APPROVER_ID, unsigned),
|
||||
),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function rotationEnvelope(
|
||||
recipientDeviceId: string,
|
||||
encappedKey: string,
|
||||
ciphertext: string,
|
||||
): ApprovedDeviceRevocationRequest["envelopes"][number] {
|
||||
return {
|
||||
recipientDeviceId,
|
||||
envelope: { version: 1, suite: SUITE, encapped_key: encappedKey, ciphertext },
|
||||
};
|
||||
}
|
||||
|
||||
function envelopeRow(
|
||||
recipientDeviceId: string,
|
||||
encappedKey: string,
|
||||
ciphertext: string,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
recipient_device_id: recipientDeviceId,
|
||||
approver_device_id: APPROVER_ID,
|
||||
key_id: NEW_KEY,
|
||||
generation: 2,
|
||||
envelope_version: 1,
|
||||
suite: SUITE,
|
||||
encapped_key: encappedKey,
|
||||
ciphertext,
|
||||
created_at: NOW,
|
||||
};
|
||||
}
|
||||
|
||||
function authContext() {
|
||||
return {
|
||||
userId: USER_ID,
|
||||
sessionId: "session-01",
|
||||
tokenHash: "0".repeat(64),
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
deviceId: APPROVER_ID,
|
||||
};
|
||||
}
|
||||
|
||||
function seedSql(): string {
|
||||
return `
|
||||
INSERT INTO better_auth_user
|
||||
(id, name, email, emailVerified, createdAt, updatedAt)
|
||||
VALUES ('${USER_ID}', 'User', 'user@example.com', 1, '2026-01-01', '2026-01-01');
|
||||
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}', '${APPROVER_ID}', '${PUBLIC_KEY}', 'Approver', 'macOS', 'approved',
|
||||
10, 11, 12, NULL, 'device-register-0001'),
|
||||
('${USER_ID}', '${TARGET_ID}', '${PUBLIC_KEY}', 'Target', 'macOS', 'approved',
|
||||
10, 11, 12, NULL, 'device-register-0002'),
|
||||
('${USER_ID}', '${REMAINING_ID}', '${PUBLIC_KEY}', 'Remaining', 'macOS', 'approved',
|
||||
10, 11, 12, NULL, 'device-register-0003');
|
||||
INSERT INTO user_device_keys
|
||||
(user_id, device_id, signing_public_key, wrapping_public_key,
|
||||
key_protocol_version, created_at)
|
||||
VALUES
|
||||
('${USER_ID}', '${APPROVER_ID}', '${PUBLIC_KEY}', '${WRAPPING_PUBLIC_KEY}', 2, 10),
|
||||
('${USER_ID}', '${TARGET_ID}', '${PUBLIC_KEY}', '${WRAPPING_PUBLIC_KEY}', 2, 10),
|
||||
('${USER_ID}', '${REMAINING_ID}', '${PUBLIC_KEY}', '${WRAPPING_PUBLIC_KEY}', 2, 10);
|
||||
INSERT INTO better_auth_session
|
||||
(id, expiresAt, token, createdAt, updatedAt, userId)
|
||||
VALUES
|
||||
('target-session', '2099-01-01', 'target-session-token',
|
||||
'2026-01-01', '2026-01-01', '${USER_ID}');
|
||||
INSERT INTO better_auth_session_device_context
|
||||
(session_id, user_id, device_id, updated_at)
|
||||
VALUES ('target-session', '${USER_ID}', '${TARGET_ID}', 15);
|
||||
INSERT INTO sync_vault_accounts
|
||||
(user_id, current_key_id, current_generation, created_at, updated_at)
|
||||
VALUES ('${USER_ID}', '${OLD_KEY}', 1, 20, 20);
|
||||
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
|
||||
) VALUES (
|
||||
'${PAYLOAD_R2_KEY}', '${USER_ID}', '${USER_HASH}', 'payload', 'pending',
|
||||
'${"1".repeat(64)}', 1000, NULL, 30, 30, NULL, NULL, NULL, NULL
|
||||
);
|
||||
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 ('${USER_ID}', 'object-01', 'bookmarks', NULL, '${PAYLOAD_R2_KEY}', '${HASH}',
|
||||
1, 1, '${APPROVER_ID}', 30, 30, NULL);
|
||||
UPDATE sync_r2_gc_candidates
|
||||
SET state = 'referenced', referenced_at = 30, updated_at = 30
|
||||
WHERE r2_key = '${PAYLOAD_R2_KEY}';
|
||||
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
|
||||
) VALUES (
|
||||
'${SNAPSHOT_R2_KEY}', '${USER_ID}', '${USER_HASH}', 'snapshot', 'pending',
|
||||
'${"2".repeat(64)}', 1000, NULL, 40, 40, NULL, NULL, NULL, NULL
|
||||
);
|
||||
INSERT INTO sync_snapshots
|
||||
(user_id, snapshot_id, r2_key, payload_hash, schema_rev, logical_clock,
|
||||
device_id, size_bytes, created_at)
|
||||
VALUES ('${USER_ID}', 'snapshot-01', '${SNAPSHOT_R2_KEY}', '${HASH}', 1, 1,
|
||||
'${APPROVER_ID}', 64, 40);
|
||||
INSERT INTO sync_snapshot_encryption
|
||||
(user_id, snapshot_id, encryption_version, vault_generation, key_id, content_hash)
|
||||
VALUES ('${USER_ID}', 'snapshot-01', 1, 1, '${OLD_KEY}', '${HASH}');
|
||||
`;
|
||||
}
|
||||
|
||||
function raceDeviceSql(): string {
|
||||
return `
|
||||
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-04', '${PUBLIC_KEY}', 'Race', 'macOS', 'approved',
|
||||
100, 101, 102, NULL, 'device-register-0004');
|
||||
INSERT INTO user_device_keys
|
||||
(user_id, device_id, signing_public_key, wrapping_public_key,
|
||||
key_protocol_version, created_at)
|
||||
VALUES ('${USER_ID}', 'device-04', '${PUBLIC_KEY}', '${WRAPPING_PUBLIC_KEY}', 2, 100);
|
||||
`;
|
||||
}
|
||||
|
||||
async function withDatabase(run: (databasePath: string) => Promise<void>): Promise<void> {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "ely-revoke-handler-"));
|
||||
try {
|
||||
const databasePath = join(tempDir, "ely.db");
|
||||
const migrations = readdirSync(MIGRATIONS_DIR)
|
||||
.filter((name) => name.endsWith(".sql"))
|
||||
.sort()
|
||||
.map((name) => readFileSync(join(MIGRATIONS_DIR, name), "utf8"))
|
||||
.join("\n");
|
||||
execute(databasePath, migrations);
|
||||
execute(databasePath, seedSql());
|
||||
await run(databasePath);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
compareDeviceIds,
|
||||
deviceRevocationProofBytes,
|
||||
deviceRevocationRequest,
|
||||
pendingDeviceRevocationProofBytes,
|
||||
} from "../src/device_revocation_schema.js";
|
||||
import { DeviceSchemaError } from "../src/device_schema.js";
|
||||
import { handleRequest } from "../src/index.js";
|
||||
import {
|
||||
ACCESS_TOKEN,
|
||||
PUBLIC_KEY,
|
||||
signDeviceMessage,
|
||||
testD1Database,
|
||||
testEnv,
|
||||
} from "./devices_test_support.js";
|
||||
|
||||
const USER_ID = "user-01", APPROVER_ID = "device-01", TARGET_ID = "device-02";
|
||||
const OLD_KEY = "a".repeat(64), NEW_KEY = "b".repeat(64);
|
||||
const IDEMPOTENCY_KEY = "device-revocation-0001";
|
||||
const SUITE = "HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305";
|
||||
|
||||
describe("device revocation proof schema", () => {
|
||||
it("rejects malformed envelope recipients and rotation metadata", async () => {
|
||||
for (const body of [
|
||||
approvedBody({ envelopes: [envelope(TARGET_ID)] }),
|
||||
approvedBody({ envelopes: [envelope(APPROVER_ID), envelope(APPROVER_ID)] }),
|
||||
approvedBody({ envelopes: [envelope(APPROVER_ID, { encapped_key: `${"A".repeat(42)}B` })] }),
|
||||
approvedBody({ new_generation: 3 }),
|
||||
approvedBody({ new_key_id: OLD_KEY }),
|
||||
{ ...approvedBody(), mode: undefined },
|
||||
{ ...pendingBody(), new_key_id: NEW_KEY },
|
||||
]) {
|
||||
await assert.rejects(
|
||||
deviceRevocationRequest(request({ ...body, rotation_proof: "0".repeat(128) })),
|
||||
DeviceSchemaError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses one ASCII code-unit order for proof and exact recipient checks", async () => {
|
||||
const ids = ["a_1", "a:1", "a.1", "a-1", "A_1", "A-1"];
|
||||
const expected = ["A-1", "A_1", "a-1", "a.1", "a:1", "a_1"];
|
||||
assert.deepEqual([...ids].sort(compareDeviceIds), expected);
|
||||
const parsed = await deviceRevocationRequest(request(
|
||||
await signedBody(approvedBody({ envelopes: ids.map((id) => envelope(id)) })),
|
||||
));
|
||||
assert.equal(parsed.mode, "approved_rotate");
|
||||
if (parsed.mode !== "approved_rotate") throw new Error("approved rotation expected");
|
||||
assert.deepEqual(parsed.envelopes.map((item) => item.recipientDeviceId), expected);
|
||||
});
|
||||
|
||||
it("uses the frozen v2 approved rotation proof wire", async () => {
|
||||
const parsed = await deviceRevocationRequest(request(
|
||||
await signedBody(approvedBody({ envelopes: [envelope(APPROVER_ID)] })),
|
||||
));
|
||||
if (parsed.mode !== "approved_rotate") throw new Error("approved rotation expected");
|
||||
const { rotationProof: _, ...unsigned } = parsed;
|
||||
assert.equal(new TextDecoder().decode(
|
||||
deviceRevocationProofBytes(USER_ID, APPROVER_ID, unsigned),
|
||||
), [
|
||||
"28:elydora-device-revocation-v2",
|
||||
"7:user-01",
|
||||
"9:device-01",
|
||||
"9:device-02",
|
||||
`64:${OLD_KEY}`,
|
||||
"1:1",
|
||||
`64:${NEW_KEY}`,
|
||||
"1:2",
|
||||
"22:device-revocation-0001",
|
||||
"1:1",
|
||||
"9:device-01",
|
||||
"1:1",
|
||||
`45:${SUITE}`,
|
||||
`43:${"A".repeat(43)}`,
|
||||
`64:${"B".repeat(64)}`,
|
||||
].join(""));
|
||||
});
|
||||
|
||||
it("uses the frozen v2 pending revocation proof wire", async () => {
|
||||
const parsed = await deviceRevocationRequest(request(await signedBody(pendingBody())));
|
||||
if (parsed.mode !== "pending_revoke") throw new Error("pending revocation expected");
|
||||
const { pendingRevocationProof: _, ...unsigned } = parsed;
|
||||
assert.equal(new TextDecoder().decode(
|
||||
pendingDeviceRevocationProofBytes(USER_ID, APPROVER_ID, unsigned),
|
||||
), [
|
||||
"36:elydora-pending-device-revocation-v2",
|
||||
"7:user-01",
|
||||
"9:device-01",
|
||||
"9:device-02",
|
||||
"22:device-revocation-0001",
|
||||
].join(""));
|
||||
});
|
||||
|
||||
it("rejects invalid and tampered proofs before revocation state reads", async () => {
|
||||
const cases = [
|
||||
approvedBody({ rotation_proof: "0".repeat(128) }),
|
||||
{ ...await signedBody(approvedBody()), new_key_id: "c".repeat(64) },
|
||||
pendingBody({ pending_revocation_proof: "0".repeat(128) }),
|
||||
];
|
||||
for (const body of cases) {
|
||||
const d1 = testD1Database({ firstRows: [approverRow()] });
|
||||
const response = await handleRequest(request(body, true), testEnv({ d1 }));
|
||||
assert.equal(response.status, 403);
|
||||
assert.equal(d1.queries.length, 1);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function approvedBody(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
version: 2,
|
||||
mode: "approved_rotate",
|
||||
device_id: TARGET_ID,
|
||||
previous_key_id: OLD_KEY,
|
||||
previous_generation: 1,
|
||||
new_key_id: NEW_KEY,
|
||||
new_generation: 2,
|
||||
envelopes: [envelope("device-03"), envelope(APPROVER_ID)],
|
||||
idempotency_key: IDEMPOTENCY_KEY,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function pendingBody(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
version: 2,
|
||||
mode: "pending_revoke",
|
||||
device_id: TARGET_ID,
|
||||
idempotency_key: IDEMPOTENCY_KEY,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function envelope(
|
||||
recipient: string,
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Record<string, unknown> {
|
||||
const other = recipient === "device-03";
|
||||
return {
|
||||
recipient_device_id: recipient,
|
||||
envelope: {
|
||||
version: 1,
|
||||
suite: SUITE,
|
||||
encapped_key: other ? `${"C".repeat(42)}E` : "A".repeat(43),
|
||||
ciphertext: other ? "D".repeat(64) : "B".repeat(64),
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function signedBody(body: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
if (body.mode === "pending_revoke") {
|
||||
if (body.pending_revocation_proof !== undefined) return body;
|
||||
const draft = { ...body, pending_revocation_proof: "0".repeat(128) };
|
||||
const parsed = await deviceRevocationRequest(request(draft));
|
||||
if (parsed.mode !== "pending_revoke") throw new Error("pending revocation expected");
|
||||
const { pendingRevocationProof: _, ...unsigned } = parsed;
|
||||
return {
|
||||
...body,
|
||||
pending_revocation_proof: await signDeviceMessage(
|
||||
pendingDeviceRevocationProofBytes(USER_ID, APPROVER_ID, unsigned),
|
||||
),
|
||||
};
|
||||
}
|
||||
if (body.rotation_proof !== undefined) return body;
|
||||
const draft = { ...body, rotation_proof: "0".repeat(128) };
|
||||
const parsed = await deviceRevocationRequest(request(draft));
|
||||
if (parsed.mode !== "approved_rotate") throw new Error("approved rotation expected");
|
||||
const { rotationProof: _, ...unsigned } = parsed;
|
||||
return {
|
||||
...body,
|
||||
rotation_proof: await signDeviceMessage(
|
||||
deviceRevocationProofBytes(USER_ID, APPROVER_ID, unsigned),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function request(body: Record<string, unknown>, authenticated = false): Request {
|
||||
return new Request("https://elydora.test/api/devices/revoke", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...(authenticated ? { authorization: `Bearer ${ACCESS_TOKEN}` } : {}),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function approverRow(): Record<string, unknown> {
|
||||
return { device_id: APPROVER_ID, signing_public_key: PUBLIC_KEY };
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
const MIGRATIONS_DIR = join(process.cwd(), "migrations");
|
||||
const OLD_KEY = "a".repeat(64);
|
||||
const NEW_KEY = "b".repeat(64);
|
||||
const HASH = "c".repeat(64);
|
||||
const USER_HASH = "d".repeat(64);
|
||||
const WRAPPING_KEY = "e".repeat(64);
|
||||
const SIGNING_KEY = "f".repeat(64);
|
||||
const PAYLOAD_R2_KEY = `sync-payloads/us/${USER_HASH}/bookmarks/object-01/${HASH}.bin`;
|
||||
const SNAPSHOT_R2_KEY = `sync-snapshots/us/${USER_HASH}/snapshot-01/${HASH}.bin`;
|
||||
|
||||
describe("sync vault rotation migration", () => {
|
||||
it("finalizes atomically and retains the old head until replacement", () => {
|
||||
withDatabase((databasePath) => {
|
||||
execute(databasePath, seedSql());
|
||||
execute(databasePath, validRotationSql());
|
||||
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT current_key_id, current_generation FROM sync_vault_accounts WHERE user_id = 'user-01'
|
||||
`), [{ current_key_id: NEW_KEY, current_generation: 2 }]);
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT approval_status, revoked_at FROM user_devices
|
||||
WHERE user_id = 'user-01' AND device_id = 'device-02'
|
||||
`), [{ approval_status: "revoked", revoked_at: 200 }]);
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM sync_vault_envelopes
|
||||
WHERE user_id = 'user-01' AND key_id = '${NEW_KEY}' AND generation = 2) AS envelopes,
|
||||
(SELECT COUNT(*) FROM audit_events
|
||||
WHERE event_id = 'device-revoke:user-01:rotation-key-0001') AS audits,
|
||||
(SELECT COUNT(*) FROM sync_vault_rotation_r2_objects
|
||||
WHERE user_id = 'user-01' AND rotation_idempotency_key = 'rotation-key-0001') AS r2_manifest,
|
||||
(SELECT COUNT(*) FROM sync_objects WHERE user_id = 'user-01') AS objects,
|
||||
(SELECT COUNT(*) FROM sync_snapshots WHERE user_id = 'user-01') AS snapshots,
|
||||
(SELECT COUNT(*) FROM sync_snapshot_encryption WHERE user_id = 'user-01') AS encryption
|
||||
`), [{ envelopes: 2, audits: 1, r2_manifest: 2, objects: 1, snapshots: 1, encryption: 1 }]);
|
||||
});
|
||||
});
|
||||
|
||||
it("rolls back every mutation when the staged recipient set is incomplete", () => {
|
||||
withDatabase((databasePath) => {
|
||||
execute(databasePath, seedSql());
|
||||
assert.throws(
|
||||
() => execute(databasePath, invalidRotationSql()),
|
||||
/sync_vault_rotation_guard_failed/,
|
||||
);
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT
|
||||
(SELECT current_generation FROM sync_vault_accounts WHERE user_id = 'user-01') AS generation,
|
||||
(SELECT approval_status FROM user_devices
|
||||
WHERE user_id = 'user-01' AND device_id = 'device-02') AS target_status,
|
||||
(SELECT COUNT(*) FROM audit_events WHERE user_id = 'user-01') AS audits,
|
||||
(SELECT COUNT(*) FROM sync_vault_rotations WHERE user_id = 'user-01') AS rotations
|
||||
`), [{ generation: 1, target_status: "approved", audits: 0, rotations: 0 }]);
|
||||
});
|
||||
});
|
||||
|
||||
it("quarantines approved protocol-v1 devices when 0011 is applied", () => {
|
||||
withDatabase((databasePath) => {
|
||||
execute(databasePath, `
|
||||
INSERT INTO better_auth_user
|
||||
(id, name, email, emailVerified, createdAt, updatedAt)
|
||||
VALUES ('legacy-user', 'Legacy', 'legacy@example.com', 1, '2026-01-01', '2026-01-01');
|
||||
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
|
||||
('legacy-user', 'legacy-device', '${SIGNING_KEY}', 'Legacy', 'macOS', 'approved',
|
||||
10, 11, 12, NULL, 'legacy-register-0001');
|
||||
INSERT INTO user_device_keys
|
||||
(user_id, device_id, signing_public_key, wrapping_public_key,
|
||||
key_protocol_version, created_at)
|
||||
VALUES ('legacy-user', 'legacy-device', '${SIGNING_KEY}', NULL, 1, 10);
|
||||
`);
|
||||
execute(databasePath, readFileSync(join(MIGRATIONS_DIR, "0011_sync_vault_rotation.sql"), "utf8"));
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT approval_status, revoked_at IS NOT NULL AS has_revoked_at
|
||||
FROM user_devices WHERE user_id = 'legacy-user' AND device_id = 'legacy-device'
|
||||
`), [{ approval_status: "revoked", has_revoked_at: 1 }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function seedSql(): string {
|
||||
return `
|
||||
INSERT INTO better_auth_user
|
||||
(id, name, email, emailVerified, createdAt, updatedAt)
|
||||
VALUES ('user-01', 'User', 'user@example.com', 1, '2026-01-01', '2026-01-01');
|
||||
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-01', 'device-01', '${SIGNING_KEY}', 'Approver', 'macOS', 'approved',
|
||||
10, 11, 12, NULL, 'device-register-0001'),
|
||||
('user-01', 'device-02', '${SIGNING_KEY}', 'Target', 'macOS', 'approved',
|
||||
10, 11, 12, NULL, 'device-register-0002'),
|
||||
('user-01', 'device-03', '${SIGNING_KEY}', 'Remaining', 'macOS', 'approved',
|
||||
10, 11, 12, NULL, 'device-register-0003');
|
||||
INSERT INTO user_device_keys
|
||||
(user_id, device_id, signing_public_key, wrapping_public_key,
|
||||
key_protocol_version, created_at)
|
||||
VALUES
|
||||
('user-01', 'device-01', '${SIGNING_KEY}', '${WRAPPING_KEY}', 2, 10),
|
||||
('user-01', 'device-02', '${SIGNING_KEY}', '${WRAPPING_KEY}', 2, 10),
|
||||
('user-01', 'device-03', '${SIGNING_KEY}', '${WRAPPING_KEY}', 2, 10);
|
||||
INSERT INTO sync_vault_accounts
|
||||
(user_id, current_key_id, current_generation, created_at, updated_at)
|
||||
VALUES ('user-01', '${OLD_KEY}', 1, 20, 20);
|
||||
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
|
||||
) VALUES (
|
||||
'${PAYLOAD_R2_KEY}', 'user-01', '${USER_HASH}', 'payload', 'pending',
|
||||
'${"1".repeat(64)}', 1000, NULL, 30, 30, NULL, NULL, NULL, NULL
|
||||
);
|
||||
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
|
||||
('user-01', 'object-01', 'bookmarks', NULL, '${PAYLOAD_R2_KEY}', '${HASH}',
|
||||
1, 1, 'device-01', 30, 30, NULL);
|
||||
UPDATE sync_r2_gc_candidates
|
||||
SET state = 'referenced', referenced_at = 30, updated_at = 30
|
||||
WHERE r2_key = '${PAYLOAD_R2_KEY}';
|
||||
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
|
||||
) VALUES (
|
||||
'${SNAPSHOT_R2_KEY}', 'user-01', '${USER_HASH}', 'snapshot', 'pending',
|
||||
'${"2".repeat(64)}', 1000, NULL, 40, 40, NULL, NULL, NULL, NULL
|
||||
);
|
||||
INSERT INTO sync_snapshots
|
||||
(user_id, snapshot_id, r2_key, payload_hash, schema_rev, logical_clock,
|
||||
device_id, size_bytes, created_at)
|
||||
VALUES
|
||||
('user-01', 'snapshot-01', '${SNAPSHOT_R2_KEY}', '${HASH}', 1, 1,
|
||||
'device-01', 64, 40);
|
||||
INSERT INTO sync_snapshot_encryption
|
||||
(user_id, snapshot_id, encryption_version, vault_generation, key_id, content_hash)
|
||||
VALUES ('user-01', 'snapshot-01', 1, 1, '${OLD_KEY}', '${HASH}');
|
||||
`;
|
||||
}
|
||||
|
||||
function validRotationSql(): string {
|
||||
return `
|
||||
BEGIN IMMEDIATE;
|
||||
${rotationHeaderSql(2, 2)}
|
||||
${rotationEnvelopeSql("device-01", "1".repeat(64), "A".repeat(43), "B".repeat(64))}
|
||||
${rotationEnvelopeSql("device-03", "2".repeat(64), `${"C".repeat(42)}E`, "D".repeat(64))}
|
||||
UPDATE sync_vault_rotations SET completed_at = 200
|
||||
WHERE user_id = 'user-01' AND idempotency_key = 'rotation-key-0001';
|
||||
COMMIT;
|
||||
`;
|
||||
}
|
||||
|
||||
function invalidRotationSql(): string {
|
||||
return `
|
||||
BEGIN IMMEDIATE;
|
||||
${rotationHeaderSql(2, 2)}
|
||||
${rotationEnvelopeSql("device-01", "1".repeat(64), "A".repeat(43), "B".repeat(64))}
|
||||
UPDATE sync_vault_rotations SET completed_at = 200
|
||||
WHERE user_id = 'user-01' AND idempotency_key = 'rotation-key-0001';
|
||||
COMMIT;
|
||||
`;
|
||||
}
|
||||
|
||||
function rotationHeaderSql(envelopeCount: number, r2Count: number): string {
|
||||
return `
|
||||
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
|
||||
('user-01', 'rotation-key-0001', 'device-revoke:user-01:rotation-key-0001',
|
||||
'device-02', 'device-01', '${OLD_KEY}', 1, '${NEW_KEY}', 2, '${HASH}',
|
||||
${envelopeCount}, ${r2Count}, 100, NULL);
|
||||
`;
|
||||
}
|
||||
|
||||
function rotationEnvelopeSql(
|
||||
recipient: string,
|
||||
idempotencyKey: string,
|
||||
encappedKey: string,
|
||||
ciphertext: string,
|
||||
): string {
|
||||
return `
|
||||
INSERT INTO sync_vault_rotation_envelopes
|
||||
(user_id, rotation_idempotency_key, recipient_device_id, envelope_idempotency_key,
|
||||
envelope_version, suite, encapped_key, ciphertext)
|
||||
VALUES
|
||||
('user-01', 'rotation-key-0001', '${recipient}', '${idempotencyKey}', 1,
|
||||
'HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305', '${encappedKey}', '${ciphertext}');
|
||||
`;
|
||||
}
|
||||
|
||||
function withDatabase(assertions: (databasePath: string) => void): void {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "ely-rotation-"));
|
||||
try {
|
||||
const databasePath = join(tempDir, "ely.db");
|
||||
const migrations = readdirSync(MIGRATIONS_DIR)
|
||||
.filter((name) => name.endsWith(".sql"))
|
||||
.sort()
|
||||
.map((name) => readFileSync(join(MIGRATIONS_DIR, name), "utf8"))
|
||||
.join("\n");
|
||||
execute(databasePath, migrations);
|
||||
assertions(databasePath);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function execute(databasePath: string, sql: string): void {
|
||||
execFileSync("sqlite3", [databasePath], {
|
||||
input: `.bail on\nPRAGMA foreign_keys = ON;\n${sql}`,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
function query(databasePath: string, sql: string): Record<string, unknown>[] {
|
||||
const output = execFileSync("sqlite3", ["-json", databasePath, sql], { encoding: "utf8" });
|
||||
return JSON.parse(output) as Record<string, unknown>[];
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { handleRequest } from "../src/index.js";
|
||||
import {
|
||||
ACCESS_TOKEN,
|
||||
PUBLIC_KEY,
|
||||
WRAPPING_PUBLIC_KEY,
|
||||
deviceRegistrationBody,
|
||||
signDeviceMessage,
|
||||
testD1Database,
|
||||
testEnv,
|
||||
} from "./devices_test_support.js";
|
||||
|
||||
describe("device trust routes", () => {
|
||||
it("atomically approves the first v2 device and stores both public keys", async () => {
|
||||
const device = {
|
||||
device_id: "device-01",
|
||||
public_key: PUBLIC_KEY,
|
||||
wrapping_public_key: WRAPPING_PUBLIC_KEY,
|
||||
device_name: "MacBook Pro",
|
||||
platform: "macOS",
|
||||
approval_status: "approved",
|
||||
created_at: 1_780_000_100,
|
||||
approved_at: 1_780_000_100,
|
||||
last_active_at: 1_780_000_100,
|
||||
revoked_at: null,
|
||||
};
|
||||
const d1 = testD1Database({
|
||||
firstRows: [device],
|
||||
sessionRow: {
|
||||
id: "session-01",
|
||||
userId: "user-01",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
createdAt: new Date().toISOString(),
|
||||
deviceId: null,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
new Request("https://elydora.test/api/devices/register", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(await deviceRegistrationBody()),
|
||||
}),
|
||||
testEnv({ d1 }),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 201);
|
||||
assert.equal(((await response.json()) as { device: { approval_status: string } }).device.approval_status, "approved");
|
||||
assert.ok(d1.queries.some((query) => query.includes("NOT EXISTS")));
|
||||
assert.ok(
|
||||
d1.queries.some(
|
||||
(query) =>
|
||||
query.includes("user_device_keys") &&
|
||||
query.includes("device_name = ?") &&
|
||||
query.includes("idempotency_key = ?"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps subsequent v2 devices pending", async () => {
|
||||
const device = deviceRow({ approval_status: "pending", approved_at: null });
|
||||
const d1 = testD1Database({
|
||||
firstRows: [device],
|
||||
sessionRow: unboundSession(),
|
||||
});
|
||||
const response = await registerRequest(d1, await deviceRegistrationBody());
|
||||
|
||||
assert.equal(response.status, 201);
|
||||
const body = (await response.json()) as { device: { approval_status: string } };
|
||||
assert.equal(body.device.approval_status, "pending");
|
||||
});
|
||||
|
||||
it("requires a fresh session before registering an unbound device", async () => {
|
||||
const d1 = testD1Database({
|
||||
sessionRow: { ...unboundSession(), createdAt: "2020-01-01T00:00:00.000Z" },
|
||||
});
|
||||
const response = await registerRequest(d1, await deviceRegistrationBody());
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(await response.json(), { error: "device_registration_forbidden" });
|
||||
assert.deepEqual(d1.queries, []);
|
||||
});
|
||||
|
||||
it("rejects v1 and non-canonical v2 registration keys before D1 writes", async () => {
|
||||
for (const registration of [
|
||||
{ ...(await deviceRegistrationBody()), version: 1 },
|
||||
await deviceRegistrationBody({ public_key: PUBLIC_KEY.toUpperCase() }),
|
||||
await deviceRegistrationBody({ wrapping_public_key: WRAPPING_PUBLIC_KEY.toUpperCase() }),
|
||||
]) {
|
||||
const d1 = testD1Database({ sessionRow: unboundSession() });
|
||||
const response = await registerRequest(d1, registration);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.deepEqual(d1.queries, []);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a tampered registration proof before D1 writes", async () => {
|
||||
const registration = await deviceRegistrationBody();
|
||||
registration.wrapping_public_key = "c".repeat(64);
|
||||
const d1 = testD1Database({ sessionRow: unboundSession() });
|
||||
const response = await registerRequest(d1, registration);
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(await response.json(), { error: "device_registration_forbidden" });
|
||||
assert.deepEqual(d1.queries, []);
|
||||
});
|
||||
|
||||
it("preserves an existing session binding that wins a registration race", async () => {
|
||||
const d1 = testD1Database({
|
||||
firstRows: [deviceRow()],
|
||||
runChanges: [0],
|
||||
sessionRow: unboundSession(),
|
||||
});
|
||||
const response = await registerRequest(d1, await deviceRegistrationBody());
|
||||
|
||||
assert.equal(response.status, 409);
|
||||
assert.deepEqual(await response.json(), { error: "device_registration_conflict" });
|
||||
assert.ok(d1.queries.at(-1)?.includes("ON CONFLICT(session_id) DO NOTHING"));
|
||||
});
|
||||
|
||||
it("issues a short-lived challenge only for an approved v2 device", async () => {
|
||||
const { challenge, d1 } = await issueChallenge();
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
|
||||
assert.match(challenge.challenge_id, /^[0-9a-f-]{36}$/);
|
||||
assert.match(challenge.challenge, /^elydora-device-rebind-v1\n/);
|
||||
assert.ok(challenge.expires_at - nowSeconds >= 299);
|
||||
assert.ok(challenge.expires_at - nowSeconds <= 300);
|
||||
assert.ok(d1.queries[0]?.includes("key_protocol_version = 2"));
|
||||
assert.ok(d1.queries[1]?.includes("ON CONFLICT(session_id) DO UPDATE"));
|
||||
assert.deepEqual(d1.binds[1]?.slice(1, 4), ["user-01", "session-01", "device-01"]);
|
||||
});
|
||||
|
||||
it("rebinds an unbound session after a valid Ed25519 challenge signature", async () => {
|
||||
const { challenge } = await issueChallenge();
|
||||
const signature = await signDeviceMessage(new TextEncoder().encode(challenge.challenge));
|
||||
const d1 = rebindDatabase(challenge);
|
||||
const response = await rebindRequest(d1, challenge, signature);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), {
|
||||
version: 1,
|
||||
user_id: "user-01",
|
||||
session_id: "session-01",
|
||||
device_id: "device-01",
|
||||
bound_at: d1.binds[1]?.[0],
|
||||
});
|
||||
assert.equal(d1.batches[0], 2);
|
||||
assert.ok(d1.queries[1]?.includes("consumed_at IS NULL"));
|
||||
assert.ok(d1.queries[1]?.includes("session_id = ?"));
|
||||
assert.ok(d1.queries[2]?.includes("ON CONFLICT(session_id) DO NOTHING"));
|
||||
});
|
||||
|
||||
it("rejects invalid signatures without consuming the challenge", async () => {
|
||||
const { challenge } = await issueChallenge();
|
||||
const d1 = rebindDatabase(challenge);
|
||||
const response = await rebindRequest(d1, challenge, "00".repeat(64));
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(await response.json(), { error: "device_rebind_forbidden" });
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("rejects expired and replayed challenges", async () => {
|
||||
const { challenge } = await issueChallenge();
|
||||
const signature = await signDeviceMessage(new TextEncoder().encode(challenge.challenge));
|
||||
const expiredD1 = rebindDatabase({ ...challenge, expires_at: 1 });
|
||||
const expiredResponse = await rebindRequest(expiredD1, challenge, signature);
|
||||
assert.equal(expiredResponse.status, 403);
|
||||
assert.deepEqual(expiredD1.batches, []);
|
||||
|
||||
const replayD1 = rebindDatabase(challenge, [[0, 0]]);
|
||||
const replayResponse = await rebindRequest(replayD1, challenge, signature);
|
||||
assert.equal(replayResponse.status, 409);
|
||||
assert.deepEqual(await replayResponse.json(), { error: "device_rebind_conflict" });
|
||||
});
|
||||
});
|
||||
|
||||
interface ChallengeDocument {
|
||||
challenge_id: string;
|
||||
device_id: string;
|
||||
challenge: string;
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
async function registerRequest(
|
||||
d1: ReturnType<typeof testD1Database>,
|
||||
registration: Record<string, unknown>,
|
||||
): Promise<Response> {
|
||||
return handleRequest(
|
||||
new Request("https://elydora.test/api/devices/register", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(registration),
|
||||
}),
|
||||
testEnv({ d1 }),
|
||||
);
|
||||
}
|
||||
|
||||
async function issueChallenge(): Promise<{
|
||||
challenge: ChallengeDocument;
|
||||
d1: ReturnType<typeof testD1Database>;
|
||||
}> {
|
||||
const d1 = testD1Database({
|
||||
firstRows: [{ signing_public_key: PUBLIC_KEY }],
|
||||
sessionRow: unboundSession(),
|
||||
});
|
||||
const response = await handleRequest(
|
||||
new Request("https://elydora.test/api/devices/rebind/challenge", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ version: 1, device_id: "device-01" }),
|
||||
}),
|
||||
testEnv({ d1 }),
|
||||
);
|
||||
assert.equal(response.status, 201);
|
||||
return { challenge: (await response.json()) as ChallengeDocument, d1 };
|
||||
}
|
||||
|
||||
function rebindDatabase(
|
||||
challenge: ChallengeDocument,
|
||||
batchChanges: number[][] = [[1, 1]],
|
||||
): ReturnType<typeof testD1Database> {
|
||||
return testD1Database({
|
||||
batchChanges,
|
||||
firstRows: [
|
||||
{
|
||||
challenge: challenge.challenge,
|
||||
expires_at: challenge.expires_at,
|
||||
signing_public_key: PUBLIC_KEY,
|
||||
},
|
||||
],
|
||||
sessionRow: unboundSession(),
|
||||
});
|
||||
}
|
||||
|
||||
async function rebindRequest(
|
||||
d1: ReturnType<typeof testD1Database>,
|
||||
challenge: ChallengeDocument,
|
||||
signature: string,
|
||||
): Promise<Response> {
|
||||
return handleRequest(
|
||||
new Request("https://elydora.test/api/devices/rebind", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
version: 1,
|
||||
challenge_id: challenge.challenge_id,
|
||||
device_id: challenge.device_id,
|
||||
signature,
|
||||
}),
|
||||
}),
|
||||
testEnv({ d1 }),
|
||||
);
|
||||
}
|
||||
|
||||
function unboundSession(): Record<string, unknown> {
|
||||
return {
|
||||
id: "session-01",
|
||||
userId: "user-01",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
createdAt: new Date().toISOString(),
|
||||
deviceId: null,
|
||||
};
|
||||
}
|
||||
|
||||
function deviceRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
device_id: "device-01",
|
||||
public_key: PUBLIC_KEY,
|
||||
wrapping_public_key: WRAPPING_PUBLIC_KEY,
|
||||
device_name: "MacBook Pro",
|
||||
platform: "macOS",
|
||||
approval_status: "approved",
|
||||
created_at: 1_780_000_100,
|
||||
approved_at: 1_780_000_100,
|
||||
last_active_at: 1_780_000_100,
|
||||
revoked_at: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { deviceApprovalProofBytes } from "../src/device_approval_proof.js";
|
||||
import { handleRequest } from "../src/index.js";
|
||||
import {
|
||||
ACCESS_TOKEN,
|
||||
PUBLIC_KEY,
|
||||
WRAPPING_PUBLIC_KEY,
|
||||
signDeviceMessage,
|
||||
testD1Database,
|
||||
testEnv,
|
||||
} from "./devices_test_support.js";
|
||||
|
||||
const KEY_ID = "a".repeat(64);
|
||||
const IDEMPOTENCY_KEY = "device-approval-0001";
|
||||
const SUITE = "HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305" as const;
|
||||
|
||||
describe("device approval stored state", () => {
|
||||
it("reports a malformed requester key as a persistence failure", async () => {
|
||||
await assertPersistenceFailure([{ device_id: "device-01", signing_public_key: "invalid" }]);
|
||||
});
|
||||
|
||||
it("reports malformed approval metadata as a persistence failure", async () => {
|
||||
await assertPersistenceFailure([
|
||||
deviceRow(),
|
||||
approvalRow({ device_id: 2 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports an invalid approval status as a persistence failure", async () => {
|
||||
await assertPersistenceFailure([
|
||||
deviceRow(),
|
||||
approvalRow({ status: "corrupt" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats a valid pending row as an idempotency mismatch", async () => {
|
||||
const d1 = testD1Database({
|
||||
firstRows: [deviceRow(), approvalRow({ status: "pending", decided_at: null })],
|
||||
});
|
||||
const response = await approvalRequest(d1);
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(await response.json(), { error: "device_approval_forbidden" });
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("reports malformed device state as a persistence failure", async () => {
|
||||
await assertPersistenceFailure([
|
||||
deviceRow(),
|
||||
null,
|
||||
deviceRow({ device_id: "device-02", approval_status: "pending", created_at: "invalid" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports a missing approved-device key row as a persistence failure", async () => {
|
||||
await assertPersistenceFailure([
|
||||
deviceRow(),
|
||||
approvalRow(),
|
||||
deviceRow({ device_id: "device-02", wrapping_public_key: null }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports malformed envelope state as a persistence failure", async () => {
|
||||
await assertPersistenceFailure([
|
||||
deviceRow(),
|
||||
approvalRow(),
|
||||
deviceRow({ device_id: "device-02" }),
|
||||
approvalEnvelopeRow({ generation: "1" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
async function assertPersistenceFailure(firstRows: unknown[]): Promise<void> {
|
||||
const d1 = testD1Database({ firstRows });
|
||||
const response = await approvalRequest(d1);
|
||||
assert.equal(response.status, 500);
|
||||
assert.deepEqual(await response.json(), { error: "device_approval_failed" });
|
||||
assert.deepEqual(d1.batches, []);
|
||||
}
|
||||
|
||||
async function approvalRequest(d1: ReturnType<typeof testD1Database>): Promise<Response> {
|
||||
return handleRequest(
|
||||
new Request("https://elydora.test/api/devices/approve", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(await approvalBody()),
|
||||
}),
|
||||
testEnv({ d1 }),
|
||||
);
|
||||
}
|
||||
|
||||
async function approvalBody(): Promise<Record<string, unknown>> {
|
||||
const proofCreatedAt = Math.floor(Date.now() / 1000);
|
||||
const envelope = {
|
||||
version: 1 as const,
|
||||
suite: SUITE,
|
||||
encapped_key: "A".repeat(43),
|
||||
ciphertext: "B".repeat(64),
|
||||
};
|
||||
const action = {
|
||||
deviceId: "device-02",
|
||||
keyId: KEY_ID,
|
||||
generation: 1,
|
||||
envelope,
|
||||
idempotencyKey: IDEMPOTENCY_KEY,
|
||||
proofCreatedAt,
|
||||
};
|
||||
return {
|
||||
version: 2,
|
||||
device_id: action.deviceId,
|
||||
key_id: action.keyId,
|
||||
generation: action.generation,
|
||||
envelope,
|
||||
idempotency_key: action.idempotencyKey,
|
||||
proof_created_at: proofCreatedAt,
|
||||
approval_proof: await signDeviceMessage(
|
||||
deviceApprovalProofBytes("user-01", "device-01", action),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function approvalRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
device_id: "device-02",
|
||||
requester_device_id: "device-01",
|
||||
status: "approved",
|
||||
decided_at: 1_780_000_300,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function deviceRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
device_id: "device-01",
|
||||
public_key: PUBLIC_KEY,
|
||||
signing_public_key: PUBLIC_KEY,
|
||||
wrapping_public_key: WRAPPING_PUBLIC_KEY,
|
||||
device_name: "MacBook Pro",
|
||||
platform: "macOS",
|
||||
approval_status: "approved",
|
||||
created_at: 1_780_000_000,
|
||||
approved_at: 1_780_000_010,
|
||||
last_active_at: 1_780_000_020,
|
||||
revoked_at: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function approvalEnvelopeRow(overrides: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
key_id: KEY_ID,
|
||||
generation: 1,
|
||||
recipient_device_id: "device-02",
|
||||
approver_device_id: "device-01",
|
||||
envelope_version: 1,
|
||||
suite: SUITE,
|
||||
encapped_key: "A".repeat(43),
|
||||
ciphertext: "B".repeat(64),
|
||||
idempotency_key: IDEMPOTENCY_KEY,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -2,18 +2,34 @@ import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { authSessionCacheKvKey, authTokenHash } from "../src/auth.js";
|
||||
import { deviceApprovalProofBytes } from "../src/device_approval_proof.js";
|
||||
import { handleRequest } from "../src/index.js";
|
||||
import {
|
||||
ACCESS_TOKEN,
|
||||
PUBLIC_KEY,
|
||||
WRAPPING_PUBLIC_KEY,
|
||||
sessionDocument,
|
||||
signDeviceMessage,
|
||||
testD1Database,
|
||||
testEnv,
|
||||
} from "./devices_test_support.js";
|
||||
|
||||
const DEVICE_APPROVAL_IDEMPOTENCY_KEY = "device-approval-0001";
|
||||
const KEY_ID = "a".repeat(64);
|
||||
const GENERATION = 1;
|
||||
const SUITE = "HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305";
|
||||
const ENCAPPED_KEY = "A".repeat(43);
|
||||
const CIPHERTEXT = "B".repeat(64);
|
||||
|
||||
describe("device approval routes", () => {
|
||||
it("matches the frozen cross-runtime approval proof vector", async () => {
|
||||
const body = await deviceApprovalBody({ proof_created_at: 1_780_000_300 });
|
||||
assert.equal(
|
||||
body.approval_proof,
|
||||
"f12fb7a5f7f20551bd22d0fcf8f5787d49f6202f89e42c332c248772fd9a59c82a9d8b6ac47ea84340170fc1555fc74d70a0d6ba3541df257882d46d6d79d901",
|
||||
);
|
||||
});
|
||||
|
||||
it("approves a pending device from an approved current device", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
@@ -26,6 +42,7 @@ describe("device approval routes", () => {
|
||||
approval_status: "approved",
|
||||
approved_at: 1_780_000_300,
|
||||
}),
|
||||
approvalEnvelopeRow(),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -36,7 +53,7 @@ describe("device approval routes", () => {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(deviceApprovalBody()),
|
||||
body: JSON.stringify(await deviceApprovalBody()),
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
@@ -54,6 +71,7 @@ describe("device approval routes", () => {
|
||||
device: {
|
||||
device_id: "device-02",
|
||||
public_key: PUBLIC_KEY,
|
||||
wrapping_public_key: WRAPPING_PUBLIC_KEY,
|
||||
device_name: "MacBook Pro",
|
||||
platform: "macOS",
|
||||
approval_status: "approved",
|
||||
@@ -64,22 +82,34 @@ describe("device approval routes", () => {
|
||||
current: false,
|
||||
},
|
||||
});
|
||||
assert.equal(d1.batches[0], 2);
|
||||
assert.equal(d1.batches[0], 3);
|
||||
assert.ok(d1.queries[0]?.includes("approval_status = 'approved'"));
|
||||
assert.ok(d1.queries[0]?.includes("key_protocol_version = 2"));
|
||||
assert.ok(d1.queries[1]?.includes("FROM device_approvals"));
|
||||
assert.ok(d1.queries[3]?.includes("INSERT INTO device_approvals"));
|
||||
assert.ok(d1.queries[4]?.includes("UPDATE user_devices"));
|
||||
assert.ok(d1.queries[3]?.includes("INSERT INTO sync_vault_envelopes"));
|
||||
assert.ok(d1.queries[4]?.includes("INSERT INTO device_approvals"));
|
||||
assert.ok(d1.queries[5]?.includes("UPDATE user_devices"));
|
||||
assert.ok(d1.queries[5]?.includes("sync_vault_envelopes"));
|
||||
assert.ok(d1.queries[7]?.includes("current_key_id"));
|
||||
assert.deepEqual(d1.binds[0], ["user-01", "device-01"]);
|
||||
assert.deepEqual(d1.binds[1], ["user-01", DEVICE_APPROVAL_IDEMPOTENCY_KEY]);
|
||||
assert.deepEqual(d1.binds[2], ["user-01", "device-02"]);
|
||||
assert.deepEqual(d1.binds[3]?.slice(0, 4), [
|
||||
assert.deepEqual(d1.binds[3]?.slice(0, 5), [
|
||||
"user-01",
|
||||
"device-02",
|
||||
"device-01",
|
||||
KEY_ID,
|
||||
GENERATION,
|
||||
]);
|
||||
assert.deepEqual(d1.binds[4]?.slice(0, 4), [
|
||||
"user-01",
|
||||
DEVICE_APPROVAL_IDEMPOTENCY_KEY,
|
||||
"device-02",
|
||||
"device-01",
|
||||
]);
|
||||
assert.equal(d1.binds[3]?.[7], DEVICE_APPROVAL_IDEMPOTENCY_KEY);
|
||||
assert.deepEqual(d1.binds[5], ["user-01", "device-02"]);
|
||||
assert.equal(d1.binds[4]?.[7], DEVICE_APPROVAL_IDEMPOTENCY_KEY);
|
||||
assert.deepEqual(d1.binds[6], ["user-01", "device-02"]);
|
||||
assert.deepEqual(d1.binds[7], ["user-01", "device-02"]);
|
||||
});
|
||||
|
||||
it("returns the existing approval for an idempotent replay", async () => {
|
||||
@@ -98,6 +128,7 @@ describe("device approval routes", () => {
|
||||
approval_status: "approved",
|
||||
approved_at: 1_780_000_300,
|
||||
}),
|
||||
approvalEnvelopeRow(),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -108,7 +139,7 @@ describe("device approval routes", () => {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(deviceApprovalBody()),
|
||||
body: JSON.stringify(await deviceApprovalBody()),
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
@@ -120,14 +151,79 @@ describe("device approval routes", () => {
|
||||
const body = (await response.json()) as { approved_at: number };
|
||||
assert.equal(body.approved_at, 1_780_000_300);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
assert.equal(d1.queries.length, 3);
|
||||
assert.equal(d1.queries.length, 4);
|
||||
assert.deepEqual(d1.binds, [
|
||||
["user-01", "device-01"],
|
||||
["user-01", DEVICE_APPROVAL_IDEMPOTENCY_KEY],
|
||||
["user-01", "device-02"],
|
||||
["user-01", "device-02"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects an approval replay with different wrapped key material", async () => {
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
deviceRow({ device_id: "device-01", approval_status: "approved" }),
|
||||
{
|
||||
device_id: "device-02",
|
||||
requester_device_id: "device-01",
|
||||
status: "approved",
|
||||
decided_at: 1_780_000_300,
|
||||
},
|
||||
deviceRow({ device_id: "device-02", approval_status: "approved" }),
|
||||
approvalEnvelopeRow({ ciphertext: "C".repeat(64) }),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await approvalRequest(d1, await deviceApprovalBody());
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(await response.json(), { error: "device_approval_forbidden" });
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("rejects approval replays with a different current key or generation", async () => {
|
||||
for (const override of [{ key_id: "b".repeat(64) }, { generation: 2 }]) {
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
deviceRow({ device_id: "device-01", approval_status: "approved" }),
|
||||
{
|
||||
device_id: "device-02",
|
||||
requester_device_id: "device-01",
|
||||
status: "approved",
|
||||
decided_at: 1_780_000_300,
|
||||
},
|
||||
deviceRow({ device_id: "device-02", approval_status: "approved" }),
|
||||
approvalEnvelopeRow(),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await approvalRequest(d1, await deviceApprovalBody(override));
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(await response.json(), { error: "device_approval_forbidden" });
|
||||
assert.deepEqual(d1.batches, []);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the target pending when the current vault envelope cannot be written", async () => {
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
deviceRow({ device_id: "device-01", approval_status: "approved" }),
|
||||
null,
|
||||
deviceRow({ device_id: "device-02", approval_status: "pending", approved_at: null }),
|
||||
deviceRow({ device_id: "device-02", approval_status: "pending", approved_at: null }),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await approvalRequest(d1, await deviceApprovalBody());
|
||||
|
||||
assert.equal(response.status, 409);
|
||||
assert.deepEqual(await response.json(), { error: "device_approval_conflict" });
|
||||
assert.ok(d1.queries[4]?.includes("WHERE EXISTS"));
|
||||
assert.ok(d1.queries[5]?.includes("AND EXISTS"));
|
||||
});
|
||||
|
||||
it("rejects approval from a current device that is not approved", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({ firstRows: [null] });
|
||||
@@ -138,7 +234,7 @@ describe("device approval routes", () => {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(deviceApprovalBody()),
|
||||
body: JSON.stringify(await deviceApprovalBody()),
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
@@ -151,6 +247,38 @@ describe("device approval routes", () => {
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("rejects a tampered current-device proof before approval state reads", async () => {
|
||||
const d1 = testD1Database({
|
||||
firstRows: [deviceRow({ device_id: "device-01", approval_status: "approved" })],
|
||||
});
|
||||
const body = await deviceApprovalBody();
|
||||
body.approval_proof = "0".repeat(128);
|
||||
const response = await approvalRequest(d1, body);
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(await response.json(), { error: "device_approval_forbidden" });
|
||||
assert.deepEqual(d1.batches, []);
|
||||
assert.equal(d1.queries.length, 1);
|
||||
});
|
||||
|
||||
it("requires a recent proof for a new approval", async () => {
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
deviceRow({ device_id: "device-01", approval_status: "approved" }),
|
||||
null,
|
||||
],
|
||||
});
|
||||
const response = await approvalRequest(
|
||||
d1,
|
||||
await deviceApprovalBody({ proof_created_at: 1_700_000_000 }),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(await response.json(), { error: "device_approval_forbidden" });
|
||||
assert.deepEqual(d1.batches, []);
|
||||
assert.equal(d1.queries.length, 2);
|
||||
});
|
||||
|
||||
it("rejects self approval before D1 writes", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database([]);
|
||||
@@ -161,7 +289,7 @@ describe("device approval routes", () => {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ ...deviceApprovalBody(), device_id: "device-01" }),
|
||||
body: JSON.stringify(await deviceApprovalBody({ device_id: "device-01" })),
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
@@ -183,7 +311,7 @@ describe("device approval routes", () => {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ ...deviceApprovalBody(), idempotency_key: "short" }),
|
||||
body: JSON.stringify(await deviceApprovalBody({ idempotency_key: "short" })),
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
@@ -202,7 +330,7 @@ describe("device approval routes", () => {
|
||||
new Request("https://elydora.test/api/devices/approve", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(deviceApprovalBody()),
|
||||
body: JSON.stringify(await deviceApprovalBody()),
|
||||
}),
|
||||
testEnv({ d1 }),
|
||||
);
|
||||
@@ -213,18 +341,46 @@ describe("device approval routes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function deviceApprovalBody(): Record<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
async function deviceApprovalBody(
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const body: Record<string, unknown> = {
|
||||
version: 2,
|
||||
device_id: "device-02",
|
||||
key_id: KEY_ID,
|
||||
generation: GENERATION,
|
||||
envelope: wrappedEnvelope(),
|
||||
idempotency_key: DEVICE_APPROVAL_IDEMPOTENCY_KEY,
|
||||
proof_created_at: Math.floor(Date.now() / 1000),
|
||||
...overrides,
|
||||
};
|
||||
const envelope = body.envelope as {
|
||||
version: 1;
|
||||
suite: typeof SUITE;
|
||||
encapped_key: string;
|
||||
ciphertext: string;
|
||||
};
|
||||
body.approval_proof = await signDeviceMessage(deviceApprovalProofBytes(
|
||||
"user-01",
|
||||
"device-01",
|
||||
{
|
||||
deviceId: String(body.device_id),
|
||||
keyId: String(body.key_id),
|
||||
generation: Number(body.generation),
|
||||
envelope,
|
||||
idempotencyKey: String(body.idempotency_key),
|
||||
proofCreatedAt: Number(body.proof_created_at),
|
||||
},
|
||||
));
|
||||
return body;
|
||||
}
|
||||
|
||||
function deviceRow(overrides: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
device_id: "device-01",
|
||||
public_key: PUBLIC_KEY,
|
||||
signing_public_key: PUBLIC_KEY,
|
||||
wrapping_public_key: WRAPPING_PUBLIC_KEY,
|
||||
device_name: "MacBook Pro",
|
||||
platform: "macOS",
|
||||
approval_status: "approved",
|
||||
@@ -235,3 +391,44 @@ function deviceRow(overrides: Record<string, unknown>): Record<string, unknown>
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function wrappedEnvelope(): Record<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
suite: SUITE,
|
||||
encapped_key: ENCAPPED_KEY,
|
||||
ciphertext: CIPHERTEXT,
|
||||
};
|
||||
}
|
||||
|
||||
function approvalEnvelopeRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
key_id: KEY_ID,
|
||||
generation: GENERATION,
|
||||
recipient_device_id: "device-02",
|
||||
approver_device_id: "device-01",
|
||||
envelope_version: 1,
|
||||
suite: SUITE,
|
||||
encapped_key: ENCAPPED_KEY,
|
||||
ciphertext: CIPHERTEXT,
|
||||
idempotency_key: DEVICE_APPROVAL_IDEMPOTENCY_KEY,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function approvalRequest(
|
||||
d1: ReturnType<typeof testD1Database>,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<Response> {
|
||||
return handleRequest(
|
||||
new Request("https://elydora.test/api/devices/approve", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
testEnv({ d1 }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,229 +1,452 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { authSessionCacheKvKey, authTokenHash } from "../src/auth.js";
|
||||
import {
|
||||
deviceRevocationRequest,
|
||||
deviceRevocationRequestHash,
|
||||
deviceRevocationProofBytes,
|
||||
pendingDeviceRevocationProofBytes,
|
||||
pendingDeviceRevocationRequestHash,
|
||||
} from "../src/device_revocation_schema.js";
|
||||
import { handleRequest } from "../src/index.js";
|
||||
import {
|
||||
ACCESS_TOKEN,
|
||||
PUBLIC_KEY,
|
||||
WRAPPING_PUBLIC_KEY,
|
||||
sessionDocument,
|
||||
signDeviceMessage,
|
||||
testD1Database,
|
||||
testEnv,
|
||||
} from "./devices_test_support.js";
|
||||
|
||||
const DEVICE_REVOCATION_IDEMPOTENCY_KEY = "device-revocation-0001";
|
||||
const DEVICE_REVOCATION_EVENT_ID = `device-revoke:user-01:${DEVICE_REVOCATION_IDEMPOTENCY_KEY}`;
|
||||
|
||||
const USER_ID = "user-01", APPROVER_DEVICE_ID = "device-01";
|
||||
const TARGET_DEVICE_ID = "device-02", OTHER_DEVICE_ID = "device-03";
|
||||
const IDEMPOTENCY_KEY = "device-revocation-0001";
|
||||
const PREVIOUS_KEY_ID = "a".repeat(64), NEW_KEY_ID = "b".repeat(64);
|
||||
const PREVIOUS_GENERATION = 1, NEW_GENERATION = 2;
|
||||
const SUITE = "HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305";
|
||||
describe("device revocation routes", () => {
|
||||
it("revokes a device from an approved current device", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
it("atomically rotates the vault and revokes an approved device", async () => {
|
||||
const body = deviceRevocationBody();
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
deviceRow({ device_id: "device-01", approval_status: "approved" }),
|
||||
approverRow(),
|
||||
null,
|
||||
deviceRow({ device_id: "device-02", approval_status: "approved" }),
|
||||
deviceRow({
|
||||
device_id: "device-02",
|
||||
approval_status: "revoked",
|
||||
revoked_at: 1_780_000_400,
|
||||
}),
|
||||
currentVaultKeyRow(),
|
||||
deviceRow({ device_id: TARGET_DEVICE_ID }),
|
||||
await rotationResultRow(body, { r2_object_count: 2, r2_item_count: 2 }),
|
||||
deviceRow({ device_id: TARGET_DEVICE_ID, approval_status: "revoked", revoked_at: 1_780_000_400 }),
|
||||
],
|
||||
allRowSets: [
|
||||
[{ device_id: APPROVER_DEVICE_ID }, { device_id: OTHER_DEVICE_ID }],
|
||||
[{ object_count: 2 }],
|
||||
],
|
||||
batchChanges: [[1, 1, 1, 1]],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
new Request("https://elydora.test/api/devices/revoke", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(deviceRevocationBody()),
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]],
|
||||
}),
|
||||
);
|
||||
const response = await revocationResponse(d1, body);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("cache-control"), "no-store");
|
||||
assert.deepEqual(await response.json(), {
|
||||
version: 1,
|
||||
user_id: "user-01",
|
||||
revoked_by_device_id: "device-01",
|
||||
revoked_at: 1_780_000_400,
|
||||
device: {
|
||||
device_id: "device-02",
|
||||
public_key: PUBLIC_KEY,
|
||||
device_name: "MacBook Pro",
|
||||
platform: "macOS",
|
||||
approval_status: "revoked",
|
||||
created_at: 1_780_000_000,
|
||||
approved_at: 1_780_000_010,
|
||||
last_active_at: 1_780_000_020,
|
||||
revoked_at: 1_780_000_400,
|
||||
current: false,
|
||||
},
|
||||
});
|
||||
assert.equal(d1.batches[0], 2);
|
||||
assert.ok(d1.queries[0]?.includes("approval_status = 'approved'"));
|
||||
assert.ok(d1.queries[1]?.includes("FROM audit_events"));
|
||||
assert.ok(d1.queries[3]?.includes("INSERT INTO audit_events"));
|
||||
assert.ok(d1.queries[4]?.includes("UPDATE user_devices"));
|
||||
assert.deepEqual(d1.binds[0], ["user-01", "device-01"]);
|
||||
assert.deepEqual(d1.binds[1], ["user-01", DEVICE_REVOCATION_EVENT_ID]);
|
||||
assert.deepEqual(d1.binds[2], ["user-01", "device-02"]);
|
||||
assert.deepEqual(d1.binds[3]?.slice(0, 4), [
|
||||
DEVICE_REVOCATION_EVENT_ID,
|
||||
"user-01",
|
||||
"device-01",
|
||||
"device-02",
|
||||
]);
|
||||
assert.deepEqual(d1.binds[5], ["user-01", "device-02"]);
|
||||
assert.deepEqual(await response.json(), revocationDocument());
|
||||
assert.deepEqual(d1.batches, [4]);
|
||||
assert.ok(d1.queries[6]?.includes("INSERT INTO sync_vault_rotations"));
|
||||
assert.ok(d1.queries[7]?.includes("INSERT INTO sync_vault_rotation_envelopes"));
|
||||
assert.ok(d1.queries[5]?.includes("COUNT(*) AS object_count"));
|
||||
assert.ok(d1.queries[9]?.includes("SET completed_at = ?"));
|
||||
assert.deepEqual(d1.binds[6]?.slice(0, 2), [USER_ID, IDEMPOTENCY_KEY]);
|
||||
assert.match(String(d1.binds[6]?.[2]), /^device-revoke:[a-f0-9]{64}$/);
|
||||
assert.deepEqual(d1.binds[6]?.slice(3, 5), [TARGET_DEVICE_ID, APPROVER_DEVICE_ID]);
|
||||
});
|
||||
|
||||
it("returns the existing revocation for an idempotent replay", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
it("revokes a pending target without rotating or cleaning the vault", async () => {
|
||||
const body = pendingRevocationBody();
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
deviceRow({ device_id: "device-01", approval_status: "approved" }),
|
||||
{
|
||||
actor_device_id: "device-01",
|
||||
subject_id: "device-02",
|
||||
outcome: "success",
|
||||
created_at: 1_780_000_400,
|
||||
},
|
||||
deviceRow({
|
||||
device_id: "device-02",
|
||||
approval_status: "revoked",
|
||||
revoked_at: 1_780_000_400,
|
||||
}),
|
||||
approverRow(),
|
||||
null,
|
||||
deviceRow({ approval_status: "pending", approved_at: null }),
|
||||
await pendingResultRow(body),
|
||||
deviceRow({ approval_status: "revoked", approved_at: null, revoked_at: 1_780_000_400 }),
|
||||
],
|
||||
batchChanges: [[1, 1]],
|
||||
});
|
||||
|
||||
const response = await revocationResponse(d1, body);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), pendingRevocationDocument());
|
||||
assert.deepEqual(d1.batches, [2]);
|
||||
assert.ok(d1.queries[3]?.includes("INSERT INTO pending_device_revocations"));
|
||||
assert.ok(d1.queries[4]?.includes("SET completed_at = ?"));
|
||||
assert.equal(d1.queries.some((query) => query.includes("sync_vault_accounts")), false);
|
||||
});
|
||||
|
||||
it("returns an exact pending revocation replay and rejects mismatches", async () => {
|
||||
const body = pendingRevocationBody();
|
||||
const replay = testD1Database({
|
||||
firstRows: [
|
||||
approverRow(),
|
||||
await pendingResultRow(body),
|
||||
deviceRow({ approval_status: "revoked", approved_at: null, revoked_at: 1_780_000_400 }),
|
||||
],
|
||||
});
|
||||
assert.equal((await revocationResponse(replay, body)).status, 200);
|
||||
assert.deepEqual(replay.batches, []);
|
||||
|
||||
const mismatch = testD1Database({
|
||||
firstRows: [approverRow(), { ...await pendingResultRow(body), request_hash: "f".repeat(64) }],
|
||||
});
|
||||
assert.equal((await revocationResponse(mismatch, body)).status, 409);
|
||||
assert.deepEqual(mismatch.batches, []);
|
||||
});
|
||||
|
||||
it("rejects pending mode for an approved target and trigger races", async () => {
|
||||
const body = pendingRevocationBody();
|
||||
const approved = testD1Database({
|
||||
firstRows: [approverRow(), null, deviceRow()],
|
||||
});
|
||||
assert.equal((await revocationResponse(approved, body)).status, 409);
|
||||
assert.deepEqual(approved.batches, []);
|
||||
|
||||
const race = testD1Database({
|
||||
firstRows: [approverRow(), null, deviceRow({ approval_status: "pending", approved_at: null })],
|
||||
batchError: new Error("pending_device_revocation_guard_failed"),
|
||||
});
|
||||
assert.equal((await revocationResponse(race, body)).status, 409);
|
||||
});
|
||||
|
||||
it("returns an exact idempotent replay", async () => {
|
||||
const body = deviceRevocationBody();
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
approverRow(),
|
||||
await rotationResultRow(body),
|
||||
deviceRow({ device_id: TARGET_DEVICE_ID, approval_status: "revoked", revoked_at: 1_780_000_400 }),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
new Request("https://elydora.test/api/devices/revoke", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(deviceRevocationBody()),
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]],
|
||||
}),
|
||||
);
|
||||
const response = await revocationResponse(d1, body);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const body = (await response.json()) as { revoked_at: number };
|
||||
assert.equal(body.revoked_at, 1_780_000_400);
|
||||
assert.deepEqual(await response.json(), revocationDocument());
|
||||
assert.deepEqual(d1.batches, []);
|
||||
assert.deepEqual(d1.binds, [
|
||||
["user-01", "device-01"],
|
||||
["user-01", DEVICE_REVOCATION_EVENT_ID],
|
||||
["user-01", "device-02"],
|
||||
]);
|
||||
assert.equal(d1.queries.length, 3);
|
||||
});
|
||||
|
||||
it("rejects revocation from a current device that is not approved", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({ firstRows: [null] });
|
||||
const response = await handleRequest(
|
||||
new Request("https://elydora.test/api/devices/revoke", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(deviceRevocationBody()),
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]],
|
||||
}),
|
||||
);
|
||||
it("rejects a replay with different rotation metadata", async () => {
|
||||
const body = deviceRevocationBody();
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
approverRow(),
|
||||
await rotationResultRow(body, { new_key_id: "c".repeat(64) }),
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(await response.json(), { error: "device_revocation_forbidden" });
|
||||
const response = await revocationResponse(d1, body);
|
||||
|
||||
assert.equal(response.status, 409);
|
||||
assert.deepEqual(await response.json(), { error: "device_revocation_conflict" });
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("rejects self revocation before D1 writes", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database([]);
|
||||
const response = await handleRequest(
|
||||
new Request("https://elydora.test/api/devices/revoke", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ ...deviceRevocationBody(), device_id: "device-01" }),
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(d1.queries, []);
|
||||
it("rejects missing and extra recipient envelopes", async () => {
|
||||
const cases = [
|
||||
{
|
||||
body: deviceRevocationBody({ envelopes: [rotationEnvelope(APPROVER_DEVICE_ID)] }),
|
||||
rows: [{ device_id: APPROVER_DEVICE_ID }, { device_id: OTHER_DEVICE_ID }],
|
||||
},
|
||||
{
|
||||
body: deviceRevocationBody(),
|
||||
rows: [{ device_id: APPROVER_DEVICE_ID }],
|
||||
},
|
||||
];
|
||||
for (const testCase of cases) {
|
||||
const d1 = preflightD1(testCase.rows);
|
||||
const response = await revocationResponse(d1, testCase.body);
|
||||
assert.equal(response.status, 409);
|
||||
assert.deepEqual(await response.json(), { error: "device_revocation_conflict" });
|
||||
assert.deepEqual(d1.batches, []);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid revocation payloads before D1 writes", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database([]);
|
||||
const response = await handleRequest(
|
||||
new Request("https://elydora.test/api/devices/revoke", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ ...deviceRevocationBody(), idempotency_key: "short" }),
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]],
|
||||
}),
|
||||
);
|
||||
it("rejects stale vault metadata before target reads", async () => {
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
approverRow(),
|
||||
null,
|
||||
{ key_id: "c".repeat(64), generation: PREVIOUS_GENERATION },
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.deepEqual(await response.json(), { error: "invalid_device_revocation" });
|
||||
assert.deepEqual(d1.queries, []);
|
||||
const response = await revocationResponse(d1, deviceRevocationBody());
|
||||
|
||||
assert.equal(response.status, 409);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
assert.equal(d1.queries.length, 3);
|
||||
});
|
||||
|
||||
it("rejects unauthenticated device revocation before D1 writes", async () => {
|
||||
const d1 = testD1Database([]);
|
||||
const response = await handleRequest(
|
||||
new Request("https://elydora.test/api/devices/revoke", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(deviceRevocationBody()),
|
||||
}),
|
||||
testEnv({ d1 }),
|
||||
);
|
||||
it("fails a rotation race when the D1 guard aborts", async () => {
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
approverRow(),
|
||||
null,
|
||||
currentVaultKeyRow(),
|
||||
deviceRow({ device_id: TARGET_DEVICE_ID }),
|
||||
],
|
||||
allRowSets: [
|
||||
[{ device_id: APPROVER_DEVICE_ID }, { device_id: OTHER_DEVICE_ID }],
|
||||
[{ object_count: 0 }],
|
||||
],
|
||||
batchError: new Error("sync_vault_rotation_guard_failed"),
|
||||
});
|
||||
|
||||
const response = await revocationResponse(d1, deviceRevocationBody());
|
||||
|
||||
assert.equal(response.status, 409);
|
||||
assert.deepEqual(await response.json(), { error: "device_revocation_conflict" });
|
||||
});
|
||||
|
||||
it("fails closed when a zero-change finalize has no exact completed replay", async () => {
|
||||
const body = deviceRevocationBody();
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
approverRow(),
|
||||
null,
|
||||
currentVaultKeyRow(),
|
||||
deviceRow({ device_id: TARGET_DEVICE_ID }),
|
||||
null,
|
||||
],
|
||||
allRowSets: [
|
||||
[{ device_id: APPROVER_DEVICE_ID }, { device_id: OTHER_DEVICE_ID }],
|
||||
[{ object_count: 0 }],
|
||||
],
|
||||
batchChanges: [[1, 1, 1, 0]],
|
||||
});
|
||||
|
||||
const response = await revocationResponse(d1, body);
|
||||
|
||||
assert.equal(response.status, 409);
|
||||
assert.deepEqual(d1.batches, [4]);
|
||||
});
|
||||
|
||||
it("accepts a zero-change finalize that resolves to the exact concurrent replay", async () => {
|
||||
const body = deviceRevocationBody();
|
||||
const d1 = successfulD1(body, "approved", [[1, 1, 1, 0]]);
|
||||
|
||||
const response = await revocationResponse(d1, body);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), revocationDocument());
|
||||
});
|
||||
|
||||
it("rejects unapproved, self, and unauthenticated revocation", async () => {
|
||||
const unapproved = testD1Database({ firstRows: [null] });
|
||||
assert.equal((await revocationResponse(unapproved, deviceRevocationBody())).status, 403);
|
||||
|
||||
const self = testD1Database([]);
|
||||
const selfBody = deviceRevocationBody({
|
||||
device_id: APPROVER_DEVICE_ID,
|
||||
envelopes: [rotationEnvelope(OTHER_DEVICE_ID)],
|
||||
});
|
||||
assert.equal((await revocationResponse(self, selfBody)).status, 403);
|
||||
assert.deepEqual(self.queries, []);
|
||||
|
||||
const anonymous = testD1Database([]);
|
||||
const response = await handleRequest(revocationRequest(deviceRevocationBody(), false), testEnv({ d1: anonymous }));
|
||||
assert.equal(response.status, 401);
|
||||
assert.deepEqual(await response.json(), { error: "authorization_missing" });
|
||||
assert.deepEqual(d1.queries, []);
|
||||
assert.deepEqual(anonymous.queries, []);
|
||||
});
|
||||
});
|
||||
|
||||
function deviceRevocationBody(): Record<string, unknown> {
|
||||
function successfulD1(
|
||||
body: Record<string, unknown>,
|
||||
targetStatus: "pending" | "approved",
|
||||
batchChanges: number[][] = [[1, 1, 1, 1]],
|
||||
): ReturnType<typeof testD1Database> {
|
||||
return testD1Database({
|
||||
firstRows: [
|
||||
approverRow(),
|
||||
null,
|
||||
currentVaultKeyRow(),
|
||||
deviceRow({ device_id: TARGET_DEVICE_ID, approval_status: targetStatus }),
|
||||
rotationResultRow(body),
|
||||
deviceRow({ device_id: TARGET_DEVICE_ID, approval_status: "revoked", revoked_at: 1_780_000_400 }),
|
||||
],
|
||||
allRowSets: [
|
||||
[{ device_id: APPROVER_DEVICE_ID }, { device_id: OTHER_DEVICE_ID }],
|
||||
[{ object_count: 0 }],
|
||||
],
|
||||
batchChanges,
|
||||
});
|
||||
}
|
||||
|
||||
function preflightD1(recipientRows: Record<string, unknown>[]): ReturnType<typeof testD1Database> {
|
||||
return testD1Database({
|
||||
firstRows: [
|
||||
approverRow(),
|
||||
null,
|
||||
currentVaultKeyRow(),
|
||||
deviceRow({ device_id: TARGET_DEVICE_ID }),
|
||||
],
|
||||
allRowSets: [recipientRows, [{ object_count: 0 }]],
|
||||
});
|
||||
}
|
||||
|
||||
function revocationResponse(
|
||||
d1: ReturnType<typeof testD1Database>,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<Response> {
|
||||
return signedRevocationBody(body).then((signedBody) =>
|
||||
handleRequest(revocationRequest(signedBody), testEnv({ d1 })),
|
||||
);
|
||||
}
|
||||
|
||||
function revocationRequest(body: Record<string, unknown>, authenticated = true): Request {
|
||||
return new Request("https://elydora.test/api/devices/revoke", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...(authenticated ? { authorization: `Bearer ${ACCESS_TOKEN}` } : {}),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function deviceRevocationBody(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
device_id: "device-02",
|
||||
idempotency_key: DEVICE_REVOCATION_IDEMPOTENCY_KEY,
|
||||
version: 2,
|
||||
mode: "approved_rotate",
|
||||
device_id: TARGET_DEVICE_ID,
|
||||
previous_key_id: PREVIOUS_KEY_ID,
|
||||
previous_generation: PREVIOUS_GENERATION,
|
||||
new_key_id: NEW_KEY_ID,
|
||||
new_generation: NEW_GENERATION,
|
||||
envelopes: [rotationEnvelope(OTHER_DEVICE_ID), rotationEnvelope(APPROVER_DEVICE_ID)],
|
||||
idempotency_key: IDEMPOTENCY_KEY,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function deviceRow(overrides: Record<string, unknown>): Record<string, unknown> {
|
||||
function pendingRevocationBody(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
device_id: "device-01",
|
||||
version: 2,
|
||||
mode: "pending_revoke",
|
||||
device_id: TARGET_DEVICE_ID,
|
||||
idempotency_key: IDEMPOTENCY_KEY,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function rotationEnvelope(
|
||||
recipientDeviceId: string,
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Record<string, unknown> {
|
||||
const other = recipientDeviceId === OTHER_DEVICE_ID;
|
||||
return {
|
||||
recipient_device_id: recipientDeviceId,
|
||||
envelope: {
|
||||
version: 1,
|
||||
suite: SUITE,
|
||||
encapped_key: other ? `${"C".repeat(42)}E` : "A".repeat(43),
|
||||
ciphertext: other ? "D".repeat(64) : "B".repeat(64),
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function currentVaultKeyRow(): Record<string, unknown> { return { key_id: PREVIOUS_KEY_ID, generation: PREVIOUS_GENERATION }; }
|
||||
|
||||
async function rotationResultRow(
|
||||
body: Record<string, unknown>,
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const parsed = await deviceRevocationRequest(revocationRequest(await signedRevocationBody(body)));
|
||||
if (parsed.mode !== "approved_rotate") throw new Error("approved rotation expected");
|
||||
const requestHash = await deviceRevocationRequestHash(USER_ID, APPROVER_DEVICE_ID, parsed);
|
||||
return {
|
||||
target_device_id: TARGET_DEVICE_ID,
|
||||
approver_device_id: APPROVER_DEVICE_ID,
|
||||
previous_key_id: PREVIOUS_KEY_ID,
|
||||
previous_generation: PREVIOUS_GENERATION,
|
||||
new_key_id: NEW_KEY_ID,
|
||||
new_generation: NEW_GENERATION,
|
||||
request_hash: requestHash,
|
||||
envelope_count: 2,
|
||||
r2_object_count: 0,
|
||||
completed_at: 1_780_000_400,
|
||||
current_key_id: NEW_KEY_ID,
|
||||
current_generation: NEW_GENERATION,
|
||||
target_status: "revoked",
|
||||
revoked_at: 1_780_000_400,
|
||||
active_session_count: 0,
|
||||
item_count: 2,
|
||||
r2_item_count: 0,
|
||||
persisted_count: 2,
|
||||
audit_count: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function pendingResultRow(body: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
const parsed = await deviceRevocationRequest(revocationRequest(await signedRevocationBody(body)));
|
||||
if (parsed.mode !== "pending_revoke") throw new Error("pending revocation expected");
|
||||
return {
|
||||
target_device_id: TARGET_DEVICE_ID,
|
||||
approver_device_id: APPROVER_DEVICE_ID,
|
||||
request_hash: await pendingDeviceRevocationRequestHash(USER_ID, APPROVER_DEVICE_ID, parsed),
|
||||
completed_at: 1_780_000_400,
|
||||
target_status: "revoked",
|
||||
revoked_at: 1_780_000_400,
|
||||
active_session_count: 0,
|
||||
audit_count: 1,
|
||||
};
|
||||
}
|
||||
|
||||
async function signedRevocationBody(
|
||||
body: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (body.mode === "pending_revoke") {
|
||||
if (body.pending_revocation_proof !== undefined) return body;
|
||||
const draft = { ...body, pending_revocation_proof: "0".repeat(128) };
|
||||
try {
|
||||
const parsed = await deviceRevocationRequest(revocationRequest(draft));
|
||||
if (parsed.mode !== "pending_revoke") return draft;
|
||||
const { pendingRevocationProof: _, ...unsigned } = parsed;
|
||||
return {
|
||||
...body,
|
||||
pending_revocation_proof: await signDeviceMessage(
|
||||
pendingDeviceRevocationProofBytes(USER_ID, APPROVER_DEVICE_ID, unsigned),
|
||||
),
|
||||
};
|
||||
} catch {
|
||||
return draft;
|
||||
}
|
||||
}
|
||||
if (body.rotation_proof !== undefined) return body;
|
||||
const draft = { ...body, rotation_proof: "0".repeat(128) };
|
||||
try {
|
||||
const parsed = await deviceRevocationRequest(revocationRequest(draft));
|
||||
if (parsed.mode !== "approved_rotate") return draft;
|
||||
const { rotationProof: _, ...unsigned } = parsed;
|
||||
return {
|
||||
...body,
|
||||
rotation_proof: await signDeviceMessage(
|
||||
deviceRevocationProofBytes(USER_ID, APPROVER_DEVICE_ID, unsigned),
|
||||
),
|
||||
};
|
||||
} catch {
|
||||
return draft;
|
||||
}
|
||||
}
|
||||
|
||||
function approverRow(): Record<string, unknown> { return { device_id: APPROVER_DEVICE_ID, signing_public_key: PUBLIC_KEY }; }
|
||||
|
||||
function deviceRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
device_id: TARGET_DEVICE_ID,
|
||||
public_key: PUBLIC_KEY,
|
||||
wrapping_public_key: WRAPPING_PUBLIC_KEY,
|
||||
device_name: "MacBook Pro",
|
||||
platform: "macOS",
|
||||
approval_status: "approved",
|
||||
@@ -234,3 +457,43 @@ function deviceRow(overrides: Record<string, unknown>): Record<string, unknown>
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function revocationDocument(): Record<string, unknown> {
|
||||
return {
|
||||
version: 2,
|
||||
mode: "approved_rotate",
|
||||
user_id: USER_ID,
|
||||
revoked_by_device_id: APPROVER_DEVICE_ID,
|
||||
revoked_at: 1_780_000_400,
|
||||
key_id: NEW_KEY_ID,
|
||||
generation: NEW_GENERATION,
|
||||
device: {
|
||||
device_id: TARGET_DEVICE_ID,
|
||||
public_key: PUBLIC_KEY,
|
||||
wrapping_public_key: WRAPPING_PUBLIC_KEY,
|
||||
device_name: "MacBook Pro",
|
||||
platform: "macOS",
|
||||
approval_status: "revoked",
|
||||
created_at: 1_780_000_000,
|
||||
approved_at: 1_780_000_010,
|
||||
last_active_at: 1_780_000_020,
|
||||
revoked_at: 1_780_000_400,
|
||||
current: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pendingRevocationDocument(): Record<string, unknown> {
|
||||
const document = revocationDocument();
|
||||
delete document.key_id;
|
||||
delete document.generation;
|
||||
return {
|
||||
...document,
|
||||
mode: "pending_revoke",
|
||||
device: {
|
||||
...(document.device as Record<string, unknown>),
|
||||
approval_status: "revoked",
|
||||
approved_at: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { handleRequest } from "../src/index.js";
|
||||
import {
|
||||
ACCESS_TOKEN,
|
||||
PUBLIC_KEY,
|
||||
WRAPPING_PUBLIC_KEY,
|
||||
deviceRegistrationBody,
|
||||
sessionDocument,
|
||||
testD1Database,
|
||||
testEnv,
|
||||
@@ -155,19 +157,20 @@ describe("device routes", () => {
|
||||
assert.deepEqual(await response.json(), { error: "devices_invalid" });
|
||||
});
|
||||
|
||||
it("registers the current device as a pending idempotent D1 write", async () => {
|
||||
it("registers the first current device as an approved idempotent D1 write", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const sessionCacheKey = authSessionCacheKvKey("local", tokenHash);
|
||||
const kvPuts: [string, string][] = [];
|
||||
const d1 = testD1Database([
|
||||
{
|
||||
device_id: "device-01",
|
||||
public_key: PUBLIC_KEY.toUpperCase(),
|
||||
public_key: PUBLIC_KEY,
|
||||
wrapping_public_key: WRAPPING_PUBLIC_KEY,
|
||||
device_name: "MacBook Pro",
|
||||
platform: "macOS",
|
||||
approval_status: "pending",
|
||||
approval_status: "approved",
|
||||
created_at: 1_780_000_100,
|
||||
approved_at: null,
|
||||
approved_at: 1_780_000_100,
|
||||
last_active_at: 1_780_000_100,
|
||||
revoked_at: null,
|
||||
},
|
||||
@@ -180,7 +183,7 @@ describe("device routes", () => {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(deviceRegistrationBody()),
|
||||
body: JSON.stringify(await deviceRegistrationBody()),
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
@@ -192,37 +195,49 @@ describe("device routes", () => {
|
||||
assert.equal(response.status, 201);
|
||||
assert.equal(response.headers.get("cache-control"), "no-store");
|
||||
assert.deepEqual(await response.json(), {
|
||||
version: 1,
|
||||
version: 2,
|
||||
user_id: "user-01",
|
||||
device: {
|
||||
device_id: "device-01",
|
||||
public_key: PUBLIC_KEY,
|
||||
wrapping_public_key: WRAPPING_PUBLIC_KEY,
|
||||
device_name: "MacBook Pro",
|
||||
platform: "macOS",
|
||||
approval_status: "pending",
|
||||
approval_status: "approved",
|
||||
created_at: 1_780_000_100,
|
||||
approved_at: null,
|
||||
approved_at: 1_780_000_100,
|
||||
last_active_at: 1_780_000_100,
|
||||
revoked_at: null,
|
||||
current: true,
|
||||
},
|
||||
});
|
||||
assert.ok(d1.queries[0]?.includes("INSERT INTO user_devices"));
|
||||
assert.ok(d1.queries[0]?.includes("NOT EXISTS"));
|
||||
assert.ok(d1.queries[0]?.includes("ON CONFLICT DO NOTHING"));
|
||||
assert.ok(d1.queries[1]?.includes("WHERE user_id = ? AND idempotency_key = ?"));
|
||||
assert.ok(d1.queries[2]?.includes("better_auth_session_device_context"));
|
||||
assert.deepEqual(d1.binds[0]?.slice(0, 5), [
|
||||
assert.ok(d1.queries[1]?.includes("INSERT INTO user_device_keys"));
|
||||
assert.ok(d1.queries[2]?.includes("idempotency_key = ?"));
|
||||
assert.ok(d1.queries[3]?.includes("better_auth_session_device_context"));
|
||||
assert.deepEqual(d1.binds[0]?.slice(0, 6), [
|
||||
"user-01",
|
||||
"user-01",
|
||||
"device-01",
|
||||
PUBLIC_KEY,
|
||||
"MacBook Pro",
|
||||
"macOS",
|
||||
]);
|
||||
assert.equal(typeof d1.binds[0]?.[5], "number");
|
||||
assert.equal(typeof d1.binds[0]?.[6], "number");
|
||||
assert.equal(d1.binds[0]?.[7], IDEMPOTENCY_KEY);
|
||||
assert.deepEqual(d1.binds[1], ["user-01", IDEMPOTENCY_KEY]);
|
||||
assert.deepEqual(d1.binds[2]?.slice(0, 3), ["session-01", "user-01", "device-01"]);
|
||||
assert.equal(typeof d1.binds[0]?.[7], "number");
|
||||
assert.equal(typeof d1.binds[0]?.[8], "number");
|
||||
assert.equal(d1.binds[0]?.[9], IDEMPOTENCY_KEY);
|
||||
assert.deepEqual(d1.binds[1]?.slice(0, 5), [
|
||||
"user-01",
|
||||
"device-01",
|
||||
PUBLIC_KEY,
|
||||
WRAPPING_PUBLIC_KEY,
|
||||
d1.binds[0]?.[6],
|
||||
]);
|
||||
assert.deepEqual(d1.binds[2], ["user-01", IDEMPOTENCY_KEY]);
|
||||
assert.deepEqual(d1.binds[3]?.slice(0, 3), ["session-01", "user-01", "device-01"]);
|
||||
assert.deepEqual(kvPuts, []);
|
||||
});
|
||||
|
||||
@@ -233,11 +248,12 @@ describe("device routes", () => {
|
||||
const deviceRow = {
|
||||
device_id: "device-01",
|
||||
public_key: PUBLIC_KEY,
|
||||
wrapping_public_key: WRAPPING_PUBLIC_KEY,
|
||||
device_name: "MacBook Pro",
|
||||
platform: "macOS",
|
||||
approval_status: "pending",
|
||||
approval_status: "approved",
|
||||
created_at: 1_780_000_100,
|
||||
approved_at: null,
|
||||
approved_at: 1_780_000_100,
|
||||
last_active_at: 1_780_000_100,
|
||||
revoked_at: null,
|
||||
};
|
||||
@@ -248,6 +264,7 @@ describe("device routes", () => {
|
||||
id: "session-01",
|
||||
userId: "user-01",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
createdAt: new Date().toISOString(),
|
||||
deviceId: null,
|
||||
},
|
||||
});
|
||||
@@ -259,7 +276,7 @@ describe("device routes", () => {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(deviceRegistrationBody()),
|
||||
body: JSON.stringify(await deviceRegistrationBody()),
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
@@ -269,7 +286,7 @@ describe("device routes", () => {
|
||||
);
|
||||
|
||||
assert.equal(response.status, 201);
|
||||
assert.deepEqual(d1.binds[2]?.slice(0, 3), ["session-01", "user-01", "device-01"]);
|
||||
assert.deepEqual(d1.binds[3]?.slice(0, 3), ["session-01", "user-01", "device-01"]);
|
||||
assert.deepEqual(kvPuts, []);
|
||||
});
|
||||
|
||||
@@ -278,6 +295,7 @@ describe("device routes", () => {
|
||||
const existingDevice = {
|
||||
device_id: "device-01",
|
||||
public_key: PUBLIC_KEY,
|
||||
wrapping_public_key: WRAPPING_PUBLIC_KEY,
|
||||
device_name: "MacBook Pro",
|
||||
platform: "macOS",
|
||||
approval_status: status,
|
||||
@@ -288,11 +306,12 @@ describe("device routes", () => {
|
||||
};
|
||||
const d1 = testD1Database({
|
||||
firstRows: [existingDevice],
|
||||
runChanges: [0],
|
||||
batchChanges: [[0, 0]],
|
||||
sessionRow: {
|
||||
id: "session-02",
|
||||
userId: "user-01",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
createdAt: new Date().toISOString(),
|
||||
deviceId: null,
|
||||
},
|
||||
});
|
||||
@@ -304,7 +323,7 @@ describe("device routes", () => {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(deviceRegistrationBody()),
|
||||
body: JSON.stringify(await deviceRegistrationBody()),
|
||||
}),
|
||||
testEnv({ d1 }),
|
||||
);
|
||||
@@ -319,6 +338,7 @@ describe("device routes", () => {
|
||||
const pendingDevice = {
|
||||
device_id: "device-01",
|
||||
public_key: PUBLIC_KEY,
|
||||
wrapping_public_key: WRAPPING_PUBLIC_KEY,
|
||||
device_name: "MacBook Pro",
|
||||
platform: "macOS",
|
||||
approval_status: "pending",
|
||||
@@ -327,7 +347,7 @@ describe("device routes", () => {
|
||||
last_active_at: 1_780_000_020,
|
||||
revoked_at: null,
|
||||
};
|
||||
const d1 = testD1Database({ firstRows: [pendingDevice], runChanges: [0] });
|
||||
const d1 = testD1Database({ firstRows: [pendingDevice], batchChanges: [[0, 0]] });
|
||||
|
||||
const response = await handleRequest(
|
||||
new Request("https://elydora.test/api/devices/register", {
|
||||
@@ -336,7 +356,7 @@ describe("device routes", () => {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(deviceRegistrationBody()),
|
||||
body: JSON.stringify(await deviceRegistrationBody()),
|
||||
}),
|
||||
testEnv({ d1 }),
|
||||
);
|
||||
@@ -352,12 +372,13 @@ describe("device routes", () => {
|
||||
it("rejects a device id collision with a different idempotency key", async () => {
|
||||
const d1 = testD1Database({
|
||||
firstRows: [],
|
||||
runChanges: [0],
|
||||
batchChanges: [[0, 0]],
|
||||
sessionRow: {
|
||||
id: "session-02",
|
||||
userId: "user-01",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
deviceId: null,
|
||||
id: "session-02",
|
||||
userId: "user-01",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
createdAt: new Date().toISOString(),
|
||||
deviceId: null,
|
||||
},
|
||||
});
|
||||
const response = await handleRequest(
|
||||
@@ -367,10 +388,9 @@ describe("device routes", () => {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...deviceRegistrationBody(),
|
||||
idempotency_key: "device-register-0002",
|
||||
}),
|
||||
body: JSON.stringify(
|
||||
await deviceRegistrationBody({ idempotency_key: "device-register-0002" }),
|
||||
),
|
||||
}),
|
||||
testEnv({ d1 }),
|
||||
);
|
||||
@@ -390,7 +410,7 @@ describe("device routes", () => {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ ...deviceRegistrationBody(), idempotency_key: "short" }),
|
||||
body: JSON.stringify(await deviceRegistrationBody({ idempotency_key: "short" })),
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
@@ -413,7 +433,7 @@ describe("device routes", () => {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ ...deviceRegistrationBody(), device_id: "device-02" }),
|
||||
body: JSON.stringify(await deviceRegistrationBody({ device_id: "device-02" })),
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
@@ -422,7 +442,7 @@ describe("device routes", () => {
|
||||
);
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(await response.json(), { error: "device_context_mismatch" });
|
||||
assert.deepEqual(await response.json(), { error: "device_registration_forbidden" });
|
||||
assert.deepEqual(d1.queries, []);
|
||||
});
|
||||
|
||||
@@ -432,7 +452,7 @@ describe("device routes", () => {
|
||||
new Request("https://elydora.test/api/devices/register", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(deviceRegistrationBody()),
|
||||
body: JSON.stringify(await deviceRegistrationBody()),
|
||||
}),
|
||||
testEnv({ d1 }),
|
||||
);
|
||||
@@ -442,14 +462,3 @@ describe("device routes", () => {
|
||||
assert.deepEqual(d1.queries, []);
|
||||
});
|
||||
});
|
||||
|
||||
function deviceRegistrationBody(): Record<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
device_id: "device-01",
|
||||
public_key: PUBLIC_KEY,
|
||||
device_name: "MacBook Pro",
|
||||
platform: "macOS",
|
||||
idempotency_key: IDEMPOTENCY_KEY,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,9 +5,14 @@ import type {
|
||||
ElyR2PutOptions,
|
||||
Env,
|
||||
} from "../src/bindings.js";
|
||||
import { deviceRegistrationProofBytes } from "../src/device_registration_proof.js";
|
||||
|
||||
export const ACCESS_TOKEN = "D".repeat(48);
|
||||
export const PUBLIC_KEY = "a".repeat(64);
|
||||
export const PUBLIC_KEY = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a";
|
||||
export const WRAPPING_PUBLIC_KEY = "b".repeat(64);
|
||||
const SIGNING_PRIVATE_KEY_PKCS8 =
|
||||
"302e020100300506032b657004220420" +
|
||||
"9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60";
|
||||
|
||||
export interface TestEnvOptions {
|
||||
auditEvents?: ElyAnalyticsDataPoint[];
|
||||
@@ -29,6 +34,7 @@ export interface RecordedD1Database extends ElyD1Database {
|
||||
batches: number[];
|
||||
binds: unknown[][];
|
||||
queries: string[];
|
||||
sessionConstraints?: string[];
|
||||
}
|
||||
|
||||
export interface RecordedR2Put {
|
||||
@@ -37,8 +43,54 @@ export interface RecordedR2Put {
|
||||
options: ElyR2PutOptions;
|
||||
}
|
||||
|
||||
export async function deviceRegistrationBody(
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const body: Record<string, unknown> = {
|
||||
version: 2,
|
||||
device_id: "device-01",
|
||||
public_key: PUBLIC_KEY,
|
||||
wrapping_public_key: WRAPPING_PUBLIC_KEY,
|
||||
device_name: "MacBook Pro",
|
||||
platform: "macOS",
|
||||
idempotency_key: "device-register-0001",
|
||||
...overrides,
|
||||
};
|
||||
body.registration_proof = await signDeviceMessage(
|
||||
deviceRegistrationProofBytes({
|
||||
deviceId: stringValue(body.device_id),
|
||||
publicKey: stringValue(body.public_key),
|
||||
wrappingPublicKey: stringValue(body.wrapping_public_key),
|
||||
deviceName: stringValue(body.device_name),
|
||||
platform: stringValue(body.platform),
|
||||
idempotencyKey: stringValue(body.idempotency_key),
|
||||
}),
|
||||
);
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function signDeviceMessage(message: Uint8Array): Promise<string> {
|
||||
const privateKey = await crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
hexBytes(SIGNING_PRIVATE_KEY_PKCS8),
|
||||
{ name: "Ed25519" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
const signature = await crypto.subtle.sign(
|
||||
{ name: "Ed25519" },
|
||||
privateKey,
|
||||
message,
|
||||
);
|
||||
return hexString(new Uint8Array(signature));
|
||||
}
|
||||
|
||||
interface TestD1DatabaseOptions {
|
||||
allRows?: unknown[];
|
||||
allRowSets?: unknown[][];
|
||||
batchChanges?: number[][];
|
||||
batchError?: Error;
|
||||
batchRowSets?: unknown[][][];
|
||||
firstRows?: unknown[];
|
||||
runChanges?: number[];
|
||||
sessionRow?: unknown | null;
|
||||
@@ -48,6 +100,7 @@ const DEFAULT_AUTH_SESSION_ROW = {
|
||||
id: "session-01",
|
||||
userId: "user-01",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
createdAt: new Date().toISOString(),
|
||||
deviceId: "device-01",
|
||||
};
|
||||
|
||||
@@ -108,6 +161,7 @@ export function testD1Database(rows: unknown[] | TestD1DatabaseOptions): Recorde
|
||||
const binds: unknown[][] = [];
|
||||
const batches: number[] = [];
|
||||
const queries: string[] = [];
|
||||
const sessionConstraints: string[] = [];
|
||||
const allRows = Array.isArray(rows) ? rows : rows.allRows ?? [];
|
||||
const firstRows = Array.isArray(rows) ? rows : rows.firstRows ?? [];
|
||||
const sessionRow =
|
||||
@@ -115,18 +169,21 @@ export function testD1Database(rows: unknown[] | TestD1DatabaseOptions): Recorde
|
||||
? rows.sessionRow ?? null
|
||||
: DEFAULT_AUTH_SESSION_ROW;
|
||||
let firstIndex = 0;
|
||||
let allIndex = 0;
|
||||
let batchIndex = 0;
|
||||
let runIndex = 0;
|
||||
return {
|
||||
const database: RecordedD1Database = {
|
||||
authBinds,
|
||||
authQueries,
|
||||
batches,
|
||||
binds,
|
||||
queries,
|
||||
sessionConstraints,
|
||||
prepare(query: string) {
|
||||
const isAuthSessionQuery = query.includes("FROM better_auth_session AS session");
|
||||
const isAuthSessionQuery = query.includes("WHERE session.token = ?");
|
||||
(isAuthSessionQuery ? authQueries : queries).push(query);
|
||||
return testD1PreparedStatement(
|
||||
allRows,
|
||||
() => (!Array.isArray(rows) ? rows.allRowSets?.[allIndex++] : undefined) ?? allRows,
|
||||
firstRows,
|
||||
() => firstIndex++,
|
||||
isAuthSessionQuery ? authBinds : binds,
|
||||
@@ -135,14 +192,33 @@ export function testD1Database(rows: unknown[] | TestD1DatabaseOptions): Recorde
|
||||
() => (!Array.isArray(rows) ? rows.runChanges?.[runIndex++] : undefined) ?? 1,
|
||||
);
|
||||
},
|
||||
batch(statements: ElyD1PreparedStatement[]) {
|
||||
batch<T>(statements: ElyD1PreparedStatement[]) {
|
||||
batches.push(statements.length);
|
||||
return Promise.resolve([]);
|
||||
if (!Array.isArray(rows) && rows.batchError !== undefined) {
|
||||
return Promise.reject(rows.batchError);
|
||||
}
|
||||
const currentBatchIndex = batchIndex++;
|
||||
const configuredChanges = !Array.isArray(rows)
|
||||
? rows.batchChanges?.[currentBatchIndex]
|
||||
: undefined;
|
||||
const configuredRows = !Array.isArray(rows)
|
||||
? rows.batchRowSets?.[currentBatchIndex]
|
||||
: undefined;
|
||||
const results = statements.map((_, index) => ({
|
||||
results: configuredRows?.[index] ?? [],
|
||||
meta: { changes: configuredChanges?.[index] ?? 1 },
|
||||
}));
|
||||
return Promise.resolve(results as T[]);
|
||||
},
|
||||
exec() {
|
||||
return Promise.resolve({});
|
||||
},
|
||||
withSession(constraint) {
|
||||
sessionConstraints.push(constraint);
|
||||
return database;
|
||||
},
|
||||
};
|
||||
return database;
|
||||
}
|
||||
|
||||
export function sessionDocument(deviceId: string | null = "device-01"): string {
|
||||
@@ -156,7 +232,7 @@ export function sessionDocument(deviceId: string | null = "device-01"): string {
|
||||
}
|
||||
|
||||
function testD1PreparedStatement(
|
||||
allRows: unknown[],
|
||||
allRows: () => unknown[],
|
||||
firstRows: unknown[],
|
||||
nextFirstIndex: () => number,
|
||||
binds: unknown[][],
|
||||
@@ -176,7 +252,7 @@ function testD1PreparedStatement(
|
||||
return Promise.resolve((firstRows[nextFirstIndex()] as T | undefined) ?? null);
|
||||
},
|
||||
all<T>() {
|
||||
return Promise.resolve({ results: allRows as T[] });
|
||||
return Promise.resolve({ results: allRows() as T[] });
|
||||
},
|
||||
run() {
|
||||
return Promise.resolve({ results: [], meta: { changes: nextRunChanges() } });
|
||||
@@ -220,3 +296,22 @@ function testR2Bucket(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
if (typeof value !== "string") {
|
||||
throw new TypeError("registration fixture field must be a string");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function hexString(bytes: Uint8Array): string {
|
||||
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import type { Env } from "../src/bindings.js";
|
||||
import { purgeLegacySessionCache } from "../src/legacy_auth_kv_cleanup.js";
|
||||
|
||||
describe("legacy auth KV cleanup", () => {
|
||||
it("purges KV-only historical session keys across list pages", async () => {
|
||||
const prefix = "ely:production:auth_session_cache:";
|
||||
const first = `${prefix}${"a".repeat(64)}`;
|
||||
const second = `${prefix}${"b".repeat(64)}`;
|
||||
const unrelated = "ely:production:public_cache:plugins";
|
||||
const kv = new PaginatedKv([first, second, unrelated]);
|
||||
const env = { ELY_KV: kv, ELY_ENVIRONMENT: "production" } as unknown as Env;
|
||||
|
||||
assert.equal(await purgeLegacySessionCache(env), 2);
|
||||
|
||||
assert.deepEqual(kv.deleted, [first, second]);
|
||||
assert.deepEqual([...kv.values], [unrelated]);
|
||||
assert.deepEqual(kv.cursors, [undefined, "page-2"]);
|
||||
});
|
||||
});
|
||||
|
||||
class PaginatedKv {
|
||||
readonly values: Set<string>;
|
||||
readonly deleted: string[] = [];
|
||||
readonly cursors: (string | undefined)[] = [];
|
||||
|
||||
constructor(keys: string[]) {
|
||||
this.values = new Set(keys);
|
||||
}
|
||||
|
||||
get(key: string): Promise<string | null> {
|
||||
return Promise.resolve(this.values.has(key) ? "value" : null);
|
||||
}
|
||||
|
||||
put(key: string): Promise<void> {
|
||||
this.values.add(key);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
delete(key: string): Promise<void> {
|
||||
this.deleted.push(key);
|
||||
this.values.delete(key);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
list(options: { prefix: string; cursor?: string; limit: number }) {
|
||||
this.cursors.push(options.cursor);
|
||||
const matching = [...this.values].filter((key) => key.startsWith(options.prefix)).sort();
|
||||
if (options.cursor === undefined) {
|
||||
return Promise.resolve({
|
||||
keys: matching.slice(0, 1).map((name) => ({ name })),
|
||||
list_complete: false as const,
|
||||
cursor: "page-2",
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
keys: matching.map((name) => ({ name })),
|
||||
list_complete: true as const,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -14,14 +14,31 @@ const EXPECTED_MIGRATIONS = [
|
||||
"0005_audit.sql",
|
||||
"0006_better_auth.sql",
|
||||
"0007_better_auth_session_device_context.sql",
|
||||
"0008_sync_encryption.sql",
|
||||
"0009_sync_vault.sql",
|
||||
"0010_device_trust.sql",
|
||||
"0011_sync_vault_rotation.sql",
|
||||
"0012_sync_snapshot_head.sql",
|
||||
"0013_sync_r2_gc.sql",
|
||||
];
|
||||
const USER_SCOPED_TABLES = [
|
||||
"user_devices",
|
||||
"device_approvals",
|
||||
"device_rebind_challenges",
|
||||
"pending_device_revocations",
|
||||
"sync_objects",
|
||||
"sync_r2_gc_candidates",
|
||||
"sync_change_log",
|
||||
"sync_snapshots",
|
||||
"sync_snapshot_encryption",
|
||||
"sync_snapshot_heads",
|
||||
"sync_tombstones",
|
||||
"sync_vault_accounts",
|
||||
"sync_vault_envelopes",
|
||||
"sync_vault_rotation_envelopes",
|
||||
"sync_vault_rotation_r2_objects",
|
||||
"sync_vault_rotations",
|
||||
"user_device_keys",
|
||||
];
|
||||
|
||||
describe("D1 migrations", () => {
|
||||
@@ -44,14 +61,26 @@ describe("D1 migrations", () => {
|
||||
"better_auth_user",
|
||||
"better_auth_verification",
|
||||
"device_approvals",
|
||||
"device_rebind_challenges",
|
||||
"pending_device_revocations",
|
||||
"plugin_packages",
|
||||
"plugin_registry",
|
||||
"plugin_reviews",
|
||||
"release_manifests",
|
||||
"sync_change_log",
|
||||
"sync_objects",
|
||||
"sync_r2_gc_candidates",
|
||||
"sync_r2_inventory_cursors",
|
||||
"sync_snapshots",
|
||||
"sync_snapshot_encryption",
|
||||
"sync_snapshot_heads",
|
||||
"sync_tombstones",
|
||||
"sync_vault_accounts",
|
||||
"sync_vault_envelopes",
|
||||
"sync_vault_rotation_envelopes",
|
||||
"sync_vault_rotation_r2_objects",
|
||||
"sync_vault_rotations",
|
||||
"user_device_keys",
|
||||
"user_devices",
|
||||
]) {
|
||||
assert.ok(tables.includes(table), table);
|
||||
@@ -115,6 +144,31 @@ describe("D1 migrations", () => {
|
||||
]),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
requiredColumns(databasePath, "user_device_keys", [
|
||||
"user_id",
|
||||
"device_id",
|
||||
"signing_public_key",
|
||||
"wrapping_public_key",
|
||||
"key_protocol_version",
|
||||
"created_at",
|
||||
]),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
requiredColumns(databasePath, "device_rebind_challenges", [
|
||||
"challenge_id",
|
||||
"user_id",
|
||||
"session_id",
|
||||
"device_id",
|
||||
"challenge",
|
||||
"created_at",
|
||||
"expires_at",
|
||||
"consumed_at",
|
||||
"consumption_nonce",
|
||||
]),
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,6 +203,185 @@ describe("D1 migrations", () => {
|
||||
]),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
requiredColumns(databasePath, "sync_snapshots", [
|
||||
"head_revision",
|
||||
"base_head_revision",
|
||||
"base_snapshot_id",
|
||||
"base_payload_hash",
|
||||
]),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
requiredColumns(databasePath, "sync_snapshot_encryption", [
|
||||
"user_id",
|
||||
"snapshot_id",
|
||||
"encryption_version",
|
||||
"vault_generation",
|
||||
"key_id",
|
||||
"content_hash",
|
||||
]),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
requiredColumns(databasePath, "sync_snapshot_heads", [
|
||||
"user_id",
|
||||
"head_revision",
|
||||
"snapshot_id",
|
||||
"payload_hash",
|
||||
"updated_at",
|
||||
]),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
requiredColumns(databasePath, "sync_r2_gc_candidates", [
|
||||
"r2_key",
|
||||
"user_id",
|
||||
"owner_hash",
|
||||
"object_kind",
|
||||
"state",
|
||||
"write_token",
|
||||
"lease_expires_at",
|
||||
"gc_token",
|
||||
"deleted_at",
|
||||
]),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
requiredColumns(databasePath, "sync_vault_accounts", [
|
||||
"user_id",
|
||||
"current_key_id",
|
||||
"current_generation",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
requiredColumns(databasePath, "sync_vault_envelopes", [
|
||||
"user_id",
|
||||
"recipient_device_id",
|
||||
"approver_device_id",
|
||||
"key_id",
|
||||
"generation",
|
||||
"envelope_version",
|
||||
"suite",
|
||||
"encapped_key",
|
||||
"ciphertext",
|
||||
"idempotency_key",
|
||||
"created_at",
|
||||
]),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
requiredColumns(databasePath, "pending_device_revocations", [
|
||||
"user_id",
|
||||
"idempotency_key",
|
||||
"target_device_id",
|
||||
"approver_device_id",
|
||||
"request_hash",
|
||||
"completed_at",
|
||||
]),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
requiredColumns(databasePath, "sync_vault_rotations", [
|
||||
"user_id",
|
||||
"idempotency_key",
|
||||
"target_device_id",
|
||||
"approver_device_id",
|
||||
"previous_key_id",
|
||||
"previous_generation",
|
||||
"new_key_id",
|
||||
"new_generation",
|
||||
"request_hash",
|
||||
"envelope_count",
|
||||
"r2_object_count",
|
||||
"completed_at",
|
||||
"cleanup_snapshot_id",
|
||||
"cleanup_started_at",
|
||||
"storage_cleaned_at",
|
||||
]),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
requiredColumns(databasePath, "sync_vault_rotation_envelopes", [
|
||||
"user_id",
|
||||
"rotation_idempotency_key",
|
||||
"recipient_device_id",
|
||||
"envelope_idempotency_key",
|
||||
"envelope_version",
|
||||
"suite",
|
||||
"encapped_key",
|
||||
"ciphertext",
|
||||
]),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
requiredColumns(databasePath, "sync_vault_rotation_r2_objects", [
|
||||
"user_id",
|
||||
"rotation_idempotency_key",
|
||||
"r2_key",
|
||||
]),
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("backfills one deterministic legacy encrypted head per user", () => {
|
||||
withDatabaseBeforeSnapshotHeadMigration((databasePath) => {
|
||||
execFileSync("sqlite3", [databasePath], {
|
||||
input: `
|
||||
INSERT INTO sync_snapshots (
|
||||
user_id, snapshot_id, r2_key, payload_hash, schema_rev,
|
||||
logical_clock, device_id, size_bytes, created_at
|
||||
) VALUES
|
||||
('user-01', 'snapshot-b', 'key-b', '${"b".repeat(64)}', 1, 2, 'device-01', 1, 100),
|
||||
('user-01', 'snapshot-a', 'key-a', '${"a".repeat(64)}', 1, 3, 'device-01', 1, 100),
|
||||
('user-01', 'snapshot-c', 'key-c', '${"c".repeat(64)}', 1, 1, 'device-01', 1, 90);
|
||||
INSERT INTO sync_snapshot_encryption (
|
||||
user_id, snapshot_id, encryption_version, vault_generation, key_id, content_hash
|
||||
) VALUES
|
||||
('user-01', 'snapshot-a', 1, 1, '${"1".repeat(64)}', '${"2".repeat(64)}'),
|
||||
('user-01', 'snapshot-b', 1, 1, '${"1".repeat(64)}', '${"3".repeat(64)}'),
|
||||
('user-01', 'snapshot-c', 1, 1, '${"1".repeat(64)}', '${"4".repeat(64)}');
|
||||
`,
|
||||
});
|
||||
execFileSync("sqlite3", [databasePath], {
|
||||
input: `PRAGMA foreign_keys = ON;\n${readFileSync(
|
||||
join(MIGRATIONS_DIR, "0012_sync_snapshot_head.sql"),
|
||||
"utf8",
|
||||
)}`,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
sqliteJson(databasePath, `
|
||||
SELECT head_revision, snapshot_id, payload_hash
|
||||
FROM sync_snapshot_heads
|
||||
WHERE user_id = 'user-01'
|
||||
`),
|
||||
[{ head_revision: 1, snapshot_id: "snapshot-a", payload_hash: "a".repeat(64) }],
|
||||
);
|
||||
assert.deepEqual(
|
||||
sqliteJson(databasePath, `
|
||||
SELECT snapshot_id, head_revision
|
||||
FROM sync_snapshots
|
||||
WHERE user_id = 'user-01'
|
||||
ORDER BY snapshot_id
|
||||
`),
|
||||
[
|
||||
{ snapshot_id: "snapshot-a", head_revision: 1 },
|
||||
{ snapshot_id: "snapshot-b", head_revision: 0 },
|
||||
{ snapshot_id: "snapshot-c", head_revision: 0 },
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
sqliteJson(databasePath, `
|
||||
SELECT DISTINCT encryption_version
|
||||
FROM sync_snapshot_encryption
|
||||
`),
|
||||
[{ encryption_version: 1 }],
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -165,6 +398,22 @@ function withReplayedDatabase(assertions: (databasePath: string) => void): void
|
||||
.map((fileName) => readFileSync(join(MIGRATIONS_DIR, fileName), "utf8"))
|
||||
.join("\n");
|
||||
execFileSync("sqlite3", [databasePath], { input: sql });
|
||||
assertions(databasePath);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function withDatabaseBeforeSnapshotHeadMigration(
|
||||
assertions: (databasePath: string) => void,
|
||||
): void {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "ely-d1-before-head-"));
|
||||
try {
|
||||
const databasePath = join(tempDir, "ely.db");
|
||||
const sql = migrationFiles()
|
||||
.filter((fileName) => fileName < "0012_sync_snapshot_head.sql")
|
||||
.map((fileName) => readFileSync(join(MIGRATIONS_DIR, fileName), "utf8"))
|
||||
.join("\n");
|
||||
execFileSync("sqlite3", [databasePath], { input: sql });
|
||||
assertions(databasePath);
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import type { ElyD1PreparedStatement, ElyD1Result } from "../src/bindings.js";
|
||||
import type { RecordedD1Database } from "./devices_test_support.js";
|
||||
|
||||
export class SqliteD1Database implements RecordedD1Database {
|
||||
readonly authBinds: unknown[][] = [];
|
||||
readonly authQueries: string[] = [];
|
||||
readonly batches: number[] = [];
|
||||
readonly binds: unknown[][] = [];
|
||||
readonly queries: string[] = [];
|
||||
readonly sessionConstraints: string[] = [];
|
||||
private beforeBatchSql: string | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly databasePath: string,
|
||||
beforeBatchSql?: string,
|
||||
) {
|
||||
this.beforeBatchSql = beforeBatchSql;
|
||||
}
|
||||
|
||||
prepare(sql: string): ElyD1PreparedStatement {
|
||||
this.queries.push(sql);
|
||||
return new SqliteD1Statement(this, sql);
|
||||
}
|
||||
|
||||
async batch<T>(statements: ElyD1PreparedStatement[]): Promise<T[]> {
|
||||
this.batches.push(statements.length);
|
||||
if (this.beforeBatchSql !== undefined) {
|
||||
execute(this.databasePath, this.beforeBatchSql);
|
||||
this.beforeBatchSql = undefined;
|
||||
}
|
||||
const prepared = statements.map((statement) => {
|
||||
assert.ok(statement instanceof SqliteD1Statement);
|
||||
return statement.sql();
|
||||
});
|
||||
const script = [
|
||||
".bail on",
|
||||
"PRAGMA foreign_keys = ON;",
|
||||
"BEGIN IMMEDIATE;",
|
||||
...prepared.flatMap((sql, index) => [
|
||||
`.print __ELY_BEGIN_${index}`,
|
||||
sql,
|
||||
`.print __ELY_CHANGES_${index}`,
|
||||
"SELECT changes() AS __ely_changes;",
|
||||
`.print __ELY_END_${index}`,
|
||||
]),
|
||||
"COMMIT;",
|
||||
].join("\n");
|
||||
const output = sqlite(this.databasePath, script, true);
|
||||
const lines = output.trim().split(/\r?\n/).filter(Boolean);
|
||||
const results = prepared.map((_, index) => {
|
||||
const begin = lines.indexOf(`__ELY_BEGIN_${index}`);
|
||||
const changesMarker = lines.indexOf(`__ELY_CHANGES_${index}`);
|
||||
const end = lines.indexOf(`__ELY_END_${index}`);
|
||||
assert.ok(begin >= 0 && changesMarker > begin && end > changesMarker);
|
||||
const rows = lines
|
||||
.slice(begin + 1, changesMarker)
|
||||
.flatMap((line) => JSON.parse(line) as unknown[]);
|
||||
const changeRows = JSON.parse(lines[changesMarker + 1] ?? "[]") as {
|
||||
__ely_changes?: unknown;
|
||||
}[];
|
||||
const value = changeRows[0]?.__ely_changes;
|
||||
assert.equal(typeof value, "number");
|
||||
return { results: rows, meta: { changes: value } };
|
||||
});
|
||||
return results as T[];
|
||||
}
|
||||
|
||||
async exec(sql: string): Promise<unknown> {
|
||||
execute(this.databasePath, sql);
|
||||
return {};
|
||||
}
|
||||
|
||||
withSession(constraint: "first-primary"): SqliteD1Database {
|
||||
this.sessionConstraints.push(constraint);
|
||||
return this;
|
||||
}
|
||||
|
||||
rows<T>(sql: string): T[] {
|
||||
return query(this.databasePath, sql) as T[];
|
||||
}
|
||||
}
|
||||
|
||||
class SqliteD1Statement implements ElyD1PreparedStatement {
|
||||
private values: unknown[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly database: SqliteD1Database,
|
||||
private readonly queryText: string,
|
||||
) {}
|
||||
|
||||
bind(...values: unknown[]): ElyD1PreparedStatement {
|
||||
this.values = values;
|
||||
this.database.binds.push(values);
|
||||
return this;
|
||||
}
|
||||
|
||||
async first<T>(): Promise<T | null> {
|
||||
return this.database.rows<T>(this.sql())[0] ?? null;
|
||||
}
|
||||
|
||||
async all<T>(): Promise<ElyD1Result<T>> {
|
||||
return { results: this.database.rows<T>(this.sql()) };
|
||||
}
|
||||
|
||||
async run(): Promise<unknown> {
|
||||
const rows = this.database.rows<{ changes: number }>(
|
||||
`${this.sql()}\nSELECT changes() AS changes;`,
|
||||
);
|
||||
return { results: [], meta: { changes: rows[0]?.changes ?? 0 } };
|
||||
}
|
||||
|
||||
sql(): string {
|
||||
let index = 0;
|
||||
const sql = this.queryText.replace(/\?/g, () => sqlLiteral(this.values[index++]));
|
||||
assert.equal(index, this.values.length, "D1 bind count must match SQL placeholders");
|
||||
return `${sql.trim().replace(/;$/, "")};`;
|
||||
}
|
||||
}
|
||||
|
||||
export function execute(databasePath: string, sql: string): void {
|
||||
sqlite(databasePath, `.bail on\nPRAGMA foreign_keys = ON;\n${sql}`);
|
||||
}
|
||||
|
||||
export function query(databasePath: string, sql: string): Record<string, unknown>[] {
|
||||
const output = sqlite(databasePath, `PRAGMA foreign_keys = ON;\n${sql}`, true);
|
||||
return output.trim() === "" ? [] : JSON.parse(output) as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
function sqlite(databasePath: string, sql: string, json = false): string {
|
||||
try {
|
||||
return execFileSync("sqlite3", [...(json ? ["-json"] : []), databasePath], {
|
||||
input: sql,
|
||||
encoding: "utf8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
} catch (error) {
|
||||
const stderr = typeof error === "object" && error !== null && "stderr" in error
|
||||
? String(error.stderr)
|
||||
: "";
|
||||
throw new Error(`${error instanceof Error ? error.message : String(error)}\n${stderr}`);
|
||||
}
|
||||
}
|
||||
|
||||
function sqlLiteral(value: unknown): string {
|
||||
if (value === null) return "NULL";
|
||||
if (typeof value === "string") return `'${value.replaceAll("'", "''")}'`;
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value.toString();
|
||||
throw new TypeError("Unsupported SQLite test binding");
|
||||
}
|
||||
@@ -37,8 +37,13 @@ describe("R2 storage contracts", () => {
|
||||
`sync-payloads/us-east/${USER_HASH}/tabs/tab-01/${PAYLOAD_HASH}.bin`,
|
||||
);
|
||||
assert.equal(
|
||||
syncSnapshotKey({ region: "us-east", userHash: USER_HASH, snapshotId: "snapshot-01" }),
|
||||
`sync-snapshots/us-east/${USER_HASH}/snapshot-01.bin`,
|
||||
syncSnapshotKey({
|
||||
region: "us-east",
|
||||
userHash: USER_HASH,
|
||||
snapshotId: "snapshot-01",
|
||||
payloadHash: PAYLOAD_HASH,
|
||||
}),
|
||||
`sync-snapshots/us-east/${USER_HASH}/snapshot-01/${PAYLOAD_HASH}.bin`,
|
||||
);
|
||||
assert.equal(
|
||||
pluginPackageKey({ pluginId: "elydora.reader", packageHash: PACKAGE_HASH }),
|
||||
@@ -163,6 +168,7 @@ describe("R2 storage contracts", () => {
|
||||
region: "us-east",
|
||||
userHash: USER_HASH,
|
||||
snapshotId: "snapshot-01",
|
||||
payloadHash: checksum,
|
||||
});
|
||||
|
||||
const downloaded = await getVerifiedObject(bucket, key, checksum);
|
||||
@@ -176,6 +182,7 @@ describe("R2 storage contracts", () => {
|
||||
region: "us-east",
|
||||
userHash: USER_HASH,
|
||||
snapshotId: "snapshot-01",
|
||||
payloadHash: PAYLOAD_HASH,
|
||||
});
|
||||
|
||||
await deleteKnownObject(bucket, key);
|
||||
|
||||
@@ -5,104 +5,12 @@ import { authSessionCacheKvKey, authTokenHash } from "../src/auth.js";
|
||||
import { handleRequest } from "../src/index.js";
|
||||
import { ACCESS_TOKEN, sessionDocument, testD1Database, testEnv } from "./devices_test_support.js";
|
||||
|
||||
const PAYLOAD_HASH = "a".repeat(64);
|
||||
|
||||
describe("sync pull routes", () => {
|
||||
it("returns sync change log entries for an approved current device", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [{ device_id: "device-01" }],
|
||||
allRows: [
|
||||
syncChangeRow({ change_id: 11, object_id: "tab-01" }),
|
||||
syncChangeRow({ change_id: 12, object_id: "bookmark-01", object_type: "bookmarks" }),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
new Request("https://elydora.test/api/sync/pull?cursor=10&limit=2", {
|
||||
headers: { authorization: `Bearer ${ACCESS_TOKEN}` },
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("cache-control"), "no-store");
|
||||
assert.deepEqual(await response.json(), {
|
||||
version: 1,
|
||||
user_id: "user-01",
|
||||
device_id: "device-01",
|
||||
cursor: 10,
|
||||
next_cursor: 12,
|
||||
has_more: false,
|
||||
changes: [
|
||||
syncChangeDocument({ change_id: 11, object_id: "tab-01" }),
|
||||
syncChangeDocument({ change_id: 12, object_id: "bookmark-01", object_type: "bookmarks" }),
|
||||
],
|
||||
});
|
||||
assert.ok(d1.queries[0]?.includes("approval_status = 'approved'"));
|
||||
assert.ok(d1.queries[1]?.includes("FROM sync_change_log"));
|
||||
assert.deepEqual(d1.binds, [
|
||||
["user-01", "device-01"],
|
||||
["user-01", 10, 3],
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports more changes when the pull window is saturated", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [{ device_id: "device-01" }],
|
||||
allRows: [
|
||||
syncChangeRow({ change_id: 11, object_id: "tab-01" }),
|
||||
syncChangeRow({ change_id: 12, object_id: "tab-02" }),
|
||||
syncChangeRow({ change_id: 13, object_id: "tab-03" }),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
new Request("https://elydora.test/api/sync/pull?cursor=10&limit=2", {
|
||||
headers: { authorization: `Bearer ${ACCESS_TOKEN}` },
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]],
|
||||
}),
|
||||
);
|
||||
|
||||
const body = (await response.json()) as { has_more: boolean; next_cursor: number; changes: [] };
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.has_more, true);
|
||||
assert.equal(body.next_cursor, 12);
|
||||
assert.equal(body.changes.length, 2);
|
||||
});
|
||||
|
||||
it("rejects revoked devices before reading sync deltas", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({ firstRows: [null], allRows: [syncChangeRow()] });
|
||||
|
||||
const response = await handleRequest(
|
||||
new Request("https://elydora.test/api/sync/pull?cursor=10", {
|
||||
headers: { authorization: `Bearer ${ACCESS_TOKEN}` },
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(await response.json(), { error: "device_not_approved" });
|
||||
assert.equal(d1.queries.length, 1);
|
||||
});
|
||||
|
||||
it("rejects invalid cursors after session and device validation", async () => {
|
||||
describe("retired sync pull route", () => {
|
||||
it("rejects legacy object reads after device authorization", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({ firstRows: [{ device_id: "device-01" }] });
|
||||
|
||||
const response = await handleRequest(
|
||||
new Request("https://elydora.test/api/sync/pull?cursor=old", {
|
||||
new Request("https://elydora.test/api/sync/pull?cursor=0", {
|
||||
headers: { authorization: `Bearer ${ACCESS_TOKEN}` },
|
||||
}),
|
||||
testEnv({
|
||||
@@ -111,34 +19,15 @@ describe("sync pull routes", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.deepEqual(await response.json(), { error: "invalid_sync_pull" });
|
||||
assert.equal(response.status, 410);
|
||||
assert.equal(response.headers.get("cache-control"), "no-store");
|
||||
assert.deepEqual(await response.json(), { error: "sync_object_protocol_retired" });
|
||||
assert.equal(d1.queries.length, 1);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("returns a server error for malformed sync change rows", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [{ device_id: "device-01" }],
|
||||
allRows: [syncChangeRow({ payload_hash: "bad" })],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
new Request("https://elydora.test/api/sync/pull?cursor=10", {
|
||||
headers: { authorization: `Bearer ${ACCESS_TOKEN}` },
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 500);
|
||||
assert.deepEqual(await response.json(), { error: "sync_pull_invalid" });
|
||||
});
|
||||
|
||||
it("rejects unauthenticated sync pulls before D1 reads", async () => {
|
||||
const d1 = testD1Database({ allRows: [syncChangeRow()] });
|
||||
it("keeps retired object reads behind authentication", async () => {
|
||||
const d1 = testD1Database({});
|
||||
const response = await handleRequest(
|
||||
new Request("https://elydora.test/api/sync/pull?cursor=0"),
|
||||
testEnv({ d1 }),
|
||||
@@ -149,21 +38,3 @@ describe("sync pull routes", () => {
|
||||
assert.deepEqual(d1.queries, []);
|
||||
});
|
||||
});
|
||||
|
||||
function syncChangeRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
change_id: 11,
|
||||
object_id: "tab-01",
|
||||
object_type: "tabs",
|
||||
operation: "upsert",
|
||||
payload_hash: PAYLOAD_HASH,
|
||||
logical_clock: 42,
|
||||
device_id: "device-02",
|
||||
created_at: 1_780_000_500,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function syncChangeDocument(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return syncChangeRow(overrides);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { authSessionCacheKvKey, authTokenHash } from "../src/auth.js";
|
||||
@@ -12,327 +11,32 @@ import {
|
||||
testEnv,
|
||||
} from "./devices_test_support.js";
|
||||
|
||||
const USER_ID = "user-01";
|
||||
const DEVICE_ID = "device-01";
|
||||
const OBJECT_ID = "tab-01";
|
||||
const OBJECT_TYPE = "tabs";
|
||||
|
||||
describe("sync push routes", () => {
|
||||
it("pushes an inline encrypted sync object from an approved current device", async () => {
|
||||
const payload = bytes("encrypted tab payload");
|
||||
const payloadHash = sha256(payload);
|
||||
describe("retired sync push route", () => {
|
||||
it("rejects legacy object writes before D1 and R2 persistence", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
{ device_id: DEVICE_ID },
|
||||
null,
|
||||
syncObjectRow({ payload_hash: payloadHash }),
|
||||
],
|
||||
});
|
||||
|
||||
const d1 = testD1Database({ firstRows: [{ device_id: "device-01" }] });
|
||||
const r2Puts: RecordedR2Put[] = [];
|
||||
const response = await handleRequest(
|
||||
syncPushRequest(syncPushBody({ payload_hash: payloadHash, payload: inlinePayload(payload) })),
|
||||
new Request("https://elydora.test/api/sync/push", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ version: 1, payload: "legacy-plaintext" }),
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
r2Puts,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 201);
|
||||
assert.equal(response.status, 410);
|
||||
assert.equal(response.headers.get("cache-control"), "no-store");
|
||||
assert.deepEqual(await response.json(), {
|
||||
version: 1,
|
||||
user_id: USER_ID,
|
||||
device_id: DEVICE_ID,
|
||||
object: syncObjectDocument({ payload_hash: payloadHash }),
|
||||
});
|
||||
assert.equal(d1.batches[0], 2);
|
||||
assert.ok(d1.queries[0]?.includes("approval_status = 'approved'"));
|
||||
assert.ok(d1.queries[1]?.includes("FROM sync_objects"));
|
||||
assert.ok(d1.queries[2]?.includes("INSERT INTO sync_objects"));
|
||||
assert.ok(d1.queries[3]?.includes("INSERT INTO sync_change_log"));
|
||||
assert.deepEqual(d1.binds[0], [USER_ID, DEVICE_ID]);
|
||||
assert.deepEqual(d1.binds[1], [USER_ID, OBJECT_ID]);
|
||||
assert.deepEqual(d1.binds[2]?.slice(0, 3), [USER_ID, OBJECT_ID, OBJECT_TYPE]);
|
||||
assert.deepEqual(new Uint8Array(d1.binds[2]?.[3] as ArrayBuffer), new Uint8Array(payload));
|
||||
assert.equal(d1.binds[2]?.[4], null);
|
||||
assert.equal(d1.binds[3]?.[3], "upsert");
|
||||
});
|
||||
|
||||
it("pushes an R2 encrypted sync object after checksum verification", async () => {
|
||||
const payload = bytes("large encrypted tab payload");
|
||||
const payloadHash = sha256(payload);
|
||||
const userHash = sha256(bytes(USER_ID));
|
||||
const r2Puts: RecordedR2Put[] = [];
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
{ device_id: DEVICE_ID },
|
||||
null,
|
||||
syncObjectRow({
|
||||
payload_hash: payloadHash,
|
||||
payload_r2_key: `sync-payloads/us-east/${userHash}/tabs/${OBJECT_ID}/${payloadHash}.bin`,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
syncPushRequest(
|
||||
syncPushBody({ payload_hash: payloadHash, payload: r2Payload("us-east", payload) }),
|
||||
),
|
||||
testEnv({
|
||||
d1,
|
||||
r2Puts,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 201);
|
||||
assert.equal(r2Puts.length, 1);
|
||||
assert.equal(
|
||||
r2Puts[0]?.key,
|
||||
`sync-payloads/us-east/${userHash}/tabs/${OBJECT_ID}/${payloadHash}.bin`,
|
||||
);
|
||||
assert.equal(r2Puts[0]?.options.customMetadata?.sha256, payloadHash);
|
||||
const body = (await response.json()) as { object: { payload_storage: string } };
|
||||
assert.equal(body.object.payload_storage, "r2");
|
||||
assert.equal(d1.binds[2]?.[3], null);
|
||||
assert.equal(d1.binds[2]?.[4], r2Puts[0]?.key);
|
||||
});
|
||||
|
||||
it("pushes a delete tombstone and writes the change log", async () => {
|
||||
const payloadHash = "b".repeat(64);
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
{ device_id: DEVICE_ID },
|
||||
syncObjectRow({ payload_hash: "a".repeat(64), logical_clock: 41 }),
|
||||
syncObjectRow({ payload_hash: payloadHash, logical_clock: 42, deleted_at: 1_780_000_800 }),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
syncPushRequest(
|
||||
syncPushBody({ operation: "delete", payload_hash: payloadHash, payload: undefined }),
|
||||
),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 201);
|
||||
assert.equal(d1.batches[0], 3);
|
||||
assert.ok(d1.queries[4]?.includes("INSERT INTO sync_tombstones"));
|
||||
assert.equal(d1.binds[2]?.[3], null);
|
||||
assert.equal(d1.binds[2]?.[4], null);
|
||||
assert.equal(d1.binds[3]?.[3], "delete");
|
||||
const body = (await response.json()) as { object: { payload_storage: string } };
|
||||
assert.equal(body.object.payload_storage, "tombstone");
|
||||
});
|
||||
|
||||
it("rejects payload checksum mismatches before D1 writes", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }] });
|
||||
const response = await handleRequest(
|
||||
syncPushRequest(
|
||||
syncPushBody({
|
||||
payload_hash: "c".repeat(64),
|
||||
payload: inlinePayload(bytes("encrypted tab payload")),
|
||||
}),
|
||||
),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.deepEqual(await response.json(), { error: "invalid_sync_push" });
|
||||
assert.equal(d1.queries.length, 1);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("rejects R2 object ids that cannot form storage keys before D1 writes", async () => {
|
||||
const payload = bytes("large encrypted tab payload");
|
||||
const payloadHash = sha256(payload);
|
||||
const r2Puts: RecordedR2Put[] = [];
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }] });
|
||||
|
||||
const response = await handleRequest(
|
||||
syncPushRequest(
|
||||
syncPushBody({
|
||||
object_id: "Tab:01",
|
||||
payload_hash: payloadHash,
|
||||
payload: r2Payload("us-east", payload),
|
||||
}),
|
||||
),
|
||||
testEnv({
|
||||
d1,
|
||||
r2Puts,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.deepEqual(await response.json(), { error: "invalid_sync_push" });
|
||||
assert.equal(r2Puts.length, 0);
|
||||
assert.equal(d1.queries.length, 1);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("rejects stale logical clocks before persistence writes", async () => {
|
||||
const payload = bytes("encrypted tab payload");
|
||||
const payloadHash = sha256(payload);
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
{ device_id: DEVICE_ID },
|
||||
syncObjectRow({ payload_hash: "d".repeat(64), logical_clock: 43 }),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
syncPushRequest(
|
||||
syncPushBody({
|
||||
payload_hash: payloadHash,
|
||||
logical_clock: 42,
|
||||
payload: inlinePayload(payload),
|
||||
}),
|
||||
),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 409);
|
||||
assert.deepEqual(await response.json(), { error: "sync_conflict" });
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("rejects same-clock object write races after D1 persistence", async () => {
|
||||
const payload = bytes("encrypted tab payload");
|
||||
const payloadHash = sha256(payload);
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
{ device_id: DEVICE_ID },
|
||||
null,
|
||||
syncObjectRow({ payload_hash: "e".repeat(64), logical_clock: 42 }),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
syncPushRequest(syncPushBody({ payload_hash: payloadHash, payload: inlinePayload(payload) })),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 409);
|
||||
assert.deepEqual(await response.json(), { error: "sync_conflict" });
|
||||
assert.equal(d1.batches[0], 2);
|
||||
});
|
||||
|
||||
it("rejects revoked devices before reading the sync push body", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({ firstRows: [null] });
|
||||
const response = await handleRequest(
|
||||
syncPushRequest(syncPushBody()),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(await response.json(), { error: "device_not_approved" });
|
||||
assert.deepEqual(await response.json(), { error: "sync_object_protocol_retired" });
|
||||
assert.equal(d1.queries.length, 1);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
assert.deepEqual(r2Puts, []);
|
||||
});
|
||||
});
|
||||
|
||||
function syncPushRequest(body: Record<string, unknown>): Request {
|
||||
return new Request("https://elydora.test/api/sync/push", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function syncPushBody(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
const payload = bytes("encrypted tab payload");
|
||||
const payloadHash = sha256(payload);
|
||||
return {
|
||||
version: 1,
|
||||
object_id: OBJECT_ID,
|
||||
object_type: OBJECT_TYPE,
|
||||
operation: "upsert",
|
||||
payload_hash: payloadHash,
|
||||
schema_rev: 1,
|
||||
logical_clock: 42,
|
||||
payload: inlinePayload(payload),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function inlinePayload(payload: ArrayBuffer): Record<string, unknown> {
|
||||
return { kind: "inline", data_base64: base64(payload) };
|
||||
}
|
||||
|
||||
function r2Payload(region: string, payload: ArrayBuffer): Record<string, unknown> {
|
||||
return { kind: "r2", region, data_base64: base64(payload) };
|
||||
}
|
||||
|
||||
function syncObjectRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
object_id: OBJECT_ID,
|
||||
object_type: OBJECT_TYPE,
|
||||
payload_r2_key: null,
|
||||
payload_hash: "a".repeat(64),
|
||||
schema_rev: 1,
|
||||
logical_clock: 42,
|
||||
device_id: DEVICE_ID,
|
||||
created_at: 1_780_000_700,
|
||||
updated_at: 1_780_000_700,
|
||||
deleted_at: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function syncObjectDocument(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
const row = syncObjectRow(overrides);
|
||||
return {
|
||||
object_id: row.object_id,
|
||||
object_type: row.object_type,
|
||||
operation: row.deleted_at === null ? "upsert" : "delete",
|
||||
payload_hash: row.payload_hash,
|
||||
schema_rev: row.schema_rev,
|
||||
logical_clock: row.logical_clock,
|
||||
device_id: row.device_id,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
deleted_at: row.deleted_at,
|
||||
payload_storage:
|
||||
row.deleted_at !== null ? "tombstone" : row.payload_r2_key === null ? "inline" : "r2",
|
||||
payload_r2_key: row.payload_r2_key,
|
||||
};
|
||||
}
|
||||
|
||||
function bytes(value: string): ArrayBuffer {
|
||||
return new TextEncoder().encode(value).buffer;
|
||||
}
|
||||
|
||||
function base64(payload: ArrayBuffer): string {
|
||||
return Buffer.from(payload).toString("base64");
|
||||
}
|
||||
|
||||
function sha256(payload: ArrayBuffer): string {
|
||||
return createHash("sha256").update(new Uint8Array(payload)).digest("hex");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
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 { ElyR2Object, ElyR2PutOptions, Env } from "../src/bindings.js";
|
||||
import { recentDeviceActionProofBytes } from "../src/recent_device_action_proof.js";
|
||||
import {
|
||||
SYNC_R2_ANONYMIZE_USER_QUERY,
|
||||
SYNC_R2_FENCE_USER_QUERY,
|
||||
abandonSyncR2Write,
|
||||
claimSyncR2SnapshotWrite,
|
||||
collectSyncR2Garbage,
|
||||
} from "../src/sync_r2_gc.js";
|
||||
import { inventorySyncR2Objects } from "../src/sync_r2_inventory.js";
|
||||
import { syncResetDocument } from "../src/sync_reset.js";
|
||||
import { PUBLIC_KEY, signDeviceMessage } from "./devices_test_support.js";
|
||||
import { SqliteD1Database, execute, query } from "./sqlite_d1_test_support.js";
|
||||
|
||||
const USER_ID = "user-01";
|
||||
const DEVICE_ID = "device-01";
|
||||
const KEY_ID = "1".repeat(64);
|
||||
const OWNER_HASH = createHash("sha256").update(USER_ID).digest("hex");
|
||||
const HASH_A = "a".repeat(64);
|
||||
const HASH_B = "b".repeat(64);
|
||||
const TOKEN_A = "c".repeat(64);
|
||||
const TOKEN_B = "d".repeat(64);
|
||||
const NOW = 1_800_000_000;
|
||||
const MIGRATIONS_DIR = join(process.cwd(), "migrations");
|
||||
|
||||
describe("sync R2 GC SQLite state machine", () => {
|
||||
it("commits a leased candidate and deletes it only after D1 references are fenced", async () => {
|
||||
await withDatabase(async (databasePath, database, bucket, env) => {
|
||||
const key = snapshotKey(HASH_A);
|
||||
const lease = await claimSyncR2SnapshotWrite(env, snapshotClaim(key), NOW, TOKEN_A);
|
||||
await bucket.put(key, bytes("ciphertext-a"));
|
||||
commitGenesis(databasePath, key, HASH_A, lease.writeToken);
|
||||
|
||||
assert.equal(candidateState(databasePath, key), "referenced");
|
||||
assert.equal(await collectSyncR2Garbage(env, NOW + 100_000), 0);
|
||||
|
||||
await database.batch([
|
||||
database.prepare(SYNC_R2_FENCE_USER_QUERY).bind(NOW + 1, NOW + 1, NOW + 1, USER_ID),
|
||||
database.prepare("DELETE FROM sync_snapshot_heads WHERE user_id = ?").bind(USER_ID),
|
||||
database.prepare("DELETE FROM sync_snapshot_encryption WHERE user_id = ?").bind(USER_ID),
|
||||
database.prepare("DELETE FROM sync_snapshots WHERE user_id = ?").bind(USER_ID),
|
||||
]);
|
||||
assert.equal(candidateState(databasePath, key), "ready");
|
||||
assert.equal(await collectSyncR2Garbage(env, NOW + 1), 1);
|
||||
assert.equal(candidateState(databasePath, key), "deleted");
|
||||
assert.deepEqual(bucket.deletes, [key]);
|
||||
});
|
||||
});
|
||||
|
||||
it("turns a CAS loser into an immediately collectible ready candidate", async () => {
|
||||
await withDatabase(async (databasePath, _database, bucket, env) => {
|
||||
const key = snapshotKey(HASH_A);
|
||||
const lease = await claimSyncR2SnapshotWrite(env, snapshotClaim(key), NOW, TOKEN_A);
|
||||
await bucket.put(key, bytes("ciphertext-a"));
|
||||
|
||||
await abandonSyncR2Write(env, USER_ID, OWNER_HASH, key, lease.writeToken, NOW + 1);
|
||||
|
||||
assert.equal(candidateState(databasePath, key), "ready");
|
||||
assert.equal(await collectSyncR2Garbage(env, NOW + 1), 1);
|
||||
assert.equal(candidateState(databasePath, key), "deleted");
|
||||
assert.deepEqual(bucket.deletes, [key]);
|
||||
});
|
||||
});
|
||||
|
||||
it("resets sync state while preserving the vault generation and device envelopes", async () => {
|
||||
await withDatabase(async (databasePath, _database, bucket, env) => {
|
||||
seedVaultEnvelope(databasePath);
|
||||
const key = snapshotKey(HASH_A);
|
||||
const lease = await claimSyncR2SnapshotWrite(env, snapshotClaim(key), NOW, TOKEN_A);
|
||||
await bucket.put(key, bytes("ciphertext-a"));
|
||||
commitGenesis(databasePath, key, HASH_A, lease.writeToken);
|
||||
|
||||
const document = await syncResetDocument(
|
||||
await resetRequest("sync-reset-000001", NOW + 1),
|
||||
env,
|
||||
authContext(),
|
||||
NOW + 1,
|
||||
);
|
||||
|
||||
assert.equal(document.deleted.snapshots, 1);
|
||||
assert.equal(document.deleted.r2_objects, 1);
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT current_key_id, current_generation FROM sync_vault_accounts
|
||||
WHERE user_id = '${USER_ID}'
|
||||
`), [{ current_key_id: KEY_ID, current_generation: 1 }]);
|
||||
assert.equal(query(databasePath, `
|
||||
SELECT COUNT(*) AS count FROM sync_vault_envelopes WHERE user_id = '${USER_ID}'
|
||||
`)[0]?.count, 1);
|
||||
assert.equal(query(databasePath, `
|
||||
SELECT COUNT(*) AS count FROM user_devices WHERE user_id = '${USER_ID}'
|
||||
`)[0]?.count, 1);
|
||||
assert.equal(query(databasePath, `
|
||||
SELECT COUNT(*) AS count FROM sync_snapshots WHERE user_id = '${USER_ID}'
|
||||
`)[0]?.count, 0);
|
||||
assert.equal(candidateState(databasePath, key), "deleted");
|
||||
assert.deepEqual(bucket.deletes, [key]);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a fenced pending lease until a late R2 put can be collected", async () => {
|
||||
await withDatabase(async (databasePath, _database, bucket, env) => {
|
||||
const key = snapshotKey(HASH_A);
|
||||
await claimSyncR2SnapshotWrite(env, snapshotClaim(key), NOW, TOKEN_A);
|
||||
await syncResetDocument(
|
||||
await resetRequest("sync-reset-000002", NOW + 1),
|
||||
env,
|
||||
authContext(),
|
||||
NOW + 1,
|
||||
);
|
||||
|
||||
assert.equal(candidateState(databasePath, key), "ready");
|
||||
assert.equal(await collectSyncR2Garbage(env, NOW + 1, { userId: USER_ID }), 0);
|
||||
await bucket.put(key, bytes("late-pending-ciphertext"));
|
||||
assert.equal(await collectSyncR2Garbage(env, NOW + 600, { userId: USER_ID }), 1);
|
||||
assert.equal(candidateState(databasePath, key), "deleted");
|
||||
assert.equal(bucket.has(key), false);
|
||||
assert.deepEqual(bucket.deletes, [key]);
|
||||
assert.equal(query(databasePath, `
|
||||
SELECT COUNT(*) AS count FROM sync_vault_accounts WHERE user_id = '${USER_ID}'
|
||||
`)[0]?.count, 1);
|
||||
});
|
||||
});
|
||||
|
||||
it("rolls back reset when its authenticated authority changes before the batch", async () => {
|
||||
for (const beforeBatchSql of [
|
||||
"DELETE FROM better_auth_session WHERE id = 'session-01';",
|
||||
`UPDATE user_devices SET revoked_at = ${NOW} WHERE device_id = '${DEVICE_ID}';`,
|
||||
]) {
|
||||
await withDatabase(async (databasePath, _database, bucket, env) => {
|
||||
const key = snapshotKey(HASH_A);
|
||||
const lease = await claimSyncR2SnapshotWrite(env, snapshotClaim(key), NOW, TOKEN_A);
|
||||
await bucket.put(key, bytes("ciphertext-a"));
|
||||
commitGenesis(databasePath, key, HASH_A, lease.writeToken);
|
||||
const request = await resetRequest("sync-reset-authority-race", NOW + 1);
|
||||
const racedEnv = {
|
||||
...env,
|
||||
ELY_DB: new SqliteD1Database(databasePath, beforeBatchSql),
|
||||
} as Env;
|
||||
|
||||
await assert.rejects(
|
||||
() => syncResetDocument(request, racedEnv, authContext(), NOW + 1),
|
||||
/device_action_gate_failed/,
|
||||
);
|
||||
|
||||
assert.deepEqual(query(databasePath, `SELECT
|
||||
(SELECT COUNT(*) FROM sync_snapshots WHERE user_id = '${USER_ID}') AS snapshots,
|
||||
(SELECT COUNT(*) FROM sync_snapshot_encryption WHERE user_id = '${USER_ID}') AS encryption,
|
||||
(SELECT COUNT(*) FROM sync_snapshot_heads WHERE user_id = '${USER_ID}') AS heads,
|
||||
(SELECT COUNT(*) FROM audit_events WHERE event_type = 'sync.reset') AS audits
|
||||
`), [{ snapshots: 1, encryption: 1, heads: 1, audits: 0 }]);
|
||||
assert.equal(candidateState(databasePath, key), "referenced");
|
||||
assert.equal(bucket.has(key), true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("retries an idempotent R2 deletion after a crash before D1 finalization", async () => {
|
||||
await withDatabase(async (databasePath, _database, bucket, env) => {
|
||||
const key = snapshotKey(HASH_A);
|
||||
const lease = await claimSyncR2SnapshotWrite(env, snapshotClaim(key), NOW, TOKEN_A);
|
||||
await bucket.put(key, bytes("ciphertext-a"));
|
||||
await abandonSyncR2Write(env, USER_ID, OWNER_HASH, key, lease.writeToken, NOW + 1);
|
||||
bucket.crashAfterNextDelete = true;
|
||||
|
||||
await assert.rejects(() => collectSyncR2Garbage(env, NOW + 1), /simulated_delete_crash/);
|
||||
assert.equal(candidateState(databasePath, key), "deleting");
|
||||
assert.equal(bucket.has(key), false);
|
||||
|
||||
assert.equal(await collectSyncR2Garbage(env, NOW + 62), 1);
|
||||
assert.equal(candidateState(databasePath, key), "deleted");
|
||||
assert.deepEqual(bucket.deletes, [key, key]);
|
||||
});
|
||||
});
|
||||
|
||||
it("fences an upload that overlaps account deletion and clears the raw owner id", async () => {
|
||||
await withDatabase(async (databasePath, database, bucket, env) => {
|
||||
const key = snapshotKey(HASH_A);
|
||||
const lease = await claimSyncR2SnapshotWrite(env, snapshotClaim(key), NOW, TOKEN_A);
|
||||
await database.batch([
|
||||
database.prepare(SYNC_R2_FENCE_USER_QUERY).bind(NOW + 1, NOW + 1, NOW + 1, USER_ID),
|
||||
database.prepare(SYNC_R2_ANONYMIZE_USER_QUERY).bind(NOW + 1, USER_ID, OWNER_HASH),
|
||||
]);
|
||||
assert.equal(await collectSyncR2Garbage(env, NOW + 1, { ownerHash: OWNER_HASH }), 0);
|
||||
await bucket.put(key, bytes("late-ciphertext"));
|
||||
|
||||
assert.throws(
|
||||
() => commitGenesis(databasePath, key, HASH_A, lease.writeToken),
|
||||
/sync_r2_write_fenced/,
|
||||
);
|
||||
assert.deepEqual(candidateOwner(databasePath, key), {
|
||||
user_id: null,
|
||||
owner_hash: OWNER_HASH,
|
||||
state: "ready",
|
||||
});
|
||||
assert.equal(await collectSyncR2Garbage(
|
||||
env,
|
||||
lease.leaseExpiresAt,
|
||||
{ ownerHash: OWNER_HASH },
|
||||
), 1);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a pre-put claim after reset removes the vault authority", async () => {
|
||||
await withDatabase(async (_databasePath, database, _bucket, env) => {
|
||||
await database.prepare("DELETE FROM sync_vault_accounts WHERE user_id = ?")
|
||||
.bind(USER_ID)
|
||||
.run();
|
||||
|
||||
await assert.rejects(
|
||||
() => claimSyncR2SnapshotWrite(
|
||||
env,
|
||||
snapshotClaim(snapshotKey(HASH_A)),
|
||||
NOW,
|
||||
TOKEN_A,
|
||||
),
|
||||
/sync_r2_write_fenced/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("inventories historical snapshot and sync-payload orphans", async () => {
|
||||
await withDatabase(async (databasePath, _database, bucket, env) => {
|
||||
const snapshot = snapshotKey(HASH_A);
|
||||
const payload = payloadKey(HASH_B);
|
||||
await bucket.put(snapshot, bytes("snapshot-orphan"));
|
||||
await bucket.put(payload, bytes("payload-orphan"));
|
||||
|
||||
assert.equal(await inventorySyncR2Objects(env, NOW, 100), 1);
|
||||
assert.equal(await inventorySyncR2Objects(env, NOW + 1, 100), 1);
|
||||
assert.equal(candidateState(databasePath, snapshot), "ready");
|
||||
assert.equal(candidateState(databasePath, payload), "ready");
|
||||
assert.equal(await collectSyncR2Garbage(env, NOW + 1), 2);
|
||||
assert.deepEqual(bucket.deletes.sort(), [payload, snapshot].sort());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function withDatabase(
|
||||
assertions: (
|
||||
databasePath: string,
|
||||
database: SqliteD1Database,
|
||||
bucket: TestBucket,
|
||||
env: Env,
|
||||
) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "ely-r2-gc-"));
|
||||
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"));
|
||||
}
|
||||
seedAuthority(databasePath);
|
||||
const database = new SqliteD1Database(databasePath);
|
||||
const bucket = new TestBucket();
|
||||
const env = { ELY_DB: database, ELY_STORAGE: bucket } as unknown as Env;
|
||||
await assertions(databasePath, database, bucket, env);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function seedAuthority(databasePath: string): void {
|
||||
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}', '${PUBLIC_KEY}', '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}', '${PUBLIC_KEY}', '${"f".repeat(64)}', 2, 1
|
||||
);
|
||||
INSERT INTO better_auth_session (
|
||||
id, expiresAt, token, createdAt, updatedAt, userId
|
||||
) VALUES (
|
||||
'session-01', '2099-01-01T00:00:00Z', 'session-token-01',
|
||||
'2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', '${USER_ID}'
|
||||
);
|
||||
INSERT INTO better_auth_session_device_context (
|
||||
session_id, user_id, device_id, updated_at
|
||||
) VALUES ('session-01', '${USER_ID}', '${DEVICE_ID}', 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);
|
||||
`);
|
||||
}
|
||||
|
||||
function seedVaultEnvelope(databasePath: string): void {
|
||||
execute(databasePath, `
|
||||
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
|
||||
) VALUES (
|
||||
'${USER_ID}', '${DEVICE_ID}', '${DEVICE_ID}', '${KEY_ID}', 1, 1,
|
||||
'HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305',
|
||||
'${"A".repeat(43)}', '${"B".repeat(64)}', 'vault-bootstrap-0001', 1
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
function commitGenesis(databasePath: string, r2Key: string, payloadHash: string, token: string): void {
|
||||
execute(databasePath, `
|
||||
BEGIN IMMEDIATE;
|
||||
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
|
||||
) VALUES (
|
||||
'${USER_ID}', '${DEVICE_ID}', '${r2Key}', '${payloadHash}', 1, 1,
|
||||
'${DEVICE_ID}', 12, ${NOW}, 1, NULL, NULL, NULL
|
||||
);
|
||||
INSERT INTO sync_snapshot_encryption (
|
||||
user_id, snapshot_id, encryption_version, vault_generation, key_id, content_hash
|
||||
) VALUES ('${USER_ID}', '${DEVICE_ID}', 2, 1, '${KEY_ID}', '${HASH_B}');
|
||||
INSERT INTO sync_snapshot_heads (
|
||||
user_id, head_revision, snapshot_id, payload_hash, updated_at
|
||||
) VALUES ('${USER_ID}', 1, '${DEVICE_ID}', '${payloadHash}', ${NOW});
|
||||
UPDATE sync_r2_gc_candidates
|
||||
SET state = 'referenced', lease_expires_at = ${NOW},
|
||||
updated_at = ${NOW}, referenced_at = ${NOW}
|
||||
WHERE r2_key = '${r2Key}' AND user_id = '${USER_ID}'
|
||||
AND state = 'pending' AND write_token = '${token}'
|
||||
AND lease_expires_at >= ${NOW};
|
||||
COMMIT;
|
||||
`);
|
||||
}
|
||||
|
||||
function snapshotClaim(r2Key: string) {
|
||||
return {
|
||||
userId: USER_ID,
|
||||
deviceId: DEVICE_ID,
|
||||
r2Key,
|
||||
ownerHash: OWNER_HASH,
|
||||
keyId: KEY_ID,
|
||||
generation: 1,
|
||||
headRevision: 1,
|
||||
baseHead: null,
|
||||
} as const;
|
||||
}
|
||||
|
||||
function authContext() {
|
||||
return {
|
||||
userId: USER_ID,
|
||||
deviceId: DEVICE_ID,
|
||||
sessionId: "session-01",
|
||||
tokenHash: HASH_B,
|
||||
expiresAt: "2099-01-01T00:00:00Z",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
} as const;
|
||||
}
|
||||
|
||||
async function resetRequest(idempotencyKey: string, proofCreatedAt: number): Promise<Request> {
|
||||
const confirmation = "delete-cloud-sync-data";
|
||||
const actionProof = await signDeviceMessage(recentDeviceActionProofBytes({
|
||||
action: "sync.reset",
|
||||
userId: USER_ID,
|
||||
sessionId: authContext().sessionId,
|
||||
deviceId: DEVICE_ID,
|
||||
confirmation,
|
||||
idempotencyKey,
|
||||
proofCreatedAt,
|
||||
}));
|
||||
return new Request("https://elydora.test/api/sync/reset", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
version: 2,
|
||||
confirmation,
|
||||
idempotency_key: idempotencyKey,
|
||||
proof_created_at: proofCreatedAt,
|
||||
action_proof: actionProof,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function candidateState(databasePath: string, key: string): unknown {
|
||||
return query(databasePath, `
|
||||
SELECT state FROM sync_r2_gc_candidates WHERE r2_key = '${key}'
|
||||
`)[0]?.state;
|
||||
}
|
||||
|
||||
function candidateOwner(databasePath: string, key: string): Record<string, unknown> | undefined {
|
||||
return query(databasePath, `
|
||||
SELECT user_id, owner_hash, state FROM sync_r2_gc_candidates WHERE r2_key = '${key}'
|
||||
`)[0];
|
||||
}
|
||||
|
||||
function snapshotKey(hash: string): string {
|
||||
return `sync-snapshots/us-east/${OWNER_HASH}/${DEVICE_ID}/${hash}.bin`;
|
||||
}
|
||||
|
||||
function payloadKey(hash: string): string {
|
||||
return `sync-payloads/us-east/${OWNER_HASH}/bookmarks/object-01/${hash}.bin`;
|
||||
}
|
||||
|
||||
function bytes(value: string): ArrayBuffer {
|
||||
return new TextEncoder().encode(value).buffer;
|
||||
}
|
||||
|
||||
class TestBucket {
|
||||
readonly deletes: string[] = [];
|
||||
crashAfterNextDelete = false;
|
||||
private readonly values = new Map<string, ArrayBuffer>();
|
||||
|
||||
get(key: string): Promise<ElyR2Object | null> {
|
||||
const value = this.values.get(key);
|
||||
return Promise.resolve(value === undefined ? null : object(value));
|
||||
}
|
||||
|
||||
put(key: string, value: ArrayBuffer, _options?: ElyR2PutOptions): Promise<ElyR2Object> {
|
||||
this.values.set(key, value);
|
||||
return Promise.resolve(object(value));
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
this.deletes.push(key);
|
||||
this.values.delete(key);
|
||||
if (this.crashAfterNextDelete) {
|
||||
this.crashAfterNextDelete = false;
|
||||
throw new Error("simulated_delete_crash");
|
||||
}
|
||||
}
|
||||
|
||||
list(options: { prefix: string; cursor?: string; limit: number }) {
|
||||
const keys = [...this.values.keys()].filter((key) => key.startsWith(options.prefix)).sort();
|
||||
return Promise.resolve({
|
||||
objects: keys.slice(0, options.limit).map((key) => ({ key })),
|
||||
truncated: false as const,
|
||||
});
|
||||
}
|
||||
|
||||
has(key: string): boolean {
|
||||
return this.values.has(key);
|
||||
}
|
||||
}
|
||||
|
||||
function object(value: ArrayBuffer): ElyR2Object {
|
||||
return { arrayBuffer: () => Promise.resolve(value) };
|
||||
}
|
||||
@@ -4,7 +4,15 @@ import { describe, it } from "node:test";
|
||||
|
||||
import { authSessionCacheKvKey, authTokenHash } from "../src/auth.js";
|
||||
import { handleRequest } from "../src/index.js";
|
||||
import { ACCESS_TOKEN, sessionDocument, testD1Database, testEnv } from "./devices_test_support.js";
|
||||
import { recentDeviceActionProofBytes } from "../src/recent_device_action_proof.js";
|
||||
import {
|
||||
ACCESS_TOKEN,
|
||||
PUBLIC_KEY,
|
||||
sessionDocument,
|
||||
signDeviceMessage,
|
||||
testD1Database,
|
||||
testEnv,
|
||||
} from "./devices_test_support.js";
|
||||
|
||||
const USER_ID = "user-01";
|
||||
const DEVICE_ID = "device-01";
|
||||
@@ -19,12 +27,17 @@ describe("sync reset routes", () => {
|
||||
const r2Deletes: string[] = [];
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [{ device_id: DEVICE_ID }, null, resetCountsRow()],
|
||||
firstRows: [
|
||||
{ device_id: DEVICE_ID },
|
||||
{ signing_public_key: PUBLIC_KEY },
|
||||
null,
|
||||
resetCountsRow(),
|
||||
],
|
||||
allRows: [{ r2_key: PAYLOAD_KEY }, { r2_key: SNAPSHOT_KEY }],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
syncResetRequest(syncResetBody()),
|
||||
syncResetRequest(await syncResetBody()),
|
||||
testEnv({
|
||||
d1,
|
||||
r2Deletes,
|
||||
@@ -55,16 +68,31 @@ describe("sync reset routes", () => {
|
||||
r2_objects: 2,
|
||||
});
|
||||
assert.deepEqual(r2Deletes, [PAYLOAD_KEY, SNAPSHOT_KEY]);
|
||||
assert.equal(d1.batches[0], 5);
|
||||
assert.ok(d1.queries[1]?.includes("FROM audit_events"));
|
||||
assert.ok(d1.queries[2]?.includes("FROM sync_objects"));
|
||||
assert.ok(d1.queries[3]?.includes("UNION"));
|
||||
assert.ok(d1.queries[4]?.includes("DELETE FROM sync_change_log"));
|
||||
assert.ok(d1.queries[8]?.includes("INSERT INTO audit_events"));
|
||||
assert.deepEqual(d1.binds[1], [USER_ID, syncResetEventId()]);
|
||||
assert.deepEqual(d1.binds[2], [USER_ID, USER_ID, USER_ID, USER_ID]);
|
||||
assert.deepEqual(d1.binds[3], [USER_ID, USER_ID]);
|
||||
assert.deepEqual(d1.binds[8]?.slice(0, 4), [syncResetEventId(), USER_ID, DEVICE_ID, USER_ID]);
|
||||
assert.equal(d1.batches[0], 9);
|
||||
assert.ok(d1.queries[1]?.includes("signing_public_key"));
|
||||
assert.ok(d1.queries[2]?.includes("FROM audit_events"));
|
||||
assert.ok(d1.queries[3]?.includes("FROM sync_objects"));
|
||||
assert.ok(d1.queries[4]?.includes("FROM sync_r2_gc_candidates"));
|
||||
assert.deepEqual(d1.binds[2], [USER_ID, syncResetEventId()]);
|
||||
assert.deepEqual(d1.binds[3], [USER_ID, USER_ID, USER_ID, USER_ID]);
|
||||
assert.deepEqual(d1.binds[4], [USER_ID]);
|
||||
assert.ok(d1.queries[5]?.includes("CASE WHEN EXISTS"));
|
||||
assert.ok(d1.queries[6]?.includes("UPDATE sync_r2_gc_candidates"));
|
||||
assert.ok(d1.queries[7]?.includes("UPDATE sync_vault_rotations"));
|
||||
assert.ok(d1.queries[8]?.includes("DELETE FROM sync_change_log"));
|
||||
assert.ok(d1.queries[10]?.includes("DELETE FROM sync_snapshot_heads"));
|
||||
assert.ok(d1.queries[11]?.includes("DELETE FROM sync_snapshot_encryption"));
|
||||
assert.ok(d1.queries[12]?.includes("DELETE FROM sync_snapshots"));
|
||||
assert.equal(d1.queries.some((query) => query.includes("DELETE FROM sync_vault")), false);
|
||||
assert.deepEqual(d1.binds[5]?.slice(0, 6), [
|
||||
syncResetEventId(),
|
||||
USER_ID,
|
||||
DEVICE_ID,
|
||||
"sync.reset",
|
||||
"sync",
|
||||
USER_ID,
|
||||
]);
|
||||
assert.equal(d1.binds[5]?.[11], PUBLIC_KEY);
|
||||
});
|
||||
|
||||
it("returns an idempotent reset document for existing audit events", async () => {
|
||||
@@ -73,13 +101,14 @@ describe("sync reset routes", () => {
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
{ device_id: DEVICE_ID },
|
||||
{ signing_public_key: PUBLIC_KEY },
|
||||
{ actor_device_id: DEVICE_ID, outcome: "success", created_at: 1_780_001_000 },
|
||||
],
|
||||
allRows: [{ r2_key: PAYLOAD_KEY }],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
syncResetRequest(syncResetBody()),
|
||||
syncResetRequest(await syncResetBody()),
|
||||
testEnv({
|
||||
d1,
|
||||
r2Deletes,
|
||||
@@ -96,8 +125,8 @@ describe("sync reset routes", () => {
|
||||
reset_at: 1_780_001_000,
|
||||
deleted: { objects: 0, changes: 0, snapshots: 0, tombstones: 0, r2_objects: 0 },
|
||||
});
|
||||
assert.deepEqual(r2Deletes, []);
|
||||
assert.equal(d1.queries.length, 2);
|
||||
assert.deepEqual(r2Deletes, [PAYLOAD_KEY]);
|
||||
assert.equal(d1.queries.length, 6);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
@@ -107,12 +136,13 @@ describe("sync reset routes", () => {
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
{ device_id: DEVICE_ID },
|
||||
{ signing_public_key: PUBLIC_KEY },
|
||||
{ actor_device_id: "device-02", outcome: "success", created_at: 1_780_001_000 },
|
||||
],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
syncResetRequest(syncResetBody()),
|
||||
syncResetRequest(await syncResetBody()),
|
||||
testEnv({
|
||||
d1,
|
||||
r2Deletes,
|
||||
@@ -132,7 +162,7 @@ describe("sync reset routes", () => {
|
||||
const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }] });
|
||||
|
||||
const response = await handleRequest(
|
||||
syncResetRequest(syncResetBody({ confirmation: "delete" })),
|
||||
syncResetRequest(await syncResetBody({ confirmation: "delete" })),
|
||||
testEnv({
|
||||
d1,
|
||||
r2Deletes,
|
||||
@@ -152,7 +182,7 @@ describe("sync reset routes", () => {
|
||||
const d1 = testD1Database({ firstRows: [null] });
|
||||
|
||||
const response = await handleRequest(
|
||||
syncResetRequest(syncResetBody()),
|
||||
syncResetRequest(await syncResetBody()),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
@@ -165,16 +195,21 @@ describe("sync reset routes", () => {
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("fails closed when stored R2 keys are malformed", async () => {
|
||||
it("keeps reset successful when scheduled GC must handle a malformed legacy key", async () => {
|
||||
const r2Deletes: string[] = [];
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [{ device_id: DEVICE_ID }, null, resetCountsRow()],
|
||||
firstRows: [
|
||||
{ device_id: DEVICE_ID },
|
||||
{ signing_public_key: PUBLIC_KEY },
|
||||
null,
|
||||
resetCountsRow(),
|
||||
],
|
||||
allRows: [{ r2_key: "sync-snapshots/../bad.bin" }],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
syncResetRequest(syncResetBody()),
|
||||
syncResetRequest(await syncResetBody()),
|
||||
testEnv({
|
||||
d1,
|
||||
r2Deletes,
|
||||
@@ -182,10 +217,9 @@ describe("sync reset routes", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 500);
|
||||
assert.deepEqual(await response.json(), { error: "sync_reset_failed" });
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(r2Deletes, []);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
assert.deepEqual(d1.batches, [9]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -200,13 +234,26 @@ function syncResetRequest(body: Record<string, unknown>): Request {
|
||||
});
|
||||
}
|
||||
|
||||
function syncResetBody(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
async function syncResetBody(
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const body: Record<string, unknown> = {
|
||||
version: 2,
|
||||
confirmation: "delete-cloud-sync-data",
|
||||
idempotency_key: IDEMPOTENCY_KEY,
|
||||
proof_created_at: Math.floor(Date.now() / 1000),
|
||||
...overrides,
|
||||
};
|
||||
body.action_proof = await signDeviceMessage(recentDeviceActionProofBytes({
|
||||
action: "sync.reset",
|
||||
userId: USER_ID,
|
||||
sessionId: "session-01",
|
||||
deviceId: DEVICE_ID,
|
||||
confirmation: String(body.confirmation),
|
||||
idempotencyKey: String(body.idempotency_key),
|
||||
proofCreatedAt: Number(body.proof_created_at),
|
||||
}));
|
||||
return body;
|
||||
}
|
||||
|
||||
function resetCountsRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { payloadBytes, SyncSnapshotRequestError } from "../src/sync_snapshot_codec.js";
|
||||
|
||||
describe("sync snapshot codec", () => {
|
||||
it("rejects oversized base64 before decoding", () => {
|
||||
const originalAtob = globalThis.atob;
|
||||
let decoded = false;
|
||||
globalThis.atob = () => {
|
||||
decoded = true;
|
||||
return "";
|
||||
};
|
||||
try {
|
||||
assert.throws(
|
||||
() => payloadBytes("AAAAAAAA", "data_base64", 3),
|
||||
(error) =>
|
||||
error instanceof SyncSnapshotRequestError &&
|
||||
error.message === "data_base64_size_invalid",
|
||||
);
|
||||
} finally {
|
||||
globalThis.atob = originalAtob;
|
||||
}
|
||||
assert.equal(decoded, false);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { DatabaseSync, type SQLInputValue } from "node:sqlite";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import {
|
||||
SYNC_SNAPSHOT_CANDIDATE_UPSERT_QUERY,
|
||||
SYNC_SNAPSHOT_ENCRYPTION_UPSERT_QUERY,
|
||||
SYNC_SNAPSHOT_HEAD_INSERT_QUERY,
|
||||
SYNC_SNAPSHOT_HEAD_QUERY,
|
||||
SYNC_SNAPSHOT_HEAD_UPDATE_QUERY,
|
||||
} from "../src/sync_snapshot_sql.js";
|
||||
import { SYNC_R2_MARK_REFERENCED_QUERY } from "../src/sync_r2_gc.js";
|
||||
import {
|
||||
SyncSnapshotHeadSchemaError,
|
||||
type SyncSnapshotRow,
|
||||
snapshotDocumentFromRow,
|
||||
} from "../src/sync_snapshot_head.js";
|
||||
|
||||
const USER_ID = "user-01";
|
||||
const DEVICE_ID = "device-01";
|
||||
const KEY_ID = "1".repeat(64);
|
||||
const NEXT_KEY_ID = "2".repeat(64);
|
||||
const WRITE_TOKEN = "9".repeat(64);
|
||||
const MIGRATIONS_DIR = join(process.cwd(), "migrations");
|
||||
|
||||
interface HeadRef {
|
||||
revision: number;
|
||||
snapshotId: string;
|
||||
payloadHash: string;
|
||||
}
|
||||
|
||||
interface Candidate {
|
||||
snapshotId: string;
|
||||
payloadHash: string;
|
||||
contentHash: string;
|
||||
logicalClock: number;
|
||||
headRevision: number;
|
||||
base: HeadRef | null;
|
||||
keyId?: string;
|
||||
generation?: number;
|
||||
}
|
||||
|
||||
describe("sync snapshot head SQLite guards", () => {
|
||||
it("commits a genesis head through the route SQL", () => {
|
||||
using database = databaseWithApprovedDevice();
|
||||
const genesis = candidate({ payloadHash: "a".repeat(64) });
|
||||
|
||||
commitCandidate(database, genesis);
|
||||
|
||||
assert.deepEqual(currentHead(database), {
|
||||
head_revision: 1,
|
||||
snapshot_id: "device-01",
|
||||
payload_hash: "a".repeat(64),
|
||||
});
|
||||
});
|
||||
|
||||
it("allows one writer per base even when the stale writer has a higher clock", () => {
|
||||
using database = databaseWithApprovedDevice();
|
||||
const genesis = candidate({ payloadHash: "a".repeat(64) });
|
||||
commitCandidate(database, genesis);
|
||||
const base = headRef(genesis);
|
||||
const winner = candidate({
|
||||
payloadHash: "b".repeat(64),
|
||||
contentHash: "3".repeat(64),
|
||||
logicalClock: 11,
|
||||
headRevision: 2,
|
||||
base,
|
||||
});
|
||||
commitCandidate(database, winner);
|
||||
const loser = candidate({
|
||||
payloadHash: "c".repeat(64),
|
||||
contentHash: "4".repeat(64),
|
||||
logicalClock: 999,
|
||||
headRevision: 2,
|
||||
base,
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => commitCandidate(database, loser),
|
||||
/sync_r2_write_fenced/,
|
||||
);
|
||||
assert.deepEqual(currentHead(database), {
|
||||
head_revision: 2,
|
||||
snapshot_id: "device-01",
|
||||
payload_hash: "b".repeat(64),
|
||||
});
|
||||
assert.deepEqual(snapshotState(database, "device-01"), {
|
||||
payload_hash: "b".repeat(64),
|
||||
content_hash: "3".repeat(64),
|
||||
logical_clock: 11,
|
||||
head_revision: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("rolls back candidate metadata when the final head guard aborts", () => {
|
||||
using database = databaseWithApprovedDevice();
|
||||
const genesis = candidate({ payloadHash: "a".repeat(64) });
|
||||
commitCandidate(database, genesis);
|
||||
const child = candidate({
|
||||
snapshotId: "device-02",
|
||||
payloadHash: "b".repeat(64),
|
||||
contentHash: "3".repeat(64),
|
||||
logicalClock: 11,
|
||||
headRevision: 2,
|
||||
base: headRef(genesis),
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => commitCandidate(database, child, "f".repeat(64)),
|
||||
/sync_r2_write_fenced/,
|
||||
);
|
||||
assert.equal(snapshotState(database, "device-02"), undefined);
|
||||
assert.deepEqual(currentHead(database), {
|
||||
head_revision: 1,
|
||||
snapshot_id: "device-01",
|
||||
payload_hash: "a".repeat(64),
|
||||
});
|
||||
});
|
||||
|
||||
it("advances an old-generation base with the current rotated key", () => {
|
||||
using database = databaseWithApprovedDevice();
|
||||
const genesis = candidate({ payloadHash: "a".repeat(64) });
|
||||
commitCandidate(database, genesis);
|
||||
database.prepare(`
|
||||
UPDATE sync_vault_accounts
|
||||
SET current_key_id = ?, current_generation = 2, updated_at = 2
|
||||
WHERE user_id = ?
|
||||
`).run(NEXT_KEY_ID, USER_ID);
|
||||
const child = candidate({
|
||||
payloadHash: "b".repeat(64),
|
||||
contentHash: "3".repeat(64),
|
||||
logicalClock: 11,
|
||||
headRevision: 2,
|
||||
base: headRef(genesis),
|
||||
keyId: NEXT_KEY_ID,
|
||||
generation: 2,
|
||||
});
|
||||
|
||||
commitCandidate(database, child);
|
||||
|
||||
assert.deepEqual(snapshotState(database, DEVICE_ID), {
|
||||
payload_hash: "b".repeat(64),
|
||||
content_hash: "3".repeat(64),
|
||||
logical_clock: 11,
|
||||
head_revision: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces a current head whose encryption row is missing", () => {
|
||||
using database = databaseWithApprovedDevice();
|
||||
const genesis = candidate({ payloadHash: "a".repeat(64) });
|
||||
commitCandidate(database, genesis);
|
||||
const deletion = database.prepare(`
|
||||
DELETE FROM sync_snapshot_encryption
|
||||
WHERE user_id = ? AND snapshot_id = ?
|
||||
`);
|
||||
assert.throws(() => deletion.run(USER_ID, DEVICE_ID), /FOREIGN KEY constraint failed/);
|
||||
database.exec("PRAGMA foreign_keys = OFF");
|
||||
deletion.run(USER_ID, DEVICE_ID);
|
||||
database.exec("PRAGMA foreign_keys = ON");
|
||||
const row = database.prepare(SYNC_SNAPSHOT_HEAD_QUERY).get(USER_ID) as
|
||||
| SyncSnapshotRow
|
||||
| undefined;
|
||||
|
||||
assert.ok(row !== undefined);
|
||||
assert.throws(
|
||||
() => snapshotDocumentFromRow(row),
|
||||
(error) =>
|
||||
error instanceof SyncSnapshotHeadSchemaError &&
|
||||
error.message === "encryption_version_invalid",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function databaseWithApprovedDevice(): DatabaseSync {
|
||||
const database = new DatabaseSync(":memory:");
|
||||
database.exec("PRAGMA foreign_keys = ON");
|
||||
for (const fileName of readdirSync(MIGRATIONS_DIR).filter((name) => name.endsWith(".sql")).sort()) {
|
||||
database.exec(readFileSync(join(MIGRATIONS_DIR, fileName), "utf8"));
|
||||
}
|
||||
database.exec(`
|
||||
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);
|
||||
`);
|
||||
return database;
|
||||
}
|
||||
|
||||
function commitCandidate(
|
||||
database: DatabaseSync,
|
||||
value: Candidate,
|
||||
headPayloadHash = value.payloadHash,
|
||||
): void {
|
||||
const snapshotValues = candidateValues(value);
|
||||
const r2Key = candidateR2Key(value);
|
||||
database.prepare(`
|
||||
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
|
||||
) VALUES (?, ?, ?, 'snapshot', 'pending', ?, 1000, NULL, 0, 0, NULL, NULL, NULL, NULL)
|
||||
`).run(r2Key, USER_ID, "f".repeat(64), WRITE_TOKEN);
|
||||
database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
database.prepare(SYNC_SNAPSHOT_CANDIDATE_UPSERT_QUERY).run(
|
||||
...snapshotValues,
|
||||
value.keyId ?? KEY_ID,
|
||||
value.generation ?? 1,
|
||||
WRITE_TOKEN,
|
||||
value.headRevision,
|
||||
);
|
||||
database.prepare(SYNC_SNAPSHOT_ENCRYPTION_UPSERT_QUERY).run(
|
||||
...snapshotValues,
|
||||
2,
|
||||
value.generation ?? 1,
|
||||
value.keyId ?? KEY_ID,
|
||||
value.contentHash,
|
||||
WRITE_TOKEN,
|
||||
value.headRevision,
|
||||
);
|
||||
if (value.base === null) {
|
||||
database.prepare(SYNC_SNAPSHOT_HEAD_INSERT_QUERY).run(
|
||||
USER_ID,
|
||||
value.headRevision,
|
||||
value.snapshotId,
|
||||
headPayloadHash,
|
||||
value.headRevision,
|
||||
r2Key,
|
||||
USER_ID,
|
||||
WRITE_TOKEN,
|
||||
value.headRevision,
|
||||
);
|
||||
} else {
|
||||
database.prepare(SYNC_SNAPSHOT_HEAD_UPDATE_QUERY).run(
|
||||
value.headRevision,
|
||||
value.snapshotId,
|
||||
headPayloadHash,
|
||||
value.headRevision,
|
||||
USER_ID,
|
||||
r2Key,
|
||||
USER_ID,
|
||||
WRITE_TOKEN,
|
||||
value.headRevision,
|
||||
);
|
||||
}
|
||||
database.prepare(SYNC_R2_MARK_REFERENCED_QUERY).run(
|
||||
value.headRevision,
|
||||
value.headRevision,
|
||||
value.headRevision,
|
||||
r2Key,
|
||||
USER_ID,
|
||||
WRITE_TOKEN,
|
||||
value.headRevision,
|
||||
);
|
||||
database.exec("COMMIT");
|
||||
} catch (error) {
|
||||
database.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function candidateValues(value: Candidate): SQLInputValue[] {
|
||||
return [
|
||||
USER_ID,
|
||||
value.snapshotId,
|
||||
candidateR2Key(value),
|
||||
value.payloadHash,
|
||||
1,
|
||||
value.logicalClock,
|
||||
DEVICE_ID,
|
||||
26,
|
||||
value.headRevision,
|
||||
value.headRevision,
|
||||
value.base?.revision ?? null,
|
||||
value.base?.snapshotId ?? null,
|
||||
value.base?.payloadHash ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
function candidateR2Key(value: Candidate): string {
|
||||
return `sync-snapshots/us-east/${"f".repeat(64)}/${value.snapshotId}/${value.payloadHash}.bin`;
|
||||
}
|
||||
|
||||
function candidate(overrides: Partial<Candidate>): Candidate {
|
||||
return {
|
||||
snapshotId: DEVICE_ID,
|
||||
payloadHash: "a".repeat(64),
|
||||
contentHash: "2".repeat(64),
|
||||
logicalClock: 10,
|
||||
headRevision: 1,
|
||||
base: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function headRef(value: Candidate): HeadRef {
|
||||
return {
|
||||
revision: value.headRevision,
|
||||
snapshotId: value.snapshotId,
|
||||
payloadHash: value.payloadHash,
|
||||
};
|
||||
}
|
||||
|
||||
function currentHead(database: DatabaseSync): Record<string, unknown> | undefined {
|
||||
const row = database.prepare(`
|
||||
SELECT head_revision, snapshot_id, payload_hash
|
||||
FROM sync_snapshot_heads
|
||||
WHERE user_id = ?
|
||||
`).get(USER_ID) as Record<string, unknown> | undefined;
|
||||
return row === undefined ? undefined : { ...row };
|
||||
}
|
||||
|
||||
function snapshotState(
|
||||
database: DatabaseSync,
|
||||
snapshotId: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
const row = database.prepare(`
|
||||
SELECT
|
||||
snapshot.payload_hash,
|
||||
encryption.content_hash,
|
||||
snapshot.logical_clock,
|
||||
snapshot.head_revision
|
||||
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
|
||||
WHERE snapshot.user_id = ? AND snapshot.snapshot_id = ?
|
||||
`).get(USER_ID, snapshotId) as Record<string, unknown> | undefined;
|
||||
return row === undefined ? undefined : { ...row };
|
||||
}
|
||||
@@ -14,273 +14,372 @@ import {
|
||||
|
||||
const USER_ID = "user-01";
|
||||
const DEVICE_ID = "device-01";
|
||||
const SNAPSHOT_ID = "snapshot-01";
|
||||
const SNAPSHOT_ID = "device-01";
|
||||
const REGION = "us-east";
|
||||
const KEY_ID = "1".repeat(64);
|
||||
const CONTENT_HASH = "2".repeat(64);
|
||||
|
||||
describe("sync snapshot routes", () => {
|
||||
it("uploads an encrypted snapshot from an approved current device", async () => {
|
||||
const payload = bytes("encrypted snapshot payload");
|
||||
it("commits a genesis encrypted snapshot as the global head", async () => {
|
||||
const payload = opaqueEnvelopeBytes();
|
||||
const payloadHash = sha256(payload);
|
||||
const key = snapshotKey(payloadHash);
|
||||
const row = snapshotRow({
|
||||
r2_key: snapshotKey(payloadHash),
|
||||
payload_hash: payloadHash,
|
||||
size_bytes: payload.byteLength,
|
||||
});
|
||||
const r2Puts: RecordedR2Put[] = [];
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
{ device_id: DEVICE_ID },
|
||||
null,
|
||||
snapshotRow({ r2_key: key, payload_hash: payloadHash, size_bytes: payload.byteLength }),
|
||||
],
|
||||
firstRows: [{ device_id: DEVICE_ID }, null, vaultKeyRow()],
|
||||
batchRowSets: [[[], [], [], [], [row]]],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
syncSnapshotPostRequest(
|
||||
syncSnapshotBody({ payload_hash: payloadHash, data_base64: base64(payload) }),
|
||||
),
|
||||
testEnv({
|
||||
d1,
|
||||
r2Puts,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
syncSnapshotPostRequest(syncSnapshotBody({
|
||||
payload_hash: payloadHash,
|
||||
data_base64: base64(payload),
|
||||
})),
|
||||
await authorizedEnv(d1, { r2Puts }),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 201);
|
||||
assert.equal(response.headers.get("cache-control"), "no-store");
|
||||
assert.deepEqual(await response.json(), {
|
||||
version: 1,
|
||||
user_id: USER_ID,
|
||||
device_id: DEVICE_ID,
|
||||
snapshot: snapshotDocument({
|
||||
r2_key: key,
|
||||
payload_hash: payloadHash,
|
||||
size_bytes: payload.byteLength,
|
||||
}),
|
||||
});
|
||||
assert.deepEqual(await response.json(), uploadDocument(row));
|
||||
assert.equal(r2Puts.length, 1);
|
||||
assert.equal(r2Puts[0]?.key, key);
|
||||
assert.deepEqual(new Uint8Array(r2Puts[0]?.payload ?? new ArrayBuffer(0)), new Uint8Array(payload));
|
||||
assert.equal(r2Puts[0]?.options.customMetadata?.sha256, payloadHash);
|
||||
assert.equal(d1.batches[0], 1);
|
||||
assert.ok(d1.queries[1]?.includes("FROM sync_snapshots"));
|
||||
assert.ok(d1.queries[2]?.includes("INSERT INTO sync_snapshots"));
|
||||
assert.deepEqual(d1.binds[2]?.slice(0, 4), [USER_ID, SNAPSHOT_ID, key, payloadHash]);
|
||||
assert.equal(d1.batches[0], 5);
|
||||
assert.ok(d1.queries.some((query) => query.includes("INSERT INTO sync_snapshot_heads")));
|
||||
});
|
||||
|
||||
it("downloads an encrypted snapshot with R2 checksum verification", async () => {
|
||||
const payload = bytes("encrypted snapshot payload");
|
||||
it("returns the original success document for an exact replay", async () => {
|
||||
const payload = opaqueEnvelopeBytes();
|
||||
const payloadHash = sha256(payload);
|
||||
const key = snapshotKey(payloadHash);
|
||||
const r2Gets: string[] = [];
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const row = snapshotRow({
|
||||
r2_key: snapshotKey(payloadHash),
|
||||
payload_hash: payloadHash,
|
||||
size_bytes: payload.byteLength,
|
||||
});
|
||||
const r2Puts: RecordedR2Put[] = [];
|
||||
const d1 = testD1Database({
|
||||
firstRows: [{ device_id: DEVICE_ID }, row, vaultKeyRow()],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
syncSnapshotPostRequest(syncSnapshotBody({
|
||||
payload_hash: payloadHash,
|
||||
data_base64: base64(payload),
|
||||
})),
|
||||
await authorizedEnv(d1, { r2Puts }),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 201);
|
||||
assert.deepEqual(await response.json(), uploadDocument(row));
|
||||
assert.deepEqual(d1.batches, []);
|
||||
assert.equal(r2Puts.length, 0);
|
||||
assert.ok(d1.queries.some((query) => query.includes("SET cleanup_snapshot_id = ?")));
|
||||
});
|
||||
|
||||
it("returns the original success after the vault rotates", async () => {
|
||||
const payload = opaqueEnvelopeBytes();
|
||||
const payloadHash = sha256(payload);
|
||||
const row = snapshotRow({
|
||||
r2_key: snapshotKey(payloadHash),
|
||||
payload_hash: payloadHash,
|
||||
size_bytes: payload.byteLength,
|
||||
});
|
||||
const r2Puts: RecordedR2Put[] = [];
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
{ device_id: DEVICE_ID },
|
||||
snapshotRow({ r2_key: key, payload_hash: payloadHash, size_bytes: payload.byteLength }),
|
||||
row,
|
||||
{ key_id: "f".repeat(64), generation: 2 },
|
||||
],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
syncSnapshotGetRequest(),
|
||||
testEnv({
|
||||
d1,
|
||||
r2Gets,
|
||||
r2Objects: [[key, payload]],
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
syncSnapshotPostRequest(syncSnapshotBody({
|
||||
payload_hash: payloadHash,
|
||||
data_base64: base64(payload),
|
||||
})),
|
||||
await authorizedEnv(d1, { r2Puts }),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 201);
|
||||
assert.deepEqual(await response.json(), uploadDocument(row));
|
||||
assert.equal(r2Puts.length, 0);
|
||||
assert.equal(d1.queries.some((query) => query.includes("SET cleanup_snapshot_id = ?")), false);
|
||||
});
|
||||
|
||||
it("returns the committed document when an identical concurrent writer wins", async () => {
|
||||
const payload = opaqueEnvelopeBytes();
|
||||
const payloadHash = sha256(payload);
|
||||
const baseRow = snapshotRow({ payload_hash: "a".repeat(64) });
|
||||
const committed = snapshotRow({
|
||||
payload_hash: payloadHash,
|
||||
r2_key: snapshotKey(payloadHash),
|
||||
content_hash: CONTENT_HASH,
|
||||
logical_clock: 43,
|
||||
head_revision: 2,
|
||||
base_head_revision: 1,
|
||||
base_snapshot_id: SNAPSHOT_ID,
|
||||
base_payload_hash: "a".repeat(64),
|
||||
size_bytes: payload.byteLength,
|
||||
});
|
||||
const d1 = testD1Database({
|
||||
firstRows: [{ device_id: DEVICE_ID }, baseRow, vaultKeyRow(), committed],
|
||||
batchError: new Error("sync_snapshot_head_cas_failed"),
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
syncSnapshotPostRequest(syncSnapshotBody({
|
||||
payload_hash: payloadHash,
|
||||
logical_clock: 43,
|
||||
head_revision: 2,
|
||||
base_head: headRef(baseRow),
|
||||
data_base64: base64(payload),
|
||||
})),
|
||||
await authorizedEnv(d1),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 201);
|
||||
assert.deepEqual(await response.json(), uploadDocument(committed));
|
||||
assert.deepEqual(d1.sessionConstraints, ["first-primary", "first-primary"]);
|
||||
});
|
||||
|
||||
it("rejects a stale base before writing R2", async () => {
|
||||
const current = snapshotRow({ payload_hash: "a".repeat(64) });
|
||||
const staleBase = headRef({ payload_hash: "b".repeat(64) });
|
||||
const r2Puts: RecordedR2Put[] = [];
|
||||
const d1 = testD1Database({
|
||||
firstRows: [{ device_id: DEVICE_ID }, current, vaultKeyRow()],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
syncSnapshotPostRequest(syncSnapshotBody({
|
||||
head_revision: 2,
|
||||
base_head: staleBase,
|
||||
logical_clock: 43,
|
||||
})),
|
||||
await authorizedEnv(d1, { r2Puts }),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 409);
|
||||
assert.deepEqual(await response.json(), conflictDocument(current));
|
||||
assert.equal(r2Puts.length, 0);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("returns the winning head when D1 rejects a concurrent writer", async () => {
|
||||
const baseRow = snapshotRow({ payload_hash: "a".repeat(64) });
|
||||
const winner = snapshotRow({
|
||||
payload_hash: "b".repeat(64),
|
||||
r2_key: snapshotKey("b".repeat(64)),
|
||||
content_hash: "3".repeat(64),
|
||||
logical_clock: 43,
|
||||
head_revision: 2,
|
||||
base_head_revision: 1,
|
||||
base_snapshot_id: SNAPSHOT_ID,
|
||||
base_payload_hash: "a".repeat(64),
|
||||
});
|
||||
const d1 = testD1Database({
|
||||
firstRows: [{ device_id: DEVICE_ID }, baseRow, vaultKeyRow(), winner],
|
||||
batchError: new Error("sync_snapshot_head_cas_failed"),
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
syncSnapshotPostRequest(syncSnapshotBody({
|
||||
head_revision: 2,
|
||||
base_head: headRef(baseRow),
|
||||
logical_clock: 44,
|
||||
})),
|
||||
await authorizedEnv(d1),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 409);
|
||||
assert.deepEqual(await response.json(), conflictDocument(winner));
|
||||
});
|
||||
|
||||
it("downloads a snapshot only through its exact head token", async () => {
|
||||
const payload = opaqueEnvelopeBytes();
|
||||
const payloadHash = sha256(payload);
|
||||
const key = snapshotKey(payloadHash);
|
||||
const row = snapshotRow({
|
||||
r2_key: key,
|
||||
payload_hash: payloadHash,
|
||||
size_bytes: payload.byteLength,
|
||||
});
|
||||
const r2Gets: string[] = [];
|
||||
const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }, row] });
|
||||
|
||||
const response = await handleRequest(
|
||||
syncSnapshotGetRequest(headRef(row)),
|
||||
await authorizedEnv(d1, { r2Gets, r2Objects: [[key, payload]] }),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), {
|
||||
version: 1,
|
||||
user_id: USER_ID,
|
||||
device_id: DEVICE_ID,
|
||||
snapshot: snapshotDocument({
|
||||
r2_key: key,
|
||||
payload_hash: payloadHash,
|
||||
size_bytes: payload.byteLength,
|
||||
}),
|
||||
...uploadDocument(row),
|
||||
data_base64: base64(payload),
|
||||
});
|
||||
assert.deepEqual(r2Gets, [key]);
|
||||
});
|
||||
|
||||
it("returns not found for missing snapshot indexes", async () => {
|
||||
const r2Gets: string[] = [];
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }, null] });
|
||||
it("returns the current head for a historical different-device token", async () => {
|
||||
const current = snapshotRow({
|
||||
snapshot_id: "device-02",
|
||||
payload_hash: "b".repeat(64),
|
||||
r2_key: snapshotKey("b".repeat(64)),
|
||||
head_revision: 2,
|
||||
base_head_revision: 1,
|
||||
base_snapshot_id: SNAPSHOT_ID,
|
||||
base_payload_hash: "a".repeat(64),
|
||||
});
|
||||
const d1 = testD1Database({
|
||||
firstRows: [{ device_id: DEVICE_ID }, null, current],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
syncSnapshotGetRequest(),
|
||||
testEnv({
|
||||
d1,
|
||||
r2Gets,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
syncSnapshotGetRequest(headRef({ payload_hash: "a".repeat(64) })),
|
||||
await authorizedEnv(d1),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 404);
|
||||
assert.deepEqual(await response.json(), { error: "sync_snapshot_not_found" });
|
||||
assert.deepEqual(r2Gets, []);
|
||||
assert.equal(response.status, 409);
|
||||
assert.deepEqual(await response.json(), conflictDocument(current));
|
||||
});
|
||||
|
||||
it("rejects snapshot checksum mismatches before D1 writes", async () => {
|
||||
const payload = bytes("encrypted snapshot payload");
|
||||
it("returns a new head when cleanup removes payload after token validation", async () => {
|
||||
const old = snapshotRow();
|
||||
const current = snapshotRow({
|
||||
snapshot_id: "device-02",
|
||||
payload_hash: "b".repeat(64),
|
||||
r2_key: snapshotKey("b".repeat(64)),
|
||||
head_revision: 2,
|
||||
base_head_revision: 1,
|
||||
base_snapshot_id: SNAPSHOT_ID,
|
||||
base_payload_hash: "a".repeat(64),
|
||||
logical_clock: 43,
|
||||
});
|
||||
const d1 = testD1Database({
|
||||
firstRows: [{ device_id: DEVICE_ID }, old, current],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
syncSnapshotGetRequest(headRef(old)),
|
||||
await authorizedEnv(d1),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 409);
|
||||
assert.deepEqual(await response.json(), conflictDocument(current));
|
||||
assert.deepEqual(d1.sessionConstraints, ["first-primary", "first-primary"]);
|
||||
});
|
||||
|
||||
it("preserves legacy encryption metadata on exact downloads", async () => {
|
||||
const payload = opaqueEnvelopeBytes();
|
||||
const payloadHash = sha256(payload);
|
||||
const key = snapshotKey(payloadHash);
|
||||
const row = snapshotRow({
|
||||
r2_key: key,
|
||||
payload_hash: payloadHash,
|
||||
encryption_version: 1,
|
||||
size_bytes: payload.byteLength,
|
||||
});
|
||||
const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }, row] });
|
||||
|
||||
const response = await handleRequest(
|
||||
syncSnapshotGetRequest(headRef(row)),
|
||||
await authorizedEnv(d1, { r2Objects: [[key, payload]] }),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const document = await response.json() as { version: number; snapshot: { encryption_version: number } };
|
||||
assert.equal(document.version, 3);
|
||||
assert.equal(document.snapshot.encryption_version, 1);
|
||||
});
|
||||
|
||||
it("rejects upload wire version 2 before R2 and D1 writes", async () => {
|
||||
const r2Puts: RecordedR2Put[] = [];
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }] });
|
||||
|
||||
const response = await handleRequest(
|
||||
syncSnapshotPostRequest(
|
||||
syncSnapshotBody({ payload_hash: "c".repeat(64), data_base64: base64(payload) }),
|
||||
),
|
||||
testEnv({
|
||||
d1,
|
||||
r2Puts,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
syncSnapshotPostRequest(syncSnapshotBody({ version: 2 })),
|
||||
await authorizedEnv(d1, { r2Puts }),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.deepEqual(await response.json(), { error: "invalid_sync_snapshot" });
|
||||
assert.equal(r2Puts.length, 0);
|
||||
assert.equal(d1.queries.length, 1);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("rejects stale snapshot clocks before R2 writes", async () => {
|
||||
const payload = bytes("encrypted snapshot payload");
|
||||
const payloadHash = sha256(payload);
|
||||
const r2Puts: RecordedR2Put[] = [];
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
it("fails closed when the committed head SELECT is empty", async () => {
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
{ device_id: DEVICE_ID },
|
||||
snapshotRow({ payload_hash: "d".repeat(64), logical_clock: 43 }),
|
||||
],
|
||||
firstRows: [{ device_id: DEVICE_ID }, null, vaultKeyRow()],
|
||||
batchRowSets: [[[], [], [], [], []]],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
syncSnapshotPostRequest(
|
||||
syncSnapshotBody({
|
||||
payload_hash: payloadHash,
|
||||
logical_clock: 42,
|
||||
data_base64: base64(payload),
|
||||
}),
|
||||
),
|
||||
testEnv({
|
||||
d1,
|
||||
r2Puts,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 409);
|
||||
assert.deepEqual(await response.json(), { error: "sync_snapshot_conflict" });
|
||||
assert.equal(r2Puts.length, 0);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("rejects same-clock snapshot write races after D1 persistence", async () => {
|
||||
const payload = bytes("encrypted snapshot payload");
|
||||
const payloadHash = sha256(payload);
|
||||
const key = snapshotKey(payloadHash);
|
||||
const r2Puts: RecordedR2Put[] = [];
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
{ device_id: DEVICE_ID },
|
||||
null,
|
||||
snapshotRow({ r2_key: key, payload_hash: "e".repeat(64), size_bytes: payload.byteLength }),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
syncSnapshotPostRequest(
|
||||
syncSnapshotBody({ payload_hash: payloadHash, data_base64: base64(payload) }),
|
||||
),
|
||||
testEnv({
|
||||
d1,
|
||||
r2Puts,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 409);
|
||||
assert.deepEqual(await response.json(), { error: "sync_snapshot_conflict" });
|
||||
assert.equal(r2Puts.length, 1);
|
||||
assert.equal(d1.batches[0], 1);
|
||||
});
|
||||
|
||||
it("rejects revoked devices before reading snapshot payloads", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({ firstRows: [null] });
|
||||
|
||||
const response = await handleRequest(
|
||||
syncSnapshotPostRequest(syncSnapshotBody()),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(await response.json(), { error: "device_not_approved" });
|
||||
assert.equal(d1.queries.length, 1);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("fails closed when a stored snapshot payload fails checksum verification", async () => {
|
||||
const payload = bytes("encrypted snapshot payload");
|
||||
const payloadHash = sha256(payload);
|
||||
const key = snapshotKey(payloadHash);
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
{ device_id: DEVICE_ID },
|
||||
snapshotRow({ r2_key: key, payload_hash: payloadHash, size_bytes: payload.byteLength }),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
syncSnapshotGetRequest(),
|
||||
testEnv({
|
||||
d1,
|
||||
r2Objects: [[key, bytes("corrupt snapshot payload")]],
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
await authorizedEnv(d1),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 500);
|
||||
assert.deepEqual(await response.json(), { error: "sync_snapshot_failed" });
|
||||
});
|
||||
|
||||
it("fails closed when stored ciphertext fails checksum verification", async () => {
|
||||
const payload = opaqueEnvelopeBytes();
|
||||
const payloadHash = sha256(payload);
|
||||
const key = snapshotKey(payloadHash);
|
||||
const row = snapshotRow({
|
||||
r2_key: key,
|
||||
payload_hash: payloadHash,
|
||||
size_bytes: payload.byteLength,
|
||||
});
|
||||
const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }, row, row] });
|
||||
|
||||
const response = await handleRequest(
|
||||
syncSnapshotGetRequest(headRef(row)),
|
||||
await authorizedEnv(d1, { r2Objects: [[key, bytes("corrupt")]] }),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 500);
|
||||
assert.deepEqual(await response.json(), { error: "sync_snapshot_failed" });
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
function syncSnapshotPostRequest(body: Record<string, unknown>): Request {
|
||||
return new Request("https://elydora.test/api/sync/snapshot", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
headers: { authorization: `Bearer ${ACCESS_TOKEN}`, "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function syncSnapshotGetRequest(): Request {
|
||||
return new Request(`https://elydora.test/api/sync/snapshot?snapshot_id=${SNAPSHOT_ID}`, {
|
||||
function syncSnapshotGetRequest(head: Record<string, unknown>): Request {
|
||||
const query = new URLSearchParams({
|
||||
snapshot_id: String(head.snapshot_id),
|
||||
head_revision: String(head.revision),
|
||||
payload_hash: String(head.payload_hash),
|
||||
});
|
||||
return new Request(`https://elydora.test/api/sync/snapshot?${query}`, {
|
||||
headers: { authorization: `Bearer ${ACCESS_TOKEN}` },
|
||||
});
|
||||
}
|
||||
|
||||
function syncSnapshotBody(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
const payload = bytes("encrypted snapshot payload");
|
||||
const payloadHash = sha256(payload);
|
||||
const payload = opaqueEnvelopeBytes();
|
||||
return {
|
||||
version: 1,
|
||||
version: 3,
|
||||
snapshot_id: SNAPSHOT_ID,
|
||||
region: REGION,
|
||||
payload_hash: payloadHash,
|
||||
payload_hash: sha256(payload),
|
||||
encryption_version: 2,
|
||||
vault_generation: 1,
|
||||
key_id: KEY_ID,
|
||||
content_hash: CONTENT_HASH,
|
||||
schema_rev: 1,
|
||||
logical_clock: 42,
|
||||
head_revision: 1,
|
||||
base_head: null,
|
||||
data_base64: base64(payload),
|
||||
...overrides,
|
||||
};
|
||||
@@ -291,27 +390,91 @@ function snapshotRow(overrides: Record<string, unknown> = {}): Record<string, un
|
||||
snapshot_id: SNAPSHOT_ID,
|
||||
r2_key: snapshotKey("a".repeat(64)),
|
||||
payload_hash: "a".repeat(64),
|
||||
encryption_version: 2,
|
||||
vault_generation: 1,
|
||||
key_id: KEY_ID,
|
||||
content_hash: CONTENT_HASH,
|
||||
schema_rev: 1,
|
||||
logical_clock: 42,
|
||||
head_revision: 1,
|
||||
base_head_revision: null,
|
||||
base_snapshot_id: null,
|
||||
base_payload_hash: null,
|
||||
device_id: DEVICE_ID,
|
||||
size_bytes: 26,
|
||||
size_bytes: 11,
|
||||
created_at: 1_780_000_900,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotDocument(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return snapshotRow(overrides);
|
||||
function snapshotDocument(row: Record<string, unknown>): Record<string, unknown> {
|
||||
const {
|
||||
base_head_revision: revision,
|
||||
base_snapshot_id: snapshotId,
|
||||
base_payload_hash: payloadHash,
|
||||
...document
|
||||
} = row;
|
||||
return {
|
||||
...document,
|
||||
base_head: revision === null
|
||||
? null
|
||||
: { revision, snapshot_id: snapshotId, payload_hash: payloadHash },
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotKey(_payloadHash: string): string {
|
||||
return `sync-snapshots/${REGION}/${sha256(bytes(USER_ID))}/${SNAPSHOT_ID}.bin`;
|
||||
function uploadDocument(row: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
version: 3,
|
||||
user_id: USER_ID,
|
||||
device_id: DEVICE_ID,
|
||||
snapshot: snapshotDocument(row),
|
||||
};
|
||||
}
|
||||
|
||||
function conflictDocument(row: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
error: "sync_snapshot_head_conflict",
|
||||
current_head: snapshotDocument(row),
|
||||
};
|
||||
}
|
||||
|
||||
function headRef(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
revision: overrides.head_revision ?? 1,
|
||||
snapshot_id: overrides.snapshot_id ?? SNAPSHOT_ID,
|
||||
payload_hash: overrides.payload_hash ?? "a".repeat(64),
|
||||
};
|
||||
}
|
||||
|
||||
function vaultKeyRow(): Record<string, unknown> {
|
||||
return { key_id: KEY_ID, generation: 1 };
|
||||
}
|
||||
|
||||
async function authorizedEnv(
|
||||
d1: ReturnType<typeof testD1Database>,
|
||||
options: Omit<Parameters<typeof testEnv>[0], "d1" | "kvEntries"> = {},
|
||||
): Promise<ReturnType<typeof testEnv>> {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
return testEnv({
|
||||
...options,
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
});
|
||||
}
|
||||
|
||||
function snapshotKey(payloadHash: string): string {
|
||||
return `sync-snapshots/${REGION}/${sha256(bytes(USER_ID))}/${SNAPSHOT_ID}/${payloadHash}.bin`;
|
||||
}
|
||||
|
||||
function bytes(value: string): ArrayBuffer {
|
||||
return new TextEncoder().encode(value).buffer;
|
||||
}
|
||||
|
||||
function opaqueEnvelopeBytes(): ArrayBuffer {
|
||||
return new Uint8Array([0x45, 0x4c, 0x59, 0x53, 0x59, 0x4e, 0x43, 0x00, 0xff, 0x80, 0x01]).buffer;
|
||||
}
|
||||
|
||||
function base64(payload: ArrayBuffer): string {
|
||||
return Buffer.from(payload).toString("base64");
|
||||
}
|
||||
|
||||
@@ -12,17 +12,17 @@ describe("sync status routes", () => {
|
||||
it("returns cloud sync cursor, object, snapshot, and device status", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
{ device_id: DEVICE_ID },
|
||||
{ latest_change_id: 51, total_changes: 7 },
|
||||
{ total_snapshots: 2 },
|
||||
latestSnapshotRow(),
|
||||
{ approved_devices: 3 },
|
||||
],
|
||||
allRows: [
|
||||
objectStatusRow({ object_type: "bookmarks", active_count: 4, deleted_count: 1 }),
|
||||
objectStatusRow({ object_type: "tabs", active_count: 9, latest_logical_clock: 44 }),
|
||||
],
|
||||
firstRows: [{ device_id: DEVICE_ID }],
|
||||
batchRowSets: [[
|
||||
[{ latest_change_id: 51, total_changes: 7 }],
|
||||
[
|
||||
objectStatusRow({ object_type: "bookmarks", active_count: 4, deleted_count: 1 }),
|
||||
objectStatusRow({ object_type: "tabs", active_count: 9, latest_logical_clock: 44 }),
|
||||
],
|
||||
[{ total_snapshots: 2 }],
|
||||
[snapshotHeadRow()],
|
||||
[{ approved_devices: 3 }],
|
||||
]],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
@@ -36,7 +36,7 @@ describe("sync status routes", () => {
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("cache-control"), "no-store");
|
||||
assert.deepEqual(await response.json(), {
|
||||
version: 1,
|
||||
version: 2,
|
||||
user_id: USER_ID,
|
||||
device_id: DEVICE_ID,
|
||||
cursor: { latest_change_id: 51, total_changes: 7 },
|
||||
@@ -46,7 +46,7 @@ describe("sync status routes", () => {
|
||||
],
|
||||
snapshots: {
|
||||
total_snapshots: 2,
|
||||
latest: latestSnapshotRow(),
|
||||
head: snapshotHeadStatus(),
|
||||
},
|
||||
devices: {
|
||||
approved_count: 3,
|
||||
@@ -58,7 +58,7 @@ describe("sync status routes", () => {
|
||||
assert.ok(d1.queries[1]?.includes("FROM sync_change_log"));
|
||||
assert.ok(d1.queries[2]?.includes("FROM sync_objects"));
|
||||
assert.ok(d1.queries[3]?.includes("FROM sync_snapshots"));
|
||||
assert.ok(d1.queries[4]?.includes("FROM sync_snapshots"));
|
||||
assert.ok(d1.queries[4]?.includes("FROM sync_snapshot_heads"));
|
||||
assert.ok(d1.queries[5]?.includes("FROM user_devices"));
|
||||
assert.deepEqual(d1.binds, [
|
||||
[USER_ID, DEVICE_ID],
|
||||
@@ -68,18 +68,21 @@ describe("sync status routes", () => {
|
||||
[USER_ID],
|
||||
[USER_ID],
|
||||
]);
|
||||
assert.deepEqual(d1.batches, [5]);
|
||||
assert.deepEqual(d1.sessionConstraints, ["first-primary"]);
|
||||
});
|
||||
|
||||
it("returns empty status when the account has no sync facts", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
{ device_id: DEVICE_ID },
|
||||
{ latest_change_id: 0, total_changes: 0 },
|
||||
{ total_snapshots: 0 },
|
||||
null,
|
||||
{ approved_devices: 1 },
|
||||
],
|
||||
firstRows: [{ device_id: DEVICE_ID }],
|
||||
batchRowSets: [[
|
||||
[{ latest_change_id: 0, total_changes: 0 }],
|
||||
[],
|
||||
[{ total_snapshots: 0 }],
|
||||
[],
|
||||
[{ approved_devices: 1 }],
|
||||
]],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
@@ -94,18 +97,17 @@ describe("sync status routes", () => {
|
||||
const body = (await response.json()) as {
|
||||
cursor: { latest_change_id: number; total_changes: number };
|
||||
objects: [];
|
||||
snapshots: { total_snapshots: number; latest: null };
|
||||
snapshots: { total_snapshots: number; head: null };
|
||||
};
|
||||
assert.deepEqual(body.cursor, { latest_change_id: 0, total_changes: 0 });
|
||||
assert.deepEqual(body.objects, []);
|
||||
assert.deepEqual(body.snapshots, { total_snapshots: 0, latest: null });
|
||||
assert.deepEqual(body.snapshots, { total_snapshots: 0, head: null });
|
||||
});
|
||||
|
||||
it("rejects revoked devices before reading sync status", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [null, { latest_change_id: 51, total_changes: 7 }],
|
||||
allRows: [objectStatusRow()],
|
||||
firstRows: [null],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
@@ -138,14 +140,64 @@ describe("sync status routes", () => {
|
||||
it("returns a server error for malformed status rows", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
{ device_id: DEVICE_ID },
|
||||
{ latest_change_id: 51, total_changes: 7 },
|
||||
{ total_snapshots: 1 },
|
||||
latestSnapshotRow(),
|
||||
{ approved_devices: 1 },
|
||||
],
|
||||
allRows: [objectStatusRow({ object_type: "passwords" })],
|
||||
firstRows: [{ device_id: DEVICE_ID }],
|
||||
batchRowSets: [[
|
||||
[{ latest_change_id: 51, total_changes: 7 }],
|
||||
[objectStatusRow({ object_type: "passwords" })],
|
||||
[{ total_snapshots: 1 }],
|
||||
[snapshotHeadRow()],
|
||||
[{ approved_devices: 1 }],
|
||||
]],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
syncStatusRequest(),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 500);
|
||||
assert.deepEqual(await response.json(), { error: "sync_status_invalid" });
|
||||
});
|
||||
|
||||
it("fails closed when encrypted snapshots exist without a global head", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [{ device_id: DEVICE_ID }],
|
||||
batchRowSets: [[
|
||||
[{ latest_change_id: 0, total_changes: 0 }],
|
||||
[],
|
||||
[{ total_snapshots: 1 }],
|
||||
[],
|
||||
[{ approved_devices: 1 }],
|
||||
]],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
syncStatusRequest(),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 500);
|
||||
assert.deepEqual(await response.json(), { error: "sync_status_invalid" });
|
||||
});
|
||||
|
||||
it("fails closed when global head storage metadata is malformed", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [{ device_id: DEVICE_ID }],
|
||||
batchRowSets: [[
|
||||
[{ latest_change_id: 0, total_changes: 0 }],
|
||||
[],
|
||||
[{ total_snapshots: 1 }],
|
||||
[snapshotHeadRow({ r2_key: "invalid" })],
|
||||
[{ approved_devices: 1 }],
|
||||
]],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
@@ -182,14 +234,39 @@ function objectStatusDocument(overrides: Record<string, unknown> = {}): Record<s
|
||||
return objectStatusRow(overrides);
|
||||
}
|
||||
|
||||
function latestSnapshotRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
function snapshotHeadRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
snapshot_id: "snapshot-01",
|
||||
r2_key: `sync-snapshots/us-east/${"d".repeat(64)}/snapshot-01/${"a".repeat(64)}.bin`,
|
||||
payload_hash: "a".repeat(64),
|
||||
encryption_version: 2,
|
||||
vault_generation: 1,
|
||||
key_id: "b".repeat(64),
|
||||
content_hash: "c".repeat(64),
|
||||
schema_rev: 1,
|
||||
logical_clock: 42,
|
||||
head_revision: 1,
|
||||
base_head_revision: null,
|
||||
base_snapshot_id: null,
|
||||
base_payload_hash: null,
|
||||
device_id: DEVICE_ID,
|
||||
size_bytes: 26,
|
||||
created_at: 1_780_000_900,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotHeadStatus(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
const {
|
||||
r2_key: _r2Key,
|
||||
schema_rev: _schemaRev,
|
||||
base_head_revision: _baseHeadRevision,
|
||||
base_snapshot_id: _baseSnapshotId,
|
||||
base_payload_hash: _basePayloadHash,
|
||||
...status
|
||||
} = snapshotHeadRow(overrides);
|
||||
return {
|
||||
...status,
|
||||
base_head: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import assert from "node:assert/strict";
|
||||
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 { cleanupRotatedVaultStorage } from "../src/sync_vault_rotation_cleanup.js";
|
||||
import { testEnv } from "./devices_test_support.js";
|
||||
import { SqliteD1Database, execute, query } from "./sqlite_d1_test_support.js";
|
||||
|
||||
const MIGRATIONS_DIR = join(process.cwd(), "migrations");
|
||||
const USER_ID = "user-01", APPROVER_ID = "device-01", TARGET_ID = "device-02";
|
||||
const OLD_KEY = "a".repeat(64), NEW_KEY = "b".repeat(64);
|
||||
const OLD_HASH = "c".repeat(64), NEW_HASH = "d".repeat(64), USER_HASH = "e".repeat(64);
|
||||
const OLD_PAYLOAD_KEY = `sync-payloads/us/${USER_HASH}/bookmarks/object-01/${OLD_HASH}.bin`;
|
||||
const OLD_SNAPSHOT_KEY = `sync-snapshots/us/${USER_HASH}/snapshot-01/${OLD_HASH}.bin`;
|
||||
const NEW_SNAPSHOT_KEY = `sync-snapshots/us/${USER_HASH}/snapshot-02/${NEW_HASH}.bin`;
|
||||
const CLEANUP_AT = 300;
|
||||
|
||||
describe("sync vault rotation cleanup real D1 flow", () => {
|
||||
it("cleans staged storage with a higher-clock non-head history row", async () => {
|
||||
await withDatabase(true, async (databasePath) => {
|
||||
seedHighClockNonHead(databasePath);
|
||||
const r2Deletes: string[] = [];
|
||||
await cleanupRotatedVaultStorage(
|
||||
testEnv({ d1: new SqliteD1Database(databasePath), r2Deletes }),
|
||||
USER_ID,
|
||||
"snapshot-02",
|
||||
NEW_KEY,
|
||||
2,
|
||||
CLEANUP_AT,
|
||||
);
|
||||
assert.deepEqual(r2Deletes, [OLD_PAYLOAD_KEY, OLD_SNAPSHOT_KEY]);
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM sync_objects WHERE user_id = '${USER_ID}') AS objects,
|
||||
(SELECT COUNT(*) FROM sync_snapshots
|
||||
WHERE user_id = '${USER_ID}' AND snapshot_id = 'snapshot-01') AS old_snapshot,
|
||||
(SELECT COUNT(*) FROM sync_snapshots
|
||||
WHERE user_id = '${USER_ID}' AND snapshot_id = 'snapshot-02') AS new_snapshot,
|
||||
(SELECT COUNT(*) FROM sync_snapshots
|
||||
WHERE user_id = '${USER_ID}' AND snapshot_id = 'snapshot-high') AS non_head,
|
||||
(SELECT head_revision FROM sync_snapshot_heads
|
||||
WHERE user_id = '${USER_ID}') AS head_revision,
|
||||
(SELECT storage_cleaned_at FROM sync_vault_rotations
|
||||
WHERE user_id = '${USER_ID}') AS storage_cleaned_at
|
||||
`), [{
|
||||
objects: 0,
|
||||
old_snapshot: 0,
|
||||
new_snapshot: 1,
|
||||
non_head: 1,
|
||||
head_revision: 2,
|
||||
storage_cleaned_at: CLEANUP_AT,
|
||||
}]);
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the old head and R2 state for a CAS loser", async () => {
|
||||
await withDatabase(false, async (databasePath) => {
|
||||
const r2Deletes: string[] = [];
|
||||
await cleanupRotatedVaultStorage(
|
||||
testEnv({ d1: new SqliteD1Database(databasePath), r2Deletes }),
|
||||
USER_ID,
|
||||
"snapshot-02",
|
||||
NEW_KEY,
|
||||
2,
|
||||
CLEANUP_AT,
|
||||
);
|
||||
assert.deepEqual(r2Deletes, []);
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM sync_objects WHERE user_id = '${USER_ID}') AS objects,
|
||||
(SELECT COUNT(*) FROM sync_snapshots
|
||||
WHERE user_id = '${USER_ID}' AND snapshot_id = 'snapshot-01') AS old_snapshot,
|
||||
(SELECT snapshot_id FROM sync_snapshot_heads
|
||||
WHERE user_id = '${USER_ID}') AS head_snapshot_id,
|
||||
(SELECT cleanup_snapshot_id FROM sync_vault_rotations
|
||||
WHERE user_id = '${USER_ID}') AS cleanup_snapshot_id
|
||||
`), [{
|
||||
objects: 1,
|
||||
old_snapshot: 1,
|
||||
head_snapshot_id: "snapshot-01",
|
||||
cleanup_snapshot_id: null,
|
||||
}]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function withDatabase(
|
||||
commitReplacement: boolean,
|
||||
run: (databasePath: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "ely-rotation-cleanup-"));
|
||||
try {
|
||||
const databasePath = join(tempDir, "ely.db");
|
||||
const migrations = readdirSync(MIGRATIONS_DIR)
|
||||
.filter((name) => name.endsWith(".sql"))
|
||||
.sort()
|
||||
.map((name) => readFileSync(join(MIGRATIONS_DIR, name), "utf8"))
|
||||
.join("\n");
|
||||
execute(databasePath, migrations);
|
||||
execute(databasePath, seedSql(commitReplacement));
|
||||
await run(databasePath);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function seedSql(commitReplacement: boolean): string {
|
||||
return `
|
||||
INSERT INTO better_auth_user
|
||||
(id, name, email, emailVerified, createdAt, updatedAt)
|
||||
VALUES ('${USER_ID}', 'User', 'user@example.com', 1, '2026-01-01', '2026-01-01');
|
||||
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}', '${APPROVER_ID}', '${"1".repeat(64)}', 'Approver', 'macOS',
|
||||
'approved', 10, 11, 12, NULL, 'device-register-0001'),
|
||||
('${USER_ID}', '${TARGET_ID}', '${"2".repeat(64)}', 'Target', 'macOS',
|
||||
'approved', 10, 11, 12, NULL, 'device-register-0002'),
|
||||
('${USER_ID}', 'device-03', '${"3".repeat(64)}', 'Remaining', 'macOS',
|
||||
'approved', 10, 11, 12, NULL, 'device-register-0003');
|
||||
INSERT INTO user_device_keys
|
||||
(user_id, device_id, signing_public_key, wrapping_public_key,
|
||||
key_protocol_version, created_at)
|
||||
VALUES
|
||||
('${USER_ID}', '${APPROVER_ID}', '${"1".repeat(64)}', '${"4".repeat(64)}', 2, 10),
|
||||
('${USER_ID}', '${TARGET_ID}', '${"2".repeat(64)}', '${"5".repeat(64)}', 2, 10),
|
||||
('${USER_ID}', 'device-03', '${"3".repeat(64)}', '${"6".repeat(64)}', 2, 10);
|
||||
INSERT INTO sync_vault_accounts
|
||||
(user_id, current_key_id, current_generation, created_at, updated_at)
|
||||
VALUES ('${USER_ID}', '${OLD_KEY}', 1, 20, 20);
|
||||
${ledgerSql(OLD_PAYLOAD_KEY, "payload", "1".repeat(64), 30)}
|
||||
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 ('${USER_ID}', 'object-01', 'bookmarks', NULL, '${OLD_PAYLOAD_KEY}', '${OLD_HASH}',
|
||||
1, 1, '${APPROVER_ID}', 30, 30, NULL);
|
||||
UPDATE sync_r2_gc_candidates
|
||||
SET state = 'referenced', referenced_at = 30, updated_at = 30
|
||||
WHERE r2_key = '${OLD_PAYLOAD_KEY}';
|
||||
${ledgerSql(OLD_SNAPSHOT_KEY, "snapshot", "2".repeat(64), 40)}
|
||||
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)
|
||||
VALUES ('${USER_ID}', 'snapshot-01', '${OLD_SNAPSHOT_KEY}', '${OLD_HASH}', 1, 1,
|
||||
'${APPROVER_ID}', 64, 40, 1, NULL, NULL, NULL);
|
||||
INSERT INTO sync_snapshot_encryption
|
||||
(user_id, snapshot_id, encryption_version, vault_generation, key_id, content_hash)
|
||||
VALUES ('${USER_ID}', 'snapshot-01', 2, 1, '${OLD_KEY}', '${OLD_HASH}');
|
||||
INSERT INTO sync_snapshot_heads
|
||||
(user_id, head_revision, snapshot_id, payload_hash, updated_at)
|
||||
VALUES ('${USER_ID}', 1, 'snapshot-01', '${OLD_HASH}', 40);
|
||||
UPDATE sync_r2_gc_candidates
|
||||
SET state = 'referenced', referenced_at = 40, updated_at = 40
|
||||
WHERE r2_key = '${OLD_SNAPSHOT_KEY}';
|
||||
${rotationSql()}
|
||||
${ledgerSql(NEW_SNAPSHOT_KEY, "snapshot", "3".repeat(64), 250)}
|
||||
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)
|
||||
VALUES ('${USER_ID}', 'snapshot-02', '${NEW_SNAPSHOT_KEY}', '${NEW_HASH}', 1, 2,
|
||||
'${APPROVER_ID}', 64, 250, 2, 1, 'snapshot-01', '${OLD_HASH}');
|
||||
INSERT INTO sync_snapshot_encryption
|
||||
(user_id, snapshot_id, encryption_version, vault_generation, key_id, content_hash)
|
||||
VALUES ('${USER_ID}', 'snapshot-02', 2, 2, '${NEW_KEY}', '${NEW_HASH}');
|
||||
${commitReplacement ? `
|
||||
UPDATE sync_snapshot_heads
|
||||
SET head_revision = 2, snapshot_id = 'snapshot-02',
|
||||
payload_hash = '${NEW_HASH}', updated_at = 250
|
||||
WHERE user_id = '${USER_ID}';
|
||||
UPDATE sync_r2_gc_candidates
|
||||
SET state = 'referenced', lease_expires_at = 250,
|
||||
referenced_at = 250, updated_at = 250
|
||||
WHERE r2_key = '${NEW_SNAPSHOT_KEY}';
|
||||
` : ""}
|
||||
`;
|
||||
}
|
||||
|
||||
function ledgerSql(r2Key: string, kind: "payload" | "snapshot", token: string, now: number): string {
|
||||
return `
|
||||
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
|
||||
) VALUES (
|
||||
'${r2Key}', '${USER_ID}', '${USER_HASH}', '${kind}', 'pending', '${token}',
|
||||
1000, NULL, ${now}, ${now}, NULL, NULL, NULL, NULL
|
||||
);
|
||||
`;
|
||||
}
|
||||
|
||||
function seedHighClockNonHead(databasePath: string): void {
|
||||
const hash = "f".repeat(64);
|
||||
const r2Key = `sync-snapshots/us/${USER_HASH}/snapshot-high/${hash}.bin`;
|
||||
execute(databasePath, `
|
||||
${ledgerSql(r2Key, "snapshot", "4".repeat(64), 260)}
|
||||
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
|
||||
) VALUES (
|
||||
'${USER_ID}', 'snapshot-high', '${r2Key}', '${hash}', 1,
|
||||
${Number.MAX_SAFE_INTEGER}, '${APPROVER_ID}', 64, 260, 0, NULL, NULL, NULL
|
||||
);
|
||||
INSERT INTO sync_snapshot_encryption (
|
||||
user_id, snapshot_id, encryption_version, vault_generation, key_id, content_hash
|
||||
) VALUES ('${USER_ID}', 'snapshot-high', 2, 2, '${NEW_KEY}', '${hash}');
|
||||
`);
|
||||
}
|
||||
|
||||
function rotationSql(): string {
|
||||
return `
|
||||
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 ('${USER_ID}', 'rotation-key-0001', 'rotation-audit-0001', '${TARGET_ID}',
|
||||
'${APPROVER_ID}', '${OLD_KEY}', 1, '${NEW_KEY}', 2, '${"7".repeat(64)}', 2, 2, 100, NULL);
|
||||
INSERT INTO sync_vault_rotation_envelopes
|
||||
(user_id, rotation_idempotency_key, recipient_device_id, envelope_idempotency_key,
|
||||
envelope_version, suite, encapped_key, ciphertext)
|
||||
VALUES
|
||||
('${USER_ID}', 'rotation-key-0001', '${APPROVER_ID}', '${"8".repeat(64)}', 1,
|
||||
'HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305', '${"A".repeat(43)}', '${"B".repeat(64)}'),
|
||||
('${USER_ID}', 'rotation-key-0001', 'device-03', '${"9".repeat(64)}', 1,
|
||||
'HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305', '${"C".repeat(42)}E', '${"D".repeat(64)}');
|
||||
UPDATE sync_vault_rotations SET completed_at = 200
|
||||
WHERE user_id = '${USER_ID}' AND idempotency_key = 'rotation-key-0001';
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { authSessionCacheKvKey, authTokenHash } from "../src/auth.js";
|
||||
import { handleRequest } from "../src/index.js";
|
||||
import {
|
||||
SyncVaultConflictError,
|
||||
SyncVaultNotFoundError,
|
||||
assertCurrentSyncVaultKey,
|
||||
parseWrappedAccountKey,
|
||||
syncVaultRecipientEnvelopeStatement,
|
||||
} from "../src/sync_vault.js";
|
||||
import { syncVaultBootstrapProofBytes } from "../src/sync_vault_bootstrap_proof.js";
|
||||
import {
|
||||
ACCESS_TOKEN,
|
||||
PUBLIC_KEY,
|
||||
sessionDocument,
|
||||
signDeviceMessage,
|
||||
testD1Database,
|
||||
testEnv,
|
||||
} from "./devices_test_support.js";
|
||||
|
||||
const USER_ID = "user-01";
|
||||
const DEVICE_ID = "device-01";
|
||||
const KEY_ID = "a".repeat(64);
|
||||
const GENERATION = 1;
|
||||
const HISTORICAL_KEY_ID = "c".repeat(64);
|
||||
const HISTORICAL_GENERATION = 3;
|
||||
const IDEMPOTENCY_KEY = "sync-vault-bootstrap-0001";
|
||||
const SUITE = "HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305";
|
||||
const ENCAPPED_KEY = "A".repeat(43);
|
||||
const CIPHERTEXT = "B".repeat(64);
|
||||
|
||||
describe("sync vault routes", () => {
|
||||
it("bootstraps the current approved device envelope", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [approvedDeviceRow(), signingKeyRow(), currentEnvelopeRow()],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
vaultBootstrapRequest(await vaultBootstrapBody()),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 201);
|
||||
assert.equal(response.headers.get("cache-control"), "no-store");
|
||||
assert.deepEqual(await response.json(), vaultDocument());
|
||||
assert.equal(d1.batches[0], 2);
|
||||
assert.ok(d1.queries[1]?.includes("keys.signing_public_key"));
|
||||
assert.ok(d1.queries[1]?.includes("keys.key_protocol_version = 2"));
|
||||
assert.ok(d1.queries[2]?.includes("INSERT INTO sync_vault_accounts"));
|
||||
assert.ok(d1.queries[3]?.includes("INSERT INTO sync_vault_envelopes"));
|
||||
assert.ok(d1.queries[4]?.includes("FROM sync_vault_accounts AS accounts"));
|
||||
assert.deepEqual(d1.binds[4], [USER_ID, DEVICE_ID]);
|
||||
});
|
||||
|
||||
it("returns the current device envelope", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [{ device_id: DEVICE_ID }, currentEnvelopeRow()],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
new Request("https://elydora.test/api/sync/vault", {
|
||||
headers: { authorization: `Bearer ${ACCESS_TOKEN}` },
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), vaultDocument());
|
||||
assert.equal(d1.batches.length, 0);
|
||||
assert.deepEqual(d1.binds[1], [USER_ID, DEVICE_ID]);
|
||||
});
|
||||
|
||||
it("returns an exact historical envelope for the authenticated device", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
approvedDeviceRow(),
|
||||
currentEnvelopeRow({ key_id: HISTORICAL_KEY_ID, generation: HISTORICAL_GENERATION }),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
vaultGetRequest(`?generation=${HISTORICAL_GENERATION}&key_id=${HISTORICAL_KEY_ID}`),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), vaultDocument({
|
||||
key_id: HISTORICAL_KEY_ID,
|
||||
generation: HISTORICAL_GENERATION,
|
||||
}));
|
||||
assert.ok(d1.queries[1]?.includes("FROM sync_vault_envelopes"));
|
||||
assert.ok(d1.queries[1]?.includes("recipient_device_id = ?"));
|
||||
assert.deepEqual(d1.binds[1], [
|
||||
USER_ID,
|
||||
DEVICE_ID,
|
||||
HISTORICAL_KEY_ID,
|
||||
HISTORICAL_GENERATION,
|
||||
]);
|
||||
});
|
||||
|
||||
it("isolates historical envelopes by recipient and exact generation", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
for (const generation of [HISTORICAL_GENERATION, HISTORICAL_GENERATION + 1]) {
|
||||
const d1 = testD1Database({ firstRows: [approvedDeviceRow(), null] });
|
||||
const response = await handleRequest(
|
||||
vaultGetRequest(`?key_id=${HISTORICAL_KEY_ID}&generation=${generation}`),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 404);
|
||||
assert.deepEqual(d1.binds[1], [USER_ID, DEVICE_ID, HISTORICAL_KEY_ID, generation]);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects partial, duplicate, and extra historical queries", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const queries = [
|
||||
`?generation=${HISTORICAL_GENERATION}`,
|
||||
`?key_id=${HISTORICAL_KEY_ID}`,
|
||||
`?key_id=${HISTORICAL_KEY_ID}&generation=3&generation=3`,
|
||||
`?key_id=${HISTORICAL_KEY_ID}&generation=3&extra=1`,
|
||||
];
|
||||
for (const query of queries) {
|
||||
const d1 = testD1Database({ firstRows: [approvedDeviceRow()] });
|
||||
const response = await handleRequest(
|
||||
vaultGetRequest(query),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.equal(d1.queries.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects malformed opaque envelopes before vault writes", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }] });
|
||||
|
||||
const response = await handleRequest(
|
||||
vaultBootstrapRequest(
|
||||
await vaultBootstrapBody({ envelope: { ...wrappedEnvelope(), ciphertext: "B".repeat(63) } }),
|
||||
),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.deepEqual(await response.json(), { error: "invalid_sync_vault" });
|
||||
assert.equal(d1.queries.length, 1);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("rejects noncanonical encapped keys before vault writes", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }] });
|
||||
|
||||
const response = await handleRequest(
|
||||
vaultBootstrapRequest(
|
||||
await vaultBootstrapBody({ envelope: { ...wrappedEnvelope(), encapped_key: `${"A".repeat(42)}B` } }),
|
||||
),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.deepEqual(await response.json(), { error: "invalid_sync_vault" });
|
||||
assert.equal(d1.queries.length, 1);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("rejects unknown envelope fields before vault writes", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }] });
|
||||
|
||||
const response = await handleRequest(
|
||||
vaultBootstrapRequest(
|
||||
await vaultBootstrapBody({ envelope: { ...wrappedEnvelope(), plaintext_key: KEY_ID } }),
|
||||
),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.equal(d1.queries.length, 1);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("rejects noninitial bootstrap generations before vault writes", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }] });
|
||||
|
||||
const response = await handleRequest(
|
||||
vaultBootstrapRequest(await vaultBootstrapBody({ generation: 2 })),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.deepEqual(await response.json(), { error: "invalid_sync_vault" });
|
||||
assert.equal(d1.queries.length, 1);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("rejects a bootstrap replay with different stored ciphertext", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
approvedDeviceRow(),
|
||||
signingKeyRow(),
|
||||
currentEnvelopeRow({ ciphertext: "C".repeat(64) }),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
vaultBootstrapRequest(await vaultBootstrapBody()),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 409);
|
||||
assert.deepEqual(await response.json(), { error: "sync_vault_conflict" });
|
||||
});
|
||||
|
||||
it("rejects tampered bootstrap fields before vault writes", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const body = await vaultBootstrapBody();
|
||||
body.key_id = "b".repeat(64);
|
||||
const d1 = testD1Database({ firstRows: [approvedDeviceRow(), signingKeyRow()] });
|
||||
|
||||
const response = await handleRequest(
|
||||
vaultBootstrapRequest(body),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(await response.json(), { error: "sync_vault_forbidden" });
|
||||
assert.equal(d1.queries.length, 2);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("rejects bootstrap v1 before signing-key reads", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({ firstRows: [approvedDeviceRow()] });
|
||||
|
||||
const response = await handleRequest(
|
||||
vaultBootstrapRequest(await vaultBootstrapBody({ version: 1 })),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.deepEqual(await response.json(), { error: "invalid_sync_vault" });
|
||||
assert.equal(d1.queries.length, 1);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("rejects missing approved v2 signing keys before vault writes", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({ firstRows: [approvedDeviceRow(), null] });
|
||||
|
||||
const response = await handleRequest(
|
||||
vaultBootstrapRequest(await vaultBootstrapBody()),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.equal(d1.queries.length, 2);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("uses the frozen v2 bootstrap proof wire", () => {
|
||||
assert.equal(
|
||||
new TextDecoder().decode(syncVaultBootstrapProofBytes(
|
||||
USER_ID,
|
||||
DEVICE_ID,
|
||||
bootstrapProofInput(),
|
||||
)),
|
||||
"31:elydora-sync-vault-bootstrap-v2" +
|
||||
"7:user-01" +
|
||||
"9:device-01" +
|
||||
`64:${KEY_ID}` +
|
||||
"1:1" +
|
||||
"1:1" +
|
||||
`45:${SUITE}` +
|
||||
`43:${ENCAPPED_KEY}` +
|
||||
`64:${CIPHERTEXT}` +
|
||||
"25:sync-vault-bootstrap-0001",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns not found when the current device has no envelope", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }, null] });
|
||||
|
||||
const response = await handleRequest(
|
||||
new Request("https://elydora.test/api/sync/vault", {
|
||||
headers: { authorization: `Bearer ${ACCESS_TOKEN}` },
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 404);
|
||||
assert.deepEqual(await response.json(), { error: "sync_vault_not_found" });
|
||||
});
|
||||
|
||||
it("validates snapshot key metadata against the current vault key", async () => {
|
||||
const matching = testD1Database({ firstRows: [{ key_id: KEY_ID, generation: GENERATION }] });
|
||||
await assertCurrentSyncVaultKey(testEnv({ d1: matching }), USER_ID, KEY_ID, GENERATION);
|
||||
|
||||
const mismatched = testD1Database({ firstRows: [{ key_id: KEY_ID, generation: GENERATION }] });
|
||||
await assert.rejects(
|
||||
assertCurrentSyncVaultKey(testEnv({ d1: mismatched }), USER_ID, "b".repeat(64), GENERATION),
|
||||
SyncVaultConflictError,
|
||||
);
|
||||
|
||||
const missing = testD1Database({ firstRows: [null] });
|
||||
await assert.rejects(
|
||||
assertCurrentSyncVaultKey(testEnv({ d1: missing }), USER_ID, KEY_ID, GENERATION),
|
||||
SyncVaultNotFoundError,
|
||||
);
|
||||
});
|
||||
|
||||
it("builds a recipient envelope write guarded by device trust and the current vault key", () => {
|
||||
const d1 = testD1Database([]);
|
||||
syncVaultRecipientEnvelopeStatement(
|
||||
testEnv({ d1 }),
|
||||
USER_ID,
|
||||
"device-02",
|
||||
DEVICE_ID,
|
||||
KEY_ID,
|
||||
GENERATION,
|
||||
parseWrappedAccountKey(wrappedEnvelope()),
|
||||
"sync-vault-recipient-0001",
|
||||
1_780_000_400,
|
||||
);
|
||||
|
||||
assert.ok(d1.queries[0]?.includes("accounts.current_key_id = ?"));
|
||||
assert.ok(d1.queries[0]?.includes("recipient.approval_status = ?"));
|
||||
assert.ok(d1.queries[0]?.includes("approver.approval_status = 'approved'"));
|
||||
assert.deepEqual(d1.binds[0], [
|
||||
USER_ID,
|
||||
"device-02",
|
||||
DEVICE_ID,
|
||||
KEY_ID,
|
||||
GENERATION,
|
||||
1,
|
||||
SUITE,
|
||||
ENCAPPED_KEY,
|
||||
CIPHERTEXT,
|
||||
"sync-vault-recipient-0001",
|
||||
1_780_000_400,
|
||||
"device-02",
|
||||
"pending",
|
||||
DEVICE_ID,
|
||||
USER_ID,
|
||||
KEY_ID,
|
||||
GENERATION,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function vaultBootstrapRequest(body: Record<string, unknown>): Request {
|
||||
return new Request("https://elydora.test/api/sync/vault/bootstrap", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function vaultGetRequest(query = ""): Request {
|
||||
return new Request(`https://elydora.test/api/sync/vault${query}`, {
|
||||
headers: { authorization: `Bearer ${ACCESS_TOKEN}` },
|
||||
});
|
||||
}
|
||||
|
||||
async function vaultBootstrapBody(
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
return {
|
||||
version: 2,
|
||||
key_id: KEY_ID,
|
||||
generation: GENERATION,
|
||||
envelope: wrappedEnvelope(),
|
||||
idempotency_key: IDEMPOTENCY_KEY,
|
||||
bootstrap_proof: await signDeviceMessage(
|
||||
syncVaultBootstrapProofBytes(USER_ID, DEVICE_ID, bootstrapProofInput()),
|
||||
),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function bootstrapProofInput() {
|
||||
return {
|
||||
keyId: KEY_ID,
|
||||
generation: GENERATION,
|
||||
envelope: parseWrappedAccountKey(wrappedEnvelope()),
|
||||
idempotencyKey: IDEMPOTENCY_KEY,
|
||||
};
|
||||
}
|
||||
|
||||
function approvedDeviceRow(): Record<string, unknown> {
|
||||
return { device_id: DEVICE_ID };
|
||||
}
|
||||
|
||||
function signingKeyRow(): Record<string, unknown> {
|
||||
return { signing_public_key: PUBLIC_KEY };
|
||||
}
|
||||
|
||||
function wrappedEnvelope(): Record<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
suite: SUITE,
|
||||
encapped_key: ENCAPPED_KEY,
|
||||
ciphertext: CIPHERTEXT,
|
||||
};
|
||||
}
|
||||
|
||||
function currentEnvelopeRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
key_id: KEY_ID,
|
||||
generation: GENERATION,
|
||||
recipient_device_id: DEVICE_ID,
|
||||
approver_device_id: DEVICE_ID,
|
||||
envelope_version: 1,
|
||||
suite: SUITE,
|
||||
encapped_key: ENCAPPED_KEY,
|
||||
ciphertext: CIPHERTEXT,
|
||||
idempotency_key: IDEMPOTENCY_KEY,
|
||||
created_at: 1_780_000_300,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function vaultDocument(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
user_id: USER_ID,
|
||||
key_id: KEY_ID,
|
||||
generation: GENERATION,
|
||||
recipient_device_id: DEVICE_ID,
|
||||
approver_device_id: DEVICE_ID,
|
||||
envelope: wrappedEnvelope(),
|
||||
created_at: 1_780_000_300,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user