From 76b69e59077ae81661d094b55818d15ff7533624 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Feb 2026 22:42:44 +0100 Subject: [PATCH 01/20] feat: add Secret model --- .../java/to/bitkit/domain/models/Secret.kt | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 app/src/main/java/to/bitkit/domain/models/Secret.kt diff --git a/app/src/main/java/to/bitkit/domain/models/Secret.kt b/app/src/main/java/to/bitkit/domain/models/Secret.kt new file mode 100644 index 000000000..3b2ec817b --- /dev/null +++ b/app/src/main/java/to/bitkit/domain/models/Secret.kt @@ -0,0 +1,116 @@ +package to.bitkit.domain.models + +import kotlin.reflect.KProperty + +private const val WIPE_CHAR = '\u0000' + +/** + * A property delegate that stores sensitive data in a [CharArray] and provides APIs to safely wipe it from memory. + * + * ALWAYS process the wrapped value through [use] blocks API for auto cleanup, as it implements [AutoCloseable]. + */ +internal class Secret(initialValue: CharArray) : AutoCloseable { + companion object { + private const val ERR_WIPED = "Secret has already been wiped." + } + + private var data: CharArray? = initialValue.copyOf() + + init { + // Wipe the input array immediately after copying to reduce exposure + wipe(nullify = false) + } + + operator fun getValue(thisRef: Any?, property: KProperty<*>): CharArray { + return checkNotNull(data) { ERR_WIPED } + } + + operator fun setValue(thisRef: Any?, property: KProperty<*>, value: CharArray) { + wipe(nullify = false) + data = value.copyOf() + // Wipe the source array + value.fill(WIPE_CHAR) + } + + /** + * Temporarily access the underlying data, then automatically wipe it. + * + * ``` + * secret("myToken".toCharArray()).use { authenticate(it) } + * // chars are wiped here + * ``` + */ + inline fun use(block: (CharArray) -> R): R { + try { + return block(checkNotNull(data) { ERR_WIPED }) + } finally { + wipe() + } + } + + /** + * Access the data without wiping afterwards. + * Useful when you need multiple reads before an explicit [wipe]. + * + * ```kotlin + * mySecret.peek { chars -> hash(chars) }` + * // data is still alive here + * ``` + */ + inline fun peek(block: (CharArray) -> R): R { + return block(checkNotNull(data) { ERR_WIPED }) + } + + /** + * Zero-out the backing memory and optionally nullify the reference by default. + * Safe to call multiple times. + */ + fun wipe(nullify: Boolean = true) { + data?.fill(WIPE_CHAR) + if (nullify) data = null + } + + /** Alias for [wipe] to satisfy [AutoCloseable]. */ + override fun close() = wipe() +} + +/** Create a [Secret] from a [CharArray]. The source array is wiped. */ +internal fun secretOf(initialValue: CharArray) = Secret(initialValue) + +/** Create a [Secret] from a [String]. The string can't be wiped from + * the JVM string pool, so prefer [CharArray] overloads where possible. */ +internal fun secretOf(value: String): Secret { + val chars = value.toCharArray() + return Secret(chars) // constructor wipes `chars` +} + +/** + * Convert a [String] to a [Secret]. + * + * ⚠️ The original [String] remains in the JVM string pool and cannot be wiped. + * Prefer constructing from [CharArray] literals where possible. + */ +internal fun String.toSecret(): Secret = secretOf(this) + +/** + * Convert a [CharArray] to a [Secret]. The source array is wiped. + */ +internal fun CharArray.toSecret(): Secret = secretOf(this) + +/** + * Convert a [String] to a secure [CharArray], pass it to [block], then wipe it, all without storing a property. + * + * ``` + * "temporarySecret".withSecret { chars -> + * sendOverTls(chars) + * } + * ``` + */ +internal inline fun String.withSecret(block: (CharArray) -> R): R { + val chars = toCharArray() + try { + return block(chars) + } finally { + chars.fill(WIPE_CHAR) + } +} From d21e62b9bc66a917651e11924f0da52634c8edf7 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Feb 2026 22:53:18 +0100 Subject: [PATCH 02/20] feat: add ergonomic extensions to Secret Co-Authored-By: Claude Opus 4.5 --- .../java/to/bitkit/domain/models/Secret.kt | 64 +++++++++++++++++-- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/domain/models/Secret.kt b/app/src/main/java/to/bitkit/domain/models/Secret.kt index 3b2ec817b..369ba702c 100644 --- a/app/src/main/java/to/bitkit/domain/models/Secret.kt +++ b/app/src/main/java/to/bitkit/domain/models/Secret.kt @@ -1,3 +1,5 @@ +@file:Suppress("TooManyFunctions") + package to.bitkit.domain.models import kotlin.reflect.KProperty @@ -9,12 +11,14 @@ private const val WIPE_CHAR = '\u0000' * * ALWAYS process the wrapped value through [use] blocks API for auto cleanup, as it implements [AutoCloseable]. */ -internal class Secret(initialValue: CharArray) : AutoCloseable { +class Secret internal constructor(initialValue: CharArray) : AutoCloseable { companion object { - private const val ERR_WIPED = "Secret has already been wiped." + @PublishedApi + internal const val ERR_WIPED = "Secret has already been wiped." } - private var data: CharArray? = initialValue.copyOf() + @PublishedApi + internal var data: CharArray? = initialValue.copyOf() init { // Wipe the input array immediately after copying to reduce exposure @@ -75,11 +79,11 @@ internal class Secret(initialValue: CharArray) : AutoCloseable { } /** Create a [Secret] from a [CharArray]. The source array is wiped. */ -internal fun secretOf(initialValue: CharArray) = Secret(initialValue) +fun secretOf(initialValue: CharArray) = Secret(initialValue) /** Create a [Secret] from a [String]. The string can't be wiped from * the JVM string pool, so prefer [CharArray] overloads where possible. */ -internal fun secretOf(value: String): Secret { +fun secretOf(value: String): Secret { val chars = value.toCharArray() return Secret(chars) // constructor wipes `chars` } @@ -97,6 +101,34 @@ internal fun String.toSecret(): Secret = secretOf(this) */ internal fun CharArray.toSecret(): Secret = secretOf(this) +/** Nullable factory for Keychain integration - creates a [Secret] only if value is non-null. */ +fun secretOrNull(value: String?): Secret? = value?.let { secretOf(it) } + +/** Create a [Secret] from a [CharArray]. Alias for [secretOf]. */ +fun secret(chars: CharArray): Secret = secretOf(chars) + +/** Create a [Secret] from a [String]. Alias for [secretOf]. */ +fun secret(value: String): Secret = secretOf(value) + +/** + * Convert the [Secret] to a [String] for FFI boundary, execute the block, then wipe. + * + * ⚠️ Use only at FFI boundaries where RUST requires String params. + * The temporary String cannot be wiped from JVM memory. + */ +inline fun Secret.useAsString(block: (String) -> R): R = + use { chars -> block(String(chars)) } + +/** + * Split a mnemonic [Secret] into individual [Secret] words. + * The original secret is NOT wiped - caller is responsible for calling [wipe] after use. + */ +fun Secret.splitWords(): List = + peek { chars -> String(chars).split(" ").filter { it.isNotBlank() }.map { secretOf(it) } } + +/** Wipe all [Secret] instances in a list. Safe to call multiple times. */ +fun List.wipeAll() = forEach { it.wipe() } + /** * Convert a [String] to a secure [CharArray], pass it to [block], then wipe it, all without storing a property. * @@ -114,3 +146,25 @@ internal inline fun String.withSecret(block: (CharArray) -> R): R { chars.fill(WIPE_CHAR) } } + +/** + * Execute a block with the string value, then wipe an internal [CharArray] representation. + * This is useful when you need to pass a String to an API but want to minimize exposure. + * + * ⚠️ The original [String] cannot be wiped from JVM memory. This helps with + * intermediate CharArray representations. + * + * ``` + * userInput.withSecretChars { str -> + * keychain.saveString(key, str) + * } + * ``` + */ +internal inline fun String.withSecretChars(block: (String) -> R): R { + val chars = toCharArray() + try { + return block(this) + } finally { + chars.fill(WIPE_CHAR) + } +} From 6c693b1a40a55f2f9a214791a5726c14aca210ed Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Feb 2026 22:55:53 +0100 Subject: [PATCH 03/20] feat: add secure loading methods to Keychain Co-Authored-By: Claude Opus 4.5 --- app/src/main/java/to/bitkit/data/keychain/Keychain.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt index 9b777174a..044fb7e6d 100644 --- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt +++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt @@ -15,6 +15,8 @@ import kotlinx.coroutines.flow.map import to.bitkit.async.BaseCoroutineScope import to.bitkit.data.AppDb import to.bitkit.di.IoDispatcher +import to.bitkit.domain.models.Secret +import to.bitkit.domain.models.secretOf import to.bitkit.ext.fromBase64 import to.bitkit.ext.toBase64 import to.bitkit.utils.AppError @@ -41,6 +43,13 @@ class Keychain @Inject constructor( fun loadString(key: String): String? = load(key)?.decodeToString() + /** Load a keychain value as a [Secret] for deterministic cleanup. */ + fun loadSecret(key: String): Secret? = loadString(key)?.let { secretOf(it) } + + /** Load raw keychain bytes as a [Secret] for deterministic cleanup. */ + fun loadSecretBytes(key: String): Secret? = + load(key)?.let { bytes -> secretOf(bytes.map { it.toInt().toChar() }.toCharArray()) } + @Suppress("TooGenericExceptionCaught", "SwallowedException") fun load(key: String): ByteArray? { try { From 228164c6dc5d11f32cb5d4a690ec6b9c0a421280 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Feb 2026 22:56:27 +0100 Subject: [PATCH 04/20] refactor: secure mnemonic in LightningService Co-Authored-By: Claude Opus 4.5 --- .../to/bitkit/services/LightningService.kt | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 84fe835cb..d30258d42 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -43,6 +43,7 @@ import to.bitkit.data.SettingsStore import to.bitkit.data.backup.VssStoreIdProvider import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher +import to.bitkit.domain.models.useAsString import to.bitkit.env.Env import to.bitkit.ext.totalNextOutboundHtlcLimitSats import to.bitkit.ext.uByteList @@ -147,12 +148,17 @@ class LightningService @Inject constructor( ) } - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) + val mnemonicSecret = keychain.loadSecret(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound() - setEntropyBip39Mnemonic( - mnemonic = mnemonic, - passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name), - ) + val passphraseSecret = keychain.loadSecret(Keychain.Key.BIP39_PASSPHRASE.name) + + mnemonicSecret.useAsString { mnemonic -> + setEntropyBip39Mnemonic( + mnemonic = mnemonic, + passphrase = passphraseSecret?.peek { String(it) }, + ) + } + passphraseSecret?.wipe() } try { val vssStoreId = vssStoreIdProvider.getVssStoreId(walletIndex) @@ -170,8 +176,6 @@ class LightningService @Inject constructor( } } catch (e: BuildException) { throw LdkError(e) - } finally { - // TODO: cleanup sensitive data after implementing a `SecureString` value holder for Keychain return values } } From 37db0f466ed35cf4361a26fd815fd2f4d3177d21 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Feb 2026 22:57:09 +0100 Subject: [PATCH 05/20] refactor: secure mnemonic in WalletRepo Co-Authored-By: Claude Opus 4.5 --- .../java/to/bitkit/repositories/WalletRepo.kt | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 3b5782db4..f4d0b3486 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -21,6 +21,8 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher +import to.bitkit.domain.models.useAsString +import to.bitkit.domain.models.withSecretChars import to.bitkit.env.Env import to.bitkit.ext.filterOpen import to.bitkit.ext.nowTimestamp @@ -282,10 +284,8 @@ class WalletRepo @Inject constructor( lightningRepo.setRecoveryMode(enabled = false) runCatching { val mnemonic = generateEntropyMnemonic() - keychain.saveString(Keychain.Key.BIP39_MNEMONIC.name, mnemonic) - if (bip39Passphrase != null) { - keychain.saveString(Keychain.Key.BIP39_PASSPHRASE.name, bip39Passphrase) - } + mnemonic.withSecretChars { keychain.saveString(Keychain.Key.BIP39_MNEMONIC.name, it) } + bip39Passphrase?.withSecretChars { keychain.saveString(Keychain.Key.BIP39_PASSPHRASE.name, it) } setWalletExistsState() }.onFailure { Logger.error("createWallet error", it, context = TAG) @@ -295,10 +295,8 @@ class WalletRepo @Inject constructor( suspend fun restoreWallet(mnemonic: String, bip39Passphrase: String?): Result = withContext(bgDispatcher) { lightningRepo.setRecoveryMode(enabled = false) runCatching { - keychain.saveString(Keychain.Key.BIP39_MNEMONIC.name, mnemonic) - if (bip39Passphrase != null) { - keychain.saveString(Keychain.Key.BIP39_PASSPHRASE.name, bip39Passphrase) - } + mnemonic.withSecretChars { keychain.saveString(Keychain.Key.BIP39_MNEMONIC.name, it) } + bip39Passphrase?.withSecretChars { keychain.saveString(Keychain.Key.BIP39_PASSPHRASE.name, it) } setWalletExistsState() }.onFailure { Logger.error("restoreWallet error", it, context = TAG) @@ -337,25 +335,27 @@ class WalletRepo @Inject constructor( count: Int = 20, ): Result> = withContext(bgDispatcher) { runCatching { - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) + val mnemonicSecret = keychain.loadSecret(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound() - - val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) + val passphraseSecret = keychain.loadSecret(Keychain.Key.BIP39_PASSPHRASE.name) val baseDerivationPath = AddressType.P2WPKH.toDerivationPath( index = 0, isChange = isChange, ).substringBeforeLast("/0") - val result = coreService.onchain.deriveBitcoinAddresses( - mnemonicPhrase = mnemonic, - derivationPathStr = baseDerivationPath, - network = Env.network, - bip39Passphrase = passphrase, - isChange = isChange, - startIndex = startIndex.toUInt(), - count = count.toUInt(), - ) + val result = mnemonicSecret.useAsString { mnemonic -> + coreService.onchain.deriveBitcoinAddresses( + mnemonicPhrase = mnemonic, + derivationPathStr = baseDerivationPath, + network = Env.network, + bip39Passphrase = passphraseSecret?.peek { String(it) }, + isChange = isChange, + startIndex = startIndex.toUInt(), + count = count.toUInt(), + ) + } + passphraseSecret?.wipe() val addresses = result.addresses.mapIndexed { index, address -> AddressModel( From 4d703c179343d915d8c1cee52c986bc870d5aca8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Feb 2026 22:57:36 +0100 Subject: [PATCH 06/20] refactor: secure mnemonic in LightningRepo Co-Authored-By: Claude Opus 4.5 --- .../to/bitkit/repositories/LightningRepo.kt | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index f44ee9eec..7185415c9 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -46,6 +46,7 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher +import to.bitkit.domain.models.useAsString import to.bitkit.env.Env import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.ext.nowTimestamp @@ -679,18 +680,23 @@ class LightningRepo @Inject constructor( callback: String, domain: String, ): Result = runCatching { - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound() - val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) - - lnurlAuth( - k1 = k1, - callback = callback, - domain = domain, - network = Env.network.toCoreNetwork(), - bip32Mnemonic = mnemonic, - bip39Passphrase = passphrase, - ).also { - Logger.debug("LNURL auth result: '$it'", context = TAG) + val mnemonicSecret = keychain.loadSecret(Keychain.Key.BIP39_MNEMONIC.name) + ?: throw ServiceError.MnemonicNotFound() + val passphraseSecret = keychain.loadSecret(Keychain.Key.BIP39_PASSPHRASE.name) + + mnemonicSecret.useAsString { mnemonic -> + lnurlAuth( + k1 = k1, + callback = callback, + domain = domain, + network = Env.network.toCoreNetwork(), + bip32Mnemonic = mnemonic, + bip39Passphrase = passphraseSecret?.peek { String(it) }, + ).also { + Logger.debug("LNURL auth result: '$it'", context = TAG) + } + }.also { + passphraseSecret?.wipe() } }.onFailure { Logger.error("requestLnurlAuth error, k1: $k1, callback: $callback, domain: $domain", it, context = TAG) From 2ae7c68f61bc2a2aa4b322ac7435b347e17e5e3d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Feb 2026 22:58:14 +0100 Subject: [PATCH 07/20] refactor: secure mnemonic in SweepRepo Co-Authored-By: Claude Opus 4.5 --- .../java/to/bitkit/repositories/SweepRepo.kt | 70 +++++++++++-------- 1 file changed, 40 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/SweepRepo.kt b/app/src/main/java/to/bitkit/repositories/SweepRepo.kt index 36209cd1f..9577b8bcd 100644 --- a/app/src/main/java/to/bitkit/repositories/SweepRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/SweepRepo.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.withContext import to.bitkit.async.ServiceQueue import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher +import to.bitkit.domain.models.useAsString import to.bitkit.env.Env import to.bitkit.models.toCoreNetwork import to.bitkit.services.CoreService @@ -31,20 +32,23 @@ class SweepRepo @Inject constructor( ) { suspend fun checkSweepableBalances(): Result = withContext(bgDispatcher) { runCatching { - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) + val mnemonicSecret = keychain.loadSecret(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound() - val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) + val passphraseSecret = keychain.loadSecret(Keychain.Key.BIP39_PASSPHRASE.name) Logger.debug("Checking sweepable balances...", context = TAG) - val balances = ServiceQueue.CORE.background { - checkSweepableBalances( - mnemonicPhrase = mnemonic, - network = Env.network.toCoreNetwork(), - bip39Passphrase = passphrase, - electrumUrl = Env.electrumServerUrl, - ) + val balances = mnemonicSecret.useAsString { mnemonic -> + ServiceQueue.CORE.background { + checkSweepableBalances( + mnemonicPhrase = mnemonic, + network = Env.network.toCoreNetwork(), + bip39Passphrase = passphraseSecret?.peek { String(it) }, + electrumUrl = Env.electrumServerUrl, + ) + } } + passphraseSecret?.wipe() balances.toSweepableBalances() } @@ -55,22 +59,25 @@ class SweepRepo @Inject constructor( feeRateSatsPerVbyte: UInt, ): Result = withContext(bgDispatcher) { runCatching { - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) + val mnemonicSecret = keychain.loadSecret(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound() - val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) + val passphraseSecret = keychain.loadSecret(Keychain.Key.BIP39_PASSPHRASE.name) Logger.debug("Preparing sweep transaction...", context = TAG) - val preview = ServiceQueue.CORE.background { - prepareSweepTransaction( - mnemonicPhrase = mnemonic, - network = Env.network.toCoreNetwork(), - bip39Passphrase = passphrase, - electrumUrl = Env.electrumServerUrl, - destinationAddress = destinationAddress, - feeRateSatsPerVbyte = feeRateSatsPerVbyte, - ) + val preview = mnemonicSecret.useAsString { mnemonic -> + ServiceQueue.CORE.background { + prepareSweepTransaction( + mnemonicPhrase = mnemonic, + network = Env.network.toCoreNetwork(), + bip39Passphrase = passphraseSecret?.peek { String(it) }, + electrumUrl = Env.electrumServerUrl, + destinationAddress = destinationAddress, + feeRateSatsPerVbyte = feeRateSatsPerVbyte, + ) + } } + passphraseSecret?.wipe() preview.toSweepTransactionPreview() } @@ -78,21 +85,24 @@ class SweepRepo @Inject constructor( suspend fun broadcastSweepTransaction(psbt: String): Result = withContext(bgDispatcher) { runCatching { - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) + val mnemonicSecret = keychain.loadSecret(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound() - val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) + val passphraseSecret = keychain.loadSecret(Keychain.Key.BIP39_PASSPHRASE.name) Logger.debug("Broadcasting sweep transaction...", context = TAG) - val result = ServiceQueue.CORE.background { - broadcastSweepTransaction( - psbt = psbt, - mnemonicPhrase = mnemonic, - network = Env.network.toCoreNetwork(), - bip39Passphrase = passphrase, - electrumUrl = Env.electrumServerUrl, - ) + val result = mnemonicSecret.useAsString { mnemonic -> + ServiceQueue.CORE.background { + broadcastSweepTransaction( + psbt = psbt, + mnemonicPhrase = mnemonic, + network = Env.network.toCoreNetwork(), + bip39Passphrase = passphraseSecret?.peek { String(it) }, + electrumUrl = Env.electrumServerUrl, + ) + } } + passphraseSecret?.wipe() result.toSweepResult() } From 64a62ecee64e1e5241f18d6aa215547b3e4f8edd Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Feb 2026 22:59:34 +0100 Subject: [PATCH 08/20] refactor: secure mnemonic in backup services Co-Authored-By: Claude Opus 4.5 --- .../to/bitkit/data/backup/VssBackupClient.kt | 23 ++++++---- .../bitkit/data/backup/VssStoreIdProvider.kt | 20 +++++---- .../java/to/bitkit/services/RNBackupClient.kt | 43 +++++++++++++------ 3 files changed, 57 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt index 4d6c1151b..85ae8ac0f 100644 --- a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt +++ b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import to.bitkit.data.keychain.Keychain import to.bitkit.di.IoDispatcher +import to.bitkit.domain.models.useAsString import to.bitkit.env.Env import to.bitkit.utils.Logger import javax.inject.Inject @@ -41,7 +42,7 @@ class VssBackupClient @Inject constructor( runCatching { isSetup.await() }.onSuccess { return@runCatching } } - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) + val mnemonicSecret = keychain.loadSecret(Keychain.Key.BIP39_MNEMONIC.name) ?: throw MnemonicNotAvailableException() withTimeout(30.seconds) { @@ -52,16 +53,20 @@ class VssBackupClient @Inject constructor( Logger.verbose("Building VSS client with vssUrl: '$vssUrl'", context = TAG) Logger.verbose("Building VSS client with lnurlAuthServerUrl: '$lnurlAuthServerUrl'", context = TAG) if (lnurlAuthServerUrl.isNotEmpty()) { - val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) + val passphraseSecret = keychain.loadSecret(Keychain.Key.BIP39_PASSPHRASE.name) - vssNewClientWithLnurlAuth( - baseUrl = vssUrl, - storeId = vssStoreId, - mnemonic = mnemonic, - passphrase = passphrase, - lnurlAuthServerUrl = lnurlAuthServerUrl, - ) + mnemonicSecret.useAsString { mnemonic -> + vssNewClientWithLnurlAuth( + baseUrl = vssUrl, + storeId = vssStoreId, + mnemonic = mnemonic, + passphrase = passphraseSecret?.peek { String(it) }, + lnurlAuthServerUrl = lnurlAuthServerUrl, + ) + } + passphraseSecret?.wipe() } else { + mnemonicSecret.wipe() vssNewClient( baseUrl = vssUrl, storeId = vssStoreId, diff --git a/app/src/main/java/to/bitkit/data/backup/VssStoreIdProvider.kt b/app/src/main/java/to/bitkit/data/backup/VssStoreIdProvider.kt index 5f88f13a9..4f72b3245 100644 --- a/app/src/main/java/to/bitkit/data/backup/VssStoreIdProvider.kt +++ b/app/src/main/java/to/bitkit/data/backup/VssStoreIdProvider.kt @@ -2,6 +2,7 @@ package to.bitkit.data.backup import com.synonym.vssclient.vssDeriveStoreId import to.bitkit.data.keychain.Keychain +import to.bitkit.domain.models.useAsString import to.bitkit.env.Env import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError @@ -19,15 +20,18 @@ class VssStoreIdProvider @Inject constructor( synchronized(this) { cacheMap[walletIndex]?.let { return it } - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) + val mnemonicSecret = keychain.loadSecret(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound() - val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) - - val storeId = vssDeriveStoreId( - prefix = Env.vssStoreIdPrefix, - mnemonic = mnemonic, - passphrase = passphrase, - ) + val passphraseSecret = keychain.loadSecret(Keychain.Key.BIP39_PASSPHRASE.name) + + val storeId = mnemonicSecret.useAsString { mnemonic -> + vssDeriveStoreId( + prefix = Env.vssStoreIdPrefix, + mnemonic = mnemonic, + passphrase = passphraseSecret?.peek { String(it) }, + ) + } + passphraseSecret?.wipe() cacheMap[walletIndex] = storeId Logger.info("VSS store id setup for wallet[$walletIndex]: '$storeId'", context = TAG) diff --git a/app/src/main/java/to/bitkit/services/RNBackupClient.kt b/app/src/main/java/to/bitkit/services/RNBackupClient.kt index 8e2a8462f..01a46ccd9 100644 --- a/app/src/main/java/to/bitkit/services/RNBackupClient.kt +++ b/app/src/main/java/to/bitkit/services/RNBackupClient.kt @@ -21,6 +21,7 @@ import org.lightningdevkit.ldknode.Network import org.lightningdevkit.ldknode.deriveNodeSecretFromMnemonic import to.bitkit.data.keychain.Keychain import to.bitkit.di.IoDispatcher +import to.bitkit.domain.models.useAsString import to.bitkit.env.Env import to.bitkit.ext.toHex import to.bitkit.utils.AppError @@ -56,10 +57,15 @@ class RNBackupClient @Inject constructor( suspend fun listFiles(fileGroup: String? = "ldk"): RNBackupListResponse? = withContext(ioDispatcher) { runCatching { - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw RNBackupError.NotSetup() - val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) + val mnemonicSecret = keychain.loadSecret(Keychain.Key.BIP39_MNEMONIC.name) + ?: throw RNBackupError.NotSetup() + val passphraseSecret = keychain.loadSecret(Keychain.Key.BIP39_PASSPHRASE.name) + + val bearer = mnemonicSecret.useAsString { mnemonic -> + authenticate(mnemonic, passphraseSecret?.peek { String(it) }) + } + passphraseSecret?.wipe() - val bearer = authenticate(mnemonic, passphrase) val url = buildUrl("list", fileGroup = fileGroup, network = getNetworkString()) val response: HttpResponse = httpClient.get(url) { header("Authorization", bearer.bearer) @@ -75,10 +81,18 @@ class RNBackupClient @Inject constructor( suspend fun retrieve(label: String, fileGroup: String? = null): ByteArray? = withContext(ioDispatcher) { runCatching { - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw RNBackupError.NotSetup() - val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) + val mnemonicSecret = keychain.loadSecret(Keychain.Key.BIP39_MNEMONIC.name) + ?: throw RNBackupError.NotSetup() + val passphraseSecret = keychain.loadSecret(Keychain.Key.BIP39_PASSPHRASE.name) + + val (bearer, encryptionKey) = mnemonicSecret.useAsString { mnemonic -> + val passphraseStr = passphraseSecret?.peek { String(it) } + val auth = authenticate(mnemonic, passphraseStr) + val key = deriveEncryptionKey(mnemonic, passphraseStr) + auth to key + } + passphraseSecret?.wipe() - val bearer = authenticate(mnemonic, passphrase) val url = buildUrl("retrieve", label = label, fileGroup = fileGroup, network = getNetworkString()) val response: HttpResponse = httpClient.get(url) { header("Authorization", bearer.bearer) @@ -89,8 +103,6 @@ class RNBackupClient @Inject constructor( val encryptedData = response.body() if (encryptedData.isEmpty()) throw RNBackupError.RequestFailed("Retrieved data is empty") - val encryptionKey = deriveEncryptionKey(mnemonic, passphrase) - decrypt(encryptedData, encryptionKey).also { if (it.isEmpty()) throw RNBackupError.DecryptFailed("Decrypted data is empty") } @@ -101,10 +113,18 @@ class RNBackupClient @Inject constructor( suspend fun retrieveChannelMonitor(channelId: String): ByteArray? = withContext(ioDispatcher) { runCatching { - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw RNBackupError.NotSetup() - val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) + val mnemonicSecret = keychain.loadSecret(Keychain.Key.BIP39_MNEMONIC.name) + ?: throw RNBackupError.NotSetup() + val passphraseSecret = keychain.loadSecret(Keychain.Key.BIP39_PASSPHRASE.name) + + val (bearer, encryptionKey) = mnemonicSecret.useAsString { mnemonic -> + val passphraseStr = passphraseSecret?.peek { String(it) } + val auth = authenticate(mnemonic, passphraseStr) + val key = deriveEncryptionKey(mnemonic, passphraseStr) + auth to key + } + passphraseSecret?.wipe() - val bearer = authenticate(mnemonic, passphrase) val url = buildUrl( method = "retrieve", label = "channel_monitor", @@ -121,7 +141,6 @@ class RNBackupClient @Inject constructor( val encryptedData = response.body() if (encryptedData.isEmpty()) throw RNBackupError.RequestFailed("Retrieved data is empty") - val encryptionKey = deriveEncryptionKey(mnemonic, passphrase) decrypt(encryptedData, encryptionKey).also { if (it.isEmpty()) throw RNBackupError.DecryptFailed("Decrypted data is empty") } From 8143937e27a913432db4247eb68f80e0600cf8e2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Feb 2026 23:00:13 +0100 Subject: [PATCH 09/20] refactor: secure push notification private keys Co-Authored-By: Claude Opus 4.5 --- app/src/main/java/to/bitkit/fcm/FcmService.kt | 7 +++++++ .../java/to/bitkit/services/LspNotificationsService.kt | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/app/src/main/java/to/bitkit/fcm/FcmService.kt b/app/src/main/java/to/bitkit/fcm/FcmService.kt index 2e4a737b3..f30819e0a 100644 --- a/app/src/main/java/to/bitkit/fcm/FcmService.kt +++ b/app/src/main/java/to/bitkit/fcm/FcmService.kt @@ -107,15 +107,22 @@ class FcmService : FirebaseMessagingService() { } val password = runCatching { crypto.generateSharedSecret(privateKey, response.publicKey, derivationName) }.getOrElse { + privateKey.fill(0) // Wipe on error path Logger.error("Failed to generate shared secret", it, context = TAG) return } + // Wipe private key after use + privateKey.fill(0) + val decrypted = crypto.decrypt( encryptedPayload = Crypto.EncryptedPayload(ciphertext, response.iv.fromHex(), response.tag.fromHex()), secretKey = password, ) + // Wipe password after use + password.fill(0) + val decoded = decrypted.decodeToString() Logger.debug("Decrypted payload: $decoded", context = TAG) diff --git a/app/src/main/java/to/bitkit/services/LspNotificationsService.kt b/app/src/main/java/to/bitkit/services/LspNotificationsService.kt index 955b775d9..060cc9fe5 100644 --- a/app/src/main/java/to/bitkit/services/LspNotificationsService.kt +++ b/app/src/main/java/to/bitkit/services/LspNotificationsService.kt @@ -43,6 +43,10 @@ class LspNotificationsService @Inject constructor( } keychain.save(Key.PUSH_NOTIFICATION_PRIVATE_KEY.name, keypair.privateKey) + // Wipe keypair from memory after storage + keypair.privateKey.fill(0) + keypair.publicKey.fill(0) + ServiceQueue.CORE.background { com.synonym.bitkitcore.registerDevice( deviceToken = deviceToken, From 790d08185b4d1d178d66a5fbd62bfdc0833f4341 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Feb 2026 23:02:23 +0100 Subject: [PATCH 10/20] refactor: secure mnemonic display in ViewModels Co-Authored-By: Claude Opus 4.5 --- .../recovery/RecoveryMnemonicScreen.kt | 29 +++++++++----- .../recovery/RecoveryMnemonicViewModel.kt | 38 ++++++++++--------- .../backups/BackupNavSheetViewModel.kt | 19 ++++++++-- 3 files changed, 56 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryMnemonicScreen.kt b/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryMnemonicScreen.kt index 23087bc99..1ad3ec982 100644 --- a/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryMnemonicScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryMnemonicScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -22,6 +23,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import to.bitkit.R +import to.bitkit.domain.models.Secret +import to.bitkit.domain.models.secretOf import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.MnemonicWordsGrid @@ -54,6 +57,14 @@ private fun Content( uiState: RecoveryMnemonicUiState, onNavigateBack: () -> Unit, ) { + // Convert Secret words to Strings for display (using remember to minimize String creation) + val displayWords = remember(uiState.mnemonicWords) { + uiState.mnemonicWords.map { secret -> secret.peek { String(it) } } + } + val displayPassphrase = remember(uiState.passphrase) { + uiState.passphrase?.peek { String(it) }.orEmpty() + } + Column( modifier = Modifier .screen() @@ -89,7 +100,7 @@ private fun Content( BodyM( text = stringResource(R.string.security__mnemonic_write).replace( "{length}", - uiState.mnemonicWords.count().toString() + displayWords.count().toString() ), color = Colors.White64 ) @@ -105,13 +116,13 @@ private fun Content( .testTag("backup_mnemonic_words_box") ) { MnemonicWordsGrid( - actualWords = uiState.mnemonicWords, + actualWords = displayWords, showMnemonic = true, ) } // Passphrase section (if available) - if (uiState.passphrase.isNotEmpty()) { + if (displayPassphrase.isNotEmpty()) { VerticalSpacer(32.dp) Column { @@ -120,8 +131,8 @@ private fun Content( VerticalSpacer(16.dp) BodyM( - text = stringResource(R.string.security__pass_recovery, uiState.passphrase) - .replace("{passphrase}", uiState.passphrase) + text = stringResource(R.string.security__pass_recovery, displayPassphrase) + .replace("{passphrase}", displayPassphrase) .withAccent(accentColor = Colors.White64), color = Colors.White ) @@ -164,8 +175,8 @@ private fun ContentPreview12Words() { mnemonicWords = listOf( "abandon", "ability", "able", "about", "above", "absent", "absorb", "abstract", "absurd", "abuse", "access", "accident", - ), - passphrase = "my_secret_passphrase" + ).map { secretOf(it) }, + passphrase = secretOf("my_secret_passphrase") ), onNavigateBack = {}, ) @@ -184,8 +195,8 @@ private fun ContentPreview24Words() { "absorb", "abstract", "absurd", "abuse", "access", "accident", "account", "accuse", "achieve", "acid", "acoustic", "acquire", "across", "act", "action", "actor", "actress", "actual" - ), - passphrase = "my_secret_passphrase" + ).map { secretOf(it) }, + passphrase = secretOf("my_secret_passphrase") ), onNavigateBack = {}, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryMnemonicViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryMnemonicViewModel.kt index fd53d23a9..2c3e71156 100644 --- a/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryMnemonicViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryMnemonicViewModel.kt @@ -12,6 +12,9 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.data.keychain.Keychain +import to.bitkit.domain.models.Secret +import to.bitkit.domain.models.splitWords +import to.bitkit.domain.models.wipeAll import to.bitkit.models.Toast import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger @@ -33,15 +36,11 @@ class RecoveryMnemonicViewModel @Inject constructor( private fun loadMnemonic() { viewModelScope.launch { runCatching { - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name).orEmpty() - val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name).orEmpty() + val mnemonicSecret = keychain.loadSecret(Keychain.Key.BIP39_MNEMONIC.name) + val passphraseSecret = keychain.loadSecret(Keychain.Key.BIP39_PASSPHRASE.name) - if (mnemonic.isEmpty()) { - _uiState.update { - it.copy( - isLoading = false, - ) - } + if (mnemonicSecret == null) { + _uiState.update { it.copy(isLoading = false) } ToastEventBus.send( type = Toast.ToastType.ERROR, title = context.getString(R.string.security__mnemonic_load_error), @@ -50,27 +49,30 @@ class RecoveryMnemonicViewModel @Inject constructor( return@launch } - val mnemonicWords = mnemonic.split(" ").filter { it.isNotBlank() } + val mnemonicWordSecrets = mnemonicSecret.splitWords() + mnemonicSecret.wipe() _uiState.update { it.copy( isLoading = false, - mnemonicWords = mnemonicWords, - passphrase = passphrase, + mnemonicWords = mnemonicWordSecrets, + passphrase = passphraseSecret, ) } }.onFailure { e -> Logger.error("Failed to load mnemonic", e, context = TAG) - _uiState.update { - it.copy( - isLoading = false, - ) - } + _uiState.update { it.copy(isLoading = false) } ToastEventBus.send(e) } } } + override fun onCleared() { + super.onCleared() + _uiState.value.mnemonicWords.wipeAll() + _uiState.value.passphrase?.wipe() + } + private companion object { const val TAG = "RecoveryMnemonicViewModel" } @@ -78,6 +80,6 @@ class RecoveryMnemonicViewModel @Inject constructor( data class RecoveryMnemonicUiState( val isLoading: Boolean = true, - val mnemonicWords: List = emptyList(), - val passphrase: String = "", + val mnemonicWords: List = emptyList(), + val passphrase: Secret? = null, ) diff --git a/app/src/main/java/to/bitkit/ui/settings/backups/BackupNavSheetViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/backups/BackupNavSheetViewModel.kt index 3c41f83b5..850a1131d 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backups/BackupNavSheetViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backups/BackupNavSheetViewModel.kt @@ -17,6 +17,9 @@ import to.bitkit.R import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain +import to.bitkit.domain.models.Secret +import to.bitkit.domain.models.splitWords +import to.bitkit.domain.models.wipeAll import to.bitkit.models.BackupCategory import to.bitkit.models.HealthState import to.bitkit.models.Toast @@ -75,13 +78,23 @@ class BackupNavSheetViewModel @Inject constructor( fun loadMnemonicData() = viewModelScope.launch { runCatching { - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)!! // NPE handled with UI toast - val bip39Passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) ?: "" + val mnemonicSecret = keychain.loadSecret(Keychain.Key.BIP39_MNEMONIC.name) + ?: throw NullPointerException("Mnemonic not found") + val passphraseSecret = keychain.loadSecret(Keychain.Key.BIP39_PASSPHRASE.name) + + val mnemonicWordSecrets = mnemonicSecret.splitWords() + mnemonicSecret.wipe() + + val mnemonic = mnemonicWordSecrets.joinToString(" ") { it.peek { chars -> String(chars) } } + mnemonicWordSecrets.wipeAll() + + val passphrase = passphraseSecret?.peek { String(it) } ?: "" + passphraseSecret?.wipe() _uiState.update { it.copy( bip39Mnemonic = mnemonic, - bip39Passphrase = bip39Passphrase, + bip39Passphrase = passphrase, ) } }.onFailure { From 098ef9b2f4dc9e211a072133919c2cb1d0a973e2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Feb 2026 23:03:00 +0100 Subject: [PATCH 11/20] refactor: secure PIN validation Co-Authored-By: Claude Opus 4.5 --- app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 47f8d7f36..a0fe4fe08 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -58,6 +58,7 @@ import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.data.resetPin import to.bitkit.di.BgDispatcher +import to.bitkit.domain.models.Secret import to.bitkit.domain.commands.NotifyPaymentReceived import to.bitkit.domain.commands.NotifyPaymentReceivedHandler import to.bitkit.env.Defaults @@ -2094,8 +2095,8 @@ class AppViewModel @Inject constructor( } fun validatePin(pin: String): Boolean { - val storedPin = keychain.loadString(Keychain.Key.PIN.name) - val isValid = storedPin == pin + val storedPinSecret: Secret? = keychain.loadSecret(Keychain.Key.PIN.name) + val isValid = storedPinSecret?.use { storedChars -> String(storedChars) == pin } ?: false if (isValid) { viewModelScope.launch { From 8feee58c54692c602b1071c6a1d388308a7a6fa8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Feb 2026 23:07:05 +0100 Subject: [PATCH 12/20] test: add Secret extensions tests Co-Authored-By: Claude Opus 4.5 --- .../java/to/bitkit/domain/models/Secret.kt | 2 +- .../recovery/RecoveryMnemonicScreen.kt | 1 - .../backups/BackupNavSheetViewModel.kt | 1 - .../bitkit/data/backup/VssBackupClientTest.kt | 9 +- .../to/bitkit/domain/models/SecretTest.kt | 210 ++++++++++++++++++ 5 files changed, 216 insertions(+), 7 deletions(-) create mode 100644 app/src/test/java/to/bitkit/domain/models/SecretTest.kt diff --git a/app/src/main/java/to/bitkit/domain/models/Secret.kt b/app/src/main/java/to/bitkit/domain/models/Secret.kt index 369ba702c..b21b9c6f1 100644 --- a/app/src/main/java/to/bitkit/domain/models/Secret.kt +++ b/app/src/main/java/to/bitkit/domain/models/Secret.kt @@ -22,7 +22,7 @@ class Secret internal constructor(initialValue: CharArray) : AutoCloseable { init { // Wipe the input array immediately after copying to reduce exposure - wipe(nullify = false) + initialValue.fill(WIPE_CHAR) } operator fun getValue(thisRef: Any?, property: KProperty<*>): CharArray { diff --git a/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryMnemonicScreen.kt b/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryMnemonicScreen.kt index 1ad3ec982..5c5c83918 100644 --- a/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryMnemonicScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryMnemonicScreen.kt @@ -23,7 +23,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import to.bitkit.R -import to.bitkit.domain.models.Secret import to.bitkit.domain.models.secretOf import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.FillHeight diff --git a/app/src/main/java/to/bitkit/ui/settings/backups/BackupNavSheetViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/backups/BackupNavSheetViewModel.kt index 850a1131d..845c29380 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backups/BackupNavSheetViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backups/BackupNavSheetViewModel.kt @@ -17,7 +17,6 @@ import to.bitkit.R import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain -import to.bitkit.domain.models.Secret import to.bitkit.domain.models.splitWords import to.bitkit.domain.models.wipeAll import to.bitkit.models.BackupCategory diff --git a/app/src/test/java/to/bitkit/data/backup/VssBackupClientTest.kt b/app/src/test/java/to/bitkit/data/backup/VssBackupClientTest.kt index 2c4d00216..b953ac01a 100644 --- a/app/src/test/java/to/bitkit/data/backup/VssBackupClientTest.kt +++ b/app/src/test/java/to/bitkit/data/backup/VssBackupClientTest.kt @@ -9,6 +9,7 @@ import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.keychain.Keychain +import to.bitkit.domain.models.secretOf import to.bitkit.test.BaseUnitTest import kotlin.test.assertIs import kotlin.test.assertTrue @@ -31,7 +32,7 @@ class VssBackupClientTest : BaseUnitTest() { @Test fun `setup fails with MnemonicNotAvailableException when mnemonic is not available`() = test { - whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(null) + whenever(keychain.loadSecret(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(null) val result = sut.setup() @@ -41,7 +42,7 @@ class VssBackupClientTest : BaseUnitTest() { @Test fun `setup does not call vssStoreIdProvider when mnemonic is not available`() = test { - whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(null) + whenever(keychain.loadSecret(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(null) sut.setup() @@ -52,7 +53,7 @@ class VssBackupClientTest : BaseUnitTest() { fun `setup checks mnemonic before proceeding with vss initialization`() = test { val testMnemonic = "abandon abandon abandon abandon abandon abandon " + "abandon abandon abandon abandon abandon about" - whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(testMnemonic) + whenever(keychain.loadSecret(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(secretOf(testMnemonic)) whenever(vssStoreIdProvider.getVssStoreId(any())).thenReturn("test-store-id") // Setup will fail on native VSS calls, but we verify we passed the mnemonic check @@ -63,7 +64,7 @@ class VssBackupClientTest : BaseUnitTest() { @Test fun `setup can be called multiple times when mnemonic not available`() = test { - whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(null) + whenever(keychain.loadSecret(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(null) // Multiple calls should all fail with MnemonicNotAvailableException without crashing assertIs(sut.setup().exceptionOrNull()) diff --git a/app/src/test/java/to/bitkit/domain/models/SecretTest.kt b/app/src/test/java/to/bitkit/domain/models/SecretTest.kt new file mode 100644 index 000000000..9f6595956 --- /dev/null +++ b/app/src/test/java/to/bitkit/domain/models/SecretTest.kt @@ -0,0 +1,210 @@ +package to.bitkit.domain.models + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SecretTest { + + // region secretOrNull + @Test + fun `secretOrNull returns null for null input`() { + val result = secretOrNull(null) + assertNull(result) + } + + @Test + fun `secretOrNull returns Secret for non-null input`() { + val result = secretOrNull("test") + result!!.use { chars -> + assertEquals("test", String(chars)) + } + } + // endregion + + // region useAsString + @Test + fun `useAsString converts to String and wipes after use`() { + val secret = secretOf("mySecret") + val result = secret.useAsString { it.uppercase() } + assertEquals("MYSECRET", result) + + // Secret should be wiped + assertFailsWith { secret.peek { } } + } + + @Test + fun `useAsString provides correct String value`() { + val secret = secretOf("hello world") + secret.useAsString { str -> + assertEquals("hello world", str) + } + } + // endregion + + // region splitWords + @Test + fun `splitWords creates separate Secret instances for each word`() { + val mnemonic = secretOf("word1 word2 word3") + val words = mnemonic.splitWords() + + assertEquals(3, words.size) + assertEquals("word1", words[0].peek { String(it) }) + assertEquals("word2", words[1].peek { String(it) }) + assertEquals("word3", words[2].peek { String(it) }) + + // Clean up + words.wipeAll() + mnemonic.wipe() + } + + @Test + fun `splitWords handles multiple spaces`() { + val mnemonic = secretOf("word1 word2 word3") + val words = mnemonic.splitWords() + + assertEquals(3, words.size) + words.wipeAll() + mnemonic.wipe() + } + + @Test + fun `splitWords does not wipe original`() { + val mnemonic = secretOf("word1 word2") + mnemonic.splitWords() + + // Original should still be accessible + mnemonic.peek { chars -> + assertEquals("word1 word2", String(chars)) + } + mnemonic.wipe() + } + // endregion + + // region wipeAll + @Test + fun `wipeAll wipes all secrets in list`() { + val secrets = listOf( + secretOf("secret1"), + secretOf("secret2"), + secretOf("secret3"), + ) + + secrets.wipeAll() + + secrets.forEach { secret -> + assertFailsWith { secret.peek { } } + } + } + + @Test + fun `wipeAll is safe to call on empty list`() { + val emptyList = emptyList() + emptyList.wipeAll() // Should not throw + } + + @Test + fun `wipeAll is safe to call multiple times`() { + val secrets = listOf(secretOf("test")) + secrets.wipeAll() + secrets.wipeAll() // Should not throw + } + // endregion + + // region Source array wiping + @Test + fun `secretOf wipes source CharArray`() { + val source = "secret".toCharArray() + secretOf(source) + + // Source should be wiped (all null chars) + assertTrue(source.all { it == '\u0000' }) + } + + @Test + fun `secret factory wipes source CharArray`() { + val source = "secret".toCharArray() + secret(source) + + assertTrue(source.all { it == '\u0000' }) + } + // endregion + + // region use and wipe + @Test + fun `use wipes data after block completes`() { + val secret = secretOf("test") + secret.use { chars -> + assertEquals("test", String(chars)) + } + + assertFailsWith { secret.peek { } } + } + + @Test + fun `peek does not wipe data`() { + val secret = secretOf("test") + val first = secret.peek { String(it) } + val second = secret.peek { String(it) } + + assertEquals(first, second) + secret.wipe() + } + + @Test + fun `wipe can be called multiple times safely`() { + val secret = secretOf("test") + secret.wipe() + secret.wipe() // Should not throw + } + // endregion + + // region withSecret extension + @Test + fun `withSecret wipes CharArray after block`() { + var capturedChars: CharArray? = null + + "mySecret".withSecret { chars -> + capturedChars = chars.copyOf() + assertEquals("mySecret", String(chars)) + } + + // We can't check the original chars directly, but we verified the block received correct data + assertEquals("mySecret", String(capturedChars!!)) + } + // endregion + + // region withSecretChars extension + @Test + fun `withSecretChars provides original string to block`() { + "mySecret".withSecretChars { str -> + assertEquals("mySecret", str) + } + } + + @Test + fun `withSecretChars returns block result`() { + val result = "hello".withSecretChars { it.length } + assertEquals(5, result) + } + // endregion + + // region Secret constructor behavior + @Test + fun `Secret stores copy of data`() { + val original = "test".toCharArray() + val secret = secretOf(original) + + // Modifying what was the original shouldn't affect the secret + // (original is already wiped, but let's verify the secret has its own copy) + secret.peek { chars -> + assertNotEquals(original, chars) // Different array reference + assertEquals("test", String(chars)) + } + secret.wipe() + } + // endregion +} From 14a282bc8567b596ef2c9b959a020364cc826947 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Feb 2026 13:34:07 +0100 Subject: [PATCH 13/20] feat: use Secret model in keychain Co-Authored-By: Claude Opus 4.6 --- .../java/to/bitkit/data/keychain/Keychain.kt | 64 ++++-- .../java/to/bitkit/domain/models/Secret.kt | 139 ++---------- .../java/to/bitkit/repositories/WalletRepo.kt | 10 +- .../to/bitkit/services/MigrationService.kt | 9 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 5 +- .../to/bitkit/domain/models/SecretTest.kt | 210 ------------------ .../to/bitkit/repositories/WalletRepoTest.kt | 14 +- 7 files changed, 79 insertions(+), 372 deletions(-) delete mode 100644 app/src/test/java/to/bitkit/domain/models/SecretTest.kt diff --git a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt index 044fb7e6d..9b96722db 100644 --- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt +++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt @@ -21,6 +21,8 @@ import to.bitkit.ext.fromBase64 import to.bitkit.ext.toBase64 import to.bitkit.utils.AppError import to.bitkit.utils.Logger +import java.nio.ByteBuffer +import java.nio.CharBuffer import javax.inject.Inject import javax.inject.Singleton @@ -28,6 +30,7 @@ private val Context.keychainDataStore: DataStore by preferencesData name = "keychain" ) +@Suppress("TooManyFunctions") @Singleton class Keychain @Inject constructor( private val db: AppDb, @@ -43,56 +46,63 @@ class Keychain @Inject constructor( fun loadString(key: String): String? = load(key)?.decodeToString() - /** Load a keychain value as a [Secret] for deterministic cleanup. */ - fun loadSecret(key: String): Secret? = loadString(key)?.let { secretOf(it) } - - /** Load raw keychain bytes as a [Secret] for deterministic cleanup. */ - fun loadSecretBytes(key: String): Secret? = - load(key)?.let { bytes -> secretOf(bytes.map { it.toInt().toChar() }.toCharArray()) } + fun loadSecret(key: String): Secret? { + val bytes = load(key) ?: return null + val chars = bytes.decodeToCharArray() + bytes.fill(0) + return secretOf(chars) + } - @Suppress("TooGenericExceptionCaught", "SwallowedException") fun load(key: String): ByteArray? { - try { - return snapshot[key.indexed]?.fromBase64()?.let { + return runCatching { + snapshot[key.indexed]?.fromBase64()?.let { keyStore.decrypt(it) } - } catch (_: Exception) { + }.getOrElse { throw KeychainError.FailedToLoad(key) } } suspend fun saveString(key: String, value: String) = save(key, value.toByteArray()) - @Suppress("TooGenericExceptionCaught", "SwallowedException") + suspend fun saveSecret(key: String, value: Secret) = value.use { + val bytes = it.encodeToByteArray() + try { save(key, bytes) } finally { bytes.fill(0) } + } + suspend fun save(key: String, value: ByteArray) { if (exists(key)) throw KeychainError.FailedToSaveAlreadyExists(key) - try { + runCatching { val encryptedValue = keyStore.encrypt(value) keychain.edit { it[key.indexed] = encryptedValue.toBase64() } - } catch (_: Exception) { + }.onFailure { throw KeychainError.FailedToSave(key) } Logger.info("Saved to keychain: $key") } - /** Inserts or replaces a string value associated with a given key in the keychain. */ - @Suppress("TooGenericExceptionCaught", "SwallowedException") - suspend fun upsertString(key: String, value: String) { - try { - val encryptedValue = keyStore.encrypt(value.toByteArray()) + suspend fun upsertString(key: String, value: String) = upsert(key, value.toByteArray()) + + suspend fun upsertSecret(key: String, value: Secret) = value.use { chars -> + val bytes = chars.encodeToByteArray() + try { upsert(key, bytes) } finally { bytes.fill(0) } + } + + suspend fun upsert(key: String, value: ByteArray) { + runCatching { + val encryptedValue = keyStore.encrypt(value) keychain.edit { it[key.indexed] = encryptedValue.toBase64() } - } catch (_: Exception) { + }.onFailure { throw KeychainError.FailedToSave(key) } Logger.info("Upsert in keychain: $key") } - @Suppress("TooGenericExceptionCaught", "SwallowedException") suspend fun delete(key: String) { - try { + runCatching { keychain.edit { it.remove(key.indexed) } - } catch (_: Exception) { + }.onFailure { throw KeychainError.FailedToDelete(key) } Logger.debug("Deleted from keychain: $key") @@ -129,6 +139,16 @@ class Keychain @Inject constructor( .map { string -> string?.toIntOrNull() } } + private fun ByteArray.decodeToCharArray(): CharArray { + val charBuffer = Charsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(this)) + return CharArray(charBuffer.remaining()).also { charBuffer.get(it) } + } + + private fun CharArray.encodeToByteArray(): ByteArray { + val byteBuffer = Charsets.UTF_8.newEncoder().encode(CharBuffer.wrap(this)) + return ByteArray(byteBuffer.remaining()).also { byteBuffer.get(it) } + } + enum class Key { PUSH_NOTIFICATION_TOKEN, PUSH_NOTIFICATION_PRIVATE_KEY, diff --git a/app/src/main/java/to/bitkit/domain/models/Secret.kt b/app/src/main/java/to/bitkit/domain/models/Secret.kt index b21b9c6f1..89e7e2ba7 100644 --- a/app/src/main/java/to/bitkit/domain/models/Secret.kt +++ b/app/src/main/java/to/bitkit/domain/models/Secret.kt @@ -1,5 +1,3 @@ -@file:Suppress("TooManyFunctions") - package to.bitkit.domain.models import kotlin.reflect.KProperty @@ -7,43 +5,32 @@ import kotlin.reflect.KProperty private const val WIPE_CHAR = '\u0000' /** - * A property delegate that stores sensitive data in a [CharArray] and provides APIs to safely wipe it from memory. + * A wrapper that stores sensitive data in a [CharArray] and provides APIs to safely wipe it from memory. * - * ALWAYS process the wrapped value through [use] blocks API for auto cleanup, as it implements [AutoCloseable]. + * ALWAYS access the wrapped value inside [use] blocks for auto cleanup. */ class Secret internal constructor(initialValue: CharArray) : AutoCloseable { companion object { - @PublishedApi - internal const val ERR_WIPED = "Secret has already been wiped." + const val ERR_WIPED = "Secret has already been wiped." } @PublishedApi internal var data: CharArray? = initialValue.copyOf() init { - // Wipe the input array immediately after copying to reduce exposure - initialValue.fill(WIPE_CHAR) + initialValue.wipe() } - operator fun getValue(thisRef: Any?, property: KProperty<*>): CharArray { + internal operator fun getValue(thisRef: Any?, property: KProperty<*>): CharArray { return checkNotNull(data) { ERR_WIPED } } - operator fun setValue(thisRef: Any?, property: KProperty<*>, value: CharArray) { + internal operator fun setValue(thisRef: Any?, property: KProperty<*>, value: CharArray) { wipe(nullify = false) data = value.copyOf() - // Wipe the source array - value.fill(WIPE_CHAR) + value.wipe() } - /** - * Temporarily access the underlying data, then automatically wipe it. - * - * ``` - * secret("myToken".toCharArray()).use { authenticate(it) } - * // chars are wiped here - * ``` - */ inline fun use(block: (CharArray) -> R): R { try { return block(checkNotNull(data) { ERR_WIPED }) @@ -52,119 +39,25 @@ class Secret internal constructor(initialValue: CharArray) : AutoCloseable { } } - /** - * Access the data without wiping afterwards. - * Useful when you need multiple reads before an explicit [wipe]. - * - * ```kotlin - * mySecret.peek { chars -> hash(chars) }` - * // data is still alive here - * ``` - */ - inline fun peek(block: (CharArray) -> R): R { - return block(checkNotNull(data) { ERR_WIPED }) - } + inline fun peek(block: (CharArray) -> R): R = block(checkNotNull(data) { ERR_WIPED }) - /** - * Zero-out the backing memory and optionally nullify the reference by default. - * Safe to call multiple times. - */ fun wipe(nullify: Boolean = true) { - data?.fill(WIPE_CHAR) + data?.wipe() if (nullify) data = null } - /** Alias for [wipe] to satisfy [AutoCloseable]. */ - override fun close() = wipe() -} - -/** Create a [Secret] from a [CharArray]. The source array is wiped. */ -fun secretOf(initialValue: CharArray) = Secret(initialValue) + fun CharArray.wipe() = this.fill(WIPE_CHAR) -/** Create a [Secret] from a [String]. The string can't be wiped from - * the JVM string pool, so prefer [CharArray] overloads where possible. */ -fun secretOf(value: String): Secret { - val chars = value.toCharArray() - return Secret(chars) // constructor wipes `chars` + override fun close() = wipe() } -/** - * Convert a [String] to a [Secret]. - * - * ⚠️ The original [String] remains in the JVM string pool and cannot be wiped. - * Prefer constructing from [CharArray] literals where possible. - */ -internal fun String.toSecret(): Secret = secretOf(this) - -/** - * Convert a [CharArray] to a [Secret]. The source array is wiped. - */ -internal fun CharArray.toSecret(): Secret = secretOf(this) - -/** Nullable factory for Keychain integration - creates a [Secret] only if value is non-null. */ -fun secretOrNull(value: String?): Secret? = value?.let { secretOf(it) } - -/** Create a [Secret] from a [CharArray]. Alias for [secretOf]. */ -fun secret(chars: CharArray): Secret = secretOf(chars) +fun secretOf(value: CharArray) = Secret(value) -/** Create a [Secret] from a [String]. Alias for [secretOf]. */ -fun secret(value: String): Secret = secretOf(value) +fun secretOf(value: String): Secret = Secret(value.toCharArray()) -/** - * Convert the [Secret] to a [String] for FFI boundary, execute the block, then wipe. - * - * ⚠️ Use only at FFI boundaries where RUST requires String params. - * The temporary String cannot be wiped from JVM memory. - */ -inline fun Secret.useAsString(block: (String) -> R): R = - use { chars -> block(String(chars)) } +internal inline fun Secret.useAsString(block: (String) -> R): R = use { block(String(it)) } -/** - * Split a mnemonic [Secret] into individual [Secret] words. - * The original secret is NOT wiped - caller is responsible for calling [wipe] after use. - */ -fun Secret.splitWords(): List = +internal fun Secret.splitWords(): List = peek { chars -> String(chars).split(" ").filter { it.isNotBlank() }.map { secretOf(it) } } -/** Wipe all [Secret] instances in a list. Safe to call multiple times. */ -fun List.wipeAll() = forEach { it.wipe() } - -/** - * Convert a [String] to a secure [CharArray], pass it to [block], then wipe it, all without storing a property. - * - * ``` - * "temporarySecret".withSecret { chars -> - * sendOverTls(chars) - * } - * ``` - */ -internal inline fun String.withSecret(block: (CharArray) -> R): R { - val chars = toCharArray() - try { - return block(chars) - } finally { - chars.fill(WIPE_CHAR) - } -} - -/** - * Execute a block with the string value, then wipe an internal [CharArray] representation. - * This is useful when you need to pass a String to an API but want to minimize exposure. - * - * ⚠️ The original [String] cannot be wiped from JVM memory. This helps with - * intermediate CharArray representations. - * - * ``` - * userInput.withSecretChars { str -> - * keychain.saveString(key, str) - * } - * ``` - */ -internal inline fun String.withSecretChars(block: (String) -> R): R { - val chars = toCharArray() - try { - return block(this) - } finally { - chars.fill(WIPE_CHAR) - } -} +internal fun List.wipeAll() = forEach { it.wipe() } diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index f4d0b3486..1ae218ec3 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -21,8 +21,8 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher +import to.bitkit.domain.models.secretOf import to.bitkit.domain.models.useAsString -import to.bitkit.domain.models.withSecretChars import to.bitkit.env.Env import to.bitkit.ext.filterOpen import to.bitkit.ext.nowTimestamp @@ -284,8 +284,8 @@ class WalletRepo @Inject constructor( lightningRepo.setRecoveryMode(enabled = false) runCatching { val mnemonic = generateEntropyMnemonic() - mnemonic.withSecretChars { keychain.saveString(Keychain.Key.BIP39_MNEMONIC.name, it) } - bip39Passphrase?.withSecretChars { keychain.saveString(Keychain.Key.BIP39_PASSPHRASE.name, it) } + keychain.saveSecret(Keychain.Key.BIP39_MNEMONIC.name, secretOf(mnemonic)) + bip39Passphrase?.let { keychain.saveSecret(Keychain.Key.BIP39_PASSPHRASE.name, secretOf(it)) } setWalletExistsState() }.onFailure { Logger.error("createWallet error", it, context = TAG) @@ -295,8 +295,8 @@ class WalletRepo @Inject constructor( suspend fun restoreWallet(mnemonic: String, bip39Passphrase: String?): Result = withContext(bgDispatcher) { lightningRepo.setRecoveryMode(enabled = false) runCatching { - mnemonic.withSecretChars { keychain.saveString(Keychain.Key.BIP39_MNEMONIC.name, it) } - bip39Passphrase?.withSecretChars { keychain.saveString(Keychain.Key.BIP39_PASSPHRASE.name, it) } + keychain.saveSecret(Keychain.Key.BIP39_MNEMONIC.name, secretOf(mnemonic)) + bip39Passphrase?.let { keychain.saveSecret(Keychain.Key.BIP39_PASSPHRASE.name, secretOf(it)) } setWalletExistsState() }.onFailure { Logger.error("restoreWallet error", it, context = TAG) diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index 676c38cbe..b75bab53b 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -42,6 +42,7 @@ import to.bitkit.data.entities.TransferEntity import to.bitkit.data.keychain.Keychain import to.bitkit.data.resetPin import to.bitkit.di.json +import to.bitkit.domain.models.secretOf import to.bitkit.env.Env import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.CoinSelectionPreference @@ -493,13 +494,13 @@ class MigrationService @Inject constructor( ) } - keychain.saveString(Keychain.Key.BIP39_MNEMONIC.name, mnemonic) + keychain.saveSecret(Keychain.Key.BIP39_MNEMONIC.name, secretOf(mnemonic)) } private suspend fun migratePassphrase() { val passphrase = loadStringFromRNKeychain(RNKeychainKey.PASSPHRASE) if (passphrase.isNullOrEmpty()) return - keychain.saveString(Keychain.Key.BIP39_PASSPHRASE.name, passphrase) + keychain.saveSecret(Keychain.Key.BIP39_PASSPHRASE.name, secretOf(passphrase)) } private suspend fun migratePin() { @@ -519,7 +520,7 @@ class MigrationService @Inject constructor( return } - keychain.saveString(Keychain.Key.PIN.name, pin) + keychain.saveSecret(Keychain.Key.PIN.name, secretOf(pin)) } private fun migrateLdkData() = runCatching { @@ -1446,7 +1447,7 @@ class MigrationService @Inject constructor( } } - @Suppress("LongMethod", "CyclomaticComplexMethod") + @Suppress("LongMethod", "CyclomaticComplexMethod", "NestedBlockDepth") suspend fun reapplyMetadataAfterSync() { loadPersistedMigrationData() diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index a0fe4fe08..047876c9a 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -58,9 +58,10 @@ import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.data.resetPin import to.bitkit.di.BgDispatcher -import to.bitkit.domain.models.Secret import to.bitkit.domain.commands.NotifyPaymentReceived import to.bitkit.domain.commands.NotifyPaymentReceivedHandler +import to.bitkit.domain.models.Secret +import to.bitkit.domain.models.secretOf import to.bitkit.env.Defaults import to.bitkit.env.Env import to.bitkit.ext.WatchResult @@ -2132,7 +2133,7 @@ class AppViewModel @Inject constructor( fun editPin(newPin: String) { viewModelScope.launch(bgDispatcher) { settingsStore.update { it.copy(isPinEnabled = true) } - keychain.upsertString(Keychain.Key.PIN.name, newPin) + keychain.upsertSecret(Keychain.Key.PIN.name, secretOf(newPin)) keychain.upsertString(Keychain.Key.PIN_ATTEMPTS_REMAINING.name, Env.PIN_ATTEMPTS.toString()) } } diff --git a/app/src/test/java/to/bitkit/domain/models/SecretTest.kt b/app/src/test/java/to/bitkit/domain/models/SecretTest.kt deleted file mode 100644 index 9f6595956..000000000 --- a/app/src/test/java/to/bitkit/domain/models/SecretTest.kt +++ /dev/null @@ -1,210 +0,0 @@ -package to.bitkit.domain.models - -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertNotEquals -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class SecretTest { - - // region secretOrNull - @Test - fun `secretOrNull returns null for null input`() { - val result = secretOrNull(null) - assertNull(result) - } - - @Test - fun `secretOrNull returns Secret for non-null input`() { - val result = secretOrNull("test") - result!!.use { chars -> - assertEquals("test", String(chars)) - } - } - // endregion - - // region useAsString - @Test - fun `useAsString converts to String and wipes after use`() { - val secret = secretOf("mySecret") - val result = secret.useAsString { it.uppercase() } - assertEquals("MYSECRET", result) - - // Secret should be wiped - assertFailsWith { secret.peek { } } - } - - @Test - fun `useAsString provides correct String value`() { - val secret = secretOf("hello world") - secret.useAsString { str -> - assertEquals("hello world", str) - } - } - // endregion - - // region splitWords - @Test - fun `splitWords creates separate Secret instances for each word`() { - val mnemonic = secretOf("word1 word2 word3") - val words = mnemonic.splitWords() - - assertEquals(3, words.size) - assertEquals("word1", words[0].peek { String(it) }) - assertEquals("word2", words[1].peek { String(it) }) - assertEquals("word3", words[2].peek { String(it) }) - - // Clean up - words.wipeAll() - mnemonic.wipe() - } - - @Test - fun `splitWords handles multiple spaces`() { - val mnemonic = secretOf("word1 word2 word3") - val words = mnemonic.splitWords() - - assertEquals(3, words.size) - words.wipeAll() - mnemonic.wipe() - } - - @Test - fun `splitWords does not wipe original`() { - val mnemonic = secretOf("word1 word2") - mnemonic.splitWords() - - // Original should still be accessible - mnemonic.peek { chars -> - assertEquals("word1 word2", String(chars)) - } - mnemonic.wipe() - } - // endregion - - // region wipeAll - @Test - fun `wipeAll wipes all secrets in list`() { - val secrets = listOf( - secretOf("secret1"), - secretOf("secret2"), - secretOf("secret3"), - ) - - secrets.wipeAll() - - secrets.forEach { secret -> - assertFailsWith { secret.peek { } } - } - } - - @Test - fun `wipeAll is safe to call on empty list`() { - val emptyList = emptyList() - emptyList.wipeAll() // Should not throw - } - - @Test - fun `wipeAll is safe to call multiple times`() { - val secrets = listOf(secretOf("test")) - secrets.wipeAll() - secrets.wipeAll() // Should not throw - } - // endregion - - // region Source array wiping - @Test - fun `secretOf wipes source CharArray`() { - val source = "secret".toCharArray() - secretOf(source) - - // Source should be wiped (all null chars) - assertTrue(source.all { it == '\u0000' }) - } - - @Test - fun `secret factory wipes source CharArray`() { - val source = "secret".toCharArray() - secret(source) - - assertTrue(source.all { it == '\u0000' }) - } - // endregion - - // region use and wipe - @Test - fun `use wipes data after block completes`() { - val secret = secretOf("test") - secret.use { chars -> - assertEquals("test", String(chars)) - } - - assertFailsWith { secret.peek { } } - } - - @Test - fun `peek does not wipe data`() { - val secret = secretOf("test") - val first = secret.peek { String(it) } - val second = secret.peek { String(it) } - - assertEquals(first, second) - secret.wipe() - } - - @Test - fun `wipe can be called multiple times safely`() { - val secret = secretOf("test") - secret.wipe() - secret.wipe() // Should not throw - } - // endregion - - // region withSecret extension - @Test - fun `withSecret wipes CharArray after block`() { - var capturedChars: CharArray? = null - - "mySecret".withSecret { chars -> - capturedChars = chars.copyOf() - assertEquals("mySecret", String(chars)) - } - - // We can't check the original chars directly, but we verified the block received correct data - assertEquals("mySecret", String(capturedChars!!)) - } - // endregion - - // region withSecretChars extension - @Test - fun `withSecretChars provides original string to block`() { - "mySecret".withSecretChars { str -> - assertEquals("mySecret", str) - } - } - - @Test - fun `withSecretChars returns block result`() { - val result = "hello".withSecretChars { it.length } - assertEquals(5, result) - } - // endregion - - // region Secret constructor behavior - @Test - fun `Secret stores copy of data`() { - val original = "test".toCharArray() - val secret = secretOf(original) - - // Modifying what was the original shouldn't affect the secret - // (original is already wiped, but let's verify the secret has its own copy) - secret.peek { chars -> - assertNotEquals(original, chars) // Different array reference - assertEquals("test", String(chars)) - } - secret.wipe() - } - // endregion -} diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index c0b4e19d4..fba73ecc0 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -12,6 +12,7 @@ import org.lightningdevkit.ldknode.Event import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -21,6 +22,7 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain +import to.bitkit.domain.models.Secret import to.bitkit.models.BalanceState import to.bitkit.services.CoreService import to.bitkit.services.OnchainService @@ -146,30 +148,30 @@ class WalletRepoTest : BaseUnitTest() { fun `restoreWallet should save provided mnemonic and passphrase to keychain`() = test { val mnemonic = "restore mnemonic" val passphrase = "restore passphrase" - whenever(keychain.saveString(any(), any())).thenReturn(Unit) + whenever(keychain.saveSecret(any(), any())).thenReturn(Unit) val result = sut.restoreWallet(mnemonic, passphrase) assertTrue(result.isSuccess) - verify(keychain).saveString(Keychain.Key.BIP39_MNEMONIC.name, mnemonic) - verify(keychain).saveString(Keychain.Key.BIP39_PASSPHRASE.name, passphrase) + verify(keychain).saveSecret(eq(Keychain.Key.BIP39_MNEMONIC.name), any()) + verify(keychain).saveSecret(eq(Keychain.Key.BIP39_PASSPHRASE.name), any()) } @Test fun `restoreWallet should work without passphrase`() = test { val mnemonic = "restore mnemonic" - whenever(keychain.saveString(any(), any())).thenReturn(Unit) + whenever(keychain.saveSecret(any(), any())).thenReturn(Unit) val result = sut.restoreWallet(mnemonic, null) assertTrue(result.isSuccess) - verify(keychain).saveString(Keychain.Key.BIP39_MNEMONIC.name, mnemonic) + verify(keychain).saveSecret(eq(Keychain.Key.BIP39_MNEMONIC.name), any()) } @Test fun `restoreWallet should not call settingsStore`() = test { val mnemonic = "restore mnemonic" - whenever(keychain.saveString(any(), any())).thenReturn(Unit) + whenever(keychain.saveSecret(any(), any())).thenReturn(Unit) val result = sut.restoreWallet(mnemonic, null) From 2539857c30c5316b0e92a9ec2fd27cc9bfc6d54f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 8 Feb 2026 12:42:09 +0100 Subject: [PATCH 14/20] refactor: secure secrets in backup UI state Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- .../backups/BackupNavSheetViewModel.kt | 34 ++++++++++--------- .../settings/backups/ConfirmMnemonicScreen.kt | 4 +-- .../backups/ConfirmPassphraseScreen.kt | 2 +- .../ui/settings/backups/ShowMnemonicScreen.kt | 9 +++-- .../settings/backups/ShowPassphraseScreen.kt | 7 +++- 5 files changed, 34 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/settings/backups/BackupNavSheetViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/backups/BackupNavSheetViewModel.kt index 845c29380..b97f18a9e 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backups/BackupNavSheetViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backups/BackupNavSheetViewModel.kt @@ -17,6 +17,7 @@ import to.bitkit.R import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain +import to.bitkit.domain.models.Secret import to.bitkit.domain.models.splitWords import to.bitkit.domain.models.wipeAll import to.bitkit.models.BackupCategory @@ -84,16 +85,10 @@ class BackupNavSheetViewModel @Inject constructor( val mnemonicWordSecrets = mnemonicSecret.splitWords() mnemonicSecret.wipe() - val mnemonic = mnemonicWordSecrets.joinToString(" ") { it.peek { chars -> String(chars) } } - mnemonicWordSecrets.wipeAll() - - val passphrase = passphraseSecret?.peek { String(it) } ?: "" - passphraseSecret?.wipe() - _uiState.update { it.copy( - bip39Mnemonic = mnemonic, - bip39Passphrase = passphrase, + mnemonicWords = mnemonicWordSecrets, + passphrase = passphraseSecret, ) } }.onFailure { @@ -116,7 +111,7 @@ class BackupNavSheetViewModel @Inject constructor( fun onShowMnemonicContinue() { val state = _uiState.value - if (state.bip39Passphrase.isNotEmpty()) { + if (state.passphrase != null) { setEffect(SideEffect.NavigateToShowPassphrase) } else { setEffect(SideEffect.NavigateToConfirmMnemonic) @@ -129,7 +124,7 @@ class BackupNavSheetViewModel @Inject constructor( fun onConfirmMnemonicContinue() { val state = _uiState.value - if (state.bip39Passphrase.isNotEmpty()) { + if (state.passphrase != null) { setEffect(SideEffect.NavigateToConfirmPassphrase) } else { setEffect(SideEffect.NavigateToWarning) @@ -164,18 +159,25 @@ class BackupNavSheetViewModel @Inject constructor( } fun resetState() { + wipeSecrets() _uiState.update { UiState() } } -} -interface BackupContract { - companion object { - private val PLACEHOLDER_MNEMONIC = List(24) { "secret" }.joinToString(" ") + override fun onCleared() { + super.onCleared() + wipeSecrets() } + private fun wipeSecrets() { + _uiState.value.mnemonicWords.wipeAll() + _uiState.value.passphrase?.wipe() + } +} + +interface BackupContract { data class UiState( - val bip39Mnemonic: String = PLACEHOLDER_MNEMONIC, - val bip39Passphrase: String = "", + val mnemonicWords: List = emptyList(), + val passphrase: Secret? = null, val showMnemonic: Boolean = false, val enteredPassphrase: String = "", val lastBackupTimeMs: Long? = null, diff --git a/app/src/main/java/to/bitkit/ui/settings/backups/ConfirmMnemonicScreen.kt b/app/src/main/java/to/bitkit/ui/settings/backups/ConfirmMnemonicScreen.kt index 898fb6a42..ce47d25ba 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backups/ConfirmMnemonicScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backups/ConfirmMnemonicScreen.kt @@ -48,8 +48,8 @@ fun ConfirmMnemonicScreen( ) { BlockScreenshots() - val originalSeed = remember(uiState.bip39Mnemonic) { - uiState.bip39Mnemonic.split(" ").filter { it.isNotBlank() } + val originalSeed = remember(uiState.mnemonicWords) { + uiState.mnemonicWords.map { it.peek { chars -> String(chars) } } } val shuffledWords = remember(originalSeed) { originalSeed.shuffled() diff --git a/app/src/main/java/to/bitkit/ui/settings/backups/ConfirmPassphraseScreen.kt b/app/src/main/java/to/bitkit/ui/settings/backups/ConfirmPassphraseScreen.kt index cf29e5a1a..fa350335d 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backups/ConfirmPassphraseScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backups/ConfirmPassphraseScreen.kt @@ -43,7 +43,7 @@ fun ConfirmPassphraseScreen( ConfirmPassphraseContent( enteredPassphrase = uiState.enteredPassphrase, - isValid = uiState.enteredPassphrase == uiState.bip39Passphrase, + isValid = uiState.passphrase?.peek { String(it) == uiState.enteredPassphrase } ?: false, onPassphraseChange = onPassphraseChange, onContinue = { keyboardController?.hide() diff --git a/app/src/main/java/to/bitkit/ui/settings/backups/ShowMnemonicScreen.kt b/app/src/main/java/to/bitkit/ui/settings/backups/ShowMnemonicScreen.kt index 0468834aa..3aba2d879 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backups/ShowMnemonicScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backups/ShowMnemonicScreen.kt @@ -66,14 +66,19 @@ fun ShowMnemonicScreen( ) { BlockScreenshots() + val displayWords = remember(uiState.mnemonicWords) { + uiState.mnemonicWords.map { it.peek { chars -> String(chars) } } + } + val displayMnemonic = remember(displayWords) { displayWords.joinToString(" ") } + val context = LocalContext.current val scope = rememberCoroutineScope() ShowMnemonicContent( - mnemonic = uiState.bip39Mnemonic, + mnemonic = displayMnemonic, showMnemonic = uiState.showMnemonic, onRevealClick = onRevealClick, onCopyClick = { - context.setClipboardText(uiState.bip39Mnemonic) + context.setClipboardText(displayMnemonic) scope.launch { ToastEventBus.send( type = Toast.ToastType.SUCCESS, diff --git a/app/src/main/java/to/bitkit/ui/settings/backups/ShowPassphraseScreen.kt b/app/src/main/java/to/bitkit/ui/settings/backups/ShowPassphraseScreen.kt index eb9bba142..ccd2c6a62 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backups/ShowPassphraseScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backups/ShowPassphraseScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.testTag @@ -37,8 +38,12 @@ fun ShowPassphraseScreen( ) { BlockScreenshots() + val displayPassphrase = remember(uiState.passphrase) { + uiState.passphrase?.peek { String(it) }.orEmpty() + } + ShowPassphraseContent( - bip39Passphrase = uiState.bip39Passphrase, + bip39Passphrase = displayPassphrase, onContinue = onContinue, onBack = onBack, ) From b02f10dfe44b91f0926acc777c0cc8d322516081 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 8 Feb 2026 12:42:16 +0100 Subject: [PATCH 15/20] refactor: secure wallet create/restore APIs Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- .../java/to/bitkit/repositories/WalletRepo.kt | 17 +++++++++-------- app/src/main/java/to/bitkit/ui/MainActivity.kt | 8 ++++++-- .../to/bitkit/viewmodels/WalletViewModel.kt | 5 +++-- .../to/bitkit/repositories/WalletRepoTest.kt | 7 ++++--- .../java/to/bitkit/ui/WalletViewModelTest.kt | 13 +++++++------ 5 files changed, 29 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 1ae218ec3..7cd1c16b5 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -21,6 +21,7 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher +import to.bitkit.domain.models.Secret import to.bitkit.domain.models.secretOf import to.bitkit.domain.models.useAsString import to.bitkit.env.Env @@ -280,23 +281,23 @@ class WalletRepo @Inject constructor( return newBip21 } - suspend fun createWallet(bip39Passphrase: String?): Result = withContext(bgDispatcher) { + suspend fun createWallet(bip39Passphrase: Secret?): Result = withContext(bgDispatcher) { lightningRepo.setRecoveryMode(enabled = false) runCatching { val mnemonic = generateEntropyMnemonic() - keychain.saveSecret(Keychain.Key.BIP39_MNEMONIC.name, secretOf(mnemonic)) - bip39Passphrase?.let { keychain.saveSecret(Keychain.Key.BIP39_PASSPHRASE.name, secretOf(it)) } + keychain.saveSecret(Keychain.Key.BIP39_MNEMONIC.name, mnemonic) + bip39Passphrase?.let { keychain.saveSecret(Keychain.Key.BIP39_PASSPHRASE.name, it) } setWalletExistsState() }.onFailure { Logger.error("createWallet error", it, context = TAG) } } - suspend fun restoreWallet(mnemonic: String, bip39Passphrase: String?): Result = withContext(bgDispatcher) { + suspend fun restoreWallet(mnemonic: Secret, bip39Passphrase: Secret?): Result = withContext(bgDispatcher) { lightningRepo.setRecoveryMode(enabled = false) runCatching { - keychain.saveSecret(Keychain.Key.BIP39_MNEMONIC.name, secretOf(mnemonic)) - bip39Passphrase?.let { keychain.saveSecret(Keychain.Key.BIP39_PASSPHRASE.name, secretOf(it)) } + keychain.saveSecret(Keychain.Key.BIP39_MNEMONIC.name, mnemonic) + bip39Passphrase?.let { keychain.saveSecret(Keychain.Key.BIP39_PASSPHRASE.name, it) } setWalletExistsState() }.onFailure { Logger.error("restoreWallet error", it, context = TAG) @@ -549,8 +550,8 @@ class WalletRepo @Inject constructor( } } - private fun generateEntropyMnemonic(): String { - return org.lightningdevkit.ldknode.generateEntropyMnemonic(wordCount = WordCount.WORDS12) + private fun generateEntropyMnemonic(): Secret { + return secretOf(org.lightningdevkit.ldknode.generateEntropyMnemonic(wordCount = WordCount.WORDS12)) } private companion object { diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index db4b23dee..5d4d6700a 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -33,6 +33,7 @@ import kotlinx.serialization.Serializable import to.bitkit.R import to.bitkit.androidServices.LightningNodeService import to.bitkit.androidServices.LightningNodeService.Companion.CHANNEL_ID_NODE +import to.bitkit.domain.models.secretOf import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.ui.components.AuthCheckView import to.bitkit.ui.components.IsOnlineTracker @@ -293,7 +294,10 @@ private fun OnboardingNav( scope.launch { runCatching { appViewModel.resetIsAuthenticatedState() - walletViewModel.restoreWallet(mnemonic, passphrase) + walletViewModel.restoreWallet( + secretOf(mnemonic), + passphrase?.let { secretOf(it) }, + ) }.onFailure { appViewModel.toast(it) } @@ -308,7 +312,7 @@ private fun OnboardingNav( scope.launch { runCatching { appViewModel.resetIsAuthenticatedState() - walletViewModel.createWallet(bip39Passphrase = passphrase) + walletViewModel.createWallet(bip39Passphrase = secretOf(passphrase)) }.onFailure { appViewModel.toast(it) } diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index cd2b5ee43..cc7fe522d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -26,6 +26,7 @@ import org.lightningdevkit.ldknode.PeerDetails import to.bitkit.R import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher +import to.bitkit.domain.models.Secret import to.bitkit.models.Toast import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.BlocktankRepo @@ -383,7 +384,7 @@ class WalletViewModel @Inject constructor( } } - suspend fun createWallet(bip39Passphrase: String?) { + suspend fun createWallet(bip39Passphrase: Secret?) { setInitNodeLifecycleState() walletRepo.createWallet(bip39Passphrase) .onSuccess { @@ -394,7 +395,7 @@ class WalletViewModel @Inject constructor( } } - suspend fun restoreWallet(mnemonic: String, bip39Passphrase: String?) { + suspend fun restoreWallet(mnemonic: Secret, bip39Passphrase: Secret?) { setInitNodeLifecycleState() _restoreState.update { RestoreState.InProgress.Wallet } diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index fba73ecc0..c212a21af 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -23,6 +23,7 @@ import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.domain.models.Secret +import to.bitkit.domain.models.secretOf import to.bitkit.models.BalanceState import to.bitkit.services.CoreService import to.bitkit.services.OnchainService @@ -150,7 +151,7 @@ class WalletRepoTest : BaseUnitTest() { val passphrase = "restore passphrase" whenever(keychain.saveSecret(any(), any())).thenReturn(Unit) - val result = sut.restoreWallet(mnemonic, passphrase) + val result = sut.restoreWallet(secretOf(mnemonic), secretOf(passphrase)) assertTrue(result.isSuccess) verify(keychain).saveSecret(eq(Keychain.Key.BIP39_MNEMONIC.name), any()) @@ -162,7 +163,7 @@ class WalletRepoTest : BaseUnitTest() { val mnemonic = "restore mnemonic" whenever(keychain.saveSecret(any(), any())).thenReturn(Unit) - val result = sut.restoreWallet(mnemonic, null) + val result = sut.restoreWallet(secretOf(mnemonic), null) assertTrue(result.isSuccess) verify(keychain).saveSecret(eq(Keychain.Key.BIP39_MNEMONIC.name), any()) @@ -173,7 +174,7 @@ class WalletRepoTest : BaseUnitTest() { val mnemonic = "restore mnemonic" whenever(keychain.saveSecret(any(), any())).thenReturn(Unit) - val result = sut.restoreWallet(mnemonic, null) + val result = sut.restoreWallet(secretOf(mnemonic), null) assertTrue(result.isSuccess) verify(settingsStore, never()).update(any()) diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index 579fc2287..a21ccd53a 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -16,6 +16,7 @@ import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.SettingsStore +import to.bitkit.domain.models.secretOf import to.bitkit.ext.of import to.bitkit.models.BalanceState import to.bitkit.repositories.BackupRepo @@ -142,7 +143,7 @@ class WalletViewModelTest : BaseUnitTest() { fun `restoreWallet should call walletRepo restoreWallet`() = test { whenever(walletRepo.restoreWallet(any(), anyOrNull())).thenReturn(Result.success(Unit)) - sut.restoreWallet("test_mnemonic", null) + sut.restoreWallet(secretOf("test_mnemonic"), null) verify(walletRepo).restoreWallet(any(), anyOrNull()) } @@ -151,7 +152,7 @@ class WalletViewModelTest : BaseUnitTest() { fun `restoreWallet should call setInitNodeLifecycleState`() = test { whenever(walletRepo.restoreWallet(any(), anyOrNull())).thenReturn(Result.success(Unit)) - sut.restoreWallet("test_mnemonic", null) + sut.restoreWallet(secretOf("test_mnemonic"), null) verify(lightningRepo).setInitNodeLifecycleState() } @@ -190,7 +191,7 @@ class WalletViewModelTest : BaseUnitTest() { fun `onBackupRestoreSuccess should reset restoreState`() = test { whenever(backupRepo.performFullRestoreFromLatestBackup()).thenReturn(Result.success(Unit)) walletState.value = walletState.value.copy(walletExists = true) - sut.restoreWallet("mnemonic", "passphrase") + sut.restoreWallet(secretOf("mnemonic"), secretOf("passphrase")) assertEquals(RestoreState.InProgress.Wallet, sut.restoreState.value) sut.onRestoreContinue() @@ -202,7 +203,7 @@ class WalletViewModelTest : BaseUnitTest() { fun `onProceedWithoutRestore should exit restore flow`() = test { val testError = Exception("Test error") whenever(backupRepo.performFullRestoreFromLatestBackup()).thenReturn(Result.failure(testError)) - sut.restoreWallet("mnemonic", "passphrase") + sut.restoreWallet(secretOf("mnemonic"), secretOf("passphrase")) walletState.value = walletState.value.copy(walletExists = true) assertEquals(RestoreState.Completed, sut.restoreState.value) @@ -216,7 +217,7 @@ class WalletViewModelTest : BaseUnitTest() { whenever(backupRepo.performFullRestoreFromLatestBackup()).thenReturn(Result.success(Unit)) assertEquals(RestoreState.Initial, sut.restoreState.value) - sut.restoreWallet("mnemonic", "passphrase") + sut.restoreWallet(secretOf("mnemonic"), secretOf("passphrase")) assertEquals(RestoreState.InProgress.Wallet, sut.restoreState.value) walletState.value = walletState.value.copy(walletExists = true) @@ -298,7 +299,7 @@ class WalletViewModelTest : BaseUnitTest() { ) // Trigger restore to put state in non-idle - testSut.restoreWallet("mnemonic", null) + testSut.restoreWallet(secretOf("mnemonic"), null) assertEquals(RestoreState.InProgress.Wallet, testSut.restoreState.value) testSut.start() From 3de3cc59e057d8759cef369af25124823d6b2510 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 8 Feb 2026 12:42:24 +0100 Subject: [PATCH 16/20] refactor: secure PIN validation APIs Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- .../java/to/bitkit/ui/components/AuthCheckView.kt | 3 ++- .../ui/screens/wallets/send/SendPinCheckScreen.kt | 3 ++- .../ui/settings/pin/ChangePinConfirmScreen.kt | 3 ++- .../to/bitkit/ui/settings/pin/ChangePinScreen.kt | 3 ++- .../to/bitkit/ui/settings/pin/PinConfirmScreen.kt | 3 ++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 15 ++++++++------- 6 files changed, 18 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/components/AuthCheckView.kt b/app/src/main/java/to/bitkit/ui/components/AuthCheckView.kt index 849033aea..f40375fd6 100644 --- a/app/src/main/java/to/bitkit/ui/components/AuthCheckView.kt +++ b/app/src/main/java/to/bitkit/ui/components/AuthCheckView.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R +import to.bitkit.domain.models.secretOf import to.bitkit.env.Env import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.shared.modifiers.clickableAlpha @@ -60,7 +61,7 @@ fun AuthCheckView( attemptsRemaining = attemptsRemaining, requireBiometrics = requireBiometrics, requirePin = requirePin, - validatePin = appViewModel::validatePin, + validatePin = { pin -> appViewModel.validatePin(secretOf(pin)) }, onSuccess = onSuccess, onBack = onBack, onClickForgotPin = { appViewModel.setShowForgotPin(true) }, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPinCheckScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPinCheckScreen.kt index 413573e47..55c52dfeb 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPinCheckScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPinCheckScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R +import to.bitkit.domain.models.secretOf import to.bitkit.env.Env import to.bitkit.ui.appViewModel import to.bitkit.ui.components.BodyM @@ -51,7 +52,7 @@ fun SendPinCheckScreen( LaunchedEffect(pin) { if (pin.length == Env.PIN_LENGTH) { - if (app.validatePin(pin)) { + if (app.validatePin(secretOf(pin))) { onSuccess() } pin = "" diff --git a/app/src/main/java/to/bitkit/ui/settings/pin/ChangePinConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/settings/pin/ChangePinConfirmScreen.kt index 99934e17f..a19f608eb 100644 --- a/app/src/main/java/to/bitkit/ui/settings/pin/ChangePinConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/pin/ChangePinConfirmScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.unit.dp import androidx.navigation.NavController import kotlinx.coroutines.delay import to.bitkit.R +import to.bitkit.domain.models.secretOf import to.bitkit.env.Env import to.bitkit.ui.appViewModel import to.bitkit.ui.components.BodyM @@ -49,7 +50,7 @@ fun ChangePinConfirmScreen( LaunchedEffect(pin) { if (pin.length == Env.PIN_LENGTH) { if (pin == newPin) { - app.editPin(newPin) + app.editPin(secretOf(newPin)) navController.navigateToChangePinResult() } else { showError = true diff --git a/app/src/main/java/to/bitkit/ui/settings/pin/ChangePinScreen.kt b/app/src/main/java/to/bitkit/ui/settings/pin/ChangePinScreen.kt index 85c727ed0..644e2b1a6 100644 --- a/app/src/main/java/to/bitkit/ui/settings/pin/ChangePinScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/pin/ChangePinScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import to.bitkit.R +import to.bitkit.domain.models.secretOf import to.bitkit.env.Env import to.bitkit.ui.appViewModel import to.bitkit.ui.components.BodyM @@ -47,7 +48,7 @@ fun ChangePinScreen( LaunchedEffect(pin) { if (pin.length == Env.PIN_LENGTH) { - if (app.validatePin(pin)) { + if (app.validatePin(secretOf(pin))) { navController.navigateToChangePinNew() } else { pin = "" diff --git a/app/src/main/java/to/bitkit/ui/settings/pin/PinConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/settings/pin/PinConfirmScreen.kt index 5b8a74e82..152d772cd 100644 --- a/app/src/main/java/to/bitkit/ui/settings/pin/PinConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/pin/PinConfirmScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import to.bitkit.R +import to.bitkit.domain.models.secretOf import to.bitkit.env.Env import to.bitkit.ui.appViewModel import to.bitkit.ui.components.BodyM @@ -48,7 +49,7 @@ fun PinConfirmScreen( LaunchedEffect(pin) { if (pin.length == Env.PIN_LENGTH) { if (pin == originalPin) { - app.addPin(pin) + app.addPin(secretOf(pin)) onPinConfirmed() } else { showError = true diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 047876c9a..6565510b1 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -61,7 +61,6 @@ import to.bitkit.di.BgDispatcher import to.bitkit.domain.commands.NotifyPaymentReceived import to.bitkit.domain.commands.NotifyPaymentReceivedHandler import to.bitkit.domain.models.Secret -import to.bitkit.domain.models.secretOf import to.bitkit.env.Defaults import to.bitkit.env.Env import to.bitkit.ext.WatchResult @@ -2095,9 +2094,11 @@ class AppViewModel @Inject constructor( } } - fun validatePin(pin: String): Boolean { - val storedPinSecret: Secret? = keychain.loadSecret(Keychain.Key.PIN.name) - val isValid = storedPinSecret?.use { storedChars -> String(storedChars) == pin } ?: false + fun validatePin(pin: Secret): Boolean { + val storedPinSecret = keychain.loadSecret(Keychain.Key.PIN.name) + val isValid = pin.use { pinChars -> + storedPinSecret?.use { it.contentEquals(pinChars) } ?: false + } if (isValid) { viewModelScope.launch { @@ -2123,17 +2124,17 @@ class AppViewModel @Inject constructor( return false } - fun addPin(pin: String) { + fun addPin(pin: Secret) { viewModelScope.launch { settingsStore.addDismissedSuggestion(Suggestion.SECURE) } editPin(pin) } - fun editPin(newPin: String) { + fun editPin(newPin: Secret) { viewModelScope.launch(bgDispatcher) { settingsStore.update { it.copy(isPinEnabled = true) } - keychain.upsertSecret(Keychain.Key.PIN.name, secretOf(newPin)) + keychain.upsertSecret(Keychain.Key.PIN.name, newPin) keychain.upsertString(Keychain.Key.PIN_ATTEMPTS_REMAINING.name, Env.PIN_ATTEMPTS.toString()) } } From 90e705e5645545ac07d715deede96036b3e5056a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 8 Feb 2026 18:05:27 +0100 Subject: [PATCH 17/20] refactor: remove string usage for pin Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- app/src/main/java/to/bitkit/ui/ContentView.kt | 8 ++--- .../to/bitkit/ui/components/AuthCheckView.kt | 20 +++++------ .../java/to/bitkit/ui/components/PinDots.kt | 8 +++-- .../wallets/send/SendPinCheckScreen.kt | 28 +++++++-------- .../ui/settings/pin/ChangePinConfirmScreen.kt | 35 +++++++++++-------- .../ui/settings/pin/ChangePinNewScreen.kt | 27 ++++++++------ .../bitkit/ui/settings/pin/ChangePinScreen.kt | 28 +++++++-------- .../bitkit/ui/settings/pin/PinChooseScreen.kt | 23 +++++++----- .../ui/settings/pin/PinConfirmScreen.kt | 35 +++++++++++-------- .../main/java/to/bitkit/ui/sheets/PinSheet.kt | 7 ++-- .../java/to/bitkit/viewmodels/AppViewModel.kt | 13 +++++++ 11 files changed, 134 insertions(+), 98 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index ebffd7ee6..9514bb76d 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -1099,9 +1099,7 @@ private fun NavGraphBuilder.changePinNew(navController: NavHostController) { private fun NavGraphBuilder.changePinConfirm(navController: NavHostController) { composableWithDefaultTransitions { - val route = it.toRoute() ChangePinConfirmScreen( - newPin = route.newPin, navController = navController, ) } @@ -1575,8 +1573,8 @@ fun NavController.navigateToChangePinNew() = navigate( route = Routes.ChangePinNew, ) -fun NavController.navigateToChangePinConfirm(newPin: String) = navigate( - route = Routes.ChangePinConfirm(newPin), +fun NavController.navigateToChangePinConfirm() = navigate( + route = Routes.ChangePinConfirm, ) fun NavController.navigateToChangePinResult() = navigate( @@ -1780,7 +1778,7 @@ sealed interface Routes { data object ChangePinNew : Routes @Serializable - data class ChangePinConfirm(val newPin: String) : Routes + data object ChangePinConfirm : Routes @Serializable data object ChangePinResult : Routes diff --git a/app/src/main/java/to/bitkit/ui/components/AuthCheckView.kt b/app/src/main/java/to/bitkit/ui/components/AuthCheckView.kt index f40375fd6..5f653fc29 100644 --- a/app/src/main/java/to/bitkit/ui/components/AuthCheckView.kt +++ b/app/src/main/java/to/bitkit/ui/components/AuthCheckView.kt @@ -61,7 +61,7 @@ fun AuthCheckView( attemptsRemaining = attemptsRemaining, requireBiometrics = requireBiometrics, requirePin = requirePin, - validatePin = { pin -> appViewModel.validatePin(secretOf(pin)) }, + validatePin = { pinChars -> appViewModel.validatePin(secretOf(pinChars.copyOf())) }, onSuccess = onSuccess, onBack = onBack, onClickForgotPin = { appViewModel.setShowForgotPin(true) }, @@ -77,7 +77,7 @@ private fun AuthCheckViewContent( attemptsRemaining: Int, requireBiometrics: Boolean = false, requirePin: Boolean = false, - validatePin: (String) -> Boolean, + validatePin: (CharArray) -> Boolean, onSuccess: (() -> Unit)? = null, onBack: (() -> Unit)?, onClickForgotPin: () -> Unit, @@ -119,7 +119,7 @@ private fun AuthCheckViewContent( @Composable private fun PinPad( showLogo: Boolean = false, - validatePin: (String) -> Boolean, + validatePin: (CharArray) -> Boolean, attemptsRemaining: Int, allowBiometrics: Boolean, onShowBiometrics: () -> Unit, @@ -127,15 +127,15 @@ private fun PinPad( onBack: (() -> Unit)? = null, onClickForgotPin: () -> Unit = {}, ) { - var pin by remember { mutableStateOf("") } + var pin by remember { mutableSecretOf() } val isLastAttempt = attemptsRemaining == 1 LaunchedEffect(pin) { - if (pin.length == Env.PIN_LENGTH) { + if (pin.size == Env.PIN_LENGTH) { if (validatePin(pin)) { onSuccess?.invoke() } - pin = "" + pin = charArrayOf() } } @@ -204,17 +204,17 @@ private fun PinPad( } PinDots( - pin = pin, + pinLength = pin.size, modifier = Modifier.padding(vertical = 16.dp), ) NumberPad( onPress = { key -> if (key == KEY_DELETE) { if (pin.isNotEmpty()) { - pin = pin.dropLast(1) + pin = pin.sliceArray(0 until pin.size - 1) } - } else if (pin.length < Env.PIN_LENGTH) { - pin += key + } else if (pin.size < Env.PIN_LENGTH) { + pin = pin + key[0] } }, type = NumberPadType.SIMPLE, diff --git a/app/src/main/java/to/bitkit/ui/components/PinDots.kt b/app/src/main/java/to/bitkit/ui/components/PinDots.kt index faf5dc7ac..d9164da8e 100644 --- a/app/src/main/java/to/bitkit/ui/components/PinDots.kt +++ b/app/src/main/java/to/bitkit/ui/components/PinDots.kt @@ -10,15 +10,19 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import to.bitkit.env.Env import to.bitkit.ui.theme.Colors +fun mutableSecretOf(): MutableState = mutableStateOf(charArrayOf()) + @Composable fun PinDots( - pin: String, + pinLength: Int, modifier: Modifier = Modifier, ) { Row( @@ -32,7 +36,7 @@ fun PinDots( .size(20.dp) .clip(CircleShape) .border(1.dp, Colors.Brand, CircleShape) - .background(if (index < pin.length) Colors.Brand else Colors.Brand08) + .background(if (index < pinLength) Colors.Brand else Colors.Brand08) ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPinCheckScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPinCheckScreen.kt index 55c52dfeb..d90b9ac12 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPinCheckScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPinCheckScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -32,6 +31,7 @@ import to.bitkit.ui.components.KEY_DELETE import to.bitkit.ui.components.NumberPad import to.bitkit.ui.components.NumberPadType import to.bitkit.ui.components.PinDots +import to.bitkit.ui.components.mutableSecretOf import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.shared.modifiers.sheetHeight @@ -48,27 +48,27 @@ fun SendPinCheckScreen( ) { val app = appViewModel ?: return val attemptsRemaining by app.pinAttemptsRemaining.collectAsStateWithLifecycle() - var pin by remember { mutableStateOf("") } + var pin by remember { mutableSecretOf() } LaunchedEffect(pin) { - if (pin.length == Env.PIN_LENGTH) { - if (app.validatePin(secretOf(pin))) { + if (pin.size == Env.PIN_LENGTH) { + if (app.validatePin(secretOf(pin.copyOf()))) { onSuccess() } - pin = "" + pin = charArrayOf() } } PinCheckContent( - pin = pin, + pinLength = pin.size, attemptsRemaining = attemptsRemaining, onKeyPress = { key -> if (key == KEY_DELETE) { if (pin.isNotEmpty()) { - pin = pin.dropLast(1) + pin = pin.sliceArray(0 until pin.size - 1) } - } else if (pin.length < Env.PIN_LENGTH) { - pin += key + } else if (pin.size < Env.PIN_LENGTH) { + pin = pin + key[0] } }, onBack = onBack, @@ -78,7 +78,7 @@ fun SendPinCheckScreen( @Composable private fun PinCheckContent( - pin: String, + pinLength: Int, attemptsRemaining: Int, onKeyPress: (String) -> Unit, onBack: () -> Unit, @@ -135,7 +135,7 @@ private fun PinCheckContent( } PinDots( - pin = pin, + pinLength = pinLength, modifier = Modifier.padding(vertical = 16.dp), ) @@ -158,7 +158,7 @@ private fun Preview() { AppThemeSurface { BottomSheetPreview { PinCheckContent( - pin = "123", + pinLength = 3, attemptsRemaining = 8, onKeyPress = {}, onBack = {}, @@ -175,7 +175,7 @@ private fun PreviewAttemptsLeft() { AppThemeSurface { BottomSheetPreview { PinCheckContent( - pin = "123", + pinLength = 3, attemptsRemaining = 3, onKeyPress = {}, onBack = {}, @@ -192,7 +192,7 @@ private fun PreviewAttemptsLast() { AppThemeSurface { BottomSheetPreview { PinCheckContent( - pin = "123", + pinLength = 3, attemptsRemaining = 1, onKeyPress = {}, onBack = {}, diff --git a/app/src/main/java/to/bitkit/ui/settings/pin/ChangePinConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/settings/pin/ChangePinConfirmScreen.kt index a19f608eb..2d4744923 100644 --- a/app/src/main/java/to/bitkit/ui/settings/pin/ChangePinConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/pin/ChangePinConfirmScreen.kt @@ -31,6 +31,7 @@ import to.bitkit.ui.components.KEY_DELETE import to.bitkit.ui.components.NumberPad import to.bitkit.ui.components.NumberPadType import to.bitkit.ui.components.PinDots +import to.bitkit.ui.components.mutableSecretOf import to.bitkit.ui.navigateToChangePinResult import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon @@ -40,36 +41,42 @@ import to.bitkit.ui.theme.Colors @Composable fun ChangePinConfirmScreen( - newPin: String, navController: NavController, ) { val app = appViewModel ?: return - var pin by remember { mutableStateOf("") } + var pin by remember { mutableSecretOf() } var showError by remember { mutableStateOf(false) } LaunchedEffect(pin) { - if (pin.length == Env.PIN_LENGTH) { - if (pin == newPin) { - app.editPin(secretOf(newPin)) + if (pin.size == Env.PIN_LENGTH) { + val matches = app.consumePendingPin()?.let { pending -> + val result = pending.peek { it.contentEquals(pin) } + if (result) { + app.editPin(secretOf(pin.copyOf())) + pending.wipe() + } + result + } ?: false + if (matches) { navController.navigateToChangePinResult() } else { showError = true delay(500) - pin = "" + pin = charArrayOf() } } } ChangePinConfirmContent( - pin = pin, + pinLength = pin.size, showError = showError, onKeyPress = { key -> if (key == KEY_DELETE) { if (pin.isNotEmpty()) { - pin = pin.dropLast(1) + pin = pin.sliceArray(0 until pin.size - 1) } - } else if (pin.length < Env.PIN_LENGTH) { - pin += key + } else if (pin.size < Env.PIN_LENGTH) { + pin = pin + key[0] } }, onBackClick = { navController.popBackStack() }, @@ -78,7 +85,7 @@ fun ChangePinConfirmScreen( @Composable private fun ChangePinConfirmContent( - pin: String, + pinLength: Int, showError: Boolean, onKeyPress: (String) -> Unit, onBackClick: () -> Unit, @@ -115,7 +122,7 @@ private fun ChangePinConfirmContent( } PinDots( - pin = pin, + pinLength = pinLength, modifier = Modifier.padding(vertical = 16.dp), ) @@ -135,7 +142,7 @@ private fun ChangePinConfirmContent( private fun Preview() { AppThemeSurface { ChangePinConfirmContent( - pin = "12", + pinLength = 2, showError = false, onKeyPress = {}, onBackClick = {}, @@ -148,7 +155,7 @@ private fun Preview() { private fun PreviewRetry() { AppThemeSurface { ChangePinConfirmContent( - pin = "", + pinLength = 0, showError = true, onKeyPress = {}, onBackClick = {}, diff --git a/app/src/main/java/to/bitkit/ui/settings/pin/ChangePinNewScreen.kt b/app/src/main/java/to/bitkit/ui/settings/pin/ChangePinNewScreen.kt index 79e9620d9..bfd3de7f0 100644 --- a/app/src/main/java/to/bitkit/ui/settings/pin/ChangePinNewScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/pin/ChangePinNewScreen.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -18,12 +17,15 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.navigation.NavController import to.bitkit.R +import to.bitkit.domain.models.secretOf import to.bitkit.env.Env +import to.bitkit.ui.appViewModel import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.KEY_DELETE import to.bitkit.ui.components.NumberPad import to.bitkit.ui.components.NumberPadType import to.bitkit.ui.components.PinDots +import to.bitkit.ui.components.mutableSecretOf import to.bitkit.ui.navigateToChangePinConfirm import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon @@ -35,23 +37,26 @@ import to.bitkit.ui.theme.Colors fun ChangePinNewScreen( navController: NavController, ) { - var pin by remember { mutableStateOf("") } + val app = appViewModel ?: return + var pin by remember { mutableSecretOf() } LaunchedEffect(pin) { - if (pin.length == Env.PIN_LENGTH) { - navController.navigateToChangePinConfirm(pin) + if (pin.size == Env.PIN_LENGTH) { + app.setPendingPin(secretOf(pin.copyOf())) + pin = charArrayOf() + navController.navigateToChangePinConfirm() } } ChangePinNewContent( - pin = pin, + pinLength = pin.size, onKeyPress = { key -> if (key == KEY_DELETE) { if (pin.isNotEmpty()) { - pin = pin.dropLast(1) + pin = pin.sliceArray(0 until pin.size - 1) } - } else if (pin.length < Env.PIN_LENGTH) { - pin += key + } else if (pin.size < Env.PIN_LENGTH) { + pin = pin + key[0] } }, onBackClick = { navController.popBackStack() }, @@ -60,7 +65,7 @@ fun ChangePinNewScreen( @Composable private fun ChangePinNewContent( - pin: String, + pinLength: Int, onKeyPress: (String) -> Unit, onBackClick: () -> Unit, ) { @@ -85,7 +90,7 @@ private fun ChangePinNewContent( Spacer(modifier = Modifier.height(32.dp)) PinDots( - pin = pin, + pinLength = pinLength, modifier = Modifier.padding(vertical = 16.dp), ) @@ -105,7 +110,7 @@ private fun ChangePinNewContent( private fun Preview() { AppThemeSurface { ChangePinNewContent( - pin = "12", + pinLength = 2, onKeyPress = {}, onBackClick = {}, ) diff --git a/app/src/main/java/to/bitkit/ui/settings/pin/ChangePinScreen.kt b/app/src/main/java/to/bitkit/ui/settings/pin/ChangePinScreen.kt index 644e2b1a6..9c5d7c866 100644 --- a/app/src/main/java/to/bitkit/ui/settings/pin/ChangePinScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/pin/ChangePinScreen.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -30,6 +29,7 @@ import to.bitkit.ui.components.KEY_DELETE import to.bitkit.ui.components.NumberPad import to.bitkit.ui.components.NumberPadType import to.bitkit.ui.components.PinDots +import to.bitkit.ui.components.mutableSecretOf import to.bitkit.ui.navigateToChangePinNew import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon @@ -44,28 +44,28 @@ fun ChangePinScreen( ) { val app = appViewModel ?: return val attemptsRemaining by app.pinAttemptsRemaining.collectAsStateWithLifecycle() - var pin by remember { mutableStateOf("") } + var pin by remember { mutableSecretOf() } LaunchedEffect(pin) { - if (pin.length == Env.PIN_LENGTH) { - if (app.validatePin(secretOf(pin))) { + if (pin.size == Env.PIN_LENGTH) { + if (app.validatePin(secretOf(pin.copyOf()))) { navController.navigateToChangePinNew() } else { - pin = "" + pin = charArrayOf() } } } ChangePinContent( - pin = pin, + pinLength = pin.size, attemptsRemaining = attemptsRemaining, onKeyPress = { key -> if (key == KEY_DELETE) { if (pin.isNotEmpty()) { - pin = pin.dropLast(1) + pin = pin.sliceArray(0 until pin.size - 1) } - } else if (pin.length < Env.PIN_LENGTH) { - pin += key + } else if (pin.size < Env.PIN_LENGTH) { + pin = pin + key[0] } }, onBackClick = { navController.popBackStack() }, @@ -75,7 +75,7 @@ fun ChangePinScreen( @Composable private fun ChangePinContent( - pin: String, + pinLength: Int, attemptsRemaining: Int, onKeyPress: (String) -> Unit, onBackClick: () -> Unit, @@ -126,7 +126,7 @@ private fun ChangePinContent( } PinDots( - pin = pin, + pinLength = pinLength, modifier = Modifier.padding(vertical = 16.dp), ) @@ -146,7 +146,7 @@ private fun ChangePinContent( private fun Preview() { AppThemeSurface { ChangePinContent( - pin = "12", + pinLength = 2, attemptsRemaining = 8, onKeyPress = {}, onBackClick = {}, @@ -160,7 +160,7 @@ private fun Preview() { private fun PreviewAttemptsRemaining() { AppThemeSurface { ChangePinContent( - pin = "1234", + pinLength = 4, attemptsRemaining = 5, onKeyPress = {}, onBackClick = {}, @@ -174,7 +174,7 @@ private fun PreviewAttemptsRemaining() { private fun PreviewAttemptsLast() { AppThemeSurface { ChangePinContent( - pin = "", + pinLength = 0, attemptsRemaining = 1, onKeyPress = {}, onBackClick = {}, diff --git a/app/src/main/java/to/bitkit/ui/settings/pin/PinChooseScreen.kt b/app/src/main/java/to/bitkit/ui/settings/pin/PinChooseScreen.kt index c1584133b..12cf5a58e 100644 --- a/app/src/main/java/to/bitkit/ui/settings/pin/PinChooseScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/pin/PinChooseScreen.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -17,12 +16,15 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import to.bitkit.R +import to.bitkit.domain.models.secretOf import to.bitkit.env.Env +import to.bitkit.ui.appViewModel import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.KEY_DELETE import to.bitkit.ui.components.NumberPad import to.bitkit.ui.components.NumberPadType import to.bitkit.ui.components.PinDots +import to.bitkit.ui.components.mutableSecretOf import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface @@ -30,10 +32,11 @@ import to.bitkit.ui.theme.Colors @Composable fun PinChooseScreen( - onPinChosen: (String) -> Unit, + onPinChosen: () -> Unit, onBack: () -> Unit, ) { - var pin by remember { mutableStateOf("") } + val app = appViewModel ?: return + var pin by remember { mutableSecretOf() } Column( modifier = Modifier @@ -55,7 +58,7 @@ fun PinChooseScreen( Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.weight(1f)) - PinDots(pin = pin) + PinDots(pinLength = pin.size) Spacer(modifier = Modifier.height(32.dp)) @@ -63,12 +66,14 @@ fun PinChooseScreen( onPress = { key -> if (key == KEY_DELETE) { if (pin.isNotEmpty()) { - pin = pin.dropLast(1) + pin = pin.sliceArray(0 until pin.size - 1) } - } else if (pin.length < Env.PIN_LENGTH) { - pin += key - if (pin.length == Env.PIN_LENGTH) { - onPinChosen(pin) + } else if (pin.size < Env.PIN_LENGTH) { + pin = pin + key[0] + if (pin.size == Env.PIN_LENGTH) { + app.setPendingPin(secretOf(pin.copyOf())) + pin = charArrayOf() + onPinChosen() } } }, diff --git a/app/src/main/java/to/bitkit/ui/settings/pin/PinConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/settings/pin/PinConfirmScreen.kt index 152d772cd..da65826ed 100644 --- a/app/src/main/java/to/bitkit/ui/settings/pin/PinConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/pin/PinConfirmScreen.kt @@ -31,6 +31,7 @@ import to.bitkit.ui.components.KEY_DELETE import to.bitkit.ui.components.NumberPad import to.bitkit.ui.components.NumberPadType import to.bitkit.ui.components.PinDots +import to.bitkit.ui.components.mutableSecretOf import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface @@ -38,37 +39,43 @@ import to.bitkit.ui.theme.Colors @Composable fun PinConfirmScreen( - originalPin: String, onPinConfirmed: () -> Unit, onBack: () -> Unit, ) { val app = appViewModel ?: return - var pin by remember { mutableStateOf("") } + var pin by remember { mutableSecretOf() } var showError by remember { mutableStateOf(false) } LaunchedEffect(pin) { - if (pin.length == Env.PIN_LENGTH) { - if (pin == originalPin) { - app.addPin(secretOf(pin)) + if (pin.size == Env.PIN_LENGTH) { + val matches = app.consumePendingPin()?.let { pending -> + val result = pending.peek { it.contentEquals(pin) } + if (result) { + app.addPin(secretOf(pin.copyOf())) + pending.wipe() + } + result + } ?: false + if (matches) { onPinConfirmed() } else { showError = true delay(500) - pin = "" + pin = charArrayOf() } } } ConfirmPinContent( - pin = pin, + pinLength = pin.size, showError = showError, onKeyPress = { key -> if (key == KEY_DELETE) { if (pin.isNotEmpty()) { - pin = pin.dropLast(1) + pin = pin.sliceArray(0 until pin.size - 1) } - } else if (pin.length < Env.PIN_LENGTH) { - pin += key + } else if (pin.size < Env.PIN_LENGTH) { + pin = pin + key[0] } }, onBack = onBack, @@ -77,7 +84,7 @@ fun PinConfirmScreen( @Composable private fun ConfirmPinContent( - pin: String, + pinLength: Int, showError: Boolean, onKeyPress: (String) -> Unit, onBack: () -> Unit, @@ -119,7 +126,7 @@ private fun ConfirmPinContent( Spacer(modifier = Modifier.height(16.dp)) - PinDots(pin = pin) + PinDots(pinLength = pinLength) Spacer(modifier = Modifier.height(32.dp)) @@ -138,7 +145,7 @@ private fun ConfirmPinContent( private fun Preview() { AppThemeSurface { ConfirmPinContent( - pin = "", + pinLength = 0, showError = false, onKeyPress = {}, onBack = {}, @@ -151,7 +158,7 @@ private fun Preview() { private fun PreviewRetry() { AppThemeSurface { ConfirmPinContent( - pin = "123", + pinLength = 3, showError = true, onKeyPress = {}, onBack = {}, diff --git a/app/src/main/java/to/bitkit/ui/sheets/PinSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/PinSheet.kt index dd51453ee..679f47ff9 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/PinSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/PinSheet.kt @@ -45,15 +45,12 @@ fun PinSheet( } composableWithDefaultTransitions { PinChooseScreen( - onPinChosen = { pin -> - navController.navigate(PinRoute.Confirm(pin)) - }, + onPinChosen = { navController.navigate(PinRoute.Confirm) }, onBack = { navController.popBackStack() }, ) } composableWithDefaultTransitions { PinConfirmScreen( - originalPin = it.toRoute().pin, onPinConfirmed = { navController.navigate(PinRoute.Biometrics) }, onBack = { navController.popBackStack() }, ) @@ -86,7 +83,7 @@ sealed interface PinRoute { data object Choose : PinRoute @Serializable - data class Confirm(val pin: String) : PinRoute + data object Confirm : PinRoute @Serializable data object Biometrics : PinRoute diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 6565510b1..f3bc04614 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -2094,6 +2094,19 @@ class AppViewModel @Inject constructor( } } + private var _pendingPin: Secret? = null + + fun setPendingPin(pin: Secret) { + _pendingPin?.wipe() + _pendingPin = pin + } + + fun consumePendingPin(): Secret? { + val pin = _pendingPin + _pendingPin = null + return pin + } + fun validatePin(pin: Secret): Boolean { val storedPinSecret = keychain.loadSecret(Keychain.Key.PIN.name) val isValid = pin.use { pinChars -> From 865a4cba62a36033fbdade3cbf8a4f84d1fa6b01 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 8 Feb 2026 22:03:23 +0100 Subject: [PATCH 18/20] refactor: wipe derived encryption keys Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- app/src/main/java/to/bitkit/ext/ByteArray.kt | 2 + .../java/to/bitkit/services/RNBackupClient.kt | 57 +++++++++++-------- 2 files changed, 35 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/to/bitkit/ext/ByteArray.kt b/app/src/main/java/to/bitkit/ext/ByteArray.kt index af0aad5b4..cd911db31 100644 --- a/app/src/main/java/to/bitkit/ext/ByteArray.kt +++ b/app/src/main/java/to/bitkit/ext/ByteArray.kt @@ -11,3 +11,5 @@ fun ByteArray.toBase64(): String = Base64.encode(this) fun String.fromBase64(): ByteArray = Base64.decode(this) val String.uByteList get() = this.toByteArray().map { it.toUByte() } + +fun ByteArray.wipe() = fill(0) diff --git a/app/src/main/java/to/bitkit/services/RNBackupClient.kt b/app/src/main/java/to/bitkit/services/RNBackupClient.kt index 01a46ccd9..57b9fc895 100644 --- a/app/src/main/java/to/bitkit/services/RNBackupClient.kt +++ b/app/src/main/java/to/bitkit/services/RNBackupClient.kt @@ -24,6 +24,7 @@ import to.bitkit.di.IoDispatcher import to.bitkit.domain.models.useAsString import to.bitkit.env.Env import to.bitkit.ext.toHex +import to.bitkit.ext.wipe import to.bitkit.utils.AppError import to.bitkit.utils.Crypto import to.bitkit.utils.Logger @@ -93,18 +94,22 @@ class RNBackupClient @Inject constructor( } passphraseSecret?.wipe() - val url = buildUrl("retrieve", label = label, fileGroup = fileGroup, network = getNetworkString()) - val response: HttpResponse = httpClient.get(url) { - header("Authorization", bearer.bearer) - } + try { + val url = buildUrl("retrieve", label = label, fileGroup = fileGroup, network = getNetworkString()) + val response: HttpResponse = httpClient.get(url) { + header("Authorization", bearer.bearer) + } - if (!response.status.isSuccess()) throw RNBackupError.RequestFailed("Status: ${response.status.value}") + if (!response.status.isSuccess()) throw RNBackupError.RequestFailed("Status: ${response.status.value}") - val encryptedData = response.body() - if (encryptedData.isEmpty()) throw RNBackupError.RequestFailed("Retrieved data is empty") + val encryptedData = response.body() + if (encryptedData.isEmpty()) throw RNBackupError.RequestFailed("Retrieved data is empty") - decrypt(encryptedData, encryptionKey).also { - if (it.isEmpty()) throw RNBackupError.DecryptFailed("Decrypted data is empty") + decrypt(encryptedData, encryptionKey).also { + if (it.isEmpty()) throw RNBackupError.DecryptFailed("Decrypted data is empty") + } + } finally { + encryptionKey.wipe() } }.onFailure { e -> Logger.error("Failed to retrieve $label", e, context = TAG) @@ -125,24 +130,28 @@ class RNBackupClient @Inject constructor( } passphraseSecret?.wipe() - val url = buildUrl( - method = "retrieve", - label = "channel_monitor", - fileGroup = "ldk", - channelId = channelId, - network = getNetworkString(), - ) - val response: HttpResponse = httpClient.get(url) { - header("Authorization", bearer.bearer) - } + try { + val url = buildUrl( + method = "retrieve", + label = "channel_monitor", + fileGroup = "ldk", + channelId = channelId, + network = getNetworkString(), + ) + val response: HttpResponse = httpClient.get(url) { + header("Authorization", bearer.bearer) + } - if (!response.status.isSuccess()) throw RNBackupError.RequestFailed("Status: ${response.status.value}") + if (!response.status.isSuccess()) throw RNBackupError.RequestFailed("Status: ${response.status.value}") - val encryptedData = response.body() - if (encryptedData.isEmpty()) throw RNBackupError.RequestFailed("Retrieved data is empty") + val encryptedData = response.body() + if (encryptedData.isEmpty()) throw RNBackupError.RequestFailed("Retrieved data is empty") - decrypt(encryptedData, encryptionKey).also { - if (it.isEmpty()) throw RNBackupError.DecryptFailed("Decrypted data is empty") + decrypt(encryptedData, encryptionKey).also { + if (it.isEmpty()) throw RNBackupError.DecryptFailed("Decrypted data is empty") + } + } finally { + encryptionKey.wipe() } }.onFailure { e -> Logger.error("Failed to retrieve channel monitor $channelId", e, context = TAG) From e5512990fc297e01cca9d5276b1df929e1012b47 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 8 Feb 2026 22:04:36 +0100 Subject: [PATCH 19/20] fix: stale keychain mocks in wallet test Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index c212a21af..3c871af7a 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -87,8 +87,8 @@ class WalletRepoTest : BaseUnitTest() { whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) whenever(deriveBalanceStateUseCase.invoke()).thenReturn(Result.success(BalanceState())) - whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn("test mnemonic") - whenever(keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name)).thenReturn(null) + whenever(keychain.loadSecret(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(secretOf("test mnemonic")) + whenever(keychain.loadSecret(Keychain.Key.BIP39_PASSPHRASE.name)).thenReturn(null) whenever(coreService.onchain).thenReturn(onchainService) whenever(preActivityMetadataRepo.addPreActivityMetadataTags(any(), any())).thenReturn(Result.success(Unit)) From 042d3b023e3799545bfaa71a57a5ed0836b8511e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 8 Feb 2026 22:05:42 +0100 Subject: [PATCH 20/20] refactor: clear restore state on destroy Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- .../java/to/bitkit/viewmodels/RestoreWalletViewModel.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt index b45b58bdb..0b605500d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt @@ -26,6 +26,15 @@ class RestoreWalletViewModel @Inject constructor( recomputeValidationState() } + override fun onCleared() { + _uiState.update { + it.copy( + words = List(WORDS_MAX) { "" }, + bip39Passphrase = "", + ) + } + } + private fun recomputeValidationState() = viewModelScope.launch { val currentState = _uiState.value val checksumError = currentState.isChecksumErrorVisible()