fix(sync): secure encrypted snapshot lifecycle

This commit is contained in:
2026-07-10 06:24:53 -04:00
parent 556c5ff624
commit 540b901fd6
106 changed files with 18026 additions and 3309 deletions
+141 -40
View File
@@ -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) };
}
+3
View File
@@ -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,
};
}
+213 -16
View File
@@ -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 }),
);
}
+435 -172
View File
@@ -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,
},
};
}
+56 -47
View File
@@ -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,
};
}
+103 -8
View File
@@ -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,
});
}
}
+249
View File
@@ -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 {
+151
View File
@@ -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");
}
+9 -2
View File
@@ -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);
+9 -138
View File
@@ -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);
}
+17 -313
View File
@@ -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");
}
+456
View File
@@ -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) };
}
+75 -28
View File
@@ -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 };
}
+356 -193
View File
@@ -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");
}
+111 -34
View File
@@ -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';
`;
}
+490
View File
@@ -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,
};
}