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
Check · Review · Land · Live done — Landed and live in production.
scout/relay:scout/35-app-identity-the-core-the-app-table-src → main 1 commit fork 1 thread · 1 unresolved
6 files
changed +415
−2
README.md +14
| @@ -31,6 +31,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 31 | 31 | | file | what it is | |
| 32 | 32 | | ----------------------------------- | ------------------------------------------------------------ | |
| 33 | 33 | | `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 | | |
| 34 | 35 | | `src/who.ts` | who is asking, from the forge's signed headers | |
| 35 | 36 | | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations | |
| 36 | 37 | | `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: | ||
| 62 | 63 | stripping them, and a `link`'s scheme is checked here so no other door has |
| 63 | 64 | to remember. |
| 64 | 65 | |
| 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 | ||
| 65 | 79 | ## Who you are |
| 66 | 80 | |
| 67 | 81 | 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"; | ||
| 2 | 2 | import { mkdtempSync } from "node:fs"; |
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | 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"; | |
| 6 | 15 | import { openDb } from "./db.ts"; |
| 7 | 16 | import type { Who } from "./who.ts"; |
| 8 | 17 | |
| @@ -184,3 +193,106 @@ test("getEvent finds an event by id, 404 when absent", () => { | ||
| 184 | 193 | expect(bad.ok).toBe(false); |
| 185 | 194 | if (!bad.ok) expect(bad.error).toBe("invalid"); |
| 186 | 195 | }); |
| 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 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | import { appSecret, type AppRow } from "./apps.ts"; | |
| 11 | import { identityKey } from "./env.ts"; | |
| 10 | 12 | import { emit } from "./live.ts"; |
| 11 | 13 | import type { Who } from "./who.ts"; |
| 12 | 14 | |
| @@ -22,7 +24,12 @@ export interface Event { | ||
| 22 | 24 | readonly dedupe: string | null; |
| 23 | 25 | } |
| 24 | 26 | |
| 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"; | |
| 26 | 33 | export type Result<T> = |
| 27 | 34 | | { ok: true; value: T } |
| 28 | 35 | | { ok: false; error: Failure; message: string }; |
| @@ -252,3 +259,181 @@ export function getEvent( | ||
| 252 | 259 | if (event === null) return fail("not_found", "No such event."); |
| 253 | 260 | return ok(event); |
| 254 | 261 | } |
| 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) | |
| 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[] = [ | ||
| 46 | 46 | `CREATE INDEX IF NOT EXISTS event_kind ON event (kind)`, |
| 47 | 47 | `CREATE UNIQUE INDEX IF NOT EXISTS event_source_dedupe ON event (source, dedupe) WHERE dedupe IS NOT NULL`, |
| 48 | 48 | `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 | )`, | |
| 49 | 57 | ]; |
| 50 | 58 | |
| 51 | 59 | export function openDb(dir = dataDir): Database { |
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
sourceon every event the app posts, andgenerationis 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 insrc/who.ts. Three consequences follow:FORGE_APP_IDENTITY_KEY(worth confirming before merge), an admin who registersbazaarin a throwaway pull preview walks away holding production'sbazaarkey.src/db.tsis careful that "production and every preview each have their own" database; this key space would be shared across both.revokeAppcannot 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.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 NULLcolumn to theapprow — a freshrandomBytes(16).toString("base64url")written at register and rewritten at rotate — and deriveHMAC(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 toidentityKey— 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.