Skip to content
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
90ceaf8
chore: add Drizzle schema and generated migrations for default and pe…
diegolmello Jun 12, 2026
61cea36
feat: add expo-sqlite driver adapter, key service, and live-query hoo…
diegolmello Jun 13, 2026
2c2103b
chore: fix lint on schema index and ignore generated Drizzle migrations
diegolmello Jun 13, 2026
4286748
chore: merge lint fixes from the schema branch
diegolmello Jun 13, 2026
e5e3091
chore: fix lint on driver adapter sources
diegolmello Jun 13, 2026
0b56390
feat: rewrite native database readers for SQLCipher and add native ke…
diegolmello Jun 13, 2026
6aaba61
chore: register iOS key-store in Xcode project and harden native readers
diegolmello Jun 15, 2026
a3b082e
refactor: migrate database key store to a TurboModule
diegolmello Jun 15, 2026
72a719c
fix: validate SQLCipher key on open and prevent NSE cursor leak
diegolmello Jun 15, 2026
5606e6f
fix: fail closed when a stored database key can't be read
diegolmello Jun 15, 2026
600b127
feat: add WatermelonDB-shaped facade over the Drizzle driver
diegolmello Jun 15, 2026
0c5a9fa
fix: model facade Q operators on WatermelonDB's comparison shape
diegolmello Jun 15, 2026
3f0d38c
refactor: cut all database call sites over to the facade
diegolmello Jun 17, 2026
15438be
feat: port client data into the encrypted database at first boot
diegolmello Jun 17, 2026
2bbea8d
ci: match slashed base branches so stacked PRs build
diegolmello Jun 17, 2026
b9c1382
fix: prevent iOS 0xdead10cc kill on the encrypted WAL database
diegolmello Jun 18, 2026
b23efd3
refactor: deduplicate message columns, add notNull constraints, drop …
diegolmello Jun 19, 2026
56d87d8
refactor: single baseline migration with NOT NULL constraints folded …
diegolmello Jun 19, 2026
7a507c5
Merge remote-tracking branch 'origin/feat/native-1275-drizzle-schema'…
diegolmello Jun 19, 2026
8b6befb
fix: harden driver adapter open/key concurrency and validation
diegolmello Jun 19, 2026
ee159ef
refactor: trim forward-looking comment in driver connection
diegolmello Jun 19, 2026
0122f34
Merge remote-tracking branch 'origin/feat/native-1274-driver-adapter'…
diegolmello Jun 19, 2026
8dbe804
fix: correct native reader room lookup and key-store lifecycle
diegolmello Jun 19, 2026
b9809a6
fix: align native reader db-name derivation and cipher pragmas with J…
diegolmello Jun 19, 2026
505200b
Merge remote-tracking branch 'origin/feat/native-1276-native-readers'…
diegolmello Jun 19, 2026
30aee8f
fix: correct facade child-collection typing and write/observe semantics
diegolmello Jun 19, 2026
3ca4574
fix: restore iOS App Group SQLite subdirectory isolation
diegolmello Jun 22, 2026
6aa817b
docs: correct resolveDbDirectory comment to reflect SQLite subdir
diegolmello Jun 22, 2026
f5faca0
Merge branch 'feat/native-1277-facade-cutover' into feat/native-1278-…
diegolmello Jun 22, 2026
4b5fe40
fix: harden SQLCipher migration against partial-state data loss
diegolmello Jun 22, 2026
1ca85bb
Merge remote-tracking branch 'origin/feat/native-1272-sqlcipher-migra…
diegolmello Jun 22, 2026
870fd04
chore: resolve eslint errors in key service and connection driver
diegolmello Jun 22, 2026
02d0623
Merge remote-tracking branch 'origin/feat/native-1276-native-readers'…
diegolmello Jun 23, 2026
a856027
fix: route subscription search clause through the db facade
diegolmello Jun 23, 2026
451e850
fix: satisfy eslint import-order and type-import rules in the db facade
diegolmello Jun 23, 2026
245228a
Merge remote-tracking branch 'origin/feat/native-1277-facade-cutover'…
diegolmello Jun 23, 2026
13449fc
fix: use object destructuring in readState to satisfy prefer-destruct…
diegolmello Jun 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ android
ios

.worktrees/
app/lib/database/driver/migrations/
33 changes: 33 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
// Keep the database engine behind the facade. Everything outside app/lib/database/ must import
// from app/lib/database/facade — never the raw engine or the facade's internal modules. The
// migration reader and driver live inside app/lib/database/ and are excluded below.
const reactDefaultImport = {
name: 'react',
importNames: ['default'],
message: 'Import specific named exports from React instead.'
};
const facadeOnlyPatterns = [
{
group: ['@nozbe/watermelondb', '@nozbe/watermelondb/**', 'expo-sqlite', 'expo-sqlite/**', 'drizzle-orm', 'drizzle-orm/**'],
message: 'Do not import the database engine directly. Use the facade at app/lib/database/facade.'
},
{
group: ['**/database/facade/*'],
message: 'Import from the facade barrel (app/lib/database/facade), not its internal modules.'
}
];

module.exports = {
settings: {
'import/resolver': {
Expand Down Expand Up @@ -165,6 +184,20 @@ module.exports = {
env: {
'react-native/react-native': true
}
},
{
files: ['app/**/*.js'],
excludedFiles: ['app/lib/database/**'],
rules: {
'no-restricted-imports': ['error', { paths: [reactDefaultImport], patterns: facadeOnlyPatterns }]
}
},
{
files: ['app/**/*.{ts,tsx}'],
excludedFiles: ['app/lib/database/**'],
rules: {
'@typescript-eslint/no-restricted-imports': ['error', { paths: [reactDefaultImport], patterns: facadeOnlyPatterns }]
}
}
]
};
2 changes: 1 addition & 1 deletion .github/workflows/build-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ permissions:
on:
pull_request:
branches:
- '*'
- '**'

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
Expand Down
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ pnpm-lock.yaml
app/i18n/locales/
app/containers/CustomIcon/mappedIcons.js
app/containers/CustomIcon/selection.json
app/lib/database/driver/migrations/
5 changes: 5 additions & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,11 @@ dependencies {
// For SecureKeystore (EncryptedSharedPreferences)
implementation 'androidx.security:security-crypto:1.1.0'

// SQLCipher for reading encrypted databases from the FCM notification path.
// Version locked to match expo-sqlite 16.0.10's vendored SQLCipher 4.7.0.
// @aar ensures Gradle unpacks the AAR (ships libsqlcipher.so for all ABIs).
implementation 'net.zetetic:sqlcipher-android:4.7.0@aar'

testImplementation 'junit:junit:4.13.2'
testImplementation 'org.robolectric:robolectric:4.14.1'
testImplementation 'com.squareup.okhttp3:mockwebserver:4.9.2'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import com.nozbe.watermelondb.jsi.WatermelonDBJSIPackage;
import com.bugsnag.android.Bugsnag
import expo.modules.ApplicationLifecycleDispatcher
import chat.rocket.reactnative.networking.SSLPinningTurboPackage;
import chat.rocket.reactnative.storage.DatabaseKeyStoreTurboPackage;
import chat.rocket.reactnative.storage.MMKVKeyManager;
import chat.rocket.reactnative.storage.SecureStoragePackage;
import chat.rocket.reactnative.notification.VideoConfTurboPackage
Expand Down Expand Up @@ -49,6 +50,7 @@ open class MainApplication : Application(), ReactApplication {
add(PushNotificationTurboPackage())
add(VoipTurboPackage())
add(SecureStoragePackage())
add(DatabaseKeyStoreTurboPackage())
add(InvertedScrollPackage())
add(ExternalInputPackage())
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
import chat.rocket.mobilecrypto.algorithms.AESCrypto;
import chat.rocket.mobilecrypto.algorithms.RSACrypto;
import chat.rocket.mobilecrypto.algorithms.CryptoUtils;
import com.nozbe.watermelondb.WMDatabase;
import chat.rocket.reactnative.storage.DatabaseKeyStoreModule;
import net.zetetic.database.sqlcipher.SQLiteDatabase;

import java.security.SecureRandom;
import java.util.Arrays;
Expand Down Expand Up @@ -102,6 +103,10 @@ class RoomKeyResult {
}

class Encryption {
static {
System.loadLibrary("sqlcipher");
}

static class EncryptionContent {
String algorithm;
String ciphertext;
Expand Down Expand Up @@ -172,55 +177,102 @@ private ParsedMessage parseMessage(Ejson.Content content) {
}

public Room readRoom(final Ejson ejson, Context context) {
String dbName = getDatabaseName(ejson.serverURL(), context);
WMDatabase db = null;
String dbName = deriveDbName(ejson.serverURL());
String dbPath = context.getFilesDir().getAbsolutePath() + "/SQLite/" + dbName;

// Read key + salt from the AndroidKeyStore-backed store.
// Storage keys match JS KEY_PREFIX / SALT_PREFIX in keyService.ts.
String storageKey = "db_key_v1:" + dbName;
String saltStorageKey = "db_salt_v1:" + dbName;
String keyHex;
String saltHex;
try {
keyHex = DatabaseKeyStoreModule.getItemInternal(context, storageKey);
} catch (Exception e) {
Log.w(TAG, "Could not read encryption key for " + dbName + " — cannot read room", e);
return null;
}
if (keyHex == null) {
Log.w(TAG, "No encryption key found for " + dbName + " — cannot read room");
return null;
}
try {
saltHex = DatabaseKeyStoreModule.getItemInternal(context, saltStorageKey);
} catch (Exception e) {
Log.w(TAG, "Could not read cipher salt for " + dbName + " — cannot read room", e);
return null;
}
if (saltHex == null) {
Log.w(TAG, "No cipher salt found for " + dbName + " — cannot read room");
return null;
}

// Raw-key string form: "x'<64 hex>'" — skips PBKDF2, matches the JS driver.
// Never use the byte[] overload: it silently PBKDF2-derives and produces
// "file is not a database" even when the bytes match.
String rawKey = "x'" + keyHex + "'";

SQLiteDatabase db = null;
try {
db = WMDatabase.getInstance(dbName, context);
String[] queryArgs = {ejson.rid};

Cursor cursor = db.rawQuery("SELECT * FROM subscriptions WHERE id == ? LIMIT 1", queryArgs);

if (cursor.getCount() == 0) {
cursor.close();
return null;
}

cursor.moveToFirst();
int e2eKeyColumnIndex = cursor.getColumnIndex("e2e_key");
int encryptedColumnIndex = cursor.getColumnIndex("encrypted");

if (e2eKeyColumnIndex == -1) {
Log.e(TAG, "e2e_key column not found in subscriptions table");
cursor.close();
return null;
}

String e2eKey = cursor.getString(e2eKeyColumnIndex);
Boolean encrypted = encryptedColumnIndex != -1 && cursor.getInt(encryptedColumnIndex) > 0;
cursor.close();

return new Room(e2eKey, encrypted);
db = SQLiteDatabase.openDatabase(dbPath, rawKey, null, SQLiteDatabase.OPEN_READONLY, null);

// Mirror the JS driver's open PRAGMAs (connection.ts applyOpenPragmas):
// cipher_plaintext_header_size = 32 — the driver exposes a 32-byte plaintext header
// so iOS grants the background idle-WAL exemption (0xdead10cc); the reader must
// set this too or SQLCipher will attempt to decrypt the header and fail.
// cipher_salt — with a plaintext header SQLCipher no longer stores the salt in the
// file; it must be supplied from the same keychain entry the JS driver wrote.
// busy_timeout — mandatory multi-process WAL safety.
db.execSQL("PRAGMA cipher_plaintext_header_size = 32;");
db.execSQL("PRAGMA cipher_salt = \"x'" + saltHex + "'\";");
db.execSQL("PRAGMA busy_timeout = 500;");

Cursor cursor = db.rawQuery("SELECT * FROM subscriptions WHERE rid = ? LIMIT 1", new String[]{ejson.rid});
try {
if (cursor.getCount() == 0) {
return null;
}

cursor.moveToFirst();
int e2eKeyColumnIndex = cursor.getColumnIndex("e2e_key");
int encryptedColumnIndex = cursor.getColumnIndex("encrypted");

if (e2eKeyColumnIndex == -1) {
Log.e(TAG, "e2e_key column not found in subscriptions table");
return null;
}

String e2eKey = cursor.getString(e2eKeyColumnIndex);
Boolean encrypted = encryptedColumnIndex != -1 && cursor.getInt(encryptedColumnIndex) > 0;
return new Room(e2eKey, encrypted);
} finally {
cursor.close();
}

} catch (Exception e) {
Log.e(TAG, "Error reading room", e);
return null;

} finally {
if (db != null) {
if (db != null && db.isOpen()) {
db.close();
}
}
}

private String getDatabaseName(String serverUrl, Context context) {
// Match JS WatermelonDB naming: strip scheme, replace '/' with '.', and append one ".db".
String name = serverUrl.replaceFirst("^(\\w+:)?//", "").replace("/", ".");
name += ".db";

// Important: return just the name (not an absolute path). WMDatabase will resolve and append its own ".db" internally,
// so the physical file becomes "*.db.db", matching the JS adapter.
return name;
/**
* Derives the clean database filename from a server URL.
* Matches the JS `deriveServerDbName` in connection.ts:
* strip trailing slashes → strip scheme → replace '/' with '_' → append ".db"
*/
private static String deriveDbName(String serverUrl) {
// Strip trailing slashes
String s = serverUrl.replaceAll("/+$", "");
// Strip scheme ("https://", "http://", or bare "//")
s = s.replaceFirst("^(\\w+:)?//", "");
// Replace remaining slashes with underscores (matches JS deriveServerDbName)
s = s.replace("/", "_");
return s + ".db";
}

public String readUserKey(final Ejson ejson) throws Exception {
Expand Down
Loading
Loading