Public: anyone can read this; the crew can change it.

App identity, the core: the `app` table, `src/apps.ts` and the four actions — no doors #44

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/relay:scout/35-app-identity-the-core-the-app-table-src → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/35-app-identity-the-core-the-app-table-src was merged into main as 94b7317.

Review

Sign in to review this change.
6 files changed +415 −2
README.md +14
@@ -31,6 +31,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
3131 | file | what it is |
3232 | ----------------------------------- | ------------------------------------------------------------ |
3333 | `src/actions.ts` | post, list, get an event — and who may |
34 | `src/apps.ts` | an app's key, derived from the identity key, never stored |
3435 | `src/who.ts` | who is asking, from the forge's signed headers |
3536 | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations |
3637 | `src/live.ts` | the in-process bus and `GET /events` |
@@ -62,6 +63,19 @@ One table, `event`, and one row an app posts and anyone reads:
6263 stripping them, and a `link`'s scheme is checked here so no other door has
6364 to remember.
6465
66 ## An app's identity
67
68 Registering an app (`registerApp` in `src/actions.ts`, admin only) makes a
69 row in `app`; its posting key is never stored, only derived from the id,
70 a generation and the forge's identity key (`src/apps.ts`), and shown once,
71 at the moment it's issued. Rotating (`rotateAppKey`) bumps the generation,
72 which is what makes the previous key stop verifying — there is no key to
73 delete. Revoking (`revokeApp`) sets a column rather than removing the row,
74 because a derived key stays computable forever and the check that matters
75 is whether it's still current, not whether the row exists. Nothing yet
76 calls these but tests: no route, page or terminal command reaches them in
77 this change.
78
6579 ## Who you are
6680
6781 The forge says who is asking on every request it proxies, and the app
src/actions.test.ts +113 −1
@@ -2,7 +2,16 @@ import { expect, test } from "bun:test";
22 import { mkdtempSync } from "node:fs";
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
5 import { getEvent, listEvents, postEvent } from "./actions.ts";
5 import {
6 getEvent,
7 listApps,
8 listEvents,
9 postEvent,
10 registerApp,
11 revokeApp,
12 rotateAppKey,
13 } from "./actions.ts";
14 import { appSecret } from "./apps.ts";
615 import { openDb } from "./db.ts";
716 import type { Who } from "./who.ts";
817
@@ -184,3 +193,106 @@ test("getEvent finds an event by id, 404 when absent", () => {
184193 expect(bad.ok).toBe(false);
185194 if (!bad.ok) expect(bad.error).toBe("invalid");
186195 });
196
197 const KEY = "a-secret-only-relay-holds";
198
199 test("only an admin registers an app; anon and user are refused", () => {
200 const asAnon = registerApp(db, anon, { id: "torque", name: "Torque" }, KEY);
201 expect(asAnon.ok).toBe(false);
202 if (!asAnon.ok) expect(asAnon.error).toBe("signin_required");
203 const asUser = registerApp(db, alice, { id: "torque", name: "Torque" }, KEY);
204 expect(asUser.ok).toBe(false);
205 if (!asUser.ok) expect(asUser.error).toBe("forbidden");
206 });
207
208 test("registerApp returns the key once; a second register of the same id is invalid, never a rotate", () => {
209 const first = registerApp(db, root, { id: "torque", name: "Torque" }, KEY);
210 expect(first.ok).toBe(true);
211 if (!first.ok) return;
212 expect(first.value).toEqual({
213 id: "torque",
214 name: "Torque",
215 generation: 1,
216 key: appSecret("torque", 1, KEY),
217 });
218
219 const again = registerApp(db, root, { id: "torque", name: "Torque again" }, KEY);
220 expect(again.ok).toBe(false);
221 if (!again.ok) expect(again.error).toBe("invalid");
222 });
223
224 test("registerApp validates id and name", () => {
225 const badId = registerApp(db, root, { id: "Not Valid!", name: "x" }, KEY);
226 expect(badId.ok).toBe(false);
227 if (!badId.ok) expect(badId.error).toBe("invalid");
228 const badName = registerApp(db, root, { id: "widget", name: "" }, KEY);
229 expect(badName.ok).toBe(false);
230 if (!badName.ok) expect(badName.error).toBe("invalid");
231 const controlName = registerApp(db, root, { id: "widget", name: "bell\x07" }, KEY);
232 expect(controlName.ok).toBe(false);
233 if (!controlName.ok) expect(controlName.error).toBe("invalid");
234 });
235
236 test("rotateAppKey bumps the generation and changes the key; a revoked app refuses to rotate", () => {
237 registerApp(db, root, { id: "spindle", name: "Spindle" }, KEY);
238 const rotated = rotateAppKey(db, root, { id: "spindle" }, KEY);
239 expect(rotated.ok).toBe(true);
240 if (rotated.ok) {
241 expect(rotated.value.generation).toBe(2);
242 expect(rotated.value.key).toBe(appSecret("spindle", 2, KEY));
243 expect(rotated.value.key).not.toBe(appSecret("spindle", 1, KEY));
244 }
245
246 registerApp(db, root, { id: "gasket", name: "Gasket" }, KEY);
247 const revoked = revokeApp(db, root, { id: "gasket" });
248 expect(revoked.ok).toBe(true);
249 const rotateRevoked = rotateAppKey(db, root, { id: "gasket" }, KEY);
250 expect(rotateRevoked.ok).toBe(false);
251 if (!rotateRevoked.ok) expect(rotateRevoked.error).toBe("invalid");
252 });
253
254 test("rotateAppKey and revokeApp are not_found for an id that was never registered, and admin-only", () => {
255 const missing = rotateAppKey(db, root, { id: "no-such-app" }, KEY);
256 expect(missing.ok).toBe(false);
257 if (!missing.ok) expect(missing.error).toBe("not_found");
258 const missingRevoke = revokeApp(db, root, { id: "no-such-app" });
259 expect(missingRevoke.ok).toBe(false);
260 if (!missingRevoke.ok) expect(missingRevoke.error).toBe("not_found");
261
262 registerApp(db, root, { id: "cog", name: "Cog" }, KEY);
263 const asUser = rotateAppKey(db, alice, { id: "cog" }, KEY);
264 expect(asUser.ok).toBe(false);
265 if (!asUser.ok) expect(asUser.error).toBe("forbidden");
266 const asAnon = revokeApp(db, anon, { id: "cog" });
267 expect(asAnon.ok).toBe(false);
268 if (!asAnon.ok) expect(asAnon.error).toBe("signin_required");
269 });
270
271 test("with no identity key, registering or rotating is unavailable rather than issuing a guessable key", () => {
272 const registered = registerApp(db, root, { id: "pulley", name: "Pulley" }, "");
273 expect(registered.ok).toBe(false);
274 if (!registered.ok) expect(registered.error).toBe("unavailable");
275
276 registerApp(db, root, { id: "pulley", name: "Pulley" }, KEY);
277 const rotated = rotateAppKey(db, root, { id: "pulley" }, "");
278 expect(rotated.ok).toBe(false);
279 if (!rotated.ok) expect(rotated.error).toBe("unavailable");
280 });
281
282 test("listApps is admin-only and never carries a key", () => {
283 registerApp(db, root, { id: "ratchet", name: "Ratchet" }, KEY);
284 const asAnon = listApps(db, anon);
285 expect(asAnon.ok).toBe(false);
286 if (!asAnon.ok) expect(asAnon.error).toBe("signin_required");
287 const asUser = listApps(db, alice);
288 expect(asUser.ok).toBe(false);
289 if (!asUser.ok) expect(asUser.error).toBe("forbidden");
290
291 const listed = listApps(db, root);
292 expect(listed.ok).toBe(true);
293 if (!listed.ok) return;
294 const ratchet = listed.value.find((a) => a.id === "ratchet");
295 expect(ratchet).toEqual({ id: "ratchet", name: "Ratchet", generation: 1, revoked: false });
296 for (const app of listed.value) expect(Object.keys(app)).not.toContain("key");
297 expect(JSON.stringify(listed.value)).not.toContain(KEY);
298 });
src/actions.ts +186 −1
@@ -7,6 +7,8 @@
77 */
88
99 import type { Database } from "bun:sqlite";
10 import { appSecret, type AppRow } from "./apps.ts";
11 import { identityKey } from "./env.ts";
1012 import { emit } from "./live.ts";
1113 import type { Who } from "./who.ts";
1214
@@ -22,7 +24,12 @@ export interface Event {
2224 readonly dedupe: string | null;
2325 }
2426
25 export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid";
27 export type Failure =
28 | "signin_required"
29 | "forbidden"
30 | "not_found"
31 | "invalid"
32 | "unavailable";
2633 export type Result<T> =
2734 | { ok: true; value: T }
2835 | { ok: false; error: Failure; message: string };
@@ -252,3 +259,181 @@ export function getEvent(
252259 if (event === null) return fail("not_found", "No such event.");
253260 return ok(event);
254261 }
262
263 export const APP_NAME_MIN = 1;
264 export const APP_NAME_MAX = 60;
265 const NO_IDENTITY_KEY =
266 "No identity key is configured; cannot issue an app key.";
267
268 /** What `listApps` shows: never the derived key. */
269 export interface AppSummary {
270 readonly id: string;
271 readonly name: string;
272 readonly generation: number;
273 readonly revoked: boolean;
274 }
275
276 /** What `registerApp` and `rotateAppKey` hand back once, and nothing else ever does. */
277 export interface IssuedKey {
278 readonly id: string;
279 readonly name: string;
280 readonly generation: number;
281 readonly key: string;
282 }
283
284 type AdminGate =
285 | { ok: true; id: string }
286 | { ok: false; error: Failure; message: string };
287
288 /** The rule every app action shares: only an admin manages apps. */
289 function requireAdmin(who: Who): AdminGate {
290 if (who.role === "anon")
291 return {
292 ok: false,
293 error: "signin_required",
294 message: "Sign in to manage apps.",
295 };
296 if (who.role !== "admin")
297 return {
298 ok: false,
299 error: "forbidden",
300 message: "Only an admin may manage apps.",
301 };
302 return { ok: true, id: who.id };
303 }
304
305 const toSummary = (row: AppRow): AppSummary => ({
306 id: row.id,
307 name: row.name,
308 generation: row.generation,
309 revoked: row.revoked_at !== null,
310 });
311
312 export interface RegisterAppInput {
313 readonly id?: unknown;
314 readonly name?: unknown;
315 }
316
317 /**
318 * `key` defaults to the forge's identity key (`src/env.ts`); a test passes
319 * one of its own rather than relying on the empty local default.
320 */
321 export function registerApp(
322 db: Database,
323 who: Who,
324 input: RegisterAppInput,
325 key: string = identityKey,
326 ): Result<IssuedKey> {
327 const gate = requireAdmin(who);
328 if (!gate.ok) return fail(gate.error, gate.message);
329
330 const id = String(input.id ?? "");
331 if (!SOURCE_RE.test(id))
332 return fail(
333 "invalid",
334 "id is lowercase letters, digits and hyphens, up to 32 characters.",
335 );
336
337 const name = String(input.name ?? "");
338 if (!isPlainText(name, APP_NAME_MIN, APP_NAME_MAX))
339 return fail(
340 "invalid",
341 `name is ${APP_NAME_MIN} to ${APP_NAME_MAX} characters, no control characters.`,
342 );
343
344 const existing = db
345 .query<{ id: string }, [string]>("SELECT id FROM app WHERE id = ?1")
346 .get(id);
347 if (existing !== null)
348 return fail("invalid", "An app with that id is already registered.");
349
350 // Derive before writing: a refusal to derive must leave no row and no event.
351 let secret: string;
352 try {
353 secret = appSecret(id, 1, key);
354 } catch {
355 return fail("unavailable", NO_IDENTITY_KEY);
356 }
357
358 db.query("INSERT INTO app (id, name, created_by) VALUES (?1, ?2, ?3)").run(
359 id,
360 name,
361 gate.id,
362 );
363 emit("app.registered", { id, name });
364 return ok({ id, name, generation: 1, key: secret });
365 }
366
367 export interface AppIdInput {
368 readonly id?: unknown;
369 }
370
371 /**
372 * `key` defaults to the forge's identity key (`src/env.ts`); a test passes
373 * one of its own rather than relying on the empty local default.
374 */
375 export function rotateAppKey(
376 db: Database,
377 who: Who,
378 input: AppIdInput,
379 key: string = identityKey,
380 ): Result<IssuedKey> {
381 const gate = requireAdmin(who);
382 if (!gate.ok) return fail(gate.error, gate.message);
383
384 const row = db
385 .query<AppRow, [string]>("SELECT * FROM app WHERE id = ?1")
386 .get(String(input.id ?? ""));
387 if (row === null) return fail("not_found", "No such app.");
388 if (row.revoked_at !== null)
389 return fail("invalid", "A revoked app cannot be rotated.");
390
391 const generation = row.generation + 1;
392 // Derive before writing: a refusal to derive must leave the generation unchanged.
393 let secret: string;
394 try {
395 secret = appSecret(row.id, generation, key);
396 } catch {
397 return fail("unavailable", NO_IDENTITY_KEY);
398 }
399
400 db.query("UPDATE app SET generation = ?1 WHERE id = ?2").run(
401 generation,
402 row.id,
403 );
404 emit("app.rotated", { id: row.id, name: row.name });
405 return ok({ id: row.id, name: row.name, generation, key: secret });
406 }
407
408 /** One-way: revoking twice leaves the same `revoked_at` and emits nothing the second time. */
409 export function revokeApp(
410 db: Database,
411 who: Who,
412 input: AppIdInput,
413 ): Result<{ id: string }> {
414 const gate = requireAdmin(who);
415 if (!gate.ok) return fail(gate.error, gate.message);
416
417 const row = db
418 .query<AppRow, [string]>("SELECT * FROM app WHERE id = ?1")
419 .get(String(input.id ?? ""));
420 if (row === null) return fail("not_found", "No such app.");
421 if (row.revoked_at === null) {
422 db.query(
423 "UPDATE app SET revoked_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?1",
424 ).run(row.id);
425 emit("app.revoked", { id: row.id, name: row.name });
426 }
427 return ok({ id: row.id });
428 }
429
430 export function listApps(db: Database, who: Who): Result<AppSummary[]> {
431 const gate = requireAdmin(who);
432 if (!gate.ok) return fail(gate.error, gate.message);
433 return ok(
434 db
435 .query<AppRow, []>("SELECT * FROM app ORDER BY created_at DESC")
436 .all()
437 .map(toSummary),
438 );
439 }
src/apps.test.ts added +45
@@ -0,0 +1,45 @@
1 import { expect, test } from "bun:test";
2 import { mkdtempSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { appById, appSecret } from "./apps.ts";
6 import { openDb } from "./db.ts";
7
8 const KEY = "a-secret-only-relay-holds";
9
10 // Computed independently (node -e with createHmac), not through this
11 // module: HMAC-SHA256("relay-app:torque:1"), ("relay-app:torque:2") and
12 // ("relay-app:other:1") under KEY, as base64url.
13 const TORQUE_GEN1 = "fsx4NLR6Efaw-3JEmUteNCAXoZOyjwZ0vo6oAlzJSeM";
14 const TORQUE_GEN2 = "ApywjXeuEm9ROedVHb3Zb7r3zm73PjycC3NFeVcKhH0";
15 const OTHER_GEN1 = "Sed6jbiCvBTiON9PNVE-q0639Pt4uvdh6ebLW5XA32Q";
16
17 test("a secret matches a hand calculation", () => {
18 expect(appSecret("torque", 1, KEY)).toBe(TORQUE_GEN1);
19 expect(appSecret("torque", 2, KEY)).toBe(TORQUE_GEN2);
20 expect(appSecret("other", 1, KEY)).toBe(OTHER_GEN1);
21 });
22
23 test("the same id and generation always derive the same secret", () => {
24 expect(appSecret("torque", 1, KEY)).toBe(appSecret("torque", 1, KEY));
25 });
26
27 test("a different generation, or a different id, derives a different secret", () => {
28 expect(appSecret("torque", 1, KEY)).not.toBe(appSecret("torque", 2, KEY));
29 expect(appSecret("torque", 1, KEY)).not.toBe(appSecret("other", 1, KEY));
30 });
31
32 test("with no identity key, it refuses rather than derive a guessable key", () => {
33 expect(() => appSecret("torque", 1, "")).toThrow();
34 });
35
36 test("appById finds a registered app, or reports undefined", () => {
37 const db = openDb(mkdtempSync(join(tmpdir(), "app-apps-")));
38 db.query(
39 "INSERT INTO app (id, name, created_by) VALUES (?1, ?2, ?3)",
40 ).run("torque", "Torque", "root");
41 const row = appById(db, "torque");
42 expect(row?.id).toBe("torque");
43 expect(row?.generation).toBe(1);
44 expect(appById(db, "no-such-app")).toBeUndefined();
45 });
src/apps.ts added +49
@@ -0,0 +1,49 @@
1 /**
2 * An app's identity: the `app` table (`src/db.ts`) says who is registered;
3 * the secret it signs with is never stored, only derived on demand:
4 *
5 * base64url(HMAC-SHA256(identityKey, "relay-app:" + id + ":" + generation))
6 *
7 * The same id and generation always derive the same secret, so rotating an
8 * app is only ever a generation bump — there is no key to delete, and no
9 * key column to leak. `key` defaults to the forge's identity key
10 * (`src/env.ts`) but is a parameter, the same seam `who(request, key)`
11 * uses, so a test can hand this a key of its own instead of the empty
12 * local default. With no identity key this refuses outright: a key every
13 * reader of this repository could compute by hand is worse than having no
14 * apps.
15 */
16
17 import type { Database } from "bun:sqlite";
18 import { createHmac } from "node:crypto";
19 import { identityKey } from "./env.ts";
20
21 export interface AppRow {
22 readonly id: string;
23 readonly name: string;
24 readonly created_at: string;
25 readonly created_by: string;
26 readonly generation: number;
27 readonly revoked_at: string | null;
28 }
29
30 export function appSecret(
31 id: string,
32 generation: number,
33 key: string = identityKey,
34 ): string {
35 if (key === "")
36 throw new Error(
37 "apps: no identity key configured; refusing to derive an app key",
38 );
39 return createHmac("sha256", key)

High — the posting key is a pure function of the app id and a small integer.

Both inputs to this message are public and guessable: the id is the source on every event the app posts, and generation is 1 for every app that has never rotated. So the secrecy of every app key rests entirely on one value, FORGE_APP_IDENTITY_KEY — which is already doing another job, authenticating people in src/who.ts. Three consequences follow:

  • The same id derives the same key everywhere that key is deployed. If the forge hands a repo's previews and its production the same FORGE_APP_IDENTITY_KEY (worth confirming before merge), an admin who registers bazaar in a throwaway pull preview walks away holding production's bazaar key. src/db.ts is careful that "production and every preview each have their own" database; this key space would be shared across both.
  • A leak of the identity key is not one compromise but all of them, for every app and every generation, past and future. revokeApp cannot help: the derivation is a pure function, so whoever holds that key can still compute a revoked app's secret, and only the verifier's row lookup stands in the way.
  • Rotating FORGE_APP_IDENTITY_KEY — an ordinary operational act, and the first thing you would do after a leak — silently invalidates every app key at once, with no row changed, no event emitted, and nothing to tell an app why it stopped verifying.

Fix, keeping your design and the README argument intact: add a salt TEXT NOT NULL column to the app row — a fresh randomBytes(16).toString("base64url") written at register and rewritten at rotate — and derive HMAC(key, "relay-app:" + id + ":" + generation + ":" + salt). The salt is not a secret, so there is still no key column to leak, still nothing to delete, and rotation is still a generation bump; but the secret now depends on something unguessable that lives only in this environment's database, so the same id in a preview and in production derive different keys and a stolen identity key alone mints nothing. Give it a key of its own too — its own environment variable, falling back to nothing rather than to identityKey — so that verifying a person and impersonating an app are not the same secret. The migration in this pull has not run in production yet, so the column can go into it rather than after it.

40 .update(`relay-app:${id}:${generation}`)
41 .digest("base64url");
42 }
43
44 export function appById(db: Database, id: string): AppRow | undefined {
45 return (
46 db.query<AppRow, [string]>("SELECT * FROM app WHERE id = ?1").get(id) ??
47 undefined
48 );
49 }
src/db.ts +8
@@ -46,6 +46,14 @@ export const migrations: string[] = [
4646 `CREATE INDEX IF NOT EXISTS event_kind ON event (kind)`,
4747 `CREATE UNIQUE INDEX IF NOT EXISTS event_source_dedupe ON event (source, dedupe) WHERE dedupe IS NOT NULL`,
4848 `DROP TABLE IF EXISTS note`,
49 `CREATE TABLE app (
50 id TEXT PRIMARY KEY,
51 name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 60),
52 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
53 created_by TEXT NOT NULL,
54 generation INTEGER NOT NULL DEFAULT 1,
55 revoked_at TEXT
56 )`,
4957 ];
5058
5159 export function openDb(dir = dataDir): Database {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs