From e7222bfade6a0a681861f6022a1a0cb184a71f60 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 2 Oct 2026 22:48:00 +0200 Subject: [PATCH 1/5] fix: install security provider before tls use --- app/src/main/java/to/bitkit/App.kt | 3 + app/src/main/java/to/bitkit/utils/Crypto.kt | 43 ++++++++---- .../test/java/to/bitkit/utils/CryptoTest.kt | 66 +++++++++++++++++++ 3 files changed, 98 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/to/bitkit/App.kt b/app/src/main/java/to/bitkit/App.kt index bb7d85b1b3..aeb41632f7 100644 --- a/app/src/main/java/to/bitkit/App.kt +++ b/app/src/main/java/to/bitkit/App.kt @@ -15,6 +15,7 @@ import to.bitkit.appwidget.AppWidgetRefreshScheduler import to.bitkit.env.Env import to.bitkit.services.BluetoothInit import to.bitkit.services.PubkyAuthHandlerRegistrar +import to.bitkit.utils.Crypto import to.bitkit.utils.Logger import to.bitkit.utils.SubscriptionClockOffsetSync import javax.inject.Inject @@ -42,6 +43,8 @@ internal open class App : Application(), Configuration.Provider { .build() override fun onCreate() { + // Runs before super.onCreate(), where Hilt starts building services that open TLS connections + Crypto.installSecurityProvider() super.onCreate() Env.initAppStoragePath(filesDir.absolutePath) installUncaughtExceptionLogger() diff --git a/app/src/main/java/to/bitkit/utils/Crypto.kt b/app/src/main/java/to/bitkit/utils/Crypto.kt index 30903e024c..fec8717e63 100644 --- a/app/src/main/java/to/bitkit/utils/Crypto.kt +++ b/app/src/main/java/to/bitkit/utils/Crypto.kt @@ -33,6 +33,34 @@ import javax.inject.Singleton @Suppress("SwallowedException", "MagicNumber", "TooGenericExceptionCaught") @Singleton class Crypto @Inject constructor() { + companion object { + /** + * Puts the bundled BouncyCastle in place of the outdated "BC" provider that Android registers. + * + * `App.onCreate` calls this before anything can open a TLS connection. While the swap runs no + * provider offers the "BKS" keystore, and a native TLS verifier that loads its classes in that + * window fails for the rest of the process. Later calls do nothing. + */ + @Synchronized + fun installSecurityProvider() { + try { + val provider = Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) + when { + provider == null -> Security.addProvider(BouncyCastleProvider()) + provider::class.java != BouncyCastleProvider::class.java -> { + // We substitute the outdated BC provider registered in Android. + // Build the replacement first so the gap without a "BC" provider stays short. + val replacement = BouncyCastleProvider() + Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME) + Security.insertProviderAt(replacement, 1) + } + } + } catch (e: Exception) { + throw CryptoError.SecurityProviderSetupFailed() + } + } + } + @Suppress("ArrayInDataClass") data class KeyPair( val privateKey: ByteArray, @@ -50,20 +78,7 @@ class Crypto @Inject constructor() { private val transformation = "AES/GCM/NoPadding" init { - // TODO move init to VM (to enable error handling on UI)? - try { - val provider = Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) - when { - provider == null -> Security.addProvider(BouncyCastleProvider()) - provider::class.java != BouncyCastleProvider::class.java -> { - // We substitute the outdated BC provider registered in Android - Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME) - Security.insertProviderAt(BouncyCastleProvider(), 1) - } - } - } catch (e: Exception) { - throw CryptoError.SecurityProviderSetupFailed() - } + installSecurityProvider() } fun generateKeyPair(): KeyPair { diff --git a/app/src/test/java/to/bitkit/utils/CryptoTest.kt b/app/src/test/java/to/bitkit/utils/CryptoTest.kt index 482588db7c..9ff6cdfbfd 100644 --- a/app/src/test/java/to/bitkit/utils/CryptoTest.kt +++ b/app/src/test/java/to/bitkit/utils/CryptoTest.kt @@ -1,5 +1,7 @@ package to.bitkit.utils +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.junit.After import org.junit.Before import org.junit.Test import to.bitkit.env.Env.derivationName @@ -8,15 +10,35 @@ import to.bitkit.ext.fromHex import to.bitkit.ext.toBase64 import to.bitkit.ext.toHex import to.bitkit.fcm.EncryptedNotification +import java.security.Provider +import java.security.Security import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertSame +import kotlin.test.assertTrue class CryptoTest { + private companion object { + const val BC = BouncyCastleProvider.PROVIDER_NAME + } + private lateinit var sut: Crypto + private var baselineProvider: Provider? = null + private var baselinePosition = 0 @Before fun setUp() { sut = Crypto() + baselineProvider = Security.getProvider(BC) + baselinePosition = positionOf(baselineProvider) + } + + @After + fun tearDown() { + // The provider list is shared by the whole JVM, so put back what setUp saw for the next test class + Security.removeProvider(BC) + baselineProvider?.let { Security.insertProviderAt(it, baselinePosition) } } @Test @@ -107,4 +129,48 @@ class CryptoTest { assertEquals(decryptedPayload, value.decodeToString()) } + + @Test + fun `installSecurityProvider adds BouncyCastle when no BC provider is registered`() { + Security.removeProvider(BC) + + Crypto.installSecurityProvider() + + assertIs(Security.getProvider(BC)) + } + + @Test + fun `installSecurityProvider replaces an outdated BC provider at position 1`() { + val outdated = OutdatedBcProvider() + Security.removeProvider(BC) + Security.addProvider(outdated) + + Crypto.installSecurityProvider() + + val installed = assertIs(Security.getProviders().first()) + assertSame(installed, Security.getProvider(BC)) + assertTrue(Security.getProviders().none { it === outdated }) + } + + @Test + fun `installSecurityProvider keeps the installed provider on later calls`() { + Security.removeProvider(BC) + Security.addProvider(OutdatedBcProvider()) + Crypto.installSecurityProvider() + val installed = Security.getProviders().toList() + + Crypto.installSecurityProvider() + Crypto() + + assertSameProviders(installed, Security.getProviders().toList()) + } + + private fun positionOf(provider: Provider?) = Security.getProviders().indexOfFirst { it === provider } + 1 + + private fun assertSameProviders(expected: List, actual: List) { + assertEquals(expected.size, actual.size) + expected.zip(actual).forEach { (want, got) -> assertSame(want, got) } + } + + private class OutdatedBcProvider : Provider(BC, 1.0, "Stub for the BC provider that Android registers") } From 1af4195f3804c3eb225e9a76448bb4c7db99f4dd Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 2 Oct 2026 22:48:46 +0200 Subject: [PATCH 2/5] chore: add changelog fragment --- changelog.d/next/1416.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/1416.fixed.md diff --git a/changelog.d/next/1416.fixed.md b/changelog.d/next/1416.fixed.md new file mode 100644 index 0000000000..e5a83c4196 --- /dev/null +++ b/changelog.d/next/1416.fixed.md @@ -0,0 +1 @@ +Pubky profile, contact and payment features no longer fail after a cold start until the app is restarted. From 52e949c58c183f247d01f803e274aff18463c797 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Tue, 6 Oct 2026 10:48:40 +0200 Subject: [PATCH 3/5] test: restore security providers to their pre-test state --- app/src/test/java/to/bitkit/utils/CryptoTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/test/java/to/bitkit/utils/CryptoTest.kt b/app/src/test/java/to/bitkit/utils/CryptoTest.kt index 9ff6cdfbfd..483cf67fad 100644 --- a/app/src/test/java/to/bitkit/utils/CryptoTest.kt +++ b/app/src/test/java/to/bitkit/utils/CryptoTest.kt @@ -29,14 +29,14 @@ class CryptoTest { @Before fun setUp() { - sut = Crypto() baselineProvider = Security.getProvider(BC) baselinePosition = positionOf(baselineProvider) + sut = Crypto() } @After fun tearDown() { - // The provider list is shared by the whole JVM, so put back what setUp saw for the next test class + // The provider list is shared by the whole JVM, so restore BC as it was before this test started Security.removeProvider(BC) baselineProvider?.let { Security.insertProviderAt(it, baselinePosition) } } From 00c6f8fc2054ea66b4954217c74081fcc3634626 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Tue, 6 Oct 2026 14:14:38 +0200 Subject: [PATCH 4/5] docs: keep the provider setup error handling note --- app/src/main/java/to/bitkit/utils/Crypto.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/to/bitkit/utils/Crypto.kt b/app/src/main/java/to/bitkit/utils/Crypto.kt index fec8717e63..22b59d90f8 100644 --- a/app/src/main/java/to/bitkit/utils/Crypto.kt +++ b/app/src/main/java/to/bitkit/utils/Crypto.kt @@ -43,6 +43,7 @@ class Crypto @Inject constructor() { */ @Synchronized fun installSecurityProvider() { + // TODO show setup failure on UI? It throws from App.onCreate and stops start-up try { val provider = Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) when { From a1478a18a670edef9bd4a1aeab7f1f51c37c1d9e Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Tue, 6 Oct 2026 14:19:26 +0200 Subject: [PATCH 5/5] test: check the security provider is installed before app injection --- app/src/test/java/to/bitkit/AppTest.kt | 59 ++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 app/src/test/java/to/bitkit/AppTest.kt diff --git a/app/src/test/java/to/bitkit/AppTest.kt b/app/src/test/java/to/bitkit/AppTest.kt new file mode 100644 index 0000000000..1ebbb44dae --- /dev/null +++ b/app/src/test/java/to/bitkit/AppTest.kt @@ -0,0 +1,59 @@ +package to.bitkit + +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.junit.After +import org.junit.Before +import org.junit.Test +import to.bitkit.utils.AppError +import java.security.Provider +import java.security.Security +import kotlin.test.assertFailsWith +import kotlin.test.assertIs + +class AppTest { + private companion object { + const val BC = BouncyCastleProvider.PROVIDER_NAME + } + + private var baselineProvider: Provider? = null + private var baselinePosition = 0 + + @Before + fun setUp() { + baselineProvider = Security.getProvider(BC) + baselinePosition = Security.getProviders().indexOfFirst { it === baselineProvider } + 1 + } + + @After + fun tearDown() { + // The provider list is shared by the whole JVM, so restore BC as it was before this test started + Security.removeProvider(BC) + baselineProvider?.let { Security.insertProviderAt(it, baselinePosition) } + } + + @Test + fun `onCreate installs the security provider before Hilt injects the app`() { + Security.removeProvider(BC) + Security.addProvider(OutdatedBcProvider()) + val app = InjectionProbeApp() + + // The Hilt Gradle plugin rewrites App to extend the generated Hilt_App, whose onCreate() injects App through + // hiltInternalInject(). The probe's method of that name overrides it at runtime and stops onCreate() there. + assertFailsWith { app.onCreate() } + + assertIs(app.providerAtInjection) + } + + private class InjectionProbeApp : App() { + var providerAtInjection: Provider? = null + + fun hiltInternalInject() { + providerAtInjection = Security.getProvider(BC) + throw InjectionReached() + } + } + + private class InjectionReached : AppError("Reached Hilt injection") + + private class OutdatedBcProvider : Provider(BC, 1.0, "Stub for the BC provider that Android registers") +}