[PM-10373] Fix FIDO 2 credential creation from unprivileged apps (#3658)

This commit is contained in:
Patrick Honkonen 2024-08-05 10:28:37 -04:00 committed by GitHub
parent f46d12c7b1
commit 31bf696e7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 175 additions and 67 deletions

View file

@ -55,18 +55,22 @@ class Fido2CredentialManagerImpl(
selectedCipherView: CipherView, selectedCipherView: CipherView,
): Fido2RegisterCredentialResult { ): Fido2RegisterCredentialResult {
val clientData = if (fido2CredentialRequest.callingAppInfo.isOriginPopulated()) { val clientData = if (fido2CredentialRequest.callingAppInfo.isOriginPopulated()) {
fido2CredentialRequest.callingAppInfo.getAppSigningSignatureFingerprint() fido2CredentialRequest
?.let { ClientData.DefaultWithCustomHash(hash = it) }
?: return Fido2RegisterCredentialResult.Error
} else {
ClientData.DefaultWithExtraData(
androidPackageName = fido2CredentialRequest
.callingAppInfo .callingAppInfo
.getAppOrigin(), .getAppSigningSignatureFingerprint()
) ?.let { ClientData.DefaultWithCustomHash(hash = it) }
} ?: return Fido2RegisterCredentialResult.Error
val origin = fido2CredentialRequest.origin } else {
?: fido2CredentialRequest.callingAppInfo.getAppOrigin() ClientData.DefaultWithExtraData(
androidPackageName = fido2CredentialRequest
.callingAppInfo
.packageName,
)
}
val origin = fido2CredentialRequest
.origin
?: getOriginUrlFromAttestationOptionsOrNull(fido2CredentialRequest.requestJson)
?: return Fido2RegisterCredentialResult.Error
return vaultSdkSource return vaultSdkSource
.registerFido2Credential( .registerFido2Credential(
@ -132,13 +136,15 @@ class Fido2CredentialManagerImpl(
val clientData = request.clientDataHash val clientData = request.clientDataHash
?.let { ClientData.DefaultWithCustomHash(hash = it) } ?.let { ClientData.DefaultWithCustomHash(hash = it) }
?: ClientData.DefaultWithExtraData(androidPackageName = callingAppInfo.getAppOrigin()) ?: ClientData.DefaultWithExtraData(androidPackageName = callingAppInfo.getAppOrigin())
val origin = request.origin
?: getOriginUrlFromAssertionOptionsOrNull(request.requestJson)
?: return Fido2CredentialAssertionResult.Error
return vaultSdkSource return vaultSdkSource
.authenticateFido2Credential( .authenticateFido2Credential(
request = AuthenticateFido2CredentialRequest( request = AuthenticateFido2CredentialRequest(
userId = userId, userId = userId,
origin = callingAppInfo.origin origin = origin,
?: callingAppInfo.getAppOrigin(),
requestJson = """{"publicKey": ${request.requestJson}}""", requestJson = """{"publicKey": ${request.requestJson}}""",
clientData = clientData, clientData = clientData,
selectedCipherView = selectedCipherView, selectedCipherView = selectedCipherView,
@ -257,6 +263,18 @@ class Fido2CredentialManagerImpl(
override fun hasAuthenticationAttemptsRemaining(): Boolean = override fun hasAuthenticationAttemptsRemaining(): Boolean =
authenticationAttempts < MAX_AUTHENTICATION_ATTEMPTS authenticationAttempts < MAX_AUTHENTICATION_ATTEMPTS
private fun getOriginUrlFromAssertionOptionsOrNull(requestJson: String) =
getPasskeyAssertionOptionsOrNull(requestJson)
?.relyingPartyId
?.let { "$HTTPS$it" }
private fun getOriginUrlFromAttestationOptionsOrNull(requestJson: String) =
getPasskeyAttestationOptionsOrNull(requestJson)
?.relyingParty
?.id
?.let { "$HTTPS$it" }
} }
private const val MAX_AUTHENTICATION_ATTEMPTS = 5 private const val MAX_AUTHENTICATION_ATTEMPTS = 5
private const val HTTPS = "https://"

View file

@ -27,6 +27,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.AuthenticateFido2CredentialRequest import com.x8bit.bitwarden.data.vault.datasource.sdk.model.AuthenticateFido2CredentialRequest
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.RegisterFido2CredentialRequest import com.x8bit.bitwarden.data.vault.datasource.sdk.model.RegisterFido2CredentialRequest
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockPublicKeyAssertionResponse
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockPublicKeyAttestationResponse import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockPublicKeyAttestationResponse
import com.x8bit.bitwarden.data.vault.datasource.sdk.util.toAndroidFido2PublicKeyCredential import com.x8bit.bitwarden.data.vault.datasource.sdk.util.toAndroidFido2PublicKeyCredential
import com.x8bit.bitwarden.ui.vault.feature.addedit.util.createMockPasskeyAssertionOptions import com.x8bit.bitwarden.ui.vault.feature.addedit.util.createMockPasskeyAssertionOptions
@ -571,6 +572,39 @@ class Fido2CredentialManagerTest {
) )
} }
@Test
fun `registerFido2Credential should return Error when origin is null`() = runTest {
val mockAssertionRequest = createMockFido2CredentialRequest(
number = 1,
origin = null,
signingInfo = mockSigningInfo,
)
val mockSelectedCipher = createMockCipherView(number = 1)
every { Base64.encodeToString(any(), any()) } returns DEFAULT_APP_SIGNATURE
every {
json.decodeFromString<PasskeyAttestationOptions>(any())
} throws SerializationException()
val result = fido2CredentialManager.registerFido2Credential(
userId = "activeUserId",
fido2CredentialRequest = mockAssertionRequest,
selectedCipherView = mockSelectedCipher,
)
coVerify(exactly = 0) {
mockVaultSdkSource.registerFido2Credential(
request = any(),
fido2CredentialStore = any(),
)
}
assertEquals(
Fido2RegisterCredentialResult.Error,
result,
)
}
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@Test @Test
fun `hasAuthenticationAttemptsRemaining returns true when authenticationAttempts is less than 5`() { fun `hasAuthenticationAttemptsRemaining returns true when authenticationAttempts is less than 5`() {
@ -700,65 +734,87 @@ class Fido2CredentialManagerTest {
) )
} }
@Suppress("MaxLineLength")
@Test @Test
fun `authenticateFido2Credential should use apk key hash for privileged apps`() = runTest { fun `authenticateFido2Credential should construct correct assertion request when calling app is unprivileged`() =
every { runTest {
mockSigningInfo.apkContentsSigners val mockAssertionRequest = createMockFido2CredentialAssertionRequest(
} returns arrayOf(Signature(DEFAULT_APP_SIGNATURE)) number = 1,
every { origin = null,
mockSigningInfo.hasMultipleSigners() clientDataHash = null,
} returns false signingInfo = mockSigningInfo,
val mockCipherView = createMockCipherView(number = 1)
val mockRequest = createMockFido2CredentialAssertionRequest(
number = 1,
clientDataHash = null,
signingInfo = mockSigningInfo,
origin = null,
)
val requestCaptureSlot = slot<AuthenticateFido2CredentialRequest>()
val mockSdkRequest = createAuthenticateFido2CredentialRequest(
number = 1,
clientData = ClientData.DefaultWithExtraData(
androidPackageName = "android:apk-key-hash:$DEFAULT_APP_SIGNATURE",
),
mockRequest = mockRequest,
mockCipherView = mockCipherView,
origin = "android:apk-key-hash:$DEFAULT_APP_SIGNATURE",
)
val mockSdkResponse =
mockk<PublicKeyCredentialAuthenticatorAssertionResponse>(relaxed = true)
every { Base64.encodeToString(any(), any()) } returns DEFAULT_APP_SIGNATURE
coEvery {
mockVaultSdkSource.authenticateFido2Credential(
request = mockSdkRequest,
fido2CredentialStore = any(),
) )
} returns mockSdkResponse.asSuccess() val mockAssertionOptions = createMockPasskeyAssertionOptions(number = 1)
val mockSelectedCipher = createMockCipherView(number = 1)
fido2CredentialManager.authenticateFido2Credential( val mockSdkRequest = createAuthenticateFido2CredentialRequest(
userId = "activeUserId", number = 1,
request = mockRequest, origin = "https://${mockAssertionOptions.relyingPartyId}",
selectedCipherView = createMockCipherView(number = 1),
)
coVerify {
mockVaultSdkSource.authenticateFido2Credential(
request = capture(requestCaptureSlot),
fido2CredentialStore = any(),
)
}
assertEquals(
AuthenticateFido2CredentialRequest(
userId = "activeUserId",
origin = "android:apk-key-hash:$DEFAULT_APP_SIGNATURE",
requestJson = """{"publicKey": ${mockRequest.requestJson}}""",
clientData = ClientData.DefaultWithExtraData( clientData = ClientData.DefaultWithExtraData(
androidPackageName = "android:apk-key-hash:$DEFAULT_APP_SIGNATURE", androidPackageName = "android:apk-key-hash:$DEFAULT_APP_SIGNATURE",
), ),
selectedCipherView = mockCipherView, )
isUserVerificationSupported = true, val requestCaptureSlot = slot<AuthenticateFido2CredentialRequest>()
),
requestCaptureSlot.captured, every { Base64.encodeToString(any(), any()) } returns DEFAULT_APP_SIGNATURE
coEvery {
mockVaultSdkSource.authenticateFido2Credential(
request = mockSdkRequest,
fido2CredentialStore = any(),
)
} returns createMockPublicKeyAssertionResponse(number = 1).asSuccess()
fido2CredentialManager.authenticateFido2Credential(
userId = "activeUserId",
request = mockAssertionRequest,
selectedCipherView = mockSelectedCipher,
)
coVerify {
mockVaultSdkSource.authenticateFido2Credential(
request = capture(requestCaptureSlot),
fido2CredentialStore = any(),
)
}
assertEquals(
"https://${mockAssertionOptions.relyingPartyId}",
requestCaptureSlot.captured.origin,
)
}
@Suppress("MaxLineLength")
@Test
fun `authenticateFido2Credential should return Error when origin is null`() = runTest {
val mockAssertionRequest = createMockFido2CredentialAssertionRequest(
number = 1,
origin = null,
clientDataHash = null,
signingInfo = mockSigningInfo,
)
val mockSelectedCipher = createMockCipherView(number = 1)
every { Base64.encodeToString(any(), any()) } returns DEFAULT_APP_SIGNATURE
every {
json.decodeFromString<PasskeyAssertionOptions>(any())
} throws SerializationException()
val result = fido2CredentialManager.authenticateFido2Credential(
userId = "activeUserId",
request = mockAssertionRequest,
selectedCipherView = mockSelectedCipher,
)
coVerify(exactly = 0) {
mockVaultSdkSource.authenticateFido2Credential(
request = any(),
fido2CredentialStore = any(),
)
}
assertEquals(
Fido2CredentialAssertionResult.Error,
result,
) )
} }

View file

@ -0,0 +1,34 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk.model
import com.bitwarden.fido.AuthenticatorAssertionResponse
import com.bitwarden.fido.ClientExtensionResults
import com.bitwarden.fido.CredPropsResult
import com.bitwarden.fido.PublicKeyCredentialAuthenticatorAssertionResponse
import com.bitwarden.fido.SelectedCredential
/**
* Creates a mock [PublicKeyCredentialAuthenticatorAssertionResponse] for testing.
*/
fun createMockPublicKeyAssertionResponse(number: Int) =
PublicKeyCredentialAuthenticatorAssertionResponse(
id = "mockId-$number",
rawId = "mockId-$number".toByteArray(),
ty = "mockTy-$number",
authenticatorAttachment = "mockAuthenticatorAttachment-$number",
clientExtensionResults = ClientExtensionResults(
credProps = CredPropsResult(
rk = true,
authenticatorDisplayName = "mockAuthenticatorDisplayName-$number",
),
),
response = AuthenticatorAssertionResponse(
clientDataJson = byteArrayOf(0),
authenticatorData = byteArrayOf(0),
signature = byteArrayOf(0),
userHandle = byteArrayOf(0),
),
selectedCredential = SelectedCredential(
cipher = createMockCipherView(number = number),
credential = createMockFido2CredentialView(number = 1),
),
)