mirror of
https://github.com/bitwarden/android.git
synced 2025-12-10 00:06:22 -06:00
[PM-27752] Add certificate signature verification to AuthenticatorBridge (#6126)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
dca97e0c8e
commit
94ed32790f
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
--------
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
8
authenticatorbridge/src/debug/res/values/strings.xml
Normal file
8
authenticatorbridge/src/debug/res/values/strings.xml
Normal 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>
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
12
authenticatorbridge/src/release/res/values/strings.xml
Normal file
12
authenticatorbridge/src/release/res/values/strings.xml
Normal 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>
|
||||
@ -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,
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user