Repository navigation
feat: native SQLCipher database readers and native key store #7398
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
diegolmello
merged 21 commits into
feat/native-1272-sqlcipher-migration
from
feat/native-1276-native-readers
Jun 23, 2026
Merged
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 61cea36
feat: add expo-sqlite driver adapter, key service, and live-query hoo…
diegolmello 2c2103b
chore: fix lint on schema index and ignore generated Drizzle migrations
diegolmello 4286748
chore: merge lint fixes from the schema branch
diegolmello e5e3091
chore: fix lint on driver adapter sources
diegolmello 0b56390
feat: rewrite native database readers for SQLCipher and add native ke…
diegolmello 6aaba61
chore: register iOS key-store in Xcode project and harden native readers
diegolmello a3b082e
refactor: migrate database key store to a TurboModule
diegolmello 72a719c
fix: validate SQLCipher key on open and prevent NSE cursor leak
diegolmello 5606e6f
fix: fail closed when a stored database key can't be read
diegolmello b9c1382
fix: prevent iOS 0xdead10cc kill on the encrypted WAL database
diegolmello b23efd3
refactor: deduplicate message columns, add notNull constraints, drop …
diegolmello 56d87d8
refactor: single baseline migration with NOT NULL constraints folded …
diegolmello 7a507c5
Merge remote-tracking branch 'origin/feat/native-1275-drizzle-schema'…
diegolmello 8b6befb
fix: harden driver adapter open/key concurrency and validation
diegolmello ee159ef
refactor: trim forward-looking comment in driver connection
diegolmello 0122f34
Merge remote-tracking branch 'origin/feat/native-1274-driver-adapter'…
diegolmello 8dbe804
fix: correct native reader room lookup and key-store lifecycle
diegolmello b9809a6
fix: align native reader db-name derivation and cipher pragmas with J…
diegolmello 1ca85bb
Merge remote-tracking branch 'origin/feat/native-1272-sqlcipher-migra…
diegolmello 870fd04
chore: resolve eslint errors in key service and connection driver
diegolmello File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,3 +6,4 @@ android | |
| ios | ||
|
|
||
| .worktrees/ | ||
| app/lib/database/driver/migrations/ | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
171 changes: 171 additions & 0 deletions
171
android/app/src/main/java/chat/rocket/reactnative/storage/DatabaseKeyStore.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| } | ||
| } |
24 changes: 24 additions & 0 deletions
24
android/app/src/main/java/chat/rocket/reactnative/storage/DatabaseKeyStorePackage.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.