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,
)