1/**2 * Proof-of-possession for sync tickets: the device generates a3 * non-extractable ECDSA P-256 keypair at enrollment, binds its public key to4 * the derived sync ticket at the node's scope-down exchange, and thereafter5 * answers a challenge with a fresh signature before connecting — the signing6 * key never leaves the device, so an exfiltrated ticket string alone no7 * longer connects. Losing the key (a wiped browser store) is not recoverable8 * by design; re-enrollment with a fresh ticket is the recovery.9 *10 * @module11 */1213/** The device public key offered at the scope-down exchange. */14export type DevicePublicKey = {15 alg: "ES256";16 /** base64url DER SubjectPublicKeyInfo. */17 spki: string;18};1920/** Storage for the device keypair; IndexedDB in browsers. */21export type PopKeyStore = {22 get(keyId: string): Promise<CryptoKeyPair | null>;23 put(keyId: string, pair: CryptoKeyPair): Promise<void>;24};2526const POP_DB_NAME = "lofi-device-keys";27const POP_STORE_NAME = "keys";2829function openDb(): Promise<IDBDatabase> {30 return new Promise((resolve, reject) => {31 const request = indexedDB.open(POP_DB_NAME, 1);32 request.onupgradeneeded = () => {33 if (!request.result.objectStoreNames.contains(POP_STORE_NAME)) {34 request.result.createObjectStore(POP_STORE_NAME);35 }36 };37 request.onsuccess = () => resolve(request.result);38 request.onerror = () => reject(request.error ?? new Error("device key store failed to open"));39 });40}4142function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {43 return new Promise((resolve, reject) => {44 request.onsuccess = () => resolve(request.result);45 request.onerror = () => reject(request.error ?? new Error("device key request failed"));46 });47}4849function indexedDbPopKeyStore(): PopKeyStore {50 return {51 async get(keyId) {52 const db = await openDb();53 try {54 const stored = await requestToPromise<CryptoKeyPair | undefined>(55 db.transaction(POP_STORE_NAME, "readonly").objectStore(POP_STORE_NAME).get(keyId),56 );57 return stored ?? null;58 } finally {59 db.close();60 }61 },62 async put(keyId, pair) {63 const db = await openDb();64 try {65 await requestToPromise(66 db.transaction(POP_STORE_NAME, "readwrite").objectStore(POP_STORE_NAME).put(67 pair,68 keyId,69 ),70 );71 } finally {72 db.close();73 }74 },75 };76}7778/** A process-lifetime store for tests and non-browser environments. */79export function memoryPopKeyStore(): PopKeyStore {80 const pairs = new Map<string, CryptoKeyPair>();81 return {82 get: (keyId) => Promise.resolve(pairs.get(keyId) ?? null),83 put: (keyId, pair) => {84 pairs.set(keyId, pair);85 return Promise.resolve();86 },87 };88}8990let sharedStore: PopKeyStore | null = null;9192/** The device keypair store for this runtime: IndexedDB where available. */93export function defaultPopKeyStore(): PopKeyStore {94 sharedStore ??= typeof indexedDB === "undefined" ? memoryPopKeyStore() : indexedDbPopKeyStore();95 return sharedStore;96}9798function toBase64Url(bytes: Uint8Array): string {99 let binary = "";100 for (const byte of bytes) binary += String.fromCharCode(byte);101 return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");102}103104/**105 * The device keypair for an app's sync enrollment, minted on first use. The106 * private key is non-extractable — usable by this origin, exportable by107 * nobody.108 */109export async function getOrCreatePopKeyPair(110 appId: string,111 store: PopKeyStore = defaultPopKeyStore(),112): Promise<CryptoKeyPair> {113 const keyId = `pop:${appId}`;114 const existing = await store.get(keyId);115 if (existing) return existing;116 const pair = await crypto.subtle.generateKey(117 { name: "ECDSA", namedCurve: "P-256" },118 false,119 ["sign", "verify"],120 ) as CryptoKeyPair;121 await store.put(keyId, pair);122 return (await store.get(keyId)) ?? pair;123}124125/** The offerable public half of a device keypair. */126export async function exportDevicePublicKey(pair: CryptoKeyPair): Promise<DevicePublicKey> {127 const spki = new Uint8Array(await crypto.subtle.exportKey("spki", pair.publicKey));128 return { alg: "ES256", spki: toBase64Url(spki) };129}130131/** The exact bytes signed to prove possession; mirrors the node's contract. */132export function popMessage(appId: string, ticketId: string, nonce: string): Uint8Array {133 return new TextEncoder().encode(`lofisync-pop-v1\n${appId}\n${ticketId}\n${nonce}`);134}135136/** Signs a challenge message with the device key (raw r||s, base64url). */137export async function signPopMessage(138 pair: CryptoKeyPair,139 message: Uint8Array,140): Promise<string> {141 const signature = await crypto.subtle.sign(142 { name: "ECDSA", hash: "SHA-256" },143 pair.privateKey,144 message.buffer as ArrayBuffer,145 );146 return toBase64Url(new Uint8Array(signature));147}148149/**150 * Runs the pre-connect exchange against a bound ticket's base URL and151 * returns the token-suffixed server URL to hand to the sync client, or null152 * when the exchange fails — an expired knowledge (node restart), a revoked153 * ticket, or a node predating the exchange. Callers treat null like a154 * revoked ticket: surface re-enrollment, never retry silently forever.155 */156export async function completePopExchange(input: {157 serverUrl: string;158 appId: string;159 ticketId: string;160 keyPair: CryptoKeyPair;161 fetcher?: typeof fetch;162}): Promise<string | null> {163 const fetcher = input.fetcher ?? fetch;164 try {165 const challengeResponse = await fetcher(`${input.serverUrl}/pop/challenge`, {166 method: "POST",167 });168 if (!challengeResponse.ok) {169 await challengeResponse.body?.cancel();170 return null;171 }172 const challenge = await challengeResponse.json() as { id?: unknown; nonce?: unknown };173 if (typeof challenge.id !== "string" || typeof challenge.nonce !== "string") return null;174 const sig = await signPopMessage(175 input.keyPair,176 popMessage(input.appId, input.ticketId, challenge.nonce),177 );178 const answerResponse = await fetcher(`${input.serverUrl}/pop/answer`, {179 method: "POST",180 headers: { "content-type": "application/json" },181 body: JSON.stringify({ v: 1, id: challenge.id, sig }),182 });183 if (!answerResponse.ok) {184 await answerResponse.body?.cancel();185 return null;186 }187 const answer = await answerResponse.json() as { connect?: unknown };188 if (typeof answer.connect !== "string" || !answer.connect) return null;189 return `${input.serverUrl}/c/${answer.connect}`;190 } catch {191 return null;192 }193}