297 lines
9.8 KiB
TypeScript
297 lines
9.8 KiB
TypeScript
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,
|
|
};
|
|
}
|