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
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import expo.modules.ApplicationLifecycleDispatcher
import chat.rocket.reactnative.networking.SSLPinningTurboPackage;
import chat.rocket.reactnative.storage.MMKVKeyManager;
import chat.rocket.reactnative.storage.SecureStoragePackage;
import chat.rocket.reactnative.storage.DatabaseKeyStoreTurboPackage;
import chat.rocket.reactnative.notification.VideoConfTurboPackage
import chat.rocket.reactnative.notification.PushNotificationTurboPackage
import chat.rocket.reactnative.VoipTurboPackage
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
@@ -0,0 +1,177 @@
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 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 / removeItemInternal 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 DatabaseKeyStoreModule extends NativeDatabaseKeyStoreSpec {

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;

public DatabaseKeyStoreModule(ReactApplicationContext reactContext) {
super(reactContext);
}

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

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

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

@Override
public void removeItem(String key, Promise promise) {
try {
removeItemInternal(getReactApplicationContext(), 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 the key is genuinely absent
* (SharedPreferences blob not present). Throws on any other failure — corrupt
* data, missing Keystore alias for an existing blob, decryption error, etc. —
* so callers can distinguish a true not-found from a read/decrypt failure.
*/
public static String getItemInternal(Context context, String key) throws Exception {
SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
String encoded = prefs.getString(key, null);
if (encoded == null) {
// Genuine not-found: no blob stored for this key.
return null;
}

KeyStore ks = KeyStore.getInstance(KEYSTORE_PROVIDER);
ks.load(null);
if (!ks.containsAlias(key)) {
// Blob exists but Keystore alias is gone — this is corruption, not a miss.
throw new IllegalStateException("keystore alias missing for existing key blob: " + key);
}

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);
}

/** Stores value under key. Write-once: an existing key is never overwritten. */
public static void setItemInternal(Context context, String key, String value) throws Exception {
// Write-once: if a key is already stored, confirm it matches and return.
// A differing value means another caller already minted a key — overwriting
// it would strand a database encrypted with the first key.
String existing = getItemInternal(context, key);
if (existing != null) {
if (!existing.equals(value)) {
throw new IllegalStateException("refusing to overwrite existing database key: " + key);
}
return;
}

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);
boolean persisted = prefs.edit().putString(key, Base64.encodeToString(combined, Base64.DEFAULT)).commit();
if (!persisted) {
throw new IllegalStateException("failed to persist encrypted database key blob: " + key);
}
}

/** 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,43 @@
package chat.rocket.reactnative.storage;

import androidx.annotation.Nullable;

import com.facebook.react.TurboReactPackage;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.module.model.ReactModuleInfo;
import com.facebook.react.module.model.ReactModuleInfoProvider;

import java.util.HashMap;
import java.util.Map;

public class DatabaseKeyStoreTurboPackage extends TurboReactPackage {

@Nullable
@Override
public NativeModule getModule(String name, ReactApplicationContext reactContext) {
if (name.equals(NativeDatabaseKeyStoreSpec.NAME)) {
return new DatabaseKeyStoreModule(reactContext);
}
return null;
}

@Override
public ReactModuleInfoProvider getReactModuleInfoProvider() {
return () -> {
final Map<String, ReactModuleInfo> moduleInfos = new HashMap<>();
moduleInfos.put(
NativeDatabaseKeyStoreSpec.NAME,
new ReactModuleInfo(
NativeDatabaseKeyStoreSpec.NAME,
NativeDatabaseKeyStoreSpec.NAME,
false, // canOverrideExistingModule
false, // needsEagerInit
true, // hasConstants
false, // isCxxModule
true // isTurboModule
));
return moduleInfos;
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package chat.rocket.reactnative.storage;

import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.Promise;
import com.facebook.react.turbomodule.core.interfaces.TurboModule;

public abstract class NativeDatabaseKeyStoreSpec extends ReactContextBaseJavaModule implements TurboModule {

public static final String NAME = "DatabaseKeyStoreModule";

public NativeDatabaseKeyStoreSpec(ReactApplicationContext reactContext) {
super(reactContext);
}

@Override
public String getName() {
return NAME;
}

@ReactMethod
public abstract void getItem(String key, Promise promise);

@ReactMethod
public abstract void setItem(String key, String value, Promise promise);

@ReactMethod
public abstract void removeItem(String key, Promise promise);
}
86 changes: 86 additions & 0 deletions app/lib/encryption/__tests__/keyStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { getOrCreateDatabaseKey } from '../keyStore';

// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------

const mockGetItem = jest.fn<Promise<string | null>, [string]>();
const mockSetItem = jest.fn<Promise<void>, [string, string]>();

jest.mock('../../native/NativeDatabaseKeyStore', () => ({
__esModule: true,
default: {
getItem: (...args: [string]) => mockGetItem(...args),
setItem: (...args: [string, string]) => mockSetItem(...args),
removeItem: jest.fn()
}
}));

jest.mock('@rocket.chat/mobile-crypto', () => ({
randomKey: jest.fn(async () => 'a'.repeat(64))
}));

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('getOrCreateDatabaseKey', () => {
beforeEach(() => {
jest.clearAllMocks();
mockSetItem.mockResolvedValue(undefined);
});

it('returns the existing key unchanged when one is stored', async () => {
const existingKey = 'b'.repeat(64);
mockGetItem.mockResolvedValueOnce(existingKey);

const result = await getOrCreateDatabaseKey('test.db');

expect(result).toBe(existingKey);
expect(result).toMatch(/^[0-9a-f]{64}$/i);
expect(mockSetItem).not.toHaveBeenCalled();
});

it('mints a 64-char hex key, stores it, and returns it when none exists', async () => {
mockGetItem.mockResolvedValueOnce(null);

const result = await getOrCreateDatabaseKey('new.db');

expect(typeof result).toBe('string');
expect(result).toHaveLength(64);
expect(result).toMatch(/^[0-9a-f]{64}$/i);
expect(mockSetItem).toHaveBeenCalledTimes(1);
expect(mockSetItem).toHaveBeenCalledWith('db_key_v1:new.db', result);
});

it('propagates a read error and does NOT call setItem (fail-closed)', async () => {
const readError = Object.assign(new Error('Keychain read failed'), { code: 'KEYCHAIN_READ_ERROR' });
mockGetItem.mockRejectedValueOnce(readError);

await expect(getOrCreateDatabaseKey('encrypted.db')).rejects.toThrow('Keychain read failed');
expect(mockSetItem).not.toHaveBeenCalled();
});

it('treats resolved null as not-found and mints a new key', async () => {
mockGetItem.mockResolvedValueOnce(null);

const result = await getOrCreateDatabaseKey('absent.db');

expect(mockSetItem).toHaveBeenCalledTimes(1);
expect(result).toHaveLength(64);
});

it('throws on undefined (contract violation) and does NOT call setItem', async () => {
mockGetItem.mockResolvedValueOnce(undefined as unknown as null);

await expect(getOrCreateDatabaseKey('undef.db')).rejects.toThrow('unexpected value');
expect(mockSetItem).not.toHaveBeenCalled();
});

it('rejects a malformed stored key (fail-closed) and does NOT mint', async () => {
mockGetItem.mockResolvedValueOnce('z'.repeat(64)); // 64 chars but not hex

await expect(getOrCreateDatabaseKey('corrupt.db')).rejects.toThrow('malformed');
expect(mockSetItem).not.toHaveBeenCalled();
});
});
40 changes: 40 additions & 0 deletions app/lib/encryption/keyStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { randomKey } from '@rocket.chat/mobile-crypto';

import NativeDatabaseKeyStore from '../native/NativeDatabaseKeyStore';

const KEY_PREFIX = 'db_key_v1:';
const DATABASE_KEY_PATTERN = /^[0-9a-f]{64}$/i;

/**
* Returns the stored 64-hex-char SQLCipher key for dbName, creating and persisting
* a fresh one if none exists yet.
*
* Fail-closed: a read rejection propagates (we never mint over a read failure), and a
* stored value that isn't a 64-hex-char key is rejected rather than handed to SQLCipher —
* either case would otherwise risk locking or corrupting an existing encrypted database.
*/
export async function getOrCreateDatabaseKey(dbName: string): Promise<string> {
const storageKey = KEY_PREFIX + dbName;

const existing = await NativeDatabaseKeyStore.getItem(storageKey);

if (typeof existing === 'string') {
if (!DATABASE_KEY_PATTERN.test(existing)) {
throw new Error(`DatabaseKeyStore returned a malformed key for ${storageKey}`);
}
return existing;
}

if (existing !== null) {
// undefined or any unexpected value is a contract violation — never mint
throw new Error(`DatabaseKeyStore.getItem returned unexpected value for key ${storageKey}`);
}

// True not-found (null) — mint a new 32-byte key as 64 hex chars
const newKey = await randomKey(32);
if (!DATABASE_KEY_PATTERN.test(newKey)) {
throw new Error(`Generated a malformed database key for ${storageKey}`);
}
await NativeDatabaseKeyStore.setItem(storageKey, newKey);
return newKey;
}
Loading
Loading