sync-and-recovery and tickets-explained now state the declaration persists only as a sealed envelope, with the exact scope of that protection (at rest, not same-origin script). Troubleshooting gains the partially-cleared-site-data path: an unopenable record deliberately runs local-only and re-enrollment resumes under the same account. seed.md's PRF field-encryption item is promoted from stretch to required per the owner decision, pointing at the #126 scope split. Decision record 0001 captures the envelope design, the capability split, the attempt-don't-detect PRF posture, the no-PRF custody fallback, and the rejected alternatives. Part of #123 (A3 phase 1 + C2).
- commit
- 5e158e58f6809b4831e6096ad3cb829f1eca13ce
- parent
- 6286d53b53d29805ce06702a10effb617fd71d6d
- author
- Dami <dami.etoile@gmail.com>
| A | docs/ | 94 | ++++++++++++++++++++++++++++++ |
| M | docs/ | 8 | +- |
| M | docs/ | 4 | +- |
| M | docs/ | 10 | ++- |
| M | docs/ | 8 | +++ |
@@ -0,0 +1,94 @@
1+# Sealing the data sink at rest: slotted envelope with a capability split2+3+Date: 2026-07-18\4+Decision: adopt a multi-slot at-rest envelope with a silent device-key baseline; split sync and5+provision capability tiers at rest; attempt PRF, never detect it; no-PRF fallback is memory-only6+provision capability with password-manager custody7+8+## Question9+10+The enrolled app ticket's URL is a bearer credential (`/t/<secret>`), and for a provision-scoped11+ticket, possession is store administration. How does it persist on a device so that nothing12+bearer-shaped sits in storage in cleartext, without taxing every boot of a synced app with a passkey13+ceremony — and what happens on devices whose authenticator cannot evaluate the WebAuthn PRF14+extension?15+16+## Contract IDs17+18+No existing measurable promise covers at-rest credential custody; the affected documented semantics19+are the ticket-bearer bullets in `docs/sync-and-recovery.md` and `docs/node/tickets-explained.md`.20+The threat-model disclosure page (#122 §2) is where the claim set gets a permanent home.21+22+## Exact versions23+24+`@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 the26+browser baseline; `jazz-tools@2.0.0-alpha.53` unchanged.27+28+## Control29+30+The pre-#125 behavior: the sink declaration, including the full ticket URL, stored as cleartext JSON31+in `localStorage` under `lofi:data-sink:<anchorAppId>`.32+33+## Decision34+35+1. **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.40+2. **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.44+3. **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.49+4. **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.53+5. **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.58+59+Rejected alternatives: PRF-sealing the whole sink record (a passkey tap before every boot's60+WebSocket, breaking ceremony-free boot for the common path); gating on the capability report (wrong61+on most real devices); a passphrase slot as the default no-PRF fallback (envelope security becomes62+the weakest slot, and password-manager custody yields gesture-gated persistence without a passphrase63+UX to own); persisting the sync ticket in cleartext with a narrowed conformance check (the universal64+envelope keeps one storage format and an unconditional no-plaintext-bearer assertion).65+66+Rule that makes optimistic PRF safe to ship: only PRF-seal material that is recoverable out-of-band.67+A deleted passkey or an uncooperative authenticator means re-enrollment, never data loss.68+69+## Procedure70+71+`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 every73+stored value for the secret and URL in cleartext, base64, base64url, and URL-encoding, then asserts74+the record parses as a v1 envelope and restores to the exact ticket.75+76+## Evidence77+78+`package/runtime/envelope_test.ts` (multi-slot open, fall-through, tamper → corrupt, purpose79+binding, strict validation) and `package/runtime/data-sink_test.ts` (sealed round-trip, cleartext80+migration, unopenable-not-destroyed, no-plaintext-bearer scan). Golden suite passes through the81+boot-time restore. lofi-node#4 carries the exchange and cascade tests.82+83+## Contract delta84+85+The 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.89+90+## Follow-up91+92+PRF slot wiring, the enrollment exchange flow, and password-manager custody UX (tracked as A3 phase93+2 on #123); field encryption scoped in #126 with the account-private/group-shared split; claim94+alignment in #122 §2 and §4.@@ -59,7 +59,7 @@ tickets per device or context so revocation is scoped.
5959 6060 You paste; the app does the rest. The ticket is validated (against the same rules this node6161 enforces; the two repos share machine-readable conformance fixtures), declared as the device's sync62-location, and sync is elected in one step. The app keeps the ticket URL in a device-local record and63-never surfaces it through its session snapshot — only the host and your label show. For app64-developers, the framework call behind that paste is `enrollSyncTicket`, documented in65-[Sync and recovery](/docs/sync-and-recovery).62+location, and sync is elected in one step. The app keeps the ticket URL in a sealed device-local63+record (encrypted at rest under a device-bound key) and never surfaces it through its session64+snapshot — only the host and your label show. For app developers, the framework call behind that65+paste is `enrollSyncTicket`, documented in [Sync and recovery](/docs/sync-and-recovery).@@ -22,7 +22,7 @@ targeting Android Chrome 148+ / iOS Safari 16.4+ as hard floors**.
2222 | UI runtime | **Preact 10 islands via `client:only="preact"`** | Skips SSR entirely — correct when all data hydrates from local storage. |2323 | Data | **jazz-tools 2.0 alpha (pinned exact version), OPFS persistent mode** | ~5x faster than the 0.20.x IndexedDB backend. Persistence requires SharedWorker + Web Locks + MessageChannel; fallback is memory-only — hence the hard platform floors below. |2424 | Sync backend | **Jazz Cloud** (managed WebSocket) | Zero-config; self-hosting later changes nothing client-side. |25-| Auth | **Jazz PasskeyAuth + 24-word passphrase recovery** | Jazz custodies the account secret in a resident WebAuthn credential; wrap, don't reinvent. PRF-derived E2E-at-rest is an optional stretch. |25+| Auth | **Jazz PasskeyAuth + 24-word passphrase recovery** | Jazz custodies the account secret in a resident WebAuthn credential; wrap, don't reinvent. PRF-derived E2E-at-rest is required for account-private fields (#126). |2626 | PWA | **@vite-pwa/astro v1.2 (Workbox)** | SW's job stays deliberately small: shell precache + push readiness. The SW never touches sync or OPFS. Full prerender = full offline shell for free (precache only covers prerendered routes). |2727 | Cross-island state | **@preact/signals + module-scope singleton client** | React/Preact context can't cross Astro island boundaries; a module singleton + signals sidestep it. No provider component in the public API. |2828 | Toolchain | **Deno 2.9** task runner + server; Astro/Vite under npm-compat | Fallback if npm-compat friction appears: Node for `astro build` only, Deno for everything else. |@@ -160,7 +160,7 @@ exists, which is what makes multi-island apps work.
160160 | 2 | **Astro shell + islands + Deno toolchain** — scaffold workspace, port spikes into `@nzip/lofi` subpath modules, two _separate_ islands sharing one client and live-updating each other (acceptance test for the singleton design); `deno task dev/build/serve` end-to-end with @deno/astro-adapter | 1 wk | Island-boundary state sharing works; npm-compat friction. **Soft kill:** Astro misbehaves under Deno → Node for build, Deno for serve; cap at 1 day |161161 | 3 | **PWA hardening** — Workbox shell precache, manifest, Android install prompt, iOS coach-mark, EU-iOS degrade, `storage.persist()` incl. Safari notification-permission dance, reconnect-on-foreground verified on devices | 1 wk | Offline cold-start (airplane mode → installed PWA → shell + data render); WS reconnect reliability. Failures here are degradable UX, not fatal |162162 | 4 | **Demo app "Ticktick-lite"** — checklists, shared lists via Group invite links (two accounts, two devices), sync badge, offline edits, conflict-free merge test | 1 wk | Is the API pleasant; where the abstraction leaks; invite-revocation gap surfaces concretely |163-| 5 | **(Stretch) PRF-derived E2E-at-rest** — WebAuthn PRF → HKDF → field-level encryption before data enters Jazz | optional | Only if a real threat model demands it; Safari 18 iCloud-Keychain-only; feature-detect, never require |163+| 5 | **PRF-derived E2E-at-rest** — WebAuthn PRF → HKDF → field-level encryption before data enters Jazz (promoted from stretch to required; scope split in issue #126) | optional | Only if a real threat model demands it; Safari 18 iCloud-Keychain-only; feature-detect, never require |164164 165165 ## Verification166166 @@ -62,7 +62,8 @@ Enrollment declares the ticket as this device's _data sink_ and elects sync in o
6262 data pushes up under the same account identity, exactly as electing against a compiled managed app6363 does. `parseSyncTicket` validates a pasted string without enrolling it (returns `null` on any6464 malformed input), and `readDeclaredSink` / `clearDeclaredSink` manage the declaration directly. A65-non-ticket location can be declared with `declareDataSink({ appId, serverUrl })`.65+non-ticket location can be declared with `await declareDataSink({ appId, serverUrl })` —66+declarations are asynchronous because the record is sealed before it is stored.6667 6768 Semantics to rely on:6869 @@ -74,8 +75,11 @@ Semantics to rely on:
7475 - **One store, one active sink.** Declaring a different sink over an existing one is refused; clear7576 the declaration first. Local data and elections survive clearing.7677 - **The ticket URL is a bearer credential.** It is used verbatim as the sync server (its secret path77- is what authorizes transport), stored in a device-local record, and never exposed through the78- session snapshot — `session.sink` carries only the source, host, and label.78+ is what authorizes transport) and never exposed through the session snapshot — `session.sink`79+ carries only the source, host, and label. At rest the declaration persists only as a sealed80+ envelope under a device-bound key, so nothing bearer-shaped sits in storage in cleartext. That81+ protects the record against disk images, backups, and storage exfiltration; it does not protect it82+ from script running on the same origin, which can drive the silent open itself.7983 - **Tickets carry a scope.** A plain (or `scope: "sync"`) ticket is transport only; a8084 `scope: "provision"` ticket additionally administers the store through the node's gate — the basis8185 for opt-in store provisioning. A ticket with an unrecognized scope is rejected outright rather@@ -116,6 +116,14 @@ Check these states separately:
116116 “Sync configured” is not proof that a specific user opted in or that a specific write reached the117117 server.118118 119+## A device with an enrolled ticket came back local-only120+121+The declared sync location persists as a sealed record whose key lives in the browser's IndexedDB.122+Clearing site data partially — IndexedDB without localStorage, or a browser "free up space" eviction123+— leaves the record in place but unopenable, and the device deliberately falls back to local-only124+rather than guessing. Local data is untouched. Re-enroll the ticket (paste it again) to declare the125+sink and resume syncing under the same account.126+119127 ## Recovery does not restore a recent item120128 121129 The phrase restores account authority, not unsynced device storage. Data returns only if it reached