docs: warn that the default AsyncStorage persistor is unencrypted - #1327
docs: warn that the default AsyncStorage persistor is unencrypted#1327sunitaprajapati89 wants to merge 1 commit into
Conversation
| get: async (key) => { | ||
| const value = await EncryptedStorage.getItem(key); | ||
| return value ? JSON.parse(value) : undefined; | ||
| }, |
There was a problem hiding this comment.
This get has no try/catch, and unlike the default it replaces a rejecting get doesn't degrade — it permanently wedges the SDK.
JSON.parse(value) throws on any truncated/corrupted blob, and EncryptedStorage.getItem itself can reject: the linked library sets no kSecAttrAccessible, so Keychain defaults to kSecAttrAccessibleWhenUnlocked and access fails with errSecInteractionNotAllowed when the app is launched in the background before first device unlock.
When that happens, packages/sovran/src/store.ts:131 only console.warns and never calls config.persist.onInitialized. So hasRestoredUserInfo/hasRestoredPendingEvents stay false, SegmentClient.init() blocks forever on storageReady() (packages/core/src/analytics.ts:295), isReady never flips, and every subsequent event is parked in pendingEvents and never sent. Storage readiness has no timeout (unlike QueueFlushingPlugin's 1s restoreTimeout), so it's unrecoverable for the life of the process.
AsyncStoragePersistor try/catches both methods and returns undefined, so as written this snippet is strictly less safe than the default:
| get: async (key) => { | |
| const value = await EncryptedStorage.getItem(key); | |
| return value ? JSON.parse(value) : undefined; | |
| }, | |
| get: async (key) => { | |
| try { | |
| const value = await EncryptedStorage.getItem(key); | |
| return value ? JSON.parse(value) : undefined; | |
| } catch { | |
| return undefined; | |
| } | |
| }, |
| set: async (key, state) => { | ||
| await EncryptedStorage.setItem(key, JSON.stringify(state)); | ||
| }, |
There was a problem hiding this comment.
set can reject too — a locked-device Keychain write, or a pending-event queue large enough to exceed what the Keychain / EncryptedSharedPreferences write accepts. store.ts:150 swallows it with a console.warn, so integrators who copy this snippet get silent, permanent loss of cross-restart persistence (queued events dropped on app kill) with nothing surfaced to their error handler.
| set: async (key, state) => { | |
| await EncryptedStorage.setItem(key, JSON.stringify(state)); | |
| }, | |
| set: async (key, state) => { | |
| try { | |
| await EncryptedStorage.setItem(key, JSON.stringify(state)); | |
| } catch (e) { | |
| console.warn('Failed to persist Segment state', e); | |
| } | |
| }, |
Worth a line in the prose noting the queue blob can grow large, since that's the realistic trigger.
|
|
||
| \* The default value of `debug` will be false in production. | ||
|
|
||
| ### Data Storage & Security |
There was a problem hiding this comment.
This section tells integrators to swap in a storePersistor with no migration step or switchover warning, which is a breaking change for any app that already shipped.
Each sovran store is keyed independently and only ever read through the configured persistor, so an app adopting this loses all previously persisted AsyncStorage state on the upgrade build:
checkInstalledVersionseespreviousContext?.app === undefinedand fires a spurious "Application Installed" for every existing user (packages/core/src/analytics.ts:697)userInfois regenerated, so every existing user gets a newanonymousId— identity/stitching breaks- events queued at the moment of upgrade are dropped
That's the same failure this PR warns about 76 lines earlier (the new note at README.md:35 about "multiple 'Application Installed' events"), so it'd be good not to walk readers straight into it. At minimum a callout here; ideally the snippet reads the existing AsyncStorage key once and seeds the encrypted store before switching over.
|
|
||
| By default, `analytics-react-native` persists its state—`userId`, `identify` traits, and the queue of events pending upload—to disk using [`@react-native-async-storage/async-storage`](https://github.com/react-native-async-storage/async-storage). **This default persistor stores data as plaintext JSON with no encryption.** On a rooted/jailbroken device, via an ADB backup, or through forensic extraction, that data—including any PII passed to `identify` or `track` calls—can be read. | ||
|
|
||
| If your app handles sensitive PII (email, name, government IDs, etc.), supply your own `storePersistor` backed by encrypted storage, such as the OS Keychain/Keystore via [`react-native-encrypted-storage`](https://github.com/emeraldsanto/react-native-encrypted-storage) or [`react-native-keychain`](https://github.com/oblador/react-native-keychain). Your persistor must implement the [`Persistor`](https://github.com/segmentio/analytics-react-native/blob/master/packages/sovran/src/persistor/persistor.ts#L1-L18) interface: |
There was a problem hiding this comment.
The link for "the interface you must implement" points at persistor.ts#L1-L18, but those lines are PersistenceConfig (storeId/saveDelay/persistor/onInitialized). Persistor is lines 20–31, so a reader clicking through lands on the wrong interface.
Same stale anchor on the storePersistor row of the config table at README.md:101, which this PR also touches — worth fixing both to #L20-L31, or pinning a permalink SHA the way the defaultSettings row above it does.
| const segmentClient = createClient({ | ||
| writeKey: 'SEGMENT_API_KEY', | ||
| storePersistor: EncryptedStoragePersistor, | ||
| }); |
There was a problem hiding this comment.
Worth setting storePersistorSaveDelay in this example. QueueFlushingPlugin defaults it to 0 (packages/core/src/plugins/QueueFlushingPlugin.ts:55), so with an encrypted persistor every single tracked event re-serializes the entire pending queue and pushes it through a Keychain/Keystore write — an O(n) native crypto write per event on the hot path, getting worse as the queue grows.
| const segmentClient = createClient({ | |
| writeKey: 'SEGMENT_API_KEY', | |
| storePersistor: EncryptedStoragePersistor, | |
| }); | |
| const segmentClient = createClient({ | |
| writeKey: 'SEGMENT_API_KEY', | |
| storePersistor: EncryptedStoragePersistor, | |
| // Batch writes: the default of 0 re-encrypts the whole queue on every event. | |
| storePersistorSaveDelay: 1000, | |
| }); |
didiergarcia
left a comment
There was a problem hiding this comment.
I think we need to add a bit of error handling.
Summary
The default AsyncStorage persistor stores userId, identify
traits (email/name/etc.), and the full pending-event queue as cleartext
JSON, with no warning to integrators. This is an accepted tradeoff, not
a bug — the fix here is purely documentation.
Changes
packages/core/src/types.ts: JSDoc onstorePersistorexplaining thedefault is unencrypted and pointing to an encrypted alternative.
packages/sovran/src/persistor/async-storage-persistor.ts: doccomment on
AsyncStoragePersistorspelling out the exposure (rooted/jailbroken devices, ADB backup, forensic extraction) and what's at risk.
README.md: new Data Storage & Security section with a runnableexample of a
storePersistorbacked byreact-native-encrypted-storageand the
Persistorinterface; linked from the install note and thestorePersistorrow in Client Options.Testing
tsc --noEmitclean onpackages/coreandpackages/sovransovranStoragetest suite passes unchanged