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-mmkv | expo-sqlite + SQLCipher | expo-sqlite/kv-store | |
|---|---|---|---|
| Cipher | AES-256-CFB, unauthenticated; CRC32 over ciphertext | AES-256-CBC + HMAC page auth | — |
| Can it be encrypted? | Yes, per instance | Yes, via PRAGMA key on every connection | No. See below |
| Key handling | Runtime argument to createMMKV | PRAGMA key statement, plus useSQLCipher build flag | — |
| Startup cost | None — synchronous JSI, opens during module evaluation | Async open ⇒ a bootstrap gate in front of persistStore | — |
| Silent-plaintext risk | None — no key, no store | Real — drop the build flag or the pragma and it writes plaintext with no error | Certain |
| Fingerprint | New native module ⇒ store release, no OTA | Already a dependency | Already 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. Shardingpersist:rootinto 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 toexpo-secure-storeunderAFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY. Written once and never rewritten, because a second write on iOS takes theSecItemUpdatepath and silently keeps the original accessibility. - Store location.
Library/Application Supporton iOS rather than MMKV's default ofDocuments/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:rootis copied across as an opaque string so_persist.versionarrives untouched andcreateMigratereplays 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 RoomAsyncStoragedatabase, its-wal/-shm, and the legacyRKStoragedatabase that next-storage copies from and never deletes are all removed. The other production consumer is Segment, whosesovranstore 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 throughexpo-file-systemand 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 beforecreateMMKV, because MMKVftruncates 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
persistConfigsetstimeout: 0, disabling redux-persist's 5-second rehydration timeout, which does precisely that wipe on a slow device.
Alternatives Considered
expo-sqlitewith SQLCipher, used directly — rejected. It is a genuine option and was fully built, but it loses on two counts. Opening the database is asynchronous, sopersistStorehas to sit behind a bootstrap gate, changing app startup. More importantly its encryption can fail open:useSQLCipheris a build-time plugin flag andPRAGMA keyis 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.expo-sqlite/kv-storeas 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.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.
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 tosharedpref, sodatabases/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
/cacheare 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_ONLYkeeps 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_UNLOCKrather thanWHEN_UNLOCKEDis deliberate for the same reason the store opens synchronously: a background launch can reach module evaluation while the device is still locked, andWHEN_UNLOCKEDwould 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:55copies 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_migratedPostHog event gives the migration-launch denominator, withsigned_in_before = trueandsigned_in_after = falseas 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 withnothing-to-copyandsigned_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 leavesPersistGaterendering 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
compareBeforeSetdoes the same job but asserts!m_crypterand 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.
legacyStoreMigrationcan only be deleted once no install can still hold an un-migratedpersist:root— that needsmin-app-versionarmed 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 itoptional: 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