From 61cea36649998fc5b1e8f2bfce3d70ae8381d244 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 12 Jun 2026 21:30:32 -0300 Subject: [PATCH 1/5] feat: add expo-sqlite driver adapter, key service, and live-query hooks (NATIVE-1274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delivers the driver adapter layer sitting between expo-sqlite and the rest of the app, as the only permitted call site for expo-sqlite: - connection.ts: open/close lifecycle with App Group path resolution (iOS), post-open PRAGMA key → busy_timeout=500 → WAL invariant, per-DB handle registry, drizzle() wrapping, deleteDb support. - keyService.ts: getOrCreateDatabaseKey / deleteDatabaseKey backed by a keychainShim interface; shim defaults to an in-memory dev stand-in pending the native Keychain binding (NATIVE-1276). CSPRNG from @rocket.chat/mobile-crypto randomBytes (64 hex chars / 32 bytes). - observe.ts: useTableQuery (V2 structural-sharing list hook, ~16ms debounce, table-filtered addDatabaseChangeListener) and useRowObserve (V3 per-rowid hook), both ported from the on-device validated PoC. - ios/Podfile.properties.json: expo.sqlite.useSQLCipher = "true" - android/gradle.properties: expo.sqlite.useSQLCipher=true - expo-sqlite ~16.0.10 added via `expo install` (SDK-54 compatible) L1 Jest tests cover: key creation/idempotence, no key material in errors, shim replacement, DB name derivation, open-sequence ordering (PRAGMA key first), busy_timeout, WAL, registry dedup, debounce coalescing, table filtering, structural sharing (same ref/new ref), useRowObserve rowId matching. 33 tests added; full suite 1572 tests green. --- android/gradle.properties | 5 +- .../driver/__tests__/connection.test.ts | 215 +++++++++++ .../driver/__tests__/keyService.test.ts | 148 ++++++++ .../database/driver/__tests__/observe.test.ts | 333 ++++++++++++++++++ app/lib/database/driver/connection.ts | 228 ++++++++++++ app/lib/database/driver/keyService.ts | 109 ++++++ app/lib/database/driver/observe.ts | 210 +++++++++++ ios/Podfile.properties.json | 3 + package.json | 1 + pnpm-lock.yaml | 28 +- 10 files changed, 1277 insertions(+), 3 deletions(-) create mode 100644 app/lib/database/driver/__tests__/connection.test.ts create mode 100644 app/lib/database/driver/__tests__/keyService.test.ts create mode 100644 app/lib/database/driver/__tests__/observe.test.ts create mode 100644 app/lib/database/driver/connection.ts create mode 100644 app/lib/database/driver/keyService.ts create mode 100644 app/lib/database/driver/observe.ts create mode 100644 ios/Podfile.properties.json diff --git a/android/gradle.properties b/android/gradle.properties index 10c0243a7bf..b8dc418e2d4 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -48,4 +48,7 @@ hermesEnabled=true # Use this property to enable edge-to-edge display support. # This allows your app to draw behind system bars for an immersive UI. # Note: Only works with ReactActivity and should not be used with custom Activity. -edgeToEdgeEnabled=false \ No newline at end of file +edgeToEdgeEnabled=false + +# Enable SQLCipher via expo-sqlite (required for encrypted-at-rest databases) +expo.sqlite.useSQLCipher=true \ No newline at end of file diff --git a/app/lib/database/driver/__tests__/connection.test.ts b/app/lib/database/driver/__tests__/connection.test.ts new file mode 100644 index 00000000000..afbe88ba3bd --- /dev/null +++ b/app/lib/database/driver/__tests__/connection.test.ts @@ -0,0 +1,215 @@ +/** + * Connection lifecycle tests — L1 (Jest, mocked expo-sqlite and keyService). + * + * Covers: + * - deriveServerDbName: strips scheme, replaces slashes, single .db suffix + * - open-sequence ordering: PRAGMA key is first, busy_timeout second, WAL third + * - registry: same handle returned on second open, no duplicate opens + * - closeDb: removes from registry; next open re-runs full sequence + * - open-verify failure: safe error message, no key material + */ + +import { deriveServerDbName, openServersDb, openServerDb, closeDb, _clearRegistry, _getRegistry, DEFAULT_DB_NAME } from '../connection'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +// expo-sqlite mock — records calls to execAsync in order so we can assert PRAGMA ordering +const execCalls: string[] = []; +let verifyFails = false; + +const mockSqlite = { + execAsync: jest.fn(async (sql: string) => { + execCalls.push(sql); + }), + getFirstAsync: jest.fn(async (_sql: string) => { + if (verifyFails) throw new Error('file is not a database'); + return { count: 0 }; + }), + closeAsync: jest.fn(async () => {}), + // Minimal stub for drizzle to accept — drizzle-orm/expo-sqlite wraps the raw client + execSync: jest.fn(), + runAsync: jest.fn(async () => ({ changes: 0, lastInsertRowId: 0 })), + getAllAsync: jest.fn(async () => []), + getFirstSync: jest.fn(() => null), + getAllSync: jest.fn(() => []) +}; + +jest.mock('expo-sqlite', () => ({ + openDatabaseAsync: jest.fn(async (_name: string, _opts?: unknown, _dir?: string) => mockSqlite), + deleteDatabaseAsync: jest.fn(async () => {}), + addDatabaseChangeListener: jest.fn(() => ({ remove: jest.fn() })) +})); + +jest.mock('expo-file-system', () => ({ + Paths: { + appleSharedContainers: { + 'group.ios.chat.rocket': { uri: '/fake/app-group/' } + } + } +})); + +jest.mock('../keyService', () => ({ + getOrCreateDatabaseKey: jest.fn(async (_name: string) => 'a'.repeat(64)), + deleteDatabaseKey: jest.fn(async () => {}) +})); + +// drizzle-orm/expo-sqlite — stub out Drizzle wrapping (we only test connection logic) +jest.mock('drizzle-orm/expo-sqlite', () => ({ + drizzle: jest.fn(() => ({})) +})); + +// React Native Platform +jest.mock('react-native', () => ({ + Platform: { OS: 'ios' } +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +import { openDatabaseAsync } from 'expo-sqlite'; +import { getOrCreateDatabaseKey } from '../keyService'; + +beforeEach(() => { + execCalls.length = 0; + verifyFails = false; + _clearRegistry(); + jest.clearAllMocks(); + // Restore defaults after clearAllMocks + (openDatabaseAsync as jest.Mock).mockResolvedValue(mockSqlite); + (getOrCreateDatabaseKey as jest.Mock).mockResolvedValue('a'.repeat(64)); + mockSqlite.execAsync.mockImplementation(async (sql: string) => { + execCalls.push(sql); + }); + mockSqlite.getFirstAsync.mockImplementation(async () => { + if (verifyFails) throw new Error('file is not a database'); + return { count: 0 }; + }); + mockSqlite.closeAsync.mockResolvedValue(undefined); +}); + +// --------------------------------------------------------------------------- +// deriveServerDbName +// --------------------------------------------------------------------------- + +describe('deriveServerDbName', () => { + it('strips https scheme and replaces slashes with dots', () => { + expect(deriveServerDbName('https://open.rocket.chat')).toBe('open.rocket.chat.db'); + }); + + it('strips http scheme', () => { + expect(deriveServerDbName('http://localhost:3000')).toBe('localhost:3000.db'); + }); + + it('trims trailing slashes before munging', () => { + expect(deriveServerDbName('https://open.rocket.chat/')).toBe('open.rocket.chat.db'); + expect(deriveServerDbName('https://open.rocket.chat//')).toBe('open.rocket.chat.db'); + }); + + it('produces a single .db suffix (no .db.db doubling)', () => { + const name = deriveServerDbName('https://my.server.com'); + expect(name.endsWith('.db')).toBe(true); + expect(name.endsWith('.db.db')).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Open sequence ordering +// --------------------------------------------------------------------------- + +describe('open sequence', () => { + it('applies PRAGMA key before busy_timeout before WAL', async () => { + await openServersDb(); + + const keyIdx = execCalls.findIndex(s => s.includes('PRAGMA key')); + const busyIdx = execCalls.findIndex(s => s.includes('busy_timeout')); + const walIdx = execCalls.findIndex(s => s.includes('journal_mode')); + + expect(keyIdx).toBeGreaterThanOrEqual(0); + expect(busyIdx).toBeGreaterThan(keyIdx); + expect(walIdx).toBeGreaterThan(busyIdx); + }); + + it('includes the raw-key x-hex form in the PRAGMA key statement', async () => { + await openServersDb(); + const pragmaKey = execCalls.find(s => s.includes('PRAGMA key')); + expect(pragmaKey).toMatch(/PRAGMA key = "x'/); + expect(pragmaKey).toMatch(/[0-9a-fA-F]{64}/); + }); + + it('sets busy_timeout = 500', async () => { + await openServersDb(); + expect(execCalls.some(s => s.includes('busy_timeout = 500'))).toBe(true); + }); + + it('sets journal_mode = WAL', async () => { + await openServersDb(); + expect(execCalls.some(s => s.toLowerCase().includes('journal_mode = wal'))).toBe(true); + }); + + it('fetches the key before opening the database', async () => { + const callOrder: string[] = []; + (getOrCreateDatabaseKey as jest.Mock).mockImplementation(async () => { + callOrder.push('key'); + return 'a'.repeat(64); + }); + (openDatabaseAsync as jest.Mock).mockImplementation(async () => { + callOrder.push('open'); + return mockSqlite; + }); + + await openServerDb('https://example.com'); + expect(callOrder.indexOf('key')).toBeLessThan(callOrder.indexOf('open')); + }); +}); + +// --------------------------------------------------------------------------- +// Registry +// --------------------------------------------------------------------------- + +describe('registry', () => { + it('returns the cached handle on a second open (no duplicate openDatabaseAsync)', async () => { + const h1 = await openServersDb(); + const h2 = await openServersDb(); + expect(h1).toBe(h2); + expect(openDatabaseAsync).toHaveBeenCalledTimes(1); + }); + + it('registers different handles for different server URLs', async () => { + const h1 = await openServerDb('https://server-a.com'); + const h2 = await openServerDb('https://server-b.com'); + expect(h1).not.toBe(h2); + }); + + it('closeDb removes the entry so the next open re-runs the sequence', async () => { + await openServersDb(); + await closeDb(DEFAULT_DB_NAME); + expect(_getRegistry().has(DEFAULT_DB_NAME)).toBe(false); + await openServersDb(); + expect(openDatabaseAsync).toHaveBeenCalledTimes(2); + }); +}); + +// --------------------------------------------------------------------------- +// Open-verify failure +// --------------------------------------------------------------------------- + +describe('open-verify failure', () => { + it('throws a safe error when verify fails', async () => { + verifyFails = true; + await expect(openServersDb()).rejects.toThrow('database open-verify failed'); + }); + + it('error message contains no key material', async () => { + verifyFails = true; + let err: Error | undefined; + try { + await openServersDb(); + } catch (e) { + err = e as Error; + } + expect(err!.message).not.toMatch(/[0-9a-fA-F]{32,}/); + }); +}); diff --git a/app/lib/database/driver/__tests__/keyService.test.ts b/app/lib/database/driver/__tests__/keyService.test.ts new file mode 100644 index 00000000000..2d29350a36b --- /dev/null +++ b/app/lib/database/driver/__tests__/keyService.test.ts @@ -0,0 +1,148 @@ +/** + * Key service tests — L1 (Jest, mocked storage). + * + * Covers: + * - creation: generates a 64-hex-char key + * - idempotence: repeated calls return the same key + * - no key material in thrown errors + * - deleteDatabaseKey removes the entry (next call generates a new key) + * - installKeychainShim replaces the backing store + * - dev shim fails loud outside __DEV__ (silent data loss otherwise) + */ + +import { randomKey } from '@rocket.chat/mobile-crypto'; + +import { getOrCreateDatabaseKey, deleteDatabaseKey, installKeychainShim, type IKeychainShim } from '../keyService'; + +// Mock @rocket.chat/mobile-crypto so Jest doesn't need the native module. +// randomKey returns hex (the source uses it instead of randomBytes, which returns base64). +jest.mock('@rocket.chat/mobile-crypto', () => ({ + randomKey: jest.fn(async (_bytes: number) => 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90') +})); + +function makeMemoryShim(): IKeychainShim { + const store = new Map(); + return { + getItem: async (key: string) => store.get(key) ?? null, + setItem: async (key: string, value: string) => { + store.set(key, value); + }, + removeItem: async (key: string) => { + store.delete(key); + } + }; +} + +beforeEach(() => { + // Install a fresh shim so tests are isolated + installKeychainShim(makeMemoryShim()); +}); + +describe('getOrCreateDatabaseKey', () => { + it('generates a 64-hex-char key', async () => { + const key = await getOrCreateDatabaseKey('servers.db'); + expect(key).toMatch(/^[0-9a-fA-F]{64}$/); + }); + + it('returns the same key on repeated calls (idempotent)', async () => { + const k1 = await getOrCreateDatabaseKey('open.rocket.chat.db'); + const k2 = await getOrCreateDatabaseKey('open.rocket.chat.db'); + expect(k1).toBe(k2); + }); + + it('returns different keys for different db names', async () => { + // The mock always returns the same hex but each name gets its own entry; + // since we mock randomKey to the same value both will equal the mock value — + // the key isolation per name is structural (separate store entries), tested via delete below. + const k1 = await getOrCreateDatabaseKey('server-a.db'); + const k2 = await getOrCreateDatabaseKey('server-b.db'); + // Both are 64-hex; they happen to be equal under the mock but the storage keys differ + expect(k1).toMatch(/^[0-9a-fA-F]{64}$/); + expect(k2).toMatch(/^[0-9a-fA-F]{64}$/); + }); + + it('does not include key material in thrown errors', async () => { + // Simulate a CSPRNG that returns invalid output (e.g. the base64 randomBytes shape) + (randomKey as jest.Mock).mockResolvedValueOnce('not-valid-hex!!'); + + let thrown: Error | undefined; + try { + await getOrCreateDatabaseKey('bad.db'); + } catch (e) { + thrown = e as Error; + } + + expect(thrown).toBeDefined(); + // Error message must not contain the bad output or any hex key material + expect(thrown!.message).not.toMatch(/not-valid-hex/); + expect(thrown!.message).not.toMatch(/[0-9a-fA-F]{32}/); + // Restore + (randomKey as jest.Mock).mockResolvedValue('a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90'); + }); +}); + +describe('deleteDatabaseKey', () => { + it('removes the stored key so the next call generates a fresh one', async () => { + const shim = makeMemoryShim(); + installKeychainShim(shim); + + await getOrCreateDatabaseKey('temp.db'); + await deleteDatabaseKey('temp.db'); + + // The store should now be empty for this key; mock returns same value so we verify + // the setItem was called again by checking the shim received a second write + const spySet = jest.spyOn(shim, 'setItem'); + await getOrCreateDatabaseKey('temp.db'); + expect(spySet).toHaveBeenCalledTimes(1); + }); + + it('is a no-op for a name that was never stored', async () => { + await expect(deleteDatabaseKey('nonexistent.db')).resolves.toBeUndefined(); + }); +}); + +describe('installKeychainShim', () => { + it('redirects all calls to the new shim', async () => { + const shim = makeMemoryShim(); + const spyGet = jest.spyOn(shim, 'getItem'); + const spySet = jest.spyOn(shim, 'setItem'); + + installKeychainShim(shim); + await getOrCreateDatabaseKey('redirect.db'); + + expect(spyGet).toHaveBeenCalledWith('db_key_v1:redirect.db'); + expect(spySet).toHaveBeenCalledWith('db_key_v1:redirect.db', expect.stringMatching(/^[0-9a-fA-F]{64}$/)); + }); +}); + +describe('dev keychain shim', () => { + it('fails loud outside __DEV__ when no real shim is installed', async () => { + const g = globalThis as unknown as { __DEV__: boolean }; + const originalDev = g.__DEV__; + g.__DEV__ = false; + try { + // Fresh module instance so the default dev shim is in place + // (beforeEach installed a memory shim on the shared instance) + let fresh!: typeof import('../keyService'); + jest.isolateModules(() => { + // eslint-disable-next-line global-require + fresh = require('../keyService'); + }); + await expect(fresh.getOrCreateDatabaseKey('prod.db')).rejects.toThrow( + 'keychain shim not installed — call installKeychainShim before opening databases' + ); + } finally { + g.__DEV__ = originalDev; + } + }); + + it('serves keys in __DEV__ without an installed shim', async () => { + let fresh!: typeof import('../keyService'); + jest.isolateModules(() => { + // eslint-disable-next-line global-require + fresh = require('../keyService'); + }); + const key = await fresh.getOrCreateDatabaseKey('dev.db'); + expect(key).toMatch(/^[0-9a-fA-F]{64}$/); + }); +}); diff --git a/app/lib/database/driver/__tests__/observe.test.ts b/app/lib/database/driver/__tests__/observe.test.ts new file mode 100644 index 00000000000..0fa53979fba --- /dev/null +++ b/app/lib/database/driver/__tests__/observe.test.ts @@ -0,0 +1,333 @@ +/** + * Live-query hook tests — L1 (Jest, mocked expo-sqlite, React 19 renderHook). + * + * Covers: + * - debounce/coalescing: many rapid events → single re-query (jest fake timers) + * - table filtering: events for other tables are ignored + * - cross-DB filtering: events from another database file are ignored (the change + * listener is global and both DBs share table names) + * - structural sharing: same object ref when row content unchanged; new ref when changed + * - useRowObserve: re-fetches only on matching rowId; ignores other rowIds + * - useRowObserve: returns null when rowId is null + * - stable callback discipline: listener registration survives re-renders + */ + +import { renderHook, act } from '@testing-library/react-native'; +import { useTableQuery, useRowObserve, type UseTableQueryOptions } from '../observe'; +import type { DbHandle } from '../connection'; + +// --------------------------------------------------------------------------- +// expo-sqlite mock +// --------------------------------------------------------------------------- + +type ChangeListener = (event: { tableName: string; rowId: number; databaseName: string; databaseFilePath: string }) => void; +const _listeners: ChangeListener[] = []; + +const mockSubscription = { remove: jest.fn() }; + +jest.mock('expo-sqlite', () => ({ + addDatabaseChangeListener: jest.fn((fn: ChangeListener) => { + _listeners.push(fn); + return { + remove: jest.fn(() => { + const idx = _listeners.indexOf(fn); + if (idx !== -1) _listeners.splice(idx, 1); + }) + }; + }) +})); + +// Database names — events carry an absolute file path; the hooks match on its basename +const DB_NAME = 'open.rocket.chat.db'; +const OTHER_DB_NAME = 'default.db'; + +function fireChange(tableName: string, rowId: number, dbName: string = DB_NAME): void { + _listeners.forEach(fn => fn({ tableName, rowId, databaseName: 'main', databaseFilePath: `/data/databases/${dbName}` })); +} + +// --------------------------------------------------------------------------- +// Fake timer setup +// --------------------------------------------------------------------------- + +beforeEach(() => { + jest.useFakeTimers(); + _listeners.length = 0; + mockSubscription.remove.mockClear(); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +type Row = { id: string; value: string }; + +const rowKey = (r: Row) => r.id; +const equalFn = (a: Row, b: Row) => a.id === b.id && a.value === b.value; + +function makeHandle(queryFn: () => Row[]): { handle: DbHandle; options: UseTableQueryOptions } { + const handle = { dbName: DB_NAME } as DbHandle; // test doesn't use db/sqlite + const options: UseTableQueryOptions = { + tables: ['rooms'], + queryFn, + rowKey, + equalFn + }; + return { handle, options }; +} + +// --------------------------------------------------------------------------- +// useTableQuery — debounce / coalescing +// --------------------------------------------------------------------------- + +describe('useTableQuery debounce', () => { + it('coalesces many rapid events into a single re-query', () => { + let callCount = 0; + const queryFn = jest.fn((): Row[] => { + callCount++; + return [{ id: '1', value: `v${callCount}` }]; + }); + const { handle, options } = makeHandle(queryFn); + + renderHook(() => useTableQuery(handle, options)); + + const initialCalls = queryFn.mock.calls.length; // initial load + + // Fire 100 events without advancing timers + for (let i = 0; i < 100; i++) { + fireChange('rooms', i); + } + + // Still only the initial call — debounce hasn't fired + expect(queryFn).toHaveBeenCalledTimes(initialCalls); + + // Advance timers by 16ms to flush the debounce + act(() => { + jest.advanceTimersByTime(16); + }); + + // Exactly one additional call — all 100 events coalesced + expect(queryFn).toHaveBeenCalledTimes(initialCalls + 1); + }); + + it('restarts the debounce timer on each event', () => { + const queryFn = jest.fn((): Row[] => []); + const { handle, options } = makeHandle(queryFn); + + renderHook(() => useTableQuery(handle, options)); + const initial = queryFn.mock.calls.length; + + // Fire event, advance 10ms (less than 16ms window), fire again + fireChange('rooms', 1); + act(() => { + jest.advanceTimersByTime(10); + }); + fireChange('rooms', 2); + act(() => { + jest.advanceTimersByTime(10); + }); + + // Timer restarted — no re-query yet + expect(queryFn).toHaveBeenCalledTimes(initial); + + act(() => { + jest.advanceTimersByTime(6); + }); + + // Now 16ms since last event — one re-query + expect(queryFn).toHaveBeenCalledTimes(initial + 1); + }); +}); + +// --------------------------------------------------------------------------- +// useTableQuery — table filtering +// --------------------------------------------------------------------------- + +describe('useTableQuery table filtering', () => { + it('ignores events for tables not in the subscription list', () => { + const queryFn = jest.fn((): Row[] => []); + const { handle, options } = makeHandle(queryFn); + + renderHook(() => useTableQuery(handle, options)); + const initial = queryFn.mock.calls.length; + + fireChange('messages', 1); // 'rooms' not 'messages' + act(() => { + jest.advanceTimersByTime(20); + }); + + expect(queryFn).toHaveBeenCalledTimes(initial); + }); + + it('responds to events for a subscribed table', () => { + const queryFn = jest.fn((): Row[] => []); + const { handle, options } = makeHandle(queryFn); + + renderHook(() => useTableQuery(handle, options)); + const initial = queryFn.mock.calls.length; + + fireChange('rooms', 1); + act(() => { + jest.advanceTimersByTime(20); + }); + + expect(queryFn).toHaveBeenCalledTimes(initial + 1); + }); + + it('ignores events for the same table name in a different database', () => { + const queryFn = jest.fn((): Row[] => []); + const { handle, options } = makeHandle(queryFn); + + renderHook(() => useTableQuery(handle, options)); + const initial = queryFn.mock.calls.length; + + fireChange('rooms', 1, OTHER_DB_NAME); // subscribed table, other DB file + act(() => { + jest.advanceTimersByTime(20); + }); + + expect(queryFn).toHaveBeenCalledTimes(initial); + }); +}); + +// --------------------------------------------------------------------------- +// useTableQuery — structural sharing +// --------------------------------------------------------------------------- + +describe('useTableQuery structural sharing', () => { + it('reuses the same object reference when row content is unchanged', () => { + const row: Row = { id: '1', value: 'hello' }; + const queryFn = jest.fn((): Row[] => [{ ...row }]); // new object each call + const { handle, options } = makeHandle(queryFn); + + const { result } = renderHook(() => useTableQuery(handle, options)); + + const firstRef = result.current[0]; + expect(firstRef).toBeDefined(); + + // Trigger re-query with same content + fireChange('rooms', 1); + act(() => { + jest.advanceTimersByTime(20); + }); + + // Content is equal → same reference + expect(result.current[0]).toBe(firstRef); + }); + + it('produces a new object reference when row content changes', () => { + let tick = 0; + const queryFn = jest.fn((): Row[] => [{ id: '1', value: `v${tick}` }]); + const { handle, options } = makeHandle(queryFn); + + const { result } = renderHook(() => useTableQuery(handle, options)); + + const firstRef = result.current[0]; + + tick = 1; // change the value on next query + fireChange('rooms', 1); + act(() => { + jest.advanceTimersByTime(20); + }); + + // Content changed → new reference + expect(result.current[0]).not.toBe(firstRef); + expect(result.current[0].value).toBe('v1'); + }); + + it('returns empty array when dbHandle is null', () => { + const queryFn = jest.fn((): Row[] => [{ id: '1', value: 'x' }]); + const options: UseTableQueryOptions = { tables: ['rooms'], queryFn, rowKey, equalFn }; + + const { result } = renderHook(() => useTableQuery(null, options)); + expect(result.current).toEqual([]); + // queryFn not called — no handle + expect(queryFn).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// useRowObserve +// --------------------------------------------------------------------------- + +describe('useRowObserve', () => { + type FetchRow = (rowId: number) => Row | null; + + it('returns null when rowId is null', () => { + const fetchRow: FetchRow = jest.fn(() => null); + const handle = { dbName: DB_NAME } as DbHandle; + + const { result } = renderHook(() => useRowObserve(handle, 'subscriptions', null, fetchRow)); + expect(result.current).toBeNull(); + expect(fetchRow).not.toHaveBeenCalled(); + }); + + it('returns initial row from fetchRow on mount', () => { + const fetchRow: FetchRow = jest.fn(rowId => ({ id: String(rowId), value: 'initial' })); + const handle = { dbName: DB_NAME } as DbHandle; + + const { result } = renderHook(() => useRowObserve(handle, 'subscriptions', 42, fetchRow)); + expect(result.current).toEqual({ id: '42', value: 'initial' }); + expect(fetchRow).toHaveBeenCalledWith(42); + }); + + it('re-fetches when matching rowId event arrives', () => { + let counter = 0; + const fetchRow: FetchRow = jest.fn(rowId => ({ id: String(rowId), value: `v${counter}` })); + const handle = { dbName: DB_NAME } as DbHandle; + + const { result } = renderHook(() => useRowObserve(handle, 'subscriptions', 42, fetchRow)); + expect(result.current?.value).toBe('v0'); + + counter = 1; + act(() => { + fireChange('subscriptions', 42); + }); + expect(result.current?.value).toBe('v1'); + }); + + it('does not re-fetch for a different rowId', () => { + const fetchRow: FetchRow = jest.fn(rowId => ({ id: String(rowId), value: 'stable' })); + const handle = { dbName: DB_NAME } as DbHandle; + + renderHook(() => useRowObserve(handle, 'subscriptions', 42, fetchRow)); + const callsBefore = (fetchRow as jest.Mock).mock.calls.length; + + act(() => { + fireChange('subscriptions', 99); // different rowId + }); + + expect(fetchRow).toHaveBeenCalledTimes(callsBefore); + }); + + it('does not re-fetch for a different table', () => { + const fetchRow: FetchRow = jest.fn(rowId => ({ id: String(rowId), value: 'stable' })); + const handle = { dbName: DB_NAME } as DbHandle; + + renderHook(() => useRowObserve(handle, 'subscriptions', 42, fetchRow)); + const callsBefore = (fetchRow as jest.Mock).mock.calls.length; + + act(() => { + fireChange('messages', 42); // right rowId, wrong table + }); + + expect(fetchRow).toHaveBeenCalledTimes(callsBefore); + }); + + it('does not re-fetch for the same table and rowId in a different database', () => { + const fetchRow: FetchRow = jest.fn(rowId => ({ id: String(rowId), value: 'stable' })); + const handle = { dbName: DB_NAME } as DbHandle; + + renderHook(() => useRowObserve(handle, 'subscriptions', 42, fetchRow)); + const callsBefore = (fetchRow as jest.Mock).mock.calls.length; + + act(() => { + fireChange('subscriptions', 42, OTHER_DB_NAME); // right table + rowId, other DB file + }); + + expect(fetchRow).toHaveBeenCalledTimes(callsBefore); + }); +}); diff --git a/app/lib/database/driver/connection.ts b/app/lib/database/driver/connection.ts new file mode 100644 index 00000000000..233dff9e7fd --- /dev/null +++ b/app/lib/database/driver/connection.ts @@ -0,0 +1,228 @@ +/** + * Database connection lifecycle — open, key, configure, wrap with Drizzle, close. + * + * Files outside app/lib/database/driver/ must not import expo-sqlite; an ESLint rule + * enforcing the ban arrives with the facade work. + * + * Open sequence (non-negotiable invariants): + * 1. openDatabaseAsync → raw SQLiteDatabase handle + * 2. PRAGMA key = "x'<64-hex>'" ← must be the FIRST statement; SQLCipher + * requires this before any schema access or the file is opened unencrypted + * and subsequent reads produce garbage or "not a database" errors. + * 3. PRAGMA busy_timeout = 500 ← mandatory; without it, concurrent access from + * the notification service extension causes SQLITE_BUSY starvation (multi-process + * WAL spike: 100% failure rate without it, 100% success with it) + * 4. PRAGMA journal_mode = WAL + * 5. Verify encryption with a trivial read (sqlite_master count) before handing out handle + * 6. drizzle() wraps the raw handle with the appropriate schema + * + * Raw-key form: PRAGMA key = "x'<64-hex>'" — the x'...' quoting tells SQLCipher + * to treat the value as raw bytes and skip the PBKDF2 key derivation step. This + * is required because our keys come from the CSPRNG (already full-entropy); running + * PBKDF2 on top would add cost with zero security benefit, and the Android native + * reader spike proved the byte[] overload of SQLiteDatabase.openDatabase silently + * PBKDF2-derives (causing "file is not a database" error) — both sides must use the + * same raw-hex string form. + */ + +import { Platform } from 'react-native'; +import { openDatabaseAsync, deleteDatabaseAsync, type SQLiteDatabase } from 'expo-sqlite'; +import { drizzle, type ExpoSQLiteDatabase } from 'drizzle-orm/expo-sqlite'; +import { Paths } from 'expo-file-system'; + +import * as appSchema from './schema/app'; +import * as serversSchema from './schema/servers'; +import { getOrCreateDatabaseKey } from './keyService'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type AppDbKind = 'app'; +export type ServersDbKind = 'servers'; +export type DbKind = AppDbKind | ServersDbKind; + +type SchemaForKind = K extends 'servers' ? typeof serversSchema : typeof appSchema; + +export interface DbHandle { + /** Drizzle-wrapped handle for query/mutation work */ + db: ExpoSQLiteDatabase>; + /** Raw expo-sqlite handle, needed for addDatabaseChangeListener and direct PRAGMAs */ + sqlite: SQLiteDatabase; + /** Derived DB file name (single .db suffix, no path) */ + dbName: string; +} + +// --------------------------------------------------------------------------- +// Name derivation — mirrors the legacy WMDB path but with a single .db suffix +// --------------------------------------------------------------------------- + +const APP_GROUP_ID = 'group.ios.chat.rocket'; + +/** The single servers/global DB name (no server URL involved). */ +export const DEFAULT_DB_NAME = 'default.db'; + +/** + * Derives the per-server database filename from the server URL. + * Strips scheme, replaces slashes with dots, appends a single `.db`. + * Matches the legacy `getDatabasePath` logic in `app/lib/database/index.ts` + * but deliberately drops the `.db.db` double-suffix (wipe-and-restore recreates all files). + */ +export function deriveServerDbName(serverUrl: string): string { + const sanitized = serverUrl + .replace(/\/+$/, '') + .replace(/(^\w+:|^)\/\//, '') + .replace(/\//g, '.'); + return `${sanitized}.db`; +} + +// --------------------------------------------------------------------------- +// App Group directory resolution +// --------------------------------------------------------------------------- + +/** + * Returns the iOS App Group container URI for database placement. + * Falls back to undefined (expo-sqlite default dir) when: + * - running on Android + * - the container is unavailable (simulator builds without entitlement, unit tests) + * A warning is logged on fallback; this never crashes. + */ +function resolveDbDirectory(): string | undefined { + if (Platform.OS !== 'ios') { + return undefined; + } + try { + const containers = Paths.appleSharedContainers as Record; + const container = containers[APP_GROUP_ID]; + if (!container?.uri) { + console.warn( + `[db/connection] App Group container '${APP_GROUP_ID}' not found — falling back to default SQLite directory. ` + + 'Ensure the entitlement is configured for production builds.' + ); + return undefined; + } + // uri may have a trailing slash; expo-sqlite wants a directory path + return container.uri.replace(/\/$/, ''); + } catch (e) { + console.warn('[db/connection] Failed to resolve App Group path:', (e as Error).message, '— falling back to default directory'); + return undefined; + } +} + +// Resolved once at module load; the container path is stable for the process lifetime. +const DB_DIRECTORY = resolveDbDirectory(); + +// --------------------------------------------------------------------------- +// Handle registry — prevents opening the same file twice +// --------------------------------------------------------------------------- + +const _registry = new Map(); + +// --------------------------------------------------------------------------- +// Open +// --------------------------------------------------------------------------- + +async function applyOpenPragmas(sqlite: SQLiteDatabase, keyHex: string): Promise { + // 1. Key MUST be first — before any schema read/write + // Raw-key form: the x'...' prefix tells SQLCipher to skip PBKDF2 + await sqlite.execAsync(`PRAGMA key = "x'${keyHex}'";`); + + // 2. Busy timeout: mandatory for multi-process WAL safety (app + extensions share the file) + await sqlite.execAsync('PRAGMA busy_timeout = 500;'); + + // 3. WAL mode for concurrent reads + one writer + await sqlite.execAsync('PRAGMA journal_mode = WAL;'); + + // 4. Verify encryption is working — a trivial read on an unkeyed SQLCipher file + // throws "file is not a database"; we surface a safe error that contains no key material + try { + await sqlite.getFirstAsync('SELECT count(*) FROM sqlite_master;'); + } catch { + // Do not include keyHex or the raw error (which may echo the PRAGMA) in the thrown message + throw new Error('database open-verify failed — key may be wrong or file corrupt'); + } +} + +/** + * Opens a database for the given `dbName`, applies the full open sequence + * (key → busy_timeout → WAL → verify), wraps with Drizzle, and registers the handle. + * Returns the same handle on repeated calls for the same name. + */ +async function openDb(dbName: string, kind: K): Promise> { + const cached = _registry.get(dbName); + if (cached) { + return cached as DbHandle; + } + + const keyHex = await getOrCreateDatabaseKey(dbName); + + const sqlite = await openDatabaseAsync(dbName, { enableChangeListener: true }, DB_DIRECTORY); + + await applyOpenPragmas(sqlite, keyHex); + + const schema = kind === 'servers' ? serversSchema : appSchema; + // The conditional type SchemaForKind cannot be narrowed by the JS runtime check above; + // casting through unknown is the standard TS pattern for this shape. + const db = drizzle(sqlite, { schema }) as unknown as ExpoSQLiteDatabase>; + + const handle: DbHandle = { db, sqlite, dbName }; + _registry.set(dbName, handle as DbHandle); + return handle; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Opens (or returns the cached handle for) the global servers database. + */ +export async function openServersDb(): Promise> { + return openDb(DEFAULT_DB_NAME, 'servers'); +} + +/** + * Opens (or returns the cached handle for) the per-server app database. + * @param serverUrl The full server URL, e.g. "https://open.rocket.chat" + */ +export async function openServerDb(serverUrl: string): Promise> { + const dbName = deriveServerDbName(serverUrl); + return openDb(dbName, 'app'); +} + +/** + * Closes the database for `dbName`, removing it from the registry. + * Subsequent opens will re-run the full open sequence. + */ +export async function closeDb(dbName: string): Promise { + const handle = _registry.get(dbName); + if (!handle) return; + _registry.delete(dbName); + await handle.sqlite.closeAsync(); +} + +/** + * Deletes the database file for `dbName`. Closes any open handle first. + * Does NOT delete the Keychain key — call `deleteDatabaseKey(dbName)` separately + * if the key should also be destroyed. + */ +export async function deleteDb(dbName: string): Promise { + await closeDb(dbName); + await deleteDatabaseAsync(dbName, DB_DIRECTORY); +} + +/** + * Exposes the internal registry for testing only. Do not import outside tests. + * @internal + */ +export function _getRegistry(): Map { + return _registry; +} + +/** + * Clears the registry without closing handles. For test teardown only. + * @internal + */ +export function _clearRegistry(): void { + _registry.clear(); +} diff --git a/app/lib/database/driver/keyService.ts b/app/lib/database/driver/keyService.ts new file mode 100644 index 00000000000..2ce40c5a1dd --- /dev/null +++ b/app/lib/database/driver/keyService.ts @@ -0,0 +1,109 @@ +/** + * Database key service — generates, stores, and retrieves per-database SQLCipher keys. + * + * Keys are 32-byte CSPRNG values, hex-encoded (64 hex chars). They must never appear + * in logs, thrown errors, or telemetry. + * + * Persistence goes through the IKeychainShim interface. The real backend lands with + * the native-readers work and must satisfy: + * iOS — kSecClassGenericPassword, kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + * kSecAttrSynchronizable = false, access group S6UPZG7ZR3.chat.rocket.reactnative + * (full team-prefixed form; the bare suffix fails with errSecMissingEntitlement) + * Android — Keystore-backed storage (hardware-backed where available) + * MMKV is forbidden as the backing store. + */ + +import { randomKey } from '@rocket.chat/mobile-crypto'; + +// --------------------------------------------------------------------------- +// Keychain shim — replaced via installKeychainShim by the real native binding +// --------------------------------------------------------------------------- + +export interface IKeychainShim { + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; + removeItem(key: string): Promise; +} + +// Dev-only in-memory stand-in. Keys are lost on process restart, so a DB created +// against this shim becomes permanently unreadable — outside dev that is silent +// data loss, hence the loud failure. +const _devStore = new Map(); + +function assertDevShimAllowed(): void { + if (!__DEV__) { + throw new Error('keychain shim not installed — call installKeychainShim before opening databases'); + } +} + +const _devShim: IKeychainShim = { + getItem: async (key: string) => { + assertDevShimAllowed(); + return _devStore.get(key) ?? null; + }, + setItem: async (key: string, value: string) => { + assertDevShimAllowed(); + _devStore.set(key, value); + }, + removeItem: async (key: string) => { + assertDevShimAllowed(); + _devStore.delete(key); + } +}; + +let _shim: IKeychainShim = _devShim; + +/** + * Swap the keychain shim. Call this once at app startup from the native bridge + * integration before any database is opened. + */ +export function installKeychainShim(shim: IKeychainShim): void { + _shim = shim; +} + +// --------------------------------------------------------------------------- +// Key service +// --------------------------------------------------------------------------- + +const KEY_PREFIX = 'db_key_v1:'; + +function storageKey(dbName: string): string { + return `${KEY_PREFIX}${dbName}`; +} + +/** + * Returns the hex key for `dbName`, generating and storing a fresh one if none exists. + * Idempotent: repeated calls for the same name return the same key. + * + * The key is 32 bytes (256-bit) from the platform CSPRNG, hex-encoded to 64 chars. + * It is never logged, never included in thrown errors, never sent to telemetry. + */ +export async function getOrCreateDatabaseKey(dbName: string): Promise { + const k = storageKey(dbName); + const existing = await _shim.getItem(k); + if (existing !== null) { + return existing; + } + + // randomKey (not randomBytes): the mobile-crypto bridge encodes randomBytes as + // BASE64 on both platforms, while randomKey returns hex (SecureRandom/SecRandomCopyBytes + // + bytesToHex). The argument is the byte count: 32 bytes → 64 hex chars. + const hex = await randomKey(32); + // Guard the bridge contract: anything but 64 hex chars must not be used as a key + if (!/^[0-9a-fA-F]{64}$/.test(hex)) { + // Sanitize error — do not include the value in the message + throw new Error('key generation produced unexpected output; cannot open database safely'); + } + + await _shim.setItem(k, hex); + return hex; +} + +/** + * Deletes the stored key for `dbName`. Call this when the database file is being + * permanently destroyed (e.g. server logout + database wipe), not during migration. + * After calling this, the database file is permanently inaccessible. + */ +export async function deleteDatabaseKey(dbName: string): Promise { + await _shim.removeItem(storageKey(dbName)); +} diff --git a/app/lib/database/driver/observe.ts b/app/lib/database/driver/observe.ts new file mode 100644 index 00000000000..a1e2e4dd582 --- /dev/null +++ b/app/lib/database/driver/observe.ts @@ -0,0 +1,210 @@ +/** + * Live-query primitives — PoC-validated patterns ported from poc-drizzle-livequery. + * + * Two patterns: + * + * useTableQuery (V2) — table-filtered change listener + ~16ms debounce + re-query + + * structural sharing. expo-sqlite fires one event per row even inside a single + * transaction: a 5000-row tx fires 5000 events. The debounce coalesces these into + * one re-query. Structural sharing keeps the same object reference when a row's + * content is unchanged, so React.memo bails out for unchanged rows. + * + * useRowObserve (V3) — per-rowid subscription. Only re-fetches when the event's + * rowId matches the watched rowid. Suitable for hot single-row sites (e.g. a + * RoomItem that re-renders on every unread-count change). + * + * Two subtle bugs from the PoC that would silently degrade back to re-render-everything + * if not handled inside these primitives: + * (a) Fresh callback prop per render: the listener must capture a stable ref, not the + * component's latest render closure, or every render re-registers the listener. + * (b) Rebuilt result objects per event: structural sharing must compare by content, + * not by reference, and reuse previous references when content is unchanged. + * + * The `equalFn` parameter encodes which fields constitute "same content" for a given + * row type. Callers provide it once; the hook holds it stable via useRef. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { addDatabaseChangeListener, type DatabaseChangeEvent } from 'expo-sqlite'; + +import type { DbHandle } from './connection'; + +// addDatabaseChangeListener is GLOBAL across every open database, and both our DBs +// share table names (e.g. `users` exists in default.db and each per-server DB). +// Filtering by tableName alone would let a write in one DB trigger re-queries — +// or, worse, rowid-collision refetches — in components observing the other. +// `databaseName` in the event is always 'main', so the file path is the only +// reliable discriminator. +function eventMatchesDb(event: DatabaseChangeEvent, dbName: string): boolean { + return event.databaseFilePath.endsWith(`/${dbName}`); +} + +// --------------------------------------------------------------------------- +// useTableQuery — V2 structural-sharing list hook +// --------------------------------------------------------------------------- + +export type QueryFn = () => T[]; +export type EqualFn = (a: T, b: T) => boolean; +export type RowKey = (row: T) => string | number; + +export interface UseTableQueryOptions { + /** Which tables to listen on — filter ignores events for other tables */ + tables: string[]; + /** Function that runs the Drizzle query synchronously and returns the full result set */ + queryFn: QueryFn; + /** + * Returns the stable row identity key (e.g. row.id or row.rowid). + * Used to locate the previous row object for structural sharing. + */ + rowKey: RowKey; + /** + * Returns true when two row objects have the same content. + * When true, the previous object reference is reused so React.memo bails out. + */ + equalFn: EqualFn; + /** Debounce window in ms. Defaults to 16ms (one animation frame). */ + debounceMs?: number; +} + +/** + * Subscribes to the listed tables and returns a structurally-shared, stably-referenced + * array of rows. Re-renders only when the query result actually changes. + * + * @param dbHandle The database handle from `openServerDb` / `openServersDb`. + * @param options Query and structural-sharing configuration. + * @param deps Extra deps beyond `dbHandle` that should trigger a full re-fetch + * (e.g. filter values). Changing `tables` or `queryFn` identity does + * not automatically re-subscribe — pass them as deps if they can change. + */ +export function useTableQuery(dbHandle: DbHandle | null | undefined, options: UseTableQueryOptions, deps: unknown[] = []): T[] { + const { tables, queryFn, rowKey, equalFn, debounceMs = 16 } = options; + + const [rows, setRows] = useState([]); + const prevMap = useRef(new Map()); + const timerRef = useRef | null>(null); + + // Stable refs — prevent the listener from capturing a stale closure + const queryFnRef = useRef(queryFn); + useEffect(() => { + queryFnRef.current = queryFn; + }); + const rowKeyRef = useRef(rowKey); + useEffect(() => { + rowKeyRef.current = rowKey; + }); + const equalFnRef = useRef(equalFn); + useEffect(() => { + equalFnRef.current = equalFn; + }); + + const fetchAndReconcile = useCallback(() => { + const fresh = queryFnRef.current(); + const prev = prevMap.current; + const next = new Map(); + + const result = fresh.map(row => { + const key = rowKeyRef.current(row); + const old = prev.get(key); + // Reuse previous reference when content is identical — memo bailout + const reused = old !== undefined && equalFnRef.current(old, row) ? old : row; + next.set(key, reused); + return reused; + }); + + prevMap.current = next; + setRows(result); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!dbHandle) { + setRows([]); + return; + } + + // Initial load + fetchAndReconcile(); + + const tableSet = new Set(tables); + const sub = addDatabaseChangeListener((event: DatabaseChangeEvent) => { + if (!eventMatchesDb(event, dbHandle.dbName)) return; + if (!tableSet.has(event.tableName)) return; + // Debounce: coalesce all events from a single large transaction into one re-query + if (timerRef.current !== null) clearTimeout(timerRef.current); + timerRef.current = setTimeout(fetchAndReconcile, debounceMs); + }); + + return () => { + sub.remove(); + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + // deps are caller-controlled; tables/debounceMs treated as stable across renders + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dbHandle, fetchAndReconcile, ...deps]); + + return rows; +} + +// --------------------------------------------------------------------------- +// useRowObserve — V3 per-rowid hook +// --------------------------------------------------------------------------- + +export type FetchRowFn = (rowId: number) => T | null; + +/** + * Subscribes to changes for a single row identified by its SQLite rowid. + * Re-fetches only when an event for `tableName` + `rowId` arrives. + * + * `onConflictDoUpdate` keeps the same rowid, so per-rowid observers survive upserts. + * A rowid of null/undefined (row not yet inserted) causes the hook to return null. + */ +export function useRowObserve( + dbHandle: DbHandle | null | undefined, + tableName: string, + rowId: number | null | undefined, + fetchRow: FetchRowFn +): T | null { + const [row, setRow] = useState(() => { + if (!dbHandle || rowId == null) return null; + return fetchRow(rowId); + }); + + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + // Stable ref so the listener never captures a stale fetchRow + const fetchRowRef = useRef(fetchRow); + useEffect(() => { + fetchRowRef.current = fetchRow; + }); + + useEffect(() => { + if (!dbHandle || rowId == null) { + setRow(null); + return; + } + + // Load immediately on mount or when rowId changes + setRow(fetchRowRef.current(rowId)); + + const sub = addDatabaseChangeListener((event: DatabaseChangeEvent) => { + if (!eventMatchesDb(event, dbHandle.dbName)) return; + if (event.tableName !== tableName) return; + if (event.rowId !== rowId) return; + if (!mounted.current) return; + setRow(fetchRowRef.current(rowId)); + }); + + return () => sub.remove(); + }, [dbHandle, tableName, rowId]); + + return row; +} diff --git a/ios/Podfile.properties.json b/ios/Podfile.properties.json new file mode 100644 index 00000000000..fccef262541 --- /dev/null +++ b/ios/Podfile.properties.json @@ -0,0 +1,3 @@ +{ + "expo.sqlite.useSQLCipher": "true" +} diff --git a/package.json b/package.json index 436e85031e3..26c46cf8672 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "expo-local-authentication": "~17.0.8", "expo-navigation-bar": "~5.0.10", "expo-notifications": "~0.32.15", + "expo-sqlite": "~16.0.10", "expo-status-bar": "~3.0.9", "expo-system-ui": "~6.0.9", "expo-video-thumbnails": "~10.0.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0043b4cc033..93824fbd9d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -113,7 +113,7 @@ importers: version: 2.0.3 drizzle-orm: specifier: ^0.45.2 - version: 0.45.2 + version: 0.45.2(expo-sqlite@16.0.10(expo@54.0.30(@babel/core@7.25.9)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)) ejson: specifier: 2.2.3 version: 2.2.3 @@ -159,6 +159,9 @@ importers: expo-notifications: specifier: ~0.32.15 version: 0.32.15(expo@54.0.30(@babel/core@7.25.9)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-sqlite: + specifier: ~16.0.10 + version: 16.0.10(expo@54.0.30(@babel/core@7.25.9)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo-status-bar: specifier: ~3.0.9 version: 3.0.9(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -3584,6 +3587,9 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + await-lock@2.2.2: + resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==} + axios@0.30.3: resolution: {integrity: sha512-5/tmEb6TmE/ax3mdXBc/Mi6YdPGxQsv+0p5YlciXWt3PHIn0VamqCXhRMtScnwY3lbgSXLneOuXAKUhgmSRpwg==} @@ -4910,6 +4916,13 @@ packages: resolution: {integrity: sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA==} engines: {node: '>=20.16.0'} + expo-sqlite@16.0.10: + resolution: {integrity: sha512-tUOKxE9TpfneRG3eOfbNfhN9236SJ7IiUnP8gCqU7umd9DtgDGB/5PhYVVfl+U7KskgolgNoB9v9OZ9iwXN8Eg==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + expo-status-bar@3.0.9: resolution: {integrity: sha512-xyYyVg6V1/SSOZWh4Ni3U129XHCnFHBTcUo0dhWtFDrZbNp/duw5AGsQfb2sVeU0gxWHXSY1+5F0jnKYC7WuOw==} peerDependencies: @@ -11952,6 +11965,8 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + await-lock@2.2.2: {} + axios@0.30.3: dependencies: follow-redirects: 1.15.11 @@ -12708,7 +12723,9 @@ snapshots: esbuild: 0.25.12 tsx: 4.22.4 - drizzle-orm@0.45.2: {} + drizzle-orm@0.45.2(expo-sqlite@16.0.10(expo@54.0.30(@babel/core@7.25.9)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)): + optionalDependencies: + expo-sqlite: 16.0.10(expo@54.0.30(@babel/core@7.25.9)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) dunder-proto@1.0.1: dependencies: @@ -13400,6 +13417,13 @@ snapshots: expo-server@1.0.5: {} + expo-sqlite@16.0.10(expo@54.0.30(@babel/core@7.25.9)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + await-lock: 2.2.2 + expo: 54.0.30(@babel/core@7.25.9)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0) + expo-status-bar@3.0.9(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: react: 19.1.0 From e5e3091fb1d6466aa9c6322328de7f0d4ef112ae Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 12 Jun 2026 22:21:01 -0300 Subject: [PATCH 2/5] chore: fix lint on driver adapter sources --- app/lib/database/driver/connection.ts | 10 +++++++--- app/lib/database/driver/keyService.ts | 10 ++++++---- app/lib/database/driver/observe.ts | 6 +++++- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/app/lib/database/driver/connection.ts b/app/lib/database/driver/connection.ts index 233dff9e7fd..d61d8170728 100644 --- a/app/lib/database/driver/connection.ts +++ b/app/lib/database/driver/connection.ts @@ -104,7 +104,11 @@ function resolveDbDirectory(): string | undefined { // uri may have a trailing slash; expo-sqlite wants a directory path return container.uri.replace(/\/$/, ''); } catch (e) { - console.warn('[db/connection] Failed to resolve App Group path:', (e as Error).message, '— falling back to default directory'); + console.warn( + '[db/connection] Failed to resolve App Group path:', + (e as Error).message, + '— falling back to default directory' + ); return undefined; } } @@ -177,7 +181,7 @@ async function openDb(dbName: string, kind: K): Promise> { +export function openServersDb(): Promise> { return openDb(DEFAULT_DB_NAME, 'servers'); } @@ -185,7 +189,7 @@ export async function openServersDb(): Promise> { * Opens (or returns the cached handle for) the per-server app database. * @param serverUrl The full server URL, e.g. "https://open.rocket.chat" */ -export async function openServerDb(serverUrl: string): Promise> { +export function openServerDb(serverUrl: string): Promise> { const dbName = deriveServerDbName(serverUrl); return openDb(dbName, 'app'); } diff --git a/app/lib/database/driver/keyService.ts b/app/lib/database/driver/keyService.ts index 2ce40c5a1dd..2d14299627a 100644 --- a/app/lib/database/driver/keyService.ts +++ b/app/lib/database/driver/keyService.ts @@ -37,17 +37,19 @@ function assertDevShimAllowed(): void { } const _devShim: IKeychainShim = { - getItem: async (key: string) => { + getItem: (key: string) => { assertDevShimAllowed(); - return _devStore.get(key) ?? null; + return Promise.resolve(_devStore.get(key) ?? null); }, - setItem: async (key: string, value: string) => { + setItem: (key: string, value: string) => { assertDevShimAllowed(); _devStore.set(key, value); + return Promise.resolve(); }, - removeItem: async (key: string) => { + removeItem: (key: string) => { assertDevShimAllowed(); _devStore.delete(key); + return Promise.resolve(); } }; diff --git a/app/lib/database/driver/observe.ts b/app/lib/database/driver/observe.ts index a1e2e4dd582..076cacbecf0 100644 --- a/app/lib/database/driver/observe.ts +++ b/app/lib/database/driver/observe.ts @@ -76,7 +76,11 @@ export interface UseTableQueryOptions { * (e.g. filter values). Changing `tables` or `queryFn` identity does * not automatically re-subscribe — pass them as deps if they can change. */ -export function useTableQuery(dbHandle: DbHandle | null | undefined, options: UseTableQueryOptions, deps: unknown[] = []): T[] { +export function useTableQuery( + dbHandle: DbHandle | null | undefined, + options: UseTableQueryOptions, + deps: unknown[] = [] +): T[] { const { tables, queryFn, rowKey, equalFn, debounceMs = 16 } = options; const [rows, setRows] = useState([]); From b9c1382a667d2d427224811ec1ba4c01b3b50d39 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 18 Jun 2026 18:01:22 -0300 Subject: [PATCH 3/5] fix: prevent iOS 0xdead10cc kill on the encrypted WAL database MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SQLCipher-encrypted databases run in WAL mode inside the iOS App Group container. iOS kills a suspended app that holds a file lock on a file in a shared container unless it can recognise the WAL file as SQLite — but default SQLCipher encrypts the file header, so iOS denies the idle-WAL background exemption and the held shared lock trips RUNNINGBOARD 0xdead10cc on suspend. Open every database with PRAGMA cipher_plaintext_header_size = 32 so iOS reads the WAL magic and grants the exemption. With a plaintext header SQLCipher no longer stores the salt in the file, so generate and persist a per-database salt alongside the key and supply it via PRAGMA cipher_salt at open time. The 32 plaintext bytes are header metadata only (version/page size), so this is not a security regression. Losing the salt makes the DB unreadable, same as losing the key, so both are destroyed together. --- .../driver/__tests__/connection.test.ts | 24 ++++++- .../driver/__tests__/keyService.test.ts | 70 +++++++++++++++++-- app/lib/database/driver/connection.ts | 38 +++++++--- app/lib/database/driver/keyService.ts | 52 +++++++++++--- 4 files changed, 157 insertions(+), 27 deletions(-) diff --git a/app/lib/database/driver/__tests__/connection.test.ts b/app/lib/database/driver/__tests__/connection.test.ts index afbe88ba3bd..a24d63d21a8 100644 --- a/app/lib/database/driver/__tests__/connection.test.ts +++ b/app/lib/database/driver/__tests__/connection.test.ts @@ -52,6 +52,7 @@ jest.mock('expo-file-system', () => ({ jest.mock('../keyService', () => ({ getOrCreateDatabaseKey: jest.fn(async (_name: string) => 'a'.repeat(64)), + getOrCreateDatabaseSalt: jest.fn(async (_name: string) => 'b'.repeat(32)), deleteDatabaseKey: jest.fn(async () => {}) })); @@ -70,7 +71,7 @@ jest.mock('react-native', () => ({ // --------------------------------------------------------------------------- import { openDatabaseAsync } from 'expo-sqlite'; -import { getOrCreateDatabaseKey } from '../keyService'; +import { getOrCreateDatabaseKey, getOrCreateDatabaseSalt } from '../keyService'; beforeEach(() => { execCalls.length = 0; @@ -80,6 +81,7 @@ beforeEach(() => { // Restore defaults after clearAllMocks (openDatabaseAsync as jest.Mock).mockResolvedValue(mockSqlite); (getOrCreateDatabaseKey as jest.Mock).mockResolvedValue('a'.repeat(64)); + (getOrCreateDatabaseSalt as jest.Mock).mockResolvedValue('b'.repeat(32)); mockSqlite.execAsync.mockImplementation(async (sql: string) => { execCalls.push(sql); }); @@ -120,18 +122,34 @@ describe('deriveServerDbName', () => { // --------------------------------------------------------------------------- describe('open sequence', () => { - it('applies PRAGMA key before busy_timeout before WAL', async () => { + it('applies PRAGMA key, then cipher header + salt, then busy_timeout, then WAL', async () => { await openServersDb(); const keyIdx = execCalls.findIndex(s => s.includes('PRAGMA key')); + const headerIdx = execCalls.findIndex(s => s.includes('cipher_plaintext_header_size')); + const saltIdx = execCalls.findIndex(s => s.includes('cipher_salt')); const busyIdx = execCalls.findIndex(s => s.includes('busy_timeout')); const walIdx = execCalls.findIndex(s => s.includes('journal_mode')); expect(keyIdx).toBeGreaterThanOrEqual(0); - expect(busyIdx).toBeGreaterThan(keyIdx); + expect(headerIdx).toBeGreaterThan(keyIdx); + expect(saltIdx).toBeGreaterThan(headerIdx); + expect(busyIdx).toBeGreaterThan(saltIdx); expect(walIdx).toBeGreaterThan(busyIdx); }); + it('sets cipher_plaintext_header_size = 32', async () => { + await openServersDb(); + expect(execCalls.some(s => s.includes('cipher_plaintext_header_size = 32'))).toBe(true); + }); + + it('includes the raw-salt x-hex form in the cipher_salt statement', async () => { + await openServersDb(); + const pragmaSalt = execCalls.find(s => s.includes('cipher_salt')); + expect(pragmaSalt).toMatch(/PRAGMA cipher_salt = "x'/); + expect(pragmaSalt).toMatch(/[0-9a-fA-F]{32}/); + }); + it('includes the raw-key x-hex form in the PRAGMA key statement', async () => { await openServersDb(); const pragmaKey = execCalls.find(s => s.includes('PRAGMA key')); diff --git a/app/lib/database/driver/__tests__/keyService.test.ts b/app/lib/database/driver/__tests__/keyService.test.ts index 2d29350a36b..58c33c130a4 100644 --- a/app/lib/database/driver/__tests__/keyService.test.ts +++ b/app/lib/database/driver/__tests__/keyService.test.ts @@ -12,12 +12,19 @@ import { randomKey } from '@rocket.chat/mobile-crypto'; -import { getOrCreateDatabaseKey, deleteDatabaseKey, installKeychainShim, type IKeychainShim } from '../keyService'; +import { + getOrCreateDatabaseKey, + getOrCreateDatabaseSalt, + deleteDatabaseKey, + installKeychainShim, + type IKeychainShim +} from '../keyService'; // Mock @rocket.chat/mobile-crypto so Jest doesn't need the native module. -// randomKey returns hex (the source uses it instead of randomBytes, which returns base64). +// randomKey returns hex (the source uses it instead of randomBytes, which returns base64); +// two hex chars per byte, so the length tracks the requested byte count (32 → 64, 16 → 32). jest.mock('@rocket.chat/mobile-crypto', () => ({ - randomKey: jest.fn(async (_bytes: number) => 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90') + randomKey: jest.fn(async (bytes: number) => 'ab'.repeat(bytes)) })); function makeMemoryShim(): IKeychainShim { @@ -36,6 +43,8 @@ function makeMemoryShim(): IKeychainShim { beforeEach(() => { // Install a fresh shim so tests are isolated installKeychainShim(makeMemoryShim()); + // Reset the CSPRNG mock after any one-shot override below + (randomKey as jest.Mock).mockImplementation(async (bytes: number) => 'ab'.repeat(bytes)); }); describe('getOrCreateDatabaseKey', () => { @@ -76,8 +85,46 @@ describe('getOrCreateDatabaseKey', () => { // Error message must not contain the bad output or any hex key material expect(thrown!.message).not.toMatch(/not-valid-hex/); expect(thrown!.message).not.toMatch(/[0-9a-fA-F]{32}/); - // Restore - (randomKey as jest.Mock).mockResolvedValue('a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90'); + }); +}); + +describe('getOrCreateDatabaseSalt', () => { + it('generates a 32-hex-char salt', async () => { + const salt = await getOrCreateDatabaseSalt('servers.db'); + expect(salt).toMatch(/^[0-9a-fA-F]{32}$/); + }); + + it('returns the same salt on repeated calls (idempotent)', async () => { + const s1 = await getOrCreateDatabaseSalt('open.rocket.chat.db'); + const s2 = await getOrCreateDatabaseSalt('open.rocket.chat.db'); + expect(s1).toBe(s2); + }); + + it('stores salt under a separate key from the encryption key', async () => { + const shim = makeMemoryShim(); + const spySet = jest.spyOn(shim, 'setItem'); + installKeychainShim(shim); + + await getOrCreateDatabaseKey('combo.db'); + await getOrCreateDatabaseSalt('combo.db'); + + expect(spySet).toHaveBeenCalledWith('db_key_v1:combo.db', expect.any(String)); + expect(spySet).toHaveBeenCalledWith('db_salt_v1:combo.db', expect.stringMatching(/^[0-9a-fA-F]{32}$/)); + }); + + it('does not include salt material in thrown errors', async () => { + (randomKey as jest.Mock).mockResolvedValueOnce('not-valid-hex!!'); + + let thrown: Error | undefined; + try { + await getOrCreateDatabaseSalt('bad.db'); + } catch (e) { + thrown = e as Error; + } + + expect(thrown).toBeDefined(); + expect(thrown!.message).not.toMatch(/not-valid-hex/); + expect(thrown!.message).not.toMatch(/[0-9a-fA-F]{16}/); }); }); @@ -96,6 +143,19 @@ describe('deleteDatabaseKey', () => { expect(spySet).toHaveBeenCalledTimes(1); }); + it('removes the salt as well as the key', async () => { + const shim = makeMemoryShim(); + const spyRemove = jest.spyOn(shim, 'removeItem'); + installKeychainShim(shim); + + await getOrCreateDatabaseKey('temp.db'); + await getOrCreateDatabaseSalt('temp.db'); + await deleteDatabaseKey('temp.db'); + + expect(spyRemove).toHaveBeenCalledWith('db_key_v1:temp.db'); + expect(spyRemove).toHaveBeenCalledWith('db_salt_v1:temp.db'); + }); + it('is a no-op for a name that was never stored', async () => { await expect(deleteDatabaseKey('nonexistent.db')).resolves.toBeUndefined(); }); diff --git a/app/lib/database/driver/connection.ts b/app/lib/database/driver/connection.ts index d61d8170728..a20df450e80 100644 --- a/app/lib/database/driver/connection.ts +++ b/app/lib/database/driver/connection.ts @@ -9,12 +9,20 @@ * 2. PRAGMA key = "x'<64-hex>'" ← must be the FIRST statement; SQLCipher * requires this before any schema access or the file is opened unencrypted * and subsequent reads produce garbage or "not a database" errors. - * 3. PRAGMA busy_timeout = 500 ← mandatory; without it, concurrent access from + * 3. PRAGMA cipher_plaintext_header_size = 32 ← exposes a 32-byte plaintext header so + * iOS recognises the WAL SQLite magic and grants the background idle-WAL exemption. + * Default SQLCipher encrypts the header; iOS then cannot identify the suspended app's + * WAL file and kills it for holding a file lock (RUNNINGBOARD 0xdead10cc). The 32 bytes + * are header metadata (version/page-size) — no row data — so this is not a security + * regression. Must follow PRAGMA key, precede any other statement. + * 4. PRAGMA cipher_salt = "x'<32-hex>'" ← with a plaintext header SQLCipher no longer + * stores the salt in the file; it is supplied from the keychain (see keyService). + * 5. PRAGMA busy_timeout = 500 ← mandatory; without it, concurrent access from * the notification service extension causes SQLITE_BUSY starvation (multi-process * WAL spike: 100% failure rate without it, 100% success with it) - * 4. PRAGMA journal_mode = WAL - * 5. Verify encryption with a trivial read (sqlite_master count) before handing out handle - * 6. drizzle() wraps the raw handle with the appropriate schema + * 6. PRAGMA journal_mode = WAL + * 7. Verify encryption with a trivial read (sqlite_master count) before handing out handle + * 8. drizzle() wraps the raw handle with the appropriate schema * * Raw-key form: PRAGMA key = "x'<64-hex>'" — the x'...' quoting tells SQLCipher * to treat the value as raw bytes and skip the PBKDF2 key derivation step. This @@ -32,7 +40,7 @@ import { Paths } from 'expo-file-system'; import * as appSchema from './schema/app'; import * as serversSchema from './schema/servers'; -import { getOrCreateDatabaseKey } from './keyService'; +import { getOrCreateDatabaseKey, getOrCreateDatabaseSalt } from './keyService'; // --------------------------------------------------------------------------- // Types @@ -126,18 +134,26 @@ const _registry = new Map(); // Open // --------------------------------------------------------------------------- -async function applyOpenPragmas(sqlite: SQLiteDatabase, keyHex: string): Promise { +async function applyOpenPragmas(sqlite: SQLiteDatabase, keyHex: string, saltHex: string): Promise { // 1. Key MUST be first — before any schema read/write // Raw-key form: the x'...' prefix tells SQLCipher to skip PBKDF2 await sqlite.execAsync(`PRAGMA key = "x'${keyHex}'";`); - // 2. Busy timeout: mandatory for multi-process WAL safety (app + extensions share the file) + // 2. Plaintext header so iOS recognises the encrypted WAL file and grants the background + // idle-WAL exemption; otherwise a suspended app holding a WAL lock on the App Group DB is + // killed with 0xdead10cc. Must follow key, precede any other statement. + await sqlite.execAsync('PRAGMA cipher_plaintext_header_size = 32;'); + + // 3. With a plaintext header the salt is no longer in the file — supply it from the keychain. + await sqlite.execAsync(`PRAGMA cipher_salt = "x'${saltHex}'";`); + + // 4. Busy timeout: mandatory for multi-process WAL safety (app + extensions share the file) await sqlite.execAsync('PRAGMA busy_timeout = 500;'); - // 3. WAL mode for concurrent reads + one writer + // 5. WAL mode for concurrent reads + one writer await sqlite.execAsync('PRAGMA journal_mode = WAL;'); - // 4. Verify encryption is working — a trivial read on an unkeyed SQLCipher file + // 6. Verify encryption is working — a trivial read on an unkeyed SQLCipher file // throws "file is not a database"; we surface a safe error that contains no key material try { await sqlite.getFirstAsync('SELECT count(*) FROM sqlite_master;'); @@ -158,11 +174,11 @@ async function openDb(dbName: string, kind: K): Promise; } - const keyHex = await getOrCreateDatabaseKey(dbName); + const [keyHex, saltHex] = await Promise.all([getOrCreateDatabaseKey(dbName), getOrCreateDatabaseSalt(dbName)]); const sqlite = await openDatabaseAsync(dbName, { enableChangeListener: true }, DB_DIRECTORY); - await applyOpenPragmas(sqlite, keyHex); + await applyOpenPragmas(sqlite, keyHex, saltHex); const schema = kind === 'servers' ? serversSchema : appSchema; // The conditional type SchemaForKind cannot be narrowed by the JS runtime check above; diff --git a/app/lib/database/driver/keyService.ts b/app/lib/database/driver/keyService.ts index 2d14299627a..66fc8b5fecd 100644 --- a/app/lib/database/driver/keyService.ts +++ b/app/lib/database/driver/keyService.ts @@ -1,8 +1,18 @@ /** - * Database key service — generates, stores, and retrieves per-database SQLCipher keys. + * Database key service — generates, stores, and retrieves per-database SQLCipher + * key + salt material. * - * Keys are 32-byte CSPRNG values, hex-encoded (64 hex chars). They must never appear - * in logs, thrown errors, or telemetry. + * Keys are 32-byte CSPRNG values, hex-encoded (64 hex chars). Salts are 16-byte CSPRNG + * values, hex-encoded (32 hex chars). Neither may ever appear in logs, thrown errors, + * or telemetry. + * + * The salt is stored externally because the DB runs with a plaintext header + * (cipher_plaintext_header_size = 32, set in connection.ts so iOS recognises the + * encrypted WAL file and grants the background idle-WAL exemption). With a plaintext + * header SQLCipher no longer persists the salt in the file's first 16 bytes, so it must + * be supplied at open time via PRAGMA cipher_salt. Losing the salt makes the DB + * permanently unreadable — same blast radius as losing the key — so both are destroyed + * together in deleteDatabaseKey. * * Persistence goes through the IKeychainShim interface. The real backend lands with * the native-readers work and must satisfy: @@ -68,9 +78,10 @@ export function installKeychainShim(shim: IKeychainShim): void { // --------------------------------------------------------------------------- const KEY_PREFIX = 'db_key_v1:'; +const SALT_PREFIX = 'db_salt_v1:'; -function storageKey(dbName: string): string { - return `${KEY_PREFIX}${dbName}`; +function storageKey(prefix: string, dbName: string): string { + return `${prefix}${dbName}`; } /** @@ -81,7 +92,7 @@ function storageKey(dbName: string): string { * It is never logged, never included in thrown errors, never sent to telemetry. */ export async function getOrCreateDatabaseKey(dbName: string): Promise { - const k = storageKey(dbName); + const k = storageKey(KEY_PREFIX, dbName); const existing = await _shim.getItem(k); if (existing !== null) { return existing; @@ -102,10 +113,35 @@ export async function getOrCreateDatabaseKey(dbName: string): Promise { } /** - * Deletes the stored key for `dbName`. Call this when the database file is being + * Returns the hex salt for `dbName`, generating and storing a fresh one if none exists. + * Idempotent: repeated calls for the same name return the same salt. + * + * 16 bytes (128-bit) from the platform CSPRNG, hex-encoded to 32 chars — the size + * SQLCipher expects for cipher_salt. Never logged, thrown, or sent to telemetry. + */ +export async function getOrCreateDatabaseSalt(dbName: string): Promise { + const k = storageKey(SALT_PREFIX, dbName); + const existing = await _shim.getItem(k); + if (existing !== null) { + return existing; + } + + // 16 bytes → 32 hex chars; same hex-encoding contract as randomKey above. + const hex = await randomKey(16); + if (!/^[0-9a-fA-F]{32}$/.test(hex)) { + throw new Error('salt generation produced unexpected output; cannot open database safely'); + } + + await _shim.setItem(k, hex); + return hex; +} + +/** + * Deletes the stored key AND salt for `dbName`. Call this when the database file is being * permanently destroyed (e.g. server logout + database wipe), not during migration. * After calling this, the database file is permanently inaccessible. */ export async function deleteDatabaseKey(dbName: string): Promise { - await _shim.removeItem(storageKey(dbName)); + await _shim.removeItem(storageKey(KEY_PREFIX, dbName)); + await _shim.removeItem(storageKey(SALT_PREFIX, dbName)); } From 8b6befb9772a626751cee6fdd95a9b7fb1ae66e3 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 19 Jun 2026 17:44:10 -0300 Subject: [PATCH 4/5] fix: harden driver adapter open/key concurrency and validation - connection: coalesce concurrent opens for the same dbName via _inflight map so only one openDatabaseAsync call races; close raw handle on failed PRAGMA application (no fd leak) - connection: replace slashes with '_' not '.' in deriveServerDbName so distinct paths can't collide with host dots - connection: collapse 5 PRAGMA statements into one multi-statement execAsync call; keep the verify getFirstAsync as a separate call - keyService: extract getOrCreate helper with per-storageKey inflight serialization, removing the key/salt duplication; re-validate stored values against the expected hex pattern before returning (corrupt entry throws a safe error with no key material) - keyService: parallelize the two removeItem calls in deleteDatabaseKey - observe: derive stable tableKey string from tables list and use it as the effect dep instead of spreading tables (removes eslint-disable) - observe: remove dead mounted ref and its effect from useRowObserve --- .../driver/__tests__/connection.test.ts | 27 +++--- app/lib/database/driver/connection.ts | 84 +++++++++++------- app/lib/database/driver/keyService.ts | 87 +++++++++++-------- app/lib/database/driver/observe.ts | 17 ++-- 4 files changed, 123 insertions(+), 92 deletions(-) diff --git a/app/lib/database/driver/__tests__/connection.test.ts b/app/lib/database/driver/__tests__/connection.test.ts index a24d63d21a8..a6b2002dd3b 100644 --- a/app/lib/database/driver/__tests__/connection.test.ts +++ b/app/lib/database/driver/__tests__/connection.test.ts @@ -122,20 +122,23 @@ describe('deriveServerDbName', () => { // --------------------------------------------------------------------------- describe('open sequence', () => { - it('applies PRAGMA key, then cipher header + salt, then busy_timeout, then WAL', async () => { + it('applies PRAGMA key, then cipher header + salt, then busy_timeout, then WAL — in one execAsync call', async () => { await openServersDb(); - const keyIdx = execCalls.findIndex(s => s.includes('PRAGMA key')); - const headerIdx = execCalls.findIndex(s => s.includes('cipher_plaintext_header_size')); - const saltIdx = execCalls.findIndex(s => s.includes('cipher_salt')); - const busyIdx = execCalls.findIndex(s => s.includes('busy_timeout')); - const walIdx = execCalls.findIndex(s => s.includes('journal_mode')); - - expect(keyIdx).toBeGreaterThanOrEqual(0); - expect(headerIdx).toBeGreaterThan(keyIdx); - expect(saltIdx).toBeGreaterThan(headerIdx); - expect(busyIdx).toBeGreaterThan(saltIdx); - expect(walIdx).toBeGreaterThan(busyIdx); + // All 5 PRAGMAs are sent in a single multi-statement execAsync call. + expect(execCalls).toHaveLength(1); + const call = execCalls[0]; + const keyPos = call.indexOf('PRAGMA key'); + const headerPos = call.indexOf('cipher_plaintext_header_size'); + const saltPos = call.indexOf('cipher_salt'); + const busyPos = call.indexOf('busy_timeout'); + const walPos = call.indexOf('journal_mode'); + + expect(keyPos).toBeGreaterThanOrEqual(0); + expect(headerPos).toBeGreaterThan(keyPos); + expect(saltPos).toBeGreaterThan(headerPos); + expect(busyPos).toBeGreaterThan(saltPos); + expect(walPos).toBeGreaterThan(busyPos); }); it('sets cipher_plaintext_header_size = 32', async () => { diff --git a/app/lib/database/driver/connection.ts b/app/lib/database/driver/connection.ts index a20df450e80..2e3901cb80d 100644 --- a/app/lib/database/driver/connection.ts +++ b/app/lib/database/driver/connection.ts @@ -80,7 +80,7 @@ export function deriveServerDbName(serverUrl: string): string { const sanitized = serverUrl .replace(/\/+$/, '') .replace(/(^\w+:|^)\/\//, '') - .replace(/\//g, '.'); + .replace(/\//g, '_'); return `${sanitized}.db`; } @@ -129,32 +129,27 @@ const DB_DIRECTORY = resolveDbDirectory(); // --------------------------------------------------------------------------- const _registry = new Map(); +// Coalesces concurrent opens for the same dbName so only one openDatabaseAsync call runs. +const _inflight = new Map>>(); // --------------------------------------------------------------------------- // Open // --------------------------------------------------------------------------- async function applyOpenPragmas(sqlite: SQLiteDatabase, keyHex: string, saltHex: string): Promise { - // 1. Key MUST be first — before any schema read/write - // Raw-key form: the x'...' prefix tells SQLCipher to skip PBKDF2 - await sqlite.execAsync(`PRAGMA key = "x'${keyHex}'";`); - - // 2. Plaintext header so iOS recognises the encrypted WAL file and grants the background - // idle-WAL exemption; otherwise a suspended app holding a WAL lock on the App Group DB is - // killed with 0xdead10cc. Must follow key, precede any other statement. - await sqlite.execAsync('PRAGMA cipher_plaintext_header_size = 32;'); - - // 3. With a plaintext header the salt is no longer in the file — supply it from the keychain. - await sqlite.execAsync(`PRAGMA cipher_salt = "x'${saltHex}'";`); - - // 4. Busy timeout: mandatory for multi-process WAL safety (app + extensions share the file) - await sqlite.execAsync('PRAGMA busy_timeout = 500;'); - - // 5. WAL mode for concurrent reads + one writer - await sqlite.execAsync('PRAGMA journal_mode = WAL;'); - - // 6. Verify encryption is working — a trivial read on an unkeyed SQLCipher file - // throws "file is not a database"; we surface a safe error that contains no key material + // Key MUST be the first statement; then plaintext header (0xdead10cc exemption), + // salt supply, busy timeout, and WAL mode — all in one round-trip. + // Raw-key form: x'...' tells SQLCipher to skip PBKDF2. + await sqlite.execAsync( + `PRAGMA key = "x'${keyHex}'";` + + 'PRAGMA cipher_plaintext_header_size = 32;' + + `PRAGMA cipher_salt = "x'${saltHex}'";` + + 'PRAGMA busy_timeout = 500;' + + 'PRAGMA journal_mode = WAL;' + ); + + // Verify encryption is working — a trivial read on an unkeyed SQLCipher file + // throws "file is not a database"; surface a safe error with no key material. try { await sqlite.getFirstAsync('SELECT count(*) FROM sqlite_master;'); } catch { @@ -167,27 +162,50 @@ async function applyOpenPragmas(sqlite: SQLiteDatabase, keyHex: string, saltHex: * Opens a database for the given `dbName`, applies the full open sequence * (key → busy_timeout → WAL → verify), wraps with Drizzle, and registers the handle. * Returns the same handle on repeated calls for the same name. + * Concurrent calls for the same name coalesce into a single open; a failed open + * closes the raw handle to prevent a file-descriptor leak. */ -async function openDb(dbName: string, kind: K): Promise> { +function openDb(dbName: string, kind: K): Promise> { const cached = _registry.get(dbName); if (cached) { - return cached as DbHandle; + return Promise.resolve(cached as DbHandle); } - const [keyHex, saltHex] = await Promise.all([getOrCreateDatabaseKey(dbName), getOrCreateDatabaseSalt(dbName)]); + const inflight = _inflight.get(dbName); + if (inflight) { + return inflight as Promise>; + } + + const promise = (async (): Promise> => { + const [keyHex, saltHex] = await Promise.all([getOrCreateDatabaseKey(dbName), getOrCreateDatabaseSalt(dbName)]); + + const sqlite = await openDatabaseAsync(dbName, { enableChangeListener: true }, DB_DIRECTORY); + + try { + await applyOpenPragmas(sqlite, keyHex, saltHex); + } catch (err) { + await sqlite.closeAsync().catch(() => {}); + throw err; + } - const sqlite = await openDatabaseAsync(dbName, { enableChangeListener: true }, DB_DIRECTORY); + const schema = kind === 'servers' ? serversSchema : appSchema; + // The conditional type SchemaForKind cannot be narrowed by the JS runtime check above; + // casting through unknown is the standard TS pattern for this shape. + const db = drizzle(sqlite, { schema }) as unknown as ExpoSQLiteDatabase>; - await applyOpenPragmas(sqlite, keyHex, saltHex); + const handle: DbHandle = { db, sqlite, dbName }; + _registry.set(dbName, handle as DbHandle); + return handle; + })(); - const schema = kind === 'servers' ? serversSchema : appSchema; - // The conditional type SchemaForKind cannot be narrowed by the JS runtime check above; - // casting through unknown is the standard TS pattern for this shape. - const db = drizzle(sqlite, { schema }) as unknown as ExpoSQLiteDatabase>; + _inflight.set(dbName, promise); + // Cleanup inflight entry regardless of outcome. The .catch here silences the secondary + // rejection on the finally-chained promise — the real rejection propagates via `promise`. + promise.finally(() => { + _inflight.delete(dbName); + }).catch(() => {}); - const handle: DbHandle = { db, sqlite, dbName }; - _registry.set(dbName, handle as DbHandle); - return handle; + return promise as Promise>; } // --------------------------------------------------------------------------- diff --git a/app/lib/database/driver/keyService.ts b/app/lib/database/driver/keyService.ts index 66fc8b5fecd..7fa88af26bc 100644 --- a/app/lib/database/driver/keyService.ts +++ b/app/lib/database/driver/keyService.ts @@ -84,6 +84,52 @@ function storageKey(prefix: string, dbName: string): string { return `${prefix}${dbName}`; } +// Per-storageKey in-flight map: serializes concurrent getOrCreate calls so only one +// CSPRNG + setItem round-trip runs even when callers race. +const _getOrCreateInflight = new Map>(); + +/** + * Generates or retrieves a hex material string for `storageKey`. + * Validates both stored values (corrupt → throw) and generated values (bad bridge → throw). + * Neither the stored value nor the generated value ever appears in thrown error messages. + */ +async function getOrCreate(sk: string, byteLen: number, hexLen: number, label: string): Promise { + const inflight = _getOrCreateInflight.get(sk); + if (inflight) return inflight; + + const promise = (async (): Promise => { + const existing = await _shim.getItem(sk); + if (existing !== null) { + // Re-validate the stored value — a corrupt entry must not reach SQLCipher. + if (!new RegExp(`^[0-9a-fA-F]{${hexLen}}$`).test(existing)) { + throw new Error(`stored ${label} corrupt`); + } + return existing; + } + + // randomKey (not randomBytes): the mobile-crypto bridge encodes randomBytes as + // BASE64 on both platforms, while randomKey returns hex (SecureRandom/SecRandomCopyBytes + // + bytesToHex). The argument is the byte count. + const hex = await randomKey(byteLen); + if (!new RegExp(`^[0-9a-fA-F]{${hexLen}}$`).test(hex)) { + // Sanitize error — do not include the value in the message. + throw new Error(`${label} generation produced unexpected output; cannot open database safely`); + } + + await _shim.setItem(sk, hex); + return hex; + })(); + + _getOrCreateInflight.set(sk, promise); + // Cleanup regardless of outcome. The .catch silences the secondary rejection on the + // finally-chained promise — the real rejection propagates via `promise`. + promise.finally(() => { + _getOrCreateInflight.delete(sk); + }).catch(() => {}); + + return promise; +} + /** * Returns the hex key for `dbName`, generating and storing a fresh one if none exists. * Idempotent: repeated calls for the same name return the same key. @@ -91,25 +137,8 @@ function storageKey(prefix: string, dbName: string): string { * The key is 32 bytes (256-bit) from the platform CSPRNG, hex-encoded to 64 chars. * It is never logged, never included in thrown errors, never sent to telemetry. */ -export async function getOrCreateDatabaseKey(dbName: string): Promise { - const k = storageKey(KEY_PREFIX, dbName); - const existing = await _shim.getItem(k); - if (existing !== null) { - return existing; - } - - // randomKey (not randomBytes): the mobile-crypto bridge encodes randomBytes as - // BASE64 on both platforms, while randomKey returns hex (SecureRandom/SecRandomCopyBytes - // + bytesToHex). The argument is the byte count: 32 bytes → 64 hex chars. - const hex = await randomKey(32); - // Guard the bridge contract: anything but 64 hex chars must not be used as a key - if (!/^[0-9a-fA-F]{64}$/.test(hex)) { - // Sanitize error — do not include the value in the message - throw new Error('key generation produced unexpected output; cannot open database safely'); - } - - await _shim.setItem(k, hex); - return hex; +export function getOrCreateDatabaseKey(dbName: string): Promise { + return getOrCreate(storageKey(KEY_PREFIX, dbName), 32, 64, 'key'); } /** @@ -119,21 +148,8 @@ export async function getOrCreateDatabaseKey(dbName: string): Promise { * 16 bytes (128-bit) from the platform CSPRNG, hex-encoded to 32 chars — the size * SQLCipher expects for cipher_salt. Never logged, thrown, or sent to telemetry. */ -export async function getOrCreateDatabaseSalt(dbName: string): Promise { - const k = storageKey(SALT_PREFIX, dbName); - const existing = await _shim.getItem(k); - if (existing !== null) { - return existing; - } - - // 16 bytes → 32 hex chars; same hex-encoding contract as randomKey above. - const hex = await randomKey(16); - if (!/^[0-9a-fA-F]{32}$/.test(hex)) { - throw new Error('salt generation produced unexpected output; cannot open database safely'); - } - - await _shim.setItem(k, hex); - return hex; +export function getOrCreateDatabaseSalt(dbName: string): Promise { + return getOrCreate(storageKey(SALT_PREFIX, dbName), 16, 32, 'salt'); } /** @@ -142,6 +158,5 @@ export async function getOrCreateDatabaseSalt(dbName: string): Promise { * After calling this, the database file is permanently inaccessible. */ export async function deleteDatabaseKey(dbName: string): Promise { - await _shim.removeItem(storageKey(KEY_PREFIX, dbName)); - await _shim.removeItem(storageKey(SALT_PREFIX, dbName)); + await Promise.all([_shim.removeItem(storageKey(KEY_PREFIX, dbName)), _shim.removeItem(storageKey(SALT_PREFIX, dbName))]); } diff --git a/app/lib/database/driver/observe.ts b/app/lib/database/driver/observe.ts index 076cacbecf0..fae99f45231 100644 --- a/app/lib/database/driver/observe.ts +++ b/app/lib/database/driver/observe.ts @@ -120,6 +120,10 @@ export function useTableQuery( // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Stable string derived from the tables list so the effect has a primitive dep + // rather than a spread that would differ on every render. + const tableKey = tables.slice().sort().join('\0'); + useEffect(() => { if (!dbHandle) { setRows([]); @@ -145,9 +149,9 @@ export function useTableQuery( timerRef.current = null; } }; - // deps are caller-controlled; tables/debounceMs treated as stable across renders + // tableKey is a stable string derived from tables; debounceMs treated as stable // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dbHandle, fetchAndReconcile, ...deps]); + }, [dbHandle, fetchAndReconcile, tableKey, ...deps]); return rows; } @@ -176,14 +180,6 @@ export function useRowObserve( return fetchRow(rowId); }); - const mounted = useRef(true); - useEffect(() => { - mounted.current = true; - return () => { - mounted.current = false; - }; - }, []); - // Stable ref so the listener never captures a stale fetchRow const fetchRowRef = useRef(fetchRow); useEffect(() => { @@ -203,7 +199,6 @@ export function useRowObserve( if (!eventMatchesDb(event, dbHandle.dbName)) return; if (event.tableName !== tableName) return; if (event.rowId !== rowId) return; - if (!mounted.current) return; setRow(fetchRowRef.current(rowId)); }); From ee159ef0d55671c4e215c904e65fc2a1d3a4b60e Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 19 Jun 2026 17:55:30 -0300 Subject: [PATCH 5/5] refactor: trim forward-looking comment in driver connection --- app/lib/database/driver/connection.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/lib/database/driver/connection.ts b/app/lib/database/driver/connection.ts index 2e3901cb80d..dcbc8a84a29 100644 --- a/app/lib/database/driver/connection.ts +++ b/app/lib/database/driver/connection.ts @@ -1,8 +1,7 @@ /** * Database connection lifecycle — open, key, configure, wrap with Drizzle, close. * - * Files outside app/lib/database/driver/ must not import expo-sqlite; an ESLint rule - * enforcing the ban arrives with the facade work. + * Files outside app/lib/database/driver/ must not import expo-sqlite. * * Open sequence (non-negotiable invariants): * 1. openDatabaseAsync → raw SQLiteDatabase handle