Skip to content

0007. Encrypted on-device persistence for the Redux store

Date: 2026-08-17

Author: Eugene Nagorny

Stakeholders: Mobile team, Security (pen-test remediation), QA

Status: Proposed

Context

The Bulletproof penetration test (PT81969-6974, finding R1773 / DEP-96, severity Medium) found the Engineer app's persisted state sitting in plaintext on both platforms: full user profile (name, email, phone, address), company and Gas Safe details, job and appliance data, insurance dates, and uploaded compliance certificates. On Android that is /data/<pkg>/databases/AsyncStorage*; on iOS it is RCTAsyncLocalStorage_V1. A filesystem pull off a lost, shared, or rooted device reads all of it.

Everything the app persists goes through a single redux-persist root (persist:root) backed by AsyncStorage, so the exposure is one key. APY-2180 tracks the remediation under the APY-832 tech-debt epic.

Three storage backends were evaluated, each built end to end on its own branch:

react-native-mmkvexpo-sqlite + SQLCipherexpo-sqlite/kv-store
CipherAES-256-CFB, unauthenticated; CRC32 over ciphertextAES-256-CBC + HMAC page auth
Can it be encrypted?Yes, per instanceYes, via PRAGMA key on every connectionNo. See below
Key handlingRuntime argument to createMMKVPRAGMA key statement, plus useSQLCipher build flag
Startup costNone — synchronous JSI, opens during module evaluationAsync open ⇒ a bootstrap gate in front of persistStore
Silent-plaintext riskNone — no key, no storeReal — drop the build flag or the pragma and it writes plaintext with no errorCertain
FingerprintNew native module ⇒ store release, no OTAAlready a dependencyAlready a dependency

expo-sqlite/kv-store looks like the smallest possible change and is the one option that cannot work at all. SQLiteStorage never issues PRAGMA key, SQLiteOpenOptions has no key field, and the obvious workaround — pre-opening a keyed connection so the native connection cache hands it back — breaks on multiSet, multiMerge, and the updater form of setItem, because Transaction.createAsync forces useNewConnection: true. The combination of useSQLCipher and kv-store therefore produces a silently unencrypted database. A plan for fixing this upstream is written up in plans/expo-sqlite-kv-store-encryption-pr.md.

The refinement notes on the ticket anticipated that the encryption key would have to be fetched before the data layer could come up, making this "a meaningful change to the startup sequence". That turned out not to be true for MMKV: expo-secure-store's getItem/setItem are synchronous native calls, so the key is available during module evaluation and no bootstrap gate is needed. That is the single biggest difference between the candidates.

Decision

Persist the whole redux-persist root into a single AES-256 MMKV instance, with the key held in the platform secure store.

  • One store, not per-slice encryption. The blocklist already keeps volatile slices (api, auth, snackbar, coldStart, uploads, flags) out of persistence; everything else contains PII or is cheap to encrypt alongside it. Sharding persist:root into several keys is a worthwhile future change for write cost, not a security one, and was kept out of this ticket.
  • Key generation and storage. 32 bytes from crypto.getRandomValues, mapped onto a 64-character alphabet, written once to expo-secure-store under AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY. Written once and never rewritten, because a second write on iOS takes the SecItemUpdate path and silently keeps the original accessibility.
  • Store location. Library/Application Support on iOS rather than MMKV's default of Documents/mmkv, which is the user-facing, iCloud-backed container. Android's default is already the app's private files directory.
  • One-shot migration, not a forced logout. persist:root is copied across as an opaque string so _persist.version arrives untouched and createMigrate replays exactly the same 2 → 16 chain. The copy is read back before the source is deleted, completion is recorded last so a crash repeats the work rather than skipping it, and the storage adapter queues every read and write behind the migration so rehydration cannot overtake it.
  • Purge the old files, not just the rows. On Android AsyncStorage.clear() only deletes rows; the values stay recoverable in the SQLite free pages and in the write-ahead log. The Room AsyncStorage database, its -wal/-shm, and the legacy RKStorage database that next-storage copies from and never deletes are all removed. The other production consumer is Segment, whose sovran store persists through AsyncStorage, so the purge also drops its queued events and resets its anonymous id once per install. That is unavoidable on Android — the free pages are the point — and acceptable because Segment is being retired. PostHog is unaffected: it persists through expo-file-system and never reaches its AsyncStorage fallback.
  • A canary key distinguishes "empty" from "undecryptable". Opening MMKV with the wrong key neither throws nor fails the CRC: the CRC is computed over the ciphertext (MMKV.cpp:446) and its digest lives in an unencrypted meta file, while the decode failure is swallowed (MiniPBCoder.cpp:543). From JS the two states are identical. Without the canary the first write would append on top of PII ciphertext that can never be read again. The store file is probed for before createMMKV, because MMKV ftruncates the file into existence on open.
  • Every failure path is total. A store that cannot be opened reads empty and drops writes rather than overwriting what is on disk, and the migration promise always resolves — a rejection reaches redux-persist as a failed read, which rehydrates defaults and then flushes them over the stored blob. For the same reason persistConfig sets timeout: 0, disabling redux-persist's 5-second rehydration timeout, which does precisely that wipe on a slow device.

Alternatives Considered

  1. expo-sqlite with SQLCipher, used directly — rejected. It is a genuine option and was fully built, but it loses on two counts. Opening the database is asynchronous, so persistStore has to sit behind a bootstrap gate, changing app startup. More importantly its encryption can fail open: useSQLCipher is a build-time plugin flag and PRAGMA key is a statement that must be issued on every connection, so a dropped flag or a missed pragma yields a working, unencrypted database with no error anywhere. MMKV has no equivalent failure — without a key there is no store.

  2. expo-sqlite/kv-store as a drop-in AsyncStorage replacement — rejected as unimplementable, not merely inferior; see Context. The failure is silent, which is the worst property a security control can have.

  3. Encrypting only the sensitive slices — rejected as more moving parts for less coverage. It requires a judgement call about which slices are sensitive on every future slice added, and the finding lists profile, company, job, and certificate data, which is most of what is persisted.

  4. Relying on OS-level protection alone (iOS Data Protection, Android FBE, plus turning off allowBackup) — rejected as not addressing the finding. Both backup vectors are in fact already closed — expo-secure-store's data-extraction rules restrict Android backups to sharedpref, so databases/ never leaves the device — but the reported reproduction is a filesystem pull on an unlocked, compromised device, which OS-level protection does not defend against.

Consequences

  • AC2 is met in substance but not to the letter. The key is not hard-coded and lives only in the Keychain/Keystore. It cannot, however, stay out of JS: MMKV takes it as a JavaScript string, so it is materialised on the JS heap at open time. SQLCipher is no better — the key goes in through PRAGMA key. Any JS-side encrypted store has this property; avoiding it entirely needs a fully native storage layer. An attacker who can attach a debugger to a running app can still recover the key.

  • AC4 passes for the reported attack and not for a stronger one. A filesystem pull now yields ciphertext, including our own key names, which MMKV encrypts along with the values. A live-instrumentation attacker on the same rooted device is not defeated, and cannot be by this control.

  • AC3 is not covered here. Plaintext uploaded documents and photos in /cache are a separate concern; the on-device PII purge landed under APY-2300.

  • No OTA. MMKV is a native module, so this changes the fingerprint and needs a store release on a release train. That also makes the migration safe against OTA rollback: an older JS bundle cannot be served to the new binary. A binary rollback after migration would find AsyncStorage purged and log the engineer out.

  • A device restore is a key loss by design. THIS_DEVICE_ONLY keeps the key out of the iCloud Keychain, so an engineer restoring onto a new handset finds an undecryptable store, takes a canary wipe and signs in again with local job state gone. That is the intended trade — a key that followed the backup would put the plaintext one restore away — but it is a real cost for the ~24% of job work that spans an app restart, and the ~66% of Solar installations that do. AFTER_FIRST_UNLOCK rather than WHEN_UNLOCKED is deliberate for the same reason the store opens synchronously: a background launch can reach module evaluation while the device is still locked, and WHEN_UNLOCKED would fail that read.

  • The key carries 192 bits of entropy. MMKV takes the key as a JavaScript string and uses its bytes, so holding 32 characters to 32 UTF-8 bytes forces every character below 0x80 — 7 bits each, a 224-bit ceiling for this shape of key, not 256. Drawing from a 64-character alphabet spends 32 of those bits on making the key safe to log, url-encode and JSON-quote. AESCrypt.cpp:55 copies exactly 32 bytes for AES-256, so the key length is right and 192 bits is far above any practical attack. Changing the alphabet makes every existing store undecryptable.

  • Key loss logs the engineer out silently. If the Keychain item disappears, the store is undecryptable, the canary clears it, and the engineer signs in again with all local job state gone. This is reported to Sentry and is the reason the canary exists at all; a persisted_store_migrated PostHog event gives the migration-launch denominator, with signed_in_before = true and signed_in_after = false as the rollout halt condition. That denominator overcounts slightly: clearing the store also drops the completion marker, so the next launch re-runs the migration against an already-purged AsyncStorage and emits a second event with nothing-to-copy and signed_in_before = false — noise in the count, never in the halt condition.

  • No tamper detection. MMKV's AES-CFB is unauthenticated and its CRC32 covers the ciphertext, so an attacker with write access to the file can corrupt or flip bits without detection. The threat model in the finding is disclosure, not integrity, and a device-local integrity attack has better targets than the Redux blob.

  • A hang replaces a wipe. With timeout: 0, a storage promise that never settles leaves PersistGate rendering nothing rather than rehydrating defaults. This is deliberate: a hang is recoverable by force-quitting, a wipe of the stored blob is not.

  • Identical writes are deduplicated in JS. MMKV's native compareBeforeSet does the same job but asserts !m_crypter and is invalid on an encrypted instance, so the check has to live in the storage adapter.

  • The migration cannot be covered by E2E. Every wdio session uninstalls before installing, so app data is always wiped before the first launch. Verification is a manual upgrade-in-place plus a container grep, both local-iOS-simulator-only — BrowserStack's AFC access is limited to Documents, and Android needs a debuggable build.

  • The migration's exit condition is a flag, not a date. legacyStoreMigration can only be deleted once no install can still hold an un-migrated persist:root — that needs min-app-version armed at or above this release, and even then engineers returning from months of dormancy arrive on a fresh binary carrying old state. Roughly 34 per month return after 90+ days. AsyncStorage can be dropped at the same time: every library here declares it optional: true, so nothing else forces it to be installed.

Relevant Jira Tickets

  • APY-2180 — Implement RN mmkv for secure PII storage (this decision; PR #4849)
  • DEP-96 — [Pen Test Eng App] Sensitive Data in Memory — Android and iOS (source finding R1773)
  • APY-2300 — Purge submitted CP1 and job-photo PII from on-device storage (done; covers AC3's data-minimisation half)
  • APY-2188 — iOS keychain accessibility scope, and APY-2309 — moving tokens out of the password field, under the APY-2181 Cognito epic
  • APY-1553 — Ensure token is invalidated immediately on user logout
  • Sibling pen-test hardening: see ADR 0006 and ADR 0005