Skip to content
19 changes: 16 additions & 3 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()+
}
Expand Down
105 changes: 105 additions & 0 deletions app/src/androidTest/java/to/bitkit/data/keychain/KeychainStoreTest.kt
Original file line number Diff line number Diff line change
@@ -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<IllegalArgumentException> {
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()
}
}
20 changes: 20 additions & 0 deletions app/src/androidTest/java/to/bitkit/test/BaseTest.kt
Original file line number Diff line number Diff line change
@@ -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() }
}
22 changes: 22 additions & 0 deletions app/src/androidTest/java/to/bitkit/test/MainDispatcherRule.kt
Original file line number Diff line number Diff line change
@@ -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()
}
}
82 changes: 82 additions & 0 deletions app/src/main/java/to/bitkit/data/keychain/AndroidKeyStore.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
78 changes: 78 additions & 0 deletions app/src/main/java/to/bitkit/data/keychain/KeychainStore.kt
Original file line number Diff line number Diff line change
@@ -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<Preferences> 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<String>
get() {
val walletIndex = runBlocking(coroutineContext) { db.configDao().getAll().first() }.first().walletIndex
return "${this}_$walletIndex".let(::stringPreferencesKey)
}
}
15 changes: 10 additions & 5 deletions app/src/main/java/to/bitkit/ext/ByteArray.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -13,21 +14,25 @@ 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)
oos.writeObject(this)
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)
Loading