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 / appticket.ts
appticket.ts 12.1K
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/** A device public key bound to a ticket at derive time: connections on the36 * ticket then require a proof-of-possession challenge, so the ticket string37 * alone no longer connects. `alg` is carried per record so a future curve38 * upgrade is a value change, not a format change. */39export interface AppTicketPop {40 alg: "ES256";41 /** base64url DER SubjectPublicKeyInfo of the device's P-256 public key. */42 spki: string;43 boundAt: string;44}4546/** Persisted record of an issued ticket — digest only, never the secret. */47export interface AppTicketRecord {48 id: string;49 scope?: "sync" | "provision";50 label?: string;51 /** Id of the ticket this one was derived from (the gate's scope-down52 * exchange). Revocation cascades: a ticket is dead whenever any ancestor53 * is revoked or missing. */54 parentId?: string;55 secretHash: string;56 createdAt: string;57 revokedAt?: string;58 /** Present when the ticket is possession-bound; absent records stay pure59 * bearer, so tickets.json remains v1 and older nodes ignore the field. */60 pop?: AppTicketPop;61}6263function base64urlNoPad(bytes: Uint8Array): string {64 let bin = "";65 for (const b of bytes) bin += String.fromCharCode(b);66 return btoa(bin).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");67}6869/** Generate a fresh 256-bit ticket secret (43-char base64url). */70export function generateSecret(): string {71 return base64urlNoPad(crypto.getRandomValues(new Uint8Array(32)));72}7374/** SHA-256 hex digest of a ticket secret — what the node stores. */75export async function hashSecret(secret: string): Promise<string> {76 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));77 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");78}7980/** Serialize an app ticket to its `lofisync1.` string form. */81export function encodeAppTicket(ticket: AppTicket): string {82 return TICKET_PREFIX + base64urlNoPad(new TextEncoder().encode(JSON.stringify(ticket)));83}8485/** Parse a ticket string; null on any malformed input (paste path — never86 * throws). */87export function decodeAppTicket(text: string): AppTicket | null {88 const trimmed = text.trim();89 if (!trimmed.startsWith(TICKET_PREFIX)) return null;90 try {91 const b64 = trimmed.slice(TICKET_PREFIX.length).replaceAll("-", "+").replaceAll("_", "/");92 const json = atob(b64);93 const parsed = JSON.parse(json) as AppTicket;94 if (parsed.v !== 1 || typeof parsed.appId !== "string" || typeof parsed.url !== "string") {95 return null;96 }97 if (parsed.scope !== undefined && parsed.scope !== "sync" && parsed.scope !== "provision") {98 return null;99 }100 const url = new URL(parsed.url);101 if (!/^https?:$/.test(url.protocol) || !/^\/t\/[A-Za-z0-9_-]{43}$/.test(url.pathname)) {102 return null;103 }104 return parsed;105 } catch {106 return null;107 }108}109110/** Cheap prefix check — lets the CLI `pair` command reject app tickets with a111 * pointed message (an app ticket is not a node-pairing ticket). */112export function looksLikeAppTicket(text: string): boolean {113 return text.trim().startsWith(TICKET_PREFIX);114}115116interface TicketsFile {117 v: 1;118 tickets: AppTicketRecord[];119}120121function timingSafeEqualHex(a: string, b: string): boolean {122 // Constant-time over same-length hex digests; length mismatch exits early,123 // which leaks only that the input wasn't a SHA-256 hex string.124 if (a.length !== b.length) return false;125 let diff = 0;126 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);127 return diff === 0;128}129130/** True when the record or any ancestor in `all` is revoked or missing —131 * derived tickets die with their parent, so the check walks the whole chain132 * (bounded; a cycle would mean a corrupt file and counts as dead). */133export function isRevokedByLineage(record: AppTicketRecord, all: AppTicketRecord[]): boolean {134 const seen = new Set<string>();135 let cursor: AppTicketRecord | undefined = record;136 while (cursor) {137 if (cursor.revokedAt) return true;138 if (cursor.parentId === undefined) return false;139 if (seen.has(cursor.id)) return true;140 seen.add(cursor.id);141 const parentId: string = cursor.parentId;142 cursor = all.find((t) => t.id === parentId);143 }144 return true; // parent record missing145}146147export type VerifyResult =148 | { status: "valid"; record: AppTicketRecord }149 | { status: "revoked"; record: AppTicketRecord }150 | { status: "unknown" };151152/** Issued-ticket persistence. File-backed when a dataDir is given (the CLI153 * writes it for issue/revoke; the daemon reads via throttled mtime checks —154 * so those commands need no IPC — and writes only when the gate's scope-down155 * exchange derives a ticket), in-memory otherwise (tests, ephemeral nodes). */156export class AppTicketStore {157 #path: string | null;158 #tickets: AppTicketRecord[] = [];159 #mtime = 0;160 #lastStat = 0;161162 private constructor(path: string | null) {163 this.#path = path;164 }165166 static async load(dataDir?: string): Promise<AppTicketStore> {167 const store = new AppTicketStore(dataDir ? `${dataDir}/tickets.json` : null);168 await store.#reload();169 return store;170 }171172 async #reload(): Promise<void> {173 if (!this.#path) return;174 try {175 const stat = await Deno.stat(this.#path);176 const mtime = stat.mtime?.getTime() ?? 0;177 if (mtime !== 0 && mtime === this.#mtime) return;178 const parsed = JSON.parse(await Deno.readTextFile(this.#path)) as TicketsFile;179 if (parsed.v !== 1) throw new Error(`unsupported tickets.json version ${parsed.v}`);180 this.#tickets = parsed.tickets;181 this.#mtime = mtime;182 } catch (e) {183 if (e instanceof Deno.errors.NotFound) {184 this.#tickets = [];185 this.#mtime = 0;186 return;187 }188 throw e;189 }190 }191192 /** Throttled (~1s) mtime check so per-request verify stays cheap. */193 async #maybeReload(): Promise<void> {194 const now = Date.now();195 if (now - this.#lastStat < 1000) return;196 this.#lastStat = now;197 await this.#reload();198 }199200 async #persist(): Promise<void> {201 if (!this.#path) return;202 const file: TicketsFile = { v: 1, tickets: this.#tickets };203 const tmp = `${this.#path}.${Deno.pid}.tmp`;204 await Deno.writeTextFile(tmp, JSON.stringify(file, null, 2) + "\n");205 await Deno.rename(tmp, this.#path);206 this.#mtime = (await Deno.stat(this.#path)).mtime?.getTime() ?? 0;207 }208209 /** Issue a ticket. `parentId` links a derived ticket to the ticket it was210 * minted from — the derived ticket then dies with its parent. The parent211 * must be live at issuance. `pop` binds a device public key: the gate then212 * requires a proof-of-possession exchange before any connection on the213 * ticket. Records without a parent or pop serialize exactly as before (the214 * fields are omitted), so tickets.json stays v1. */215 async issue(216 label?: string,217 scope: "sync" | "provision" = "sync",218 parentId?: string,219 pop?: Omit<AppTicketPop, "boundAt">,220 ): Promise<{ record: AppTicketRecord; secret: string }> {221 await this.#reload();222 if (parentId !== undefined) {223 const parent = this.#tickets.find((t) => t.id === parentId);224 if (!parent || isRevokedByLineage(parent, this.#tickets)) {225 throw new Error(`parent ticket ${parentId} is unknown or revoked`);226 }227 }228 const secret = generateSecret();229 const secretHash = await hashSecret(secret);230 const record: AppTicketRecord = {231 id: secretHash.slice(0, 12),232 scope,233 label,234 parentId,235 secretHash,236 createdAt: new Date().toISOString(),237 ...(pop ? { pop: { ...pop, boundAt: new Date().toISOString() } } : {}),238 };239 this.#tickets.push(record);240 await this.#persist();241 return { record, secret };242 }243244 async revoke(id: string): Promise<AppTicketRecord | null> {245 await this.#reload();246 const record = this.#tickets.find((t) => t.id === id);247 if (!record) return null;248 if (!record.revokedAt) {249 record.revokedAt = new Date().toISOString();250 await this.#persist();251 }252 return record;253 }254255 async list(): Promise<AppTicketRecord[]> {256 await this.#reload();257 return [...this.#tickets];258 }259260 /** Timing-safe lookup by presented secret. Compares against EVERY record261 * (valid and revoked) so probers cannot distinguish unknown from revoked by262 * timing; the gate returns 401 for both. A derived ticket whose lineage is263 * dead reports "revoked" exactly like a directly revoked one. */264 async verify(secret: string): Promise<VerifyResult> {265 await this.#maybeReload();266 const presented = await hashSecret(secret);267 let match: AppTicketRecord | null = null;268 for (const record of this.#tickets) {269 if (timingSafeEqualHex(presented, record.secretHash)) match = record;270 }271 if (!match) return { status: "unknown" };272 return isRevokedByLineage(match, this.#tickets)273 ? { status: "revoked", record: match }274 : { status: "valid", record: match };275 }276}277278/** The exact bytes a device signs to prove possession: version line, app id,279 * ticket id, and the challenge nonce, newline-joined. Binding the app and280 * ticket ids prevents splicing a signature across stores or tickets; the281 * single-use nonce prevents replay. */282export function popMessage(appId: string, ticketId: string, nonce: string): Uint8Array {283 return new TextEncoder().encode(`lofisync-pop-v1\n${appId}\n${ticketId}\n${nonce}`);284}285286function fromBase64url(text: string): Uint8Array {287 const b64 = text.replaceAll("-", "+").replaceAll("_", "/");288 const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);289 const bin = atob(padded);290 const bytes = new Uint8Array(bin.length);291 for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);292 return bytes;293}294295/** Import a bound device key's SPKI; throws on any malformed or wrong-curve296 * key — used by the derive endpoint to reject bad bindings up front. */297export async function importPopKey(spki: string): Promise<CryptoKey> {298 return await crypto.subtle.importKey(299 "spki",300 fromBase64url(spki).buffer as ArrayBuffer,301 { name: "ECDSA", namedCurve: "P-256" },302 false,303 ["verify"],304 );305}306307/** Verify a proof-of-possession signature (WebCrypto raw r||s ECDSA over308 * SHA-256). False on any failure — malformed keys and signatures verify309 * false rather than throwing, so the caller keeps one rejection path. */310export async function verifyPopSignature(311 spki: string,312 message: Uint8Array,313 signature: string,314): Promise<boolean> {315 try {316 const key = await importPopKey(spki);317 return await crypto.subtle.verify(318 { name: "ECDSA", hash: "SHA-256" },319 key,320 fromBase64url(signature).buffer as ArrayBuffer,321 message.buffer as ArrayBuffer,322 );323 } catch {324 return false;325 }326}