Zum Inhalt springen
Docs

Befehlspalette

Zu einer Seite oder Ansicht springen oder ein Todo per Name finden.

Änderungen

Encrypted local store on the device

The repository's architectural decision, rationale and consequences.

Markdown

Status: accepted · Date: 2026-10-31 · Related: ADR-0017, ADR-0026

Enforcement: apps/mobile/src/platform/local-store/local-store.test.ts (US-12 raw-read, SCOPE isolation, STORE atomic transaction); apps/mobile/src/platform/vault/vault.test.ts (US-03 lockout, counters, clocks; RF-02 inactivity and PIN format); apps/mobile/src/platform/vault/keystore.test.ts (wrap-key init, shared counter); apps/mobile/src/platform/enrollment/enrollment.hooks.test.ts (destroy wipes every namespace); scripts/mobile-feature-boundaries.test.mjs keeps the platform layer where it is.

Wundklar runs bedside, often offline. The device needs clinical state at hand — patients, wounds, measurements, outbox entries — but a stolen or seized phone must never leak plaintext. The web runtime (Expo web) is also our dev target this quarter and will stay in production for internal review tools; its storage story is different from a native sandbox. We also carry three identity axes — server, account, Organization — and a single device may see several Organizations in one install.

Decision

One interface, LocalStore, with the IndexedDB implementation now and SQLCipher later. platform/local-store/ holds:

  • local-store.ts — the interface. Seven stores (records, outbox, blobs, conflicts, meta, drafts, linkVault), multi-store transactions, non-identifying indexes only (entity, id, rev, patientId, woundId, status).
  • idb-local-store.ts — web implementation on the idb package. One IndexedDB database per scope wk:<serverOrigin>:<userId>:<organizationId>; wipe deletes the database.
  • memory-local-store.ts — in-memory adapter for tests. Snapshots back on transaction error so multi-store writes stay atomic.
  • encrypted-store.ts — AES-256-GCM payload encryption under the vault's localKey with AAD <store>:<key>. The row stored in the underlying LocalStore carries only { key, cipher, …whitelisted indexes }; the index object is projected field by field and any other key is rejected at runtime. The store asks the vault for localKey on every operation (localKey: vault.localKey), so a handle opened while unlocked fails closed with VaultLockedError once the vault locks.

Scope and fencing. The scope is the (server, userId, organizationId) triple, as in platform/offline/snapshot-scope.ts; useEnrollmentState() carries it. createLocalStoreSession().switchTo(scope, localKey) owns the one open namespace: it calls EncryptedStore.fence() on the old one before closing it. The fence bumps a revision; any transaction captured under an older revision throws ScopeChangedError instead of landing, mirroring the pattern scoped-snapshot.ts uses for the cache. Closing an IndexedDB namespace aborts the transactions it still has in flight, so a write already queued in IndexedDB is dropped too and surfaces as ScopeChangedError.

Vault counters and clocks. Every PIN attempt runs inside VaultKeystore.exclusive (Web Locks API across tabs, an in-tab queue otherwise), reloads the record and persists the incremented counter before the PBKDF2 derivation; the web keystore creates its wrapping key with a check-and-put in one readwrite transaction. Inactivity runs on the monotonic clock (performance.now()). The 15-minute lockout persists its wall-clock deadline and, within one page session, is enforced on the monotonic clock from the moment it was set or first loaded, so moving the wall clock neither shortens it nor stretches an unlocked session. A lockout loaded after a reload is capped at the policy duration. Residual risk: the web has no persistent monotonic clock, so reloading the page after moving the system clock forward ends the lockout early; the 10-failure destroy still bounds the total number of guesses. On destroy, the device enrollment deletes every wk: namespace database on the origin (the local key is per device).

Payload encryption, not database encryption. Payloads are always encrypted under localKey. We considered relying on the browser's at-rest encryption and declined: the quota is user-controlled, private browsing drops to session scope, and we want ciphertext on disk in all cases. The non-identifying indexes stay in cleartext because IndexedDB cannot index arbitrary bytes.

Native path (ticket 75). The native implementation of LocalStore is expo-sqlite + SQLCipher, refusing to open unless PRAGMA cipher_version answers. ADR-0026 covers that handoff.

Consequences

  • A single interface and a single contract (indexes, AAD rule, transaction semantics) carries all three implementations. The sync engine (ticket 16) and later features never know whether they are talking to IndexedDB, memory or SQLCipher.
  • The index whitelist survives schema growth: adding a new index is deliberate and audited, so no identifier accidentally sits in cleartext.
  • Persistent storage is best-effort on the web: navigator.storage.persist() is requested on first open and reported to the user through the dev /dev/platform screen (and in production, through the sync centre, ticket 68).
  • Switching to a different account or Organization is a scope move: the open store is closed, a new one opens, and in-flight writes are fenced through EncryptedStore.

Alternatives considered

  • IndexedDB without payload encryption. Rejected: a browser profile backup leaks everything the device cached.
  • A WASM SQLCipher on the web. Not now: no vetted preview on web (OPFS SQLite without SQLCipher), delivery cost of the WASM, and the AEAD layer buys us the same at-rest property with less rope.
  • A single global IndexedDB and tenant columns. Rejected: no isolation across accounts on the same device, and a scope move cannot cheaply enumerate rows to wipe.
Auf GitHub bearbeitenDie Dokumentation ist auf Englisch.