Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
21 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
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
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
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/
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.DatabaseKeyStorePackage;
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(DatabaseKeyStorePackage())
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.DatabaseKeyStore;
import net.zetetic.database.sqlcipher.SQLiteDatabase;

import java.security.SecureRandom;
import java.util.Arrays;
Expand Down Expand Up @@ -172,55 +173,82 @@ 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;

// Load the SQLCipher native library. Safe to call multiple times per process.
System.loadLibrary("sqlcipher");
Comment thread
diegolmello marked this conversation as resolved.
Outdated

// Read the key from the AndroidKeyStore-backed store.
// Storage key matches JS KEY_PREFIX: "db_key_v1:<dbName>"
String storageKey = "db_key_v1:" + dbName;
String keyHex = DatabaseKeyStore.getItemInternal(context, storageKey);
if (keyHex == null) {
Log.w(TAG, "No encryption key 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);

// Mandatory multi-process WAL safety, matching the JS driver and the iOS reader:
// the notification path can read while the main app holds a WAL lock; without a
// busy timeout that contention surfaces as an immediate SQLITE_BUSY.
db.execSQL("PRAGMA busy_timeout = 500;");

Cursor cursor = db.rawQuery("SELECT * FROM subscriptions WHERE id == ? LIMIT 1", new String[]{ejson.rid});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Comment thread
diegolmello marked this conversation as resolved.
Outdated

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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

} 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 dots
s = s.replace("/", ".");
return s + ".db";
}

public String readUserKey(final Ejson ejson) throws Exception {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package chat.rocket.reactnative.storage;

import android.content.Context;
import android.content.SharedPreferences;
import android.security.keystore.KeyGenParameterSpec;
import android.security.keystore.KeyProperties;
import android.util.Base64;
import android.util.Log;

import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;

import java.nio.charset.StandardCharsets;
import java.security.KeyStore;

import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;

/**
* Stores and retrieves per-database SQLCipher keys using Android Keystore AES-GCM.
*
* Each storage key maps to an AndroidKeyStore AES entry and a SharedPreferences entry
* that holds the IV-prefixed ciphertext. The AES key is never exported; only the
* encrypted value is written to disk.
*
* getItemInternal / setItemInternal are intentionally static so Encryption.java can
* call them without a ReactApplicationContext (same app process, plain Context is enough).
*
* SharedPreferences file: "RCDatabaseKeyStore" — separate from MMKV and SecureStoragePrefs
* so these keys can be found unambiguously from native-only callers.
*/
public class DatabaseKeyStore extends ReactContextBaseJavaModule {

private static final String TAG = "RocketChat.DBKeyStore";
private static final String KEYSTORE_PROVIDER = "AndroidKeyStore";
private static final String PREFS_NAME = "RCDatabaseKeyStore";
private static final String TRANSFORMATION = "AES/GCM/NoPadding";
private static final int GCM_IV_LENGTH = 12;
private static final int GCM_TAG_LENGTH = 128;

// Kept for the ReactContextBaseJavaModule constructor path
private final ReactApplicationContext reactContext;

public DatabaseKeyStore(ReactApplicationContext reactContext) {
super(reactContext);
this.reactContext = reactContext;
}

@Override
public String getName() {
return "DatabaseKeyStore";
}

// -------------------------------------------------------------------------
// JS-facing methods
// -------------------------------------------------------------------------

@ReactMethod
public void getItem(String key, Promise promise) {
try {
promise.resolve(getItemInternal(reactContext, key));
} catch (Exception e) {
Log.e(TAG, "getItem failed for key: " + key, e);
promise.resolve(null);
}
}

@ReactMethod
public void setItem(String key, String value, Promise promise) {
try {
setItemInternal(reactContext, key, value);
promise.resolve(null);
} catch (Exception e) {
Log.e(TAG, "setItem failed for key: " + key, e);
promise.reject("KEYSTORE_WRITE_ERROR", e);
}
}

@ReactMethod
public void removeItem(String key, Promise promise) {
try {
removeItemInternal(reactContext, key);
promise.resolve(null);
} catch (Exception e) {
Log.e(TAG, "removeItem failed for key: " + key, e);
promise.resolve(null);
}
}

// -------------------------------------------------------------------------
// Native-side helpers — callable from Encryption.java (same process, plain Context)
// -------------------------------------------------------------------------

/** Returns the stored plaintext value or null if not found. */
public static String getItemInternal(Context context, String key) {
try {
SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
String encoded = prefs.getString(key, null);
if (encoded == null) {
return null;
}

KeyStore ks = KeyStore.getInstance(KEYSTORE_PROVIDER);
ks.load(null);
if (!ks.containsAlias(key)) {
return null;
}

SecretKey secretKey = (SecretKey) ks.getKey(key, null);
byte[] combined = Base64.decode(encoded, Base64.DEFAULT);

byte[] iv = new byte[GCM_IV_LENGTH];
byte[] encrypted = new byte[combined.length - GCM_IV_LENGTH];
System.arraycopy(combined, 0, iv, 0, GCM_IV_LENGTH);
System.arraycopy(combined, GCM_IV_LENGTH, encrypted, 0, encrypted.length);

Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.DECRYPT_MODE, secretKey, new GCMParameterSpec(GCM_TAG_LENGTH, iv));
byte[] plain = cipher.doFinal(encrypted);
return new String(plain, StandardCharsets.UTF_8);
} catch (Exception e) {
Log.e(TAG, "getItemInternal failed for key: " + key, e);
return null;
}
}

/** Stores value under key. Idempotent: the AES Keystore entry is reused if it exists. */
public static void setItemInternal(Context context, String key, String value) throws Exception {
KeyStore ks = KeyStore.getInstance(KEYSTORE_PROVIDER);
ks.load(null);

// Create the AES-GCM wrapping key once; never export it
if (!ks.containsAlias(key)) {
KeyGenerator kg = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER);
kg.init(
new KeyGenParameterSpec.Builder(key, KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(false)
.setRandomizedEncryptionRequired(true)
.build()
);
kg.generateKey();
}

SecretKey secretKey = (SecretKey) ks.getKey(key, null);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.ENCRYPT_MODE, secretKey);

byte[] iv = cipher.getIV();
byte[] encrypted = cipher.doFinal(value.getBytes(StandardCharsets.UTF_8));

// Prepend IV to ciphertext; single Base64 blob stored in SharedPreferences
byte[] combined = new byte[iv.length + encrypted.length];
System.arraycopy(iv, 0, combined, 0, iv.length);
System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length);

SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
prefs.edit().putString(key, Base64.encodeToString(combined, Base64.DEFAULT)).apply();
}

/** Removes the SharedPreferences entry. Does not delete the AndroidKeyStore key. */
public static void removeItemInternal(Context context, String key) {
SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
prefs.edit().remove(key).apply();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package chat.rocket.reactnative.storage;

import com.facebook.react.ReactPackage;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.uimanager.ViewManager;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class DatabaseKeyStorePackage implements ReactPackage {
@Override
public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
List<NativeModule> modules = new ArrayList<>();
modules.add(new DatabaseKeyStore(reactContext));
return modules;
}

@Override
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
return Collections.emptyList();
}
}
5 changes: 4 additions & 1 deletion android/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
edgeToEdgeEnabled=false

# Enable SQLCipher via expo-sqlite (required for encrypted-at-rest databases)
expo.sqlite.useSQLCipher=true
Loading