From c387ad405d2abf952375c115adeacf8d0ea4d63c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 13 Aug 2024 17:21:32 +0200 Subject: [PATCH 01/13] deps: Use Kotlin bom --- app/build.gradle.kts | 1 + gradle/libs.versions.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b3b0c397c..3aa29ae67 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -66,6 +66,7 @@ android { } dependencies { implementation(fileTree("libs") { include("*.aar") }) + implementation(platform(libs.kotlin.bom)) implementation(libs.core.ktx) implementation(libs.appcompat) implementation(libs.activity.compose) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 23b55e5db..8ffb9711f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -45,6 +45,7 @@ hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", ve hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltWork" } junit = { module = "junit:junit", version.ref = "junit" } junit-ext = { module = "androidx.test.ext:junit", version.ref = "junitExt" } +kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin" } kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlinTestJunit" } 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" } From 985d3c653143f574f152cd84f07b7010ee1f5976 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 13 Aug 2024 17:21:51 +0200 Subject: [PATCH 02/13] deps: Use kotlin("test") --- app/build.gradle.kts | 4 ++-- gradle/libs.versions.toml | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3aa29ae67..842325930 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -121,9 +121,9 @@ dependencies { // Test + Debug androidTestImplementation(libs.espresso.core) androidTestImplementation(libs.junit.ext) - androidTestImplementation(libs.kotlin.test.junit) + androidTestImplementation(kotlin("test")) + testImplementation(kotlin("test")) testImplementation(libs.junit) - testImplementation(libs.kotlin.test.junit) // Other implementation(libs.guava) // for ByteArray.toHex()+ } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8ffb9711f..b602a3686 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,7 +16,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" @@ -46,7 +45,6 @@ hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltWork" } junit = { module = "junit:junit", version.ref = "junit" } junit-ext = { module = "androidx.test.ext:junit", version.ref = "junitExt" } kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin" } -kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlinTestJunit" } 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" } From 55f44ad7e7db61bcd0788788d4f0e0abb5c9a335 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 13 Aug 2024 18:47:45 +0200 Subject: [PATCH 03/13] deps: Add datastore --- app/build.gradle.kts | 1 + gradle/libs.versions.toml | 2 ++ 2 files changed, 3 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 842325930..70fc45e1d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -71,6 +71,7 @@ dependencies { 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) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b602a3686..18382cea6 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" @@ -32,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" } From d5dae144e7a4ccabf138796eeb449ba4d7e3bf10 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 13 Aug 2024 18:48:19 +0200 Subject: [PATCH 04/13] feat: Keychain data store --- .../to/bitkit/data/keychain/KeychainStore.kt | 38 +++++++++++++++++++ .../main/java/to/bitkit/ui/MainActivity.kt | 15 ++++++++ 2 files changed, 53 insertions(+) create mode 100644 app/src/main/java/to/bitkit/data/keychain/KeychainStore.kt 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..7940f3aa6 --- /dev/null +++ b/app/src/main/java/to/bitkit/data/keychain/KeychainStore.kt @@ -0,0 +1,38 @@ +package to.bitkit.data.keychain + +import android.content.Context +import android.util.Base64 +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.flow.first +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class KeychainStore @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val Context.prefs: DataStore by preferencesDataStore("keychain") + private val prefs: DataStore by lazy { context.prefs } + + suspend fun get(key: String): ByteArray? { + val prefKey = stringPreferencesKey(key) + return prefs.data.map { it[prefKey].fromBase64() }.first() + } + + suspend fun add(key: String, encryptedValue: ByteArray) { + val prefKey = stringPreferencesKey(key) + prefs.edit { it[prefKey] = encryptedValue.toBase64() } + } + + suspend fun remove(key: String) { + val prefKey = stringPreferencesKey(key) + prefs.edit { it.remove(prefKey) } + } + + private fun ByteArray.toBase64(flags: Int = Base64.DEFAULT) = Base64.encodeToString(this, flags) + private fun String?.fromBase64(flags: Int = Base64.DEFAULT) = 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..4105e40cd 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -35,6 +35,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -44,17 +45,23 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch 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 + private lateinit var keychain: KeychainStore + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -67,6 +74,14 @@ class MainActivity : ComponentActivity() { Button(onClick = viewModel::debugDb) { Text(text = "Debug DB") } + val scope = rememberCoroutineScope() + Button(onClick = { + scope.launch { + keychain.add("test", "test".toByteArray()) + } + }) { + Text(text = "Test Keychain") + } } Peers(viewModel.peers, viewModel::togglePeerConnection) From ebb28050e47f6e0c8f5bc269b5dd6d840115d6e2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 13 Aug 2024 19:11:33 +0200 Subject: [PATCH 05/13] feat: Indexed keychain values --- .../to/bitkit/data/keychain/KeychainStore.kt | 23 +++++++++++++------ app/src/main/java/to/bitkit/ext/ByteArray.kt | 15 ++++++++---- .../main/java/to/bitkit/ui/MainActivity.kt | 2 +- 3 files changed, 27 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/keychain/KeychainStore.kt b/app/src/main/java/to/bitkit/data/keychain/KeychainStore.kt index 7940f3aa6..9e5f91a00 100644 --- a/app/src/main/java/to/bitkit/data/keychain/KeychainStore.kt +++ b/app/src/main/java/to/bitkit/data/keychain/KeychainStore.kt @@ -1,7 +1,8 @@ +@file:Suppress("unused") + package to.bitkit.data.keychain import android.content.Context -import android.util.Base64 import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit @@ -10,29 +11,37 @@ import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import to.bitkit.data.AppDb +import to.bitkit.ext.fromBase64 +import to.bitkit.ext.toBase64 import javax.inject.Inject class KeychainStore @Inject constructor( @ApplicationContext private val context: Context, + db: AppDb, ) { private val Context.prefs: DataStore by preferencesDataStore("keychain") private val prefs: DataStore by lazy { context.prefs } - suspend fun get(key: String): ByteArray? { - val prefKey = stringPreferencesKey(key) + private val walletIndex by lazy { db.configDao().getAll().map { it.first().walletIndex }.toString() } + + suspend fun get(key: String): ByteArray { + val prefKey = indexed(key) return prefs.data.map { it[prefKey].fromBase64() }.first() } suspend fun add(key: String, encryptedValue: ByteArray) { - val prefKey = stringPreferencesKey(key) + val prefKey = indexed(key) prefs.edit { it[prefKey] = encryptedValue.toBase64() } } suspend fun remove(key: String) { - val prefKey = stringPreferencesKey(key) + val prefKey = indexed(key) prefs.edit { it.remove(prefKey) } } - private fun ByteArray.toBase64(flags: Int = Base64.DEFAULT) = Base64.encodeToString(this, flags) - private fun String?.fromBase64(flags: Int = Base64.DEFAULT) = Base64.decode(this, flags) + /** + * Generates a preferences key for storing a value associated with a specific wallet index. + */ + private fun indexed(key: String) = "$walletIndex:$key".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 4105e40cd..03879e884 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -60,7 +60,7 @@ class MainActivity : ComponentActivity() { private val viewModel by viewModels() @Inject - private lateinit var keychain: KeychainStore + lateinit var keychain: KeychainStore override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) From 9cd1f0d7a8862b3735f027afcd1c0fd5eba34188 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 15 Aug 2024 13:12:41 +0200 Subject: [PATCH 06/13] feat: Keychain APIs --- .../to/bitkit/data/keychain/KeychainStore.kt | 39 ++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/keychain/KeychainStore.kt b/app/src/main/java/to/bitkit/data/keychain/KeychainStore.kt index 9e5f91a00..23c02d089 100644 --- a/app/src/main/java/to/bitkit/data/keychain/KeychainStore.kt +++ b/app/src/main/java/to/bitkit/data/keychain/KeychainStore.kt @@ -3,6 +3,7 @@ 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 @@ -11,6 +12,7 @@ import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import to.bitkit.Tag.APP import to.bitkit.data.AppDb import to.bitkit.ext.fromBase64 import to.bitkit.ext.toBase64 @@ -21,23 +23,50 @@ class KeychainStore @Inject constructor( db: AppDb, ) { private val Context.prefs: DataStore by preferencesDataStore("keychain") - private val prefs: DataStore by lazy { context.prefs } + private val prefs = context.prefs private val walletIndex by lazy { db.configDao().getAll().map { it.first().walletIndex }.toString() } - suspend fun get(key: String): ByteArray { + // TODO throw if not found? + suspend fun load(key: String): ByteArray? { val prefKey = indexed(key) - return prefs.data.map { it[prefKey].fromBase64() }.first() + return prefs.data.map { it[prefKey]?.fromBase64() }.first() } - suspend fun add(key: String, encryptedValue: ByteArray) { + suspend fun loadString(key: String): String? { + // TODO decrypt + return load(key)?.toString(Charsets.UTF_8) + } + + suspend fun save(key: String, encryptedValue: ByteArray) { + require(!exists(key)) { "Entry $key exists. Explicitly delete it first to update value." } val prefKey = indexed(key) prefs.edit { it[prefKey] = encryptedValue.toBase64() } + + Log.i(APP, "Saved $key to keychain") } - suspend fun remove(key: String) { + suspend fun saveString(key: String, value: String) { + // TODO encrypt + save(key, value.toByteArray()) + } + + suspend fun delete(key: String) { val prefKey = indexed(key) prefs.edit { it.remove(prefKey) } + + Log.d(APP, "Deleted $key from keychain") + } + + suspend fun exists(key: String): Boolean { + val prefKey = indexed(key) + return prefs.data.map { it.contains(prefKey) }.first() + } + + suspend fun wipe() { + val keys = prefs.data.map { it.asMap().keys }.first() + prefs.edit { it.clear() } + Log.i(APP, "Deleted all entries from keychain: ${keys.joinToString()}") } /** From 85f1b1ac6313cc6c4780a03fd260a4aa551bad86 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 15 Aug 2024 13:34:38 +0200 Subject: [PATCH 07/13] feat: AndroidKeyStore --- .../bitkit/data/keychain/AndroidKeyStore.kt | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 app/src/main/java/to/bitkit/data/keychain/AndroidKeyStore.kt 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..b7e0714c1 --- /dev/null +++ b/app/src/main/java/to/bitkit/data/keychain/AndroidKeyStore.kt @@ -0,0 +1,71 @@ +package to.bitkit.data.keychain + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +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_PKCS7 + 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)) { + val generator = KeyGenerator.getInstance(algorithm, type) + val spec = KeyGenParameterSpec + .Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(blockMode) + .setEncryptionPaddings(padding) + .setRandomizedEncryptionRequired(true) + .setKeySize(256) + .setIsStrongBoxBacked(true) + .build() + generator.init(spec) + generator.generateKey() + } + } + + 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) + } +} From 99c36fc4fe8eb8d4b53a041e7046e6dce99ba0a1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 15 Aug 2024 13:35:11 +0200 Subject: [PATCH 08/13] feat: KeychainStore encrypt & decrypt --- .../to/bitkit/data/keychain/KeychainStore.kt | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/keychain/KeychainStore.kt b/app/src/main/java/to/bitkit/data/keychain/KeychainStore.kt index 23c02d089..33396c566 100644 --- a/app/src/main/java/to/bitkit/data/keychain/KeychainStore.kt +++ b/app/src/main/java/to/bitkit/data/keychain/KeychainStore.kt @@ -22,23 +22,25 @@ class KeychainStore @Inject constructor( @ApplicationContext private val context: Context, db: AppDb, ) { - private val Context.prefs: DataStore by preferencesDataStore("keychain") + private val alias = "keychain" + + private val Context.prefs: DataStore by preferencesDataStore(alias) private val prefs = context.prefs + private val keyStore by lazy { AndroidKeyStore(alias) } private val walletIndex by lazy { db.configDao().getAll().map { it.first().walletIndex }.toString() } + suspend fun loadString(key: String): String? = load(key)?.let { keyStore.decrypt(it) } + // TODO throw if not found? - suspend fun load(key: String): ByteArray? { + private suspend fun load(key: String): ByteArray? { val prefKey = indexed(key) return prefs.data.map { it[prefKey]?.fromBase64() }.first() } - suspend fun loadString(key: String): String? { - // TODO decrypt - return load(key)?.toString(Charsets.UTF_8) - } + suspend fun saveString(key: String, value: String) = save(key, value.let { keyStore.encrypt(it) }) - suspend fun save(key: String, encryptedValue: ByteArray) { + private suspend fun save(key: String, encryptedValue: ByteArray) { require(!exists(key)) { "Entry $key exists. Explicitly delete it first to update value." } val prefKey = indexed(key) prefs.edit { it[prefKey] = encryptedValue.toBase64() } @@ -46,11 +48,6 @@ class KeychainStore @Inject constructor( Log.i(APP, "Saved $key to keychain") } - suspend fun saveString(key: String, value: String) { - // TODO encrypt - save(key, value.toByteArray()) - } - suspend fun delete(key: String) { val prefKey = indexed(key) prefs.edit { it.remove(prefKey) } From 0a003f17b54734fc9577ff184e4d07dc3de4a9c9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 15 Aug 2024 13:39:20 +0200 Subject: [PATCH 09/13] refactor: Indexed as computed pref key --- .../java/to/bitkit/data/keychain/KeychainStore.kt | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/keychain/KeychainStore.kt b/app/src/main/java/to/bitkit/data/keychain/KeychainStore.kt index 33396c566..655bc9e3c 100644 --- a/app/src/main/java/to/bitkit/data/keychain/KeychainStore.kt +++ b/app/src/main/java/to/bitkit/data/keychain/KeychainStore.kt @@ -34,30 +34,26 @@ class KeychainStore @Inject constructor( // TODO throw if not found? private suspend fun load(key: String): ByteArray? { - val prefKey = indexed(key) - return prefs.data.map { it[prefKey]?.fromBase64() }.first() + return prefs.data.map { it[key.indexed]?.fromBase64() }.first() } 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." } - val prefKey = indexed(key) - prefs.edit { it[prefKey] = encryptedValue.toBase64() } + prefs.edit { it[key.indexed] = encryptedValue.toBase64() } Log.i(APP, "Saved $key to keychain") } suspend fun delete(key: String) { - val prefKey = indexed(key) - prefs.edit { it.remove(prefKey) } + prefs.edit { it.remove(key.indexed) } Log.d(APP, "Deleted $key from keychain") } suspend fun exists(key: String): Boolean { - val prefKey = indexed(key) - return prefs.data.map { it.contains(prefKey) }.first() + return prefs.data.map { it.contains(key.indexed) }.first() } suspend fun wipe() { @@ -69,5 +65,5 @@ class KeychainStore @Inject constructor( /** * Generates a preferences key for storing a value associated with a specific wallet index. */ - private fun indexed(key: String) = "$walletIndex:$key".let(::stringPreferencesKey) + private val String.indexed get() = "$walletIndex:$this".let(::stringPreferencesKey) } From 33244fbafb42900224db8e25c131d90fc95b5684 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 15 Aug 2024 23:21:09 +0200 Subject: [PATCH 10/13] feat: Retry wo Strongbox if not available --- .../bitkit/data/keychain/AndroidKeyStore.kt | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/keychain/AndroidKeyStore.kt b/app/src/main/java/to/bitkit/data/keychain/AndroidKeyStore.kt index b7e0714c1..74059f110 100644 --- a/app/src/main/java/to/bitkit/data/keychain/AndroidKeyStore.kt +++ b/app/src/main/java/to/bitkit/data/keychain/AndroidKeyStore.kt @@ -2,6 +2,7 @@ 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 @@ -16,7 +17,7 @@ class AndroidKeyStore( private val algorithm = KeyProperties.KEY_ALGORITHM_AES private val blockMode = KeyProperties.BLOCK_MODE_GCM - private val padding = KeyProperties.ENCRYPTION_PADDING_PKCS7 + private val padding = KeyProperties.ENCRYPTION_PADDING_NONE private val transformation = "$algorithm/$blockMode/$padding" private val ivLength = 12 // GCM typically uses a 12-byte IV @@ -29,20 +30,30 @@ class AndroidKeyStore( private fun generateKey() { if (!keyStore.containsAlias(alias)) { - val generator = KeyGenerator.getInstance(algorithm, type) - val spec = KeyGenParameterSpec - .Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) - .setBlockModes(blockMode) - .setEncryptionPaddings(padding) - .setRandomizedEncryptionRequired(true) - .setKeySize(256) - .setIsStrongBoxBacked(true) - .build() - generator.init(spec) - generator.generateKey() + 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) } From 95abfd2e41ce7d1265ca5843ce3dee78dd1e58ec Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 15 Aug 2024 23:48:06 +0200 Subject: [PATCH 11/13] fix: Keychain concurrency --- .../to/bitkit/data/keychain/KeychainStore.kt | 57 +++++++++++-------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/keychain/KeychainStore.kt b/app/src/main/java/to/bitkit/data/keychain/KeychainStore.kt index 655bc9e3c..b3ea787bc 100644 --- a/app/src/main/java/to/bitkit/data/keychain/KeychainStore.kt +++ b/app/src/main/java/to/bitkit/data/keychain/KeychainStore.kt @@ -10,60 +10,69 @@ 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.flow.map +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, - db: AppDb, -) { - private val alias = "keychain" + @IoDispatcher private val dispatcher: CoroutineDispatcher, +) : CoroutineScope { - private val Context.prefs: DataStore by preferencesDataStore(alias) - private val prefs = context.prefs + private val job = Job() + override val coroutineContext = dispatcher + job + private val alias = "keychain" private val keyStore by lazy { AndroidKeyStore(alias) } - private val walletIndex by lazy { db.configDao().getAll().map { it.first().walletIndex }.toString() } - suspend fun loadString(key: String): String? = load(key)?.let { keyStore.decrypt(it) } + private val Context.keychain: DataStore by preferencesDataStore(alias, scope = this) + val snapshot get() = runBlocking(coroutineContext) { context.keychain.data.first() } - // TODO throw if not found? - private suspend fun load(key: String): ByteArray? { - return prefs.data.map { it[key.indexed]?.fromBase64() }.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." } - prefs.edit { it[key.indexed] = encryptedValue.toBase64() } + context.keychain.edit { it[key.indexed] = encryptedValue.toBase64() } - Log.i(APP, "Saved $key to keychain") + Log.i(APP, "Saved to keychain: $key") } suspend fun delete(key: String) { - prefs.edit { it.remove(key.indexed) } + context.keychain.edit { it.remove(key.indexed) } - Log.d(APP, "Deleted $key from keychain") + Log.d(APP, "Deleted from keychain: $key ") } - suspend fun exists(key: String): Boolean { - return prefs.data.map { it.contains(key.indexed) }.first() + fun exists(key: String): Boolean { + return snapshot.contains(key.indexed) } suspend fun wipe() { - val keys = prefs.data.map { it.asMap().keys }.first() - prefs.edit { it.clear() } - Log.i(APP, "Deleted all entries from keychain: ${keys.joinToString()}") + val keys = snapshot.asMap().keys + context.keychain.edit { it.clear() } + + Log.i(APP, "Deleted all keychain entries: ${keys.joinToString()}") } - /** - * Generates a preferences key for storing a value associated with a specific wallet index. - */ - private val String.indexed get() = "$walletIndex:$this".let(::stringPreferencesKey) + private val String.indexed: Preferences.Key + get() { + val walletIndex = runBlocking(coroutineContext) { db.configDao().getAll().first() }.first().walletIndex + return "${this}_$walletIndex".let(::stringPreferencesKey) + } } From a75165f61a571cc33c54a770da417005891aed7f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 15 Aug 2024 23:58:02 +0200 Subject: [PATCH 12/13] feat: Keychain UI to debug --- app/src/main/java/to/bitkit/ui/MainActivity.kt | 14 ++++---------- app/src/main/java/to/bitkit/ui/MainViewModel.kt | 12 ++++++++++++ 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 03879e884..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 @@ -35,7 +36,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -45,7 +45,6 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.data.keychain.KeychainStore import to.bitkit.ext.requiresPermission @@ -70,17 +69,12 @@ class MainActivity : ComponentActivity() { AppThemeSurface { MainScreen(viewModel) { WalletScreen(viewModel) { - Row { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { Button(onClick = viewModel::debugDb) { Text(text = "Debug DB") } - val scope = rememberCoroutineScope() - Button(onClick = { - scope.launch { - keychain.add("test", "test".toByteArray()) - } - }) { - Text(text = "Test Keychain") + Button(onClick = viewModel::debugKeychain) { + Text(text = "Debug Keychain") } } 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() { From 392428f3037622935e57e6aa1de82a098ad58f65 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 15 Aug 2024 23:59:35 +0200 Subject: [PATCH 13/13] feat: Keychain android test --- app/build.gradle.kts | 13 ++- .../bitkit/data/keychain/KeychainStoreTest.kt | 105 ++++++++++++++++++ .../java/to/bitkit/test/BaseTest.kt | 20 ++++ .../java/to/bitkit/test/MainDispatcherRule.kt | 22 ++++ gradle/libs.versions.toml | 2 +- 5 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 app/src/androidTest/java/to/bitkit/data/keychain/KeychainStoreTest.kt create mode 100644 app/src/androidTest/java/to/bitkit/test/BaseTest.kt create mode 100644 app/src/androidTest/java/to/bitkit/test/MainDispatcherRule.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 70fc45e1d..afff04e76 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -63,6 +63,13 @@ android { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } + @Suppress("UnstableApiUsage") + testOptions { + unitTests { + // isReturnDefaultValues = true // mockito + // isIncludeAndroidResources = true // robolectric + } + } } dependencies { implementation(fileTree("libs") { include("*.aar") }) @@ -124,7 +131,11 @@ dependencies { androidTestImplementation(libs.junit.ext) androidTestImplementation(kotlin("test")) testImplementation(kotlin("test")) - testImplementation(libs.junit) + 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 18382cea6..637e1be3f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,7 +44,7 @@ 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-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }