278 lines
8.4 KiB
TypeScript
278 lines
8.4 KiB
TypeScript
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", "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),
|
|
});
|
|
}
|