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 secretHash: string;41 createdAt: string;42 revokedAt?: string;43}4445function base64urlNoPad(bytes: Uint8Array): string {46 let bin = "";47 for (const b of bytes) bin += String.fromCharCode(b);48 return btoa(bin).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");49}5051/** Generate a fresh 256-bit ticket secret (43-char base64url). */52export function generateSecret(): string {53 return base64urlNoPad(crypto.getRandomValues(new Uint8Array(32)));54}5556/** SHA-256 hex digest of a ticket secret — what the node stores. */57export async function hashSecret(secret: string): Promise<string> {58 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));59 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");60}6162/** Serialize an app ticket to its `lofisync1.` string form. */63export function encodeAppTicket(ticket: AppTicket): string {64 return TICKET_PREFIX + base64urlNoPad(new TextEncoder().encode(JSON.stringify(ticket)));65}6667/** Parse a ticket string; null on any malformed input (paste path — never68 * throws). */69export function decodeAppTicket(text: string): AppTicket | null {70 const trimmed = text.trim();71 if (!trimmed.startsWith(TICKET_PREFIX)) return null;72 try {73 const b64 = trimmed.slice(TICKET_PREFIX.length).replaceAll("-", "+").replaceAll("_", "/");74 const json = atob(b64);75 const parsed = JSON.parse(json) as AppTicket;76 if (parsed.v !== 1 || typeof parsed.appId !== "string" || typeof parsed.url !== "string") {77 return null;78 }79 if (parsed.scope !== undefined && parsed.scope !== "sync" && parsed.scope !== "provision") {80 return null;81 }82 const url = new URL(parsed.url);83 if (!/^https?:$/.test(url.protocol) || !/^\/t\/[A-Za-z0-9_-]{43}$/.test(url.pathname)) {84 return null;85 }86 return parsed;87 } catch {88 return null;89 }90}9192/** Cheap prefix check — lets the CLI `pair` command reject app tickets with a93 * pointed message (an app ticket is not a node-pairing ticket). */94export function looksLikeAppTicket(text: string): boolean {95 return text.trim().startsWith(TICKET_PREFIX);96}9798interface TicketsFile {99 v: 1;100 tickets: AppTicketRecord[];101}102103function timingSafeEqualHex(a: string, b: string): boolean {104 // Constant-time over same-length hex digests; length mismatch exits early,105 // which leaks only that the input wasn't a SHA-256 hex string.106 if (a.length !== b.length) return false;107 let diff = 0;108 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);109 return diff === 0;110}111112export type VerifyResult =113 | { status: "valid"; record: AppTicketRecord }114 | { status: "revoked"; record: AppTicketRecord }115 | { status: "unknown" };116117/** 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). */121export class AppTicketStore {122 #path: string | null;123 #tickets: AppTicketRecord[] = [];124 #mtime = 0;125 #lastStat = 0;126127 private constructor(path: string | null) {128 this.#path = path;129 }130131 static async load(dataDir?: string): Promise<AppTicketStore> {132 const store = new AppTicketStore(dataDir ? `${dataDir}/tickets.json` : null);133 await store.#reload();134 return store;135 }136137 async #reload(): Promise<void> {138 if (!this.#path) return;139 try {140 const stat = await Deno.stat(this.#path);141 const mtime = stat.mtime?.getTime() ?? 0;142 if (mtime !== 0 && mtime === this.#mtime) return;143 const parsed = JSON.parse(await Deno.readTextFile(this.#path)) as TicketsFile;144 if (parsed.v !== 1) throw new Error(`unsupported tickets.json version ${parsed.v}`);145 this.#tickets = parsed.tickets;146 this.#mtime = mtime;147 } catch (e) {148 if (e instanceof Deno.errors.NotFound) {149 this.#tickets = [];150 this.#mtime = 0;151 return;152 }153 throw e;154 }155 }156157 /** Throttled (~1s) mtime check so per-request verify stays cheap. */158 async #maybeReload(): Promise<void> {159 const now = Date.now();160 if (now - this.#lastStat < 1000) return;161 this.#lastStat = now;162 await this.#reload();163 }164165 async #persist(): Promise<void> {166 if (!this.#path) return;167 const file: TicketsFile = { v: 1, tickets: this.#tickets };168 const tmp = `${this.#path}.${Deno.pid}.tmp`;169 await Deno.writeTextFile(tmp, JSON.stringify(file, null, 2) + "\n");170 await Deno.rename(tmp, this.#path);171 this.#mtime = (await Deno.stat(this.#path)).mtime?.getTime() ?? 0;172 }173174 async issue(175 label?: string,176 scope: "sync" | "provision" = "sync",177 ): Promise<{ record: AppTicketRecord; secret: string }> {178 await this.#reload();179 const secret = generateSecret();180 const secretHash = await hashSecret(secret);181 const record: AppTicketRecord = {182 id: secretHash.slice(0, 12),183 scope,184 label,185 secretHash,186 createdAt: new Date().toISOString(),187 };188 this.#tickets.push(record);189 await this.#persist();190 return { record, secret };191 }192193 async revoke(id: string): Promise<AppTicketRecord | null> {194 await this.#reload();195 const record = this.#tickets.find((t) => t.id === id);196 if (!record) return null;197 if (!record.revokedAt) {198 record.revokedAt = new Date().toISOString();199 await this.#persist();200 }201 return record;202 }203204 async list(): Promise<AppTicketRecord[]> {205 await this.#reload();206 return [...this.#tickets];207 }208209 /** Timing-safe lookup by presented secret. Compares against EVERY record210 * (valid and revoked) so probers cannot distinguish unknown from revoked by211 * timing; the gate returns 401 for both. */212 async verify(secret: string): Promise<VerifyResult> {213 await this.#maybeReload();214 const presented = await hashSecret(secret);215 let match: AppTicketRecord | null = null;216 for (const record of this.#tickets) {217 if (timingSafeEqualHex(presented, record.secretHash)) match = record;218 }219 if (!match) return { status: "unknown" };220 return match.revokedAt221 ? { status: "revoked", record: match }222 : { status: "valid", record: match };223 }224}