From 94ed32790f39db9461d96827e976f10bb21e5000 Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Thu, 6 Nov 2025 08:15:26 -0500 Subject: [PATCH] [PM-27752] Add certificate signature verification to AuthenticatorBridge (#6126) Co-authored-by: Claude --- app/build.gradle.kts | 6 +- authenticatorbridge/CHANGELOG.md | 7 + authenticatorbridge/build.gradle.kts | 3 +- .../src/debug/res/values/strings.xml | 8 ++ .../factory/AuthenticatorBridgeFactory.kt | 2 + .../manager/AuthenticatorBridgeManagerImpl.kt | 19 +-- .../util/PasswordManagerSignatureVerifier.kt | 14 ++ .../PasswordManagerSignatureVerifierImpl.kt | 128 ++++++++++++++++++ .../src/release/res/values/strings.xml | 12 ++ .../manager/AuthenticatorBridgeManagerTest.kt | 20 ++- 10 files changed, 200 insertions(+), 19 deletions(-) create mode 100644 authenticatorbridge/src/debug/res/values/strings.xml create mode 100644 authenticatorbridge/src/main/kotlin/com/bitwarden/authenticatorbridge/util/PasswordManagerSignatureVerifier.kt create mode 100644 authenticatorbridge/src/main/kotlin/com/bitwarden/authenticatorbridge/util/PasswordManagerSignatureVerifierImpl.kt create mode 100644 authenticatorbridge/src/release/res/values/strings.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ca60d7a9d0..e90312893a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -303,9 +303,9 @@ tasks { useJUnitPlatform() maxHeapSize = "2g" maxParallelForks = Runtime.getRuntime().availableProcessors() - jvmArgs = jvmArgs.orEmpty() + "-XX:+UseParallelGC" + - // Explicitly setting the user Country and Language because tests assume en-US - "-Duser.country=US" + + jvmArgs = jvmArgs.orEmpty() + "-XX:+UseParallelGC" + + // Explicitly setting the user Country and Language because tests assume en-US + "-Duser.country=US" + "-Duser.language=en" } } diff --git a/authenticatorbridge/CHANGELOG.md b/authenticatorbridge/CHANGELOG.md index a0b7a5f8c6..1daf6683fa 100644 --- a/authenticatorbridge/CHANGELOG.md +++ b/authenticatorbridge/CHANGELOG.md @@ -7,6 +7,13 @@ v1.1.0 (pending) ### Bug Fixes +v1.0.2 +-------- + +### Bug Fixes + +- Added cryptographic certificate signature verification to prevent malicious applications from spoofing Password Manager package names and intercepting TOTP secrets (VULN-314). + v1.0.1 -------- diff --git a/authenticatorbridge/build.gradle.kts b/authenticatorbridge/build.gradle.kts index 20eda47e1f..707f21f308 100644 --- a/authenticatorbridge/build.gradle.kts +++ b/authenticatorbridge/build.gradle.kts @@ -1,7 +1,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget // For more info on versioning, see the README. -val version = "1.0.1" +val version = "1.0.2" plugins { alias(libs.plugins.android.library) @@ -66,6 +66,7 @@ dependencies { implementation(libs.androidx.lifecycle.process) implementation(libs.kotlinx.serialization) implementation(libs.kotlinx.coroutines.core) + implementation(libs.timber) // Test environment dependencies: testImplementation(platform(libs.junit.bom)) diff --git a/authenticatorbridge/src/debug/res/values/strings.xml b/authenticatorbridge/src/debug/res/values/strings.xml new file mode 100644 index 0000000000..e44081b992 --- /dev/null +++ b/authenticatorbridge/src/debug/res/values/strings.xml @@ -0,0 +1,8 @@ + + + + + 0418ff9b3a883040ee3e1931093a7edca19b2f87f455388f28906bf262e32de7 + + diff --git a/authenticatorbridge/src/main/kotlin/com/bitwarden/authenticatorbridge/factory/AuthenticatorBridgeFactory.kt b/authenticatorbridge/src/main/kotlin/com/bitwarden/authenticatorbridge/factory/AuthenticatorBridgeFactory.kt index 018ac85c19..9eb36f30f0 100644 --- a/authenticatorbridge/src/main/kotlin/com/bitwarden/authenticatorbridge/factory/AuthenticatorBridgeFactory.kt +++ b/authenticatorbridge/src/main/kotlin/com/bitwarden/authenticatorbridge/factory/AuthenticatorBridgeFactory.kt @@ -5,6 +5,7 @@ import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManagerImpl import com.bitwarden.authenticatorbridge.manager.model.AuthenticatorBridgeConnectionType import com.bitwarden.authenticatorbridge.provider.SymmetricKeyStorageProvider +import com.bitwarden.authenticatorbridge.util.PasswordManagerSignatureVerifierImpl /** * Factory for supplying implementation instances of Authenticator Bridge SDK interfaces. @@ -29,5 +30,6 @@ class AuthenticatorBridgeFactory( context = applicationContext, connectionType = connectionType, symmetricKeyStorageProvider = symmetricKeyStorageProvider, + passwordManagerSignatureVerifier = PasswordManagerSignatureVerifierImpl(applicationContext), ) } diff --git a/authenticatorbridge/src/main/kotlin/com/bitwarden/authenticatorbridge/manager/AuthenticatorBridgeManagerImpl.kt b/authenticatorbridge/src/main/kotlin/com/bitwarden/authenticatorbridge/manager/AuthenticatorBridgeManagerImpl.kt index f827ccaeb9..b86748e76e 100644 --- a/authenticatorbridge/src/main/kotlin/com/bitwarden/authenticatorbridge/manager/AuthenticatorBridgeManagerImpl.kt +++ b/authenticatorbridge/src/main/kotlin/com/bitwarden/authenticatorbridge/manager/AuthenticatorBridgeManagerImpl.kt @@ -4,7 +4,6 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.ServiceConnection -import android.content.pm.PackageManager.NameNotFoundException import android.os.Build import android.os.IBinder import androidx.lifecycle.DefaultLifecycleObserver @@ -19,6 +18,7 @@ import com.bitwarden.authenticatorbridge.model.EncryptedSharedAccountData import com.bitwarden.authenticatorbridge.provider.AuthenticatorBridgeCallbackProvider import com.bitwarden.authenticatorbridge.provider.StubAuthenticatorBridgeCallbackProvider import com.bitwarden.authenticatorbridge.provider.SymmetricKeyStorageProvider +import com.bitwarden.authenticatorbridge.util.PasswordManagerSignatureVerifier import com.bitwarden.authenticatorbridge.util.decrypt import com.bitwarden.authenticatorbridge.util.encrypt import com.bitwarden.authenticatorbridge.util.toFingerprint @@ -37,6 +37,7 @@ private const val AUTHENTICATOR_BRIDGE_SERVICE_CLASS = * @param connectionType Specifies which build variant to connect to. * @param symmetricKeyStorageProvider Provides access to local storage of the symmetric encryption * key. + * @param passwordManagerSignatureVerifier Verifies the authenticity of the Password Manager app. * @param callbackProvider Provides a way to construct a service callback that can be mocked in * tests. * @param processLifecycleOwner Lifecycle owner that is used to listen for start/stop @@ -45,6 +46,7 @@ private const val AUTHENTICATOR_BRIDGE_SERVICE_CLASS = internal class AuthenticatorBridgeManagerImpl( private val connectionType: AuthenticatorBridgeConnectionType, private val symmetricKeyStorageProvider: SymmetricKeyStorageProvider, + private val passwordManagerSignatureVerifier: PasswordManagerSignatureVerifier, callbackProvider: AuthenticatorBridgeCallbackProvider = StubAuthenticatorBridgeCallbackProvider(), context: Context, @@ -67,6 +69,7 @@ internal class AuthenticatorBridgeManagerImpl( !isBuildVersionAtLeast(Build.VERSION_CODES.S) -> { AccountSyncState.OsVersionNotSupported } + !isBitwardenAppInstalled() -> AccountSyncState.AppNotInstalled else -> AccountSyncState.Loading }, @@ -243,14 +246,12 @@ internal class AuthenticatorBridgeManagerImpl( } } - private fun isBitwardenAppInstalled(): Boolean = - // Check to see if correct Bitwarden app is installed: - try { - applicationContext.packageManager.getPackageInfo(connectionType.toPackageName(), 0) - true - } catch (e: NameNotFoundException) { - false - } + private fun isBitwardenAppInstalled(): Boolean { + // Verify the Password Manager app is installed and has a valid signature + return passwordManagerSignatureVerifier.isValidPasswordManagerApp( + packageName = connectionType.toPackageName(), + ) + } } /** diff --git a/authenticatorbridge/src/main/kotlin/com/bitwarden/authenticatorbridge/util/PasswordManagerSignatureVerifier.kt b/authenticatorbridge/src/main/kotlin/com/bitwarden/authenticatorbridge/util/PasswordManagerSignatureVerifier.kt new file mode 100644 index 0000000000..30cf683923 --- /dev/null +++ b/authenticatorbridge/src/main/kotlin/com/bitwarden/authenticatorbridge/util/PasswordManagerSignatureVerifier.kt @@ -0,0 +1,14 @@ +package com.bitwarden.authenticatorbridge.util + +/** + * Verifies that a Password Manager application is authentic by validating its signing certificate. + */ +internal interface PasswordManagerSignatureVerifier { + /** + * Validates the signature of the specified Password Manager package. + * + * @param packageName The package name to verify + * @return true if the package has a valid signature, false otherwise + */ + fun isValidPasswordManagerApp(packageName: String): Boolean +} diff --git a/authenticatorbridge/src/main/kotlin/com/bitwarden/authenticatorbridge/util/PasswordManagerSignatureVerifierImpl.kt b/authenticatorbridge/src/main/kotlin/com/bitwarden/authenticatorbridge/util/PasswordManagerSignatureVerifierImpl.kt new file mode 100644 index 0000000000..5828055086 --- /dev/null +++ b/authenticatorbridge/src/main/kotlin/com/bitwarden/authenticatorbridge/util/PasswordManagerSignatureVerifierImpl.kt @@ -0,0 +1,128 @@ +package com.bitwarden.authenticatorbridge.util + +import android.content.Context +import android.content.pm.PackageManager +import com.bitwarden.annotation.OmitFromCoverage +import com.bitwarden.authenticatorbridge.R +import timber.log.Timber +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException + +/** + * Default implementation of [PasswordManagerSignatureVerifier]. + * + * Validates Password Manager applications by verifying their cryptographic signing certificate + * against a whitelist of known-good certificate hashes. + * + * This addresses VULN-314: Insufficient package name validation allows TOTP secret harvesting + * when malicious applications spoof legitimate package names without proper cryptographic + * identity verification. + * + * Security Considerations: + * - Rejects apps with multiple signers to prevent signature rotation attacks + * - Uses GET_SIGNING_CERTIFICATES (API 28+) for secure certificate retrieval + * - Validates SHA-256 certificate fingerprint against hardcoded whitelist + * - Fails closed on any validation error or exception + * + * References: + * [1] Android AOSP. "APK Signature Scheme v3." + * https://source.android.com/docs/security/features/apksigning/v3 + * "Multiple signers are not supported and Google Play does not publish apps signed + * with multiple certificates" + * + * [2] Android Developers. "SigningInfo API Reference." + * https://developer.android.com/reference/android/content/pm/SigningInfo + * Deprecation of GET_SIGNATURES in favor of GET_SIGNING_CERTIFICATES for improved security. + * + * [3] OWASP MASTG. "MASTG-TEST-0038: Making Sure that the App is Properly Signed." + * https://mas.owasp.org/MASTG/tests/android/MASVS-RESILIENCE/MASTG-TEST-0038/ + * Best practices for signature validation and integrity verification. + * + * [4] GuardSquare. "Janus Vulnerability (CVE-2017-13156)." + * https://www.guardsquare.com/blog/janus-vulnerability + * Historical context: signature bypass attacks mitigated by v2/v3 schemes. + * + * @param context Android context for accessing PackageManager and resources + */ +@OmitFromCoverage +internal class PasswordManagerSignatureVerifierImpl( + private val context: Context, +) : PasswordManagerSignatureVerifier { + + private val knownPasswordManagerCertificates: List by lazy { + context.resources + .getStringArray(R.array.known_bitwarden_certs) + .toList() + } + + override fun isValidPasswordManagerApp(packageName: String): Boolean { + return try { + val packageManager = context.packageManager + val packageInfo = packageManager.getPackageInfo( + packageName, + PackageManager.GET_SIGNING_CERTIFICATES, + ) + + val signingInfo = packageInfo.signingInfo ?: run { + Timber.w( + "Signature verification failed: signingInfo is null for package $packageName", + ) + return false + } + + // Reject multiple signers to prevent signature rotation attacks. + // Bitwarden uses stable, long-lived signing certificates and never performs rotation. + // Any multi-signer scenario indicates: + // - Malicious rotation attempt + // - Compromised/tampered APK + // - Non-genuine Bitwarden application + // See: https://source.android.com/docs/security/features/apksigning/v3 + if (signingInfo.hasMultipleSigners()) { + Timber.w( + "Signature verification failed: multiple signers detected for $packageName", + ) + return false + } + + val signature = signingInfo.apkContentsSigners.first() + val sha256 = MessageDigest.getInstance("SHA-256") + val certHash = sha256.digest(signature.toByteArray()) + val certHashHex = certHash.joinToString("") { "%02x".format(it) } + + // Use constant-time comparison to prevent timing attacks + val isValid = knownPasswordManagerCertificates.any { knownCert -> + MessageDigest.isEqual( + knownCert.toByteArray(), + certHashHex.toByteArray(), + ) + } + if (!isValid) { + Timber.w( + "Signature verification failed for $packageName: unknown certificate hash", + ) + } + isValid + } catch (e: PackageManager.NameNotFoundException) { + Timber.w(e, "Signature verification failed for $packageName: package not found") + false + } catch (e: SecurityException) { + Timber.e( + e, + "Signature verification failed for $packageName: security exception", + ) + false + } catch (e: NoSuchAlgorithmException) { + Timber.e( + e, + "Signature verification failed for $packageName: SHA-256 unavailable", + ) + false + } catch (e: NoSuchElementException) { + Timber.e( + e, + "Signature verification failed for $packageName: no signing certificates", + ) + false + } + } +} diff --git a/authenticatorbridge/src/release/res/values/strings.xml b/authenticatorbridge/src/release/res/values/strings.xml new file mode 100644 index 0000000000..ab4ee1632c --- /dev/null +++ b/authenticatorbridge/src/release/res/values/strings.xml @@ -0,0 +1,12 @@ + + + + + 24e06c04c208048f19f1c993b4dda4430ea8b06db8375ea0e37b834696b9ac3a + + + de6ec91431557995297bf3e65bc80349bc603a04708160618c86bc9994171c90 + + diff --git a/authenticatorbridge/src/test/kotlin/com/bitwarden/authenticatorbridge/manager/AuthenticatorBridgeManagerTest.kt b/authenticatorbridge/src/test/kotlin/com/bitwarden/authenticatorbridge/manager/AuthenticatorBridgeManagerTest.kt index 9624c1143e..17055e6f35 100644 --- a/authenticatorbridge/src/test/kotlin/com/bitwarden/authenticatorbridge/manager/AuthenticatorBridgeManagerTest.kt +++ b/authenticatorbridge/src/test/kotlin/com/bitwarden/authenticatorbridge/manager/AuthenticatorBridgeManagerTest.kt @@ -3,7 +3,6 @@ package com.bitwarden.authenticatorbridge.manager import android.content.Context import android.content.Intent import android.content.ServiceConnection -import android.content.pm.PackageManager.NameNotFoundException import android.os.Build import com.bitwarden.authenticatorbridge.IAuthenticatorBridgeService import com.bitwarden.authenticatorbridge.IAuthenticatorBridgeServiceCallback @@ -13,6 +12,7 @@ import com.bitwarden.authenticatorbridge.model.EncryptedSharedAccountData import com.bitwarden.authenticatorbridge.model.SharedAccountData import com.bitwarden.authenticatorbridge.util.FakeLifecycleOwner import com.bitwarden.authenticatorbridge.util.FakeSymmetricKeyStorageProvider +import com.bitwarden.authenticatorbridge.util.PasswordManagerSignatureVerifier import com.bitwarden.authenticatorbridge.util.TestAuthenticatorBridgeCallbackProvider import com.bitwarden.authenticatorbridge.util.decrypt import com.bitwarden.authenticatorbridge.util.generateSecretKey @@ -44,6 +44,9 @@ class AuthenticatorBridgeManagerTest { } returns mockk() } private val mockBridgeService: IAuthenticatorBridgeService = mockk() + private val mockPasswordManagerSignatureVerifier = mockk { + every { isValidPasswordManagerApp(any()) } returns true + } private val fakeLifecycleOwner = FakeLifecycleOwner() private val fakeSymmetricKeyStorageProvider = FakeSymmetricKeyStorageProvider() private val testAuthenticatorBridgeCallbackProvider = TestAuthenticatorBridgeCallbackProvider() @@ -65,6 +68,7 @@ class AuthenticatorBridgeManagerTest { context = context, connectionType = AuthenticatorBridgeConnectionType.DEV, symmetricKeyStorageProvider = fakeSymmetricKeyStorageProvider, + passwordManagerSignatureVerifier = mockPasswordManagerSignatureVerifier, callbackProvider = testAuthenticatorBridgeCallbackProvider, processLifecycleOwner = fakeLifecycleOwner, ) @@ -86,12 +90,13 @@ class AuthenticatorBridgeManagerTest { @Test fun `initial AccountSyncState should be AppNotInstalled when Bitwarden app is not present`() { every { - context.packageManager.getPackageInfo("com.x8bit.bitwarden.dev", 0) - } throws NameNotFoundException() + mockPasswordManagerSignatureVerifier.isValidPasswordManagerApp(any()) + } returns false val manager = AuthenticatorBridgeManagerImpl( context = context, connectionType = AuthenticatorBridgeConnectionType.DEV, symmetricKeyStorageProvider = fakeSymmetricKeyStorageProvider, + passwordManagerSignatureVerifier = mockPasswordManagerSignatureVerifier, callbackProvider = testAuthenticatorBridgeCallbackProvider, processLifecycleOwner = fakeLifecycleOwner, ) @@ -127,10 +132,12 @@ class AuthenticatorBridgeManagerTest { @Test fun `onStart when Bitwarden app is not present should set state to AppNotInstalled`() { - val mockIntent: Intent = mockk() + // Mock verifier to return false (app not valid/installed) every { - context.packageManager.getPackageInfo("com.x8bit.bitwarden.dev", 0) - } throws NameNotFoundException() + mockPasswordManagerSignatureVerifier.isValidPasswordManagerApp(any()) + } returns false + + val mockIntent: Intent = mockk() every { anyConstructed().setComponent(any()) } returns mockIntent @@ -578,6 +585,7 @@ class AuthenticatorBridgeManagerTest { context = context, connectionType = AuthenticatorBridgeConnectionType.DEV, symmetricKeyStorageProvider = fakeSymmetricKeyStorageProvider, + passwordManagerSignatureVerifier = mockPasswordManagerSignatureVerifier, callbackProvider = testAuthenticatorBridgeCallbackProvider, processLifecycleOwner = fakeLifecycleOwner, )