clone

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

feat/prf-provision-custody
lofi / package / runtime / envelope.ts
envelope.ts 15.9K
1/**2 * Package-owned at-rest envelope for secret-bearing runtime state.3 *4 * A random 32-byte data-encryption key (DEK) encrypts the payload once, and5 * the DEK is wrapped under one or more protector slots, so protectors can be6 * added or retried without re-encrypting the payload. Opening tries slots in7 * order and falls through on a protector that is unavailable, which is what8 * lets a passkey-PRF slot be attempted optimistically alongside a silent9 * device-key slot. The slot table and the envelope's purpose are bound to the10 * payload as authenticated data, so slots cannot be substituted after seal.11 *12 * Slot types and what they defend against:13 * - `device-key`: a non-extractable AES key kept in the device key store14 * (IndexedDB in browsers). Opens with zero ceremony. Defends data at rest —15 * disk images, backups, storage exfiltration — but not same-origin script,16 * which can drive the silent open itself.17 * - `prf`: a key derived from a passkey's PRF output; opening is a18 * user-verifying ceremony. Adds the interactive gate the device key cannot.19 *20 * @module21 */2223const textEncoder = new TextEncoder();24const textDecoder = new TextDecoder();2526/** A DEK wrap under a silent, device-bound key from the {@link DeviceKeyStore}. */27export type DeviceKeySlot = {28 /** Discriminates the protector kind. */29 type: "device-key";30 /** Names the wrapping key in the device key store. */31 keyId: string;32 /** Base64url AES-GCM IV of the DEK wrap. */33 iv: string;34 /** Base64url AES-GCM ciphertext of the DEK. */35 wrappedDek: string;36};3738/** A DEK wrap under a key derived from a passkey's PRF output. */39export type PrfSlot = {40 /** Discriminates the protector kind. */41 type: "prf";42 /** Base64url credential id of the passkey that evaluated the PRF. */43 credentialId: string;44 /** Base64url PRF evaluation input; fixed per slot so the output is stable. */45 prfSalt: string;46 /** Base64url HKDF salt turning the PRF output into the wrapping key. */47 hkdfSalt: string;48 /** Whether the credential syncs across devices (affects where this slot opens). */49 portable?: boolean;50 /** Base64url AES-GCM IV of the DEK wrap. */51 iv: string;52 /** Base64url AES-GCM ciphertext of the DEK. */53 wrappedDek: string;54};5556/** One protector's wrap of the envelope DEK. */57export type EnvelopeSlot = DeviceKeySlot | PrfSlot;5859/** Slot metadata a caller supplies at seal time; wrap fields are computed. */60export type SlotDescriptor =61 | Omit<DeviceKeySlot, "iv" | "wrappedDek">62 | Omit<PrfSlot, "iv" | "wrappedDek">;6364/** A protector at seal time: the slot metadata plus its wrapping key. */65export type EnvelopeProtector = {66 /** Persisted slot metadata identifying how to re-derive the wrapping key. */67 slot: SlotDescriptor;68 /** AES-GCM key that wraps the DEK for this slot. */69 key: CryptoKey;70};7172/** The persisted envelope: versioned, purpose-bound, self-describing. */73export type SealedEnvelope = {74 /** Envelope format version. */75 v: 1;76 /** Domain-separation string; opening under a different purpose fails. */77 purpose: string;78 /** DEK wraps, tried in order at open time. */79 slots: EnvelopeSlot[];80 /** Base64url AES-GCM IV of the payload encryption. */81 iv: string;82 /** Base64url AES-GCM ciphertext of the payload. */83 ciphertext: string;84};8586/** Raised when an envelope cannot be opened or is not a valid envelope. */87export class EnvelopeError extends Error {88 /** Stable error class name for diagnostics and error boundaries. */89 override readonly name = "EnvelopeError";90 /**91 * `locked` — no supplied protector could unwrap the DEK (the data is intact92 * but this context lacks a key). `corrupt` — the envelope failed93 * authentication or validation (tampered, truncated, or wrong purpose).94 */95 readonly code: "locked" | "corrupt";96 /** Creates an envelope failure with a stable category. */97 constructor(code: "locked" | "corrupt", message: string) {98 super(message);99 this.code = code;100 }101}102103/** Encodes bytes as unpadded base64url, the envelope's storage encoding. */104export function toBase64Url(bytes: Uint8Array): string {105 let binary = "";106 for (const byte of bytes) binary += String.fromCharCode(byte);107 return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");108}109110/** Decodes the envelope's unpadded base64url storage encoding. */111export function fromBase64Url(text: string): Uint8Array {112 const base64 = text.replaceAll("-", "+").replaceAll("_", "/");113 const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);114 const binary = atob(padded);115 const bytes = new Uint8Array(binary.length);116 for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);117 return bytes;118}119120// Key-order-independent serialization so the authenticated data recomputed at121// open time matches the seal regardless of how the stored JSON round-tripped.122function canonicalJson(value: unknown): string {123 if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;124 if (value !== null && typeof value === "object") {125 const entries = Object.entries(value as Record<string, unknown>)126 .filter(([, entryValue]) => entryValue !== undefined)127 .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));128 return `{${129 entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${canonicalJson(entryValue)}`)130 .join(",")131 }}`;132 }133 return JSON.stringify(value);134}135136function payloadAad(purpose: string, slots: EnvelopeSlot[]): Uint8Array {137 return textEncoder.encode(canonicalJson({ v: 1, purpose, slots }));138}139140function slotAad(purpose: string, type: EnvelopeSlot["type"]): Uint8Array {141 return textEncoder.encode(`lofi-envelope/1:${purpose}:slot:${type}`);142}143144function randomBytes(length: number): Uint8Array {145 return crypto.getRandomValues(new Uint8Array(length));146}147148async function importDek(raw: Uint8Array): Promise<CryptoKey> {149 return await crypto.subtle.importKey("raw", raw as BufferSource, "AES-GCM", false, [150 "encrypt",151 "decrypt",152 ]);153}154155/**156 * Seals `payload` under every supplied protector. At least one protector is157 * required — an envelope nobody can open is a bug, not a security posture.158 */159export async function sealEnvelope(160 purpose: string,161 payload: Uint8Array,162 protectors: readonly EnvelopeProtector[],163): Promise<SealedEnvelope> {164 if (protectors.length === 0) {165 throw new EnvelopeError("corrupt", "sealing needs at least one protector");166 }167 const dekRaw = randomBytes(32);168 const slots: EnvelopeSlot[] = [];169 for (const protector of protectors) {170 const iv = randomBytes(12);171 const wrapped = await crypto.subtle.encrypt(172 {173 name: "AES-GCM",174 iv: iv as BufferSource,175 additionalData: slotAad(purpose, protector.slot.type) as BufferSource,176 },177 protector.key,178 dekRaw as BufferSource,179 );180 slots.push({181 ...protector.slot,182 iv: toBase64Url(iv),183 wrappedDek: toBase64Url(new Uint8Array(wrapped)),184 } as EnvelopeSlot);185 }186 const dek = await importDek(dekRaw);187 dekRaw.fill(0);188 const iv = randomBytes(12);189 const ciphertext = await crypto.subtle.encrypt(190 {191 name: "AES-GCM",192 iv: iv as BufferSource,193 additionalData: payloadAad(purpose, slots) as BufferSource,194 },195 dek,196 payload as BufferSource,197 );198 return {199 v: 1,200 purpose,201 slots,202 iv: toBase64Url(iv),203 ciphertext: toBase64Url(new Uint8Array(ciphertext)),204 };205}206207/**208 * Opens an envelope by trying each slot in order. `resolveKey` returns the209 * wrapping key for a slot, or `null` when that protector is unavailable in210 * this context (missing device key, PRF declined) — the next slot is tried.211 * Throws `locked` when no slot opens and `corrupt` for a tampered or212 * wrong-purpose envelope.213 */214export async function openEnvelope(215 purpose: string,216 envelope: SealedEnvelope,217 resolveKey: (slot: EnvelopeSlot) => Promise<CryptoKey | null>,218): Promise<Uint8Array> {219 if (envelope.v !== 1) {220 throw new EnvelopeError("corrupt", `unsupported envelope version ${envelope.v}`);221 }222 if (envelope.purpose !== purpose) {223 throw new EnvelopeError(224 "corrupt",225 `envelope purpose "${envelope.purpose}" does not match "${purpose}"`,226 );227 }228 for (const slot of envelope.slots) {229 const key = await resolveKey(slot);230 if (!key) continue;231 let dekRaw: Uint8Array;232 try {233 dekRaw = new Uint8Array(234 await crypto.subtle.decrypt(235 {236 name: "AES-GCM",237 iv: fromBase64Url(slot.iv) as BufferSource,238 additionalData: slotAad(purpose, slot.type) as BufferSource,239 },240 key,241 fromBase64Url(slot.wrappedDek) as BufferSource,242 ),243 );244 } catch {245 // A wrap that does not authenticate under this key is treated as an246 // unavailable protector (a rotated or foreign key), not fatal.247 continue;248 }249 const dek = await importDek(dekRaw);250 dekRaw.fill(0);251 try {252 return new Uint8Array(253 await crypto.subtle.decrypt(254 {255 name: "AES-GCM",256 iv: fromBase64Url(envelope.iv) as BufferSource,257 additionalData: payloadAad(purpose, envelope.slots) as BufferSource,258 },259 dek,260 fromBase64Url(envelope.ciphertext) as BufferSource,261 ),262 );263 } catch {264 // The slot authenticated its DEK but the payload does not authenticate:265 // the envelope body was tampered with or truncated.266 throw new EnvelopeError("corrupt", "envelope payload failed authentication");267 }268 }269 throw new EnvelopeError("locked", "no available protector opens this envelope");270}271272/** Seals a UTF-8 JSON payload; see {@link sealEnvelope}. */273export async function sealJsonEnvelope(274 purpose: string,275 payload: unknown,276 protectors: readonly EnvelopeProtector[],277): Promise<SealedEnvelope> {278 return await sealEnvelope(purpose, textEncoder.encode(JSON.stringify(payload)), protectors);279}280281/** Opens a UTF-8 JSON payload; see {@link openEnvelope}. */282export async function openJsonEnvelope(283 purpose: string,284 envelope: SealedEnvelope,285 resolveKey: (slot: EnvelopeSlot) => Promise<CryptoKey | null>,286): Promise<unknown> {287 const payload = await openEnvelope(purpose, envelope, resolveKey);288 try {289 return JSON.parse(textDecoder.decode(payload));290 } catch {291 throw new EnvelopeError("corrupt", "envelope payload is not JSON");292 }293}294295/** Validates an untrusted parsed value as a v1 envelope, or returns `null`. */296export function parseSealedEnvelope(value: unknown): SealedEnvelope | null {297 if (value === null || typeof value !== "object") return null;298 const record = value as Partial<SealedEnvelope>;299 if (300 record.v !== 1 || typeof record.purpose !== "string" ||301 typeof record.iv !== "string" || typeof record.ciphertext !== "string" ||302 !Array.isArray(record.slots)303 ) {304 return null;305 }306 for (const slot of record.slots) {307 if (slot === null || typeof slot !== "object") return null;308 const candidate = slot as Partial<EnvelopeSlot>;309 if (typeof candidate.iv !== "string" || typeof candidate.wrappedDek !== "string") return null;310 if (candidate.type === "device-key") {311 if (typeof (candidate as Partial<DeviceKeySlot>).keyId !== "string") return null;312 } else if (candidate.type === "prf") {313 const prf = candidate as Partial<PrfSlot>;314 if (315 typeof prf.credentialId !== "string" || typeof prf.prfSalt !== "string" ||316 typeof prf.hkdfSalt !== "string"317 ) {318 return null;319 }320 } else {321 return null;322 }323 }324 return value as SealedEnvelope;325}326327/**328 * Holds the non-extractable device-bound wrapping keys for `device-key`329 * slots. Browsers back this with IndexedDB; non-browser runtimes and tests330 * get an in-memory store.331 */332export interface DeviceKeyStore {333 /** Returns the wrapping key for `keyId`, creating it when absent. */334 getOrCreate(keyId: string): Promise<CryptoKey>;335 /** Returns the wrapping key for `keyId`, or `null` when the store lost it. */336 get(keyId: string): Promise<CryptoKey | null>;337}338339function generateDeviceKey(): Promise<CryptoKey> {340 // Non-extractable: same-origin script can use the key while the page runs,341 // but cannot export it — the at-rest copy in IndexedDB is all there is.342 return crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, [343 "encrypt",344 "decrypt",345 ]);346}347348/** Creates an in-memory device key store (non-browser runtimes and tests). */349export function memoryDeviceKeyStore(): DeviceKeyStore {350 const keys = new Map<string, CryptoKey>();351 return {352 async getOrCreate(keyId) {353 const existing = keys.get(keyId);354 if (existing) return existing;355 const created = await generateDeviceKey();356 keys.set(keyId, created);357 return created;358 },359 get(keyId) {360 return Promise.resolve(keys.get(keyId) ?? null);361 },362 };363}364365const deviceKeyDbName = "lofi-device-keys";366const deviceKeyStoreName = "keys";367368function openDeviceKeyDb(): Promise<IDBDatabase> {369 return new Promise((resolve, reject) => {370 const request = indexedDB.open(deviceKeyDbName, 1);371 request.onupgradeneeded = () => {372 if (!request.result.objectStoreNames.contains(deviceKeyStoreName)) {373 request.result.createObjectStore(deviceKeyStoreName);374 }375 };376 request.onsuccess = () => resolve(request.result);377 request.onerror = () => reject(request.error ?? new Error("device key store failed to open"));378 });379}380381function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {382 return new Promise((resolve, reject) => {383 request.onsuccess = () => resolve(request.result);384 request.onerror = () => reject(request.error ?? new Error("device key store request failed"));385 });386}387388/** Creates the browser device key store backed by IndexedDB. */389export function indexedDbDeviceKeyStore(): DeviceKeyStore {390 async function read(keyId: string): Promise<CryptoKey | null> {391 const db = await openDeviceKeyDb();392 try {393 const stored = await requestToPromise<CryptoKey | undefined>(394 db.transaction(deviceKeyStoreName, "readonly").objectStore(deviceKeyStoreName).get(keyId),395 );396 return stored ?? null;397 } finally {398 db.close();399 }400 }401 async function write(keyId: string, key: CryptoKey): Promise<void> {402 const db = await openDeviceKeyDb();403 try {404 await requestToPromise(405 db.transaction(deviceKeyStoreName, "readwrite").objectStore(deviceKeyStoreName).put(406 key,407 keyId,408 ),409 );410 } finally {411 db.close();412 }413 }414 return {415 async getOrCreate(keyId) {416 const existing = await read(keyId);417 if (existing) return existing;418 const created = await generateDeviceKey();419 await write(keyId, created);420 // Re-read so two racing tabs converge on whichever write landed last;421 // both copies wrap the same envelope generation only after reseal, so422 // the loser's envelope stays openable via the winner's key.423 return (await read(keyId)) ?? created;424 },425 get(keyId) {426 return read(keyId);427 },428 };429}430431let sharedDefaultStore: DeviceKeyStore | null = null;432433/**434 * The device key store for this runtime: IndexedDB in browsers, a435 * process-lifetime in-memory store elsewhere. One shared instance, so436 * sealing and opening in the same session agree on keys.437 */438export function defaultDeviceKeyStore(): DeviceKeyStore {439 sharedDefaultStore ??= typeof indexedDB === "undefined"440 ? memoryDeviceKeyStore()441 : indexedDbDeviceKeyStore();442 return sharedDefaultStore;443}444445/** Builds the seal-time protector for a `device-key` slot. */446export async function deviceKeyProtector(447 store: DeviceKeyStore,448 keyId: string,449): Promise<EnvelopeProtector> {450 return { slot: { type: "device-key", keyId }, key: await store.getOrCreate(keyId) };451}452453/** Builds an open-time resolver that answers only `device-key` slots. */454export function deviceKeyResolver(455 store: DeviceKeyStore,456): (slot: EnvelopeSlot) => Promise<CryptoKey | null> {457 return (slot) => (slot.type === "device-key" ? store.get(slot.keyId) : Promise.resolve(null));458}