clone

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

feat/184-task-notice-migration
lofi / docs / decisions / 0001-sink-at-rest-envelope.md
0001-sink-at-rest-envelope.md 5.6K rendered
1# Sealing the data sink at rest: slotted envelope with a capability split23Date: 2026-07-18\4Decision: adopt a multi-slot at-rest envelope with a silent device-key baseline; split sync and5provision capability tiers at rest; attempt PRF, never detect it; no-PRF fallback is memory-only6provision capability with password-manager custody78## Question910The enrolled app ticket's URL is a bearer credential (`/t/<secret>`), and for a provision-scoped11ticket, possession is store administration. How does it persist on a device so that nothing12bearer-shaped sits in storage in cleartext, without taxing every boot of a synced app with a passkey13ceremony — and what happens on devices whose authenticator cannot evaluate the WebAuthn PRF14extension?1516## Contract IDs1718No existing measurable promise covers at-rest credential custody; the affected documented semantics19are the ticket-bearer bullets in `docs/sync-and-recovery.md` and `docs/node/tickets-explained.md`.20The threat-model disclosure page (#122 §2) is where the claim set gets a permanent home.2122## Exact versions2324`@nzip/lofi` 0.5.0 working tree (#125); lofi-node `feat/scope-down-ticket-exchange`25(FelineStateMachine/lofi-node#4, commit 92a7877); WebCrypto AES-GCM/HKDF and IndexedDB as the26browser baseline; `jazz-tools@2.0.0-alpha.53` unchanged.2728## Control2930The pre-#125 behavior: the sink declaration, including the full ticket URL, stored as cleartext JSON31in `localStorage` under `lofi:data-sink:<anchorAppId>`.3233## Decision34351. **Slotted envelope** (`package/runtime/envelope.ts`): a random 32-byte DEK encrypts the payload;36 the DEK is wrapped under one or more protector slots; the slot table and a purpose string are37 bound as authenticated data. Adding a protector re-wraps 32 bytes, never the payload — the38 property that later lets provision material gain a PRF slot, and field encryption change39 protectors, without re-encrypting data.402. **Silent baseline, universally applied**: every sink record is sealed under a `device-key` slot —41 a non-extractable AES key in IndexedDB that opens with zero ceremony at boot. This defends the42 record at rest (disk images, backups, storage exfiltration). It deliberately does not defend43 against same-origin script, which can drive the silent open; that claim is never made.443. **Capability split at rest**: sync-scope material opens silently forever (its theft is45 transport-only by the node gate's own scope enforcement); provision material is the tier that46 gets an interactive PRF gate. A single pasted provision ticket supports the split via the node's47 scope-down exchange (lofi-node#4): derive a parent-linked sync ticket, persist that silently,48 gate the provision original. Revoking the parent cascades.494. **Attempt PRF, never detect it**: `getClientCapabilities` reports `not-reported` across most of50 the real matrix and PRF is a property of the authenticator, not the browser. Enrollment attempts51 the evaluation and records in the envelope what actually succeeded; the open path never consults52 a capability report.535. **No-PRF fallback**: provision capability is not persisted at all — memory-only for the session,54 with ticket custody pushed to the user's password manager (the enrollment form is a55 `current-password` field with a label-as-username companion, so managers save on first paste and56 autofill later). The node stores only secret hashes, so mint time is the only time the ticket can57 enter the user's custody; the CLI says so.5859Rejected alternatives: PRF-sealing the whole sink record (a passkey tap before every boot's60WebSocket, breaking ceremony-free boot for the common path); gating on the capability report (wrong61on most real devices); a passphrase slot as the default no-PRF fallback (envelope security becomes62the weakest slot, and password-manager custody yields gesture-gated persistence without a passphrase63UX to own); persisting the sync ticket in cleartext with a narrowed conformance check (the universal64envelope keeps one storage format and an unconditional no-plaintext-bearer assertion).6566Rule that makes optimistic PRF safe to ship: only PRF-seal material that is recoverable out-of-band.67A deleted passkey or an uncooperative authenticator means re-enrollment, never data loss.6869## Procedure7071`deno task test` (envelope and data-sink suites), `deno task check`, `deno task build`,72`deno task test:golden`. The conformance check enrolls a provision-scoped ticket and scans every73stored value for the secret and URL in cleartext, base64, base64url, and URL-encoding, then asserts74the record parses as a v1 envelope and restores to the exact ticket.7576## Evidence7778`package/runtime/envelope_test.ts` (multi-slot open, fall-through, tamper → corrupt, purpose79binding, strict validation) and `package/runtime/data-sink_test.ts` (sealed round-trip, cleartext80migration, unopenable-not-destroyed, no-plaintext-bearer scan). Golden suite passes through the81boot-time restore. lofi-node#4 carries the exchange and cascade tests.8283## Contract delta8485The stored declaration is no longer cleartext; declaration APIs (`declareDataSink`,86`declareSinkFromTicket`) became asynchronous; a sealed record whose device key is gone reports87`unopenable`, leaves the record intact, and the device runs local-only until re-enrollment88(`docs/troubleshooting.md`). The XSS posture is unchanged in phase 1 and documented as such.8990## Follow-up9192PRF slot wiring, the enrollment exchange flow, and password-manager custody UX landed as A3 phase 293(`provision.ts`, `authenticateAndDerivePrfSecret`, `TicketEnrollForm`); field encryption is scoped94in #126 with the account-private/group-shared split; claim alignment continues in #122 §2 and §4.