clone

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

fix/175-template-quality-pass
lofi / package / runtime / envelope.ts
envelope.ts 16.1K
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/**104 * True when an error is an envelope failure: `locked` (no available protector105 * opens the record) or `corrupt` (the record failed authentication).106 */107export function isEnvelopeError(error: unknown): error is EnvelopeError {108 return error instanceof EnvelopeError;109}110111/** Encodes bytes as unpadded base64url, the envelope's storage encoding. */112export function toBase64Url(bytes: Uint8Array): string {113 let binary = "";114 for (const byte of bytes) binary += String.fromCharCode(byte);115 return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");116}117118/** Decodes the envelope's unpadded base64url storage encoding. */119export function fromBase64Url(text: string): Uint8Array {120 const base64 = text.replaceAll("-", "+").replaceAll("_", "/");121 const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);122 const binary = atob(padded);123 const bytes = new Uint8Array(binary.length);124 for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);125 return bytes;126}127128// Key-order-independent serialization so the authenticated data recomputed at129// open time matches the seal regardless of how the stored JSON round-tripped.130function canonicalJson(value: unknown): string {131 if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;132 if (value !== null && typeof value === "object") {133 const entries = Object.entries(value as Record<string, unknown>)134 .filter(([, entryValue]) => entryValue !== undefined)135 .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));136 return `{${137 entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${canonicalJson(entryValue)}`)138 .join(",")139 }}`;140 }141 return JSON.stringify(value);142}143144function payloadAad(purpose: string, slots: EnvelopeSlot[]): Uint8Array {145 return textEncoder.encode(canonicalJson({ v: 1, purpose, slots }));146}147148function slotAad(purpose: string, type: EnvelopeSlot["type"]): Uint8Array {149 return textEncoder.encode(`lofi-envelope/1:${purpose}:slot:${type}`);150}151152function randomBytes(length: number): Uint8Array {153 return crypto.getRandomValues(new Uint8Array(length));154}155156async function importDek(raw: Uint8Array): Promise<CryptoKey> {157 return await crypto.subtle.importKey("raw", raw as BufferSource, "AES-GCM", false, [158 "encrypt",159 "decrypt",160 ]);161}162163/**164 * Seals `payload` under every supplied protector. At least one protector is165 * required — an envelope nobody can open is a bug, not a security posture.166 */167export async function sealEnvelope(168 purpose: string,169 payload: Uint8Array,170 protectors: readonly EnvelopeProtector[],171): Promise<SealedEnvelope> {172 if (protectors.length === 0) {173 throw new EnvelopeError("corrupt", "sealing needs at least one protector");174 }175 const dekRaw = randomBytes(32);176 const slots: EnvelopeSlot[] = [];177 for (const protector of protectors) {178 const iv = randomBytes(12);179 const wrapped = await crypto.subtle.encrypt(180 {181 name: "AES-GCM",182 iv: iv as BufferSource,183 additionalData: slotAad(purpose, protector.slot.type) as BufferSource,184 },185 protector.key,186 dekRaw as BufferSource,187 );188 slots.push({189 ...protector.slot,190 iv: toBase64Url(iv),191 wrappedDek: toBase64Url(new Uint8Array(wrapped)),192 } as EnvelopeSlot);193 }194 const dek = await importDek(dekRaw);195 dekRaw.fill(0);196 const iv = randomBytes(12);197 const ciphertext = await crypto.subtle.encrypt(198 {199 name: "AES-GCM",200 iv: iv as BufferSource,201 additionalData: payloadAad(purpose, slots) as BufferSource,202 },203 dek,204 payload as BufferSource,205 );206 return {207 v: 1,208 purpose,209 slots,210 iv: toBase64Url(iv),211 ciphertext: toBase64Url(new Uint8Array(ciphertext)),212 };213}214215/**216 * Opens an envelope by trying each slot in order. `resolveKey` returns the217 * wrapping key for a slot, or `null` when that protector is unavailable in218 * this context (missing device key, PRF declined) — the next slot is tried.219 * Throws `locked` when no slot opens and `corrupt` for a tampered or220 * wrong-purpose envelope.221 */222export async function openEnvelope(223 purpose: string,224 envelope: SealedEnvelope,225 resolveKey: (slot: EnvelopeSlot) => Promise<CryptoKey | null>,226): Promise<Uint8Array> {227 if (envelope.v !== 1) {228 throw new EnvelopeError("corrupt", `unsupported envelope version ${envelope.v}`);229 }230 if (envelope.purpose !== purpose) {231 throw new EnvelopeError(232 "corrupt",233 `envelope purpose "${envelope.purpose}" does not match "${purpose}"`,234 );235 }236 for (const slot of envelope.slots) {237 const key = await resolveKey(slot);238 if (!key) continue;239 let dekRaw: Uint8Array;240 try {241 dekRaw = new Uint8Array(242 await crypto.subtle.decrypt(243 {244 name: "AES-GCM",245 iv: fromBase64Url(slot.iv) as BufferSource,246 additionalData: slotAad(purpose, slot.type) as BufferSource,247 },248 key,249 fromBase64Url(slot.wrappedDek) as BufferSource,250 ),251 );252 } catch {253 // A wrap that does not authenticate under this key is treated as an254 // unavailable protector (a rotated or foreign key), not fatal.255 continue;256 }257 const dek = await importDek(dekRaw);258 dekRaw.fill(0);259 try {260 return new Uint8Array(261 await crypto.subtle.decrypt(262 {263 name: "AES-GCM",264 iv: fromBase64Url(envelope.iv) as BufferSource,265 additionalData: payloadAad(purpose, envelope.slots) as BufferSource,266 },267 dek,268 fromBase64Url(envelope.ciphertext) as BufferSource,269 ),270 );271 } catch {272 // The slot authenticated its DEK but the payload does not authenticate:273 // the envelope body was tampered with or truncated.274 throw new EnvelopeError("corrupt", "envelope payload failed authentication");275 }276 }277 throw new EnvelopeError("locked", "no available protector opens this envelope");278}279280/** Seals a UTF-8 JSON payload; see {@link sealEnvelope}. */281export async function sealJsonEnvelope(282 purpose: string,283 payload: unknown,284 protectors: readonly EnvelopeProtector[],285): Promise<SealedEnvelope> {286 return await sealEnvelope(purpose, textEncoder.encode(JSON.stringify(payload)), protectors);287}288289/** Opens a UTF-8 JSON payload; see {@link openEnvelope}. */290export async function openJsonEnvelope(291 purpose: string,292 envelope: SealedEnvelope,293 resolveKey: (slot: EnvelopeSlot) => Promise<CryptoKey | null>,294): Promise<unknown> {295 const payload = await openEnvelope(purpose, envelope, resolveKey);296 try {297 return JSON.parse(textDecoder.decode(payload));298 } catch {299 throw new EnvelopeError("corrupt", "envelope payload is not JSON");300 }301}302303/** Validates an untrusted parsed value as a v1 envelope, or returns `null`. */304export function parseSealedEnvelope(value: unknown): SealedEnvelope | null {305 if (value === null || typeof value !== "object") return null;306 const record = value as Partial<SealedEnvelope>;307 if (308 record.v !== 1 || typeof record.purpose !== "string" ||309 typeof record.iv !== "string" || typeof record.ciphertext !== "string" ||310 !Array.isArray(record.slots)311 ) {312 return null;313 }314 for (const slot of record.slots) {315 if (slot === null || typeof slot !== "object") return null;316 const candidate = slot as Partial<EnvelopeSlot>;317 if (typeof candidate.iv !== "string" || typeof candidate.wrappedDek !== "string") return null;318 if (candidate.type === "device-key") {319 if (typeof (candidate as Partial<DeviceKeySlot>).keyId !== "string") return null;320 } else if (candidate.type === "prf") {321 const prf = candidate as Partial<PrfSlot>;322 if (323 typeof prf.credentialId !== "string" || typeof prf.prfSalt !== "string" ||324 typeof prf.hkdfSalt !== "string"325 ) {326 return null;327 }328 } else {329 return null;330 }331 }332 return value as SealedEnvelope;333}334335/**336 * Holds the non-extractable device-bound wrapping keys for `device-key`337 * slots. Browsers back this with IndexedDB; non-browser runtimes and tests338 * get an in-memory store.339 */340export interface DeviceKeyStore {341 /** Returns the wrapping key for `keyId`, creating it when absent. */342 getOrCreate(keyId: string): Promise<CryptoKey>;343 /** Returns the wrapping key for `keyId`, or `null` when the store lost it. */344 get(keyId: string): Promise<CryptoKey | null>;345}346347function generateDeviceKey(): Promise<CryptoKey> {348 // Non-extractable: same-origin script can use the key while the page runs,349 // but cannot export it — the at-rest copy in IndexedDB is all there is.350 return crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, [351 "encrypt",352 "decrypt",353 ]);354}355356/** Creates an in-memory device key store (non-browser runtimes and tests). */357export function memoryDeviceKeyStore(): DeviceKeyStore {358 const keys = new Map<string, CryptoKey>();359 return {360 async getOrCreate(keyId) {361 const existing = keys.get(keyId);362 if (existing) return existing;363 const created = await generateDeviceKey();364 keys.set(keyId, created);365 return created;366 },367 get(keyId) {368 return Promise.resolve(keys.get(keyId) ?? null);369 },370 };371}372373const deviceKeyDbName = "lofi-device-keys";374const deviceKeyStoreName = "keys";375376function openDeviceKeyDb(): Promise<IDBDatabase> {377 return new Promise((resolve, reject) => {378 const request = indexedDB.open(deviceKeyDbName, 1);379 request.onupgradeneeded = () => {380 if (!request.result.objectStoreNames.contains(deviceKeyStoreName)) {381 request.result.createObjectStore(deviceKeyStoreName);382 }383 };384 request.onsuccess = () => resolve(request.result);385 request.onerror = () => reject(request.error ?? new Error("device key store failed to open"));386 });387}388389function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {390 return new Promise((resolve, reject) => {391 request.onsuccess = () => resolve(request.result);392 request.onerror = () => reject(request.error ?? new Error("device key store request failed"));393 });394}395396/** Creates the browser device key store backed by IndexedDB. */397export function indexedDbDeviceKeyStore(): DeviceKeyStore {398 async function read(keyId: string): Promise<CryptoKey | null> {399 const db = await openDeviceKeyDb();400 try {401 const stored = await requestToPromise<CryptoKey | undefined>(402 db.transaction(deviceKeyStoreName, "readonly").objectStore(deviceKeyStoreName).get(keyId),403 );404 return stored ?? null;405 } finally {406 db.close();407 }408 }409 async function write(keyId: string, key: CryptoKey): Promise<void> {410 const db = await openDeviceKeyDb();411 try {412 await requestToPromise(413 db.transaction(deviceKeyStoreName, "readwrite").objectStore(deviceKeyStoreName).put(414 key,415 keyId,416 ),417 );418 } finally {419 db.close();420 }421 }422 return {423 async getOrCreate(keyId) {424 const existing = await read(keyId);425 if (existing) return existing;426 const created = await generateDeviceKey();427 await write(keyId, created);428 // Re-read so two racing tabs converge on whichever write landed last;429 // both copies wrap the same envelope generation only after reseal, so430 // the loser's envelope stays openable via the winner's key.431 return (await read(keyId)) ?? created;432 },433 get(keyId) {434 return read(keyId);435 },436 };437}438439let sharedDefaultStore: DeviceKeyStore | null = null;440441/**442 * The device key store for this runtime: IndexedDB in browsers, a443 * process-lifetime in-memory store elsewhere. One shared instance, so444 * sealing and opening in the same session agree on keys.445 */446export function defaultDeviceKeyStore(): DeviceKeyStore {447 sharedDefaultStore ??= typeof indexedDB === "undefined"448 ? memoryDeviceKeyStore()449 : indexedDbDeviceKeyStore();450 return sharedDefaultStore;451}452453/** Builds the seal-time protector for a `device-key` slot. */454export async function deviceKeyProtector(455 store: DeviceKeyStore,456 keyId: string,457): Promise<EnvelopeProtector> {458 return { slot: { type: "device-key", keyId }, key: await store.getOrCreate(keyId) };459}460461/** Builds an open-time resolver that answers only `device-key` slots. */462export function deviceKeyResolver(463 store: DeviceKeyStore,464): (slot: EnvelopeSlot) => Promise<CryptoKey | null> {465 return (slot) => (slot.type === "device-key" ? store.get(slot.keyId) : Promise.resolve(null));466}