Skip to content

Commit 111e21a

Browse files
authored
feat: offline db encryption (#3780)
## 🎯 Goal Let integrators encrypt the offline database at rest. The offline cache stores channels, messages, members, drafts and reminders, and right now we write all of it as plaintext `SQLite`. It's opt-in. Apps that don't pass the new prop behave exactly as they do today. One thing to know up front, since it shapes the rest of the PR: `op-sqlite` accepts an `encryptionKey` on a build without `SQLCipher` and then ignores it. You get a plaintext database and no error at any layer. So part of this change is detecting that and refusing to open the database, instead of passing the key along and assuming it was used. Accompanying docs PR: GetStream/docs-content#1521 ## 🛠 Implementation details ### API `Chat` takes one new prop: ```tsx <Chat client={client} enableOfflineSupport getEncryptionKey={getEncryptionKey}> ``` `getEncryptionKey?: () => Promise<string | undefined>` runs once per database open, so once per launch and again after a sign-out. Its result is passed to `SQLCipher` through `op-sqlite`. `SqliteClientError` and `SqliteClientErrorCode` are exported too. ### We throw instead of recovering When the database can't be opened with the encryption that was asked for, `Chat` throws a `SqliteClientError` from render and the integrator's error boundary handles it. We don't fall back to plaintext, we don't switch offline support off, and we don't delete anything. The reason is that all of those recoveries have a security consequence and there's no default that's right for everyone. Falling back to plaintext defeats the point of the feature and nothing tells you it happened. Dropping the cache decides a compliance question for the integrator. Deleting the file throws away offline actions that are still queued. We also can't tell "the Keystore isn't unlocked yet, try again shortly" from "something is wrong here, sign this device out". So we detect the failure and classify it, and the app decides what to do about it. ### Scenarios | Scenario | What it means | What the SDK does | Recommended recovery | | ------------------------------------------------------- | ------------------------------------------------------------ | ---------------------------------------------- | ------------------------------------------------------------------ | | No `getEncryptionKey` passed | Encryption not requested | Opens plaintext, same as today | n/a | | Key supplied, fresh install | Nothing on disk yet | Creates the database encrypted with that key | n/a | | Key supplied, plaintext database already on disk | Integrator is turning encryption on for an existing install | Throws `OFFLINE_DB_UNREADABLE` | Delete the database, remount `Chat` | | Key differs from the one the database was written with | Key rotated, or read from the wrong place | Throws `OFFLINE_DB_UNREADABLE` | Delete the database, remount `Chat` | | `getEncryptionKey` removed, encrypted database on disk | Integrator is turning encryption off again | Throws `OFFLINE_DB_UNREADABLE` | Delete the database, remount `Chat` | | `getEncryptionKey` throws | Key isn't available yet, e.g. Keystore still locked | Throws `ENCRYPTION_KEY_UNAVAILABLE` | Remount to retry, e.g. on next app foreground | | `getEncryptionKey` resolves `undefined` | Same as above | Throws `ENCRYPTION_KEY_UNAVAILABLE` | Remount to retry, e.g. on next app foreground | | Key supplied, native build has no `SQLCipher` | The key would be ignored and the database left plaintext | Throws `SQLCIPHER_BUILD_MISSING`, doesn't open | Not fixable at runtime, remount with `enableOfflineSupport={false}` | | Database file corrupted | Nothing to do with encryption | Throws `OFFLINE_DB_UNREADABLE` | Delete the database, remount `Chat` | Two of those rows need a closer look in review. `OFFLINE_DB_UNREADABLE` is not gated on `getEncryptionKey` being set, and that's on purpose, because of the "turning encryption off again" row. If we only threw it when a key was supplied, an integrator removing the prop would get a blank screen instead of an error they can recover from. I hit that on device. The last row is why the boundary is useful even for apps that never use encryption. A corrupted database gives you the same code, so anything using `enableOfflineSupport` can end up there. ### Where the error comes from `AbstractOfflineDB.init` in the LLC catches whatever `initializeDB` throws and doesn't re-throw it, so a caller can't find out why initialisation failed. I left that alone, because changing it would tie this PR to an LLC release. `OfflineDB` stores the reason on the instance on the way out instead, and the new hook reads it back once `init` has settled. No LLC changes needed for this. - `SqliteClient` resolves the key, opens through `SQLCipher` and maps failures onto the codes above. It also gets `preflightEncryption()`, which runs before `setOfflineDBApi`. Without that ordering the client attaches a database that's already dead, and the unguarded `await this.offlineDb.upsertChannels(...)` inside `queryChannels` rejects. You end up on a loading screen that never resolves. - `useInitializeOfflineDb()` is new and does preflight, attach, init and raise, with the init options behind an `options` param. It's pulled out of `Chat`, which loses 72 lines. - `OfflineDB` records `initializationError` and re-throws, so `init` still marks the database uninitialised. ## 🎨 UI Changes ## 🧪 Testing <!-- Explain how this change can be tested (or why it can't be tested) --> ## ☑️ Checklist - [x] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [x] PR targets the `develop` branch - [x] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [ ] Expo iOS and Android
1 parent 6d8ba12 commit 111e21a

11 files changed

Lines changed: 1006 additions & 40 deletions

File tree

examples/SampleApp/App.tsx

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
} from 'stream-chat-react-native';
2828

2929
import { MenuDrawer } from './src/components/MenuDrawer';
30+
import { OfflineDbBoundary } from './src/components/OfflineDbBoundary';
3031
import { useSampleAppComponentOverrides } from './src/components/SampleAppComponentOverrides';
3132
import {
3233
MessageInputFloatingConfigItem,
@@ -334,20 +335,35 @@ const DrawerNavigatorWrapper: React.FC<{
334335
chatClient: StreamChat;
335336
i18nInstance: Streami18n;
336337
}> = ({ chatClient, i18nInstance }) => {
338+
// `attempt` re-mounts <Chat> after the offline database has been deleted;
339+
// `offlineSupport` is switched off once there is no usable encryption key.
340+
const [attempt, setAttempt] = useState(0);
341+
const [offlineSupport, setOfflineSupport] = useState(true);
342+
343+
// The boundary stops rendering its children once it has caught (see its render), and
344+
// nothing else clears that. Keying it on both recovery levers re-mounts it when one is
345+
// pulled - without that it would sit on a blank screen forever, having already deleted
346+
// the database.
337347
return (
338-
<Chat
339-
client={chatClient}
340-
enableOfflineSupport
341-
isMessageAIGenerated={isMessageAIGenerated}
342-
i18nInstance={i18nInstance}
343-
useNativeMultipartUpload
348+
<OfflineDbBoundary
349+
key={`${attempt}-${offlineSupport}`}
350+
onGiveUp={() => setOfflineSupport(false)}
351+
onRetry={() => setAttempt((value) => value + 1)}
344352
>
345-
<StreamChatProvider>
346-
<UserSearchProvider>
347-
<DrawerNavigator />
348-
</UserSearchProvider>
349-
</StreamChatProvider>
350-
</Chat>
353+
<Chat
354+
client={chatClient}
355+
enableOfflineSupport={offlineSupport}
356+
i18nInstance={i18nInstance}
357+
isMessageAIGenerated={isMessageAIGenerated}
358+
useNativeMultipartUpload
359+
>
360+
<StreamChatProvider>
361+
<UserSearchProvider>
362+
<DrawerNavigator />
363+
</UserSearchProvider>
364+
</StreamChatProvider>
365+
</Chat>
366+
</OfflineDbBoundary>
351367
);
352368
};
353369

examples/SampleApp/ios/Podfile.lock

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ PODS:
298298
- React-utils
299299
- ReactNativeDependencies
300300
- Yoga
301-
- React-Core-prebuilt (0.86.0):
301+
- React-Core-prebuilt (0.86.2):
302302
- ReactNativeDependencies
303303
- React-Core/CoreModulesHeaders (0.86.2):
304304
- hermes-engine
@@ -2857,7 +2857,7 @@ PODS:
28572857
- SDWebImageWebPCoder (0.15.0):
28582858
- libwebp (~> 1.0)
28592859
- SDWebImage/Core (~> 5.17)
2860-
- stream-chat-react-native (9.7.2):
2860+
- stream-chat-react-native (9.7.6):
28612861
- hermes-engine
28622862
- RCTRequired
28632863
- RCTTypeSafety
@@ -3291,7 +3291,7 @@ SPEC CHECKSUMS:
32913291
GoogleAppMeasurement: 57270ccc2b77472d7e85c4cbe45972564eff78bb
32923292
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
32933293
GoogleUtilities: 766ace00c6b10d8148408f329d10c4f051931850
3294-
hermes-engine: 188393eb43a0cce2dfbf912e6d22c7bb6469957d
3294+
hermes-engine: 3730f5b467f988fa954ded67cbea8a9ba32d854c
32953295
libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7
32963296
libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f
32973297
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
@@ -3309,7 +3309,7 @@ SPEC CHECKSUMS:
33093309
React: 4b2532a459d15e1adf6c22d3e399e5c85a94220f
33103310
React-callinvoker: 0b8ce4057e02a0bd15cf0532596e8eb8c0392e92
33113311
React-Core: 5af045531a540ba3f65f07de1e3f585ddfb27948
3312-
React-Core-prebuilt: 13924a267683b3d6fa4bde9c80380becf83a9c5c
3312+
React-Core-prebuilt: 405cf395d66cf694faf9aed3483a21b5515cec85
33133313
React-CoreModules: 99b194a721de84ccfc1be149a0de52647dc38c0e
33143314
React-cxxreact: b7e8e254074fd8111d147202b391ccf7816946a6
33153315
React-debug: 3281bfefe5ece9a9d8b28bec3f871db229f9d8d8
@@ -3399,7 +3399,7 @@ SPEC CHECKSUMS:
33993399
SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57
34003400
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
34013401
SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377
3402-
stream-chat-react-native: e97f6d3ed0c2828b20610ffc0023ad7f9c90738d
3402+
stream-chat-react-native: ea3499916019c499ecb745be61e7ecf82f0fd999
34033403
Teleport: c56b30b08bd20d10da1efb0f21c6bc50c269ee6a
34043404
Yoga: 542a30dafe5b0f5f1d9f185ea7b2a3811ac54801
34053405

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import React from 'react';
2+
3+
import {
4+
SqliteClient,
5+
SqliteClientError,
6+
type SqliteClientErrorCode,
7+
} from 'stream-chat-react-native';
8+
9+
/**
10+
* `<Chat>` throws a {@link SqliteClientError} from render when it cannot open the
11+
* offline database - most often `OFFLINE_DB_UNREADABLE`, meaning the file on disk
12+
* cannot be read (corruption, or a database left behind from a different encryption
13+
* mode). It never silently continues without the cache; recovery is the application's
14+
* decision.
15+
*
16+
* The recommended recovery, shown here: the contents are a cache, so delete the
17+
* database and let it rebuild from the server. The only real loss is actions that were
18+
* queued while offline, so a real app may want to confirm with the user first.
19+
*
20+
* The `onGiveUp` path covers the codes that mean "no usable encryption key"
21+
* (`SQLCIPHER_BUILD_MISSING`, `ENCRYPTION_KEY_UNAVAILABLE`). Those only occur when
22+
* `<Chat>` is given a `getOfflineDbEncryptionKey` prop, which this sample does not do -
23+
* a new database would then be written in plaintext, so running online-only is the safe
24+
* response.
25+
*/
26+
type BoundaryProps = React.PropsWithChildren<{
27+
onGiveUp: () => void;
28+
onRetry: () => void;
29+
}>;
30+
31+
type BoundaryState = { code?: SqliteClientErrorCode };
32+
33+
export class OfflineDbBoundary extends React.Component<BoundaryProps, BoundaryState> {
34+
state: BoundaryState = {};
35+
36+
// Must return state, and render() must stop rendering the failing subtree. Returning
37+
// null here would re-render the same children, they would throw again, and React
38+
// would give up and unmount the whole app.
39+
static getDerivedStateFromError(error: unknown) {
40+
if (!(error instanceof SqliteClientError)) {
41+
// Not one of ours - re-throw so it reaches whatever boundary owns it.
42+
throw error;
43+
}
44+
return { code: error.code };
45+
}
46+
47+
componentDidCatch(error: unknown) {
48+
if (!(error instanceof SqliteClientError)) {
49+
return;
50+
}
51+
52+
if (error.code === 'OFFLINE_DB_UNREADABLE') {
53+
// The recommended recovery: the contents are a cache, so drop the database and
54+
// let it rebuild. Only actions queued while offline are lost.
55+
try {
56+
SqliteClient.deleteDatabase();
57+
} catch (deleteError) {
58+
console.warn('[SampleApp] could not delete the offline database', deleteError);
59+
}
60+
this.props.onRetry();
61+
return;
62+
}
63+
64+
// No usable key, so a new database would be plaintext. Run online-only instead.
65+
console.warn(`[SampleApp] offline encryption unavailable (${error.code}); going online-only`);
66+
this.props.onGiveUp();
67+
}
68+
69+
render() {
70+
return this.state.code ? null : this.props.children;
71+
}
72+
}

package/src/components/Chat/Chat.tsx

Lines changed: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Channel, OfflineDBState } from 'stream-chat';
66
import { useClientMutedUsers } from './hooks';
77
import { useAppSettings } from './hooks/useAppSettings';
88
import { useCreateChatContext } from './hooks/useCreateChatContext';
9+
import { useInitializeOfflineDb } from './hooks/useInitializeOfflineDb';
910
import { useIsOnline } from './hooks/useIsOnline';
1011

1112
import { ChannelsStateProvider } from '../../contexts/channelsStateContext/ChannelsStateContext';
@@ -24,7 +25,6 @@ import init from '../../init';
2425

2526
import { NativeHandlers } from '../../native';
2627
import { DEFAULT_MAX_SYNC_EVENTS_LIMIT } from '../../store/constants';
27-
import { OfflineDB } from '../../store/OfflineDB';
2828

2929
import type { Streami18n } from '../../utils/i18n/Streami18n';
3030
import { installNativeMultipartAdapter } from '../../utils/installNativeMultipartAdapter';
@@ -45,6 +45,50 @@ export type ChatProps = Pick<ChatContextValue, 'client'> &
4545
* Enables offline storage and loading for chat data.
4646
*/
4747
enableOfflineSupport?: boolean;
48+
/**
49+
* Encrypts the offline database at rest with SQLCipher, using the key this
50+
* resolves to. Only relevant when `enableOfflineSupport` is enabled. Leaving it
51+
* unset keeps the offline database unencrypted, which is the default.
52+
*
53+
* Requires a native build of `@op-engineering/op-sqlite` that includes SQLCipher.
54+
* Add the following to your application's `package.json` and rebuild the native
55+
* app - without the flag the key is accepted and then silently ignored:
56+
*
57+
* ```json
58+
* { "op-sqlite": { "sqlcipher": true } }
59+
* ```
60+
*
61+
* **Wrap `<Chat>` in an error boundary.** If the database cannot be opened with
62+
* the encryption you asked for, `<Chat>` throws a {@link SqliteClientError}
63+
* from render instead of continuing without it. The SDK deliberately takes no
64+
* recovery action of its own - it never deletes data, and never silently falls
65+
* back to an unencrypted or absent cache. Discriminate on `code`:
66+
*
67+
* - `OFFLINE_DB_UNREADABLE` - the file exists but this key cannot read it (the
68+
* key changed, or the database predates encryption). **Recommended recovery:
69+
* `SqliteClient.deleteDatabase()`, then re-mount `<Chat>`.** The contents are a
70+
* cache and are refetched from the server; the exception is actions queued while
71+
* offline, which are lost - prompt the user first if that matters to you.
72+
* - `ENCRYPTION_KEY_UNAVAILABLE` - the key could not be read (a locked keychain, a
73+
* launch before first unlock). The database is untouched. **Recommended
74+
* recovery: re-mount to retry** once the key is readable - for example when the
75+
* app next returns to the foreground.
76+
* - `SQLCIPHER_BUILD_MISSING` - the native build has no SQLCipher, so the key
77+
* would be ignored and the database written in plaintext. Not recoverable at
78+
* runtime; it needs the build flag above and a new binary. **Recommended
79+
* recovery: re-mount with `enableOfflineSupport={false}`** so nothing is
80+
* persisted unencrypted.
81+
*
82+
* The key must be **stable for the lifetime of the database file**. There is no
83+
* rekey path, so a key that changes costs one `OFFLINE_DB_UNREADABLE` and a
84+
* rebuild. To rotate without paying that, rotate a key-encryption key and keep the
85+
* database key it protects unchanged (envelope encryption).
86+
*
87+
* Switching encryption on, or back off, leaves a database from the other mode on
88+
* disk and so raises `OFFLINE_DB_UNREADABLE` once in each direction. Deleting it
89+
* from your boundary is all that is needed.
90+
*/
91+
getOfflineDbEncryptionKey?: () => Promise<string | undefined>;
4892
/**
4993
* Optional positive cap on the number of events a single `/sync` response may
5094
* contain before the offline sync manager skips replaying those events into
@@ -172,6 +216,7 @@ const ChatWithContext = (props: PropsWithChildren<ChatProps>) => {
172216
client,
173217
closeConnectionOnBackground = true,
174218
enableOfflineSupport = false,
219+
getOfflineDbEncryptionKey,
175220
i18nInstance,
176221
isMessageAIGenerated,
177222
maxSyncEventsLimit = DEFAULT_MAX_SYNC_EVENTS_LIMIT,
@@ -241,23 +286,12 @@ const ChatWithContext = (props: PropsWithChildren<ChatProps>) => {
241286

242287
const setActiveChannel = (newChannel?: Channel) => setChannel(newChannel);
243288

244-
useEffect(() => {
245-
if (!(userID && enableOfflineSupport)) {
246-
return;
247-
}
248-
249-
const initializeDatabase = async () => {
250-
if (!client.offlineDb) {
251-
client.setOfflineDBApi(new OfflineDB({ client, maxSyncEventsLimit }));
252-
}
253-
254-
if (client.offlineDb) {
255-
await client.offlineDb.init(userID);
256-
}
257-
};
258-
259-
initializeDatabase();
260-
}, [userID, enableOfflineSupport, client, maxSyncEventsLimit]);
289+
useInitializeOfflineDb({
290+
client,
291+
enabled: enableOfflineSupport,
292+
options: { getEncryptionKey: getOfflineDbEncryptionKey, maxSyncEventsLimit },
293+
userID,
294+
});
261295

262296
useEffect(() => {
263297
if (!client) {

0 commit comments

Comments
 (0)