clone

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

feat(runtime): seal the data sink at rest in a slotted envelope (#125)

FelineStateMachine committed 799188a
The declared sink's ticket URL is a bearer credential but persisted as
cleartext JSON in localStorage. It now persists only as a versioned
envelope: a random DEK encrypts the record, wrapped under protector
slots with the slot table bound as authenticated data. The device-key
slot (non-extractable AES key in IndexedDB) opens silently at boot, so
first-boot and reconnect stay ceremony-free; a prf slot type is defined
in the v1 format for passkey-gated material to use without a migration.

This protects the record at rest (disk images, backups, storage
exfiltration), not against same-origin script — the PRF slot work is
what adds an interactive gate. Boot unseals once into module memory;
synchronous readers are unchanged. Pre-envelope cleartext records
migrate on the first restore. An envelope whose device key is gone
reports unopenable and is left intact.

Conformance: no bearer material reaches storage in cleartext or a
trivial encoding (value-based scan over every stored value), and the
sink at rest must parse as a sealed envelope.

Part of #123 (item A3 phase 1 + C2).
commit
799188a471466fd2176f5beac9205a644b65bacd
parent
c587a06f2dd8ef7c333c7d3883d0d0d8fd7d642e
author
FelineStateMachine <dami.etoile@gmail.com>
8 files changed, +913 -71
Mpackage/astro/mod.ts1+
Mpackage/runtime/boot.ts9+
Mpackage/runtime/data-sink.ts182+++++++++---
Mpackage/runtime/data-sink_test.ts150++++++++--
Apackage/runtime/envelope.ts456++++++++++++++++++++++++++++++
Apackage/runtime/envelope_test.ts182++++++++++++
Mpackage/runtime/mod.ts2+
Mpackage/runtime/session.ts2+-
M package/astro/mod.ts +1 -0
M package/runtime/boot.ts +9 -0
M package/runtime/data-sink.ts +136 -46
@@ -8,15 +8,30 @@
88 * runtime (typically by enrolling a `lofisync1.` app-connect ticket issued by99 * a self-hosted node) overrides the default for this device.1010 *11- * One durable record holds the declaration, written atomically and keyed by12- * the device-local anchor app id, and this module is its only reader and13- * writer. The record is device-local state, like the namespace election; the14- * recovery envelope carrying the sink behind the passkey is separate,15- * follow-on work.11+ * The ticket URL is a bearer credential, so the declaration never touches12+ * storage in cleartext: it persists as a sealed envelope (see `envelope.ts`)13+ * under a silent device-bound key. That protects the record at rest — disk14+ * images, backups, storage exfiltration — while opening with zero ceremony;15+ * it does not defend against same-origin script, which can drive the silent16+ * open itself. Boot unseals the record once into module memory17+ * ({@link restoreDeclaredSink}), and every synchronous reader answers from18+ * that state. The recovery envelope carrying the sink behind the passkey is19+ * separate, follow-on work.1620 *1721 * @module1822 */1923 24+import {25+ defaultDeviceKeyStore,26+ deviceKeyProtector,27+ deviceKeyResolver,28+ type DeviceKeyStore,29+ EnvelopeError,30+ openJsonEnvelope,31+ parseSealedEnvelope,32+ sealJsonEnvelope,33+} from "./envelope.ts";34+2035 declare const __LOFI_JAZZ_APP_ID__: string;2136 declare const __LOFI_JAZZ_SERVER_URL__: string;2237
@@ -87,41 +102,115 @@ export class DataSinkError extends Error {
87102 }88103 }89104 90-type SinkRecord = { v: 1; sink: DataSinkDeclaration };91-92105 const sinkKey = `lofi:data-sink:${anchorAppId}`;106+const sinkPurpose = `lofi:data-sink:${anchorAppId}`;107+const sinkDeviceKeyId = `data-sink:${anchorAppId}`;108+109+// The declaration in effect for this document, populated by110+// restoreDeclaredSink at boot and by successful declarations afterwards.111+// Synchronous readers answer from here; storage holds only the envelope.112+let cachedSink: DataSinkDeclaration | null = null;113+114+function validateDeclaration(value: unknown): DataSinkDeclaration | null {115+ if (value === null || typeof value !== "object") return null;116+ const sink = value as Partial<DataSinkDeclaration>;117+ if (118+ typeof sink.appId !== "string" || !sink.appId ||119+ typeof sink.serverUrl !== "string" || !sink.serverUrl120+ ) {121+ return null;122+ }123+ return {124+ appId: sink.appId,125+ serverUrl: sink.serverUrl,126+ ...(sink.scope === "provision" || sink.scope === "sync" ? { scope: sink.scope } : {}),127+ ...(typeof sink.label === "string" ? { label: sink.label } : {}),128+ ...(typeof sink.node === "string" ? { node: sink.node } : {}),129+ };130+}93131 94-function parseRecord(raw: string | null): DataSinkDeclaration | null {95- if (!raw) return null;132+async function persistSealed(133+ declaration: DataSinkDeclaration,134+ keyStore: DeviceKeyStore,135+): Promise<void> {136+ if (typeof localStorage === "undefined") return;96137 try {97- const record = JSON.parse(raw) as Partial<SinkRecord> | null;98- const sink = record?.v === 1 ? record.sink : undefined;99- if (100- sink && typeof sink.appId === "string" && sink.appId &&101- typeof sink.serverUrl === "string" && sink.serverUrl102- ) {103- return {104- appId: sink.appId,105- serverUrl: sink.serverUrl,106- ...(sink.scope === "provision" || sink.scope === "sync" ? { scope: sink.scope } : {}),107- ...(typeof sink.label === "string" ? { label: sink.label } : {}),108- ...(typeof sink.node === "string" ? { node: sink.node } : {}),109- };110- }138+ const sealed = await sealJsonEnvelope(sinkPurpose, declaration, [139+ await deviceKeyProtector(keyStore, sinkDeviceKeyId),140+ ]);141+ localStorage.setItem(sinkKey, JSON.stringify({ v: 2, sealed }));111142 } catch {112- // An unreadable record is treated as absent below.143+ // A private-mode storage failure must not break enrollment; the sink then144+ // lasts for this document only via the in-memory declaration.113145 }114- return null;115146 }116147 117-/** Reads the declared sink, or `null` when this device has not declared one. */118-export function readDeclaredSink(): DataSinkDeclaration | null {119- if (typeof localStorage === "undefined") return null;148+/**149+ * How {@link restoreDeclaredSink} resolved the persisted record: `none` (no150+ * record), `restored` (envelope opened), `migrated` (a pre-envelope cleartext151+ * record was read and resealed), or `unopenable` (an envelope exists but no152+ * available key opens it — the record is left intact and the device behaves153+ * as local-only until the sink is re-declared).154+ */155+export type SinkRestoreOutcome = "none" | "restored" | "migrated" | "unopenable";156+157+/**158+ * Unseals the persisted declaration into memory. Boot awaits this before any159+ * sync decision (see `boot.ts`); tests and non-boot embedders call it160+ * directly. Safe to call repeatedly — it re-reads storage each time.161+ */162+export async function restoreDeclaredSink(163+ keyStore: DeviceKeyStore = defaultDeviceKeyStore(),164+): Promise<SinkRestoreOutcome> {165+ cachedSink = null;166+ if (typeof localStorage === "undefined") return "none";167+ let raw: string | null = null;120168 try {121- return parseRecord(localStorage.getItem(sinkKey));169+ raw = localStorage.getItem(sinkKey);122170 } catch {123- return null;171+ return "none";172+ }173+ if (!raw) return "none";174+ let parsed: unknown;175+ try {176+ parsed = JSON.parse(raw);177+ } catch {178+ return "none";179+ }180+ const record = parsed as { v?: number; sink?: unknown; sealed?: unknown } | null;181+ if (record?.v === 1) {182+ // A record from before the envelope: adopt it, then reseal so the183+ // cleartext bearer URL leaves storage on the first boot that can.184+ const sink = validateDeclaration(record.sink);185+ if (!sink) return "none";186+ cachedSink = sink;187+ await persistSealed(sink, keyStore);188+ return "migrated";189+ }190+ if (record?.v === 2) {191+ const sealed = parseSealedEnvelope(record.sealed);192+ if (!sealed) return "unopenable";193+ try {194+ const payload = await openJsonEnvelope(sinkPurpose, sealed, deviceKeyResolver(keyStore));195+ const sink = validateDeclaration(payload);196+ if (!sink) return "unopenable";197+ cachedSink = sink;198+ return "restored";199+ } catch (error) {200+ if (error instanceof EnvelopeError) return "unopenable";201+ throw error;202+ }124203 }204+ return "none";205+}206+207+/**208+ * Reads the declared sink, or `null` when this device has not declared one.209+ * Answers from the state restored at boot; callers outside the booted app210+ * (tests, embedders) await {@link restoreDeclaredSink} first.211+ */212+export function readDeclaredSink(): DataSinkDeclaration | null {213+ return cachedSink;125214 }126215 127216 function assertHttpServerUrl(serverUrl: string): void {
@@ -147,10 +236,15 @@ function assertHttpServerUrl(serverUrl: string): void {
147236 * app id that contradicts a compiled-in managed app, and refuses to replace a148237 * different declared sink (one store, one active sink — clear the existing149238 * declaration first; switching stores is deliberately not a silent overwrite).150- * Re-declaring the same store updates the label. Declaration alone changes no151- * runtime state; electing sync (see `session.ts`) is what connects.239+ * Re-declaring the same store updates the label. The declaration persists as240+ * a sealed envelope; in a context that cannot store it (private mode) it241+ * lasts for this document only. Declaration alone changes no runtime state;242+ * electing sync (see `session.ts`) is what connects.152243 */153-export function declareDataSink(declaration: DataSinkDeclaration): DataSinkDeclaration {244+export async function declareDataSink(245+ declaration: DataSinkDeclaration,246+ keyStore: DeviceKeyStore = defaultDeviceKeyStore(),247+): Promise<DataSinkDeclaration> {154248 if (!declaration.appId.trim()) {155249 throw new DataSinkError("invalid-ticket", "sink app id must not be empty");156250 }
@@ -170,21 +264,14 @@ export function declareDataSink(declaration: DataSinkDeclaration): DataSinkDecla
170264 "a different sink is already declared on this device; clear it before declaring another",171265 );172266 }173- if (typeof localStorage !== "undefined") {174- try {175- const record: SinkRecord = { v: 1, sink: declaration };176- localStorage.setItem(sinkKey, JSON.stringify(record));177- } catch {178- // A private-mode storage failure must not break enrollment; the sink179- // then lasts for this document only via the default resolution below180- // returning null — callers observe the absence through readDeclaredSink.181- }182- }267+ await persistSealed(declaration, keyStore);268+ cachedSink = declaration;183269 return declaration;184270 }185271 186272 /** Removes the declared sink. Existing local data and elections are untouched. */187273 export function clearDeclaredSink(): void {274+ cachedSink = null;188275 if (typeof localStorage === "undefined") return;189276 try {190277 localStorage.removeItem(sinkKey);
@@ -250,18 +337,21 @@ export function parseSyncTicket(text: string): SyncTicket | null {
250337 * declaration errors otherwise. Electing sync afterwards (see251338 * `enrollSyncTicket` in `session.ts`) is what actually connects.252339 */253-export function declareSinkFromTicket(text: string): DataSinkDeclaration {340+export async function declareSinkFromTicket(341+ text: string,342+ keyStore: DeviceKeyStore = defaultDeviceKeyStore(),343+): Promise<DataSinkDeclaration> {254344 const ticket = parseSyncTicket(text);255345 if (!ticket) {256346 throw new DataSinkError("invalid-ticket", "not a valid lofisync1 app-connect ticket");257347 }258- return declareDataSink({348+ return await declareDataSink({259349 appId: ticket.appId,260350 serverUrl: ticket.url,261351 ...(ticket.scope ? { scope: ticket.scope } : {}),262352 ...(ticket.label ? { label: ticket.label } : {}),263353 ...(ticket.node ? { node: ticket.node } : {}),264- });354+ }, keyStore);265355 }266356 267357 /** True when an error is a data-sink or ticket problem the user can fix and retry. */
M package/runtime/data-sink_test.ts +126 -24
@@ -1,6 +1,7 @@
1-// Package contract tests for the runtime-declared data sink: record2-// round-trip, ticket parsing (the lofi-node `lofisync1.` format contract),3-// declaration guards, and how the active sync location resolves into the1+// Package contract tests for the runtime-declared data sink: sealed-record2+// round-trip, cleartext-record migration, ticket parsing (the lofi-node3+// `lofisync1.` format contract), declaration guards, the no-plaintext-bearer4+// conformance check, and how the active sync location resolves into the45 // session election and database configuration.56 import {67 anchorAppId,
@@ -10,7 +11,9 @@ import {
1011 isDataSinkError,1112 parseSyncTicket,1213 readDeclaredSink,14+ restoreDeclaredSink,1315 } from "./data-sink.ts";16+import { memoryDeviceKeyStore, parseSealedEnvelope } from "./envelope.ts";1417 import {1518 activeAppId,1619 activeServerUrl,
@@ -41,15 +44,17 @@ const test = (globalThis as unknown as {
4144 const sinkKey = `lofi:data-sink:${anchorAppId}`;4245 const electionKey = `lofi:sync-elected:${appId}`;4346 44-function withCleanState(body: () => void): () => void {45- return () => {47+function withCleanState(body: () => void | Promise<void>): () => Promise<void> {48+ return async () => {4649 localStorage.removeItem(sinkKey);4750 localStorage.removeItem(electionKey);51+ clearDeclaredSink();4852 try {49- body();53+ await body();5054 } finally {5155 localStorage.removeItem(sinkKey);5256 localStorage.removeItem(electionKey);57+ clearDeclaredSink();5358 }5459 };5560 }
@@ -71,37 +76,134 @@ const validTicket = encodeTicket({
7176 });7277 7378 test(74- "a declared sink round-trips through the versioned record",75- withCleanState(() => {79+ "a declared sink round-trips through the sealed record and a restore",80+ withCleanState(async () => {7681 assert(readDeclaredSink() === null, "a clean device must have no declared sink");77- declareDataSink({ appId: ticketAppId, serverUrl: "https://node.example:4802", label: "home" });82+ await declareDataSink({83+ appId: ticketAppId,84+ serverUrl: "https://node.example:4802",85+ label: "home",86+ });7887 const sink = readDeclaredSink();7988 assert(8089 sink !== null && sink.appId === ticketAppId &&8190 sink.serverUrl === "https://node.example:4802" && sink.label === "home",8291 `declared sink read back as ${JSON.stringify(sink)}`,8392 );93+ // A fresh boot re-reads storage: the sealed record must open back to the94+ // same declaration.95+ assert(await restoreDeclaredSink() === "restored", "the sealed record must restore");96+ assert(97+ readDeclaredSink()?.serverUrl === "https://node.example:4802",98+ "the restored sink must match the declaration",99+ );84100 clearDeclaredSink();85101 assert(readDeclaredSink() === null, "clearing must remove the declaration");102+ assert(await restoreDeclaredSink() === "none", "clearing must remove the stored record");86103 }),87104 );88105 89106 test(90- "an unreadable or unversioned sink record reads as absent",91- withCleanState(() => {107+ "an unreadable or unversioned sink record restores as absent",108+ withCleanState(async () => {92109 localStorage.setItem(sinkKey, "not json");110+ assert(await restoreDeclaredSink() === "none", "malformed record must restore as absent");93111 assert(readDeclaredSink() === null, "malformed record must read as absent");94112 localStorage.setItem(sinkKey, JSON.stringify({ v: 99, sink: { appId: "x", serverUrl: "y" } }));113+ assert(await restoreDeclaredSink() === "none", "unknown record version must restore as absent");95114 assert(readDeclaredSink() === null, "unknown record version must read as absent");96115 }),97116 );98117 118+test(119+ "a pre-envelope cleartext record migrates to a sealed one on restore",120+ withCleanState(async () => {121+ const bearerUrl = `http://192.168.1.10:4802/t/${ticketSecret}`;122+ localStorage.setItem(123+ sinkKey,124+ JSON.stringify({ v: 1, sink: { appId: ticketAppId, serverUrl: bearerUrl, label: "phone" } }),125+ );126+ assert(await restoreDeclaredSink() === "migrated", "a v1 record must migrate");127+ assert(readDeclaredSink()?.serverUrl === bearerUrl, "the migrated sink must carry over");128+ const raw = localStorage.getItem(sinkKey) ?? "";129+ assert(!raw.includes(ticketSecret), "migration must remove the bearer secret from storage");130+ const record = JSON.parse(raw) as { v: number; sealed: unknown };131+ assert(132+ record.v === 2 && parseSealedEnvelope(record.sealed) !== null,133+ "migration must persist a sealed envelope",134+ );135+ assert(await restoreDeclaredSink() === "restored", "the migrated record must reopen");136+ assert(readDeclaredSink()?.serverUrl === bearerUrl, "the resealed sink must round-trip");137+ }),138+);139+140+test(141+ "a sealed record without its device key is unopenable, not destroyed",142+ withCleanState(async () => {143+ await declareDataSink({ appId: ticketAppId, serverUrl: "https://node.example:4802" });144+ const stored = localStorage.getItem(sinkKey);145+ assert(stored !== null, "the declaration must persist");146+ // A store that never held the wrapping key models cleared IndexedDB with147+ // surviving localStorage.148+ assert(149+ await restoreDeclaredSink(memoryDeviceKeyStore()) === "unopenable",150+ "a missing device key must report unopenable",151+ );152+ assert(readDeclaredSink() === null, "an unopenable record must read as absent");153+ assert(localStorage.getItem(sinkKey) === stored, "an unopenable record must be left intact");154+ assert(await restoreDeclaredSink() === "restored", "the right key must still open it");155+ }),156+);157+158+test(159+ "no bearer material reaches storage in cleartext or a trivial encoding",160+ withCleanState(async () => {161+ const bearerUrl = `http://192.168.1.10:4802/t/${ticketSecret}`;162+ await declareSinkFromTicket(163+ encodeTicket({ v: 1, appId: ticketAppId, url: bearerUrl, scope: "provision" }),164+ );165+ const base64Url = (text: string) =>166+ btoa(text).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");167+ const encodings = [168+ ticketSecret,169+ bearerUrl,170+ btoa(ticketSecret),171+ base64Url(ticketSecret),172+ encodeURIComponent(bearerUrl),173+ ];174+ for (let index = 0; index < localStorage.length; index++) {175+ const key = localStorage.key(index);176+ const value = key === null ? null : localStorage.getItem(key);177+ if (!value) continue;178+ for (const encoding of encodings) {179+ assert(180+ !value.includes(encoding),181+ `bearer material found at rest under "${key}" as ${encoding.slice(0, 16)}…`,182+ );183+ }184+ }185+ const record = JSON.parse(localStorage.getItem(sinkKey) ?? "null") as {186+ v: number;187+ sealed: unknown;188+ };189+ assert(190+ record?.v === 2 && parseSealedEnvelope(record.sealed) !== null,191+ "the sink at rest must be a sealed envelope",192+ );193+ assert(await restoreDeclaredSink() === "restored", "the sealed ticket must restore");194+ assert(195+ readDeclaredSink()?.serverUrl === bearerUrl && readDeclaredSink()?.scope === "provision",196+ "the restored declaration must return the exact ticket URL and scope",197+ );198+ }),199+);200+99201 test(100202 "sink declarations validate the server URL",101- withCleanState(() => {203+ withCleanState(async () => {102204 for (const serverUrl of ["ws://node.example/t/x", "not a url", "ftp://node.example"]) {103205 try {104- declareDataSink({ appId: ticketAppId, serverUrl });206+ await declareDataSink({ appId: ticketAppId, serverUrl });105207 throw new Error(`declaration accepted invalid server URL: ${serverUrl}`);106208 } catch (error) {107209 assert(
@@ -116,10 +218,10 @@ test(
116218 117219 test(118220 "declaring over a different sink is refused; the same store updates its label",119- withCleanState(() => {120- declareDataSink({ appId: ticketAppId, serverUrl: "https://node.example:4802" });221+ withCleanState(async () => {222+ await declareDataSink({ appId: ticketAppId, serverUrl: "https://node.example:4802" });121223 try {122- declareDataSink({ appId: ticketAppId, serverUrl: "https://other.example:4802" });224+ await declareDataSink({ appId: ticketAppId, serverUrl: "https://other.example:4802" });123225 throw new Error("a different sink must not silently replace the declared one");124226 } catch (error) {125227 assert(
@@ -127,7 +229,7 @@ test(
127229 `expected sink-already-declared, received ${String(error)}`,128230 );129231 }130- declareDataSink({232+ await declareDataSink({131233 appId: ticketAppId,132234 serverUrl: "https://node.example:4802",133235 label: "renamed",
@@ -200,8 +302,8 @@ test("ticket parsing accepts the lofi-node format and rejects everything else",
200302 201303 test(202304 "enrolling a ticket declares its URL verbatim as the sink",203- withCleanState(() => {204- const declared = declareSinkFromTicket(validTicket);305+ withCleanState(async () => {306+ const declared = await declareSinkFromTicket(validTicket);205307 assert(206308 declared.serverUrl === `http://192.168.1.10:4802/t/${ticketSecret}`,207309 "the ticket URL must be used verbatim — the secret path is the credential",
@@ -210,7 +312,7 @@ test(
210312 assert(sink?.appId === ticketAppId && sink.label === "phone", "ticket fields must carry over");211313 assert(sink.scope === undefined, "a scopeless ticket declares no explicit scope");212314 try {213- declareSinkFromTicket("lofisync1.nope");315+ await declareSinkFromTicket("lofisync1.nope");214316 throw new Error("a malformed ticket must not enroll");215317 } catch (error) {216318 assert(
@@ -223,14 +325,14 @@ test(
223325 224326 test(225327 "the active sink resolves declared over default and gates the election",226- withCleanState(() => {328+ withCleanState(async () => {227329 // This test build compiles no managed app, so the default sink is absent.228330 assert(activeSink() === null && !syncAvailable(), "local build must start with no sink");229331 assert(activeAppId() === appId, "without a sink the active app id is the anchor");230332 setSyncElected(true);231333 assert(!syncElected(), "election must be a no-op without a sync location");232334 233- declareDataSink({ appId: ticketAppId, serverUrl: "https://node.example:4802" });335+ await declareDataSink({ appId: ticketAppId, serverUrl: "https://node.example:4802" });234336 const sink = activeSink();235337 assert(236338 sink?.source === "declared" && sink.appId === ticketAppId && syncAvailable(),
@@ -248,8 +350,8 @@ test(
248350 249351 test(250352 "databaseConfig keeps the local tier on the anchor and gives managed the sink",251- withCleanState(() => {252- declareDataSink({ appId: ticketAppId, serverUrl: "https://node.example:4802" });353+ withCleanState(async () => {354+ await declareDataSink({ appId: ticketAppId, serverUrl: "https://node.example:4802" });253355 setSyncElected(true);254356 255357 const local = databaseConfig("secret", "ns", "local", false);
A package/runtime/envelope.ts +456 -0
@@ -0,0 +1,456 @@
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+ */22+23+const textEncoder = new TextEncoder();24+const textDecoder = new TextDecoder();25+26+/** A DEK wrap under a silent, device-bound key from the {@link DeviceKeyStore}. */27+export 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+};37+38+/** A DEK wrap under a key derived from a passkey's PRF output. */39+export 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+};55+56+/** One protector's wrap of the envelope DEK. */57+export type EnvelopeSlot = DeviceKeySlot | PrfSlot;58+59+/** Slot metadata a caller supplies at seal time; wrap fields are computed. */60+export type SlotDescriptor =61+ | Omit<DeviceKeySlot, "iv" | "wrappedDek">62+ | Omit<PrfSlot, "iv" | "wrappedDek">;63+64+/** A protector at seal time: the slot metadata plus its wrapping key. */65+export 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+};71+72+/** The persisted envelope: versioned, purpose-bound, self-describing. */73+export 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+};85+86+/** Raised when an envelope cannot be opened or is not a valid envelope. */87+export 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+}102+103+function toBase64Url(bytes: Uint8Array): string {104+ let binary = "";105+ for (const byte of bytes) binary += String.fromCharCode(byte);106+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");107+}108+109+function fromBase64Url(text: string): Uint8Array {110+ const base64 = text.replaceAll("-", "+").replaceAll("_", "/");111+ const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);112+ const binary = atob(padded);113+ const bytes = new Uint8Array(binary.length);114+ for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);115+ return bytes;116+}117+118+// Key-order-independent serialization so the authenticated data recomputed at119+// open time matches the seal regardless of how the stored JSON round-tripped.120+function canonicalJson(value: unknown): string {121+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;122+ if (value !== null && typeof value === "object") {123+ const entries = Object.entries(value as Record<string, unknown>)124+ .filter(([, entryValue]) => entryValue !== undefined)125+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));126+ return `{${127+ entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${canonicalJson(entryValue)}`)128+ .join(",")129+ }}`;130+ }131+ return JSON.stringify(value);132+}133+134+function payloadAad(purpose: string, slots: EnvelopeSlot[]): Uint8Array {135+ return textEncoder.encode(canonicalJson({ v: 1, purpose, slots }));136+}137+138+function slotAad(purpose: string, type: EnvelopeSlot["type"]): Uint8Array {139+ return textEncoder.encode(`lofi-envelope/1:${purpose}:slot:${type}`);140+}141+142+function randomBytes(length: number): Uint8Array {143+ return crypto.getRandomValues(new Uint8Array(length));144+}145+146+async function importDek(raw: Uint8Array): Promise<CryptoKey> {147+ return await crypto.subtle.importKey("raw", raw as BufferSource, "AES-GCM", false, [148+ "encrypt",149+ "decrypt",150+ ]);151+}152+153+/**154+ * Seals `payload` under every supplied protector. At least one protector is155+ * required — an envelope nobody can open is a bug, not a security posture.156+ */157+export async function sealEnvelope(158+ purpose: string,159+ payload: Uint8Array,160+ protectors: readonly EnvelopeProtector[],161+): Promise<SealedEnvelope> {162+ if (protectors.length === 0) {163+ throw new EnvelopeError("corrupt", "sealing needs at least one protector");164+ }165+ const dekRaw = randomBytes(32);166+ const slots: EnvelopeSlot[] = [];167+ for (const protector of protectors) {168+ const iv = randomBytes(12);169+ const wrapped = await crypto.subtle.encrypt(170+ {171+ name: "AES-GCM",172+ iv: iv as BufferSource,173+ additionalData: slotAad(purpose, protector.slot.type) as BufferSource,174+ },175+ protector.key,176+ dekRaw as BufferSource,177+ );178+ slots.push({179+ ...protector.slot,180+ iv: toBase64Url(iv),181+ wrappedDek: toBase64Url(new Uint8Array(wrapped)),182+ } as EnvelopeSlot);183+ }184+ const dek = await importDek(dekRaw);185+ dekRaw.fill(0);186+ const iv = randomBytes(12);187+ const ciphertext = await crypto.subtle.encrypt(188+ {189+ name: "AES-GCM",190+ iv: iv as BufferSource,191+ additionalData: payloadAad(purpose, slots) as BufferSource,192+ },193+ dek,194+ payload as BufferSource,195+ );196+ return {197+ v: 1,198+ purpose,199+ slots,200+ iv: toBase64Url(iv),201+ ciphertext: toBase64Url(new Uint8Array(ciphertext)),202+ };203+}204+205+/**206+ * Opens an envelope by trying each slot in order. `resolveKey` returns the207+ * wrapping key for a slot, or `null` when that protector is unavailable in208+ * this context (missing device key, PRF declined) — the next slot is tried.209+ * Throws `locked` when no slot opens and `corrupt` for a tampered or210+ * wrong-purpose envelope.211+ */212+export async function openEnvelope(213+ purpose: string,214+ envelope: SealedEnvelope,215+ resolveKey: (slot: EnvelopeSlot) => Promise<CryptoKey | null>,216+): Promise<Uint8Array> {217+ if (envelope.v !== 1) {218+ throw new EnvelopeError("corrupt", `unsupported envelope version ${envelope.v}`);219+ }220+ if (envelope.purpose !== purpose) {221+ throw new EnvelopeError(222+ "corrupt",223+ `envelope purpose "${envelope.purpose}" does not match "${purpose}"`,224+ );225+ }226+ for (const slot of envelope.slots) {227+ const key = await resolveKey(slot);228+ if (!key) continue;229+ let dekRaw: Uint8Array;230+ try {231+ dekRaw = new Uint8Array(232+ await crypto.subtle.decrypt(233+ {234+ name: "AES-GCM",235+ iv: fromBase64Url(slot.iv) as BufferSource,236+ additionalData: slotAad(purpose, slot.type) as BufferSource,237+ },238+ key,239+ fromBase64Url(slot.wrappedDek) as BufferSource,240+ ),241+ );242+ } catch {243+ // A wrap that does not authenticate under this key is treated as an244+ // unavailable protector (a rotated or foreign key), not fatal.245+ continue;246+ }247+ const dek = await importDek(dekRaw);248+ dekRaw.fill(0);249+ try {250+ return new Uint8Array(251+ await crypto.subtle.decrypt(252+ {253+ name: "AES-GCM",254+ iv: fromBase64Url(envelope.iv) as BufferSource,255+ additionalData: payloadAad(purpose, envelope.slots) as BufferSource,256+ },257+ dek,258+ fromBase64Url(envelope.ciphertext) as BufferSource,259+ ),260+ );261+ } catch {262+ // The slot authenticated its DEK but the payload does not authenticate:263+ // the envelope body was tampered with or truncated.264+ throw new EnvelopeError("corrupt", "envelope payload failed authentication");265+ }266+ }267+ throw new EnvelopeError("locked", "no available protector opens this envelope");268+}269+270+/** Seals a UTF-8 JSON payload; see {@link sealEnvelope}. */271+export async function sealJsonEnvelope(272+ purpose: string,273+ payload: unknown,274+ protectors: readonly EnvelopeProtector[],275+): Promise<SealedEnvelope> {276+ return await sealEnvelope(purpose, textEncoder.encode(JSON.stringify(payload)), protectors);277+}278+279+/** Opens a UTF-8 JSON payload; see {@link openEnvelope}. */280+export async function openJsonEnvelope(281+ purpose: string,282+ envelope: SealedEnvelope,283+ resolveKey: (slot: EnvelopeSlot) => Promise<CryptoKey | null>,284+): Promise<unknown> {285+ const payload = await openEnvelope(purpose, envelope, resolveKey);286+ try {287+ return JSON.parse(textDecoder.decode(payload));288+ } catch {289+ throw new EnvelopeError("corrupt", "envelope payload is not JSON");290+ }291+}292+293+/** Validates an untrusted parsed value as a v1 envelope, or returns `null`. */294+export function parseSealedEnvelope(value: unknown): SealedEnvelope | null {295+ if (value === null || typeof value !== "object") return null;296+ const record = value as Partial<SealedEnvelope>;297+ if (298+ record.v !== 1 || typeof record.purpose !== "string" ||299+ typeof record.iv !== "string" || typeof record.ciphertext !== "string" ||300+ !Array.isArray(record.slots)301+ ) {302+ return null;303+ }304+ for (const slot of record.slots) {305+ if (slot === null || typeof slot !== "object") return null;306+ const candidate = slot as Partial<EnvelopeSlot>;307+ if (typeof candidate.iv !== "string" || typeof candidate.wrappedDek !== "string") return null;308+ if (candidate.type === "device-key") {309+ if (typeof (candidate as Partial<DeviceKeySlot>).keyId !== "string") return null;310+ } else if (candidate.type === "prf") {311+ const prf = candidate as Partial<PrfSlot>;312+ if (313+ typeof prf.credentialId !== "string" || typeof prf.prfSalt !== "string" ||314+ typeof prf.hkdfSalt !== "string"315+ ) {316+ return null;317+ }318+ } else {319+ return null;320+ }321+ }322+ return value as SealedEnvelope;323+}324+325+/**326+ * Holds the non-extractable device-bound wrapping keys for `device-key`327+ * slots. Browsers back this with IndexedDB; non-browser runtimes and tests328+ * get an in-memory store.329+ */330+export interface DeviceKeyStore {331+ /** Returns the wrapping key for `keyId`, creating it when absent. */332+ getOrCreate(keyId: string): Promise<CryptoKey>;333+ /** Returns the wrapping key for `keyId`, or `null` when the store lost it. */334+ get(keyId: string): Promise<CryptoKey | null>;335+}336+337+function generateDeviceKey(): Promise<CryptoKey> {338+ // Non-extractable: same-origin script can use the key while the page runs,339+ // but cannot export it — the at-rest copy in IndexedDB is all there is.340+ return crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, [341+ "encrypt",342+ "decrypt",343+ ]);344+}345+346+/** Creates an in-memory device key store (non-browser runtimes and tests). */347+export function memoryDeviceKeyStore(): DeviceKeyStore {348+ const keys = new Map<string, CryptoKey>();349+ return {350+ async getOrCreate(keyId) {351+ const existing = keys.get(keyId);352+ if (existing) return existing;353+ const created = await generateDeviceKey();354+ keys.set(keyId, created);355+ return created;356+ },357+ get(keyId) {358+ return Promise.resolve(keys.get(keyId) ?? null);359+ },360+ };361+}362+363+const deviceKeyDbName = "lofi-device-keys";364+const deviceKeyStoreName = "keys";365+366+function openDeviceKeyDb(): Promise<IDBDatabase> {367+ return new Promise((resolve, reject) => {368+ const request = indexedDB.open(deviceKeyDbName, 1);369+ request.onupgradeneeded = () => {370+ if (!request.result.objectStoreNames.contains(deviceKeyStoreName)) {371+ request.result.createObjectStore(deviceKeyStoreName);372+ }373+ };374+ request.onsuccess = () => resolve(request.result);375+ request.onerror = () => reject(request.error ?? new Error("device key store failed to open"));376+ });377+}378+379+function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {380+ return new Promise((resolve, reject) => {381+ request.onsuccess = () => resolve(request.result);382+ request.onerror = () => reject(request.error ?? new Error("device key store request failed"));383+ });384+}385+386+/** Creates the browser device key store backed by IndexedDB. */387+export function indexedDbDeviceKeyStore(): DeviceKeyStore {388+ async function read(keyId: string): Promise<CryptoKey | null> {389+ const db = await openDeviceKeyDb();390+ try {391+ const stored = await requestToPromise<CryptoKey | undefined>(392+ db.transaction(deviceKeyStoreName, "readonly").objectStore(deviceKeyStoreName).get(keyId),393+ );394+ return stored ?? null;395+ } finally {396+ db.close();397+ }398+ }399+ async function write(keyId: string, key: CryptoKey): Promise<void> {400+ const db = await openDeviceKeyDb();401+ try {402+ await requestToPromise(403+ db.transaction(deviceKeyStoreName, "readwrite").objectStore(deviceKeyStoreName).put(404+ key,405+ keyId,406+ ),407+ );408+ } finally {409+ db.close();410+ }411+ }412+ return {413+ async getOrCreate(keyId) {414+ const existing = await read(keyId);415+ if (existing) return existing;416+ const created = await generateDeviceKey();417+ await write(keyId, created);418+ // Re-read so two racing tabs converge on whichever write landed last;419+ // both copies wrap the same envelope generation only after reseal, so420+ // the loser's envelope stays openable via the winner's key.421+ return (await read(keyId)) ?? created;422+ },423+ get(keyId) {424+ return read(keyId);425+ },426+ };427+}428+429+let sharedDefaultStore: DeviceKeyStore | null = null;430+431+/**432+ * The device key store for this runtime: IndexedDB in browsers, a433+ * process-lifetime in-memory store elsewhere. One shared instance, so434+ * sealing and opening in the same session agree on keys.435+ */436+export function defaultDeviceKeyStore(): DeviceKeyStore {437+ sharedDefaultStore ??= typeof indexedDB === "undefined"438+ ? memoryDeviceKeyStore()439+ : indexedDbDeviceKeyStore();440+ return sharedDefaultStore;441+}442+443+/** Builds the seal-time protector for a `device-key` slot. */444+export async function deviceKeyProtector(445+ store: DeviceKeyStore,446+ keyId: string,447+): Promise<EnvelopeProtector> {448+ return { slot: { type: "device-key", keyId }, key: await store.getOrCreate(keyId) };449+}450+451+/** Builds an open-time resolver that answers only `device-key` slots. */452+export function deviceKeyResolver(453+ store: DeviceKeyStore,454+): (slot: EnvelopeSlot) => Promise<CryptoKey | null> {455+ return (slot) => (slot.type === "device-key" ? store.get(slot.keyId) : Promise.resolve(null));456+}
A package/runtime/envelope_test.ts +182 -0
@@ -0,0 +1,182 @@
1+// Envelope contract tests: multi-slot seal/open, fall-through past2+// unavailable protectors, slot-table tamper detection, purpose binding, and3+// untrusted-record validation.4+import {5+ deviceKeyProtector,6+ deviceKeyResolver,7+ EnvelopeError,8+ type EnvelopeSlot,9+ memoryDeviceKeyStore,10+ openEnvelope,11+ parseSealedEnvelope,12+ sealEnvelope,13+} from "./envelope.ts";14+import { assert } from "./test-assert.ts";15+16+const payload = new TextEncoder().encode(JSON.stringify({ ticket: "/t/secret-material" }));17+18+async function expectEnvelopeError(19+ run: () => Promise<unknown>,20+ code: "locked" | "corrupt",21+ context: string,22+): Promise<void> {23+ try {24+ await run();25+ } catch (error) {26+ assert(27+ error instanceof EnvelopeError && error.code === code,28+ `${context}: expected ${code}, received ${String(error)}`,29+ );30+ return;31+ }32+ throw new Error(`${context}: expected ${code}, but the envelope opened`);33+}34+35+Deno.test("a sealed envelope opens back to the payload through its device key", async () => {36+ const store = memoryDeviceKeyStore();37+ const sealed = await sealEnvelope("test:purpose", payload, [38+ await deviceKeyProtector(store, "k1"),39+ ]);40+ const opened = await openEnvelope("test:purpose", sealed, deviceKeyResolver(store));41+ assert(42+ new TextDecoder().decode(opened) === new TextDecoder().decode(payload),43+ "the opened payload must equal the sealed payload",44+ );45+});46+47+Deno.test("opening falls through unavailable slots to one that answers", async () => {48+ const storeA = memoryDeviceKeyStore();49+ const storeB = memoryDeviceKeyStore();50+ const sealed = await sealEnvelope("test:purpose", payload, [51+ await deviceKeyProtector(storeA, "first"),52+ await deviceKeyProtector(storeB, "second"),53+ ]);54+ // Only the second store is available in this context: slot one resolves to55+ // null and the open must continue rather than fail.56+ const opened = await openEnvelope(57+ "test:purpose",58+ sealed,59+ (slot: EnvelopeSlot) =>60+ slot.type === "device-key" && slot.keyId === "second"61+ ? storeB.get("second")62+ : Promise.resolve(null),63+ );64+ assert(opened.length === payload.length, "the fallback slot must open the envelope");65+});66+67+Deno.test("an envelope with no available protector reports locked", async () => {68+ const store = memoryDeviceKeyStore();69+ const sealed = await sealEnvelope("test:purpose", payload, [70+ await deviceKeyProtector(store, "k1"),71+ ]);72+ await expectEnvelopeError(73+ () => openEnvelope("test:purpose", sealed, () => Promise.resolve(null)),74+ "locked",75+ "no protector",76+ );77+ // A different store's key fails the wrap authentication and must be treated78+ // as unavailable, not as corruption.79+ await expectEnvelopeError(80+ () => openEnvelope("test:purpose", sealed, deviceKeyResolver(memoryDeviceKeyStore())),81+ "locked",82+ "foreign key",83+ );84+});85+86+Deno.test("tampering with the payload or the slot table is corruption", async () => {87+ const store = memoryDeviceKeyStore();88+ const sealed = await sealEnvelope("test:purpose", payload, [89+ await deviceKeyProtector(store, "k1"),90+ ]);91+ const flipped = {92+ ...sealed,93+ ciphertext: sealed.ciphertext.startsWith("A")94+ ? `B${sealed.ciphertext.slice(1)}`95+ : `A${sealed.ciphertext.slice(1)}`,96+ };97+ await expectEnvelopeError(98+ () => openEnvelope("test:purpose", flipped, deviceKeyResolver(store)),99+ "corrupt",100+ "flipped ciphertext",101+ );102+ // The slot table is authenticated data: renaming a slot's key id after the103+ // fact must not go unnoticed even though the wrap itself still opens.104+ const tampered = {105+ ...sealed,106+ slots: sealed.slots.map((slot) => ({ ...slot, keyId: "impostor" }) as EnvelopeSlot),107+ };108+ await expectEnvelopeError(109+ () =>110+ openEnvelope(111+ "test:purpose",112+ tampered,113+ (slot) => (slot.type === "device-key" ? store.get("k1") : Promise.resolve(null)),114+ ),115+ "corrupt",116+ "renamed slot",117+ );118+});119+120+Deno.test("an envelope opens only under its own purpose", async () => {121+ const store = memoryDeviceKeyStore();122+ const sealed = await sealEnvelope("test:purpose", payload, [123+ await deviceKeyProtector(store, "k1"),124+ ]);125+ await expectEnvelopeError(126+ () => openEnvelope("test:other", sealed, deviceKeyResolver(store)),127+ "corrupt",128+ "wrong purpose",129+ );130+});131+132+Deno.test("untrusted records validate strictly before any cryptography", () => {133+ const invalid: unknown[] = [134+ null,135+ "text",136+ {},137+ { v: 2, purpose: "p", slots: [], iv: "a", ciphertext: "b" },138+ {139+ v: 1,140+ purpose: "p",141+ slots: [{ type: "unknown", iv: "a", wrappedDek: "b" }],142+ iv: "a",143+ ciphertext: "b",144+ },145+ {146+ v: 1,147+ purpose: "p",148+ slots: [{ type: "device-key", iv: "a", wrappedDek: "b" }],149+ iv: "a",150+ ciphertext: "b",151+ },152+ {153+ v: 1,154+ purpose: "p",155+ slots: [{ type: "prf", credentialId: "c", iv: "a", wrappedDek: "b" }],156+ iv: "a",157+ ciphertext: "b",158+ },159+ ];160+ for (const record of invalid) {161+ assert(162+ parseSealedEnvelope(record) === null,163+ `invalid record accepted: ${JSON.stringify(record)}`,164+ );165+ }166+ const valid = {167+ v: 1,168+ purpose: "p",169+ slots: [{ type: "device-key", keyId: "k", iv: "a", wrappedDek: "b" }],170+ iv: "a",171+ ciphertext: "b",172+ };173+ assert(parseSealedEnvelope(valid) !== null, "a well-formed record must validate");174+});175+176+Deno.test("sealing with no protector is refused", async () => {177+ await expectEnvelopeError(178+ () => sealEnvelope("test:purpose", payload, []),179+ "corrupt",180+ "empty protectors",181+ );182+});
M package/runtime/mod.ts +2 -0
M package/runtime/session.ts +1 -1