From a70f6fca5615ee2557348ad8fbb6ab191f201aab Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Sat, 17 May 2025 12:00:12 +0200 Subject: [PATCH] Add Android native vault manager unit test scaffolding (#846) --- apps/mobile-app/android/app/build.gradle | 11 ++ .../SharedCredentialStore.kt | 106 +++++++++++++--- .../nativevaultmanager/NativeVaultManager.kt | 44 ++++++- .../app/nativevaultmanager/VaultDatabase.kt | 120 ++++++++++++++---- .../NativeVaultManagerTest.kt | 111 ++++++++++++++++ 5 files changed, 343 insertions(+), 49 deletions(-) create mode 100644 apps/mobile-app/android/app/src/test/java/net/aliasvault/app/nativevaultmanager/NativeVaultManagerTest.kt diff --git a/apps/mobile-app/android/app/build.gradle b/apps/mobile-app/android/app/build.gradle index 2dc0f0956..bfa156a33 100644 --- a/apps/mobile-app/android/app/build.gradle +++ b/apps/mobile-app/android/app/build.gradle @@ -153,6 +153,16 @@ dependencies { // Add biometric dependency for credential management implementation("androidx.biometric:biometric:1.1.0") + // Test dependencies + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.mockito:mockito-core:4.0.0' + testImplementation 'org.mockito.kotlin:mockito-kotlin:4.0.0' + testImplementation 'androidx.test:core:1.5.0' + testImplementation 'androidx.test:runner:1.5.2' + testImplementation 'androidx.test:rules:1.5.0' + testImplementation 'androidx.test.ext:junit:1.1.5' + testImplementation 'org.robolectric:robolectric:4.9' + def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true"; def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true"; def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true"; @@ -176,4 +186,5 @@ dependencies { } else { implementation jscFlavor } + implementation "org.jetbrains.kotlin:kotlin-test:1.9.25" } diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialmanager/SharedCredentialStore.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialmanager/SharedCredentialStore.kt index 85857c402..91006e339 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialmanager/SharedCredentialStore.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialmanager/SharedCredentialStore.kt @@ -6,6 +6,7 @@ import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.util.Base64 import android.util.Log +import androidx.biometric.BiometricManager import androidx.biometric.BiometricPrompt import androidx.fragment.app.FragmentActivity import org.json.JSONArray @@ -26,7 +27,8 @@ import javax.crypto.spec.SecretKeySpec class SharedCredentialStore private constructor(context: Context) { private val appContext = context.applicationContext private val executor: Executor = Executors.newSingleThreadExecutor() - + private val biometricManager = BiometricManager.from(appContext) + // Cache for encryption key during the lifetime of this instance private var encryptionKey: ByteArray? = null @@ -37,7 +39,15 @@ class SharedCredentialStore private constructor(context: Context) { } /** - * Get or create encryption key using biometric authentication + * Check if biometric authentication is available on the device + */ + private fun isBiometricAvailable(): Boolean { + return biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == + BiometricManager.BIOMETRIC_SUCCESS + } + + /** + * Get or create encryption key using biometric authentication if available */ fun getEncryptionKey(activity: FragmentActivity, callback: CryptoOperationCallback) { // If key is already in memory, use it @@ -53,10 +63,68 @@ class SharedCredentialStore private constructor(context: Context) { if (encryptedKeyB64 == null) { // No key exists, create a new one - createNewEncryptionKey(activity, callback) + if (isBiometricAvailable()) { + createNewEncryptionKey(activity, callback) + } else { + // Create key without biometric protection + createNewEncryptionKeyWithoutBiometric(callback) + } } else { - // Key exists, retrieve it with biometric auth - retrieveEncryptionKey(activity, encryptedKeyB64, callback) + // Key exists, retrieve it + if (isBiometricAvailable()) { + retrieveEncryptionKey(activity, encryptedKeyB64, callback) + } else { + // Retrieve key without biometric protection + retrieveEncryptionKeyWithoutBiometric(encryptedKeyB64, callback) + } + } + } + + /** + * Create a new random encryption key without biometric protection + */ + private fun createNewEncryptionKeyWithoutBiometric(callback: CryptoOperationCallback) { + try { + // Generate a random 32-byte key for AES-256 + val secureRandom = SecureRandom() + val randomKey = ByteArray(32) + secureRandom.nextBytes(randomKey) + + // Cache the key + encryptionKey = randomKey + + // Store the key directly + val prefs = appContext.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE) + val encryptedKeyB64 = Base64.encodeToString(randomKey, Base64.DEFAULT) + prefs.edit().putString(ENCRYPTED_KEY_PREF, encryptedKeyB64).apply() + + Log.d(TAG, "Encryption key stored successfully without biometric protection") + callback.onSuccess("Key stored successfully") + } catch (e: Exception) { + Log.e(TAG, "Error creating encryption key", e) + callback.onError(e) + } + } + + /** + * Retrieve the encryption key without biometric authentication + */ + private fun retrieveEncryptionKeyWithoutBiometric( + encryptedKeyB64: String, + callback: CryptoOperationCallback + ) { + try { + // Decode the key + val key = Base64.decode(encryptedKeyB64, Base64.DEFAULT) + + // Cache the key + encryptionKey = key + + Log.d(TAG, "Encryption key retrieved successfully without biometric protection") + callback.onSuccess("Key retrieved successfully") + } catch (e: Exception) { + Log.e(TAG, "Error retrieving encryption key", e) + callback.onError(e) } } @@ -314,7 +382,7 @@ class SharedCredentialStore private constructor(context: Context) { * Decrypts data using AES/GCM/NoPadding */ @Throws(Exception::class) - private fun decryptData(encryptedData: String): String { + public fun decryptData(encryptedData: String): String { val key = encryptionKey ?: throw Exception("Encryption key not available") // Decode combined data @@ -410,30 +478,30 @@ class SharedCredentialStore private constructor(context: Context) { // First check if credentials exist val prefs = appContext.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE) val encryptedCredentialsJson = prefs.getString(CREDENTIALS_KEY, null) - + if (encryptedCredentialsJson == null) { // No credentials found, return empty array without triggering biometric authentication Log.d(TAG, "No credentials found, returning empty array without key retrieval") callback.onSuccess(JSONArray().toString()) return } - + // Credentials exist, ensure we have the encryption key getEncryptionKey(activity, object : CryptoOperationCallback { override fun onSuccess(result: String) { try { Log.d(TAG, "Retrieving credentials from SharedPreferences") - + // Decrypt credentials val decryptedJson = decryptData(encryptedCredentialsJson) - + callback.onSuccess(decryptedJson) } catch (e: Exception) { Log.e(TAG, "Error retrieving credentials", e) callback.onError(e) } } - + override fun onError(e: Exception) { Log.e(TAG, "Failed to get encryption key", e) callback.onError(e) @@ -455,20 +523,20 @@ class SharedCredentialStore private constructor(context: Context) { // Check if credentials exist val prefs = appContext.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE) val encryptedCredentialsJson = prefs.getString(CREDENTIALS_KEY, null) - + if (encryptedCredentialsJson == null) { // No credentials found, return empty array Log.d(TAG, "No credentials found, returning empty array") callback.onSuccess(JSONArray().toString()) return true } - + try { Log.d(TAG, "Retrieving credentials using cached key") - + // Decrypt credentials directly with cached key val decryptedJson = decryptData(encryptedCredentialsJson) - + callback.onSuccess(decryptedJson) return true } catch (e: Exception) { @@ -488,15 +556,15 @@ class SharedCredentialStore private constructor(context: Context) { .remove(CREDENTIALS_KEY) .remove(ENCRYPTED_KEY_PREF) .apply() - + // Clear the cached encryption key encryptionKey = null - + // Remove the key from Android Keystore if it exists try { val keyStore = KeyStore.getInstance("AndroidKeyStore") keyStore.load(null) - + if (keyStore.containsAlias(KEYSTORE_ALIAS)) { keyStore.deleteEntry(KEYSTORE_ALIAS) Log.d(TAG, "Removed encryption key from Android Keystore") @@ -562,4 +630,4 @@ class SharedCredentialStore private constructor(context: Context) { } } } -} \ No newline at end of file +} \ No newline at end of file diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt index a82001f29..ef56f6075 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt @@ -94,7 +94,25 @@ class NativeVaultManager(reactContext: ReactApplicationContext) : override fun onSuccess(result: String) { isVaultUnlocked = true lastUnlockTime = System.currentTimeMillis() - promise.resolve(true) + + // Now that we have the key, we can initialize the database + try { + val prefs = reactApplicationContext.getSharedPreferences("vault_data", Activity.MODE_PRIVATE) + val encryptedDb = prefs.getString("encrypted_db", null) + + if (encryptedDb != null) { + // Initialize the database with the decrypted data + println("Initializing database with encrypted data") + val db = VaultDatabase(reactApplicationContext) + println("Database initialized") + db.initializeWithEncryptedData(encryptedDb) + } + + promise.resolve(true) + } catch (e: Exception) { + Log.e(TAG, "Error initializing database", e) + promise.reject("ERR_INIT_DB", "Failed to initialize database: ${e.message}", e) + } } override fun onError(e: Exception) { @@ -148,9 +166,27 @@ class NativeVaultManager(reactContext: ReactApplicationContext) : @ReactMethod override fun storeEncryptionKey(base64EncryptionKey: String, promise: Promise) { try { - val prefs = reactApplicationContext.getSharedPreferences("vault_keys", Activity.MODE_PRIVATE) - prefs.edit().putString("encryption_key", base64EncryptionKey).apply() - promise.resolve(null) + val activity = getFragmentActivity() + if (activity == null) { + promise.reject("ERR_ACTIVITY", "Activity is not available") + return + } + + val store = SharedCredentialStore.getInstance(reactApplicationContext) + val keyBytes = android.util.Base64.decode(base64EncryptionKey, android.util.Base64.DEFAULT) + + // Store the key in SharedCredentialStore which will handle biometric protection + store.getEncryptionKey(activity, object : SharedCredentialStore.CryptoOperationCallback { + override fun onSuccess(result: String) { + // Key is now stored securely in SharedCredentialStore + promise.resolve(null) + } + + override fun onError(e: Exception) { + Log.e(TAG, "Error storing encryption key", e) + promise.reject("ERR_STORE_KEY", "Failed to store encryption key: ${e.message}", e) + } + }) } catch (e: Exception) { Log.e(TAG, "Error storing encryption key", e) promise.reject("ERR_STORE_KEY", "Failed to store encryption key: ${e.message}", e) diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/VaultDatabase.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/VaultDatabase.kt index ddb77d2f3..6d02bf020 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/VaultDatabase.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/VaultDatabase.kt @@ -3,10 +3,18 @@ package net.aliasvault.app.nativevaultmanager import android.content.Context import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper +import android.util.Base64 import android.util.Log +import net.aliasvault.app.credentialmanager.SharedCredentialStore +import org.json.JSONObject +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream -class VaultDatabase(context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) { +class VaultDatabase(private val context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) { private var currentTransaction: SQLiteDatabase? = null + private val TAG = "VaultDatabase" override fun onCreate(db: SQLiteDatabase) { // Create tables as needed @@ -24,8 +32,77 @@ class VaultDatabase(context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, onCreate(db) } + fun initializeWithEncryptedData(encryptedData: String) { + try { + // Get the encryption key from SharedCredentialStore + val store = SharedCredentialStore.getInstance(context) + + // Decrypt the data using the encryption key + val decryptedData = store.decryptData(encryptedData) + + // Decompress the data if it's compressed + val decompressedData = decompressData(decryptedData) + + // Create an in-memory SQLite database + currentTransaction = SQLiteDatabase.create(null) + + // Import the SQL statements from the decrypted data + val statements = decompressedData.split(";") + for (statement in statements) { + if (statement.trim().isNotEmpty()) { + currentTransaction?.execSQL(statement) + } + } + + Log.d(TAG, "Database initialized successfully") + } catch (e: Exception) { + Log.e(TAG, "Error initializing database", e) + throw e + } + } + + fun executeQuery(query: String, params: Array): List> { + val results = mutableListOf>() + + currentTransaction?.let { db -> + val cursor = db.rawQuery(query, params.map { it?.toString() }.toTypedArray()) + + cursor.use { + val columnNames = it.columnNames + while (it.moveToNext()) { + val row = mutableMapOf() + for (columnName in columnNames) { + when (it.getType(it.getColumnIndexOrThrow(columnName))) { + android.database.Cursor.FIELD_TYPE_NULL -> row[columnName] = null + android.database.Cursor.FIELD_TYPE_INTEGER -> row[columnName] = it.getLong(it.getColumnIndexOrThrow(columnName)) + android.database.Cursor.FIELD_TYPE_FLOAT -> row[columnName] = it.getDouble(it.getColumnIndexOrThrow(columnName)) + android.database.Cursor.FIELD_TYPE_STRING -> row[columnName] = it.getString(it.getColumnIndexOrThrow(columnName)) + android.database.Cursor.FIELD_TYPE_BLOB -> row[columnName] = it.getBlob(it.getColumnIndexOrThrow(columnName)) + } + } + results.add(row) + } + } + } + + return results + } + + fun executeUpdate(query: String, params: Array): Int { + currentTransaction?.let { db -> + db.execSQL(query, params.map { it?.toString() }.toTypedArray()) + // Get the number of affected rows + val cursor = db.rawQuery("SELECT changes()", null) + cursor.use { + if (it.moveToFirst()) { + return it.getInt(0) + } + } + } + return 0 + } + fun beginTransaction() { - currentTransaction = writableDatabase currentTransaction?.beginTransaction() } @@ -40,38 +117,29 @@ class VaultDatabase(context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, currentTransaction = null } - fun executeQuery(query: String, params: Array): List> { - val db = readableDatabase - val results = mutableListOf>() + private fun decompressData(compressedData: String): String { + val decoded = Base64.decode(compressedData, Base64.DEFAULT) + val inputStream = GZIPInputStream(ByteArrayInputStream(decoded)) + val outputStream = ByteArrayOutputStream() - db.rawQuery(query, params.map { it?.toString() }.toTypedArray()).use { cursor -> - val columnNames = cursor.columnNames - while (cursor.moveToNext()) { - val row = mutableMapOf() - for (columnName in columnNames) { - when (cursor.getType(cursor.getColumnIndexOrThrow(columnName))) { - android.database.Cursor.FIELD_TYPE_INTEGER -> row[columnName] = cursor.getLong(cursor.getColumnIndexOrThrow(columnName)) - android.database.Cursor.FIELD_TYPE_FLOAT -> row[columnName] = cursor.getDouble(cursor.getColumnIndexOrThrow(columnName)) - android.database.Cursor.FIELD_TYPE_STRING -> row[columnName] = cursor.getString(cursor.getColumnIndexOrThrow(columnName)) - android.database.Cursor.FIELD_TYPE_BLOB -> row[columnName] = cursor.getBlob(cursor.getColumnIndexOrThrow(columnName)) - android.database.Cursor.FIELD_TYPE_NULL -> row[columnName] = null - } - } - results.add(row) - } + val buffer = ByteArray(1024) + var len: Int + while (inputStream.read(buffer).also { len = it } > 0) { + outputStream.write(buffer, 0, len) } - return results + return outputStream.toString("UTF-8") } - fun executeUpdate(query: String, params: Array): Int { - val db = currentTransaction ?: writableDatabase - db.execSQL(query, params.map { it?.toString() }.toTypedArray()) - return 1 // Return 1 to indicate success, or you could return the actual number of affected rows if available + private fun compressData(data: String): String { + val outputStream = ByteArrayOutputStream() + val gzipOutputStream = GZIPOutputStream(outputStream) + gzipOutputStream.write(data.toByteArray(Charsets.UTF_8)) + gzipOutputStream.close() + return Base64.encodeToString(outputStream.toByteArray(), Base64.DEFAULT) } companion object { - private const val TAG = "VaultDatabase" private const val DATABASE_NAME = "vault.db" private const val DATABASE_VERSION = 1 } diff --git a/apps/mobile-app/android/app/src/test/java/net/aliasvault/app/nativevaultmanager/NativeVaultManagerTest.kt b/apps/mobile-app/android/app/src/test/java/net/aliasvault/app/nativevaultmanager/NativeVaultManagerTest.kt new file mode 100644 index 000000000..e94f65f64 --- /dev/null +++ b/apps/mobile-app/android/app/src/test/java/net/aliasvault/app/nativevaultmanager/NativeVaultManagerTest.kt @@ -0,0 +1,111 @@ +package net.aliasvault.app.nativevaultmanager + +import android.content.Context +import android.content.SharedPreferences +import androidx.test.core.app.ApplicationProvider +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.WritableArray +import com.facebook.react.bridge.Arguments +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.* +import org.mockito.MockitoAnnotations +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File +import java.io.FileInputStream +import java.io.InputStream +import java.util.concurrent.Executors +import org.junit.Assert.* + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class NativeVaultManagerTest { + + private lateinit var nativeVaultManager: NativeVaultManager + private lateinit var mockReactContext: ReactApplicationContext + private val testEncryptionKeyBase64 = "/9So3C83JLDIfjsF0VQOc4rz1uAFtIseW7yrUuztAD0=" // 32 bytes for AES-256 + + @Before + fun setup() { + MockitoAnnotations.openMocks(this) + mockReactContext = mock(ReactApplicationContext::class.java) + val context = ApplicationProvider.getApplicationContext() + `when`(mockReactContext.applicationContext).thenReturn(context) + `when`(mockReactContext.getSharedPreferences(anyString(), anyInt())).thenReturn( + context.getSharedPreferences("test_prefs", Context.MODE_PRIVATE) + ) + + nativeVaultManager = NativeVaultManager(mockReactContext) + + // Store test data + val testDb = loadTestDatabase() + val metadata = """ + { + "publicEmailDomains": ["spamok.com", "spamok.nl"], + "privateEmailDomains": ["aliasvault.net", "main.aliasvault.net"], + "vaultRevisionNumber": 1 + } + """.trimIndent() + + // Store encryption key + val promise = mock(com.facebook.react.bridge.Promise::class.java) + nativeVaultManager.storeEncryptionKey(testEncryptionKeyBase64, promise) + verify(promise).resolve(null) + + // Store encrypted database + nativeVaultManager.storeDatabase(testDb, promise) + verify(promise).resolve(null) + + // Store metadata + nativeVaultManager.storeMetadata(metadata, promise) + verify(promise).resolve(null) + + // Unlock vault + nativeVaultManager.unlockVault(promise) + verify(promise).resolve(true) + } + + @Test + fun testDatabaseInitialization() { + val promise = mock(com.facebook.react.bridge.Promise::class.java) + nativeVaultManager.isVaultUnlocked(promise) + verify(promise).resolve(true) + } + + @Test + fun testGetAllCredentials() { + val promise = mock(com.facebook.react.bridge.Promise::class.java) + val query = "SELECT * FROM credentials" + val params = Arguments.createArray() + + nativeVaultManager.executeQuery(query, params, promise) + + // Verify the promise was resolved with an array + verify(promise).resolve(any()) + } + + @Test + fun testGetGmailCredentialDetails() { + val promise = mock(com.facebook.react.bridge.Promise::class.java) + val query = "SELECT * FROM credentials WHERE service_name = ?" + val params = Arguments.createArray().apply { + pushString("Gmail Test Account") + } + + nativeVaultManager.executeQuery(query, params, promise) + + // Verify the promise was resolved with an array containing the Gmail credential + verify(promise).resolve(any()) + } + + private fun loadTestDatabase(): String { + // Load the test database file from resources + val inputStream = javaClass.classLoader?.getResourceAsStream("test-encrypted-vault.txt") + ?: throw IllegalStateException("Test database file not found") + + return inputStream.bufferedReader().use { it.readText() } + } +} \ No newline at end of file