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/data/keychain/Keychain.kt b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt index 9b777174a..9b96722db 100644 --- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt +++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt @@ -15,10 +15,14 @@ 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 import to.bitkit.utils.Logger +import java.nio.ByteBuffer +import java.nio.CharBuffer import javax.inject.Inject import javax.inject.Singleton @@ -26,6 +30,7 @@ private val Context.keychainDataStore: DataStore by preferencesData name = "keychain" ) +@Suppress("TooManyFunctions") @Singleton class Keychain @Inject constructor( private val db: AppDb, @@ -41,49 +46,63 @@ class Keychain @Inject constructor( fun loadString(key: String): String? = load(key)?.decodeToString() - @Suppress("TooGenericExceptionCaught", "SwallowedException") + fun loadSecret(key: String): Secret? { + val bytes = load(key) ?: return null + val chars = bytes.decodeToCharArray() + bytes.fill(0) + return secretOf(chars) + } + 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") @@ -120,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 new file mode 100644 index 000000000..89e7e2ba7 --- /dev/null +++ b/app/src/main/java/to/bitkit/domain/models/Secret.kt @@ -0,0 +1,63 @@ +package to.bitkit.domain.models + +import kotlin.reflect.KProperty + +private const val WIPE_CHAR = '\u0000' + +/** + * A wrapper that stores sensitive data in a [CharArray] and provides APIs to safely wipe it from memory. + * + * ALWAYS access the wrapped value inside [use] blocks for auto cleanup. + */ +class Secret internal constructor(initialValue: CharArray) : AutoCloseable { + companion object { + const val ERR_WIPED = "Secret has already been wiped." + } + + @PublishedApi + internal var data: CharArray? = initialValue.copyOf() + + init { + initialValue.wipe() + } + + internal operator fun getValue(thisRef: Any?, property: KProperty<*>): CharArray { + return checkNotNull(data) { ERR_WIPED } + } + + internal operator fun setValue(thisRef: Any?, property: KProperty<*>, value: CharArray) { + wipe(nullify = false) + data = value.copyOf() + value.wipe() + } + + inline fun use(block: (CharArray) -> R): R { + try { + return block(checkNotNull(data) { ERR_WIPED }) + } finally { + wipe() + } + } + + inline fun peek(block: (CharArray) -> R): R = block(checkNotNull(data) { ERR_WIPED }) + + fun wipe(nullify: Boolean = true) { + data?.wipe() + if (nullify) data = null + } + + fun CharArray.wipe() = this.fill(WIPE_CHAR) + + override fun close() = wipe() +} + +fun secretOf(value: CharArray) = Secret(value) + +fun secretOf(value: String): Secret = Secret(value.toCharArray()) + +internal inline fun Secret.useAsString(block: (String) -> R): R = use { block(String(it)) } + +internal fun Secret.splitWords(): List = + peek { chars -> String(chars).split(" ").filter { it.isNotBlank() }.map { secretOf(it) } } + +internal fun List.wipeAll() = forEach { it.wipe() } 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/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/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) 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() } diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 3b5782db4..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,9 @@ 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 import to.bitkit.ext.filterOpen import to.bitkit.ext.nowTimestamp @@ -278,27 +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.saveString(Keychain.Key.BIP39_MNEMONIC.name, mnemonic) - if (bip39Passphrase != null) { - keychain.saveString(Keychain.Key.BIP39_PASSPHRASE.name, bip39Passphrase) - } + 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.saveString(Keychain.Key.BIP39_MNEMONIC.name, mnemonic) - if (bip39Passphrase != null) { - keychain.saveString(Keychain.Key.BIP39_PASSPHRASE.name, bip39Passphrase) - } + 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) @@ -337,25 +336,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( @@ -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/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 } } 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, 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/services/RNBackupClient.kt b/app/src/main/java/to/bitkit/services/RNBackupClient.kt index 8e2a8462f..57b9fc895 100644 --- a/app/src/main/java/to/bitkit/services/RNBackupClient.kt +++ b/app/src/main/java/to/bitkit/services/RNBackupClient.kt @@ -21,8 +21,10 @@ 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.ext.wipe import to.bitkit.utils.AppError import to.bitkit.utils.Crypto import to.bitkit.utils.Logger @@ -56,10 +58,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,24 +82,34 @@ 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 bearer = authenticate(mnemonic, passphrase) - val url = buildUrl("retrieve", label = label, fileGroup = fileGroup, network = getNetworkString()) - val response: HttpResponse = httpClient.get(url) { - header("Authorization", bearer.bearer) + 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() - if (!response.status.isSuccess()) throw RNBackupError.RequestFailed("Status: ${response.status.value}") + try { + val url = buildUrl("retrieve", label = label, fileGroup = fileGroup, network = getNetworkString()) + val response: HttpResponse = httpClient.get(url) { + header("Authorization", bearer.bearer) + } - val encryptedData = response.body() - if (encryptedData.isEmpty()) throw RNBackupError.RequestFailed("Retrieved data is empty") + if (!response.status.isSuccess()) throw RNBackupError.RequestFailed("Status: ${response.status.value}") - val encryptionKey = deriveEncryptionKey(mnemonic, passphrase) + 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) @@ -101,29 +118,40 @@ 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 bearer = authenticate(mnemonic, passphrase) - val url = buildUrl( - method = "retrieve", - label = "channel_monitor", - fileGroup = "ldk", - channelId = channelId, - network = getNetworkString(), - ) - val response: HttpResponse = httpClient.get(url) { - header("Authorization", bearer.bearer) + 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() + + 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") - val encryptionKey = deriveEncryptionKey(mnemonic, passphrase) - 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) 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/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/ui/components/AuthCheckView.kt b/app/src/main/java/to/bitkit/ui/components/AuthCheckView.kt index 849033aea..5f653fc29 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 = { pinChars -> appViewModel.validatePin(secretOf(pinChars.copyOf())) }, onSuccess = onSuccess, onBack = onBack, onClickForgotPin = { appViewModel.setShowForgotPin(true) }, @@ -76,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, @@ -118,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, @@ -126,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() } } @@ -203,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/recovery/RecoveryMnemonicScreen.kt b/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryMnemonicScreen.kt index 23087bc99..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 @@ -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,7 @@ 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.secretOf import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.MnemonicWordsGrid @@ -54,6 +56,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 +99,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 +115,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 +130,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 +174,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 +194,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/screens/wallets/send/SendPinCheckScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPinCheckScreen.kt index 413573e47..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 @@ -22,6 +21,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 @@ -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.modifiers.clickableAlpha import to.bitkit.ui.shared.modifiers.sheetHeight @@ -47,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(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, @@ -77,7 +78,7 @@ fun SendPinCheckScreen( @Composable private fun PinCheckContent( - pin: String, + pinLength: Int, attemptsRemaining: Int, onKeyPress: (String) -> Unit, onBack: () -> Unit, @@ -134,7 +135,7 @@ private fun PinCheckContent( } PinDots( - pin = pin, + pinLength = pinLength, modifier = Modifier.padding(vertical = 16.dp), ) @@ -157,7 +158,7 @@ private fun Preview() { AppThemeSurface { BottomSheetPreview { PinCheckContent( - pin = "123", + pinLength = 3, attemptsRemaining = 8, onKeyPress = {}, onBack = {}, @@ -174,7 +175,7 @@ private fun PreviewAttemptsLeft() { AppThemeSurface { BottomSheetPreview { PinCheckContent( - pin = "123", + pinLength = 3, attemptsRemaining = 3, onKeyPress = {}, onBack = {}, @@ -191,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/backups/BackupNavSheetViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/backups/BackupNavSheetViewModel.kt index 3c41f83b5..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,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,17 @@ 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() _uiState.update { it.copy( - bip39Mnemonic = mnemonic, - bip39Passphrase = bip39Passphrase, + mnemonicWords = mnemonicWordSecrets, + passphrase = passphraseSecret, ) } }.onFailure { @@ -104,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) @@ -117,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) @@ -152,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, ) 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..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 @@ -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 @@ -30,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 @@ -39,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(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() }, @@ -77,7 +85,7 @@ fun ChangePinConfirmScreen( @Composable private fun ChangePinConfirmContent( - pin: String, + pinLength: Int, showError: Boolean, onKeyPress: (String) -> Unit, onBackClick: () -> Unit, @@ -114,7 +122,7 @@ private fun ChangePinConfirmContent( } PinDots( - pin = pin, + pinLength = pinLength, modifier = Modifier.padding(vertical = 16.dp), ) @@ -134,7 +142,7 @@ private fun ChangePinConfirmContent( private fun Preview() { AppThemeSurface { ChangePinConfirmContent( - pin = "12", + pinLength = 2, showError = false, onKeyPress = {}, onBackClick = {}, @@ -147,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 85c727ed0..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 @@ -21,6 +20,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 @@ -29,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 @@ -43,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(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() }, @@ -74,7 +75,7 @@ fun ChangePinScreen( @Composable private fun ChangePinContent( - pin: String, + pinLength: Int, attemptsRemaining: Int, onKeyPress: (String) -> Unit, onBackClick: () -> Unit, @@ -125,7 +126,7 @@ private fun ChangePinContent( } PinDots( - pin = pin, + pinLength = pinLength, modifier = Modifier.padding(vertical = 16.dp), ) @@ -145,7 +146,7 @@ private fun ChangePinContent( private fun Preview() { AppThemeSurface { ChangePinContent( - pin = "12", + pinLength = 2, attemptsRemaining = 8, onKeyPress = {}, onBackClick = {}, @@ -159,7 +160,7 @@ private fun Preview() { private fun PreviewAttemptsRemaining() { AppThemeSurface { ChangePinContent( - pin = "1234", + pinLength = 4, attemptsRemaining = 5, onKeyPress = {}, onBackClick = {}, @@ -173,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 5b8a74e82..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 @@ -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 @@ -30,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 @@ -37,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(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, @@ -76,7 +84,7 @@ fun PinConfirmScreen( @Composable private fun ConfirmPinContent( - pin: String, + pinLength: Int, showError: Boolean, onKeyPress: (String) -> Unit, onBack: () -> Unit, @@ -118,7 +126,7 @@ private fun ConfirmPinContent( Spacer(modifier = Modifier.height(16.dp)) - PinDots(pin = pin) + PinDots(pinLength = pinLength) Spacer(modifier = Modifier.height(32.dp)) @@ -137,7 +145,7 @@ private fun ConfirmPinContent( private fun Preview() { AppThemeSurface { ConfirmPinContent( - pin = "", + pinLength = 0, showError = false, onKeyPress = {}, onBack = {}, @@ -150,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 47f8d7f36..f3bc04614 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -60,6 +60,7 @@ import to.bitkit.data.resetPin 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.env.Defaults import to.bitkit.env.Env import to.bitkit.ext.WatchResult @@ -2093,9 +2094,24 @@ class AppViewModel @Inject constructor( } } - fun validatePin(pin: String): Boolean { - val storedPin = keychain.loadString(Keychain.Key.PIN.name) - val isValid = storedPin == pin + 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 -> + storedPinSecret?.use { it.contentEquals(pinChars) } ?: false + } if (isValid) { viewModelScope.launch { @@ -2121,17 +2137,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.upsertString(Keychain.Key.PIN.name, newPin) + keychain.upsertSecret(Keychain.Key.PIN.name, newPin) keychain.upsertString(Keychain.Key.PIN_ATTEMPTS_REMAINING.name, Env.PIN_ATTEMPTS.toString()) } } 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() 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/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/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index c0b4e19d4..3c871af7a 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,8 @@ 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.domain.models.secretOf import to.bitkit.models.BalanceState import to.bitkit.services.CoreService import to.bitkit.services.OnchainService @@ -84,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)) @@ -146,32 +149,32 @@ 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) + val result = sut.restoreWallet(secretOf(mnemonic), secretOf(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) + val result = sut.restoreWallet(secretOf(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) + 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()