clone

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

feat(runtime): PRF-sealed provision custody with the scope-down split (#127)

FelineStateMachine committed efb0394
* feat(runtime): PRF-sealed provision custody with the scope-down split

A provision-scoped ticket is store administration by possession, so it
no longer persists under the silent device key. Enrollment splits it:
the node's scope-down exchange mints a derived sync ticket that becomes
the declared sink (silent at rest, transport-only by the gate's scope
enforcement), and the provision bearer URL is only held in memory.
Sealing it at rest is a separate, explicit passkey ceremony that wraps
it under a prf slot — attempted, never capability-detected; the
envelope records the credential that actually evaluated the PRF and
whether it roams. Unlocking is the same ceremony; locking forgets the
memory copy and keeps the sealed record. On devices whose authenticator
cannot evaluate PRF nothing persists: the capability is memory-only and
the durable copy is the ticket in the user's password manager.

authenticateAndDerivePrfSecret runs assertion and PRF evaluation in one
user-verifying get(), so sealing and unlocking cost one prompt. The
enrollment form (preact TicketEnrollForm) is shaped for password
managers — a current-password field with a label-as-username companion
and an explicit credentials.store offer — so the manager saves the
ticket on first paste and autofills it later. Exchange failures
(a node predating the endpoint, network errors, unusable responses)
fall back to enrolling the ticket exactly as pasted.

Part of #123 (A3 phase 2).

* docs: cover the sealed sink record and the at-rest decision

sync-and-recovery and tickets-explained now state the declaration
persists only as a sealed envelope, with the exact scope of that
protection (at rest, not same-origin script). Troubleshooting gains the
partially-cleared-site-data path: an unopenable record deliberately
runs local-only and re-enrollment resumes under the same account.
seed.md's PRF field-encryption item is promoted from stretch to
required per the owner decision, pointing at the #126 scope split.
Decision record 0001 captures the envelope design, the capability
split, the attempt-don't-detect PRF posture, the no-PRF custody
fallback, and the rejected alternatives.

Part of #123 (A3 phase 1 + C2).

* docs: describe the provision split, sealing ceremony, and custody fallback

sync-and-recovery gains the split bullet: a provision ticket becomes a
derived sync sink plus in-memory admin capability, sealed behind a
passkey only through an explicit ceremony, with the password-manager
copy as the durable one on no-PRF devices. auth.md documents the
attempt-don't-detect posture and the combined assert+derive ceremony.
store-provisioning points browser flows at the capability custody for
the provision URL. tickets-explained tells the user-facing version of
the split. Decision record 0001's follow-up reflects phase 2 landing.

Part of #123 (A3 phase 2).
commit
efb03946d6d06bd287ad59f902383f71c9ae0980
parent
799188a471466fd2176f5beac9205a644b65bacd
author
FelineStateMachine <dami.etoile@gmail.com>
19 files changed, +1047 -14
Mdocs/auth.md7+-
Adocs/decisions/0001-sink-at-rest-envelope.md94+++++++++++++
Mdocs/node/tickets-explained.md7+-
Mdocs/seed.md4+-
Mdocs/store-provisioning.md4+-
Mdocs/sync-and-recovery.md19++-
Mdocs/troubleshooting.md8+
Mpackage/astro/mod.ts2+
Apackage/preact/TicketEnrollForm.tsx220+++++++++++++++++++++++++++++
Apackage/preact/TicketEnrollForm_test.tsx31++++
Mpackage/preact/mod.ts1+
Mpackage/runtime/auth.ts51+++++++
Mpackage/runtime/data-sink.ts52+++++++
Mpackage/runtime/data-sink_test.ts97+++++++++++++
Mpackage/runtime/envelope.ts6+-
Mpackage/runtime/mod.ts14++
Apackage/runtime/provision.ts194++++++++++++++++++++++++++
Apackage/runtime/provision_test.ts225++++++++++++++++++++++++++++++
Mpackage/runtime/session.ts25+++-
M docs/auth.md +6 -1
A docs/decisions/0001-sink-at-rest-envelope.md +94 -0
@@ -0,0 +1,94 @@
1+# Sealing the data sink at rest: slotted envelope with a capability split2+3+Date: 2026-07-18\4+Decision: adopt a multi-slot at-rest envelope with a silent device-key baseline; split sync and5+provision capability tiers at rest; attempt PRF, never detect it; no-PRF fallback is memory-only6+provision capability with password-manager custody7+8+## Question9+10+The enrolled app ticket's URL is a bearer credential (`/t/<secret>`), and for a provision-scoped11+ticket, possession is store administration. How does it persist on a device so that nothing12+bearer-shaped sits in storage in cleartext, without taxing every boot of a synced app with a passkey13+ceremony — and what happens on devices whose authenticator cannot evaluate the WebAuthn PRF14+extension?15+16+## Contract IDs17+18+No existing measurable promise covers at-rest credential custody; the affected documented semantics19+are the ticket-bearer bullets in `docs/sync-and-recovery.md` and `docs/node/tickets-explained.md`.20+The threat-model disclosure page (#122 §2) is where the claim set gets a permanent home.21+22+## Exact versions23+24+`@nzip/lofi` 0.5.0 working tree (#125); lofi-node `feat/scope-down-ticket-exchange`25+(FelineStateMachine/lofi-node#4, commit 92a7877); WebCrypto AES-GCM/HKDF and IndexedDB as the26+browser baseline; `jazz-tools@2.0.0-alpha.53` unchanged.27+28+## Control29+30+The pre-#125 behavior: the sink declaration, including the full ticket URL, stored as cleartext JSON31+in `localStorage` under `lofi:data-sink:<anchorAppId>`.32+33+## Decision34+35+1. **Slotted envelope** (`package/runtime/envelope.ts`): a random 32-byte DEK encrypts the payload;36+ the DEK is wrapped under one or more protector slots; the slot table and a purpose string are37+ bound as authenticated data. Adding a protector re-wraps 32 bytes, never the payload — the38+ property that later lets provision material gain a PRF slot, and field encryption change39+ protectors, without re-encrypting data.40+2. **Silent baseline, universally applied**: every sink record is sealed under a `device-key` slot —41+ a non-extractable AES key in IndexedDB that opens with zero ceremony at boot. This defends the42+ record at rest (disk images, backups, storage exfiltration). It deliberately does not defend43+ against same-origin script, which can drive the silent open; that claim is never made.44+3. **Capability split at rest**: sync-scope material opens silently forever (its theft is45+ transport-only by the node gate's own scope enforcement); provision material is the tier that46+ gets an interactive PRF gate. A single pasted provision ticket supports the split via the node's47+ scope-down exchange (lofi-node#4): derive a parent-linked sync ticket, persist that silently,48+ gate the provision original. Revoking the parent cascades.49+4. **Attempt PRF, never detect it**: `getClientCapabilities` reports `not-reported` across most of50+ the real matrix and PRF is a property of the authenticator, not the browser. Enrollment attempts51+ the evaluation and records in the envelope what actually succeeded; the open path never consults52+ a capability report.53+5. **No-PRF fallback**: provision capability is not persisted at all — memory-only for the session,54+ with ticket custody pushed to the user's password manager (the enrollment form is a55+ `current-password` field with a label-as-username companion, so managers save on first paste and56+ autofill later). The node stores only secret hashes, so mint time is the only time the ticket can57+ enter the user's custody; the CLI says so.58+59+Rejected alternatives: PRF-sealing the whole sink record (a passkey tap before every boot's60+WebSocket, breaking ceremony-free boot for the common path); gating on the capability report (wrong61+on most real devices); a passphrase slot as the default no-PRF fallback (envelope security becomes62+the weakest slot, and password-manager custody yields gesture-gated persistence without a passphrase63+UX to own); persisting the sync ticket in cleartext with a narrowed conformance check (the universal64+envelope keeps one storage format and an unconditional no-plaintext-bearer assertion).65+66+Rule that makes optimistic PRF safe to ship: only PRF-seal material that is recoverable out-of-band.67+A deleted passkey or an uncooperative authenticator means re-enrollment, never data loss.68+69+## Procedure70+71+`deno task test` (envelope and data-sink suites), `deno task check`, `deno task build`,72+`deno task test:golden`. The conformance check enrolls a provision-scoped ticket and scans every73+stored value for the secret and URL in cleartext, base64, base64url, and URL-encoding, then asserts74+the record parses as a v1 envelope and restores to the exact ticket.75+76+## Evidence77+78+`package/runtime/envelope_test.ts` (multi-slot open, fall-through, tamper → corrupt, purpose79+binding, strict validation) and `package/runtime/data-sink_test.ts` (sealed round-trip, cleartext80+migration, unopenable-not-destroyed, no-plaintext-bearer scan). Golden suite passes through the81+boot-time restore. lofi-node#4 carries the exchange and cascade tests.82+83+## Contract delta84+85+The stored declaration is no longer cleartext; declaration APIs (`declareDataSink`,86+`declareSinkFromTicket`) became asynchronous; a sealed record whose device key is gone reports87+`unopenable`, leaves the record intact, and the device runs local-only until re-enrollment88+(`docs/troubleshooting.md`). The XSS posture is unchanged in phase 1 and documented as such.89+90+## Follow-up91+92+PRF slot wiring, the enrollment exchange flow, and password-manager custody UX landed as A3 phase 293+(`provision.ts`, `authenticateAndDerivePrfSecret`, `TicketEnrollForm`); field encryption is scoped94+in #126 with the account-private/group-shared split; claim alignment continues in #122 §2 and §4.
M docs/node/tickets-explained.md +5 -2
M docs/seed.md +2 -2
@@ -22,7 +22,7 @@ targeting Android Chrome 148+ / iOS Safari 16.4+ as hard floors**.
2222 | UI runtime | **Preact 10 islands via `client:only="preact"`** | Skips SSR entirely — correct when all data hydrates from local storage. |2323 | Data | **jazz-tools 2.0 alpha (pinned exact version), OPFS persistent mode** | ~5x faster than the 0.20.x IndexedDB backend. Persistence requires SharedWorker + Web Locks + MessageChannel; fallback is memory-only — hence the hard platform floors below. |2424 | Sync backend | **Jazz Cloud** (managed WebSocket) | Zero-config; self-hosting later changes nothing client-side. |25-| Auth | **Jazz PasskeyAuth + 24-word passphrase recovery** | Jazz custodies the account secret in a resident WebAuthn credential; wrap, don't reinvent. PRF-derived E2E-at-rest is an optional stretch. |25+| Auth | **Jazz PasskeyAuth + 24-word passphrase recovery** | Jazz custodies the account secret in a resident WebAuthn credential; wrap, don't reinvent. PRF-derived E2E-at-rest is required for account-private fields (#126). |2626 | PWA | **@vite-pwa/astro v1.2 (Workbox)** | SW's job stays deliberately small: shell precache + push readiness. The SW never touches sync or OPFS. Full prerender = full offline shell for free (precache only covers prerendered routes). |2727 | Cross-island state | **@preact/signals + module-scope singleton client** | React/Preact context can't cross Astro island boundaries; a module singleton + signals sidestep it. No provider component in the public API. |2828 | Toolchain | **Deno 2.9** task runner + server; Astro/Vite under npm-compat | Fallback if npm-compat friction appears: Node for `astro build` only, Deno for everything else. |
@@ -160,7 +160,7 @@ exists, which is what makes multi-island apps work.
160160 | 2 | **Astro shell + islands + Deno toolchain** — scaffold workspace, port spikes into `@nzip/lofi` subpath modules, two _separate_ islands sharing one client and live-updating each other (acceptance test for the singleton design); `deno task dev/build/serve` end-to-end with @deno/astro-adapter | 1 wk | Island-boundary state sharing works; npm-compat friction. **Soft kill:** Astro misbehaves under Deno → Node for build, Deno for serve; cap at 1 day |161161 | 3 | **PWA hardening** — Workbox shell precache, manifest, Android install prompt, iOS coach-mark, EU-iOS degrade, `storage.persist()` incl. Safari notification-permission dance, reconnect-on-foreground verified on devices | 1 wk | Offline cold-start (airplane mode → installed PWA → shell + data render); WS reconnect reliability. Failures here are degradable UX, not fatal |162162 | 4 | **Demo app "Ticktick-lite"** — checklists, shared lists via Group invite links (two accounts, two devices), sync badge, offline edits, conflict-free merge test | 1 wk | Is the API pleasant; where the abstraction leaks; invite-revocation gap surfaces concretely |163-| 5 | **(Stretch) PRF-derived E2E-at-rest** — WebAuthn PRF → HKDF → field-level encryption before data enters Jazz | optional | Only if a real threat model demands it; Safari 18 iCloud-Keychain-only; feature-detect, never require |163+| 5 | **PRF-derived E2E-at-rest** — WebAuthn PRF → HKDF → field-level encryption before data enters Jazz (promoted from stretch to required; scope split in issue #126) | optional | Only if a real threat model demands it; Safari 18 iCloud-Keychain-only; feature-detect, never require |164164 165165 ## Verification166166
M docs/store-provisioning.md +3 -1
M docs/sync-and-recovery.md +16 -3
@@ -62,7 +62,8 @@ Enrollment declares the ticket as this device's _data sink_ and elects sync in o
6262 data pushes up under the same account identity, exactly as electing against a compiled managed app6363 does. `parseSyncTicket` validates a pasted string without enrolling it (returns `null` on any6464 malformed input), and `readDeclaredSink` / `clearDeclaredSink` manage the declaration directly. A65-non-ticket location can be declared with `declareDataSink({ appId, serverUrl })`.65+non-ticket location can be declared with `await declareDataSink({ appId, serverUrl })` —66+declarations are asynchronous because the record is sealed before it is stored.6667 6768 Semantics to rely on:6869
@@ -74,12 +75,24 @@ Semantics to rely on:
7475 - **One store, one active sink.** Declaring a different sink over an existing one is refused; clear7576 the declaration first. Local data and elections survive clearing.7677 - **The ticket URL is a bearer credential.** It is used verbatim as the sync server (its secret path77- is what authorizes transport), stored in a device-local record, and never exposed through the78- session snapshot — `session.sink` carries only the source, host, and label.78+ is what authorizes transport) and never exposed through the session snapshot — `session.sink`79+ carries only the source, host, and label. At rest the declaration persists only as a sealed80+ envelope under a device-bound key, so nothing bearer-shaped sits in storage in cleartext. That81+ protects the record against disk images, backups, and storage exfiltration; it does not protect it82+ from script running on the same origin, which can drive the silent open itself.7983 - **Tickets carry a scope.** A plain (or `scope: "sync"`) ticket is transport only; a8084 `scope: "provision"` ticket additionally administers the store through the node's gate — the basis8185 for opt-in store provisioning. A ticket with an unrecognized scope is rejected outright rather8286 than silently granted less than it claims.87+- **A provision ticket is split before anything persists.** Enrollment asks the node's scope-down88+ exchange for a derived sync ticket; that becomes the declared sink, and the provision capability89+ is only _held_ in memory. Sealing it at rest is a separate, explicit passkey ceremony90+ (`sealProvisionCapability` / `unlockProvisionCapability`), attempted rather than91+ capability-detected; on a device whose authenticator cannot evaluate the WebAuthn PRF extension,92+ nothing persists and the durable copy is the ticket in the user's password manager. The packaged93+ `TicketEnrollForm` (`@nzip/lofi/preact`) is shaped for exactly that custody — the ticket is a94+ `current-password` field a manager saves on first paste and autofills later. Against a node95+ without the exchange, the ticket enrolls as pasted, exactly as before.8396 - If the node revokes the ticket, requests fail with 401 and live sockets close; treat the stored8497 sink as dead and surface re-enrollment rather than retrying silently.8598
M docs/troubleshooting.md +8 -0
M package/astro/mod.ts +2 -0
A package/preact/TicketEnrollForm.tsx +220 -0
@@ -0,0 +1,220 @@
1+import type { VNode } from "preact";2+import { useState } from "preact/hooks";3+import { AuthError } from "../runtime/auth.ts";4+import { isDataSinkError, parseSyncTicket } from "../runtime/data-sink.ts";5+import { provisionCapabilityStatus, sealProvisionCapability } from "../runtime/provision.ts";6+import { enrollSyncTicket, type Session } from "../runtime/session.ts";7+8+/** Dependencies {@link TicketEnrollForm} accepts for testing and composition. */9+export interface TicketEnrollFormProps {10+ /** Called with the new session after a successful enrollment. */11+ readonly onEnrolled?: (session: Session) => void;12+ /** Heading rendered above the form. */13+ readonly title?: string;14+ /** Enrollment implementation; defaults to the runtime's `enrollSyncTicket`. */15+ readonly enroll?: (ticket: string) => Promise<Session>;16+ /** Sealing implementation; defaults to the runtime's `sealProvisionCapability`. */17+ readonly seal?: () => Promise<{ portable: boolean }>;18+}19+20+type Phase =21+ | { name: "edit"; problem?: string }22+ | { name: "enrolling" }23+ | { name: "enrolled"; sealOffer: boolean }24+ | { name: "sealing" }25+ | { name: "sealed"; portable: boolean }26+ | { name: "custody"; reason: "prf-unavailable" | "cancelled" };27+28+// Asks the browser's password manager to save the ticket against this origin29+// explicitly (Chromium honors the Credential Management call; other engines30+// rely on the form heuristics the markup below satisfies). Best-effort — a31+// refusal changes nothing about enrollment.32+async function offerTicketToPasswordManager(label: string, ticket: string): Promise<void> {33+ const scope = globalThis as unknown as {34+ PasswordCredential?: new (init: { id: string; password: string }) => Credential;35+ navigator?: { credentials?: { store?: (credential: Credential) => Promise<unknown> } };36+ };37+ if (!scope.PasswordCredential || !scope.navigator?.credentials?.store) return;38+ try {39+ await scope.navigator.credentials.store(40+ new scope.PasswordCredential({ id: label, password: ticket }),41+ );42+ } catch {43+ // The manager declined or the surface is unavailable; the form heuristics44+ // remain the save path.45+ }46+}47+48+/**49+ * The app-connect ticket enrollment form, shaped for password managers: the50+ * ticket is a `current-password` field with a label-as-username companion, so51+ * the manager offers to save the ticket against this origin on first paste52+ * and autofills it later. That custody is the durable copy on devices that53+ * cannot seal admin capability behind a passkey — the node stores only54+ * hashes, so the manager's copy is the one that survives.55+ *56+ * After enrolling a provision-scoped ticket whose capability was split (see57+ * `provision.ts`), the form offers the passkey-sealing ceremony, and states58+ * the password-manager fallback when the ceremony reports `prf-unavailable`59+ * or is cancelled.60+ *61+ * @example62+ * ```tsx63+ * import { TicketEnrollForm } from "@nzip/lofi/preact";64+ *65+ * <TicketEnrollForm title="Connect to your node" />;66+ * ```67+ *68+ * @param props Optional callbacks, heading, and injectable implementations.69+ * @returns The enrollment form and its follow-on custody choices.70+ */71+export function TicketEnrollForm({72+ onEnrolled,73+ title = "Connect a sync location",74+ enroll = enrollSyncTicket,75+ seal = sealProvisionCapability,76+}: TicketEnrollFormProps): VNode {77+ const [phase, setPhase] = useState<Phase>({ name: "edit" });78+ const [ticket, setTicket] = useState("");79+ const [label, setLabel] = useState("");80+ const [labelEdited, setLabelEdited] = useState(false);81+82+ function onTicketInput(value: string): void {83+ setTicket(value);84+ if (!labelEdited) {85+ const parsed = parseSyncTicket(value);86+ if (parsed?.label) setLabel(parsed.label);87+ }88+ }89+90+ async function onSubmit(event: Event): Promise<void> {91+ event.preventDefault();92+ setPhase({ name: "enrolling" });93+ try {94+ const session = await enroll(ticket);95+ await offerTicketToPasswordManager(label || "sync ticket", ticket);96+ const provision = provisionCapabilityStatus();97+ setPhase({ name: "enrolled", sealOffer: provision.held && !provision.sealed });98+ onEnrolled?.(session);99+ } catch (error) {100+ const problem = isDataSinkError(error)101+ ? error.message102+ : "Enrollment failed; check the ticket and the node, then paste it again.";103+ setPhase({ name: "edit", problem });104+ }105+ }106+107+ async function onSeal(): Promise<void> {108+ setPhase({ name: "sealing" });109+ try {110+ const { portable } = await seal();111+ setPhase({ name: "sealed", portable });112+ } catch (error) {113+ if (error instanceof AuthError && error.code === "cancelled") {114+ setPhase({ name: "custody", reason: "cancelled" });115+ return;116+ }117+ setPhase({ name: "custody", reason: "prf-unavailable" });118+ }119+ }120+121+ if (phase.name === "enrolled" && phase.sealOffer) {122+ return (123+ <section>124+ <h2>{title}</h2>125+ <p>Connected. This ticket also carries admin access to the store.</p>126+ <button type="button" onClick={() => void onSeal()}>127+ Protect admin access with your passkey128+ </button>129+ <button130+ type="button"131+ onClick={() => setPhase({ name: "enrolled", sealOffer: false })}132+ >133+ Keep it in my password manager instead134+ </button>135+ <p>136+ Either way, admin access is never stored on this device without protection; syncing137+ continues on its own.138+ </p>139+ </section>140+ );141+ }142+143+ if (phase.name === "sealed") {144+ return (145+ <section>146+ <h2>{title}</h2>147+ <p>148+ Admin access is sealed behind your passkey149+ {phase.portable150+ ? ", which syncs across your devices with your passkey provider."151+ : ", which stays on this device."} Unlocking it will ask for the passkey.152+ </p>153+ </section>154+ );155+ }156+157+ if (phase.name === "custody") {158+ return (159+ <section>160+ <h2>{title}</h2>161+ <p>162+ {phase.reason === "prf-unavailable"163+ ? "This device cannot seal admin access behind a passkey."164+ : "Passkey sealing was cancelled."}{" "}165+ Admin access stays available until this page closes; keep the ticket in your password166+ manager and paste it again when you administer the store. Syncing continues on its own.167+ </p>168+ </section>169+ );170+ }171+172+ if (phase.name === "enrolled" || phase.name === "sealing") {173+ return (174+ <section>175+ <h2>{title}</h2>176+ <p>{phase.name === "sealing" ? "Waiting for your passkey…" : "Connected and syncing."}</p>177+ </section>178+ );179+ }180+181+ return (182+ <section>183+ <h2>{title}</h2>184+ <form onSubmit={(event) => void onSubmit(event)}>185+ <label>186+ Name for this connection187+ <input188+ type="text"189+ name="username"190+ autocomplete="username"191+ value={label}192+ onInput={(event) => {193+ setLabel((event.target as HTMLInputElement).value);194+ setLabelEdited(true);195+ }}196+ />197+ </label>198+ <label>199+ App-connect ticket200+ <input201+ type="password"202+ name="ticket"203+ autocomplete="current-password"204+ required205+ value={ticket}206+ onInput={(event) => onTicketInput((event.target as HTMLInputElement).value)}207+ />208+ </label>209+ <button type="submit" disabled={phase.name === "enrolling"}>210+ {phase.name === "enrolling" ? "Connecting…" : "Connect"}211+ </button>212+ {phase.name === "edit" && phase.problem ? <p role="alert">{phase.problem}</p> : null}213+ </form>214+ <p>215+ The ticket is a bearer credential; let your password manager save it here so it can fill it216+ next time.217+ </p>218+ </section>219+ );220+}
A package/preact/TicketEnrollForm_test.tsx +31 -0
M package/preact/mod.ts +1 -0
M package/runtime/auth.ts +51 -0
@@ -376,6 +376,57 @@ export async function authenticateDeviceCredential(
376376 }377377 }378378 379+/**380+ * Authenticates and derives the PRF secret in one user-verifying ceremony:381+ * the PRF evaluation rides the assertion's extensions, so a flow that needs382+ * both the asserted credential and a credential-bound key costs one prompt,383+ * not two. Pass `credentialId` to pin the ceremony to one enrolled384+ * credential. Throws `prf-unavailable` when the client or authenticator385+ * returns no PRF result — the secret is never faked — and386+ * `credential-mismatch` when a different credential asserts than the one387+ * pinned.388+ */389+export async function authenticateAndDerivePrfSecret(390+ salt: BufferSource,391+ options: AuthenticateOptions = {},392+): Promise<{ credential: DeviceCredential; secret: Uint8Array }> {393+ const rpId = requireStableRpId(options);394+ const credentials = options.credentials ?? browserCredentials();395+ const allowCredentials: PublicKeyCredentialDescriptor[] | undefined = options.credentialId396+ ? [{ type: "public-key", id: fromBase64Url(options.credentialId) }]397+ : undefined;398+ try {399+ const credential = await credentials.get({400+ publicKey: {401+ rpId,402+ challenge: randomBytes(),403+ userVerification: "required",404+ timeout: 60_000,405+ ...(allowCredentials ? { allowCredentials } : {}),406+ extensions: { prf: { eval: { first: salt } } } as RequestExtensions,407+ },408+ });409+ const asserted = { id: toBase64Url(rawId(credential)), rpId, portable: isPortable(credential) };410+ // Verify the assertion even though `allowCredentials` was sent: the browser411+ // allow-list is a request hint, not a caller-side guarantee.412+ if (options.credentialId && asserted.id !== options.credentialId) {413+ throw new AuthError(414+ "credential-mismatch",415+ "The asserting passkey is not the enrolled credential.",416+ );417+ }418+ const results =419+ (credential as (Credential & { getClientExtensionResults?: () => { prf?: PrfResults } }))420+ .getClientExtensionResults?.().prf?.results;421+ if (!results?.first) throw new AuthError("prf-unavailable");422+ // A copy, not a view: callers zero the secret after deriving from it, and423+ // that must never reach through to the extension-result buffer.424+ return { credential: asserted, secret: new Uint8Array(results.first.slice(0)) };425+ } catch (error) {426+ throw mapError(error);427+ }428+}429+379430 // --- PRF-derived at-rest key --------------------------------------------------380431 381432 /**
M package/runtime/data-sink.ts +52 -0
@@ -358,3 +358,55 @@ export async function declareSinkFromTicket(
358358 export function isDataSinkError(error: unknown): error is DataSinkError {359359 return error instanceof DataSinkError;360360 }361+362+/**363+ * How a pasted ticket resolves for enrollment: what to declare as the sink,364+ * and the provision bearer URL to hold in memory when the capability was365+ * split off (see `provision.ts` for its custody).366+ */367+export type TicketSplit = {368+ /** The ticket to declare as this device's sink. */369+ sinkTicket: string;370+ /** The provision bearer URL when the exchange split it off, else `null`. */371+ provisionUrl: string | null;372+};373+374+/**375+ * Splits a `provision`-scoped ticket before anything persists: the node's376+ * scope-down exchange (`POST <ticket-url>/derive-sync-ticket`) mints a377+ * derived sync ticket to declare as the sink, and the provision URL is378+ * returned separately for in-memory custody. Any exchange failure — a node379+ * predating the endpoint, a network error, an unusable response — falls back380+ * to enrolling the ticket exactly as pasted. Sync-scoped and malformed381+ * tickets pass through unchanged (declaration is where malformed input382+ * rejects).383+ */384+export async function splitTicketForEnrollment(385+ ticket: string,386+ fetcher: typeof fetch = fetch,387+): Promise<TicketSplit> {388+ const parsed = parseSyncTicket(ticket);389+ if (parsed?.scope !== "provision") return { sinkTicket: ticket, provisionUrl: null };390+ let derived: string | null = null;391+ try {392+ const response = await fetcher(`${parsed.url}/derive-sync-ticket`, {393+ method: "POST",394+ headers: { "content-type": "application/json" },395+ body: JSON.stringify({}),396+ });397+ if (response.ok) {398+ const body = await response.json() as { ticket?: unknown };399+ if (typeof body.ticket === "string") derived = body.ticket;400+ }401+ } catch {402+ // Fall back to the ticket as pasted.403+ }404+ const derivedParsed = derived === null ? null : parseSyncTicket(derived);405+ if (406+ derived !== null && derivedParsed !== null && derivedParsed.appId === parsed.appId &&407+ (derivedParsed.scope ?? "sync") === "sync"408+ ) {409+ return { sinkTicket: derived, provisionUrl: parsed.url };410+ }411+ return { sinkTicket: ticket, provisionUrl: null };412+}
M package/runtime/data-sink_test.ts +97 -0
@@ -12,6 +12,7 @@ import {
1212 parseSyncTicket,1313 readDeclaredSink,1414 restoreDeclaredSink,15+ splitTicketForEnrollment,1516 } from "./data-sink.ts";1617 import { memoryDeviceKeyStore, parseSealedEnvelope } from "./envelope.ts";1718 import {
@@ -376,3 +377,99 @@ test(
376377 );377378 }),378379 );380+381+// The scope-down exchange: a provision ticket splits into a derived sync382+// ticket (declared) and the provision URL (held in memory by session.ts).383+const provisionTicket = encodeTicket({384+ v: 1,385+ appId: ticketAppId,386+ url: `http://192.168.1.10:4802/t/${ticketSecret}`,387+ scope: "provision",388+ label: "phone",389+});390+const derivedSecret = "d".repeat(43);391+const derivedTicket = encodeTicket({392+ v: 1,393+ appId: ticketAppId,394+ url: `http://192.168.1.10:4802/t/${derivedSecret}`,395+ scope: "sync",396+ label: "phone (sync)",397+});398+399+function fetcherReturning(status: number, body: unknown): typeof fetch {400+ return () => Promise.resolve(new Response(JSON.stringify(body), { status }));401+}402+403+test("a provision ticket splits into the derived sync ticket and the held URL", async () => {404+ const requests: string[] = [];405+ const fetcher: typeof fetch = (input, init) => {406+ requests.push(`${init?.method} ${String(input)}`);407+ return Promise.resolve(408+ new Response(JSON.stringify({ v: 1, id: "abc", ticket: derivedTicket }), { status: 200 }),409+ );410+ };411+ const split = await splitTicketForEnrollment(provisionTicket, fetcher);412+ assert(split.sinkTicket === derivedTicket, "the derived sync ticket must become the sink");413+ assert(414+ split.provisionUrl === `http://192.168.1.10:4802/t/${ticketSecret}`,415+ "the provision URL must be returned for in-memory custody",416+ );417+ assert(418+ requests.length === 1 &&419+ requests[0] === `POST http://192.168.1.10:4802/t/${ticketSecret}/derive-sync-ticket`,420+ `the exchange must POST the derive endpoint once, sent: ${requests.join("; ")}`,421+ );422+});423+424+test("exchange failures fall back to enrolling the ticket as pasted", async () => {425+ const fallbacks: Array<typeof fetch> = [426+ fetcherReturning(401, { error: "invalid_ticket" }),427+ fetcherReturning(404, "not found"),428+ fetcherReturning(200, { ticket: 7 }),429+ fetcherReturning(200, { v: 1, id: "abc", ticket: "lofisync1.nope" }),430+ fetcherReturning(200, {431+ v: 1,432+ id: "abc",433+ ticket: encodeTicket({434+ v: 1,435+ appId: "11111111-2222-3333-4444-555555555555",436+ url: `http://h/t/${derivedSecret}`,437+ scope: "sync",438+ }),439+ }),440+ fetcherReturning(200, {441+ v: 1,442+ id: "abc",443+ ticket: encodeTicket({444+ v: 1,445+ appId: ticketAppId,446+ url: `http://h/t/${derivedSecret}`,447+ scope: "provision",448+ }),449+ }),450+ () => Promise.reject(new Error("network down")),451+ ];452+ for (const fetcher of fallbacks) {453+ const split = await splitTicketForEnrollment(provisionTicket, fetcher);454+ assert(455+ split.sinkTicket === provisionTicket && split.provisionUrl === null,456+ "an unusable exchange must fall back to the pasted ticket",457+ );458+ }459+});460+461+test("sync-scoped and malformed tickets never touch the exchange", async () => {462+ let calls = 0;463+ const fetcher: typeof fetch = () => {464+ calls++;465+ return Promise.resolve(new Response("{}", { status: 200 }));466+ };467+ for (const ticket of [validTicket, "lofisync1.nope", "not a ticket"]) {468+ const split = await splitTicketForEnrollment(ticket, fetcher);469+ assert(470+ split.sinkTicket === ticket && split.provisionUrl === null,471+ "a non-provision paste must pass through unchanged",472+ );473+ }474+ assert(calls === 0, "the exchange endpoint must not be called for non-provision tickets");475+});
M package/runtime/envelope.ts +4 -2
M package/runtime/mod.ts +14 -0
A package/runtime/provision.ts +194 -0
@@ -0,0 +1,194 @@
1+/**2+ * Package-owned custody of the provision capability: the bearer URL of a3+ * `provision`-scoped app ticket, which administers the store through the4+ * node's gate by possession.5+ *6+ * Provision capability is deliberately custodied differently from the sync7+ * declaration (`data-sink.ts`). Sync material opens silently at boot because8+ * its theft is transport-only; provision material either persists behind a9+ * passkey-PRF slot — opening is a user-verifying ceremony — or does not10+ * persist at all. On a device whose authenticator cannot evaluate PRF, the11+ * capability is held in memory for the session and the durable copy is the12+ * ticket in the user's password manager; re-pasting it is the fallback, and13+ * the node can mint a replacement, so the sealed copy is never the only one.14+ *15+ * The PRF gate converts silent theft of the stored capability into a prompted16+ * ceremony; script running on the same origin can still request that ceremony17+ * and hope the user approves it. That residual is a property of any18+ * same-origin unlock and is documented, not hidden.19+ *20+ * @module21+ */22+23+import {24+ authenticateAndDerivePrfSecret,25+ type AuthenticateOptions,26+ deriveAtRestKey,27+} from "./auth.ts";28+import { anchorAppId } from "./data-sink.ts";29+import {30+ EnvelopeError,31+ fromBase64Url,32+ openJsonEnvelope,33+ parseSealedEnvelope,34+ type PrfSlot,35+ type SealedEnvelope,36+ sealJsonEnvelope,37+ toBase64Url,38+} from "./envelope.ts";39+40+const provisionKey = `lofi:provision:${anchorAppId}`;41+const provisionPurpose = `lofi:provision:${anchorAppId}`;42+43+// The capability in effect for this document: populated by enrollment (after44+// a scope-down exchange), by sealing, or by a successful unlock ceremony.45+let heldUrl: string | null = null;46+47+/** What provision capability exists on this device right now. */48+export type ProvisionCapabilityStatus = {49+ /** The capability is usable in this document without a ceremony. */50+ held: boolean;51+ /** A PRF-sealed record exists at rest; unlocking is a passkey ceremony. */52+ sealed: boolean;53+ /** For a sealed record, whether its passkey roams across devices. */54+ portable?: boolean;55+};56+57+function readSealedRecord(): SealedEnvelope | null {58+ if (typeof localStorage === "undefined") return null;59+ try {60+ const raw = localStorage.getItem(provisionKey);61+ if (!raw) return null;62+ const record = JSON.parse(raw) as { v?: number; sealed?: unknown } | null;63+ if (record?.v !== 1) return null;64+ return parseSealedEnvelope(record.sealed);65+ } catch {66+ return null;67+ }68+}69+70+/** Reports what provision capability exists on this device. */71+export function provisionCapabilityStatus(): ProvisionCapabilityStatus {72+ const sealed = readSealedRecord();73+ const prfSlot = sealed?.slots.find((slot): slot is PrfSlot => slot.type === "prf");74+ return {75+ held: heldUrl !== null,76+ sealed: sealed !== null,77+ ...(prfSlot?.portable !== undefined ? { portable: prfSlot.portable } : {}),78+ };79+}80+81+/**82+ * Holds the provision bearer URL in memory for this document only. This is83+ * the no-ceremony path enrollment uses after a scope-down exchange, and the84+ * whole custody story on devices that cannot seal: nothing reaches storage.85+ */86+export function holdProvisionCapability(url: string): void {87+ heldUrl = url;88+}89+90+/** The capability held in this document, or `null` (sealed-only or absent). */91+export function heldProvisionCapability(): string | null {92+ return heldUrl;93+}94+95+/**96+ * Forgets the in-memory capability while keeping any sealed record — the97+ * lock half of the unlock ceremony, for callers that drop admin capability98+ * after an operation rather than holding it for the document's lifetime.99+ */100+export function lockProvisionCapability(): void {101+ heldUrl = null;102+}103+104+/** Forgets the held capability and removes any sealed record. */105+export function clearProvisionCapability(): void {106+ heldUrl = null;107+ if (typeof localStorage === "undefined") return;108+ try {109+ localStorage.removeItem(provisionKey);110+ } catch {111+ // A private-mode storage failure leaves the sealed record in place.112+ }113+}114+115+/**116+ * Seals the held provision capability under a passkey-PRF slot in one117+ * user-verifying ceremony. The PRF evaluation is attempted, never118+ * capability-detected: on success the envelope records the credential that119+ * actually evaluated it (and whether it roams); `prf-unavailable` or120+ * `cancelled` propagate as `AuthError` so the caller can keep the capability121+ * memory-only and point the user at their password manager instead.122+ */123+export async function sealProvisionCapability(124+ options: AuthenticateOptions = {},125+): Promise<{ portable: boolean }> {126+ if (heldUrl === null) {127+ throw new EnvelopeError("locked", "no provision capability is held to seal");128+ }129+ const prfSalt = crypto.getRandomValues(new Uint8Array(32));130+ const hkdfSalt = crypto.getRandomValues(new Uint8Array(32));131+ const { credential, secret } = await authenticateAndDerivePrfSecret(132+ prfSalt as BufferSource,133+ options,134+ );135+ const key = await deriveAtRestKey(secret, `lofi-envelope:${provisionPurpose}`, hkdfSalt);136+ secret.fill(0);137+ const sealed = await sealJsonEnvelope(provisionPurpose, { url: heldUrl }, [{138+ slot: {139+ type: "prf",140+ credentialId: credential.id,141+ prfSalt: toBase64Url(prfSalt),142+ hkdfSalt: toBase64Url(hkdfSalt),143+ portable: credential.portable,144+ },145+ key,146+ }]);147+ if (typeof localStorage !== "undefined") {148+ try {149+ localStorage.setItem(provisionKey, JSON.stringify({ v: 1, sealed }));150+ } catch {151+ // A private-mode storage failure must not fail the ceremony; the152+ // capability stays held for this document and the ticket in the user's153+ // password manager remains the durable copy.154+ }155+ }156+ return { portable: credential.portable };157+}158+159+/**160+ * Unlocks the sealed provision capability through its passkey ceremony and161+ * holds it for this document. Returns the held capability directly when one162+ * is already in memory. Throws `EnvelopeError("locked")` when nothing is163+ * stored, and `AuthError` (`cancelled`, `prf-unavailable`,164+ * `credential-mismatch`) when the ceremony does not complete.165+ */166+export async function unlockProvisionCapability(167+ options: AuthenticateOptions = {},168+): Promise<string> {169+ if (heldUrl !== null) return heldUrl;170+ const sealed = readSealedRecord();171+ if (!sealed) {172+ throw new EnvelopeError("locked", "no provision capability is stored on this device");173+ }174+ const payload = await openJsonEnvelope(provisionPurpose, sealed, async (slot) => {175+ if (slot.type !== "prf") return null;176+ const { secret } = await authenticateAndDerivePrfSecret(177+ fromBase64Url(slot.prfSalt) as BufferSource,178+ { ...options, credentialId: slot.credentialId },179+ );180+ const key = await deriveAtRestKey(181+ secret,182+ `lofi-envelope:${provisionPurpose}`,183+ fromBase64Url(slot.hkdfSalt),184+ );185+ secret.fill(0);186+ return key;187+ });188+ const url = (payload as { url?: unknown } | null)?.url;189+ if (typeof url !== "string" || !url) {190+ throw new EnvelopeError("corrupt", "the sealed provision record has no capability URL");191+ }192+ heldUrl = url;193+ return url;194+}
A package/runtime/provision_test.ts +225 -0
@@ -0,0 +1,225 @@
1+// Provision-capability custody contract: memory-only hold, PRF sealing with2+// the credential that actually evaluated it, lock/unlock ceremonies, failure3+// mapping (cancelled, prf-unavailable, credential-mismatch), and the4+// no-plaintext-bearer guarantee for the sealed record.5+import { AuthError } from "./auth.ts";6+import { anchorAppId } from "./data-sink.ts";7+import { EnvelopeError, parseSealedEnvelope } from "./envelope.ts";8+import {9+ clearProvisionCapability,10+ heldProvisionCapability,11+ holdProvisionCapability,12+ lockProvisionCapability,13+ provisionCapabilityStatus,14+ sealProvisionCapability,15+ unlockProvisionCapability,16+} from "./provision.ts";17+import { defineLofiApp } from "./app.ts";18+import { assert } from "./test-assert.ts";19+20+defineLofiApp({21+ name: "lofi-test",22+ databaseName: "lofi-test",23+ schema: {},24+ storage: "durable",25+ credentialOrigins: [],26+ sync: { adapter: "jazz" },27+});28+29+const test = (globalThis as unknown as {30+ Deno: { test(name: string, body: () => void | Promise<void>): void };31+}).Deno.test;32+33+const provisionKey = `lofi:provision:${anchorAppId}`;34+const bearerUrl = `https://node.example:4802/t/${"p".repeat(43)}`;35+36+function withCleanState(body: () => void | Promise<void>): () => Promise<void> {37+ return async () => {38+ clearProvisionCapability();39+ try {40+ await body();41+ } finally {42+ clearProvisionCapability();43+ }44+ };45+}46+47+// A 37-byte authenticator-data buffer (rpIdHash[32] + flags + signCount[4]).48+// The WebAuthn "backup eligible" (BE) flag is bit 0x08 of byte index 32.49+function authenticatorData(backupEligible: boolean): ArrayBuffer {50+ const bytes = new Uint8Array(37);51+ if (backupEligible) bytes[32] |= 0x08;52+ return bytes.buffer;53+}54+55+type PrfEval = { eval?: { first?: BufferSource } };56+57+// A fake PRF authenticator: one credential, deterministic PRF output per eval58+// salt (as a real authenticator computes it), user-verifying get() only.59+function fakePrfAuthenticator(options: {60+ rawId?: Uint8Array;61+ portable?: boolean;62+ prf?: boolean;63+ cancel?: boolean;64+} = {}): CredentialsContainer {65+ const rawId = options.rawId ?? new Uint8Array([1, 2, 3, 4]);66+ const outputs = new Map<string, Uint8Array>();67+ return {68+ create: () => Promise.reject(new Error("unused")),69+ get: (request: CredentialRequestOptions) => {70+ if (options.cancel) {71+ return Promise.reject(Object.assign(new Error("cancelled"), { name: "NotAllowedError" }));72+ }73+ const extensions = (request.publicKey?.extensions ?? {}) as { prf?: PrfEval };74+ const salt = extensions.prf?.eval?.first;75+ let first: ArrayBuffer | undefined;76+ if (salt && options.prf !== false) {77+ const key = Array.from(new Uint8Array(salt as ArrayBuffer)).join(",");78+ if (!outputs.has(key)) {79+ outputs.set(key, crypto.getRandomValues(new Uint8Array(32)));80+ }81+ // A fresh buffer per ceremony, as a real authenticator re-derives it.82+ first = (outputs.get(key) as Uint8Array).slice().buffer as ArrayBuffer;83+ }84+ return Promise.resolve({85+ rawId: rawId.buffer,86+ response: { authenticatorData: authenticatorData(options.portable ?? true) },87+ getClientExtensionResults: () => (first ? { prf: { results: { first } } } : {}),88+ } as unknown as Credential);89+ },90+ store: () => Promise.reject(new Error("unused")),91+ preventSilentAccess: () => Promise.resolve(),92+ } as unknown as CredentialsContainer;93+}94+95+const rpId = "app.example.com";96+97+test(98+ "holding is memory-only: usable now, nothing at rest, gone on clear",99+ withCleanState(() => {100+ assert(heldProvisionCapability() === null, "a clean device holds nothing");101+ holdProvisionCapability(bearerUrl);102+ assert(heldProvisionCapability() === bearerUrl, "the held URL must read back");103+ const status = provisionCapabilityStatus();104+ assert(status.held && !status.sealed, "holding must not create a sealed record");105+ assert(localStorage.getItem(provisionKey) === null, "holding must write nothing to storage");106+ clearProvisionCapability();107+ assert(heldProvisionCapability() === null, "clearing must forget the capability");108+ }),109+);110+111+test(112+ "sealing records the asserting credential and keeps the bearer out of storage",113+ withCleanState(async () => {114+ holdProvisionCapability(bearerUrl);115+ const credentials = fakePrfAuthenticator({ portable: true });116+ const sealed = await sealProvisionCapability({ credentials, rpId });117+ assert(sealed.portable, "the portable flag must come from the asserting credential");118+ const status = provisionCapabilityStatus();119+ assert(120+ status.held && status.sealed && status.portable === true,121+ "status must reflect the seal",122+ );123+ const raw = localStorage.getItem(provisionKey) ?? "";124+ assert(!raw.includes(bearerUrl), "the bearer URL must not reach storage in cleartext");125+ assert(!raw.includes("p".repeat(43)), "the bearer secret must not reach storage in cleartext");126+ assert(!raw.includes(btoa(bearerUrl)), "nor in a trivial base64 encoding");127+ const record = JSON.parse(raw) as { v: number; sealed: unknown };128+ assert(129+ record.v === 1 && parseSealedEnvelope(record.sealed) !== null,130+ "the record at rest must be a sealed envelope",131+ );132+ }),133+);134+135+test(136+ "lock forgets memory, the ceremony unlocks, and the same salt re-derives",137+ withCleanState(async () => {138+ holdProvisionCapability(bearerUrl);139+ const credentials = fakePrfAuthenticator({ portable: false });140+ await sealProvisionCapability({ credentials, rpId });141+ lockProvisionCapability();142+ assert(heldProvisionCapability() === null, "locking must forget the in-memory capability");143+ assert(provisionCapabilityStatus().sealed, "locking must keep the sealed record");144+ const unlocked = await unlockProvisionCapability({ credentials, rpId });145+ assert(unlocked === bearerUrl, "the ceremony must return the exact bearer URL");146+ assert(heldProvisionCapability() === bearerUrl, "an unlock must hold the capability again");147+ }),148+);149+150+test(151+ "a different passkey cannot unlock the sealed record",152+ withCleanState(async () => {153+ holdProvisionCapability(bearerUrl);154+ await sealProvisionCapability({ credentials: fakePrfAuthenticator(), rpId });155+ lockProvisionCapability();156+ const impostor = fakePrfAuthenticator({ rawId: new Uint8Array([9, 9, 9, 9]) });157+ try {158+ await unlockProvisionCapability({ credentials: impostor, rpId });159+ throw new Error("an impostor credential must not unlock the record");160+ } catch (error) {161+ assert(162+ error instanceof AuthError && error.code === "credential-mismatch",163+ `expected credential-mismatch, received ${String(error)}`,164+ );165+ }166+ assert(provisionCapabilityStatus().sealed, "the sealed record must survive the attempt");167+ }),168+);169+170+test(171+ "cancelled and prf-unavailable ceremonies leave state intact",172+ withCleanState(async () => {173+ holdProvisionCapability(bearerUrl);174+ try {175+ await sealProvisionCapability({ credentials: fakePrfAuthenticator({ prf: false }), rpId });176+ throw new Error("a no-PRF authenticator must not seal");177+ } catch (error) {178+ assert(179+ error instanceof AuthError && error.code === "prf-unavailable",180+ `expected prf-unavailable, received ${String(error)}`,181+ );182+ }183+ assert(localStorage.getItem(provisionKey) === null, "a failed seal must store nothing");184+ assert(heldProvisionCapability() === bearerUrl, "the capability must stay held");185+186+ await sealProvisionCapability({ credentials: fakePrfAuthenticator(), rpId });187+ lockProvisionCapability();188+ try {189+ await unlockProvisionCapability({190+ credentials: fakePrfAuthenticator({ cancel: true }),191+ rpId,192+ });193+ throw new Error("a cancelled ceremony must not unlock");194+ } catch (error) {195+ assert(196+ error instanceof AuthError && error.code === "cancelled",197+ `expected cancelled, received ${String(error)}`,198+ );199+ }200+ assert(heldProvisionCapability() === null, "a cancelled unlock must not hold anything");201+ assert(provisionCapabilityStatus().sealed, "the sealed record must survive a cancellation");202+ }),203+);204+205+test(206+ "sealing without a held capability and unlocking without a record report locked",207+ withCleanState(async () => {208+ for (209+ const attempt of [210+ () => sealProvisionCapability({ credentials: fakePrfAuthenticator(), rpId }),211+ () => unlockProvisionCapability({ credentials: fakePrfAuthenticator(), rpId }),212+ ]213+ ) {214+ try {215+ await attempt();216+ throw new Error("an empty device must not seal or unlock");217+ } catch (error) {218+ assert(219+ error instanceof EnvelopeError && error.code === "locked",220+ `expected locked, received ${String(error)}`,221+ );222+ }223+ }224+ }),225+);
M package/runtime/session.ts +22 -3
@@ -27,7 +27,8 @@ import {
2727 syncElected,2828 syncing,2929 } from "./config.ts";30-import { declareSinkFromTicket } from "./data-sink.ts";30+import { declareSinkFromTicket, splitTicketForEnrollment } from "./data-sink.ts";31+import { holdProvisionCapability } from "./provision.ts";3132 import { fromRecoveryPhrase, RecoveryError, toRecoveryPhrase } from "./recovery.ts";3233 import { authenticateDeviceCredential, AuthError, enrollDeviceCredential } from "./auth.ts";3334 import {
@@ -296,6 +297,12 @@ export async function enableSyncBackup(): Promise<Session> {
296297 return readSession();297298 }298299 300+/** Options for {@link enrollSyncTicket}. */301+export type EnrollSyncTicketOptions = {302+ /** Fetch implementation for the scope-down exchange (tests inject one). */303+ fetcher?: typeof fetch;304+};305+299306 /**300307 * Enrolls a `lofisync1.` app-connect ticket as this device's sync location and301308 * elects to back up and sync in one step: the ticket's URL becomes the sync
@@ -303,9 +310,21 @@ export async function enableSyncBackup(): Promise<Session> {
303310 * reflects the declared sink. Throws `DataSinkError` (see `data-sink.ts`) for304311 * malformed tickets, ws URLs, an app id that contradicts a compiled-in managed305312 * app, or a different sink already declared on this device.313+ *314+ * A `provision`-scoped ticket is split before anything persists: the node's315+ * scope-down exchange mints a derived sync ticket, that becomes the declared316+ * sink, and the provision capability is only *held* in memory (see317+ * `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.306320 */307-export async function enrollSyncTicket(ticket: string): Promise<Session> {308- await declareSinkFromTicket(ticket);321+export async function enrollSyncTicket(322+ ticket: string,323+ options: EnrollSyncTicketOptions = {},324+): Promise<Session> {325+ const split = await splitTicketForEnrollment(ticket, options.fetcher);326+ await declareSinkFromTicket(split.sinkTicket);327+ if (split.provisionUrl !== null) holdProvisionCapability(split.provisionUrl);309328 return await enableSyncBackup();310329 }311330