Skip to content

Commit cb141bc

Browse files
authored
Merge pull request #898 from synonymdev/chore/keychain-error-logs
chore: log cause of keychain load failures
2 parents d7dcefa + 0a113fc commit cb141bc

4 files changed

Lines changed: 180 additions & 16 deletions

File tree

app/src/main/java/to/bitkit/data/keychain/AndroidKeyStore.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,4 +87,6 @@ class AndroidKeyStore(
8787
}
8888
generateKey()
8989
}
90+
91+
fun containsAlias(): Boolean = keyStore.containsAlias(alias)
9092
}

app/src/main/java/to/bitkit/data/keychain/Keychain.kt

Lines changed: 71 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,11 @@ import to.bitkit.ext.fromBase64
1919
import to.bitkit.ext.toBase64
2020
import to.bitkit.utils.AppError
2121
import to.bitkit.utils.Logger
22+
import java.util.Collections
23+
import java.util.concurrent.ConcurrentHashMap
2224
import javax.inject.Inject
2325
import javax.inject.Singleton
26+
import kotlin.coroutines.cancellation.CancellationException
2427

2528
private val Context.keychainDataStore: DataStore<Preferences> by preferencesDataStore(
2629
name = "keychain"
@@ -32,59 +35,76 @@ class Keychain @Inject constructor(
3235
@ApplicationContext private val context: Context,
3336
@IoDispatcher private val dispatcher: CoroutineDispatcher,
3437
) : BaseCoroutineScope(dispatcher) {
38+
companion object {
39+
private const val TAG = "Keychain"
40+
private const val CAUSE_CHAIN_DEPTH = 4
41+
}
42+
3543
private val keyStore by lazy { AndroidKeyStore(alias = "keychain") }
3644

3745
@Suppress("MemberNameEqualsClassName")
3846
private val keychain = context.keychainDataStore
3947

48+
private val loadDiagnosticsEmitted: MutableSet<String> =
49+
Collections.newSetFromMap(ConcurrentHashMap<String, Boolean>())
50+
4051
val snapshot get() = runBlocking(this.coroutineContext) { keychain.data.first() }
4152

4253
fun loadString(key: String): String? = load(key)?.decodeToString()
4354

44-
@Suppress("TooGenericExceptionCaught", "SwallowedException")
55+
@Suppress("TooGenericExceptionCaught")
4556
fun load(key: String): ByteArray? {
4657
try {
4758
return snapshot[key.indexed]?.fromBase64()?.let {
4859
keyStore.decrypt(it)
4960
}
50-
} catch (_: Exception) {
51-
throw KeychainError.FailedToLoad(key)
61+
} catch (c: CancellationException) {
62+
throw c
63+
} catch (t: Throwable) {
64+
emitLoadDiagnosticsOnce(key, t)
65+
throw KeychainError.FailedToLoad(key, cause = t)
5266
}
5367
}
5468

5569
suspend fun saveString(key: String, value: String) = save(key, value.toByteArray())
5670

57-
@Suppress("TooGenericExceptionCaught", "SwallowedException")
71+
@Suppress("TooGenericExceptionCaught", "ThrowsCount")
5872
suspend fun save(key: String, value: ByteArray) {
5973
if (exists(key)) throw KeychainError.FailedToSaveAlreadyExists(key)
6074

6175
try {
6276
val encryptedValue = keyStore.encrypt(value)
6377
keychain.edit { it[key.indexed] = encryptedValue.toBase64() }
64-
} catch (_: Exception) {
65-
throw KeychainError.FailedToSave(key)
78+
} catch (c: CancellationException) {
79+
throw c
80+
} catch (t: Throwable) {
81+
throw KeychainError.FailedToSave(key, cause = t)
6682
}
6783
Logger.info("Saved to keychain: $key")
6884
}
6985

7086
/** Inserts or replaces a string value associated with a given key in the keychain. */
71-
@Suppress("TooGenericExceptionCaught", "SwallowedException")
87+
@Suppress("TooGenericExceptionCaught")
7288
suspend fun upsertString(key: String, value: String) {
7389
try {
7490
val encryptedValue = keyStore.encrypt(value.toByteArray())
7591
keychain.edit { it[key.indexed] = encryptedValue.toBase64() }
76-
} catch (_: Exception) {
77-
throw KeychainError.FailedToSave(key)
92+
} catch (c: CancellationException) {
93+
throw c
94+
} catch (t: Throwable) {
95+
throw KeychainError.FailedToSave(key, cause = t)
7896
}
7997
Logger.info("Upsert in keychain: $key")
8098
}
8199

82-
@Suppress("TooGenericExceptionCaught", "SwallowedException")
100+
@Suppress("TooGenericExceptionCaught")
83101
suspend fun delete(key: String) {
84102
try {
85103
keychain.edit { it.remove(key.indexed) }
86-
} catch (_: Exception) {
87-
throw KeychainError.FailedToDelete(key)
104+
} catch (c: CancellationException) {
105+
throw c
106+
} catch (t: Throwable) {
107+
throw KeychainError.FailedToDelete(key, cause = t)
88108
}
89109
Logger.debug("Deleted from keychain: $key")
90110
}
@@ -120,6 +140,32 @@ class Keychain @Inject constructor(
120140
.map { string -> string?.toIntOrNull() }
121141
}
122142

143+
private fun emitLoadDiagnosticsOnce(key: String, cause: Throwable) {
144+
if (!loadDiagnosticsEmitted.add(key)) return
145+
146+
val aliasPresent = probe { keyStore.containsAlias() }
147+
val entryPresent = probe { snapshot.contains(key.indexed) }
148+
val walletIndex = probe {
149+
runBlocking { db.configDao().getAll().first() }.firstOrNull()?.walletIndex ?: 0L
150+
}
151+
val causeChain = generateSequence(cause) { it.cause }
152+
.take(CAUSE_CHAIN_DEPTH)
153+
.joinToString(separator = " <- ") { it.javaClass.simpleName }
154+
155+
Logger.warn(
156+
"Decrypt failed for key='$key' walletIndex='$walletIndex' " +
157+
"aliasPresent='$aliasPresent' entryPresent='$entryPresent' " +
158+
"causeChain='$causeChain'",
159+
context = TAG,
160+
)
161+
}
162+
163+
private inline fun <T> probe(block: () -> T): String =
164+
runCatching(block).fold(
165+
onSuccess = { it.toString() },
166+
onFailure = { "error:${it.javaClass.simpleName}" },
167+
)
168+
123169
enum class Key {
124170
PUSH_NOTIFICATION_TOKEN,
125171
PUSH_NOTIFICATION_PRIVATE_KEY,
@@ -130,10 +176,19 @@ class Keychain @Inject constructor(
130176
}
131177
}
132178

133-
sealed class KeychainError(message: String) : AppError(message) {
134-
class FailedToDelete(key: String) : KeychainError("Failed to delete $key from keychain.")
135-
class FailedToLoad(key: String) : KeychainError("Failed to load $key from keychain.")
136-
class FailedToSave(key: String) : KeychainError("Failed to save to $key keychain.")
179+
sealed class KeychainError(message: String, cause: Throwable? = null) : AppError(message, cause) {
180+
class FailedToDelete(key: String, cause: Throwable? = null) :
181+
KeychainError("Failed to delete $key from keychain${cause.causeSuffix()}", cause)
182+
183+
class FailedToLoad(val key: String, cause: Throwable? = null) :
184+
KeychainError("Failed to load $key from keychain${cause.causeSuffix()}", cause)
185+
186+
class FailedToSave(key: String, cause: Throwable? = null) :
187+
KeychainError("Failed to save to $key keychain${cause.causeSuffix()}", cause)
188+
137189
class FailedToSaveAlreadyExists(key: String) :
138190
KeychainError("Key $key already exists in keychain. Explicitly delete key before attempting to update value.")
139191
}
192+
193+
private fun Throwable?.causeSuffix(): String =
194+
this?.let { " (cause='${it.javaClass.simpleName}')" } ?: "."
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package to.bitkit.data.keychain
2+
3+
import org.junit.Test
4+
import java.io.IOException
5+
import javax.crypto.AEADBadTagException
6+
import javax.crypto.BadPaddingException
7+
import kotlin.test.assertEquals
8+
import kotlin.test.assertFalse
9+
import kotlin.test.assertNull
10+
import kotlin.test.assertSame
11+
12+
class KeychainErrorTest {
13+
14+
@Test
15+
fun `FailedToLoad without cause renders key and terminating period`() {
16+
val error = KeychainError.FailedToLoad(key = "BIP39_MNEMONIC")
17+
18+
assertEquals("Failed to load BIP39_MNEMONIC from keychain.", error.message)
19+
assertNull(error.cause)
20+
}
21+
22+
@Test
23+
fun `FailedToLoad preserves cause and embeds its simple class name`() {
24+
val cause = AEADBadTagException("leak-probe-9f3a")
25+
val error = KeychainError.FailedToLoad(key = "BIP39_MNEMONIC", cause = cause)
26+
27+
assertSame(cause, error.cause)
28+
assertEquals(
29+
"Failed to load BIP39_MNEMONIC from keychain (cause='AEADBadTagException')",
30+
error.message,
31+
)
32+
}
33+
34+
@Test
35+
fun `FailedToLoad does not leak underlying cause message`() {
36+
val probe = "leak-probe-9f3a"
37+
val causes = listOf(
38+
AEADBadTagException(probe),
39+
BadPaddingException(probe),
40+
ClassCastException(probe),
41+
IllegalArgumentException(probe),
42+
IOException(probe),
43+
)
44+
45+
causes.forEach { cause ->
46+
val error = KeychainError.FailedToLoad(key = "BIP39_MNEMONIC", cause = cause)
47+
assertFalse(
48+
actual = error.message.orEmpty().contains(probe),
49+
message = "message leaked cause.message for ${cause.javaClass.simpleName}",
50+
)
51+
}
52+
}
53+
54+
@Test
55+
fun `FailedToLoad exposes the failing key`() {
56+
val error = KeychainError.FailedToLoad(key = "BIP39_PASSPHRASE", cause = IOException())
57+
58+
assertEquals("BIP39_PASSPHRASE", error.key)
59+
}
60+
61+
@Test
62+
fun `FailedToSave preserves cause and renders simple class name`() {
63+
val cause = IOException("probe")
64+
val error = KeychainError.FailedToSave(key = "BIP39_MNEMONIC", cause = cause)
65+
66+
assertSame(cause, error.cause)
67+
assertEquals(
68+
"Failed to save to BIP39_MNEMONIC keychain (cause='IOException')",
69+
error.message,
70+
)
71+
}
72+
73+
@Test
74+
fun `FailedToDelete preserves cause and renders simple class name`() {
75+
val cause = IOException("probe")
76+
val error = KeychainError.FailedToDelete(key = "BIP39_MNEMONIC", cause = cause)
77+
78+
assertSame(cause, error.cause)
79+
assertEquals(
80+
"Failed to delete BIP39_MNEMONIC from keychain (cause='IOException')",
81+
error.message,
82+
)
83+
}
84+
85+
@Test
86+
fun `FailedToSaveAlreadyExists has no cause and static message`() {
87+
val error = KeychainError.FailedToSaveAlreadyExists(key = "BIP39_MNEMONIC")
88+
89+
assertNull(error.cause)
90+
assertEquals(
91+
"Key BIP39_MNEMONIC already exists in keychain. " +
92+
"Explicitly delete key before attempting to update value.",
93+
error.message,
94+
)
95+
}
96+
}

app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,17 @@ class WalletRepoTest : BaseUnitTest() {
155155
assertFalse(result)
156156
}
157157

158+
@Test
159+
fun `walletExists relies on exists and does not touch load paths`() = test {
160+
whenever(keychain.exists(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(true)
161+
162+
val result = sut.walletExists()
163+
164+
assertTrue(result)
165+
verify(keychain, never()).loadString(Keychain.Key.BIP39_MNEMONIC.name)
166+
verify(keychain, never()).load(Keychain.Key.BIP39_MNEMONIC.name)
167+
}
168+
158169
@Test
159170
fun `setWalletExistsState should update walletState with current existence status`() = test {
160171
whenever(keychain.exists(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(true)

0 commit comments

Comments
 (0)