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 */2223import {24 authenticateAndDerivePrfSecret,25 type AuthenticateOptions,26 deriveAtRestKey,27} from "./auth.ts";28import { anchorAppId } from "./data-sink.ts";29import {30 EnvelopeError,31 fromBase64Url,32 openJsonEnvelope,33 parseSealedEnvelope,34 type PrfSlot,35 type SealedEnvelope,36 sealJsonEnvelope,37 toBase64Url,38} from "./envelope.ts";3940const provisionKey = `lofi:provision:${anchorAppId}`;41const provisionPurpose = `lofi:provision:${anchorAppId}`;4243// The capability in effect for this document: populated by enrollment (after44// a scope-down exchange), by sealing, or by a successful unlock ceremony.45let heldUrl: string | null = null;4647/** What provision capability exists on this device right now. */48export 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};5657function 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}6970/** Reports what provision capability exists on this device. */71export 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}8081/**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 */86export function holdProvisionCapability(url: string): void {87 heldUrl = url;88}8990/** The capability held in this document, or `null` (sealed-only or absent). */91export function heldProvisionCapability(): string | null {92 return heldUrl;93}9495/**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 */100export function lockProvisionCapability(): void {101 heldUrl = null;102}103104/** Forgets the held capability and removes any sealed record. */105export 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}114115/**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 */123export 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}158159/**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 */166export 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}