diff --git a/examples/SampleApp/App.tsx b/examples/SampleApp/App.tsx index 00fc913a3a..14dc99a866 100644 --- a/examples/SampleApp/App.tsx +++ b/examples/SampleApp/App.tsx @@ -27,6 +27,7 @@ import { } from 'stream-chat-react-native'; import { MenuDrawer } from './src/components/MenuDrawer'; +import { OfflineDbBoundary } from './src/components/OfflineDbBoundary'; import { useSampleAppComponentOverrides } from './src/components/SampleAppComponentOverrides'; import { MessageInputFloatingConfigItem, @@ -334,20 +335,35 @@ const DrawerNavigatorWrapper: React.FC<{ chatClient: StreamChat; i18nInstance: Streami18n; }> = ({ chatClient, i18nInstance }) => { + // `attempt` re-mounts after the offline database has been deleted; + // `offlineSupport` is switched off once there is no usable encryption key. + const [attempt, setAttempt] = useState(0); + const [offlineSupport, setOfflineSupport] = useState(true); + + // The boundary stops rendering its children once it has caught (see its render), and + // nothing else clears that. Keying it on both recovery levers re-mounts it when one is + // pulled - without that it would sit on a blank screen forever, having already deleted + // the database. return ( - setOfflineSupport(false)} + onRetry={() => setAttempt((value) => value + 1)} > - - - - - - + + + + + + + + ); }; diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index 34584cc346..e86287ae53 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -298,7 +298,7 @@ PODS: - React-utils - ReactNativeDependencies - Yoga - - React-Core-prebuilt (0.86.0): + - React-Core-prebuilt (0.86.2): - ReactNativeDependencies - React-Core/CoreModulesHeaders (0.86.2): - hermes-engine @@ -2857,7 +2857,7 @@ PODS: - SDWebImageWebPCoder (0.15.0): - libwebp (~> 1.0) - SDWebImage/Core (~> 5.17) - - stream-chat-react-native (9.7.2): + - stream-chat-react-native (9.7.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -3291,7 +3291,7 @@ SPEC CHECKSUMS: GoogleAppMeasurement: 57270ccc2b77472d7e85c4cbe45972564eff78bb GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 766ace00c6b10d8148408f329d10c4f051931850 - hermes-engine: 188393eb43a0cce2dfbf912e6d22c7bb6469957d + hermes-engine: 3730f5b467f988fa954ded67cbea8a9ba32d854c libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7 libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 @@ -3309,7 +3309,7 @@ SPEC CHECKSUMS: React: 4b2532a459d15e1adf6c22d3e399e5c85a94220f React-callinvoker: 0b8ce4057e02a0bd15cf0532596e8eb8c0392e92 React-Core: 5af045531a540ba3f65f07de1e3f585ddfb27948 - React-Core-prebuilt: 13924a267683b3d6fa4bde9c80380becf83a9c5c + React-Core-prebuilt: 405cf395d66cf694faf9aed3483a21b5515cec85 React-CoreModules: 99b194a721de84ccfc1be149a0de52647dc38c0e React-cxxreact: b7e8e254074fd8111d147202b391ccf7816946a6 React-debug: 3281bfefe5ece9a9d8b28bec3f871db229f9d8d8 @@ -3399,7 +3399,7 @@ SPEC CHECKSUMS: SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57 SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377 - stream-chat-react-native: e97f6d3ed0c2828b20610ffc0023ad7f9c90738d + stream-chat-react-native: ea3499916019c499ecb745be61e7ecf82f0fd999 Teleport: c56b30b08bd20d10da1efb0f21c6bc50c269ee6a Yoga: 542a30dafe5b0f5f1d9f185ea7b2a3811ac54801 diff --git a/examples/SampleApp/src/components/OfflineDbBoundary.tsx b/examples/SampleApp/src/components/OfflineDbBoundary.tsx new file mode 100644 index 0000000000..d58806ddbb --- /dev/null +++ b/examples/SampleApp/src/components/OfflineDbBoundary.tsx @@ -0,0 +1,72 @@ +import React from 'react'; + +import { + SqliteClient, + SqliteClientError, + type SqliteClientErrorCode, +} from 'stream-chat-react-native'; + +/** + * `` throws a {@link SqliteClientError} from render when it cannot open the + * offline database - most often `OFFLINE_DB_UNREADABLE`, meaning the file on disk + * cannot be read (corruption, or a database left behind from a different encryption + * mode). It never silently continues without the cache; recovery is the application's + * decision. + * + * The recommended recovery, shown here: the contents are a cache, so delete the + * database and let it rebuild from the server. The only real loss is actions that were + * queued while offline, so a real app may want to confirm with the user first. + * + * The `onGiveUp` path covers the codes that mean "no usable encryption key" + * (`SQLCIPHER_BUILD_MISSING`, `ENCRYPTION_KEY_UNAVAILABLE`). Those only occur when + * `` is given a `getOfflineDbEncryptionKey` prop, which this sample does not do - + * a new database would then be written in plaintext, so running online-only is the safe + * response. + */ +type BoundaryProps = React.PropsWithChildren<{ + onGiveUp: () => void; + onRetry: () => void; +}>; + +type BoundaryState = { code?: SqliteClientErrorCode }; + +export class OfflineDbBoundary extends React.Component { + state: BoundaryState = {}; + + // Must return state, and render() must stop rendering the failing subtree. Returning + // null here would re-render the same children, they would throw again, and React + // would give up and unmount the whole app. + static getDerivedStateFromError(error: unknown) { + if (!(error instanceof SqliteClientError)) { + // Not one of ours - re-throw so it reaches whatever boundary owns it. + throw error; + } + return { code: error.code }; + } + + componentDidCatch(error: unknown) { + if (!(error instanceof SqliteClientError)) { + return; + } + + if (error.code === 'OFFLINE_DB_UNREADABLE') { + // The recommended recovery: the contents are a cache, so drop the database and + // let it rebuild. Only actions queued while offline are lost. + try { + SqliteClient.deleteDatabase(); + } catch (deleteError) { + console.warn('[SampleApp] could not delete the offline database', deleteError); + } + this.props.onRetry(); + return; + } + + // No usable key, so a new database would be plaintext. Run online-only instead. + console.warn(`[SampleApp] offline encryption unavailable (${error.code}); going online-only`); + this.props.onGiveUp(); + } + + render() { + return this.state.code ? null : this.props.children; + } +} diff --git a/package/src/components/Chat/Chat.tsx b/package/src/components/Chat/Chat.tsx index 7eb6228cf3..17c221054c 100644 --- a/package/src/components/Chat/Chat.tsx +++ b/package/src/components/Chat/Chat.tsx @@ -6,6 +6,7 @@ import { Channel, OfflineDBState } from 'stream-chat'; import { useClientMutedUsers } from './hooks'; import { useAppSettings } from './hooks/useAppSettings'; import { useCreateChatContext } from './hooks/useCreateChatContext'; +import { useInitializeOfflineDb } from './hooks/useInitializeOfflineDb'; import { useIsOnline } from './hooks/useIsOnline'; import { ChannelsStateProvider } from '../../contexts/channelsStateContext/ChannelsStateContext'; @@ -24,7 +25,6 @@ import init from '../../init'; import { NativeHandlers } from '../../native'; import { DEFAULT_MAX_SYNC_EVENTS_LIMIT } from '../../store/constants'; -import { OfflineDB } from '../../store/OfflineDB'; import type { Streami18n } from '../../utils/i18n/Streami18n'; import { installNativeMultipartAdapter } from '../../utils/installNativeMultipartAdapter'; @@ -45,6 +45,50 @@ export type ChatProps = Pick & * Enables offline storage and loading for chat data. */ enableOfflineSupport?: boolean; + /** + * Encrypts the offline database at rest with SQLCipher, using the key this + * resolves to. Only relevant when `enableOfflineSupport` is enabled. Leaving it + * unset keeps the offline database unencrypted, which is the default. + * + * Requires a native build of `@op-engineering/op-sqlite` that includes SQLCipher. + * Add the following to your application's `package.json` and rebuild the native + * app - without the flag the key is accepted and then silently ignored: + * + * ```json + * { "op-sqlite": { "sqlcipher": true } } + * ``` + * + * **Wrap `` in an error boundary.** If the database cannot be opened with + * the encryption you asked for, `` throws a {@link SqliteClientError} + * from render instead of continuing without it. The SDK deliberately takes no + * recovery action of its own - it never deletes data, and never silently falls + * back to an unencrypted or absent cache. Discriminate on `code`: + * + * - `OFFLINE_DB_UNREADABLE` - the file exists but this key cannot read it (the + * key changed, or the database predates encryption). **Recommended recovery: + * `SqliteClient.deleteDatabase()`, then re-mount ``.** The contents are a + * cache and are refetched from the server; the exception is actions queued while + * offline, which are lost - prompt the user first if that matters to you. + * - `ENCRYPTION_KEY_UNAVAILABLE` - the key could not be read (a locked keychain, a + * launch before first unlock). The database is untouched. **Recommended + * recovery: re-mount to retry** once the key is readable - for example when the + * app next returns to the foreground. + * - `SQLCIPHER_BUILD_MISSING` - the native build has no SQLCipher, so the key + * would be ignored and the database written in plaintext. Not recoverable at + * runtime; it needs the build flag above and a new binary. **Recommended + * recovery: re-mount with `enableOfflineSupport={false}`** so nothing is + * persisted unencrypted. + * + * The key must be **stable for the lifetime of the database file**. There is no + * rekey path, so a key that changes costs one `OFFLINE_DB_UNREADABLE` and a + * rebuild. To rotate without paying that, rotate a key-encryption key and keep the + * database key it protects unchanged (envelope encryption). + * + * Switching encryption on, or back off, leaves a database from the other mode on + * disk and so raises `OFFLINE_DB_UNREADABLE` once in each direction. Deleting it + * from your boundary is all that is needed. + */ + getOfflineDbEncryptionKey?: () => Promise; /** * Optional positive cap on the number of events a single `/sync` response may * contain before the offline sync manager skips replaying those events into @@ -172,6 +216,7 @@ const ChatWithContext = (props: PropsWithChildren) => { client, closeConnectionOnBackground = true, enableOfflineSupport = false, + getOfflineDbEncryptionKey, i18nInstance, isMessageAIGenerated, maxSyncEventsLimit = DEFAULT_MAX_SYNC_EVENTS_LIMIT, @@ -241,23 +286,12 @@ const ChatWithContext = (props: PropsWithChildren) => { const setActiveChannel = (newChannel?: Channel) => setChannel(newChannel); - useEffect(() => { - if (!(userID && enableOfflineSupport)) { - return; - } - - const initializeDatabase = async () => { - if (!client.offlineDb) { - client.setOfflineDBApi(new OfflineDB({ client, maxSyncEventsLimit })); - } - - if (client.offlineDb) { - await client.offlineDb.init(userID); - } - }; - - initializeDatabase(); - }, [userID, enableOfflineSupport, client, maxSyncEventsLimit]); + useInitializeOfflineDb({ + client, + enabled: enableOfflineSupport, + options: { getEncryptionKey: getOfflineDbEncryptionKey, maxSyncEventsLimit }, + userID, + }); useEffect(() => { if (!client) { diff --git a/package/src/components/Chat/__tests__/Chat.test.tsx b/package/src/components/Chat/__tests__/Chat.test.tsx index 4d6a43ad29..8862b49657 100644 --- a/package/src/components/Chat/__tests__/Chat.test.tsx +++ b/package/src/components/Chat/__tests__/Chat.test.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { PropsWithChildren } from 'react'; import { View } from 'react-native'; import NetInfo from '@react-native-community/netinfo'; @@ -9,10 +9,12 @@ import { useChatContext } from '../../../contexts/chatContext/ChatContext'; import type { TranslationContextValue } from '../../../contexts/translationContext/TranslationContext'; import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { sqliteMock } from '../../../mock-builders/DB/mock'; import dispatchConnectionChangedEvent from '../../../mock-builders/event/connectionChanged'; import dispatchConnectionRecoveredEvent from '../../../mock-builders/event/connectionRecovered'; import { getTestClient, getTestClientWithUser, setUser } from '../../../mock-builders/mock'; import { DEFAULT_MAX_SYNC_EVENTS_LIMIT } from '../../../store/constants'; +import { SqliteClient, SqliteClientError } from '../../../store/SqliteClient'; import { Streami18n } from '../../../utils/i18n/Streami18n'; import { Chat } from '../Chat'; @@ -368,3 +370,242 @@ describe('TranslationContext', () => { expect(chatClientWithUser.offlineDb!.syncManager.syncMaxEventCount).toBeUndefined(); }); }); + +describe('Chat offline DB encryption', () => { + const installedSpies: jest.SpyInstance[] = []; + + /** + * Registers a spy for teardown. Deliberately not jest.restoreAllMocks(): that also + * restores the connection privates mockClient() stubs out on every client created + * by earlier tests in this file, after which those clients reconnect for real and + * the failed websocket handshake resurfaces as an unhandled error somewhere else. + */ + const track = (spy: T): T => { + installedSpies.push(spy); + return spy; + }; + + /** + * Chat mounts useIsOnline, which opens the websocket whenever the app comes to the + * foreground. Left real, that connection attempt outlives the test and rejects + * asynchronously. Nothing in this block needs a connection. + */ + const createClient = async () => { + const client = await getTestClientWithUser({ id: 'testID' }); + track(jest.spyOn(client, 'openConnection').mockResolvedValue(undefined)); + track(jest.spyOn(client, 'closeConnection').mockResolvedValue(undefined)); + return client; + }; + + /** Minimal error boundary, since `` reports encryption failures by throwing. */ + class Boundary extends React.Component< + PropsWithChildren<{ onCatch: (error: Error) => void }>, + { caught: boolean } + > { + state = { caught: false }; + + static getDerivedStateFromError() { + return { caught: true }; + } + + componentDidCatch(error: Error) { + this.props.onCatch(error); + } + + render() { + return this.state.caught ? : this.props.children; + } + } + + afterEach(() => { + cleanup(); + installedSpies.splice(0).forEach((spy) => spy.mockRestore()); + SqliteClient.getEncryptionKey = undefined; + }); + + it('does not configure an encryption key when the prop is omitted', async () => { + const chatClientWithUser = await createClient(); + + render(); + + await waitFor(() => expect(chatClientWithUser.offlineDb).toBeDefined()); + expect(SqliteClient.getEncryptionKey).toBeUndefined(); + }); + + it('forwards getOfflineDbEncryptionKey to the sqlite client', async () => { + const chatClientWithUser = await createClient(); + const getOfflineDbEncryptionKey = jest.fn().mockResolvedValue('a-stable-key'); + + render( + , + ); + + await waitFor(() => expect(chatClientWithUser.offlineDb).toBeDefined()); + await waitFor(() => expect(getOfflineDbEncryptionKey).toHaveBeenCalled()); + }); + + it('does not re-initialize when getOfflineDbEncryptionKey is a new function every render', async () => { + const chatClientWithUser = await createClient(); + const resolveKey = jest.fn().mockResolvedValue('a-stable-key'); + + // An inline arrow is the shape integrators reach for first, so a changing + // identity must not restart initialization on every render. + const { rerender } = render( + resolveKey()} + />, + ); + + await waitFor(() => expect(chatClientWithUser.offlineDb).toBeDefined()); + const initSpy = track(jest.spyOn(chatClientWithUser.offlineDb!, 'init')); + + rerender( + resolveKey()} + />, + ); + rerender( + resolveKey()} + />, + ); + + await waitFor(() => expect(initSpy).not.toHaveBeenCalled()); + }); + + it.each<[string, () => Promise, string]>([ + ['the key cannot be read', () => Promise.resolve(undefined), 'ENCRYPTION_KEY_UNAVAILABLE'], + [ + 'the key getter throws', + () => Promise.reject(new Error('keychain is locked')), + 'ENCRYPTION_KEY_UNAVAILABLE', + ], + ])('throws %s so an error boundary can decide', async (_label, getKey, code) => { + const chatClientWithUser = await createClient(); + track(jest.spyOn(console, 'warn').mockImplementation(() => undefined)); + track(jest.spyOn(console, 'error').mockImplementation(() => undefined)); + track(jest.spyOn(console, 'log').mockImplementation(() => undefined)); + const onCatch = jest.fn(); + + const { getByTestId } = render( + + + + + , + ); + + await waitFor(() => expect(getByTestId('boundary')).toBeTruthy()); + expect(onCatch).toHaveBeenCalledWith(expect.any(SqliteClientError)); + expect((onCatch.mock.calls[0][0] as SqliteClientError).code).toBe(code); + // Never silently downgraded to online-only. + expect(() => getByTestId('children')).toThrow(); + }); + + it('throws when the native build has no SQLCipher', async () => { + const chatClientWithUser = await createClient(); + track(jest.spyOn(console, 'error').mockImplementation(() => undefined)); + track(jest.spyOn(console, 'log').mockImplementation(() => undefined)); + track(jest.spyOn(sqliteMock, 'isSQLCipher').mockReturnValue(false)); + const onCatch = jest.fn(); + + const { getByTestId } = render( + + Promise.resolve('a-stable-key')} + /> + , + ); + + await waitFor(() => expect(getByTestId('boundary')).toBeTruthy()); + expect((onCatch.mock.calls[0][0] as SqliteClientError).code).toBe('SQLCIPHER_BUILD_MISSING'); + }); + + it('throws OFFLINE_DB_UNREADABLE without deleting the database', async () => { + const chatClientWithUser = await createClient(); + track(jest.spyOn(console, 'warn').mockImplementation(() => undefined)); + track(jest.spyOn(console, 'error').mockImplementation(() => undefined)); + track(jest.spyOn(console, 'log').mockImplementation(() => undefined)); + // Preflight passes, then the first read of the file fails to decrypt. + track( + jest + .spyOn(SqliteClient, 'getUserPragmaVersion') + .mockRejectedValue(new Error('Querying for user_version failed: file is not a database')), + ); + const deleteSpy = track(jest.spyOn(SqliteClient, 'deleteDatabase')); + const onCatch = jest.fn(); + + const { getByTestId } = render( + + Promise.resolve('a-stable-key')} + /> + , + ); + + await waitFor(() => expect(getByTestId('boundary')).toBeTruthy()); + expect((onCatch.mock.calls[0][0] as SqliteClientError).code).toBe('OFFLINE_DB_UNREADABLE'); + // Wiping is the integrator's decision, made from the boundary. + expect(deleteSpy).not.toHaveBeenCalled(); + }); + + it('never attaches an offline DB it cannot open', async () => { + const chatClientWithUser = await createClient(); + track(jest.spyOn(console, 'warn').mockImplementation(() => undefined)); + track(jest.spyOn(console, 'error').mockImplementation(() => undefined)); + track(jest.spyOn(console, 'log').mockImplementation(() => undefined)); + const setOfflineDBApiSpy = track(jest.spyOn(chatClientWithUser, 'setOfflineDBApi')); + + const { getByTestId } = render( + undefined}> + Promise.resolve(undefined)} + /> + , + ); + + await waitFor(() => expect(getByTestId('boundary')).toBeTruthy()); + + // Parts of the client write through `client.offlineDb` without checking that it + // initialized - queryChannels upserts into it - so an instance we cannot open + // would turn those writes into rejections. + expect(setOfflineDBApiSpy).not.toHaveBeenCalled(); + expect(chatClientWithUser.offlineDb).toBeUndefined(); + }); + + it('renders normally when nothing is wrong with encryption', async () => { + const chatClientWithUser = await createClient(); + const onCatch = jest.fn(); + + const { getByTestId } = render( + + Promise.resolve('a-stable-key')} + > + + + , + ); + + await waitFor(() => expect(getByTestId('children')).toBeTruthy()); + expect(onCatch).not.toHaveBeenCalled(); + }); +}); diff --git a/package/src/components/Chat/hooks/useInitializeOfflineDb.ts b/package/src/components/Chat/hooks/useInitializeOfflineDb.ts new file mode 100644 index 0000000000..04860a28e2 --- /dev/null +++ b/package/src/components/Chat/hooks/useInitializeOfflineDb.ts @@ -0,0 +1,117 @@ +import { useCallback, useEffect, useState } from 'react'; + +import type { StreamChat } from 'stream-chat'; + +import { useStableCallback } from '../../../hooks/useStableCallback'; +import { OfflineDB } from '../../../store/OfflineDB'; +import { SqliteClient, SqliteClientError } from '../../../store/SqliteClient'; + +export type InitializeOfflineDbOptions = { + /** + * Encrypts the offline database at rest with SQLCipher, using the key this resolves + * to. Leaving it unset opens the database unencrypted, which is the default. See + * `ChatProps.getOfflineDbEncryptionKey` for the build flag it requires, the stability + * requirement, and how failures are surfaced. + */ + getEncryptionKey?: () => Promise; + /** + * Optional positive cap on the number of events a single `/sync` response may + * contain before the offline sync manager skips replaying those events into local + * storage. `false` opts out entirely. + */ + maxSyncEventsLimit?: number | false; +}; + +export type UseInitializeOfflineDbParams = { + client: StreamChat; + /** Whether offline support is enabled at all. */ + enabled: boolean; + options?: InitializeOfflineDbOptions; + userID?: string; +}; + +/** + * Attaches an offline database to the client and initializes it for a user. + * + * **Raises** whatever prevented the database from opening, from render, so an error + * boundary above the caller can decide what to do. The offline database is never + * silently downgraded, because an integration that asked for encryption must not end + * up with an unencrypted cache. + */ +export const useInitializeOfflineDb = ({ + client, + enabled, + options, + userID, +}: UseInitializeOfflineDbParams) => { + /** + * Why this attempt could not open the offline database. + * + * Held per attempt rather than read from a longer-lived source: a value that outlived + * the attempt would be seen during the first render after a re-mount and raised + * before that mount's own attempt could run, so an error boundary that re-mounts to + * retry would loop forever. + */ + const [initializationError, setInitializationError] = useState(); + + const { getEncryptionKey, maxSyncEventsLimit } = options ?? {}; + + // `getEncryptionKey` is overwhelmingly likely to be an inline arrow. Stabilising it + // keeps a new identity per render out of the dependencies below, while still calling + // whatever the latest prop is. + const resolveEncryptionKey = useStableCallback( + () => getEncryptionKey?.() ?? Promise.resolve(undefined), + ); + const isEncryptionEnabled = !!getEncryptionKey; + + const initialize = useCallback(async () => { + if (!(userID && enabled)) { + return; + } + + if (!client.offlineDb) { + const keyGetter = isEncryptionEnabled ? resolveEncryptionKey : undefined; + + // Confirm the database can be opened before attaching it: the client writes + // through `client.offlineDb` without checking that it initialized, so one we + // cannot open turns those writes into rejections (and UI is affected directly). + if (keyGetter) { + SqliteClient.getEncryptionKey = keyGetter; + try { + await SqliteClient.preflightEncryption(); + } catch (error) { + if (error instanceof SqliteClientError) { + setInitializationError(error); + return; + } + throw error; + } + } + + client.setOfflineDBApi( + new OfflineDB({ client, getEncryptionKey: keyGetter, maxSyncEventsLimit }), + ); + } + + const { offlineDb } = client; + if (offlineDb) { + await offlineDb.init(userID); + // Note: Since `init()` currently swallows errors by design, we have to rely + // on consuming the error later in order to be able to still rethrow without + // introducing a breaking change. + // TODO: The DB API should be changed in the next major to always throw upwards + // and let integrators handle it if necessary. + setInitializationError( + offlineDb instanceof OfflineDB ? offlineDb.initializationError : undefined, + ); + } + }, [client, enabled, isEncryptionEnabled, maxSyncEventsLimit, resolveEncryptionKey, userID]); + + useEffect(() => { + initialize(); + }, [initialize]); + + if (initializationError) { + throw initializationError; + } +}; diff --git a/package/src/index.ts b/package/src/index.ts index 6c8f8c1f45..cb8584481d 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -40,7 +40,7 @@ export { default as ruTranslations } from './i18n/ru.json'; export { default as trTranslations } from './i18n/tr.json'; export * from './state-store'; -export { SqliteClient } from './store/SqliteClient'; +export { SqliteClient, SqliteClientError, type SqliteClientErrorCode } from './store/SqliteClient'; export { OfflineDB } from './store/OfflineDB'; export { version } from './version.json'; diff --git a/package/src/mock-builders/DB/mock.ts b/package/src/mock-builders/DB/mock.ts index 7fa94cfc65..8d03370c98 100644 --- a/package/src/mock-builders/DB/mock.ts +++ b/package/src/mock-builders/DB/mock.ts @@ -1,3 +1,5 @@ +import { rmSync } from 'fs'; + import Sqlite3 from 'better-sqlite3'; import type { PreparedQueries } from '../../store/types'; @@ -6,6 +8,11 @@ let db: Sqlite3.Database; const testDbName = `foobar-${process.env.JEST_WORKER_ID ?? '0'}.db`; export const sqliteMock = { + // better-sqlite3 has no SQLCipher, so an `encryptionKey` passed to open() is + // simply ignored. Reporting a SQLCipher build keeps the encrypted path + // exercisable in tests; whether the bytes on disk are actually encrypted can + // only be verified on a device. Spy on this to test the build-missing guard. + isSQLCipher: () => true, open: () => { db = new Sqlite3(testDbName); db.pragma('journal_mode = MEMORY'); @@ -18,6 +25,12 @@ export const sqliteMock = { status: 0, }; }, + // Mirrors op-sqlite's delete(): closes the handle and unlinks the file, so a + // subsequent open() starts from an empty database. + delete: () => { + db.close(); + rmSync(testDbName, { force: true }); + }, execute: async (queryInput: string, params: unknown[]) => { const query = queryInput.trim().toLowerCase(); diff --git a/package/src/store/OfflineDB.ts b/package/src/store/OfflineDB.ts index d0d6a408fc..09b565a75e 100644 --- a/package/src/store/OfflineDB.ts +++ b/package/src/store/OfflineDB.ts @@ -9,20 +9,30 @@ import type { } from 'stream-chat'; import * as api from './apis'; -import { SqliteClient } from './SqliteClient'; +import { SqliteClient, SqliteClientError } from './SqliteClient'; export class OfflineDB extends AbstractOfflineDB { constructor({ client, + getEncryptionKey, maxSyncEventsLimit, }: { client: StreamChat; + /** + * Supplies the SQLCipher key the offline database is opened with. See + * {@link SqliteClient.getEncryptionKey} for the stability requirement. + */ + getEncryptionKey?: () => Promise; maxSyncEventsLimit?: number | false; }) { super({ client, syncMaxEventCount: maxSyncEventsLimit === false ? undefined : maxSyncEventsLimit, }); + // Assigned unconditionally: SqliteClient holds this statically, so leaving a + // previous instance's getter in place would keep encrypting after the caller + // stopped asking for it. + SqliteClient.getEncryptionKey = getEncryptionKey; } upsertCidsForQuery = api.upsertCidsForQuery; @@ -106,5 +116,26 @@ export class OfflineDB extends AbstractOfflineDB { executeSqlBatch = SqliteClient.executeSqlBatch; - initializeDB = SqliteClient.initializeDatabase; + /** + * Why the most recent {@link initializeDB} failed, if it did. + * + * `AbstractOfflineDB.init` catches whatever `initializeDB` throws and does not + * re-throw it, so a caller has no way to see the reason. Recording it here on the + * way out gives the caller something to read back once `init` has settled. Kept on + * the instance rather than a static so two clients cannot overwrite each other. + */ + initializationError: SqliteClientError | undefined; + + initializeDB = async () => { + this.initializationError = undefined; + try { + return await SqliteClient.initializeDatabase(); + } catch (error) { + if (error instanceof SqliteClientError) { + this.initializationError = error; + } + // Re-thrown so `AbstractOfflineDB.init` still marks the database uninitialized. + throw error; + } + }; } diff --git a/package/src/store/SqliteClient.ts b/package/src/store/SqliteClient.ts index 733fc95dd8..22b532e971 100644 --- a/package/src/store/SqliteClient.ts +++ b/package/src/store/SqliteClient.ts @@ -23,6 +23,29 @@ import { tables } from './schema'; import { createCreateTableQuery } from './sqlite-utils/createCreateTableQuery'; import type { PreparedBatchQueries, PreparedQueries, Scalar, Table } from './types'; +/** + * Why the offline database could not be opened. The first two only arise when + * {@link SqliteClient.getEncryptionKey} is set; `OFFLINE_DB_UNREADABLE` can also mean + * plain corruption, or a database left behind from the other encryption mode. + */ +export type SqliteClientErrorCode = + | 'SQLCIPHER_BUILD_MISSING' + | 'ENCRYPTION_KEY_UNAVAILABLE' + | 'OFFLINE_DB_UNREADABLE'; + +export class SqliteClientError extends Error { + public readonly code: SqliteClientErrorCode; + + constructor(code: SqliteClientErrorCode, message: string, options?: { cause?: unknown }) { + super(message); + this.name = 'SqliteClientError'; + this.code = code; + // Assigned here rather than passed through `super(message, { cause })` because + // Hermes does not reliably honour the ErrorOptions overload. + this.cause = options?.cause; + } +} + /** * SqliteClient takes care of any direct interaction with sqlite. * This way usage @op-engineering/op-sqlite package is scoped to a single class/file. @@ -35,10 +58,126 @@ export class SqliteClient { static logger: Logger | undefined; static db: _InternalDB | undefined; + /** + * Supplies the SQLCipher key the offline database is opened with; `undefined` + * opens it unencrypted, which is the default. The key must be stable for the + * lifetime of the database file - there is no rekey path, so a database this key + * cannot read raises `OFFLINE_DB_UNREADABLE` on the first page read. The file is + * left untouched; recovery is `SqliteClient.deleteDatabase()` and a re-mount. + */ + static getEncryptionKey: (() => Promise) | undefined; + + /** Key resolved by {@link preflightEncryption}, consumed by the next {@link openDB}. */ + private static preflightedKey: string | undefined; + + /** Busy/disk/memory failures. Checked first: wiping over these destroys a good db. */ + private static TRANSIENT_ERROR = + /database is locked|SQLITE_BUSY|SQLITE_LOCKED|disk i\/o|SQLITE_IOERR|unable to open|SQLITE_CANTOPEN|out of memory|readonly/i; + + /** + * The bytes on disk cannot be read with the key we have: wrong/rotated key, + * plaintext-encrypted mismatch or corruption. SQLCipher has no decrypt specific + * code and overloads NOTADB (26), occasionally CORRUPT (11). + */ + private static UNREADABLE_ERROR = + /not a database|file is encrypted|malformed|disk image is malformed|SQLite (?:error )?code:?\s*(?:26|11)\b|NOTADB|SQLITE_CORRUPT/i; + static getDbVersion = () => SqliteClient.dbVersion; // Force a specific db version. This is mainly useful for testsuit. static setDbVersion = (version: number) => (SqliteClient.dbVersion = version); + /** + * Records and re-throws. Deliberately does not write to the console: the error is + * thrown, so logging it here would duplicate whatever the caller's error boundary + * reports - and in dev React already logs every boundary-caught error, which is what + * LogBox turns red. + */ + private static recordError = (e: SqliteClientError) => { + this.logger?.('error', e.message, { tag: e.code }); + + throw e; + }; + + /** + * Resolves the encryption key without opening the database, so callers can decide + * whether to attach an `OfflineDB` at all. Parts of the client write through + * `client.offlineDb` without checking that it initialized (`queryChannels` upserts + * into it), so attaching one we cannot open turns those writes into rejections. + * + * Throws {@link SqliteClientError}. The key is handed to the next + * {@link openDB} rather than read from `getEncryptionKey` twice. + */ + static preflightEncryption = async () => { + try { + this.preflightedKey = await this.resolveEncryptionKey(); + } catch (e) { + if (e instanceof SqliteClientError) { + this.recordError(e); + } + throw e; + } + }; + + /** + * The key to open with, or `undefined` when the database is meant to be + * unencrypted. Throws rather than silently falling back to an unencrypted + * database, which would hand an integration that asked for encryption a plaintext + * cache of its users' messages. + */ + private static resolveEncryptionKey = async () => { + const { getEncryptionKey } = this; + + if (!getEncryptionKey) { + return undefined; + } + + // A non-SQLCipher build accepts `encryptionKey` at the JSI boundary and then + // drops it - plaintext database, no error anywhere. `isSQLCipher` has existed + // since op-sqlite 9, well below the peer floor, so the typeof check is not really + // necessary but we'll keep it in case something changes in the future so that + // we at least have a clearer error. + if (sqlite === undefined) { + throw new SqliteClientError( + 'SQLCIPHER_BUILD_MISSING', + 'An offline database encryption key was provided but "@op-engineering/op-sqlite" ' + + 'is not installed.', + ); + } + if (typeof sqlite.isSQLCipher !== 'function' || !sqlite.isSQLCipher()) { + throw new SqliteClientError( + 'SQLCIPHER_BUILD_MISSING', + 'An offline database encryption key was provided but @op-engineering/op-sqlite was ' + + 'not built with SQLCipher, so the key would be silently ignored and the offline ' + + 'database written in plaintext. Add { "op-sqlite": { "sqlcipher": true } } to your ' + + "application's package.json and rebuild, or stop providing a key.", + ); + } + + let encryptionKey: string | undefined; + + try { + encryptionKey = await getEncryptionKey(); + } catch (error) { + throw new SqliteClientError( + 'ENCRYPTION_KEY_UNAVAILABLE', + 'The offline database encryption key getter threw, so the database cannot be opened.', + { cause: error }, + ); + } + + // Not being handed a key is not the same as being handed the wrong one, so a locked + // keychain must not cost us a database we can still read later. + if (!encryptionKey) { + throw new SqliteClientError( + 'ENCRYPTION_KEY_UNAVAILABLE', + 'The offline database encryption key getter resolved without a key, so the database ' + + 'cannot be opened.', + ); + } + + return encryptionKey; + }; + static openDB = async () => { try { if (sqlite === undefined) { @@ -46,13 +185,24 @@ export class SqliteClient { 'Please install "@op-engineering/op-sqlite" package to enable offline support', ); } + const encryptionKey = this.preflightedKey ?? (await this.resolveEncryptionKey()); + this.preflightedKey = undefined; + this.db = sqlite.open({ location: SqliteClient.dbLocation, name: SqliteClient.dbName, + ...(encryptionKey ? { encryptionKey } : {}), }); + // Note: this will not fail on an encryption key mismatch, as we do not read + // any pages, but rather look at a connection level flag. The first failure + // is going to be whatever actually reads something, which is going to be + // the user_version read in initializeDatabase. await this.db?.execute('PRAGMA foreign_keys = ON', []); } catch (e) { + if (e instanceof SqliteClientError) { + throw e; + } this.logger?.('error', `Error opening database ${SqliteClient.dbName}`, { error: e, }); @@ -154,7 +304,23 @@ export class SqliteClient { return true; }; - static initializeDatabase = async () => { + /** + * Whether the file cannot be read with the key we have, as opposed to being + * temporarily unavailable (busy, locked, disk). Works off message text because + * op-sqlite rejects with a plain Error and this class re-wraps those messages, so + * no numeric code survives. Drives `OFFLINE_DB_UNREADABLE`. + */ + static isUnreadableDbError = (e: unknown) => { + const message = String((e as Error)?.message ?? e); + + if (this.TRANSIENT_ERROR.test(message)) { + return false; + } + + return this.UNREADABLE_ERROR.test(message); + }; + + static initializeDatabase = async (): Promise => { try { await SqliteClient.openDB(); const version = await SqliteClient.getUserPragmaVersion(); @@ -180,6 +346,24 @@ export class SqliteClient { return true; } catch (e) { + if (e instanceof SqliteClientError) { + this.recordError(e); + } + + if (this.isUnreadableDbError(e)) { + this.recordError( + new SqliteClientError( + 'OFFLINE_DB_UNREADABLE', + 'The offline database exists but could not be read. Usually the encryption ' + + 'key changed, or encryption was turned on or off while a database from ' + + 'the other mode was still on disk. Delete it with ' + + 'SqliteClient.deleteDatabase() and re-mount to rebuild from the server - ' + + 'everything in it is a cache, except queued offline actions, which are lost.', + { cause: e }, + ), + ); + } + console.log('Error initializing DB', e); this.logger?.('error', 'Error initializing DB', { dbLocation: SqliteClient.dbLocation, diff --git a/package/src/store/__tests__/SqliteClient.test.ts b/package/src/store/__tests__/SqliteClient.test.ts new file mode 100644 index 0000000000..0ee0f2e406 --- /dev/null +++ b/package/src/store/__tests__/SqliteClient.test.ts @@ -0,0 +1,258 @@ +import { sqliteMock } from '../../mock-builders/DB/mock'; +import { SqliteClient, SqliteClientError } from '../SqliteClient'; + +// Captured before any spy is installed so the spy can call through to a real +// better-sqlite3 handle while still observing the arguments open() was given. +const openDatabase = sqliteMock.open; + +/** Runs `initializeDatabase` once and returns the error it threw. */ +const captureInitError = async () => { + try { + await SqliteClient.initializeDatabase(); + } catch (error) { + return error as SqliteClientError; + } + throw new Error('expected initializeDatabase to reject, but it resolved'); +}; + +describe('SqliteClient encryption', () => { + let openSpy: jest.SpyInstance>; + let deleteMocks: jest.Mock[]; + + beforeEach(() => { + SqliteClient.getEncryptionKey = undefined; + SqliteClient.db = undefined; + SqliteClient.logger = jest.fn(); + + deleteMocks = []; + openSpy = jest.spyOn(sqliteMock, 'open').mockImplementation(() => { + const db = openDatabase(); + const originalDelete = db.delete; + const deleteMock = jest.fn(() => originalDelete()); + deleteMocks.push(deleteMock); + return { ...db, delete: deleteMock }; + }); + + jest.spyOn(console, 'warn').mockImplementation(() => undefined); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + jest.spyOn(console, 'log').mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + SqliteClient.getEncryptionKey = undefined; + SqliteClient.logger = undefined; + SqliteClient.db = undefined; + }); + + describe('opening without encryption', () => { + it('does not pass an encryption key when no getter is configured', async () => { + await expect(SqliteClient.initializeDatabase()).resolves.toBe(true); + + expect(openSpy).toHaveBeenCalledTimes(1); + expect(openSpy.mock.calls[0][0]).not.toHaveProperty('encryptionKey'); + }); + + it('never consults isSQLCipher when no getter is configured', async () => { + const isSQLCipherSpy = jest.spyOn(sqliteMock, 'isSQLCipher'); + + await SqliteClient.initializeDatabase(); + + expect(isSQLCipherSpy).not.toHaveBeenCalled(); + }); + }); + + describe('opening with encryption', () => { + it('passes the resolved key to open()', async () => { + SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue('a-stable-key'); + + await expect(SqliteClient.initializeDatabase()).resolves.toBe(true); + + expect(SqliteClient.getEncryptionKey).toHaveBeenCalledTimes(1); + expect(openSpy.mock.calls[0][0]).toMatchObject({ encryptionKey: 'a-stable-key' }); + }); + + it('refuses to open at all when op-sqlite has no SQLCipher build', async () => { + jest.spyOn(sqliteMock, 'isSQLCipher').mockReturnValue(false); + SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue('a-stable-key'); + + const error = await captureInitError(); + + expect(error).toBeInstanceOf(SqliteClientError); + + expect(error.code).toBe('SQLCIPHER_BUILD_MISSING'); + // The whole point: no database is created, so nothing is written in plaintext. + expect(openSpy).not.toHaveBeenCalled(); + // Nor is the key ever requested - the build is unusable regardless of it. + expect(SqliteClient.getEncryptionKey).not.toHaveBeenCalled(); + expect(deleteMocks).toHaveLength(0); + }); + + it('refuses to open when isSQLCipher is missing from the installed op-sqlite', async () => { + // An op-sqlite too old to expose the check cannot be verified, so it is + // treated exactly like a build without SQLCipher. + const { isSQLCipher } = sqliteMock; + // @ts-expect-error deliberately simulating an older op-sqlite + delete sqliteMock.isSQLCipher; + SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue('a-stable-key'); + + try { + const error = await captureInitError(); + + expect(error).toBeInstanceOf(SqliteClientError); + expect(error.code).toBe('SQLCIPHER_BUILD_MISSING'); + expect(openSpy).not.toHaveBeenCalled(); + } finally { + sqliteMock.isSQLCipher = isSQLCipher; + } + }); + }); + + describe('when the encryption key cannot be obtained', () => { + it('gives up without wiping when the getter throws', async () => { + const cause = new Error('keychain is locked'); + SqliteClient.getEncryptionKey = jest.fn().mockRejectedValue(cause); + + const error = await captureInitError(); + + expect(error).toBeInstanceOf(SqliteClientError); + + expect(error.code).toBe('ENCRYPTION_KEY_UNAVAILABLE'); + expect(error.cause).toBe(cause); + expect(openSpy).not.toHaveBeenCalled(); + // Not being handed a key says nothing about the database on disk, so it stays. + expect(deleteMocks).toHaveLength(0); + }); + + it('gives up without wiping when the getter resolves without a key', async () => { + SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue(undefined); + + const error = await captureInitError(); + + expect(error).toBeInstanceOf(SqliteClientError); + + expect(error.code).toBe('ENCRYPTION_KEY_UNAVAILABLE'); + expect(openSpy).not.toHaveBeenCalled(); + expect(deleteMocks).toHaveLength(0); + }); + + it('treats an empty string as no key', async () => { + SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue(''); + + const error = await captureInitError(); + + expect(error).toBeInstanceOf(SqliteClientError); + + expect(error.code).toBe('ENCRYPTION_KEY_UNAVAILABLE'); + expect(openSpy).not.toHaveBeenCalled(); + }); + + it('clears a recorded encryption error once initialization succeeds', async () => { + SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue(undefined); + const error = await captureInitError(); + + expect(error).toBeInstanceOf(SqliteClientError); + expect(error).toBeDefined(); + + SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue('a-stable-key'); + await expect(SqliteClient.initializeDatabase()).resolves.toBe(true); + }); + }); + + describe('when the database cannot be read', () => { + const notADatabase = () => + new Error('Querying for user_version failed: Error: file is not a database'); + + it('throws OFFLINE_DB_UNREADABLE instead of wiping it', async () => { + SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue('a-stable-key'); + jest.spyOn(SqliteClient, 'getUserPragmaVersion').mockRejectedValue(notADatabase()); + + const error = await captureInitError(); + + expect(error).toBeInstanceOf(SqliteClientError); + + expect(error.code).toBe('OFFLINE_DB_UNREADABLE'); + // Deleting it is the caller's decision - the pending-task queue lives in there. + expect(deleteMocks.every((m) => m.mock.calls.length === 0)).toBe(true); + }); + + it('keeps the original cause on the thrown error', async () => { + const cause = notADatabase(); + SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue('a-stable-key'); + jest.spyOn(SqliteClient, 'getUserPragmaVersion').mockRejectedValue(cause); + + const error = await captureInitError(); + + expect(error).toBeInstanceOf(SqliteClientError); + + expect(error.cause).toBe(cause); + }); + + it('throws even with no encryption configured', async () => { + // Turning encryption off leaves an encrypted file and no key to read it. Simply + // reporting failure would leave offline support uninitialized forever, so the + // caller is told and can delete it. + jest.spyOn(SqliteClient, 'getUserPragmaVersion').mockRejectedValue(notADatabase()); + + const error = await captureInitError(); + + expect(error).toBeInstanceOf(SqliteClientError); + + expect(error.code).toBe('OFFLINE_DB_UNREADABLE'); + }); + + it('does not throw on a transient failure', async () => { + SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue('a-stable-key'); + jest + .spyOn(SqliteClient, 'getUserPragmaVersion') + .mockRejectedValue(new Error('Query failed: Error: database is locked')); + + await expect(SqliteClient.initializeDatabase()).resolves.toBe(false); + }); + }); + + describe('isUnreadableDbError', () => { + it.each([ + 'file is not a database', + 'SQLite error code: 26', + 'SQLite code:11', + 'NOTADB', + 'SQLITE_CORRUPT', + 'database disk image is malformed', + 'file is encrypted or is not a database', + ])('treats %p as unreadable', (message) => { + expect(SqliteClient.isUnreadableDbError(new Error(message))).toBe(true); + }); + + it.each([ + 'database is locked', + 'SQLITE_BUSY', + 'SQLITE_LOCKED', + 'disk I/O error', + 'SQLITE_IOERR', + 'unable to open database file', + 'SQLITE_CANTOPEN', + 'out of memory', + 'attempt to write a readonly database', + 'DB is not open or initialized.', + 'Please install "@op-engineering/op-sqlite" package to enable offline support', + ])('does not treat %p as unreadable', (message) => { + expect(SqliteClient.isUnreadableDbError(new Error(message))).toBe(false); + }); + + it('lets a transient reason win when both are present in one message', () => { + // Pins the precedence rule: a message that could be read either way must not + // trigger a wipe. Guessing wrong in this direction destroys a good database. + expect( + SqliteClient.isUnreadableDbError( + new Error('unable to open database file: file is not a database'), + ), + ).toBe(false); + }); + + it('handles non-Error throwables', () => { + expect(SqliteClient.isUnreadableDbError('file is not a database')).toBe(true); + expect(SqliteClient.isUnreadableDbError(undefined)).toBe(false); + }); + }); +});