Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 14 additions & 9 deletions app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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,
Expand Down
20 changes: 12 additions & 8 deletions app/src/main/java/to/bitkit/data/backup/VssStoreIdProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
61 changes: 45 additions & 16 deletions app/src/main/java/to/bitkit/data/keychain/Keychain.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,22 @@ 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

private val Context.keychainDataStore: DataStore<Preferences> by preferencesDataStore(
name = "keychain"
)

@Suppress("TooManyFunctions")
@Singleton
class Keychain @Inject constructor(
private val db: AppDb,
Expand All @@ -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")
Expand Down Expand Up @@ -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,
Expand Down
63 changes: 63 additions & 0 deletions app/src/main/java/to/bitkit/domain/models/Secret.kt
Original file line number Diff line number Diff line change
@@ -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 <R> use(block: (CharArray) -> R): R {
try {
return block(checkNotNull(data) { ERR_WIPED })
} finally {
wipe()
}
}

inline fun <R> 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 <R> Secret.useAsString(block: (String) -> R): R = use { block(String(it)) }

internal fun Secret.splitWords(): List<Secret> =
peek { chars -> String(chars).split(" ").filter { it.isNotBlank() }.map { secretOf(it) } }

internal fun List<Secret>.wipeAll() = forEach { it.wipe() }
2 changes: 2 additions & 0 deletions app/src/main/java/to/bitkit/ext/ByteArray.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
7 changes: 7 additions & 0 deletions app/src/main/java/to/bitkit/fcm/FcmService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
30 changes: 18 additions & 12 deletions app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -679,18 +680,23 @@ class LightningRepo @Inject constructor(
callback: String,
domain: String,
): Result<String> = 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)
Expand Down
Loading
Loading