clone

a self-hosted sync node for lofi apps: jazz sync over iroh, ticket-gated, with no static ip or cloud needed

feat/pop-tickets
lofi-node / src / gate.ts
gate.ts 22.4K
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}7172export interface Gate {73 port: number;74 url: string;75 /** Live gated WS connections per ticket id. */76 stats(): { ticketId: string; connections: number }[];77 close(): Promise<void>;78}7980function unauthorized(): Response {81 return new Response(JSON.stringify({ error: "invalid_ticket" }), {82 status: 401,83 headers: { "content-type": "application/json" },84 });85}8687/** Pump two OPEN (or opening) WebSockets both ways until either closes.88 * `backlog` holds client data that arrived before the upstream dial finished;89 * flushed before the listener attaches (same task — no interleave). */90export function pumpWebSockets(91 client: WebSocket,92 upstream: WebSocket,93 backlog: (string | ArrayBuffer)[] = [],94): void {95 client.binaryType = "arraybuffer";96 upstream.binaryType = "arraybuffer";97 let torndown = false;98 const teardown = (code: number, reason: string) => {99 if (torndown) return;100 torndown = true;101 for (const ws of [client, upstream]) {102 if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {103 try {104 ws.close(sanitizeCloseCode(code), reason.slice(0, 120));105 } catch {106 // already closing107 }108 }109 }110 };111112 const forward = (to: WebSocket) => (ev: MessageEvent) => {113 try {114 to.send(ev.data);115 } catch {116 teardown(1011, "gate forward failed");117 }118 };119 for (const data of backlog) upstream.send(data);120 backlog.length = 0;121 client.addEventListener("message", forward(upstream));122 upstream.addEventListener("message", forward(client));123 client.addEventListener("close", (ev) => teardown(ev.code || 1000, ev.reason ?? ""));124 upstream.addEventListener("close", (ev) => teardown(ev.code || 1000, ev.reason ?? ""));125 client.addEventListener("error", () => teardown(1011, "client ws error"));126 upstream.addEventListener("error", () => teardown(1011, "upstream ws error"));127}128129async function proxyHttp(130 req: Request,131 targetBase: string,132 path: string,133 headerOverrides?: Record<string, string | null>,134): Promise<Response> {135 try {136 const headers = forwardableHeaders(req.headers);137 for (const [name, value] of Object.entries(headerOverrides ?? {})) {138 if (value === null) delete headers[name];139 else headers[name] = value;140 }141 const res = await fetch(new URL(path, targetBase), {142 method: req.method,143 headers,144 body: req.method === "GET" || req.method === "HEAD" ? undefined : req.body,145 });146 return new Response(res.body, {147 status: res.status,148 headers: forwardableHeaders(res.headers),149 });150 } catch (e) {151 debug("http proxy error", (e as Error).message);152 return new Response("upstream unavailable", { status: 502 });153 }154}155156/** Node-served, metadata-only store preflight: lets a sync-scoped client157 * classify `no_schema` / hash-mismatch instead of hanging on writes (lofi#109158 * failure surface). Never returns schema contents, policies, or secrets. */159async function storeStatus(160 targetBase: string,161 appId: string,162 adminSecret: string,163): Promise<Response> {164 try {165 const admin = { "X-Jazz-Admin-Secret": adminSecret };166 const schemasRes = await fetch(new URL(`/apps/${appId}/schemas`, targetBase), {167 headers: admin,168 });169 if (!schemasRes.ok) {170 await schemasRes.body?.cancel();171 debug("store-status schemas fetch", schemasRes.status);172 return new Response(JSON.stringify({ error: "store_unavailable" }), {173 status: 502,174 headers: { "content-type": "application/json" },175 });176 }177 const body = await schemasRes.json() as {178 hashes?: string[];179 schemas?: { hash: string; publishedAt?: number | string }[];180 };181 // Head = newest published schema; the bare `hashes` array is NOT ordered.182 const entries = body.schemas ?? [];183 let headHash: string | undefined;184 let newest = -Infinity;185 for (const entry of entries) {186 const at = typeof entry.publishedAt === "string"187 ? Date.parse(entry.publishedAt)188 : entry.publishedAt ?? 0;189 if (at >= newest) {190 newest = at;191 headHash = entry.hash;192 }193 }194 const hashes = body.hashes ?? entries.map((e) => e.hash);195 headHash ??= hashes[hashes.length - 1];196 let permissionsHead: string | null = null;197 try {198 const headRes = await fetch(199 new URL(`/apps/${appId}/admin/permissions/head`, targetBase),200 { headers: admin },201 );202 if (headRes.ok) {203 permissionsHead = ((await headRes.json()) as { head?: string | null }).head ?? null;204 } else {205 await headRes.body?.cancel();206 }207 } catch {208 // permissions head stays null209 }210 const schema = hashes.length > 0211 ? { deployed: true, headHash, permissionsHead }212 : { deployed: false };213 return new Response(JSON.stringify({ v: 1, appId, schema }), {214 headers: { "content-type": "application/json" },215 });216 } catch (e) {217 debug("store-status error", (e as Error).message);218 return new Response(JSON.stringify({ error: "store_unavailable" }), {219 status: 502,220 headers: { "content-type": "application/json" },221 });222 }223}224225const ADMIN_PATH = /^\/apps\/[^/]+\/admin(\/|$)/;226227function proxyWebSocket(req: Request, targetBase: string, path: string): {228 response: Response;229 socket: WebSocket;230} {231 const requestedProtocols = req.headers.get("sec-websocket-protocol");232 const { socket, response } = Deno.upgradeWebSocket(233 req,234 requestedProtocols ? { protocol: requestedProtocols.split(",")[0].trim() } : {},235 );236 // Buffer client data while the upstream dial is in flight; pumpWebSockets237 // flushes before attaching its own listener (same task, no gap).238 const backlog: (string | ArrayBuffer)[] = [];239 const buffer = (ev: MessageEvent) => backlog.push(ev.data);240 socket.addEventListener("message", buffer);241 socket.addEventListener("open", () => {242 const wsUrl = new URL(path, targetBase);243 wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";244 const upstream = requestedProtocols245 ? new WebSocket(wsUrl, requestedProtocols.split(",").map((s) => s.trim()))246 : new WebSocket(wsUrl);247 upstream.addEventListener("open", () => {248 socket.removeEventListener("message", buffer);249 pumpWebSockets(socket, upstream, backlog);250 });251 upstream.addEventListener("error", () => {252 try {253 socket.close(1011, "upstream dial failed");254 } catch {255 // already closed256 }257 });258 });259 return { response, socket };260}261262export function startGate(options: GateOptions): Gate {263 const hostname = options.hostname ?? "0.0.0.0";264 const liveByTicket = new Map<string, Set<WebSocket>>();265266 // Proof-of-possession state, memory-only by design: challenges are267 // single-use with a short TTL, and connect tokens are stored hashed (the268 // node never stores secrets) with a sliding TTL.269 const challenges = new Map<string, PopChallenge>();270 const connectTokens = new Map<string, ConnectToken>();271272 const base64urlNoPad = (bytes: Uint8Array): string => {273 let bin = "";274 for (const b of bytes) bin += String.fromCharCode(b);275 return btoa(bin).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");276 };277278 const mintChallenge = (ticketId: string): { id: string; nonce: string } => {279 const perTicket = [...challenges.entries()].filter(([, c]) => c.ticketId === ticketId);280 if (perTicket.length >= MAX_CHALLENGES_PER_TICKET) {281 let oldestId = perTicket[0][0];282 let oldestAt = perTicket[0][1].expiresAt;283 for (const [id, challenge] of perTicket) {284 if (challenge.expiresAt < oldestAt) {285 oldestAt = challenge.expiresAt;286 oldestId = id;287 }288 }289 challenges.delete(oldestId);290 }291 const id = base64urlNoPad(crypto.getRandomValues(new Uint8Array(12)));292 const nonce = base64urlNoPad(crypto.getRandomValues(new Uint8Array(32)));293 challenges.set(id, { ticketId, nonce, expiresAt: Date.now() + CHALLENGE_TTL_MS });294 return { id, nonce };295 };296297 const answerChallenge = async (298 ticketId: string,299 pop: AppTicketPop,300 body: { id?: unknown; sig?: unknown },301 ): Promise<string | null> => {302 if (typeof body.id !== "string" || typeof body.sig !== "string") return null;303 const challenge = challenges.get(body.id);304 // Single-use: the challenge dies on its first answer attempt, valid or not.305 challenges.delete(body.id);306 if (!challenge || challenge.ticketId !== ticketId || challenge.expiresAt < Date.now()) {307 return null;308 }309 const verified = await verifyPopSignature(310 pop.spki,311 popMessage(options.appId, ticketId, challenge.nonce),312 body.sig,313 );314 if (!verified) return null;315 const token = base64urlNoPad(crypto.getRandomValues(new Uint8Array(32)));316 connectTokens.set(await hashSecret(token), {317 ticketId,318 expiresAt: Date.now() + CONNECT_TOKEN_TTL_MS,319 });320 return token;321 };322323 const track = (ticketId: string, socket: WebSocket) => {324 let set = liveByTicket.get(ticketId);325 if (!set) liveByTicket.set(ticketId, set = new Set());326 set.add(socket);327 socket.addEventListener("close", () => {328 set.delete(socket);329 if (set.size === 0) liveByTicket.delete(ticketId);330 });331 };332333 // Revocation sweep: piggyback on the store's hot-reload — poll live tickets334 // and close sockets whose ticket flipped to revoked. Cheap (only runs while335 // gated sockets exist).336 const sweep = setInterval(async () => {337 // Expired proof-of-possession state ages out regardless of connections.338 const now = Date.now();339 for (const [id, challenge] of challenges) {340 if (challenge.expiresAt < now) challenges.delete(id);341 }342 for (const [digest, token] of connectTokens) {343 if (token.expiresAt < now) connectTokens.delete(digest);344 }345 if (liveByTicket.size === 0 && connectTokens.size === 0 && challenges.size === 0) return;346 const records = await options.store.list();347 const dead = (ticketId: string): boolean => {348 const record = records.find((r) => r.id === ticketId);349 return !record || isRevokedByLineage(record, records);350 };351 for (const [id, challenge] of challenges) {352 if (dead(challenge.ticketId)) challenges.delete(id);353 }354 for (const [digest, token] of connectTokens) {355 if (dead(token.ticketId)) connectTokens.delete(digest);356 }357 for (const [ticketId, sockets] of liveByTicket) {358 if (!dead(ticketId)) continue;359 for (const socket of [...sockets]) {360 try {361 socket.close(CLOSE_TICKET_REVOKED, "ticket revoked");362 } catch {363 // already closing364 }365 }366 liveByTicket.delete(ticketId);367 }368 }, 2000);369370 const server = Deno.serve(371 { hostname, port: options.port, onListen: () => {} },372 async (req) => {373 const url = new URL(req.url);374 const isUpgrade = req.headers.get("upgrade")?.toLowerCase() === "websocket";375 const targetBase = options.target();376377 if (options.mode === "open") {378 // Open nodes still serve the preflight so lofi's store classifier379 // works against dev setups.380 if (url.pathname === "/store-status" && !isUpgrade) {381 return storeStatus(targetBase, options.appId, options.adminSecret);382 }383 const path = url.pathname + url.search;384 return isUpgrade385 ? proxyWebSocket(req, targetBase, path).response386 : proxyHttp(req, targetBase, path);387 }388389 // Ticket mode.390 if (url.pathname === "/health" && !isUpgrade) {391 return proxyHttp(req, targetBase, "/health");392 }393 const match = url.pathname.match(TICKET_PATH);394 if (!match) return new Response("not found", { status: 404 });395 const [, secret, rest] = match;396 const verdict = await options.store.verify(secret);397 if (verdict.status !== "valid") {398 debug("rejected", verdict.status, url.pathname.slice(0, 24));399 return unauthorized();400 }401 const scope = verdict.record.scope ?? "sync";402 let strippedPath = rest || "/";403 // Possession-bound tickets: the bare secret opens only the404 // proof-of-possession exchange; everything else must ride a connect405 // token minted by a fresh signature from the bound device key. A stolen406 // ticket string alone therefore no longer connects.407 if (verdict.record.pop) {408 // Liveness stays reachable with the bare secret — it reveals nothing409 // a valid ticket holder could not learn from the open /health route.410 if (strippedPath === "/health" && !isUpgrade) {411 return proxyHttp(req, targetBase, "/health");412 }413 if (strippedPath === "/pop/challenge" && !isUpgrade) {414 if (req.method !== "POST") {415 return new Response("method not allowed", { status: 405 });416 }417 const challenge = mintChallenge(verdict.record.id);418 return new Response(419 JSON.stringify({420 v: 1,421 id: challenge.id,422 nonce: challenge.nonce,423 expiresIn: CHALLENGE_TTL_MS / 1000,424 }),425 { headers: { "content-type": "application/json" } },426 );427 }428 if (strippedPath === "/pop/answer" && !isUpgrade) {429 if (req.method !== "POST") {430 return new Response("method not allowed", { status: 405 });431 }432 let body: { id?: unknown; sig?: unknown } = {};433 try {434 body = await req.json() as { id?: unknown; sig?: unknown };435 } catch {436 // malformed body falls through to the single rejection path437 }438 const token = await answerChallenge(verdict.record.id, verdict.record.pop, body);439 if (token === null) {440 debug("rejected pop answer", verdict.record.id);441 return unauthorized();442 }443 debug("pop verified", verdict.record.id);444 return new Response(445 JSON.stringify({ v: 1, connect: token, expiresIn: CONNECT_TOKEN_TTL_MS / 1000 }),446 { headers: { "content-type": "application/json" } },447 );448 }449 const connect = strippedPath.match(CONNECT_PATH);450 if (!connect) {451 debug("rejected pop-bound bare path", verdict.record.id);452 return unauthorized();453 }454 const [, connectSecret, connectRest] = connect;455 const tokenDigest = await hashSecret(connectSecret);456 const token = connectTokens.get(tokenDigest);457 if (!token || token.ticketId !== verdict.record.id || token.expiresAt < Date.now()) {458 debug("rejected connect token", verdict.record.id);459 return unauthorized();460 }461 // Sliding lifetime: each authenticated use extends the token.462 token.expiresAt = Date.now() + CONNECT_TOKEN_TTL_MS;463 strippedPath = connectRest || "/";464 } else if (strippedPath.startsWith("/pop/")) {465 // The exchange only exists for bound tickets; a bearer ticket probing466 // it is a client-configuration error, and the path must never leak467 // upstream.468 return new Response(JSON.stringify({ error: "pop_not_bound" }), {469 status: 400,470 headers: { "content-type": "application/json" },471 });472 }473 if (strippedPath === "/store-status" && !isUpgrade) {474 return storeStatus(targetBase, options.appId, options.adminSecret);475 }476 // Scope-down exchange: a provision ticket mints a derived sync ticket477 // (the lofi app persists that one at rest, keeping the provision ticket478 // sealed or memory-only). The derived record carries parentId, so479 // revoking the provision ticket kills its derived tickets too. A sync480 // ticket gets the SAME 401 as an invalid one (nothing to enumerate).481 if (strippedPath === "/derive-sync-ticket" && !isUpgrade) {482 if (scope !== "provision") {483 debug("rejected sync-scope on derive", verdict.record.id);484 return unauthorized();485 }486 if (req.method !== "POST") {487 return new Response("method not allowed", { status: 405 });488 }489 let requested: string | undefined;490 let deviceKey: { alg?: unknown; spki?: unknown } | undefined;491 try {492 const body = await req.json() as { label?: unknown; devicePublicKey?: unknown };493 if (typeof body.label === "string") requested = body.label;494 if (typeof body.devicePublicKey === "object" && body.devicePublicKey !== null) {495 deviceKey = body.devicePublicKey as { alg?: unknown; spki?: unknown };496 }497 } catch {498 // empty or non-JSON body: fall through to the default label499 }500 // An offered device key binds the derived ticket to possession. A501 // malformed key is a client bug, not an auth probe — 400, not 401.502 let pop: { alg: "ES256"; spki: string } | undefined;503 if (deviceKey !== undefined) {504 if (deviceKey.alg !== "ES256" || typeof deviceKey.spki !== "string") {505 return new Response(JSON.stringify({ error: "invalid_device_key" }), {506 status: 400,507 headers: { "content-type": "application/json" },508 });509 }510 try {511 await importPopKey(deviceKey.spki);512 } catch {513 return new Response(JSON.stringify({ error: "invalid_device_key" }), {514 status: 400,515 headers: { "content-type": "application/json" },516 });517 }518 pop = { alg: "ES256", spki: deviceKey.spki };519 }520 const parent = verdict.record;521 const label = requested ?? `${parent.label ?? parent.id} (sync)`;522 const derived = await options.store.issue(label, "sync", parent.id, pop);523 const base = (options.publicBase ??524 `http://127.0.0.1:${(server.addr as Deno.NetAddr).port}`).replace(/\/+$/, "");525 const ticket = encodeAppTicket({526 v: 1,527 appId: options.appId,528 url: `${base}/t/${derived.secret}`,529 label,530 node: options.nodeTicket,531 });532 debug("derived sync ticket", derived.record.id, "from", parent.id, pop ? "pop" : "bearer");533 return new Response(534 JSON.stringify({535 v: 1,536 id: derived.record.id,537 ticket,538 ...(pop ? { pop: true } : {}),539 }),540 { headers: { "content-type": "application/json" } },541 );542 }543 // Admin/catalogue-mutating routes need provision scope; a sync ticket544 // gets the SAME 401 shape as an invalid ticket (nothing to enumerate).545 if (ADMIN_PATH.test(strippedPath) && scope !== "provision") {546 debug("rejected sync-scope on admin path", verdict.record.id);547 return unauthorized();548 }549 const path = strippedPath + url.search;550 debug(isUpgrade ? "ws" : req.method, path, "ticket", verdict.record.id, scope);551 if (isUpgrade) {552 const { response, socket } = proxyWebSocket(req, targetBase, path);553 track(verdict.record.id, socket);554 return response;555 }556 // The admin secret only ever originates from the node: inbound headers557 // are stripped; provision-scoped requests get config's secret injected558 // (on catalogue reads too — the merge flow fetches the head schema559 // verbatim without ever holding the secret client-side).560 return proxyHttp(req, targetBase, path, {561 "x-jazz-admin-secret": scope === "provision" ? options.adminSecret : null,562 });563 },564 );565566 const port = (server.addr as Deno.NetAddr).port;567 return {568 port,569 url: `http://127.0.0.1:${port}`,570 stats: () =>571 [...liveByTicket.entries()].map(([ticketId, sockets]) => ({572 ticketId,573 connections: sockets.size,574 })),575 close: async () => {576 clearInterval(sweep);577 await server.shutdown();578 },579 };580}