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).
- commit
- 6286d53b53d29805ce06702a10effb617fd71d6d
- parent
- 799188a471466fd2176f5beac9205a644b65bacd
- author
- Dami <dami.etoile@gmail.com>
| M | package/ | 2 | + |
| A | package/ | 220 | +++++++++++++++++++++++++++++ |
| A | package/ | 31 | ++++ |
| M | package/ | 1 | + |
| M | package/ | 51 | +++++++ |
| M | package/ | 52 | +++++++ |
| M | package/ | 97 | +++++++++++++ |
| M | package/ | 6 | +- |
| M | package/ | 14 | ++ |
| A | package/ | 194 | ++++++++++++++++++++++++++ |
| A | package/ | 225 | ++++++++++++++++++++++++++++++ |
| M | package/ | 25 | +++- |
@@ -41,6 +41,7 @@ export const runtimeFiles = [
4141 "namespace-state.ts",4242 "passkey-recovery.ts",4343 "probe.ts",44+ "provision.ts",4445 "pwa.ts",4546 "recovery.ts",4647 "resource-lifecycle.ts",@@ -77,6 +78,7 @@ export const preactFiles = [
7778 "mod.ts",7879 "PwaActions.tsx",7980 "RuntimeRecovery.tsx",81+ "TicketEnrollForm.tsx",8082 "use-device-capabilities.ts",8183 ] as const;8284 @@ -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+}@@ -0,0 +1,31 @@
1+import { render } from "npm:preact-render-to-string@6.7.0";2+import { TicketEnrollForm } from "./TicketEnrollForm.tsx";3+4+// The form's manager-facing semantics are the contract: password managers key5+// on a real form with a current-password field, a username companion, and a6+// genuine submit button. These assertions pin exactly the markup those7+// heuristics need.8+Deno.test("TicketEnrollForm renders password-manager-compatible form semantics", () => {9+ const html = render(<TicketEnrollForm />);10+ for (11+ const needle of [12+ "<form",13+ 'type="password"',14+ 'autocomplete="current-password"',15+ 'autocomplete="username"',16+ 'type="submit"',17+ ]18+ ) {19+ if (!html.includes(needle)) throw new Error(`manager-facing markup omitted: ${needle}`);20+ }21+});22+23+Deno.test("TicketEnrollForm states the custody story without overclaiming", () => {24+ const html = render(<TicketEnrollForm />);25+ if (!html.includes("bearer credential")) {26+ throw new Error("the ticket's bearer nature was hidden");27+ }28+ if (!html.includes("password manager")) {29+ throw new Error("the manager custody guidance was omitted");30+ }31+});@@ -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 /**@@ -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+}@@ -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+});@@ -100,13 +100,15 @@ export class EnvelopeError extends Error {
100100 }101101 }102102 103-function toBase64Url(bytes: Uint8Array): string {103+/** Encodes bytes as unpadded base64url, the envelope's storage encoding. */104+export function toBase64Url(bytes: Uint8Array): string {104105 let binary = "";105106 for (const byte of bytes) binary += String.fromCharCode(byte);106107 return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");107108 }108109 109-function fromBase64Url(text: string): Uint8Array {110+/** Decodes the envelope's unpadded base64url storage encoding. */111+export function fromBase64Url(text: string): Uint8Array {110112 const base64 = text.replaceAll("-", "+").replaceAll("_", "/");111113 const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);112114 const binary = atob(padded);@@ -16,6 +16,7 @@ export { defineLofiApp, type LofiAppConfig, LofiConfigurationError } from "./app
1616 export {1717 type AuthCapability,1818 type AuthDependencies,19+ authenticateAndDerivePrfSecret,1920 authenticateDeviceCredential,2021 AuthError,2122 classifyCredentialOrigin,@@ -31,6 +32,16 @@ export {
3132 type PrfSupport,3233 } from "./auth.ts";3334 export { bootLofi } from "./boot.ts";35+export {36+ clearProvisionCapability,37+ heldProvisionCapability,38+ holdProvisionCapability,39+ lockProvisionCapability,40+ type ProvisionCapabilityStatus,41+ provisionCapabilityStatus,42+ sealProvisionCapability,43+ unlockProvisionCapability,44+} from "./provision.ts";3445 export {3546 assertDurableBrowser,3647 type DeviceCapabilityReport,@@ -97,6 +108,7 @@ export {
97108 createRecoverablePasskeyBackup,98109 enableSyncBackup,99110 enrollSyncTicket,111+ type EnrollSyncTicketOptions,100112 isAccountReplacementError,101113 isAuthError,102114 isRecoverablePasskeyError,@@ -121,7 +133,9 @@ export {
121133 readDeclaredSink,122134 restoreDeclaredSink,123135 type SinkRestoreOutcome,136+ splitTicketForEnrollment,124137 type SyncTicket,138+ type TicketSplit,125139 } from "./data-sink.ts";126140 export { RecoveryError } from "./recovery.ts";127141 export { RecoverablePasskeyError, type RecoverablePasskeyErrorCode } from "./passkey-recovery.ts";@@ -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+}@@ -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+);@@ -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