feat: scope-down ticket exchange — derive a sync ticket from a provision one
- commit
- 24706fa75783c06501f68cc8500a1c8b794b67b7
- parent
- b53228d3204507dfb1c4dbebff8d373bdb8f1eec
- parent
- 92a78775de972cc6601e1aff133aff2086d621bb
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | cli.ts | 13 | +++- |
| M | docs/ | 27 | ++++++++ |
| M | src/ | 44 | +++++++++++- |
| M | src/ | 53 | +++++++++++++++- |
| M | src/ | 25 | ++++++- |
| M | tests/ | 61 | +++++++++++++++++- |
| M | tests/ | 104 | +++++++++++++++++++++++++++++- |
@@ -26,7 +26,12 @@ import {
2626 } from "./src/config.ts";2727 import { createSyncNode } from "./src/node.ts";2828 import { looksLikeTicket } from "./src/ticket.ts";29-import { AppTicketStore, encodeAppTicket, looksLikeAppTicket } from "./src/appticket.ts";29+import {30+ AppTicketStore,31+ encodeAppTicket,32+ isRevokedByLineage,33+ looksLikeAppTicket,34+} from "./src/appticket.ts";3035 3136 const USAGE = `lofi-node — self-hostable sync node for lofi apps3237 @@ -303,10 +308,12 @@ async function cmdTicket(args: Args) {
303308 return;304309 }305310 for (const t of tickets) {311+ // Lineage-aware: a derived ticket shows REVOKED once its parent is.312+ const dead = isRevokedByLineage(t, tickets);306313 console.log(307- `${t.id} ${t.revokedAt ? "REVOKED" : "active "} ${314+ `${t.id} ${dead ? "REVOKED" : "active "} ${308315 (t.scope ?? "sync").padEnd(9)309- } ${t.createdAt} ${t.label ?? ""}`,316+ } ${t.createdAt} ${t.label ?? ""}${t.parentId ? ` [from ${t.parentId}]` : ""}`,310317 );311318 }312319 return;@@ -61,6 +61,33 @@ The node's access gate validates the secret (timing-safe, digest vs digest), str
6161 6262 Revocation semantics are identical for both scopes (401 / WS 4001).6363 64+## Scope-down exchange (derive a sync ticket)65+66+A provision ticket can mint a **derived sync ticket** from itself, so a user who pastes a single67+provision ticket ends up with two capability tiers: the app persists the derived sync ticket at rest68+and keeps the provision ticket sealed (passkey-PRF) or memory-only.69+70+```71+POST <ticket.url>/derive-sync-ticket72+```73+74+- **Auth**: the provision-scoped secret in the URL path, like every gated request. A sync-scoped or75+ unknown secret gets the same `401 {"error":"invalid_ticket"}` as any unauthorized request —76+ nothing to enumerate. Non-POST methods on a provision ticket get `405`.77+- **Request body** (optional): `{ "label": "laptop" }`. Absent, the label defaults to78+ `<parent label> (sync)` (parent id when unlabelled).79+- **Response**: `200 {"v": 1, "id": "<ticket id>", "ticket": "lofisync1.…"}` — a complete80+ sync-scoped ticket string. Its `url` uses the node's configured public base (`--public-url`),81+ exactly like CLI-issued tickets; the secret is embedded once and never stored.82+83+The derived ticket's record carries the parent's id, and **revocation cascades**: once the parent is84+revoked (or its record removed), every ticket derived from it fails verification exactly like a85+revoked ticket — 401 on new requests, close code 4001 for live WebSockets. Revoking a derived ticket86+alone leaves its parent untouched. Derived tickets are always sync scope, so derivation cannot87+escalate and effective chains are one level deep; the parent check still walks the whole lineage.88+`lofi-node ticket list` shows derived tickets as `[from <parent id>]` and reports them REVOKED once89+their lineage is.90+6491 ## Store-status preflight6592 6693 Against a store with **no deployed schema, client writes hang indefinitely** (lofi#109's pinned@@ -37,6 +37,10 @@ export interface AppTicketRecord {
3737 id: string;3838 scope?: "sync" | "provision";3939 label?: string;40+ /** Id of the ticket this one was derived from (the gate's scope-down41+ * exchange). Revocation cascades: a ticket is dead whenever any ancestor42+ * is revoked or missing. */43+ parentId?: string;4044 secretHash: string;4145 createdAt: string;4246 revokedAt?: string;@@ -109,15 +113,32 @@ function timingSafeEqualHex(a: string, b: string): boolean {
109113 return diff === 0;110114 }111115 116+/** True when the record or any ancestor in `all` is revoked or missing —117+ * derived tickets die with their parent, so the check walks the whole chain118+ * (bounded; a cycle would mean a corrupt file and counts as dead). */119+export function isRevokedByLineage(record: AppTicketRecord, all: AppTicketRecord[]): boolean {120+ const seen = new Set<string>();121+ let cursor: AppTicketRecord | undefined = record;122+ while (cursor) {123+ if (cursor.revokedAt) return true;124+ if (cursor.parentId === undefined) return false;125+ if (seen.has(cursor.id)) return true;126+ seen.add(cursor.id);127+ const parentId: string = cursor.parentId;128+ cursor = all.find((t) => t.id === parentId);129+ }130+ return true; // parent record missing131+}132+112133 export type VerifyResult =113134 | { status: "valid"; record: AppTicketRecord }114135 | { status: "revoked"; record: AppTicketRecord }115136 | { status: "unknown" };116137 117138 /** Issued-ticket persistence. File-backed when a dataDir is given (the CLI118- * writes it; a running daemon only READS, picking up changes via mtime — so119- * `ticket issue`/`revoke` need no IPC), in-memory otherwise (tests, ephemeral120- * nodes). */139+ * writes it for issue/revoke; the daemon reads via throttled mtime checks —140+ * so those commands need no IPC — and writes only when the gate's scope-down141+ * exchange derives a ticket), in-memory otherwise (tests, ephemeral nodes). */121142 export class AppTicketStore {122143 #path: string | null;123144 #tickets: AppTicketRecord[] = [];@@ -171,17 +192,29 @@ export class AppTicketStore {
171192 this.#mtime = (await Deno.stat(this.#path)).mtime?.getTime() ?? 0;172193 }173194 195+ /** Issue a ticket. `parentId` links a derived ticket to the ticket it was196+ * minted from — the derived ticket then dies with its parent. The parent197+ * must be live at issuance. Records without a parent serialize exactly as198+ * before (the field is omitted), so tickets.json stays v1. */174199 async issue(175200 label?: string,176201 scope: "sync" | "provision" = "sync",202+ parentId?: string,177203 ): Promise<{ record: AppTicketRecord; secret: string }> {178204 await this.#reload();205+ if (parentId !== undefined) {206+ const parent = this.#tickets.find((t) => t.id === parentId);207+ if (!parent || isRevokedByLineage(parent, this.#tickets)) {208+ throw new Error(`parent ticket ${parentId} is unknown or revoked`);209+ }210+ }179211 const secret = generateSecret();180212 const secretHash = await hashSecret(secret);181213 const record: AppTicketRecord = {182214 id: secretHash.slice(0, 12),183215 scope,184216 label,217+ parentId,185218 secretHash,186219 createdAt: new Date().toISOString(),187220 };@@ -208,7 +241,8 @@ export class AppTicketStore {
208241 209242 /** Timing-safe lookup by presented secret. Compares against EVERY record210243 * (valid and revoked) so probers cannot distinguish unknown from revoked by211- * timing; the gate returns 401 for both. */244+ * timing; the gate returns 401 for both. A derived ticket whose lineage is245+ * dead reports "revoked" exactly like a directly revoked one. */212246 async verify(secret: string): Promise<VerifyResult> {213247 await this.#maybeReload();214248 const presented = await hashSecret(secret);@@ -217,7 +251,7 @@ export class AppTicketStore {
217251 if (timingSafeEqualHex(presented, record.secretHash)) match = record;218252 }219253 if (!match) return { status: "unknown" };220- return match.revokedAt254+ return isRevokedByLineage(match, this.#tickets)221255 ? { status: "revoked", record: match }222256 : { status: "valid", record: match };223257 }@@ -9,7 +9,12 @@
99 // internal Jazz port. WS is terminated and re-originated (safe: Jazz auth is1010 // an in-band frame, proven by the iroh tunnel doing the same); HTTP streams.1111 12-import { type AppTicketStore, SECRET_LENGTH } from "./appticket.ts";12+import {13+ type AppTicketStore,14+ encodeAppTicket,15+ isRevokedByLineage,16+ SECRET_LENGTH,17+} from "./appticket.ts";1318 import { forwardableHeaders, sanitizeCloseCode } from "./tunnel.ts";1419 1520 const DEBUG = Deno.env.get("LOFI_NODE_DEBUG") === "1";@@ -37,6 +42,13 @@ export interface GateOptions {
3742 * mode; the gate injects config's own for provision-scoped requests. */3843 appId: string;3944 adminSecret: string;45+ /** Base URL embedded into tickets minted by the derive endpoint (the46+ * node's publicUrl); defaults to the gate's own loopback URL — same47+ * election as CLI-issued tickets. */48+ publicBase?: string;49+ /** The node's iroh EndpointTicket, carried on derived tickets exactly like50+ * node-issued ones. */51+ nodeTicket?: string;4052 }4153 4254 export interface Gate {@@ -251,7 +263,7 @@ export function startGate(options: GateOptions): Gate {
251263 const records = await options.store.list();252264 for (const [ticketId, sockets] of liveByTicket) {253265 const record = records.find((r) => r.id === ticketId);254- if (record && !record.revokedAt) continue;266+ if (record && !isRevokedByLineage(record, records)) continue;255267 for (const socket of [...sockets]) {256268 try {257269 socket.close(CLOSE_TICKET_REVOKED, "ticket revoked");@@ -299,6 +311,43 @@ export function startGate(options: GateOptions): Gate {
299311 if (strippedPath === "/store-status" && !isUpgrade) {300312 return storeStatus(targetBase, options.appId, options.adminSecret);301313 }314+ // Scope-down exchange: a provision ticket mints a derived sync ticket315+ // (the lofi app persists that one at rest, keeping the provision ticket316+ // sealed or memory-only). The derived record carries parentId, so317+ // revoking the provision ticket kills its derived tickets too. A sync318+ // ticket gets the SAME 401 as an invalid one (nothing to enumerate).319+ if (strippedPath === "/derive-sync-ticket" && !isUpgrade) {320+ if (scope !== "provision") {321+ debug("rejected sync-scope on derive", verdict.record.id);322+ return unauthorized();323+ }324+ if (req.method !== "POST") {325+ return new Response("method not allowed", { status: 405 });326+ }327+ let requested: string | undefined;328+ try {329+ const body = await req.json() as { label?: unknown };330+ if (typeof body.label === "string") requested = body.label;331+ } catch {332+ // empty or non-JSON body: fall through to the default label333+ }334+ const parent = verdict.record;335+ const label = requested ?? `${parent.label ?? parent.id} (sync)`;336+ const derived = await options.store.issue(label, "sync", parent.id);337+ const base = (options.publicBase ??338+ `http://127.0.0.1:${(server.addr as Deno.NetAddr).port}`).replace(/\/+$/, "");339+ const ticket = encodeAppTicket({340+ v: 1,341+ appId: options.appId,342+ url: `${base}/t/${derived.secret}`,343+ label,344+ node: options.nodeTicket,345+ });346+ debug("derived sync ticket", derived.record.id, "from", parent.id);347+ return new Response(JSON.stringify({ v: 1, id: derived.record.id, ticket }), {348+ headers: { "content-type": "application/json" },349+ });350+ }302351 // Admin/catalogue-mutating routes need provision scope; a sync ticket303352 // gets the SAME 401 shape as an invalid ticket (nothing to enumerate).304353 if (ADMIN_PATH.test(strippedPath) && scope !== "provision") {@@ -21,7 +21,12 @@ import {
2121 validateStorage,2222 } from "./config.ts";2323 import { type Gate, startGate } from "./gate.ts";24-import { type AppTicketRecord, AppTicketStore, encodeAppTicket } from "./appticket.ts";24+import {25+ type AppTicketRecord,26+ AppTicketStore,27+ encodeAppTicket,28+ isRevokedByLineage,29+} from "./appticket.ts";2530 import { MeshUnavailableError } from "./errors.ts";2631 2732 /** Options for {@link createSyncNode}. */@@ -75,7 +80,10 @@ export interface AppTicketInfo {
7580 id: string;7681 scope: "sync" | "provision";7782 label?: string;83+ /** Id of the ticket this one was derived from (scope-down exchange). */84+ parentId?: string;7885 createdAt: string;86+ /** True when the ticket itself or any ancestor is revoked. */7987 revoked: boolean;8088 }8189 @@ -119,13 +127,14 @@ export interface SyncNode {
119127 stop(): Promise<void>;120128 }121129 122-function toTicketInfo(record: AppTicketRecord): AppTicketInfo {130+function toTicketInfo(record: AppTicketRecord, all: AppTicketRecord[]): AppTicketInfo {123131 return {124132 id: record.id,125133 scope: record.scope ?? "sync",126134 label: record.label,135+ parentId: record.parentId,127136 createdAt: record.createdAt,128- revoked: Boolean(record.revokedAt),137+ revoked: isRevokedByLineage(record, all),129138 };130139 }131140 @@ -259,6 +268,8 @@ export async function createSyncNode(options: SyncNodeOptions): Promise<SyncNode
259268 store: ticketStore,260269 appId: options.appId,261270 adminSecret: options.adminSecret,271+ publicBase: options.publicUrl,272+ nodeTicket: ticketString ?? undefined,262273 });263274 }264275 @@ -275,9 +286,13 @@ export async function createSyncNode(options: SyncNodeOptions): Promise<SyncNode
275286 276287 // status() is sync; keep a ticket snapshot refreshed by every ticket call277288 // and opportunistically by status() itself (next call sees fresh state).278- let ticketSnapshot: AppTicketInfo[] = (await ticketStore.list()).map(toTicketInfo);289+ const snapshotTickets = async () => {290+ const records = await ticketStore.list();291+ return records.map((record) => toTicketInfo(record, records));292+ };293+ let ticketSnapshot: AppTicketInfo[] = await snapshotTickets();279294 const refreshTickets = async () => {280- ticketSnapshot = (await ticketStore.list()).map(toTicketInfo);295+ ticketSnapshot = await snapshotTickets();281296 return ticketSnapshot;282297 };283298 @@ -1,5 +1,6 @@
1-import { assert, assertEquals, assertStrictEquals } from "@std/assert";1+import { assert, assertEquals, assertRejects, assertStrictEquals } from "@std/assert";22 import {3+ type AppTicketRecord,34 AppTicketStore,45 decodeAppTicket,56 encodeAppTicket,@@ -111,6 +112,64 @@ Deno.test("store: external file change is picked up (CLI → daemon hot-reload)"
111112 }112113 });113114 115+Deno.test("store: parent revocation cascades to derived tickets", async () => {116+ const store = await AppTicketStore.load();117+ const { record: parent, secret: parentSecret } = await store.issue("laptop-admin", "provision");118+ const { record: child, secret: childSecret } = await store.issue(119+ "laptop-admin (sync)",120+ "sync",121+ parent.id,122+ );123+ assertEquals(child.parentId, parent.id);124+ assertEquals((await store.verify(parentSecret)).status, "valid");125+ assertEquals((await store.verify(childSecret)).status, "valid");126+127+ await store.revoke(parent.id);128+ assertEquals((await store.verify(parentSecret)).status, "revoked");129+ assertEquals(130+ (await store.verify(childSecret)).status,131+ "revoked",132+ "derived ticket rejects exactly like a revoked one",133+ );134+});135+136+Deno.test("store: issuing against an unknown or revoked parent throws", async () => {137+ const store = await AppTicketStore.load();138+ await assertRejects(() => store.issue("x", "sync", "no-such-id"), Error, "unknown or revoked");139+ const { record } = await store.issue("gone", "provision");140+ await store.revoke(record.id);141+ await assertRejects(() => store.issue("x", "sync", record.id), Error, "unknown or revoked");142+});143+144+Deno.test("store: parentId persists in v1 tickets.json; a removed parent kills the child", async () => {145+ const dir = await Deno.makeTempDir();146+ try {147+ const store = await AppTicketStore.load(dir);148+ const { record: parent } = await store.issue("admin", "provision");149+ const { secret: childSecret } = await store.issue("derived", "sync", parent.id);150+151+ const path = `${dir}/tickets.json`;152+ const file = JSON.parse(await Deno.readTextFile(path)) as {153+ v: number;154+ tickets: AppTicketRecord[];155+ };156+ assertEquals(file.v, 1, "file format stays v1 — parentId is an additive optional field");157+ assertEquals(file.tickets.find((t) => t.parentId)?.parentId, parent.id);158+159+ const reloaded = await AppTicketStore.load(dir);160+ assertEquals((await reloaded.verify(childSecret)).status, "valid");161+162+ // Remove the parent record entirely: the orphaned child must reject like163+ // a revoked ticket.164+ file.tickets = file.tickets.filter((t) => t.id !== parent.id);165+ await Deno.writeTextFile(path, JSON.stringify(file, null, 2) + "\n");166+ const orphaned = await AppTicketStore.load(dir);167+ assertEquals((await orphaned.verify(childSecret)).status, "revoked");168+ } finally {169+ await Deno.remove(dir, { recursive: true });170+ }171+});172+114173 Deno.test("store: in-memory fallback works without a dataDir", async () => {115174 const store = await AppTicketStore.load();116175 const { secret } = await store.issue();@@ -1,8 +1,8 @@
11 // Gate behavior against a stub upstream (no jazz, no iroh): routing,22 // secret verification, prefix stripping, WS re-origination, revocation.33 4-import { assertEquals } from "@std/assert";5-import { AppTicketStore } from "../src/appticket.ts";4+import { assert, assertEquals } from "@std/assert";5+import { AppTicketStore, decodeAppTicket } from "../src/appticket.ts";66 import { CLOSE_TICKET_REVOKED, startGate } from "../src/gate.ts";77 88 /** Stub upstream: echoes path+search+selected headers over HTTP; echoes@@ -241,6 +241,106 @@ Deno.test({
241241 }),242242 });243243 244+Deno.test({245+ name: "gate: derive-sync-ticket mints a linked sync ticket that syncs through the gate",246+ sanitizeOps: false,247+ sanitizeResources: false,248+ fn: () =>249+ withGate("ticket", async ({ gateUrl, store }) => {250+ const { record: parent, secret } = await store.issue("laptop-admin", "provision");251+ const res = await fetch(`${gateUrl}/t/${secret}/derive-sync-ticket`, {252+ method: "POST",253+ body: JSON.stringify({ label: "laptop" }),254+ });255+ assertEquals(res.status, 200);256+ const body = await res.json() as { v: number; id: string; ticket: string };257+ assertEquals(body.v, 1);258+ const derived = decodeAppTicket(body.ticket);259+ assert(derived !== null, "response carries a decodable lofisync1. ticket");260+ assertEquals(derived.appId, "stub-app");261+ assertEquals(derived.scope ?? "sync", "sync", "derived tickets are sync-scoped");262+ assertEquals(derived.label, "laptop");263+ assert(derived.url.startsWith(`${gateUrl}/t/`), "url uses the gate base");264+ const record = (await store.list()).find((t) => t.id === body.id);265+ assertEquals(record?.parentId, parent.id, "derived record links its parent");266+267+ // The derived ticket round-trips through the gate for sync traffic…268+ const ok = await fetch(`${derived.url}/apps/abc/echo`, {269+ headers: { "X-Jazz-Admin-Secret": "client-supplied-fake" },270+ });271+ assertEquals(ok.status, 200);272+ assertEquals((await ok.json()).admin, null, "sync scope: no admin injection");273+ // …but stays locked out of admin paths and further derivation.274+ const admin = await fetch(`${derived.url}/apps/stub-app/admin/schemas`, {275+ method: "POST",276+ body: "{}",277+ });278+ assertEquals(admin.status, 401);279+ assertEquals(await admin.json(), { error: "invalid_ticket" });280+281+ // Default label derives from the parent's label.282+ const res2 = await fetch(`${gateUrl}/t/${secret}/derive-sync-ticket`, { method: "POST" });283+ assertEquals(res2.status, 200);284+ const derived2 = decodeAppTicket((await res2.json() as { ticket: string }).ticket);285+ assertEquals(derived2?.label, "laptop-admin (sync)");286+ }),287+});288+289+Deno.test({290+ name: "gate: derive-sync-ticket 401s sync scope and unknown secrets with the invalid shape",291+ sanitizeOps: false,292+ sanitizeResources: false,293+ fn: () =>294+ withGate("ticket", async ({ gateUrl, store }) => {295+ const { secret } = await store.issue("sync-only"); // default scope296+ const res = await fetch(`${gateUrl}/t/${secret}/derive-sync-ticket`, { method: "POST" });297+ assertEquals(res.status, 401);298+ assertEquals(await res.json(), { error: "invalid_ticket" }, "same shape as invalid");299+ const bogus = "A".repeat(43);300+ const unknown = await fetch(`${gateUrl}/t/${bogus}/derive-sync-ticket`, { method: "POST" });301+ assertEquals(unknown.status, 401);302+ assertEquals(await unknown.json(), { error: "invalid_ticket" });303+ }),304+});305+306+Deno.test({307+ name: "gate: revoking the parent cascades — derived ticket 401s, live sockets close 4001",308+ sanitizeOps: false,309+ sanitizeResources: false,310+ fn: () =>311+ withGate("ticket", async ({ gateUrl, store }) => {312+ const { record: parent, secret } = await store.issue("admin", "provision");313+ const res = await fetch(`${gateUrl}/t/${secret}/derive-sync-ticket`, { method: "POST" });314+ const { ticket } = await res.json() as { ticket: string };315+ const derived = decodeAppTicket(ticket);316+ assert(derived !== null);317+ const before = await fetch(`${derived.url}/apps/abc/echo`);318+ assertEquals(before.status, 200);319+ await before.body?.cancel();320+321+ const ws = new WebSocket(`${derived.url.replace("http", "ws")}/apps/abc/ws`);322+ await new Promise<void>((resolve, reject) => {323+ ws.addEventListener("open", () => resolve());324+ ws.addEventListener("close", () => reject(new Error("closed before open")));325+ });326+ const closed = new Promise<CloseEvent>((resolve) =>327+ ws.addEventListener("close", (ev) => resolve(ev))328+ );329+330+ await store.revoke(parent.id);331+ const rejected = await fetch(`${derived.url}/apps/abc/echo`);332+ assertEquals(rejected.status, 401, "derived ticket dies with its parent");333+ assertEquals(await rejected.json(), { error: "invalid_ticket" }, "same shape as revoked");334+ const ev = await Promise.race([335+ closed,336+ new Promise<never>((_r, reject) =>337+ setTimeout(() => reject(new Error("no cascade close within 6s")), 6000)338+ ),339+ ]);340+ assertEquals(ev.code, CLOSE_TICKET_REVOKED, "sweep closes derived sockets too");341+ }),342+});343+244344 Deno.test({245345 name: "gate: store-status is metadata-only and any-scope",246346 sanitizeOps: false,