clone

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

feat/write-verdict-foundations
lofi-node / src / appticket.ts
appticket.ts 9.4K
1// App-connect tickets: the credential a lofi app uses to sync against this2// node. Distinct from node-pairing tickets (src/ticket.ts — iroh3// EndpointTicket strings): an app ticket names a LOCATION (gate URL with the4// secret embedded as a path prefix) plus the app id, and optionally the5// node's iroh EndpointTicket for forward compat.6//7// The node never stores secrets — only SHA-256 digests in8// <dataDir>/tickets.json. A ticket string is printable exactly once, at9// issuance. The lofi app stores it passkey-encrypted in localStorage (see10// docs/app-ticket.md for the contract the app side implements against).1112const TICKET_PREFIX = "lofisync1.";1314/** 32 random bytes, base64url without padding: 43 chars, alphabet15 * [A-Za-z0-9_-] — safe as a URL path segment with no encoding. */16export const SECRET_LENGTH = 43;1718export interface AppTicket {19 v: 1;20 appId: string;21 /** Gate base URL with the secret embedded: http(s)://host:port/t/<secret>.22 * Used VERBATIM as the lofi app's serverUrl — jazz clients preserve base23 * paths, so the secret rides every WS connect and catalogue fetch. */24 url: string;25 /** "sync" (default when absent — every pre-scope ticket keeps meaning26 * transport-only) or "provision": transport PLUS store administration (the27 * gate injects the node's admin secret for catalogue/admin routes).28 * Provision is a strict superset of sync. */29 scope?: "sync" | "provision";30 label?: string;31 /** The node's iroh EndpointTicket (forward compat; unused by browsers). */32 node?: string;33}3435/** Persisted record of an issued ticket — digest only, never the secret. */36export interface AppTicketRecord {37 id: string;38 scope?: "sync" | "provision";39 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;44 secretHash: string;45 createdAt: string;46 revokedAt?: string;47}4849function base64urlNoPad(bytes: Uint8Array): string {50 let bin = "";51 for (const b of bytes) bin += String.fromCharCode(b);52 return btoa(bin).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");53}5455/** Generate a fresh 256-bit ticket secret (43-char base64url). */56export function generateSecret(): string {57 return base64urlNoPad(crypto.getRandomValues(new Uint8Array(32)));58}5960/** SHA-256 hex digest of a ticket secret — what the node stores. */61export async function hashSecret(secret: string): Promise<string> {62 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));63 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");64}6566/** Serialize an app ticket to its `lofisync1.` string form. */67export function encodeAppTicket(ticket: AppTicket): string {68 return TICKET_PREFIX + base64urlNoPad(new TextEncoder().encode(JSON.stringify(ticket)));69}7071/** Parse a ticket string; null on any malformed input (paste path — never72 * throws). */73export function decodeAppTicket(text: string): AppTicket | null {74 const trimmed = text.trim();75 if (!trimmed.startsWith(TICKET_PREFIX)) return null;76 try {77 const b64 = trimmed.slice(TICKET_PREFIX.length).replaceAll("-", "+").replaceAll("_", "/");78 const json = atob(b64);79 const parsed = JSON.parse(json) as AppTicket;80 if (parsed.v !== 1 || typeof parsed.appId !== "string" || typeof parsed.url !== "string") {81 return null;82 }83 if (parsed.scope !== undefined && parsed.scope !== "sync" && parsed.scope !== "provision") {84 return null;85 }86 const url = new URL(parsed.url);87 if (!/^https?:$/.test(url.protocol) || !/^\/t\/[A-Za-z0-9_-]{43}$/.test(url.pathname)) {88 return null;89 }90 return parsed;91 } catch {92 return null;93 }94}9596/** Cheap prefix check — lets the CLI `pair` command reject app tickets with a97 * pointed message (an app ticket is not a node-pairing ticket). */98export function looksLikeAppTicket(text: string): boolean {99 return text.trim().startsWith(TICKET_PREFIX);100}101102interface TicketsFile {103 v: 1;104 tickets: AppTicketRecord[];105}106107function timingSafeEqualHex(a: string, b: string): boolean {108 // Constant-time over same-length hex digests; length mismatch exits early,109 // which leaks only that the input wasn't a SHA-256 hex string.110 if (a.length !== b.length) return false;111 let diff = 0;112 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);113 return diff === 0;114}115116/** 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). */119export 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}132133export type VerifyResult =134 | { status: "valid"; record: AppTicketRecord }135 | { status: "revoked"; record: AppTicketRecord }136 | { status: "unknown" };137138/** Issued-ticket persistence. File-backed when a dataDir is given (the CLI139 * 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). */142export class AppTicketStore {143 #path: string | null;144 #tickets: AppTicketRecord[] = [];145 #mtime = 0;146 #lastStat = 0;147148 private constructor(path: string | null) {149 this.#path = path;150 }151152 static async load(dataDir?: string): Promise<AppTicketStore> {153 const store = new AppTicketStore(dataDir ? `${dataDir}/tickets.json` : null);154 await store.#reload();155 return store;156 }157158 async #reload(): Promise<void> {159 if (!this.#path) return;160 try {161 const stat = await Deno.stat(this.#path);162 const mtime = stat.mtime?.getTime() ?? 0;163 if (mtime !== 0 && mtime === this.#mtime) return;164 const parsed = JSON.parse(await Deno.readTextFile(this.#path)) as TicketsFile;165 if (parsed.v !== 1) throw new Error(`unsupported tickets.json version ${parsed.v}`);166 this.#tickets = parsed.tickets;167 this.#mtime = mtime;168 } catch (e) {169 if (e instanceof Deno.errors.NotFound) {170 this.#tickets = [];171 this.#mtime = 0;172 return;173 }174 throw e;175 }176 }177178 /** Throttled (~1s) mtime check so per-request verify stays cheap. */179 async #maybeReload(): Promise<void> {180 const now = Date.now();181 if (now - this.#lastStat < 1000) return;182 this.#lastStat = now;183 await this.#reload();184 }185186 async #persist(): Promise<void> {187 if (!this.#path) return;188 const file: TicketsFile = { v: 1, tickets: this.#tickets };189 const tmp = `${this.#path}.${Deno.pid}.tmp`;190 await Deno.writeTextFile(tmp, JSON.stringify(file, null, 2) + "\n");191 await Deno.rename(tmp, this.#path);192 this.#mtime = (await Deno.stat(this.#path)).mtime?.getTime() ?? 0;193 }194195 /** 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. */199 async issue(200 label?: string,201 scope: "sync" | "provision" = "sync",202 parentId?: string,203 ): Promise<{ record: AppTicketRecord; secret: string }> {204 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 }211 const secret = generateSecret();212 const secretHash = await hashSecret(secret);213 const record: AppTicketRecord = {214 id: secretHash.slice(0, 12),215 scope,216 label,217 parentId,218 secretHash,219 createdAt: new Date().toISOString(),220 };221 this.#tickets.push(record);222 await this.#persist();223 return { record, secret };224 }225226 async revoke(id: string): Promise<AppTicketRecord | null> {227 await this.#reload();228 const record = this.#tickets.find((t) => t.id === id);229 if (!record) return null;230 if (!record.revokedAt) {231 record.revokedAt = new Date().toISOString();232 await this.#persist();233 }234 return record;235 }236237 async list(): Promise<AppTicketRecord[]> {238 await this.#reload();239 return [...this.#tickets];240 }241242 /** Timing-safe lookup by presented secret. Compares against EVERY record243 * (valid and revoked) so probers cannot distinguish unknown from revoked by244 * timing; the gate returns 401 for both. A derived ticket whose lineage is245 * dead reports "revoked" exactly like a directly revoked one. */246 async verify(secret: string): Promise<VerifyResult> {247 await this.#maybeReload();248 const presented = await hashSecret(secret);249 let match: AppTicketRecord | null = null;250 for (const record of this.#tickets) {251 if (timingSafeEqualHex(presented, record.secretHash)) match = record;252 }253 if (!match) return { status: "unknown" };254 return isRevokedByLineage(match, this.#tickets)255 ? { status: "revoked", record: match }256 : { status: "valid", record: match };257 }258}