Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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 }]
}
}
]
};
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 All @@ -43,6 +44,7 @@ open class MainApplication : Application(), ReactApplication {
object : DefaultReactNativeHost(this) {
override fun getPackages(): List<ReactPackage> =
PackageList(this).packages.apply {
add(DatabaseKeyStoreTurboPackage())
add(SSLPinningTurboPackage())
add(WatermelonDBJSIPackage())
add(VideoConfTurboPackage())
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