Encrypting the offline database
The offline database is a local SQLite cache of channels, messages, members, drafts and reminders. By default it is stored unencrypted. If your app has a compliance requirement for encryption at rest, the SDK can open the database through SQLCipher instead, with a key you supply.
Encryption is opt-in and needs two things: a native build of @op-engineering/op-sqlite that includes SQLCipher, and a getOfflineDbEncryptionKey prop on Chat.
Without the native build flag, op-sqlite accepts an encryption key and then ignores it, writing the database in plaintext with no error. The SDK therefore refuses to open the database at all in that situation. See Handling failures.
Best practices
- Return the same key on every launch. There is no rekey, so a key that changes costs you the cache.
- Read the key from the iOS Keychain or the Android Keystore, never from a constant in your bundle.
- To rotate keys, rotate a key-encryption key and keep the database key it protects unchanged (envelope encryption).
- Wrap
Chatin an error boundary. The SDK reports failures by throwing, and never deletes your data on its own. - Treat the cache as disposable. To recover from an unreadable database, delete it and let it rebuild from the server.
Enable the SQLCipher build
Add the following to your application's package.json, not to a library's, and rebuild the native app:
{
"op-sqlite": {
"sqlcipher": true
}
}npx pod-installThis links OpenSSL into your binary and compiles op-sqlite against SQLCipher. It is a native build-time flag, so a JavaScript reload isn't enough: rebuild the app.
Supply the key
import type { PropsWithChildren } from "react";
import type { StreamChat } from "stream-chat";
import { Chat } from "stream-chat-react-native";
type EncryptedChatProps = PropsWithChildren<{
client: StreamChat;
// Reads the key from the iOS Keychain or the Android Keystore. Keep it stable,
// for example by declaring it at module scope.
getOfflineDbEncryptionKey: () => Promise<string | undefined>;
}>;
export const EncryptedChat = ({
children,
client,
getOfflineDbEncryptionKey,
}: EncryptedChatProps) => (
<Chat
client={client}
enableOfflineSupport
getOfflineDbEncryptionKey={getOfflineDbEncryptionKey}
>
{children}
</Chat>
);getOfflineDbEncryptionKey is called each time the database is opened, which happens once per launch and again after a sign-out. When it returns undefined or throws, the SDK treats the key as not available yet, not as wrong, and deletes nothing.
The key must stay the same for the lifetime of the database file. SQLCipher encrypts each page, and the SDK never calls PRAGMA rekey, so a database written with one key can't be read with another.
Handling failures
When the database can't be opened with the encryption you asked for, Chat throws a SqliteClientError from render. It doesn't fall back to an unencrypted cache or to no cache, because that would run the app with a different security posture than the one you configured.
Wrap Chat in an error boundary, check that the error is a SqliteClientError, and handle it by its code:
code |
Meaning | Example recovery |
|---|---|---|
OFFLINE_DB_UNREADABLE |
The file exists but can't be read: the key changed, or the file is corrupted | SqliteClient.deleteDatabase(), then mount Chat again |
ENCRYPTION_KEY_UNAVAILABLE |
The key getter threw or resolved without a key | Mount Chat again later, for example when the app next comes to the foreground |
SQLCIPHER_BUILD_MISSING |
The native build has no SQLCipher, so the key would be ignored | Not recoverable at runtime. Mount Chat with enableOfflineSupport={false} |
Deleting the database discards the cache, which is fetched again from the server. What's lost for good are the actions queued while offline, so consider asking the user first if they matter to your app.
Here is one way to handle these failures:
import React from "react";
import {
SqliteClient,
SqliteClientError,
type SqliteClientErrorCode,
} from "stream-chat-react-native";
type BoundaryProps = React.PropsWithChildren<{
onGiveUp: () => void;
onRetry: () => void;
}>;
export class OfflineDbBoundary extends React.Component<
BoundaryProps,
{ code?: SqliteClientErrorCode }
> {
state: { code?: SqliteClientErrorCode } = {};
static getDerivedStateFromError(error: unknown) {
if (!(error instanceof SqliteClientError)) {
// Not an offline database failure. Rethrow it so it reaches the app's own boundary.
throw error;
}
// Returning state stops `render` from rendering the subtree that just threw.
return { code: error.code };
}
componentDidCatch(error: unknown) {
if (!(error instanceof SqliteClientError)) {
return;
}
if (error.code === "OFFLINE_DB_UNREADABLE") {
// The key is usable, but the file can't be read with it. The file is a cache, so
// delete it and let the retry rebuild it from the server.
try {
SqliteClient.deleteDatabase();
} catch (deleteError) {
console.warn("Could not delete the offline database", deleteError);
}
this.props.onRetry();
return;
}
// SQLCIPHER_BUILD_MISSING or ENCRYPTION_KEY_UNAVAILABLE: there is no usable key, so a
// new database would be plaintext. Run online only, so nothing lands on disk unencrypted.
this.props.onGiveUp();
}
render() {
// Stop rendering the failing subtree, or it throws again on the next render.
return this.state.code ? null : this.props.children;
}
}Key the boundary on what your recovery changes, so that it mounts again and clears its own error state:
import { useState, type PropsWithChildren } from "react";
import type { StreamChat } from "stream-chat";
import { Chat } from "stream-chat-react-native";
import { OfflineDbBoundary } from "./OfflineDbBoundary";
type OfflineChatProps = PropsWithChildren<{
client: StreamChat;
getOfflineDbEncryptionKey: () => Promise<string | undefined>;
}>;
export const OfflineChat = ({
children,
client,
getOfflineDbEncryptionKey,
}: OfflineChatProps) => {
const [attempt, setAttempt] = useState(0);
const [offlineSupport, setOfflineSupport] = useState(true);
return (
<OfflineDbBoundary
key={`${attempt}-${offlineSupport}`}
onGiveUp={() => setOfflineSupport(false)}
onRetry={() => setAttempt((value) => value + 1)}
>
<Chat
client={client}
enableOfflineSupport={offlineSupport}
getOfflineDbEncryptionKey={getOfflineDbEncryptionKey}
>
{children}
</Chat>
</OfflineDbBoundary>
);
};The boundary treats the three codes as two situations.
With OFFLINE_DB_UNREADABLE, there is a usable key, and the file just can't be read with it. Everything in the file is a cache, so the boundary deletes it and rebuilds:
Chatthrows during render.getDerivedStateFromErrorstores the code and makesrenderreturnnull, so the failing subtree stops rendering. Otherwise it would throw again on the next render and React would unmount the whole app.componentDidCatchcallsSqliteClient.deleteDatabase(), which closes the handle and deletes the file. It works on a database that can't be decrypted, because deleting never reads it.- It then increments
attempt, which changes the boundary'skey. The boundary mounts again with fresh state, and renders its children again. Chatmounts, finds no database, creates a new one with the current key, and fills it from the server.
SQLCIPHER_BUILD_MISSING and ENCRYPTION_KEY_UNAVAILABLE both mean there is no usable key right now, so a new database would be written in plaintext, and deleting anything would only lose data. The boundary sets enableOfflineSupport to false instead, and the app carries on without a local cache. Nothing is stored, so nothing is stored unencrypted. Chat still works, with every request going to the API.
The boundary stops rendering its children once it has caught an error, and only a new key clears that. Without a key that changes, it renders nothing from then on.
In development, LogBox still shows an error when the boundary catches. React reports every caught error through console.error, and LogBox shows those. Release builds don't have LogBox.
Why the SDK doesn't recover on its own
Each recovery the SDK could make for you is a policy decision with a security consequence:
- Falling back to an unencrypted database would write the data you asked to protect in plaintext, and nothing would tell you.
- Turning offline support off would change how the app behaves, and decide for you whether chat data may be on the disk.
- Deleting the database would destroy the actions queued while offline, which only your app can decide to give up.
The SDK also can't tell what a failure means for your app. Whether ENCRYPTION_KEY_UNAVAILABLE means that the Keystore isn't unlocked yet or that the device should be signed out depends on how your app provisions keys. The SDK only sees that getOfflineDbEncryptionKey returned no key.
So the SDK detects the failure, classifies it, logs it, and refuses to open a database that wouldn't honor your configuration. Your app decides what to do next. This matters most for SQLCIPHER_BUILD_MISSING: op-sqlite accepts a key on a build without SQLCipher and ignores it, so without the error, nothing would show that the database isn't encrypted.
Turning encryption on or off for existing installs
Switching in either direction leaves a database from the other mode on disk, which raises OFFLINE_DB_UNREADABLE once:
- turning encryption on, on an install that has a plaintext database;
- turning it off, on an install that has an encrypted one.
In both cases, the boundary above deletes the database and lets it rebuild.
OFFLINE_DB_UNREADABLE isn't only about encryption. A corrupted database raises it too, so an error boundary is worth having whenever enableOfflineSupport is on.
Signing out and switching users
The encryption key belongs to the device, not to the signed-in user. SqliteClient.resetDB(), the call to make on sign-out, clears the tables and opens the same file again with the same key, so switching users needs nothing more. See Reset the database on sign-out.
Checking that it worked
An encrypted database doesn't begin with the SQLite header. On an iOS simulator, you can check the file:
xcrun simctl get_app_container <device-udid> <your.bundle.id> data
# then, in Library/databases/
head -c 16 stream-chat-react-native | xxdA plaintext database starts with SQLite format 3. An encrypted one starts with random bytes, and opening it with the sqlite3 command-line tool fails with file is not a database.
Costs
- Every page the database reads and writes is encrypted and authenticated, which slows down offline database operations.
- The build flag links OpenSSL into your app, whether or not you supply a key, which increases its size.