1// The access gate: lofi-node's public front listener. JazzServer binds2// loopback-only, so the gate is BOTH the enforcement point for app tickets3// and what makes the node reachable beyond localhost at all.4//5// Ticket mode routes requests shaped /t/<secret>/… — the secret arrives as a6// serverUrl base-path prefix (jazz clients preserve base paths through every7// WS connect and catalogue fetch), gets verified against the ticket store8// (timing-safe, digest vs digest), and is stripped before proxying to the9// internal Jazz port. WS is terminated and re-originated (safe: Jazz auth is10// an in-band frame, proven by the iroh tunnel doing the same); HTTP streams.1112import {13 type AppTicketPop,14 type AppTicketStore,15 encodeAppTicket,16 hashSecret,17 importPopKey,18 isRevokedByLineage,19 popMessage,20 SECRET_LENGTH,21 verifyPopSignature,22} from "./appticket.ts";23import { forwardableHeaders, sanitizeCloseCode } from "./tunnel.ts";2425const DEBUG = Deno.env.get("LOFI_NODE_DEBUG") === "1";26function debug(...args: unknown[]): void {27 if (DEBUG) console.error("[gate]", ...args);28}2930/** WS close code sent when a ticket is revoked mid-session; the app should31 * surface re-enrollment (see docs/app-ticket.md). */32export const CLOSE_TICKET_REVOKED = 4001;3334const TICKET_PATH = new RegExp(`^/t/([A-Za-z0-9_-]{${SECRET_LENGTH}})(/.*)?$`);35const CONNECT_PATH = new RegExp(`^/c/([A-Za-z0-9_-]{${SECRET_LENGTH}})(/.*)?$`);3637/** How long an unanswered proof-of-possession challenge stays valid. */38const CHALLENGE_TTL_MS = 120_000;39/** Sliding lifetime of a connect token; refreshed on each authenticated use,40 * so a live sync session never expires mid-flight. Tokens are memory-only —41 * a node restart invalidates them and the client re-runs the exchange. */42const CONNECT_TOKEN_TTL_MS = 86_400_000;43/** Outstanding-challenge cap per ticket; the oldest is dropped past it. */44const MAX_CHALLENGES_PER_TICKET = 32;4546type PopChallenge = { ticketId: string; nonce: string; expiresAt: number };47type ConnectToken = { ticketId: string; expiresAt: number };4849export interface GateOptions {50 port: number;51 hostname?: string;52 /** Internal Jazz http URL, read per-request so Jazz restarts (pair()) never53 * invalidate the gate. */54 target: () => string;55 mode: "open" | "ticket";56 store: AppTicketStore;57 /** The node's Jazz app id + admin secret, for provision-scoped injection58 * and the store-status endpoint. The secret never transits the gate inbound59 * — client-supplied X-Jazz-Admin-Secret headers are stripped in ticket60 * mode; the gate injects config's own for provision-scoped requests. */61 appId: string;62 adminSecret: string;63 /** Base URL embedded into tickets minted by the derive endpoint (the64 * node's publicUrl); defaults to the gate's own loopback URL — same65 * election as CLI-issued tickets. */66 publicBase?: string;67 /** The node's iroh EndpointTicket, carried on derived tickets exactly like68 * node-issued ones. */69 nodeTicket?: string;70 /** Activity sink notified on every authenticated request and WS open —71 * feeds the daemon-owned last-seen sidecar. */72 activity?: { note(ticketId: string): void };73}7475export interface Gate {76 port: number;77 url: string;78 /** Live gated WS connections and last observed activity per ticket id. */79 stats(): { ticketId: string; connections: number; lastSeenAt?: string }[];80 close(): Promise<void>;81}8283function unauthorized(): Response {84 return new Response(JSON.stringify({ error: "invalid_ticket" }), {85 status: 401,86 headers: { "content-type": "application/json" },87 });88}8990/** Pump two OPEN (or opening) WebSockets both ways until either closes.91 * `backlog` holds client data that arrived before the upstream dial finished;92 * flushed before the listener attaches (same task — no interleave). */93export function pumpWebSockets(94 client: WebSocket,95 upstream: WebSocket,96 backlog: (string | ArrayBuffer)[] = [],97): void {98 client.binaryType = "arraybuffer";99 upstream.binaryType = "arraybuffer";100 let torndown = false;101 const teardown = (code: number, reason: string) => {102 if (torndown) return;103 torndown = true;104 for (const ws of [client, upstream]) {105 if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {106 try {107 ws.close(sanitizeCloseCode(code), reason.slice(0, 120));108 } catch {109 // already closing110 }111 }112 }113 };114115 const forward = (to: WebSocket) => (ev: MessageEvent) => {116 try {117 to.send(ev.data);118 } catch {119 teardown(1011, "gate forward failed");120 }121 };122 for (const data of backlog) upstream.send(data);123 backlog.length = 0;124 client.addEventListener("message", forward(upstream));125 upstream.addEventListener("message", forward(client));126 client.addEventListener("close", (ev) => teardown(ev.code || 1000, ev.reason ?? ""));127 upstream.addEventListener("close", (ev) => teardown(ev.code || 1000, ev.reason ?? ""));128 client.addEventListener("error", () => teardown(1011, "client ws error"));129 upstream.addEventListener("error", () => teardown(1011, "upstream ws error"));130}131132async function proxyHttp(133 req: Request,134 targetBase: string,135 path: string,136 headerOverrides?: Record<string, string | null>,137): Promise<Response> {138 try {139 const headers = forwardableHeaders(req.headers);140 for (const [name, value] of Object.entries(headerOverrides ?? {})) {141 if (value === null) delete headers[name];142 else headers[name] = value;143 }144 const res = await fetch(new URL(path, targetBase), {145 method: req.method,146 headers,147 body: req.method === "GET" || req.method === "HEAD" ? undefined : req.body,148 });149 return new Response(res.body, {150 status: res.status,151 headers: forwardableHeaders(res.headers),152 });153 } catch (e) {154 debug("http proxy error", (e as Error).message);155 return new Response("upstream unavailable", { status: 502 });156 }157}158159/** Node-served, metadata-only store preflight: lets a sync-scoped client160 * classify `no_schema` / hash-mismatch instead of hanging on writes (lofi#109161 * failure surface). Never returns schema contents, policies, or secrets. */162async function storeStatus(163 targetBase: string,164 appId: string,165 adminSecret: string,166): Promise<Response> {167 try {168 const admin = { "X-Jazz-Admin-Secret": adminSecret };169 const schemasRes = await fetch(new URL(`/apps/${appId}/schemas`, targetBase), {170 headers: admin,171 });172 if (!schemasRes.ok) {173 await schemasRes.body?.cancel();174 debug("store-status schemas fetch", schemasRes.status);175 return new Response(JSON.stringify({ error: "store_unavailable" }), {176 status: 502,177 headers: { "content-type": "application/json" },178 });179 }180 const body = await schemasRes.json() as {181 hashes?: string[];182 schemas?: { hash: string; publishedAt?: number | string }[];183 };184 // Head = newest published schema; the bare `hashes` array is NOT ordered.185 const entries = body.schemas ?? [];186 let headHash: string | undefined;187 let newest = -Infinity;188 for (const entry of entries) {189 const at = typeof entry.publishedAt === "string"190 ? Date.parse(entry.publishedAt)191 : entry.publishedAt ?? 0;192 if (at >= newest) {193 newest = at;194 headHash = entry.hash;195 }196 }197 const hashes = body.hashes ?? entries.map((e) => e.hash);198 headHash ??= hashes[hashes.length - 1];199 let permissionsHead: string | null = null;200 try {201 const headRes = await fetch(202 new URL(`/apps/${appId}/admin/permissions/head`, targetBase),203 { headers: admin },204 );205 if (headRes.ok) {206 permissionsHead = ((await headRes.json()) as { head?: string | null }).head ?? null;207 } else {208 await headRes.body?.cancel();209 }210 } catch {211 // permissions head stays null212 }213 const schema = hashes.length > 0214 ? { deployed: true, headHash, permissionsHead }215 : { deployed: false };216 return new Response(JSON.stringify({ v: 1, appId, schema }), {217 headers: { "content-type": "application/json" },218 });219 } catch (e) {220 debug("store-status error", (e as Error).message);221 return new Response(JSON.stringify({ error: "store_unavailable" }), {222 status: 502,223 headers: { "content-type": "application/json" },224 });225 }226}227228const ADMIN_PATH = /^\/apps\/[^/]+\/admin(\/|$)/;229230function proxyWebSocket(req: Request, targetBase: string, path: string): {231 response: Response;232 socket: WebSocket;233} {234 const requestedProtocols = req.headers.get("sec-websocket-protocol");235 const { socket, response } = Deno.upgradeWebSocket(236 req,237 requestedProtocols ? { protocol: requestedProtocols.split(",")[0].trim() } : {},238 );239 // Buffer client data while the upstream dial is in flight; pumpWebSockets240 // flushes before attaching its own listener (same task, no gap).241 const backlog: (string | ArrayBuffer)[] = [];242 const buffer = (ev: MessageEvent) => backlog.push(ev.data);243 socket.addEventListener("message", buffer);244 socket.addEventListener("open", () => {245 const wsUrl = new URL(path, targetBase);246 wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";247 const upstream = requestedProtocols248 ? new WebSocket(wsUrl, requestedProtocols.split(",").map((s) => s.trim()))249 : new WebSocket(wsUrl);250 upstream.addEventListener("open", () => {251 socket.removeEventListener("message", buffer);252 pumpWebSockets(socket, upstream, backlog);253 });254 upstream.addEventListener("error", () => {255 try {256 socket.close(1011, "upstream dial failed");257 } catch {258 // already closed259 }260 });261 });262 return { response, socket };263}264265export function startGate(options: GateOptions): Gate {266 const hostname = options.hostname ?? "0.0.0.0";267 const liveByTicket = new Map<string, Set<WebSocket>>();268269 // Proof-of-possession state, memory-only by design: challenges are270 // single-use with a short TTL, and connect tokens are stored hashed (the271 // node never stores secrets) with a sliding TTL.272 const challenges = new Map<string, PopChallenge>();273 const connectTokens = new Map<string, ConnectToken>();274275 const base64urlNoPad = (bytes: Uint8Array): string => {276 let bin = "";277 for (const b of bytes) bin += String.fromCharCode(b);278 return btoa(bin).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");279 };280281 const mintChallenge = (ticketId: string): { id: string; nonce: string } => {282 const perTicket = [...challenges.entries()].filter(([, c]) => c.ticketId === ticketId);283 if (perTicket.length >= MAX_CHALLENGES_PER_TICKET) {284 let oldestId = perTicket[0][0];285 let oldestAt = perTicket[0][1].expiresAt;286 for (const [id, challenge] of perTicket) {287 if (challenge.expiresAt < oldestAt) {288 oldestAt = challenge.expiresAt;289 oldestId = id;290 }291 }292 challenges.delete(oldestId);293 }294 const id = base64urlNoPad(crypto.getRandomValues(new Uint8Array(12)));295 const nonce = base64urlNoPad(crypto.getRandomValues(new Uint8Array(32)));296 challenges.set(id, { ticketId, nonce, expiresAt: Date.now() + CHALLENGE_TTL_MS });297 return { id, nonce };298 };299300 const answerChallenge = async (301 ticketId: string,302 pop: AppTicketPop,303 body: { id?: unknown; sig?: unknown },304 ): Promise<string | null> => {305 if (typeof body.id !== "string" || typeof body.sig !== "string") return null;306 const challenge = challenges.get(body.id);307 // Single-use: the challenge dies on its first answer attempt, valid or not.308 challenges.delete(body.id);309 if (!challenge || challenge.ticketId !== ticketId || challenge.expiresAt < Date.now()) {310 return null;311 }312 const verified = await verifyPopSignature(313 pop.spki,314 popMessage(options.appId, ticketId, challenge.nonce),315 body.sig,316 );317 if (!verified) return null;318 const token = base64urlNoPad(crypto.getRandomValues(new Uint8Array(32)));319 connectTokens.set(await hashSecret(token), {320 ticketId,321 expiresAt: Date.now() + CONNECT_TOKEN_TTL_MS,322 });323 return token;324 };325326 const lastSeenAt = new Map<string, string>();327 const seen = (ticketId: string) => {328 lastSeenAt.set(ticketId, new Date().toISOString());329 options.activity?.note(ticketId);330 };331332 const track = (ticketId: string, socket: WebSocket) => {333 let set = liveByTicket.get(ticketId);334 if (!set) liveByTicket.set(ticketId, set = new Set());335 set.add(socket);336 socket.addEventListener("close", () => {337 seen(ticketId);338 set.delete(socket);339 if (set.size === 0) liveByTicket.delete(ticketId);340 });341 };342343 // Revocation sweep: piggyback on the store's hot-reload — poll live tickets344 // and close sockets whose ticket flipped to revoked. Cheap (only runs while345 // gated sockets exist).346 const sweep = setInterval(async () => {347 // Expired proof-of-possession state ages out regardless of connections.348 const now = Date.now();349 for (const [id, challenge] of challenges) {350 if (challenge.expiresAt < now) challenges.delete(id);351 }352 for (const [digest, token] of connectTokens) {353 if (token.expiresAt < now) connectTokens.delete(digest);354 }355 if (liveByTicket.size === 0 && connectTokens.size === 0 && challenges.size === 0) return;356 const records = await options.store.list();357 const dead = (ticketId: string): boolean => {358 const record = records.find((r) => r.id === ticketId);359 return !record || isRevokedByLineage(record, records);360 };361 for (const [id, challenge] of challenges) {362 if (dead(challenge.ticketId)) challenges.delete(id);363 }364 for (const [digest, token] of connectTokens) {365 if (dead(token.ticketId)) connectTokens.delete(digest);366 }367 for (const [ticketId, sockets] of liveByTicket) {368 if (!dead(ticketId)) continue;369 for (const socket of [...sockets]) {370 try {371 socket.close(CLOSE_TICKET_REVOKED, "ticket revoked");372 } catch {373 // already closing374 }375 }376 liveByTicket.delete(ticketId);377 }378 }, 2000);379380 const server = Deno.serve(381 { hostname, port: options.port, onListen: () => {} },382 async (req) => {383 const url = new URL(req.url);384 const isUpgrade = req.headers.get("upgrade")?.toLowerCase() === "websocket";385 const targetBase = options.target();386387 if (options.mode === "open") {388 // Open nodes still serve the preflight so lofi's store classifier389 // works against dev setups.390 if (url.pathname === "/store-status" && !isUpgrade) {391 return storeStatus(targetBase, options.appId, options.adminSecret);392 }393 const path = url.pathname + url.search;394 return isUpgrade395 ? proxyWebSocket(req, targetBase, path).response396 : proxyHttp(req, targetBase, path);397 }398399 // Ticket mode.400 if (url.pathname === "/health" && !isUpgrade) {401 return proxyHttp(req, targetBase, "/health");402 }403 const match = url.pathname.match(TICKET_PATH);404 if (!match) return new Response("not found", { status: 404 });405 const [, secret, rest] = match;406 const verdict = await options.store.verify(secret);407 if (verdict.status !== "valid") {408 debug("rejected", verdict.status, url.pathname.slice(0, 24));409 return unauthorized();410 }411 const scope = verdict.record.scope ?? "sync";412 seen(verdict.record.id);413 let strippedPath = rest || "/";414 // Possession-bound tickets: the bare secret opens only the415 // proof-of-possession exchange; everything else must ride a connect416 // token minted by a fresh signature from the bound device key. A stolen417 // ticket string alone therefore no longer connects.418 if (verdict.record.pop) {419 // Liveness stays reachable with the bare secret — it reveals nothing420 // a valid ticket holder could not learn from the open /health route.421 if (strippedPath === "/health" && !isUpgrade) {422 return proxyHttp(req, targetBase, "/health");423 }424 if (strippedPath === "/pop/challenge" && !isUpgrade) {425 if (req.method !== "POST") {426 return new Response("method not allowed", { status: 405 });427 }428 const challenge = mintChallenge(verdict.record.id);429 return new Response(430 JSON.stringify({431 v: 1,432 id: challenge.id,433 nonce: challenge.nonce,434 expiresIn: CHALLENGE_TTL_MS / 1000,435 }),436 { headers: { "content-type": "application/json" } },437 );438 }439 if (strippedPath === "/pop/answer" && !isUpgrade) {440 if (req.method !== "POST") {441 return new Response("method not allowed", { status: 405 });442 }443 let body: { id?: unknown; sig?: unknown } = {};444 try {445 body = await req.json() as { id?: unknown; sig?: unknown };446 } catch {447 // malformed body falls through to the single rejection path448 }449 const token = await answerChallenge(verdict.record.id, verdict.record.pop, body);450 if (token === null) {451 debug("rejected pop answer", verdict.record.id);452 return unauthorized();453 }454 debug("pop verified", verdict.record.id);455 return new Response(456 JSON.stringify({ v: 1, connect: token, expiresIn: CONNECT_TOKEN_TTL_MS / 1000 }),457 { headers: { "content-type": "application/json" } },458 );459 }460 const connect = strippedPath.match(CONNECT_PATH);461 if (!connect) {462 debug("rejected pop-bound bare path", verdict.record.id);463 return unauthorized();464 }465 const [, connectSecret, connectRest] = connect;466 const tokenDigest = await hashSecret(connectSecret);467 const token = connectTokens.get(tokenDigest);468 if (!token || token.ticketId !== verdict.record.id || token.expiresAt < Date.now()) {469 debug("rejected connect token", verdict.record.id);470 return unauthorized();471 }472 // Sliding lifetime: each authenticated use extends the token.473 token.expiresAt = Date.now() + CONNECT_TOKEN_TTL_MS;474 strippedPath = connectRest || "/";475 } else if (strippedPath.startsWith("/pop/")) {476 // The exchange only exists for bound tickets; a bearer ticket probing477 // it is a client-configuration error, and the path must never leak478 // upstream.479 return new Response(JSON.stringify({ error: "pop_not_bound" }), {480 status: 400,481 headers: { "content-type": "application/json" },482 });483 }484 if (strippedPath === "/store-status" && !isUpgrade) {485 return storeStatus(targetBase, options.appId, options.adminSecret);486 }487 // Scope-down exchange: a provision ticket mints a derived sync ticket488 // (the lofi app persists that one at rest, keeping the provision ticket489 // sealed or memory-only). The derived record carries parentId, so490 // revoking the provision ticket kills its derived tickets too. A sync491 // ticket gets the SAME 401 as an invalid one (nothing to enumerate).492 if (strippedPath === "/derive-sync-ticket" && !isUpgrade) {493 if (scope !== "provision") {494 debug("rejected sync-scope on derive", verdict.record.id);495 return unauthorized();496 }497 if (req.method !== "POST") {498 return new Response("method not allowed", { status: 405 });499 }500 let requested: string | undefined;501 let deviceKey: { alg?: unknown; spki?: unknown } | undefined;502 try {503 const body = await req.json() as { label?: unknown; devicePublicKey?: unknown };504 if (typeof body.label === "string") requested = body.label;505 if (typeof body.devicePublicKey === "object" && body.devicePublicKey !== null) {506 deviceKey = body.devicePublicKey as { alg?: unknown; spki?: unknown };507 }508 } catch {509 // empty or non-JSON body: fall through to the default label510 }511 // An offered device key binds the derived ticket to possession. A512 // malformed key is a client bug, not an auth probe — 400, not 401.513 let pop: { alg: "ES256"; spki: string } | undefined;514 if (deviceKey !== undefined) {515 if (deviceKey.alg !== "ES256" || typeof deviceKey.spki !== "string") {516 return new Response(JSON.stringify({ error: "invalid_device_key" }), {517 status: 400,518 headers: { "content-type": "application/json" },519 });520 }521 try {522 await importPopKey(deviceKey.spki);523 } catch {524 return new Response(JSON.stringify({ error: "invalid_device_key" }), {525 status: 400,526 headers: { "content-type": "application/json" },527 });528 }529 pop = { alg: "ES256", spki: deviceKey.spki };530 }531 const parent = verdict.record;532 const label = requested ?? `${parent.label ?? parent.id} (sync)`;533 const derived = await options.store.issue(label, "sync", parent.id, pop);534 const base = (options.publicBase ??535 `http://127.0.0.1:${(server.addr as Deno.NetAddr).port}`).replace(/\/+$/, "");536 const ticket = encodeAppTicket({537 v: 1,538 appId: options.appId,539 url: `${base}/t/${derived.secret}`,540 label,541 node: options.nodeTicket,542 });543 debug("derived sync ticket", derived.record.id, "from", parent.id, pop ? "pop" : "bearer");544 return new Response(545 JSON.stringify({546 v: 1,547 id: derived.record.id,548 ticket,549 ...(pop ? { pop: true } : {}),550 }),551 { headers: { "content-type": "application/json" } },552 );553 }554 // Admin/catalogue-mutating routes need provision scope; a sync ticket555 // gets the SAME 401 shape as an invalid ticket (nothing to enumerate).556 if (ADMIN_PATH.test(strippedPath) && scope !== "provision") {557 debug("rejected sync-scope on admin path", verdict.record.id);558 return unauthorized();559 }560 const path = strippedPath + url.search;561 debug(isUpgrade ? "ws" : req.method, path, "ticket", verdict.record.id, scope);562 if (isUpgrade) {563 const { response, socket } = proxyWebSocket(req, targetBase, path);564 track(verdict.record.id, socket);565 return response;566 }567 // The admin secret only ever originates from the node: inbound headers568 // are stripped; provision-scoped requests get config's secret injected569 // (on catalogue reads too — the merge flow fetches the head schema570 // verbatim without ever holding the secret client-side).571 return proxyHttp(req, targetBase, path, {572 "x-jazz-admin-secret": scope === "provision" ? options.adminSecret : null,573 });574 },575 );576577 const port = (server.addr as Deno.NetAddr).port;578 return {579 port,580 url: `http://127.0.0.1:${port}`,581 stats: () => {582 const ids = new Set([...liveByTicket.keys(), ...lastSeenAt.keys()]);583 return [...ids].map((ticketId) => ({584 ticketId,585 connections: liveByTicket.get(ticketId)?.size ?? 0,586 ...(lastSeenAt.has(ticketId) ? { lastSeenAt: lastSeenAt.get(ticketId) } : {}),587 }));588 },589 close: async () => {590 clearInterval(sweep);591 await server.shutdown();592 },593 };594}