Public: anyone can read this; the crew can change it.
Zero trust: a src/kernel module issuing short-lived scoped credentials between forge parts #1399
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1396-zero-trust-a-src-kernel-module-issuing-s → main 4 commits fork 7 threads · 7 unresolved
README.md +15
| @@ -365,6 +365,21 @@ holds, where it goes, and the one command that restores it. | ||
| 365 | 365 | grew up in switched off: what a host owes it, the moves in order, and what is |
| 366 | 366 | given up. |
| 367 | 367 | |
| 368 | ### Passports | |
| 369 | ||
| 370 | A crew run's environment carries `FORGE_PASSPORT`, a 15-minute credential | |
| 371 | scoped to `health:read` for the forge alone (`src/kernel/passport.ts`, no | |
| 372 | dependency: Node crypto's HMAC and HKDF). It opens one route and nothing else: | |
| 373 | ||
| 374 | curl -H "authorization: Passport $FORGE_PASSPORT" "$FORGE_URL/api/v1/passport/health" | |
| 375 | ||
| 376 | Missing, expired, wrong-audience, wrong-scope or forged passports get a 401. A | |
| 377 | passport has exactly one valid encoding (padded or altered variants are refused). | |
| 378 | ||
| 379 | This route proves the mechanism and is not a privilege: it returns what the | |
| 380 | anonymous `GET /api/v1/health` already returns. The next edge should guard | |
| 381 | something only a passport may reach. | |
| 382 | ||
| 368 | 383 | ## Cloud accounts |
| 369 | 384 | |
| 370 | 385 | **Settings → Cloud accounts** (admins) holds provider keys — Cloudflare, |
docs/ARCHITECTURE.md +16
| @@ -671,3 +671,19 @@ outbox, working in Claude Code and proposing through pulls a person merges). | ||
| 671 | 671 | Not yet: containers around app processes, custom domains, a gate that |
| 672 | 672 | lands a pull on its own once review and checks agree, federation, package |
| 673 | 673 | registry. |
| 674 | ||
| 675 | ## Passports between parts | |
| 676 | ||
| 677 | `kernel/passport.ts` issues short-lived (≤15 min) scoped credentials between | |
| 678 | forge parts: `issue` names issuer, audience, scopes and a ttl; `verify` | |
| 679 | checks signature (HMAC under an HKDF subkey of `secret.key`), expiry, | |
| 680 | audience and scope with no lookup. `verify` also checks that `iss`/`aud` | |
| 681 | are valid names and every scope a string, not the MAC alone. | |
| 682 | ||
| 683 | First edge: the runner issues each crew run a `FORGE_PASSPORT` (iss `runner`, | |
| 684 | aud `forge`, scope `health:read`, 15 min; `crew/passport_env.ts`), and | |
| 685 | `GET /api/v1/passport/health` (`http/passport_edge.ts`) answers the forge's | |
| 686 | public health only to `Authorization: Passport <token>` that verifies — a | |
| 687 | missing, expired, wrong-audience, wrong-scope or forged passport is a 401. | |
| 688 | Try: `curl -H "authorization: Passport $FORGE_PASSPORT" "$FORGE_URL/api/v1/passport/health"`. | |
| 689 | More edges follow the same pattern: one audience, one scope, one route. |
scripts/gate.ts +2
| @@ -54,6 +54,8 @@ export const GATE: readonly string[] = [ | ||
| 54 | 54 | "test/crew-sandbox.test.ts", |
| 55 | 55 | "test/crew-remotes.test.ts", |
| 56 | 56 | "test/crew-brief-gate.test.ts", |
| 57 | "test/passport.test.ts", | |
| 58 | "test/passport-edge.test.ts", | |
| 57 | 59 | "test/crew-direction.test.ts", |
| 58 | 60 | "test/crew-direction-autonomy.test.ts", |
| 59 | 61 | "test/crew-guard.test.ts", |
src/crew/passport_env.ts added +26
| @@ -0,0 +1,26 @@ | ||
| 1 | /** | |
| 2 | * The passport a crew run carries beside its token: `FORGE_PASSPORT`, issued | |
| 3 | * by the runner for the forge, good for reading the forge's health for the | |
| 4 | * length of a run's first minutes. No passport is better than a failed run, | |
| 5 | * so a refusal to issue leaves the variable out. | |
| 6 | */ | |
| 7 | ||
| 8 | import { issue } from "../kernel/passport.ts"; | |
| 9 | ||
| 10 | export const PASSPORT_TTL_MS = 15 * 60_000; | |
| 11 | ||
| 12 | export function passportEnv(dataDir: string, now = Date.now()): Record<string, string> { | |
| 13 | try { | |
| 14 | const made = issue(dataDir, { | |
| 15 | iss: "runner", | |
| 16 | aud: "forge", | |
| 17 | scopes: ["health:read"], | |
| 18 | ttlMs: PASSPORT_TTL_MS, | |
| 19 | now, | |
| 20 | }); | |
| 21 | return made.ok ? { FORGE_PASSPORT: made.value } : {}; | |
| 22 | } catch { | |
| 23 | // An unreadable key (`secret.key_too_open`) throws; the run goes on without. | |
| 24 | return {}; | |
| 25 | } | |
| 26 | } |
src/crew/runner.ts +2
| @@ -107,6 +107,7 @@ import { workdirFor } from "./workdir.ts"; | ||
| 107 | 107 | import { readersAmong, voiceOf } from "./direct.ts"; |
| 108 | 108 | import { feedbackSince } from "./news.ts"; |
| 109 | 109 | import { SKIP_SUMMARY, skipsFeedback } from "./guard.ts"; |
| 110 | import { passportEnv } from "./passport_env.ts"; | |
| 110 | 111 | |
| 111 | 112 | const log = createLog("crew"); |
| 112 | 113 | |
| @@ -883,6 +884,7 @@ async function work( | ||
| 883 | 884 | ...identity, |
| 884 | 885 | FORGE_URL: options.forgeUrl, |
| 885 | 886 | FORGE_TOKEN: token.token, |
| 887 | ...passportEnv(app.config.dataDir), | |
| 886 | 888 | ...credentialEnv(picked), |
| 887 | 889 | ...grant.env, |
| 888 | 890 | ...(options.configDir === undefined |
src/http/api.ts +2
| @@ -334,6 +334,7 @@ import { | ||
| 334 | 334 | } from "../actions/explore.ts"; |
| 335 | 335 | import { frontInput, frontJson, setFront } from "../actions/front.ts"; |
| 336 | 336 | import { COPIES_PER_DAY, makeItYours } from "../actions/make_it_yours.ts"; |
| 337 | import { passportRoutes } from "./passport_edge.ts"; | |
| 337 | 338 | import type { Router } from "./router.ts"; |
| 338 | 339 | import { clientAddress, clientBucket } from "./peer.ts"; |
| 339 | 340 | import { manifestFor } from "../actions/here.ts"; |
| @@ -1087,6 +1088,7 @@ function membersAnswer<T>(result: Result<T, AppMembersError>): Response { | ||
| 1087 | 1088 | } |
| 1088 | 1089 | } |
| 1089 | 1090 | export function apiRoutes(router: Router<Handler>): void { |
| 1091 | passportRoutes(router); | |
| 1090 | 1092 | // ── accounts ───────────────────────────────────────────────────────── |
| 1091 | 1093 | |
| 1092 | 1094 | router.add("POST", "/api/v1/users", async (ctx) => { |
src/http/passport_edge.ts added +39
| @@ -0,0 +1,39 @@ | ||
| 1 | /** | |
| 2 | * The first edge on passports (`kernel/passport.ts`): `GET /api/v1/passport/health` | |
| 3 | * answers the forge's public health to a caller holding a passport the forge | |
| 4 | * issued for audience `forge` with scope `health:read`, and to no one else. | |
| 5 | * A crew run is handed one (`FORGE_PASSPORT`, a few minutes) beside its token, | |
| 6 | * so an agent can ask "is the forge well" without its token's authority. | |
| 7 | * | |
| 8 | * This route is a probe of the mechanism, not a privilege: the body is what | |
| 9 | * the anonymous `GET /api/v1/health` already gives. It exists so the whole | |
| 10 | * path (issue, carry, verify) is exercised end to end; the next edge should | |
| 11 | * guard something a passport alone is meant to reach. | |
| 12 | * | |
| 13 | * Every refusal is a 401 with one code; the body never says which check failed. | |
| 14 | */ | |
| 15 | ||
| 16 | import { readHealth } from "../actions/health.ts"; | |
| 17 | import { verify } from "../kernel/passport.ts"; | |
| 18 | import type { Handler } from "./context.ts"; | |
| 19 | import { apiError, json } from "./respond.ts"; | |
| 20 | import type { Router } from "./router.ts"; | |
| 21 | ||
| 22 | export const PASSPORT_AUDIENCE = "forge"; | |
| 23 | export const HEALTH_SCOPE = "health:read"; | |
| 24 | ||
| 25 | export function passportRoutes(router: Router<Handler>): void { | |
| 26 | router.add("GET", "/api/v1/passport/health", (ctx) => { | |
| 27 | const header = ctx.request.headers.get("authorization") ?? ""; | |
| 28 | const seen = header.startsWith("Passport ") | |
| 29 | ? verify(ctx.app.config.dataDir, header.slice(9).trim(), { | |
| 30 | audience: PASSPORT_AUDIENCE, | |
Fine as is. Note that |
||
| 31 | scope: HEALTH_SCOPE, | |
| 32 | now: Date.now(), | |
outdated
Medium: this is the same body the public |
||
| 33 | }) | |
| 34 | : undefined; | |
| 35 | if (!seen?.ok) | |
| 36 | return apiError("unauthorized", 401, "a valid passport for this is needed"); | |
| 37 | return json(readHealth(ctx.app, undefined), 200, { "cache-control": "no-store" }); | |
| 38 | }); | |
| 39 | } |
src/kernel/passport.ts added +140
| @@ -0,0 +1,140 @@ | ||
| 1 | /** | |
| 2 | * Short-lived scoped credentials between forge parts. One part (the issuer) | |
| 3 | * hands another a passport: who it is for (`audience`), what it may do | |
| 4 | * (`scopes`), and when it stops working (`exp`). The receiver verifies it | |
| 5 | * with no database lookup and no shared long-lived secret travelling on the | |
| 6 | * wire — a stolen passport is useful to one audience, for a few minutes, | |
| 7 | * for the listed scopes only. | |
| 8 | * | |
| 9 | * Format: `fp1.<payload>.<mac>`, payload base64url JSON, mac an HMAC-SHA256 | |
| 10 | * over the payload keyed by a subkey derived from the data directory's | |
| 11 | * `secret.key` (HKDF, purpose "passport"), so it never reuses the key that | |
| 12 | * seals origin tokens. | |
| 13 | * | |
| 14 | * Pure but for the key read and the clock, which is passed in. | |
| 15 | */ | |
| 16 | ||
| 17 | import { createHmac, hkdfSync, timingSafeEqual } from "node:crypto"; | |
| 18 | import { loadKey } from "./secret.ts"; | |
| 19 | import { err, ok, type Result } from "./result.ts"; | |
| 20 | ||
| 21 | const PREFIX = "fp1"; | |
| 22 | /** The longest a passport may live: a credential is minutes, not days. */ | |
| 23 | export const MAX_TTL_MS = 15 * 60_000; | |
| 24 | const MAX_LEN = 2048; | |
| 25 | const NAME = /^[a-z][a-z0-9_.:-]{0,63}$/; | |
| 26 | const MAX_SCOPES = 16; | |
| 27 | ||
| 28 | export type Passport = { | |
| 29 | /** The part that issued it. */ | |
| 30 | readonly iss: string; | |
| 31 | /** The one part that may accept it. */ | |
| 32 | readonly aud: string; | |
| 33 | readonly scopes: readonly string[]; | |
| 34 | readonly exp: number; | |
| 35 | }; | |
| 36 | ||
| 37 | export type IssueError = "bad_name" | "bad_scopes" | "bad_ttl"; | |
| 38 | export type VerifyError = | |
| 39 | | "bad_format" | |
| 40 | | "bad_signature" | |
| 41 | | "expired" | |
| 42 | | "wrong_audience" | |
| 43 | | "missing_scope"; | |
| 44 | ||
| 45 | const subkeys = new Map<string, { key: Buffer; derived: Buffer }>(); | |
| 46 | ||
| 47 | function subkey(dataDir: string): Buffer { | |
| 48 | const key = loadKey(dataDir); | |
| 49 | const hit = subkeys.get(dataDir); | |
| 50 | // Keyed on the loaded key: `loadKey` caches per process, so `forgetKey` or a restart picks up a rotation. | |
outdated
Low: |
||
| 51 | if (hit?.key.equals(key)) return hit.derived; | |
| 52 | const derived = Buffer.from( | |
| 53 | hkdfSync("sha256", key, "", "forge passport v1", 32), | |
| 54 | ); | |
| 55 | subkeys.set(dataDir, { key, derived }); | |
| 56 | return derived; | |
| 57 | } | |
| 58 | ||
| 59 | function mac(dataDir: string, payload: string): Buffer { | |
| 60 | return createHmac("sha256", subkey(dataDir)).update(payload).digest(); | |
| 61 | } | |
| 62 | ||
| 63 | /** Mint a passport for `aud` carrying `scopes`, valid for `ttlMs` from `now`. */ | |
| 64 | export function issue( | |
| 65 | dataDir: string, | |
| 66 | input: { | |
| 67 | iss: string; | |
| 68 | aud: string; | |
| 69 | scopes: readonly string[]; | |
| 70 | ttlMs: number; | |
| 71 | now: number; | |
| 72 | }, | |
| 73 | ): Result<string, IssueError> { | |
| 74 | if (!NAME.test(input.iss) || !NAME.test(input.aud)) return err("bad_name"); | |
| 75 | const scopes = [...new Set(input.scopes)].sort(); | |
| 76 | if ( | |
| 77 | scopes.length === 0 || | |
| 78 | scopes.length > MAX_SCOPES || | |
| 79 | !scopes.every((s) => NAME.test(s)) | |
| 80 | ) | |
| 81 | return err("bad_scopes"); | |
| 82 | if ( | |
| 83 | !Number.isFinite(input.ttlMs) || | |
| 84 | input.ttlMs <= 0 || | |
| 85 | input.ttlMs > MAX_TTL_MS | |
| 86 | ) | |
| 87 | return err("bad_ttl"); | |
| 88 | const body: Passport = { | |
| 89 | iss: input.iss, | |
| 90 | aud: input.aud, | |
| 91 | scopes, | |
| 92 | exp: Math.floor(input.now + input.ttlMs), | |
| 93 | }; | |
| 94 | const payload = Buffer.from(JSON.stringify(body)).toString("base64url"); | |
| 95 | return ok(`${PREFIX}.${payload}.${mac(dataDir, payload).toString("base64url")}`); | |
| 96 | } | |
| 97 | ||
| 98 | /** | |
| 99 | * Accept a passport only if it is genuine, unexpired, addressed to | |
| 100 | * `audience`, and carries `scope`. Every refusal is a value, none reveals | |
| 101 | * why the signature failed. | |
| 102 | */ | |
| 103 | export function verify( | |
| 104 | dataDir: string, | |
| 105 | token: string, | |
| 106 | want: { audience: string; scope: string; now: number }, | |
| 107 | ): Result<Passport, VerifyError> { | |
| 108 | if (typeof token !== "string" || token.length > MAX_LEN) | |
| 109 | return err("bad_format"); | |
| 110 | const parts = token.split("."); | |
| 111 | if (parts.length !== 3 || parts[0] !== PREFIX) return err("bad_format"); | |
| 112 | const [, payload, sig] = parts as [string, string, string]; | |
| 113 | // One valid encoding only: base64url decoding is lenient (padding, stray | |
outdated
High: lenient base64url decoding means a padded signature, one with stray characters, or one with a different low-bit last character all verify. Compare |
||
| 114 | // characters, spare low bits), so compare the canonical text, not the bytes. | |
| 115 | const given = Buffer.from(sig); | |
| 116 | const expected = Buffer.from(mac(dataDir, payload).toString("base64url")); | |
| 117 | if (given.length !== expected.length || !timingSafeEqual(given, expected)) | |
| 118 | return err("bad_signature"); | |
| 119 | ||
| 120 | let body: Passport; | |
| 121 | try { | |
| 122 | body = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); | |
| 123 | } catch { | |
| 124 | return err("bad_format"); | |
| 125 | } | |
| 126 | if ( | |
| 127 | typeof body?.iss !== "string" || | |
| 128 | typeof body.aud !== "string" || | |
| 129 | typeof body.exp !== "number" || | |
| 130 | !Array.isArray(body.scopes) || | |
| 131 | !NAME.test(body.iss) || | |
| 132 | !NAME.test(body.aud) || | |
| 133 | !body.scopes.every((s) => typeof s === "string") | |
| 134 | ) | |
| 135 | return err("bad_format"); | |
| 136 | if (want.now >= body.exp) return err("expired"); | |
| 137 | if (body.aud !== want.audience) return err("wrong_audience"); | |
| 138 | if (!body.scopes.includes(want.scope)) return err("missing_scope"); | |
| 139 | return ok(body); | |
| 140 | } |
test/passport-edge.test.ts added +62
| @@ -0,0 +1,62 @@ | ||
| 1 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 2 | import { mkdtemp } from "node:fs/promises"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { createApp } from "../src/app.ts"; | |
| 6 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 7 | import { issue } from "../src/kernel/passport.ts"; | |
| 8 | import { passportEnv } from "../src/crew/passport_env.ts"; | |
| 9 | ||
| 10 | let dataDir = ""; | |
| 11 | let servers: Servers; | |
| 12 | ||
| 13 | beforeAll(async () => { | |
| 14 | dataDir = await mkdtemp(join(tmpdir(), "forge-passport-edge-")); | |
| 15 | servers = await serve(createApp({ dataDir, port: 0 })); | |
| 16 | }); | |
| 17 | afterAll(() => servers.stop?.()); | |
| 18 | ||
| 19 | const ask = (token?: string) => | |
| 20 | fetch(`${servers.url}/api/v1/passport/health`, { | |
| 21 | headers: token === undefined ? {} : { authorization: `Passport ${token}` }, | |
| 22 | }); | |
| 23 | const mint = (o: { aud?: string; scopes?: string[]; ttlMs?: number; now?: number } = {}) => { | |
| 24 | const r = issue(dataDir, { | |
| 25 | iss: "runner", | |
| 26 | aud: o.aud ?? "forge", | |
| 27 | scopes: o.scopes ?? ["health:read"], | |
| 28 | ttlMs: o.ttlMs ?? 60_000, | |
| 29 | now: o.now ?? Date.now(), | |
| 30 | }); | |
| 31 | if (!r.ok) throw new Error(r.error); | |
| 32 | return r.value; | |
| 33 | }; | |
| 34 | ||
| 35 | /** Change the signature's first character: always a different MAC. */ | |
| 36 | const tamperSig = (t: string) => | |
| 37 | t.replace(/\.([^.])([^.]*)$/, (_, c, rest) => `.${c === "A" ? "B" : "A"}${rest}`); | |
| 38 | ||
| 39 | test("a good passport reads the health", async () => { | |
| 40 | const r = await ask(mint()); | |
| 41 | expect(r.status).toBe(200); | |
| 42 | expect(((await r.json()) as { ok: boolean }).ok).toBeDefined(); | |
| 43 | }); | |
| 44 | ||
| 45 | test("the runner's own passport is accepted", async () => { | |
| 46 | const r = await ask(passportEnv(dataDir)["FORGE_PASSPORT"]); | |
| 47 | expect(r.status).toBe(200); | |
| 48 | }); | |
| 49 | ||
| 50 | test("missing, expired, wrong-audience, wrong-scope and forged passports are refused", async () => { | |
| 51 | const bad = [ | |
| 52 | undefined, | |
| 53 | "garbage", | |
outdated
This flake fires about 1 run in 16. When the signature ends in |
||
| 54 | mint({ now: Date.now() - 120_000 }), | |
| 55 | mint({ aud: "app" }), | |
| 56 | mint({ scopes: ["run:read"] }), | |
| 57 | tamperSig(mint()), | |
| 58 | `${mint()}=`, | |
| 59 | mint().replace(/.$/, "!$&"), | |
| 60 | ]; | |
| 61 | for (const t of bad) expect((await ask(t)).status).toBe(401); | |
| 62 | }); |
test/passport.test.ts added +82
| @@ -0,0 +1,82 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { mkdtempSync, writeFileSync } from "node:fs"; | |
| 3 | import { randomBytes } from "node:crypto"; | |
| 4 | import { forgetKey } from "../src/kernel/secret.ts"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { issue, MAX_TTL_MS, verify } from "../src/kernel/passport.ts"; | |
| 8 | ||
| 9 | const dir = () => mkdtempSync(join(tmpdir(), "passport-")); | |
| 10 | const base = { iss: "forge", aud: "runner", scopes: ["run:read"], ttlMs: 60_000, now: 1000 }; | |
| 11 | const want = { audience: "runner", scope: "run:read", now: 2000 }; | |
| 12 | const mint = (d: string, o = {}) => { | |
| 13 | const r = issue(d, { ...base, ...o }); | |
| 14 | if (!r.ok) throw new Error(r.error); | |
| 15 | return r.value; | |
| 16 | }; | |
| 17 | ||
| 18 | test("a fresh passport verifies for its audience and scope", () => { | |
| 19 | const d = dir(); | |
| 20 | const r = verify(d, mint(d), want); | |
| 21 | expect(r.ok && r.value.iss).toBe("forge"); | |
| 22 | }); | |
| 23 | ||
| 24 | test("expiry is enforced", () => { | |
| 25 | const d = dir(); | |
| 26 | const r = verify(d, mint(d), { ...want, now: 61_000 }); | |
| 27 | expect(!r.ok && r.error).toBe("expired"); | |
| 28 | }); | |
| 29 | ||
| 30 | test("another audience or an unlisted scope is refused", () => { | |
| 31 | const d = dir(); | |
| 32 | const t = mint(d); | |
| 33 | const a = verify(d, t, { ...want, audience: "app" }); | |
| 34 | const s = verify(d, t, { ...want, scope: "run:write" }); | |
| 35 | expect(!a.ok && a.error).toBe("wrong_audience"); | |
| 36 | expect(!s.ok && s.error).toBe("missing_scope"); | |
| 37 | }); | |
| 38 | ||
| 39 | test("a tampered payload or another forge's key is refused", () => { | |
| 40 | const d = dir(); | |
| 41 | const t = mint(d); | |
| 42 | const [p, , sig] = t.split(".") as [string, string, string]; | |
| 43 | const forged = Buffer.from( | |
| 44 | JSON.stringify({ iss: "forge", aud: "runner", scopes: ["admin"], exp: 9e12 }), | |
| 45 | ).toString("base64url"); | |
| 46 | const r = verify(d, `${p}.${forged}.${sig}`, { ...want, scope: "admin" }); | |
| 47 | expect(!r.ok && r.error).toBe("bad_signature"); | |
| 48 | const other = verify(dir(), t, want); | |
outdated
This assertion tests nothing; it only keeps an unused variable alive. Destructure |
||
| 49 | expect(!other.ok && other.error).toBe("bad_signature"); | |
| 50 | }); | |
| 51 | ||
| 52 | test("garbage is bad_format", () => { | |
| 53 | const d = dir(); | |
| 54 | for (const t of ["", "x", "fp1.a", "fp2.a.b", "a.b.c.d", "x".repeat(3000)]) { | |
| 55 | const r = verify(d, t, want); | |
| 56 | expect(!r.ok && r.error).toBe("bad_format"); | |
| 57 | } | |
| 58 | }); | |
| 59 | ||
| 60 | test("issue validates names, scopes and ttl", () => { | |
| 61 | const d = dir(); | |
| 62 | const e = (o: object) => { | |
| 63 | const r = issue(d, { ...base, ...o }); | |
| 64 | return !r.ok && r.error; | |
| 65 | }; | |
| 66 | expect(e({ aud: "Bad Name" })).toBe("bad_name"); | |
| 67 | expect(e({ scopes: [] })).toBe("bad_scopes"); | |
| 68 | expect(e({ scopes: ["ok", "no spaces"] })).toBe("bad_scopes"); | |
| 69 | expect(e({ ttlMs: 0 })).toBe("bad_ttl"); | |
| 70 | expect(e({ ttlMs: MAX_TTL_MS + 1 })).toBe("bad_ttl"); | |
| 71 | expect(e({ ttlMs: NaN })).toBe("bad_ttl"); | |
| 72 | }); | |
| 73 | ||
| 74 | test("rotating the key file stops old passports verifying", () => { | |
| 75 | const d = dir(); | |
| 76 | const t = mint(d); | |
| 77 | expect(verify(d, t, want).ok).toBe(true); | |
| 78 | writeFileSync(join(d, "secret.key"), randomBytes(32), { mode: 0o600 }); | |
| 79 | forgetKey(d); | |
| 80 | const r = verify(d, t, want); | |
| 81 | expect(!r.ok && r.error).toBe("bad_signature"); | |
| 82 | }); |
"Nothing calls it yet" is the problem: ship the module with its first consumer so it is reachable and testable end to end.