From 4479d56241e522cd45c22202761a7d2f53de9642 Mon Sep 17 00:00:00 2001 From: Andre Rosado Date: Tue, 18 Nov 2025 15:24:09 +0000 Subject: [PATCH] Checking signingCertificateHistory for a valid asset link certificate --- .../credentials/manager/OriginManagerImpl.kt | 60 +++-- .../platform/util/CallingAppInfoExtensions.kt | 18 ++ .../credentials/manager/OriginManagerTest.kt | 240 ++++++++++++++++++ .../util/CallingAppInfoExtensionsTest.kt | 43 ++++ 4 files changed, 339 insertions(+), 22 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/manager/OriginManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/manager/OriginManagerImpl.kt index 7db20e95162..c8a59c189b4 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/manager/OriginManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/manager/OriginManagerImpl.kt @@ -6,7 +6,7 @@ import com.bitwarden.ui.platform.base.util.prefixHttpsIfNecessary import com.x8bit.bitwarden.data.credentials.model.ValidateOriginResult import com.x8bit.bitwarden.data.credentials.repository.PrivilegedAppRepository import com.x8bit.bitwarden.data.platform.manager.AssetManager -import com.x8bit.bitwarden.data.platform.util.getSignatureFingerprintAsHexString +import com.x8bit.bitwarden.data.platform.util.getAllSignatureFingerprintsAsHexStrings import com.x8bit.bitwarden.data.platform.util.validatePrivilegedApp import timber.log.Timber @@ -38,27 +38,43 @@ class OriginManagerImpl( relyingPartyId: String, callingAppInfo: CallingAppInfo, ): ValidateOriginResult { - return digitalAssetLinkService - .checkDigitalAssetLinksRelations( - sourceWebSite = relyingPartyId.prefixHttpsIfNecessary(), - targetPackageName = callingAppInfo.packageName, - targetCertificateFingerprint = callingAppInfo - .getSignatureFingerprintAsHexString() - .orEmpty(), - relations = listOf(DELEGATE_PERMISSION_HANDLE_ALL_URLS), - ) - .fold( - onSuccess = { - if (it.linked) { - ValidateOriginResult.Success(null) - } else { - ValidateOriginResult.Error.PasskeyNotSupportedForApp - } - }, - onFailure = { - ValidateOriginResult.Error.AssetLinkNotFound - }, - ) + val fingerprints = callingAppInfo.getAllSignatureFingerprintsAsHexStrings() + + if (fingerprints.isEmpty()) { + return ValidateOriginResult.Error.PasskeyNotSupportedForApp + } + + var assetLinkFound = false + + // Check each fingerprint in the signing certificate history + return fingerprints + .firstNotNullOfOrNull { fingerprint -> + digitalAssetLinkService + .checkDigitalAssetLinksRelations( + sourceWebSite = relyingPartyId.prefixHttpsIfNecessary(), + targetPackageName = callingAppInfo.packageName, + targetCertificateFingerprint = fingerprint, + relations = listOf(DELEGATE_PERMISSION_HANDLE_ALL_URLS), + ) + .fold( + onSuccess = { + assetLinkFound = true + if (it.linked) { + ValidateOriginResult.Success(null) + } else { + null + } + }, + onFailure = { + null + }, + ) + } + ?: if (assetLinkFound) { + ValidateOriginResult.Error.PasskeyNotSupportedForApp + } else { + ValidateOriginResult.Error.AssetLinkNotFound + } } private suspend fun validatePrivilegedAppOrigin( diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensions.kt index 26d1416d423..341d26e6bd0 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensions.kt @@ -17,6 +17,24 @@ fun CallingAppInfo.getSignatureFingerprintAsHexString(): String? { } } +/** + * Returns a list of all signing certificate hashes formatted as hex strings from the signing + * certificate history. This includes the current signing certificate and any previous ones + * (due to key rotation). + */ +@OptIn(ExperimentalStdlibApi::class) +fun CallingAppInfo.getAllSignatureFingerprintsAsHexStrings(): List { + if (signingInfo.hasMultipleSigners()) return emptyList() + + val md = MessageDigest.getInstance(SHA_ALGORITHM) + return signingInfo.signingCertificateHistory.map { signature -> + val hash = md.digest(signature.toByteArray()) + hash.joinToString(":") { b -> + b.toHexString(HexFormat.UpperCase) + } + } +} + /** * Returns true if this [CallingAppInfo] is present in the privileged app [allowList]. Otherwise, * returns false. diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/manager/OriginManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/manager/OriginManagerTest.kt index 9cbe70d40df..c49241bb4ef 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/manager/OriginManagerTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/manager/OriginManagerTest.kt @@ -38,6 +38,7 @@ class OriginManagerTest { every { getOrigin(any()) } returns null every { signingInfo } returns mockk { every { apkContentsSigners } returns arrayOf(Signature(DEFAULT_APP_SIGNATURE)) + every { signingCertificateHistory } returns arrayOf(Signature(DEFAULT_APP_SIGNATURE)) every { hasMultipleSigners() } returns false } } @@ -242,11 +243,248 @@ class OriginManagerTest { ), ) } + + @Suppress("MaxLineLength") + @Test + fun `validateOrigin should check all certificate fingerprints from signing history`() = + runTest { + val customMessageDigest = mockk { + every { digest(DEFAULT_APP_SIGNATURE.toByteArray()) } returns DEFAULT_APP_SIGNATURE.toByteArray() + every { digest(SECOND_APP_SIGNATURE.toByteArray()) } returns SECOND_APP_SIGNATURE.toByteArray() + } + every { MessageDigest.getInstance(any()) } returns customMessageDigest + + val mockAppInfoWithHistory = mockk { + every { isOriginPopulated() } returns false + every { packageName } returns DEFAULT_PACKAGE_NAME + every { signingInfo } returns mockk { + every { hasMultipleSigners() } returns false + every { signingCertificateHistory } returns arrayOf( + mockk { every { toByteArray() } returns DEFAULT_APP_SIGNATURE.toByteArray() }, + mockk { every { toByteArray() } returns SECOND_APP_SIGNATURE.toByteArray() }, + ) + } + } + + // First call with old signature fails + coEvery { + mockDigitalAssetLinkService.checkDigitalAssetLinksRelations( + sourceWebSite = HTTPS_DEFAULT_RELYING_PARTY_ID, + targetPackageName = DEFAULT_PACKAGE_NAME, + targetCertificateFingerprint = DEFAULT_CERT_FINGERPRINT, + relations = DELEGATE_PERMISSION_RELATIONS, + ) + } returns DEFAULT_ASSET_LINKS_CHECK_RESPONSE.copy(linked = false).asSuccess() + + // Second call with new signature succeeds + coEvery { + mockDigitalAssetLinkService.checkDigitalAssetLinksRelations( + sourceWebSite = HTTPS_DEFAULT_RELYING_PARTY_ID, + targetPackageName = DEFAULT_PACKAGE_NAME, + targetCertificateFingerprint = SECOND_CERT_FINGERPRINT, + relations = DELEGATE_PERMISSION_RELATIONS, + ) + } returns DEFAULT_ASSET_LINKS_CHECK_RESPONSE.asSuccess() + + val result = originManager.validateOrigin( + relyingPartyId = DEFAULT_RELYING_PARTY_ID, + callingAppInfo = mockAppInfoWithHistory, + ) + + // Verify both fingerprints were checked + coVerify(exactly = 1) { + mockDigitalAssetLinkService.checkDigitalAssetLinksRelations( + sourceWebSite = HTTPS_DEFAULT_RELYING_PARTY_ID, + targetPackageName = DEFAULT_PACKAGE_NAME, + targetCertificateFingerprint = DEFAULT_CERT_FINGERPRINT, + relations = DELEGATE_PERMISSION_RELATIONS, + ) + } + coVerify(exactly = 1) { + mockDigitalAssetLinkService.checkDigitalAssetLinksRelations( + sourceWebSite = HTTPS_DEFAULT_RELYING_PARTY_ID, + targetPackageName = DEFAULT_PACKAGE_NAME, + targetCertificateFingerprint = SECOND_CERT_FINGERPRINT, + relations = DELEGATE_PERMISSION_RELATIONS, + ) + } + + assertEquals( + ValidateOriginResult.Success(null), + result, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `validateOrigin should return Success on first matching fingerprint from signing history`() = + runTest { + val customMessageDigest = mockk { + every { digest(DEFAULT_APP_SIGNATURE.toByteArray()) } returns DEFAULT_APP_SIGNATURE.toByteArray() + every { digest(SECOND_APP_SIGNATURE.toByteArray()) } returns SECOND_APP_SIGNATURE.toByteArray() + } + every { MessageDigest.getInstance(any()) } returns customMessageDigest + + val mockAppInfoWithHistory = mockk { + every { isOriginPopulated() } returns false + every { packageName } returns DEFAULT_PACKAGE_NAME + every { signingInfo } returns mockk { + every { hasMultipleSigners() } returns false + every { signingCertificateHistory } returns arrayOf( + mockk { every { toByteArray() } returns DEFAULT_APP_SIGNATURE.toByteArray() }, + mockk { every { toByteArray() } returns SECOND_APP_SIGNATURE.toByteArray() }, + ) + } + } + + // First call with old signature succeeds + coEvery { + mockDigitalAssetLinkService.checkDigitalAssetLinksRelations( + sourceWebSite = HTTPS_DEFAULT_RELYING_PARTY_ID, + targetPackageName = DEFAULT_PACKAGE_NAME, + targetCertificateFingerprint = DEFAULT_CERT_FINGERPRINT, + relations = DELEGATE_PERMISSION_RELATIONS, + ) + } returns DEFAULT_ASSET_LINKS_CHECK_RESPONSE.asSuccess() + + val result = originManager.validateOrigin( + relyingPartyId = DEFAULT_RELYING_PARTY_ID, + callingAppInfo = mockAppInfoWithHistory, + ) + + // Verify only the first fingerprint was checked (early return on success) + coVerify(exactly = 1) { + mockDigitalAssetLinkService.checkDigitalAssetLinksRelations( + sourceWebSite = HTTPS_DEFAULT_RELYING_PARTY_ID, + targetPackageName = DEFAULT_PACKAGE_NAME, + targetCertificateFingerprint = DEFAULT_CERT_FINGERPRINT, + relations = DELEGATE_PERMISSION_RELATIONS, + ) + } + coVerify(exactly = 0) { + mockDigitalAssetLinkService.checkDigitalAssetLinksRelations( + sourceWebSite = HTTPS_DEFAULT_RELYING_PARTY_ID, + targetPackageName = DEFAULT_PACKAGE_NAME, + targetCertificateFingerprint = SECOND_CERT_FINGERPRINT, + relations = DELEGATE_PERMISSION_RELATIONS, + ) + } + + assertEquals( + ValidateOriginResult.Success(null), + result, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `validateOrigin should return AssetLinkNotFound when no fingerprints match from signing history`() = + runTest { + val customMessageDigest = mockk { + every { digest(DEFAULT_APP_SIGNATURE.toByteArray()) } returns DEFAULT_APP_SIGNATURE.toByteArray() + every { digest(SECOND_APP_SIGNATURE.toByteArray()) } returns SECOND_APP_SIGNATURE.toByteArray() + } + every { MessageDigest.getInstance(any()) } returns customMessageDigest + + val mockAppInfoWithHistory = mockk { + every { isOriginPopulated() } returns false + every { packageName } returns DEFAULT_PACKAGE_NAME + every { signingInfo } returns mockk { + every { hasMultipleSigners() } returns false + every { signingCertificateHistory } returns arrayOf( + mockk { every { toByteArray() } returns DEFAULT_APP_SIGNATURE.toByteArray() }, + mockk { every { toByteArray() } returns SECOND_APP_SIGNATURE.toByteArray() }, + ) + } + } + + // Both calls fail + coEvery { + mockDigitalAssetLinkService.checkDigitalAssetLinksRelations( + sourceWebSite = HTTPS_DEFAULT_RELYING_PARTY_ID, + targetPackageName = DEFAULT_PACKAGE_NAME, + targetCertificateFingerprint = DEFAULT_CERT_FINGERPRINT, + relations = DELEGATE_PERMISSION_RELATIONS, + ) + } returns RuntimeException().asFailure() + + coEvery { + mockDigitalAssetLinkService.checkDigitalAssetLinksRelations( + sourceWebSite = HTTPS_DEFAULT_RELYING_PARTY_ID, + targetPackageName = DEFAULT_PACKAGE_NAME, + targetCertificateFingerprint = SECOND_CERT_FINGERPRINT, + relations = DELEGATE_PERMISSION_RELATIONS, + ) + } returns RuntimeException().asFailure() + + val result = originManager.validateOrigin( + relyingPartyId = DEFAULT_RELYING_PARTY_ID, + callingAppInfo = mockAppInfoWithHistory, + ) + + // Verify both fingerprints were checked + coVerify(exactly = 1) { + mockDigitalAssetLinkService.checkDigitalAssetLinksRelations( + sourceWebSite = HTTPS_DEFAULT_RELYING_PARTY_ID, + targetPackageName = DEFAULT_PACKAGE_NAME, + targetCertificateFingerprint = DEFAULT_CERT_FINGERPRINT, + relations = DELEGATE_PERMISSION_RELATIONS, + ) + } + coVerify(exactly = 1) { + mockDigitalAssetLinkService.checkDigitalAssetLinksRelations( + sourceWebSite = HTTPS_DEFAULT_RELYING_PARTY_ID, + targetPackageName = DEFAULT_PACKAGE_NAME, + targetCertificateFingerprint = SECOND_CERT_FINGERPRINT, + relations = DELEGATE_PERMISSION_RELATIONS, + ) + } + + assertEquals( + ValidateOriginResult.Error.AssetLinkNotFound, + result, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `validateOrigin should return PasskeyNotSupportedForApp when app has multiple signers`() = + runTest { + val mockAppInfoWithMultipleSigners = mockk { + every { isOriginPopulated() } returns false + every { packageName } returns DEFAULT_PACKAGE_NAME + every { signingInfo } returns mockk { + every { hasMultipleSigners() } returns true + } + } + + val result = originManager.validateOrigin( + relyingPartyId = DEFAULT_RELYING_PARTY_ID, + callingAppInfo = mockAppInfoWithMultipleSigners, + ) + + // Verify no asset link service calls were made + coVerify(exactly = 0) { + mockDigitalAssetLinkService.checkDigitalAssetLinksRelations( + any(), + any(), + any(), + any(), + ) + } + + assertEquals( + ValidateOriginResult.Error.PasskeyNotSupportedForApp, + result, + ) + } } private const val DEFAULT_PACKAGE_NAME = "com.x8bit.bitwarden" private const val DEFAULT_APP_SIGNATURE = "0987654321ABCDEF" +private const val SECOND_APP_SIGNATURE = "FEDCBA9876543210" private const val DEFAULT_CERT_FINGERPRINT = "30:39:38:37:36:35:34:33:32:31:41:42:43:44:45:46" +private const val SECOND_CERT_FINGERPRINT = "46:45:44:43:42:41:39:38:37:36:35:34:33:32:31:30" private const val DEFAULT_ORIGIN = "bitwarden.com" private const val DEFAULT_RELYING_PARTY_ID = "www.bitwarden.com" private const val GOOGLE_ALLOW_LIST_FILENAME = "fido2_privileged_google.json" @@ -295,6 +533,8 @@ private const val FAIL_ALLOW_LIST = """ ] } """ +private const val HTTPS_DEFAULT_RELYING_PARTY_ID = "https://$DEFAULT_RELYING_PARTY_ID" +private val DELEGATE_PERMISSION_RELATIONS = listOf("delegate_permission/common.handle_all_urls") private val DEFAULT_ASSET_LINKS_CHECK_RESPONSE = DigitalAssetLinkCheckResponseJson( linked = true, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensionsTest.kt index 786b3332b25..d174843cc50 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensionsTest.kt @@ -180,6 +180,49 @@ class CallingAppInfoExtensionsTest { assertNull(mockAppInfo.getAppSigningSignatureFingerprint()) } + + @Test + fun `getAllSignatureFingerprintsAsHexStrings should return all certificate fingerprints`() { + val signature1 = "ABCDEF0123456789" + val signature2 = "9876543210FEDCBA" + val mockMessageDigest = mockk { + every { digest(signature1.toByteArray()) } returns signature1.toByteArray() + every { digest(signature2.toByteArray()) } returns signature2.toByteArray() + } + every { MessageDigest.getInstance(any()) } returns mockMessageDigest + + val mockSigningInfo = mockk { + every { hasMultipleSigners() } returns false + every { signingCertificateHistory } returns arrayOf( + mockk { every { toByteArray() } returns signature1.toByteArray() }, + mockk { every { toByteArray() } returns signature2.toByteArray() }, + ) + } + val appInfo = mockk { + every { signingInfo } returns mockSigningInfo + } + + val result = appInfo.getAllSignatureFingerprintsAsHexStrings() + + assertEquals(2, result.size) + assertEquals("41:42:43:44:45:46:30:31:32:33:34:35:36:37:38:39", result[0]) + assertEquals("39:38:37:36:35:34:33:32:31:30:46:45:44:43:42:41", result[1]) + } + + @Suppress("MaxLineLength") + @Test + fun `getAllSignatureFingerprintsAsHexStrings should return empty list when app has multiple signers`() { + val mockSigningInfo = mockk { + every { hasMultipleSigners() } returns true + } + val appInfo = mockk { + every { signingInfo } returns mockSigningInfo + } + + val result = appInfo.getAllSignatureFingerprintsAsHexStrings() + + assertEquals(0, result.size) + } } private const val DEFAULT_SIGNATURE = "0987654321ABCDEF"