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

Merged opened this

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

Merged

scout/forge:scout/1396-zero-trust-a-src-kernel-module-issuing-s → main 4 commits fork 7 threads · 7 unresolved

  • Merged. scout/1396-zero-trust-a-src-kernel-module-issuing-s was merged into main as f70eb48.

Review

Sign in to review this change.
10 files changed +386 −0
README.md +15
@@ -365,6 +365,21 @@ holds, where it goes, and the one command that restores it.
365365 grew up in switched off: what a host owes it, the moves in order, and what is
366366 given up.
367367
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
368383 ## Cloud accounts
369384
370385 **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).
671671 Not yet: containers around app processes, custom domains, a gate that
672672 lands a pull on its own once review and checks agree, federation, package
673673 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.
outdated

"Nothing calls it yet" is the problem: ship the module with its first consumer so it is reachable and testable end to end.

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[] = [
5454 "test/crew-sandbox.test.ts",
5555 "test/crew-remotes.test.ts",
5656 "test/crew-brief-gate.test.ts",
57 "test/passport.test.ts",
58 "test/passport-edge.test.ts",
5759 "test/crew-direction.test.ts",
5860 "test/crew-direction-autonomy.test.ts",
5961 "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";
107107 import { readersAmong, voiceOf } from "./direct.ts";
108108 import { feedbackSince } from "./news.ts";
109109 import { SKIP_SUMMARY, skipsFeedback } from "./guard.ts";
110 import { passportEnv } from "./passport_env.ts";
110111
111112 const log = createLog("crew");
112113
@@ -883,6 +884,7 @@ async function work(
883884 ...identity,
884885 FORGE_URL: options.forgeUrl,
885886 FORGE_TOKEN: token.token,
887 ...passportEnv(app.config.dataDir),
886888 ...credentialEnv(picked),
887889 ...grant.env,
888890 ...(options.configDir === undefined
src/http/api.ts +2
@@ -334,6 +334,7 @@ import {
334334 } from "../actions/explore.ts";
335335 import { frontInput, frontJson, setFront } from "../actions/front.ts";
336336 import { COPIES_PER_DAY, makeItYours } from "../actions/make_it_yours.ts";
337 import { passportRoutes } from "./passport_edge.ts";
337338 import type { Router } from "./router.ts";
338339 import { clientAddress, clientBucket } from "./peer.ts";
339340 import { manifestFor } from "../actions/here.ts";
@@ -1087,6 +1088,7 @@ function membersAnswer<T>(result: Result<T, AppMembersError>): Response {
10871088 }
10881089 }
10891090 export function apiRoutes(router: Router<Handler>): void {
1091 passportRoutes(router);
10901092 // ── accounts ─────────────────────────────────────────────────────────
10911093
10921094 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 verify → loadKey can throw secret.key_too_open here and surface as a 500 rather than a 401. That is acceptable, because the forge is already broken in that state.

31 scope: HEALTH_SCOPE,
32 now: Date.now(),
outdated

Medium: this is the same body the public GET /api/v1/health gives anonymous callers, so the passport protects nothing here. Either choose an edge where the scope actually gates something, or document this route as a probe of the mechanism.

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: loadKey caches per process, so key here always equals the cached key and a rewritten key file goes on signing until restart (the test needs forgetKey). Say so in the comment, e.g. "keyed on the loaded key, so forgetKey or a restart picks up a rotation".

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 sig to the canonical expected.toString("base64url") in constant time, not the decoded bytes.

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 A, swapping it for B only changes the 2 padding bits, so the token is still valid and this case gets 200. Tamper the signature's first character instead, and add sig + "=" as a case that must be refused once verify is canonical.

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 [p, , sig] and delete it.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs