Files
ELY-Browser/cloudflare/tests/device_revocation_handler_sqlite.test.ts
T

392 lines
16 KiB
TypeScript

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 });
}
}