clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat(runtime): possession-bound sync tickets — device key at enrollment, proof exchange at connect (#165)

FelineStateMachine committed 020549c
Enrollment offers a non-extractable P-256 device key to the node's
scope-down exchange; a node that accepts binds the derived ticket, and
every boot thereafter answers a fresh challenge before connecting
through a short-lived connect token. A ticket string exfiltrated from
the device then no longer connects on its own. A failed proof boots
without the token and surfaces as a rejected ticket; re-enrollment is
the recovery — key loss is unrecoverable by design.
commit
020549cc84a0888280720a827b9a54320ce06504
parent
e19bbb16848687409e5ddde8f2ccf392f4938778
author
FelineStateMachine <dami.etoile@gmail.com>
14 files changed, +568 -22
M.github/workflows/site.yml2+-
Mdocs/sync-and-recovery.md7+
Mdocs/threat-model.md23+++-
Mpackage/astro/mod.ts1+
Mpackage/preact/DeviceStatus.tsx7+
Mpackage/runtime/config.ts9+-
Mpackage/runtime/data-sink.ts46++++++-
Mpackage/runtime/data-sink_test.ts61+++++++++
Mpackage/runtime/mod.ts10++
Apackage/runtime/pop.ts193++++++++++++++++++++++++++++++
Apackage/runtime/pop_test.ts158+++++++++++++++++++++++++
Mpackage/runtime/runtime.ts24++++
Mpackage/runtime/session.ts26+++-
Apackage/testdata/pop-fixtures.json23++++
M .github/workflows/site.yml +1 -1
M docs/sync-and-recovery.md +7 -0
M docs/threat-model.md +17 -6
@@ -79,14 +79,25 @@ An app-connect ticket is a bearer credential with 256-bit entropy.
7979 everyday transport credential and dies with its parent on revocation), and the provision original8080 is sealed behind the user's passkey or kept only in memory, with the user's password manager as8181 the durable copy.82+- The **derived sync ticket is possession-bound in use** against a node that supports the exchange:83+ enrollment offers a non-extractable device key, and connecting thereafter means answering a fresh84+ challenge with a signature only that device can produce. The everyday credential then stops being85+ a pure bearer string — a copy lifted from a backup, a log line, or a pasted URL no longer connects86+ from anywhere else. Losing the device key (a wiped browser store) is deliberately unrecoverable;87+ re-enrolling a fresh ticket is the recovery.8288 8389 **Same-origin script (XSS) is the attacker this custody is shaped around.** Script running on the84-app's origin can use whatever the page can use: it can drive the silent open of the sync85-declaration, so a compromised origin leaks transport capability. What it cannot do silently is86-obtain admin capability — unlocking sealed provision material is a user-verifying passkey prompt, so87-theft degrades from silent to prompted, and a prompt the user did not initiate is the signal to88-decline. At-rest theft (disk images, backups, storage exfiltration) gets the stronger guarantee:89-sealed records are ciphertext without the device key or the passkey.90+app's origin can use whatever the page can use: the page's own device key answers the challenge for91+whatever runs inside it, so a compromised origin holds transport capability while its page is live.92+Possession binding narrows what that compromise is worth — the credential material script can read93+no longer connects from anywhere else, because the signing key cannot leave the device. Exfiltration94+loses its value; in-page abuse remains, and the Content-Security-Policy below is the control aimed95+at that residue. What script cannot do silently is obtain admin capability — unlocking sealed96+provision material is a user-verifying passkey prompt, so theft degrades from silent to prompted,97+and a prompt the user did not initiate is the signal to decline. At-rest theft (disk images,98+backups, storage exfiltration) gets the stronger guarantee: sealed records are ciphertext without99+the device key or the passkey, and the sync credential inside them is bound to a key that was never100+in the record at all.90101 91102 Generated apps also narrow the surface that creates same-origin script in the first place: every92103 built page carries a Content-Security-Policy that admits only the app's own hashed scripts — no
M package/astro/mod.ts +1 -0
M package/preact/DeviceStatus.tsx +7 -0
M package/runtime/config.ts +8 -1
M package/runtime/data-sink.ts +37 -9
@@ -20,6 +20,7 @@
2020 * @module2121 */2222 23+import type { DevicePublicKey } from "./pop.ts";2324 import {2425 defaultDeviceKeyStore,2526 deviceKeyProtector,
@@ -79,6 +80,12 @@ export type DataSinkDeclaration = {
7980 * `lofisync1.` app ticket. Unused by the browser runtime today.8081 */8182 node?: string;83+ /**84+ * Present when the derived ticket was bound to this device's keypair at85+ * enrollment: connecting then requires the proof-of-possession exchange,86+ * and the ticket id is what the device signs over.87+ */88+ pop?: { ticketId: string };8289 };8390 8491 /** Raised when a sink declaration or sync ticket is rejected. */
@@ -119,12 +126,16 @@ function validateDeclaration(value: unknown): DataSinkDeclaration | null {
119126 ) {120127 return null;121128 }129+ const pop = (sink as { pop?: { ticketId?: unknown } }).pop;122130 return {123131 appId: sink.appId,124132 serverUrl: sink.serverUrl,125133 ...(sink.scope === "provision" || sink.scope === "sync" ? { scope: sink.scope } : {}),126134 ...(typeof sink.label === "string" ? { label: sink.label } : {}),127135 ...(typeof sink.node === "string" ? { node: sink.node } : {}),136+ ...(pop && typeof pop.ticketId === "string" && pop.ticketId137+ ? { pop: { ticketId: pop.ticketId } }138+ : {}),128139 };129140 }130141
@@ -345,14 +356,17 @@ export function parseSyncTicket(text: string): SyncTicket | null {
345356 }346357 347358 /**348- * Converts a ticket into a sink declaration and records it. Throws349- * {@link DataSinkError} with `invalid-ticket` for malformed tickets and the350- * declaration errors otherwise. Electing sync afterwards (see351- * `enrollSyncTicket` in `session.ts`) is what actually connects.359+ * Converts a ticket into a sink declaration and records it. A possession360+ * binding from the scope-down exchange (see {@link splitTicketForEnrollment})361+ * is carried into the declaration when present. Throws {@link DataSinkError}362+ * with `invalid-ticket` for malformed tickets and the declaration errors363+ * otherwise. Electing sync afterwards (see `enrollSyncTicket` in364+ * `session.ts`) is what actually connects.352365 */353366 export async function declareSinkFromTicket(354367 text: string,355368 keyStore: DeviceKeyStore = defaultDeviceKeyStore(),369+ pop?: { ticketId: string } | null,356370 ): Promise<DataSinkDeclaration> {357371 const ticket = parseSyncTicket(text);358372 if (!ticket) {
@@ -364,6 +378,7 @@ export async function declareSinkFromTicket(
364378 ...(ticket.scope ? { scope: ticket.scope } : {}),365379 ...(ticket.label ? { label: ticket.label } : {}),366380 ...(ticket.node ? { node: ticket.node } : {}),381+ ...(pop ? { pop } : {}),367382 }, keyStore);368383 }369384
@@ -382,6 +397,8 @@ export type TicketSplit = {
382397 sinkTicket: string;383398 /** The provision bearer URL when the exchange split it off, else `null`. */384399 provisionUrl: string | null;400+ /** The possession binding when the node accepted a device key, else `null`. */401+ pop: { ticketId: string } | null;385402 };386403 387404 /**
@@ -397,19 +414,26 @@ export type TicketSplit = {
397414 export async function splitTicketForEnrollment(398415 ticket: string,399416 fetcher: typeof fetch = fetch,417+ devicePublicKey?: DevicePublicKey,400418 ): Promise<TicketSplit> {401419 const parsed = parseSyncTicket(ticket);402- if (parsed?.scope !== "provision") return { sinkTicket: ticket, provisionUrl: null };420+ if (parsed?.scope !== "provision") return { sinkTicket: ticket, provisionUrl: null, pop: null };403421 let derived: string | null = null;422+ let derivedId: string | null = null;423+ let bound = false;404424 try {405425 const response = await fetcher(`${parsed.url}/derive-sync-ticket`, {406426 method: "POST",407427 headers: { "content-type": "application/json" },408- body: JSON.stringify({}),428+ // Nodes predating the device-key field ignore it and answer without429+ // `pop`; enrollment then falls back to bearer custody.430+ body: JSON.stringify(devicePublicKey ? { devicePublicKey } : {}),409431 });410432 if (response.ok) {411- const body = await response.json() as { ticket?: unknown };433+ const body = await response.json() as { ticket?: unknown; id?: unknown; pop?: unknown };412434 if (typeof body.ticket === "string") derived = body.ticket;435+ if (typeof body.id === "string") derivedId = body.id;436+ bound = body.pop === true;413437 }414438 } catch {415439 // Fall back to the ticket as pasted.
@@ -419,7 +443,11 @@ export async function splitTicketForEnrollment(
419443 derived !== null && derivedParsed !== null && derivedParsed.appId === parsed.appId &&420444 (derivedParsed.scope ?? "sync") === "sync"421445 ) {422- return { sinkTicket: derived, provisionUrl: parsed.url };446+ return {447+ sinkTicket: derived,448+ provisionUrl: parsed.url,449+ pop: bound && derivedId !== null ? { ticketId: derivedId } : null,450+ };423451 }424- return { sinkTicket: ticket, provisionUrl: null };452+ return { sinkTicket: ticket, provisionUrl: null, pop: null };425453 }
M package/runtime/data-sink_test.ts +61 -0
@@ -375,6 +375,13 @@ test(
375375 managedDriver.dbName === `lofi-test-${ticketAppId}-ns-managed`,376376 `managed dbName was ${managedDriver.dbName} — each store gets its own namespace`,377377 );378+379+ const tokenUrl = "https://node.example:4802/c/token";380+ const overridden = databaseConfig("secret", "ns", "managed", true, () => {}, tokenUrl);381+ assert(382+ (overridden as { serverUrl?: string }).serverUrl === tokenUrl,383+ "a possession-bound boot must connect through the exchange's token URL",384+ );378385 }),379386 );380387
@@ -473,3 +480,57 @@ test("sync-scoped and malformed tickets never touch the exchange", async () => {
473480 }474481 assert(calls === 0, "the exchange endpoint must not be called for non-provision tickets");475482 });483+484+test("offering a device key binds the derived ticket; old nodes fall back to bearer", async () => {485+ const bodies: string[] = [];486+ const bindingFetcher: typeof fetch = (_input, init) => {487+ bodies.push(String(init?.body));488+ return Promise.resolve(489+ new Response(490+ JSON.stringify({ v: 1, id: "bound-id", ticket: derivedTicket, pop: true }),491+ { status: 200 },492+ ),493+ );494+ };495+ const deviceKey = { alg: "ES256" as const, spki: "test-spki" };496+ const bound = await splitTicketForEnrollment(provisionTicket, bindingFetcher, deviceKey);497+ assert(498+ bound.pop !== null && bound.pop.ticketId === "bound-id",499+ "a pop:true response must record the binding with its ticket id",500+ );501+ assert(502+ bodies.length === 1 && JSON.parse(bodies[0]).devicePublicKey.spki === "test-spki",503+ "the derive request must carry the offered device key",504+ );505+506+ // A node predating the field answers without `pop`: bearer fallback.507+ const legacyFetcher: typeof fetch = () =>508+ Promise.resolve(509+ new Response(JSON.stringify({ v: 1, id: "abc", ticket: derivedTicket }), { status: 200 }),510+ );511+ const legacy = await splitTicketForEnrollment(provisionTicket, legacyFetcher, deviceKey);512+ assert(513+ legacy.pop === null && legacy.sinkTicket === derivedTicket,514+ "a keyless response must enroll the derived ticket as pure bearer",515+ );516+});517+518+test(519+ "a possession binding declared with the ticket survives the sealed round-trip",520+ withCleanState(async () => {521+ await declareSinkFromTicket(derivedTicket, undefined, { ticketId: "bound-id" });522+ assert(523+ readDeclaredSink()?.pop?.ticketId === "bound-id",524+ "the declaration must carry the binding",525+ );526+ assert(await restoreDeclaredSink() === "restored", "the sealed record must restore");527+ assert(528+ readDeclaredSink()?.pop?.ticketId === "bound-id",529+ "the restored declaration must keep the binding",530+ );531+ assert(532+ activeSink()?.pop?.ticketId === "bound-id",533+ "the active sink must expose the binding so boot runs the exchange",534+ );535+ }),536+);
M package/runtime/mod.ts +10 -0
A package/runtime/pop.ts +193 -0
@@ -0,0 +1,193 @@
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+ */12+13+/** The device public key offered at the scope-down exchange. */14+export type DevicePublicKey = {15+ alg: "ES256";16+ /** base64url DER SubjectPublicKeyInfo. */17+ spki: string;18+};19+20+/** Storage for the device keypair; IndexedDB in browsers. */21+export type PopKeyStore = {22+ get(keyId: string): Promise<CryptoKeyPair | null>;23+ put(keyId: string, pair: CryptoKeyPair): Promise<void>;24+};25+26+const POP_DB_NAME = "lofi-device-keys";27+const POP_STORE_NAME = "keys";28+29+function 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+}41+42+function 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+}48+49+function 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+}77+78+/** A process-lifetime store for tests and non-browser environments. */79+export 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+}89+90+let sharedStore: PopKeyStore | null = null;91+92+/** The device keypair store for this runtime: IndexedDB where available. */93+export function defaultPopKeyStore(): PopKeyStore {94+ sharedStore ??= typeof indexedDB === "undefined" ? memoryPopKeyStore() : indexedDbPopKeyStore();95+ return sharedStore;96+}97+98+function 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+}103+104+/**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+ */109+export 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+}124+125+/** The offerable public half of a device keypair. */126+export 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+}130+131+/** The exact bytes signed to prove possession; mirrors the node's contract. */132+export function popMessage(appId: string, ticketId: string, nonce: string): Uint8Array {133+ return new TextEncoder().encode(`lofisync-pop-v1\n${appId}\n${ticketId}\n${nonce}`);134+}135+136+/** Signs a challenge message with the device key (raw r||s, base64url). */137+export 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+}148+149+/**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+ */156+export 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+}
A package/runtime/pop_test.ts +158 -0
@@ -0,0 +1,158 @@
1+// Client proof-of-possession contract: signatures match the cross-repo2+// fixtures byte for byte, the device keypair persists per app, and the3+// pre-connect exchange yields the token URL or degrades to null.4+import {5+ completePopExchange,6+ exportDevicePublicKey,7+ getOrCreatePopKeyPair,8+ memoryPopKeyStore,9+ popMessage,10+ signPopMessage,11+} from "./pop.ts";12+import { assert } from "./test-assert.ts";13+14+function fromBase64Url(text: string): Uint8Array {15+ const base64 = text.replaceAll("-", "+").replaceAll("_", "/");16+ const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);17+ const binary = atob(padded);18+ const bytes = new Uint8Array(binary.length);19+ for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);20+ return bytes;21+}22+23+type PopFixtures = {24+ privateJwk: JsonWebKey;25+ spki: string;26+ appId: string;27+ ticketId: string;28+ nonce: string;29+ signature: string;30+};31+32+async function loadFixtures(): Promise<PopFixtures> {33+ return JSON.parse(34+ await Deno.readTextFile(new URL("../testdata/pop-fixtures.json", import.meta.url)),35+ ) as PopFixtures;36+}37+38+Deno.test("signatures conform to the node's fixture vectors", async () => {39+ const fixtures = await loadFixtures();40+ const message = popMessage(fixtures.appId, fixtures.ticketId, fixtures.nonce);41+42+ // The fixture's own signature verifies under its public key — the message43+ // bytes this client builds are the bytes the node verifies.44+ const publicKey = await crypto.subtle.importKey(45+ "spki",46+ fromBase64Url(fixtures.spki).buffer as ArrayBuffer,47+ { name: "ECDSA", namedCurve: "P-256" },48+ false,49+ ["verify"],50+ );51+ assert(52+ await crypto.subtle.verify(53+ { name: "ECDSA", hash: "SHA-256" },54+ publicKey,55+ fromBase64Url(fixtures.signature).buffer as ArrayBuffer,56+ message.buffer as ArrayBuffer,57+ ),58+ "the fixture signature must verify over this client's message bytes",59+ );60+61+ // A signature this client produces with the fixture's private key verifies62+ // the same way — signing and verification agree end to end.63+ const privateKey = await crypto.subtle.importKey(64+ "jwk",65+ fixtures.privateJwk,66+ { name: "ECDSA", namedCurve: "P-256" },67+ false,68+ ["sign"],69+ );70+ const signed = await signPopMessage(71+ { privateKey, publicKey } as CryptoKeyPair,72+ message,73+ );74+ assert(75+ await crypto.subtle.verify(76+ { name: "ECDSA", hash: "SHA-256" },77+ publicKey,78+ fromBase64Url(signed).buffer as ArrayBuffer,79+ message.buffer as ArrayBuffer,80+ ),81+ "a client-produced signature must verify under the fixture key",82+ );83+});84+85+Deno.test("the device keypair persists per app and exports its public half", async () => {86+ const store = memoryPopKeyStore();87+ const first = await getOrCreatePopKeyPair("app-a", store);88+ const again = await getOrCreatePopKeyPair("app-a", store);89+ assert(first.privateKey === again.privateKey, "the same app must reuse its keypair");90+ const other = await getOrCreatePopKeyPair("app-b", store);91+ assert(other.privateKey !== first.privateKey, "apps must not share keypairs");92+ assert(first.privateKey.extractable === false, "the signing key must be non-extractable");93+ const exported = await exportDevicePublicKey(first);94+ assert(95+ exported.alg === "ES256" && exported.spki.length > 80,96+ "the public half must export as base64url SPKI",97+ );98+});99+100+Deno.test("the exchange yields the token URL and degrades to null", async () => {101+ const store = memoryPopKeyStore();102+ const keyPair = await getOrCreatePopKeyPair("app-x", store);103+ const exported = await exportDevicePublicKey(keyPair);104+105+ const requests: string[] = [];106+ const respondingFetcher = (async (input: RequestInfo | URL, init?: RequestInit) => {107+ const url = String(input);108+ requests.push(url);109+ if (url.endsWith("/pop/challenge")) {110+ return Response.json({ v: 1, id: "chal-1", nonce: "nonce-1", expiresIn: 120 });111+ }112+ if (url.endsWith("/pop/answer")) {113+ const body = JSON.parse(String(init?.body)) as { id: string; sig: string };114+ // Verify the signature exactly as the node would.115+ const publicKey = await crypto.subtle.importKey(116+ "spki",117+ fromBase64Url(exported.spki).buffer as ArrayBuffer,118+ { name: "ECDSA", namedCurve: "P-256" },119+ false,120+ ["verify"],121+ );122+ const valid = body.id === "chal-1" && await crypto.subtle.verify(123+ { name: "ECDSA", hash: "SHA-256" },124+ publicKey,125+ fromBase64Url(body.sig).buffer as ArrayBuffer,126+ popMessage("app-x", "ticket-1", "nonce-1").buffer as ArrayBuffer,127+ );128+ return valid129+ ? Response.json({ v: 1, connect: "tok-abc", expiresIn: 86400 })130+ : Response.json({ error: "invalid_ticket" }, { status: 401 });131+ }132+ return new Response("not found", { status: 404 });133+ }) as typeof fetch;134+135+ const connectUrl = await completePopExchange({136+ serverUrl: "http://node/t/secret",137+ appId: "app-x",138+ ticketId: "ticket-1",139+ keyPair,140+ fetcher: respondingFetcher,141+ });142+ assert(connectUrl === "http://node/t/secret/c/tok-abc", `unexpected url: ${connectUrl}`);143+ assert(requests.length === 2, "the exchange is exactly two requests");144+145+ // A node without the exchange (404) degrades to null, as does a refusal.146+ const failingFetcher =147+ (() => Promise.resolve(new Response("not found", { status: 404 }))) as typeof fetch;148+ assert(149+ await completePopExchange({150+ serverUrl: "http://node/t/secret",151+ appId: "app-x",152+ ticketId: "ticket-1",153+ keyPair,154+ fetcher: failingFetcher,155+ }) === null,156+ "a missing exchange must degrade to null",157+ );158+});
M package/runtime/runtime.ts +24 -0
@@ -19,6 +19,7 @@ import { clearSharedFieldKeys, sharedFieldIdentityOrNull } from "../schema/share
1919 import { sharedColumnConfigs } from "../schema/shared-registry.ts";2020 import { recordSharedFieldAlert } from "./diagnostics.ts";2121 import { activeAppId } from "./config.ts";22+import { completePopExchange, getOrCreatePopKeyPair } from "./pop.ts";2223 import { assertDurableBrowser } from "./device-capabilities.ts";2324 import {2425 createTableStore,
@@ -272,6 +273,28 @@ async function createClient(state: RuntimeSlot): Promise<Db> {
272273 clearSharedFieldKeys();273274 await installSharedFieldIdentityFromSecret(secret);274275 const namespace = await accountNamespace(secret);276+ // A possession-bound sink proves the device key before connecting: the277+ // exchange mints a connect token the sync client carries as a path278+ // segment. A failed exchange (node restart, revocation, key loss) boots279+ // without an override — the connection then fails like a revoked ticket280+ // and the existing re-enrollment surface takes over.281+ //282+ // There is deliberately no re-exchange on reconnect: the client cannot283+ // change its server URL after creation, the token's TTL slides on every284+ // use, and every runtime recreation passes back through here. The285+ // residual — a document idle past the TTL that then reconnects — fails286+ // like a revoked ticket and recovers on reload.287+ let serverUrlOverride: string | undefined;288+ const popSink = activeSink();289+ if (connect && popSink?.pop) {290+ const keyPair = await getOrCreatePopKeyPair(popSink.appId);291+ serverUrlOverride = await completePopExchange({292+ serverUrl: popSink.serverUrl,293+ appId: popSink.appId,294+ ticketId: popSink.pop.ticketId,295+ keyPair,296+ }) ?? undefined;297+ }275298 const db = await createDb(276299 databaseConfig(277300 secret,
@@ -279,6 +302,7 @@ async function createClient(state: RuntimeSlot): Promise<Db> {
279302 runtimeMode,280303 connect,281304 createBrokerIncompatibilityHandler(runtimeMode, record),305+ serverUrlOverride,282306 ),283307 );284308 // Browser `createDb()` returns before its persistent worker bridge has
M package/runtime/session.ts +21 -5
@@ -27,7 +27,8 @@ import {
2727 syncElected,2828 syncing,2929 } from "./config.ts";30-import { declareSinkFromTicket, splitTicketForEnrollment } from "./data-sink.ts";30+import { declareSinkFromTicket, parseSyncTicket, splitTicketForEnrollment } from "./data-sink.ts";31+import { type DevicePublicKey, exportDevicePublicKey, getOrCreatePopKeyPair } from "./pop.ts";3132 import { holdProvisionCapability } from "./provision.ts";3233 import { fromRecoveryPhrase, RecoveryError, toRecoveryPhrase } from "./recovery.ts";3334 import { authenticateDeviceCredential, AuthError, enrollDeviceCredential } from "./auth.ts";
@@ -315,15 +316,30 @@ export type EnrollSyncTicketOptions = {
315316 * scope-down exchange mints a derived sync ticket, that becomes the declared316317 * sink, and the provision capability is only *held* in memory (see317318 * `provision.ts` — sealing it behind a passkey is a separate, explicit318- * ceremony). Against a node without the exchange, the ticket enrolls as319- * pasted, exactly as before.319+ * ceremony). The exchange is offered this device's public key (see `pop.ts`),320+ * and when the node binds the derived ticket to it, connecting thereafter321+ * requires the proof-of-possession exchange — the enrolled credential stops322+ * being a pure bearer string. Against a node without the exchange, the ticket323+ * enrolls as pasted, exactly as before.320324 */321325 export async function enrollSyncTicket(322326 ticket: string,323327 options: EnrollSyncTicketOptions = {},324328 ): Promise<Session> {325- const split = await splitTicketForEnrollment(ticket, options.fetcher);326- await declareSinkFromTicket(split.sinkTicket);329+ // Only a provision-scoped ticket reaches the scope-down exchange, so only330+ // then is there a binding to offer.331+ let devicePublicKey: DevicePublicKey | undefined;332+ const parsed = parseSyncTicket(ticket);333+ if (parsed?.scope === "provision") {334+ try {335+ devicePublicKey = await exportDevicePublicKey(await getOrCreatePopKeyPair(parsed.appId));336+ } catch {337+ // No usable key custody in this context; the exchange still derives a338+ // ticket, held as a bearer credential exactly as before.339+ }340+ }341+ const split = await splitTicketForEnrollment(ticket, options.fetcher, devicePublicKey);342+ await declareSinkFromTicket(split.sinkTicket, undefined, split.pop);327343 if (split.provisionUrl !== null) holdProvisionCapability(split.provisionUrl);328344 return await enableSyncBackup();329345 }
A package/testdata/pop-fixtures.json +23 -0