Add Android native vault manager unit test scaffolding (#846)

This commit is contained in:
Leendert de Borst
2025-05-17 12:00:12 +02:00
parent 1480fd88d1
commit a70f6fca56
5 changed files with 343 additions and 49 deletions

View File

@@ -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"
}

View File

@@ -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) {
}
}
}
}
}

View File

@@ -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)

View File

@@ -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<Any?>): List<Map<String, Any?>> {
val results = mutableListOf<Map<String, Any?>>()
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<String, Any?>()
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<Any?>): 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<Any?>): List<Map<String, Any?>> {
val db = readableDatabase
val results = mutableListOf<Map<String, Any?>>()
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<String, Any?>()
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<Any?>): 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
}

View File

@@ -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<Context>()
`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() }
}
}