diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b3b0c397c..afff04e76 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -63,13 +63,22 @@ android { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } + @Suppress("UnstableApiUsage") + testOptions { + unitTests { + // isReturnDefaultValues = true // mockito + // isIncludeAndroidResources = true // robolectric + } + } } dependencies { implementation(fileTree("libs") { include("*.aar") }) + implementation(platform(libs.kotlin.bom)) implementation(libs.core.ktx) implementation(libs.appcompat) implementation(libs.activity.compose) implementation(libs.material) + implementation(libs.datastore.preferences) // BDK + LDK implementation(libs.bdk.android) implementation(libs.ldk.node.android) @@ -120,9 +129,13 @@ dependencies { // Test + Debug androidTestImplementation(libs.espresso.core) androidTestImplementation(libs.junit.ext) - androidTestImplementation(libs.kotlin.test.junit) - testImplementation(libs.junit) - testImplementation(libs.kotlin.test.junit) + androidTestImplementation(kotlin("test")) + testImplementation(kotlin("test")) + testImplementation(libs.junit.junit) + // testImplementation("androidx.test:core:1.6.1") + // testImplementation("org.mockito:mockito-core:5.12.0") + // testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0") + // testImplementation("org.robolectric:robolectric:4.13") // Other implementation(libs.guava) // for ByteArray.toHex()+ } diff --git a/app/src/androidTest/java/to/bitkit/data/keychain/KeychainStoreTest.kt b/app/src/androidTest/java/to/bitkit/data/keychain/KeychainStoreTest.kt new file mode 100644 index 000000000..44135d041 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/data/keychain/KeychainStoreTest.kt @@ -0,0 +1,105 @@ +package to.bitkit.data.keychain + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import to.bitkit.data.AppDb +import to.bitkit.data.entities.ConfigEntity +import to.bitkit.test.BaseTest +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@OptIn(ExperimentalCoroutinesApi::class) +class KeychainStoreTest : BaseTest() { + + private val appContext: Context by lazy { ApplicationProvider.getApplicationContext() } + private lateinit var db: AppDb + + private lateinit var sut: KeychainStore + + @Before + fun setUp() { + db = Room.inMemoryDatabaseBuilder(appContext, AppDb::class.java).build().also { + // seed db + runBlocking { + it.configDao().upsert( + ConfigEntity( + walletIndex = 0L, + ), + ) + } + } + + sut = KeychainStore( + db, + appContext, + testDispatcher, + ) + } + + @Test + fun dbSeed() = test { + val config = db.configDao().getAll().first() + + assertTrue { config.first().walletIndex == 0L } + } + + @Test + fun saveString_loadString() = test { + val (key, value) = "key" to "value" + + sut.saveString(key, value) + + assertEquals(value, sut.loadString(key)) + } + + @Test + fun saveString_existingKey_shouldThrow() = test { + assertFailsWith { + val key = "key" + sut.saveString(key, "value1") + sut.saveString(key, "value2") + } + } + + @Test + fun delete() = test { + val (key, value) = "keyToDelete" to "value" + sut.saveString(key, value) + + sut.delete(key) + + assertNull(sut.loadString(key)) + } + + @Test + fun exists() { + } + + @Test + fun wipe() = test { + List(3) { sut.saveString("keyToWipe$it", "value$it") } + + sut.wipe() + + assertTrue { sut.snapshot.asMap().isEmpty() } + } + + @After + fun tearDown() { + db.close() + sut.cancel() + } +} diff --git a/app/src/androidTest/java/to/bitkit/test/BaseTest.kt b/app/src/androidTest/java/to/bitkit/test/BaseTest.kt new file mode 100644 index 000000000..073f66a6a --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/test/BaseTest.kt @@ -0,0 +1,20 @@ +package to.bitkit.test + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Rule + +@ExperimentalCoroutinesApi +abstract class BaseTest( + testDispatcher: TestDispatcher = UnconfinedTestDispatcher() +) { + @get:Rule + val coroutinesTestRule = MainDispatcherRule(testDispatcher) + + protected val testDispatcher get() = coroutinesTestRule.testDispatcher + + protected fun test(block: suspend TestScope.() -> Unit) = runTest(testDispatcher) { block() } +} diff --git a/app/src/androidTest/java/to/bitkit/test/MainDispatcherRule.kt b/app/src/androidTest/java/to/bitkit/test/MainDispatcherRule.kt new file mode 100644 index 000000000..ae7120d5c --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/test/MainDispatcherRule.kt @@ -0,0 +1,22 @@ +package to.bitkit.test + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherRule( + val testDispatcher: TestDispatcher, +) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} diff --git a/app/src/main/java/to/bitkit/data/keychain/AndroidKeyStore.kt b/app/src/main/java/to/bitkit/data/keychain/AndroidKeyStore.kt new file mode 100644 index 000000000..74059f110 --- /dev/null +++ b/app/src/main/java/to/bitkit/data/keychain/AndroidKeyStore.kt @@ -0,0 +1,82 @@ +package to.bitkit.data.keychain + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.security.keystore.StrongBoxUnavailableException +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +class AndroidKeyStore( + private val alias: String, + private val password: CharArray? = null, +) { + private val type = "AndroidKeyStore" + + private val algorithm = KeyProperties.KEY_ALGORITHM_AES + private val blockMode = KeyProperties.BLOCK_MODE_GCM + private val padding = KeyProperties.ENCRYPTION_PADDING_NONE + private val transformation = "$algorithm/$blockMode/$padding" + + private val ivLength = 12 // GCM typically uses a 12-byte IV + + private val keyStore by lazy { KeyStore.getInstance(type).apply { load(null) } } + + init { + generateKey() + } + + private fun generateKey() { + if (!keyStore.containsAlias(alias)) { + try { + val generator = KeyGenerator.getInstance(algorithm, type) + generator.init(buildSpec(true)) + generator.generateKey() + } catch (e: StrongBoxUnavailableException) { + val generator = KeyGenerator.getInstance(algorithm, type) + generator.init(buildSpec(false)) + generator.generateKey() + } + } + } + + private fun buildSpec(isStrongboxBacked: Boolean): KeyGenParameterSpec { + val spec = KeyGenParameterSpec + .Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(blockMode) + .setEncryptionPaddings(padding) + .setRandomizedEncryptionRequired(true) + .setKeySize(256) + .setIsStrongBoxBacked(isStrongboxBacked) + .build() + return spec + } + + fun encrypt(data: String): ByteArray { + val secretKey = keyStore.getKey(alias, password) as SecretKey + val cipher = Cipher.getInstance(transformation).apply { init(Cipher.ENCRYPT_MODE, secretKey) } + + val encryptedData = cipher.doFinal(data.toByteArray(Charsets.UTF_8)) + val iv = cipher.iv + check(iv.size == ivLength) { "Unexpected IV length: ${iv.size} ≠ $ivLength" } + + // Combine the IV and encrypted data into a single byte array + return iv + encryptedData + } + + fun decrypt(data: ByteArray): String { + val secretKey = keyStore.getKey(alias, password) as SecretKey + + // Extract the IV from the beginning of the encrypted data + val iv = data.sliceArray(0 until ivLength) + val actualEncryptedData = data.sliceArray(ivLength until data.size) + + val spec = GCMParameterSpec(128, iv) + val cipher = Cipher.getInstance(transformation).apply { init(Cipher.DECRYPT_MODE, secretKey, spec) } + + val decryptedDataBytes = cipher.doFinal(actualEncryptedData) + return decryptedDataBytes.toString(Charsets.UTF_8) + } +} diff --git a/app/src/main/java/to/bitkit/data/keychain/KeychainStore.kt b/app/src/main/java/to/bitkit/data/keychain/KeychainStore.kt new file mode 100644 index 000000000..b3ea787bc --- /dev/null +++ b/app/src/main/java/to/bitkit/data/keychain/KeychainStore.kt @@ -0,0 +1,78 @@ +@file:Suppress("unused") + +package to.bitkit.data.keychain + +import android.content.Context +import android.util.Log +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import to.bitkit.Tag.APP +import to.bitkit.data.AppDb +import to.bitkit.di.IoDispatcher +import to.bitkit.ext.fromBase64 +import to.bitkit.ext.toBase64 +import javax.inject.Inject + +class KeychainStore @Inject constructor( + private val db: AppDb, + @ApplicationContext private val context: Context, + @IoDispatcher private val dispatcher: CoroutineDispatcher, +) : CoroutineScope { + + private val job = Job() + override val coroutineContext = dispatcher + job + + private val alias = "keychain" + private val keyStore by lazy { AndroidKeyStore(alias) } + + private val Context.keychain: DataStore by preferencesDataStore(alias, scope = this) + val snapshot get() = runBlocking(coroutineContext) { context.keychain.data.first() } + + fun loadString(key: String): String? = load(key)?.let { keyStore.decrypt(it) } + + private fun load(key: String): ByteArray? { + // TODO throw/warn if not found + return snapshot[key.indexed]?.fromBase64() + } + + suspend fun saveString(key: String, value: String) = save(key, value.let { keyStore.encrypt(it) }) + + private suspend fun save(key: String, encryptedValue: ByteArray) { + require(!exists(key)) { "Entry $key exists. Explicitly delete it first to update value." } + context.keychain.edit { it[key.indexed] = encryptedValue.toBase64() } + + Log.i(APP, "Saved to keychain: $key") + } + + suspend fun delete(key: String) { + context.keychain.edit { it.remove(key.indexed) } + + Log.d(APP, "Deleted from keychain: $key ") + } + + fun exists(key: String): Boolean { + return snapshot.contains(key.indexed) + } + + suspend fun wipe() { + val keys = snapshot.asMap().keys + context.keychain.edit { it.clear() } + + Log.i(APP, "Deleted all keychain entries: ${keys.joinToString()}") + } + + private val String.indexed: Preferences.Key + get() { + val walletIndex = runBlocking(coroutineContext) { db.configDao().getAll().first() }.first().walletIndex + return "${this}_$walletIndex".let(::stringPreferencesKey) + } +} diff --git a/app/src/main/java/to/bitkit/ext/ByteArray.kt b/app/src/main/java/to/bitkit/ext/ByteArray.kt index 6f30de4a8..11923631e 100644 --- a/app/src/main/java/to/bitkit/ext/ByteArray.kt +++ b/app/src/main/java/to/bitkit/ext/ByteArray.kt @@ -2,6 +2,7 @@ package to.bitkit.ext +import android.util.Base64 import com.google.common.io.BaseEncoding import java.io.ByteArrayOutputStream import java.io.ObjectOutputStream @@ -13,17 +14,17 @@ fun ByteArray.toHex(): String { // TODO check if this can be replaced with existing ByteArray.toHex() val ByteArray.hex: String get() = joinToString("") { "%02x".format(it) } -fun String.asByteArray(): ByteArray { - return BaseEncoding.base16().decode(this.uppercase()) -} - val String.hex: ByteArray get() { - check(length % 2 == 0) { "Cannot convert string of uneven length to hex ByteArray: $this" } + require(length % 2 == 0) { "Cannot convert string of uneven length to hex ByteArray: $this" } return chunked(2) .map { it.toInt(16).toByte() } .toByteArray() } +fun String.asByteArray(): ByteArray { + return BaseEncoding.base16().decode(this.uppercase()) +} + fun Any.convertToByteArray(): ByteArray { val bos = ByteArrayOutputStream() val oos = ObjectOutputStream(bos) @@ -31,3 +32,7 @@ fun Any.convertToByteArray(): ByteArray { oos.flush() return bos.toByteArray() } + +fun ByteArray.toBase64(flags: Int = Base64.DEFAULT): String = Base64.encodeToString(this, flags) + +fun String.fromBase64(flags: Int = Base64.DEFAULT): ByteArray = Base64.decode(this, flags) diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 25407b49b..bb10c6655 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -8,6 +8,7 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -45,16 +46,21 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import dagger.hilt.android.AndroidEntryPoint import to.bitkit.R +import to.bitkit.data.keychain.KeychainStore import to.bitkit.ext.requiresPermission import to.bitkit.ext.toast import to.bitkit.ui.shared.Channels import to.bitkit.ui.shared.Peers import to.bitkit.ui.theme.AppThemeSurface +import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { private val viewModel by viewModels() + @Inject + lateinit var keychain: KeychainStore + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -63,10 +69,13 @@ class MainActivity : ComponentActivity() { AppThemeSurface { MainScreen(viewModel) { WalletScreen(viewModel) { - Row { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { Button(onClick = viewModel::debugDb) { Text(text = "Debug DB") } + Button(onClick = viewModel::debugKeychain) { + Text(text = "Debug Keychain") + } } Peers(viewModel.peers, viewModel::togglePeerConnection) diff --git a/app/src/main/java/to/bitkit/ui/MainViewModel.kt b/app/src/main/java/to/bitkit/ui/MainViewModel.kt index 66a37cb4a..9f3a0102c 100644 --- a/app/src/main/java/to/bitkit/ui/MainViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/MainViewModel.kt @@ -16,6 +16,7 @@ import to.bitkit.SEED import to.bitkit.Tag.DEV import to.bitkit.bdk.BitcoinService import to.bitkit.data.AppDb +import to.bitkit.data.keychain.KeychainStore import to.bitkit.di.BgDispatcher import to.bitkit.ext.syncTo import to.bitkit.ldk.LightningService @@ -29,6 +30,7 @@ import javax.inject.Inject @HiltViewModel class MainViewModel @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + private val keychain: KeychainStore, private val appDb: AppDb, ) : ViewModel() { val ldkNodeId = mutableStateOf("Loading…") @@ -111,6 +113,16 @@ class MainViewModel @Inject constructor( } } } + + fun debugKeychain() { + viewModelScope.launch { + val key = "test" + if (keychain.exists(key)) { + keychain.delete(key) + } + keychain.saveString(key, "testValue") + } + } } fun MainViewModel.refresh() { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 23b55e5db..637e1be3f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,6 +5,7 @@ appcompat = "1.7.0" bdk = "0.31.1" composeBom = "2024.06.00" # https://developer.android.com/develop/ui/compose/bom/bom-mapping coreKtx = "1.13.1" +datastorePrefs = "1.1.1" espressoCore = "3.6.1" firebaseBom = "33.1.2" googleServices = "4.4.2" @@ -16,7 +17,6 @@ hiltWork = "1.2.0" junit = "4.13.2" junitExt = "1.2.1" kotlin = "1.9.24" -kotlinTestJunit = "1.9.21" ksp = "1.9.24-1.0.20" ktor = "2.3.12" ldkNode = "0.3.0" @@ -33,6 +33,7 @@ appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" bdk-android = { module = "org.bitcoindevkit:bdk-android", version.ref = "bdk" } compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } +datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePrefs" } espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espressoCore" } firebase-analytics = { module = "com.google.firebase:firebase-analytics" } firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" } @@ -43,9 +44,9 @@ hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", ve hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltCompiler" } hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" } hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltWork" } -junit = { module = "junit:junit", version.ref = "junit" } +junit-junit = { group = "junit", name = "junit", version.ref = "junit" } junit-ext = { module = "androidx.test.ext:junit", version.ref = "junitExt" } -kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlinTestJunit" } +kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }