[PM-27752] Add certificate signature verification to AuthenticatorBridge (#6126)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Patrick Honkonen 2025-11-06 08:15:26 -05:00 committed by GitHub
parent dca97e0c8e
commit 94ed32790f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 200 additions and 19 deletions

View File

@ -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
--------

View File

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

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="known_bitwarden_certs">
<!-- This is the SHA-256 digest for Debug signing key of the Bitwarden App debug
variant: -->
<item>0418ff9b3a883040ee3e1931093a7edca19b2f87f455388f28906bf262e32de7</item>
</string-array>
</resources>

View File

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

View File

@ -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,13 +246,11 @@ 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(),
)
}
}

View File

@ -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
}

View File

@ -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<String> 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
}
}
}

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="known_bitwarden_certs">
<!-- This is the SHA-256 digest for Google Play signing key of the Bitwarden App release
variant: -->
<item>24e06c04c208048f19f1c993b4dda4430ea8b06db8375ea0e37b834696b9ac3a</item>
<!-- This is the SHA-256 digest for F-Droid App Distribution signing key of the
Bitwarden App release variant: -->
<item>de6ec91431557995297bf3e65bc80349bc603a04708160618c86bc9994171c90</item>
</string-array>
</resources>

View File

@ -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<PasswordManagerSignatureVerifier> {
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<Intent>().setComponent(any())
} returns mockIntent
@ -578,6 +585,7 @@ class AuthenticatorBridgeManagerTest {
context = context,
connectionType = AuthenticatorBridgeConnectionType.DEV,
symmetricKeyStorageProvider = fakeSymmetricKeyStorageProvider,
passwordManagerSignatureVerifier = mockPasswordManagerSignatureVerifier,
callbackProvider = testAuthenticatorBridgeCallbackProvider,
processLifecycleOwner = fakeLifecycleOwner,
)