clone

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

feat/fix-sync-enrollment-startup
lofi / package / runtime / provision.ts
provision.ts 7.3K
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/** What a completed sealing ceremony reports about the sealed record. */116export type SealOutcome = {117 /**118 * Whether the sealing passkey roams across the user's devices (a119 * password-manager or platform-synced credential) rather than being bound120 * to this one device — and with it, where the sealed record can unlock.121 */122 portable: boolean;123};124125/**126 * Seals the held provision capability under a passkey-PRF slot in one127 * user-verifying ceremony. The PRF evaluation is attempted, never128 * capability-detected: on success the envelope records the credential that129 * actually evaluated it (and whether it roams); `prf-unavailable` or130 * `cancelled` propagate as `AuthError` so the caller can keep the capability131 * memory-only and point the user at their password manager instead.132 */133export async function sealProvisionCapability(134 options: AuthenticateOptions = {},135): Promise<SealOutcome> {136 if (heldUrl === null) {137 throw new EnvelopeError("locked", "no provision capability is held to seal");138 }139 const prfSalt = crypto.getRandomValues(new Uint8Array(32));140 const hkdfSalt = crypto.getRandomValues(new Uint8Array(32));141 const { credential, secret } = await authenticateAndDerivePrfSecret(142 prfSalt as BufferSource,143 options,144 );145 const key = await deriveAtRestKey(secret, `lofi-envelope:${provisionPurpose}`, hkdfSalt);146 secret.fill(0);147 const sealed = await sealJsonEnvelope(provisionPurpose, { url: heldUrl }, [{148 slot: {149 type: "prf",150 credentialId: credential.id,151 prfSalt: toBase64Url(prfSalt),152 hkdfSalt: toBase64Url(hkdfSalt),153 portable: credential.portable,154 },155 key,156 }]);157 if (typeof localStorage !== "undefined") {158 try {159 localStorage.setItem(provisionKey, JSON.stringify({ v: 1, sealed }));160 } catch {161 // A private-mode storage failure must not fail the ceremony; the162 // capability stays held for this document and the ticket in the user's163 // password manager remains the durable copy.164 }165 }166 return { portable: credential.portable };167}168169/**170 * Unlocks the sealed provision capability through its passkey ceremony and171 * holds it for this document. Returns the held capability directly when one172 * is already in memory. Throws `EnvelopeError("locked")` when nothing is173 * stored, and `AuthError` (`cancelled`, `prf-unavailable`,174 * `credential-mismatch`) when the ceremony does not complete.175 */176export async function unlockProvisionCapability(177 options: AuthenticateOptions = {},178): Promise<string> {179 if (heldUrl !== null) return heldUrl;180 const sealed = readSealedRecord();181 if (!sealed) {182 throw new EnvelopeError("locked", "no provision capability is stored on this device");183 }184 const payload = await openJsonEnvelope(provisionPurpose, sealed, async (slot) => {185 if (slot.type !== "prf") return null;186 const { secret } = await authenticateAndDerivePrfSecret(187 fromBase64Url(slot.prfSalt) as BufferSource,188 { ...options, credentialId: slot.credentialId },189 );190 const key = await deriveAtRestKey(191 secret,192 `lofi-envelope:${provisionPurpose}`,193 fromBase64Url(slot.hkdfSalt),194 );195 secret.fill(0);196 return key;197 });198 const url = (payload as { url?: unknown } | null)?.url;199 if (typeof url !== "string" || !url) {200 throw new EnvelopeError("corrupt", "the sealed provision record has no capability URL");201 }202 heldUrl = url;203 return url;204}