Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions app/build.gradle.kts
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions app/build.gradle.kts
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions app/build.gradle.kts
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions app/build.gradle.kts
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions app/build.gradle.kts
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions app/build.gradle.kts
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions app/build.gradle.kts
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions app/build.gradle.kts
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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