Skip to content

docs: warn that the default AsyncStorage persistor is unencrypted - #1327

Open
sunitaprajapati89 wants to merge 1 commit into
masterfrom
Fix-default-persistor-writes
Open

docs: warn that the default AsyncStorage persistor is unencrypted#1327
sunitaprajapati89 wants to merge 1 commit into
masterfrom
Fix-default-persistor-writes

Conversation

@sunitaprajapati89

Copy link
Copy Markdown
Contributor

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 on storePersistor explaining the
    default is unencrypted and pointing to an encrypted alternative.
  • packages/sovran/src/persistor/async-storage-persistor.ts: doc
    comment on AsyncStoragePersistor spelling out the exposure (rooted/
    jailbroken devices, ADB backup, forensic extraction) and what's at risk.
  • README.md: new Data Storage & Security section with a runnable
    example of a storePersistor backed by react-native-encrypted-storage
    and the Persistor interface; linked from the install note and the
    storePersistor row in Client Options.

Testing

  • tsc --noEmit clean on packages/core and packages/sovran
  • Existing sovranStorage test suite passes unchanged
  • No runtime code paths touched — comments/docs only

Comment thread README.md
Comment on lines +121 to +124
get: async (key) => {
const value = await EncryptedStorage.getItem(key);
return value ? JSON.parse(value) : undefined;
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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;
}
},

Comment thread README.md
Comment on lines +125 to +127
set: async (key, state) => {
await EncryptedStorage.setItem(key, JSON.stringify(state));
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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.

Comment thread README.md

\* The default value of `debug` will be false in production.

### Data Storage & Security

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  • checkInstalledVersion sees previousContext?.app === undefined and fires a spurious "Application Installed" for every existing user (packages/core/src/analytics.ts:697)
  • userInfo is regenerated, so every existing user gets a new anonymousId — 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.

Comment thread README.md

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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread README.md
Comment on lines +130 to +133
const segmentClient = createClient({
writeKey: 'SEGMENT_API_KEY',
storePersistor: EncryptedStoragePersistor,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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 didiergarcia left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to add a bit of error handling.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants