8000 [PM-11135] Implemented Fido2 excluded credential logic for registration by fedemkr · Pull Request #1332 · bitwarden/ios · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

[PM-11135] Implemented Fido2 excluded credential logic for registration #1332

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Feb 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,22 @@ import BitwardenSdk

@available(iOSApplicationExtension 17.0, *)
extension ASPasskeyCredentialRequest {
// MARK: Methods

/// Gets the excluded credentials list from this request.
/// - Returns: An array of `PublicKeyCredentialDescriptor` for excluded credentials based on this request.
func excludedCredentialsList() -> [PublicKeyCredentialDescriptor]? {
guard #available(iOS 18.0, *),
let excludedCredentials,
!excludedCredentials.isEmpty else {
return nil
}

return excludedCredentials.map { PublicKeyCredentialDescriptor(from: $0) }
}

/// Gets an array of the `PublicKeyCredentialParameters` based on this request.
/// - Returns: An array of `PublicKeyCredentialParameters`.
func getPublicKeyCredentialParams() -> [PublicKeyCredentialParameters] {
guard !supportedAlgorithms.isEmpty else {
return [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,76 @@ import XCTest

@testable import BitwardenShared

@available(iOS 17.0, *)
class ASPasskeyCredentialRequestExtensionsTests: BitwardenTestCase { // swiftlint:disable:this type_name
// MARK: Tests

/// `excludedCredentialsList()` gets the excluded credential list using the
/// `PublicKeyCredentialDescriptor`.
func test_excludedCredentialsList() throws {
guard #available(iOS 17.0, *) else {
throw XCTSkip("Skipped on iOS < 17.0")
}
let data = Data(repeating: 2, count: 16)
let request = MockASPasskeyCredentialRequest(
credentialIdentity: .fixture(),
clientDataHash: Data(capacity: 16),
userVerificationPreference: .preferred,
supportedAlgorithms: [-7]
)
request.setExcludedCredentials([
ASAuthorizationPlatformPublicKeyCredentialDescriptor(credentialID: data),
])
let excludedList = request.excludedCredentialsList()
guard #available(iOS 18.0, *) else {
XCTAssertNil(excludedList)
return
}
XCTAssertEqual(excludedList?.count, 1)

let excludedCredential = try XCTUnwrap(excludedList?.first)
XCTAssertEqual(excludedCredential.id, data)
XCTAssertEqual(excludedCredential.ty, Constants.defaultFido2PublicKeyCredentialType)
XCTAssertNil(excludedCredential.transports)
}

/// `excludedCredentialsList()` returns `nil` when the request excluded credentials are empty.
func test_excludedCredentialsList_nilWhenEmpty() throws {
guard #available(iOS 17.0, *) else {
throw XCTSkip("Skipped on iOS < 17.0")
}
let request = MockASPasskeyCredentialRequest(
credentialIdentity: .fixture(),
clientDataHash: Data(capacity: 16),
userVerificationPreference: .preferred,
supportedAlgorithms: [-7]
)
request.setExcludedCredentials([])
let excludedList = request.excludedCredentialsList()
XCTAssertNil(excludedList)
}

/// `excludedCredentialsList()` returns `nil` when the request excluded credentials are `nil`.
func test_excludedCredentialsList_nilWhenNil() throws {
guard #available(iOS 17.0, *) else {
throw XCTSkip("Skipped on iOS < 17.0")
}
let request = MockASPasskeyCredentialRequest(
credentialIdentity: .fixture(),
clientDataHash: Data(capacity: 16),
userVerificationPreference: .preferred,
supportedAlgorithms: [-7]
)
request.setExcludedCredentials(nil)
let excludedList = request.excludedCredentialsList()
XCTAssertNil(excludedList)
}

/// `getPublicKeyCredentialParams` returns ES256 and RS256 when
/// supported algorithms is empty.
func test_getPublicKeyCredentialParams_empty() throws {
guard #available(iOS 17.0, *) else {
throw XCTSkip("Skipped on iOS < 17.0")
}
let subject = ASPasskeyCredentialRequest(
credentialIdentity: .fixture(),
clientDataHash: Data(capacity: 16),
Expand All @@ -27,6 +90,9 @@ class ASPasskeyCredentialRequestExtensionsTests: BitwardenTestCase { // swiftlin
/// `getPublicKeyCredentialParams` returns ES256 when
/// supported algorithms contains ES256.
func test_getPublicKeyCredentialParams_es256() throws {
guard #available(iOS 17.0, *) else {
throw XCTSkip("Skipped on iOS < 17.0")
}
let subject = ASPasskeyCredentialRequest(
credentialIdentity: .fixture(),
clientDataHash: Data(capacity: 16),
Expand All @@ -46,6 +112,9 @@ class ASPasskeyCredentialRequestExtensionsTests: BitwardenTestCase { // swiftlin
/// `getPublicKeyCredentialParams` returns empty when
/// supported algorithms is not empty but doesn't contain ES256.
func test_getPublicKeyCredentialParams_noES256() throws {
guard #available(iOS 17.0, *) else {
throw XCTSkip("Skipped on iOS < 17.0")
}
let subject = ASPasskeyCredentialRequest(
credentialIdentity: .fixture(),
clientDataHash: Data(capacity: 16),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import AuthenticationServices
import BitwardenSdk

/// Extension with helpers for `PublicKeyCredentialDescriptor`.
extension PublicKeyCredentialDescriptor {
/// initializes a `PublicKeyCredentialDescriptor` from the AuthenticationServices one.
/// - Parameter asDescriptor: The descriptor provides by AuthenticationServices.
init(from asDescriptor: ASAuthorizationPublicKeyCredentialDescriptor) {
self.init(
ty: Constants.defaultFido2PublicKeyCredentialType,
id: asDescriptor.credentialID,
transports: nil
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// swiftlint:disable:this file_name

import AuthenticationServices
import BitwardenSdk
import XCTest

@testable import BitwardenShared

// MARK: - ASAuthorizationPublicKeyCredentialDescriptorExtensionTests

// swiftlint:disable:next type_name
class PublicKeyCredentialDescriptorExtensionsTests: BitwardenTestCase {
// MARK: Tests

/// `init(from:)` initializes a `PublicKeyCredentialDescriptor`
/// from a `ASAuthorizationPublicKeyCredentialDescriptor`.
func test_init_from() {
let data = Data(capacity: 16)
let asDescriptor = MockASAuthorizationPublicKeyCredentialDescriptor(credentialId: data)
let descriptor = PublicKeyCredentialDescriptor(from: asDescriptor)
AE88 XCTAssertEqual(asDescriptor.credentialID, descriptor.id)
XCTAssertEqual(descriptor.ty, "public-key")
XCTAssertNil(descriptor.transports)
}
}

// MARK: - MockASAuthorizationPublicKeyCredentialDescriptor

/// A mock of `ASAuthorizationPublicKeyCredentialDescriptor`.
class MockASAuthorizationPublicKeyCredentialDescriptor: NSObject, ASAuthorizationPublicKeyCredentialDescriptor {
// swiftlint:disable:previous type_name

// MARK: Properties

static var supportsSecureCoding = false

var credentialID: Data

// MARK: Init

init(credentialId: Data) {
credentialID = credentialId
}

required init?(coder: NSCoder) {
credentialID = Data(capacity: 16)
}

// MARK: Methods

func copy(with zone: NSZone? = nil) -> Any {
false
}

func encode(with coder: NSCoder) {
// No-op
}
}
F438
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import AuthenticationServices

@testable import BitwardenShared

/// Mock of `ASPasskeyCredentialRequest` that allows to set `excludedCredentials`.
/// We need this because the `ASPasskeyCredentialRequest` doesn't have an init that allows
/// to set `excludedCredentials`.
@available(iOS 17.0, *)
class MockASPasskeyCredentialRequest: ASPasskeyCredentialRequest {
// MARK: Properties

/// The value of `excludedCredentials`.
private var excludedCredentialsValue: [ASAuthorizationPlatformPublicKeyCredentialDescriptor]?

override var excludedCredentials: [ASAuthorizationPlatformPublicKeyCredentialDescriptor]? {
excludedCredentialsValue
}

// MARK: Methods

/// Sets the `excludedCredentials` value.
/// - Parameter value: The excluded credentials value.
func setExcludedCredentials(_ credentials: [ASAuthorizationPlatformPublicKeyCredentialDescriptor]?) {
excludedCredentialsValue = credentials
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ class MockVaultRepository: VaultRepository {

var clearTemporaryDownloadsCalled = false

// swiftlint:disable:next identifier_name
var createAutofillListExcludedCredentialSectionResult: Result<VaultListSection, Error> = .failure(
BitwardenTestError.example
)

var deleteAttachmentId: String?
var deleteAttachmentResult: Result<CipherView?, Error> = .success(.fixture())

Expand Down Expand Up @@ -148,6 +153,10 @@ class MockVaultRepository: VaultRepository {
clearTemporaryDownloadsCalled = true
}

func createAutofillListExcludedCredentialSection(from cipher: CipherView) async throws -> VaultListSection {
try createAutofillListExcludedCredentialSectionResult.get()
}

func deleteAttachment(withId attachmentId: String, cipherId _: String) async throws -> CipherView? {
deleteAttachmentId = attachmentId
return try deleteAttachmentResult.get()
Expand Down
17 changes: 16 additions & 1 deletion BitwardenShared/Core/Vault/Repositories/VaultRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ public protocol VaultRepository: AnyObject {
/// Removes any temporarily downloaded attachments.
func clearTemporaryDownloads()

/// Creates a `VaultListSection` for excluded credentials.
/// - Parameter cipher: The cipher found in excluded credentials.
/// - Returns: A `VaultListSection` with the excluded cipher found.
func createAutofillListExcludedCredentialSection(from cipher: CipherView) async throws -> VaultListSection

/// Delete an attachment from a cipher.
///
/// - Parameters:
Expand Down Expand Up @@ -985,6 +990,16 @@ extension DefaultVaultRepository: VaultRepository {
}
}

func createAutofillListExcludedCredentialSection(from cipher: CipherView) async throws -> VaultListSection {
let vaultListItem = try await createFido2VaultListItem(from: cipher)

return VaultListSection(
id: Localizations.aPasskeyAlreadyExistsForThisApplication,
items: [vaultListItem].compactMap { $0 },
name: Localizations.aPasskeyAlreadyExistsForThisApplication
)
}

func fetchCipher(withId id: String) async throws -> CipherView? {
guard let cipher = try await cipherService.fetchCipher(withId: id) else { return nil }
return try? await clientService.vault().ciphers().decrypt(cipher: cipher)
Expand Down Expand Up @@ -1510,7 +1525,7 @@ extension DefaultVaultRepository: VaultRepository {
/// Creates a `VaultListItem` from a `CipherView` with Fido2 credentials.
/// - Parameter cipher: Cipher from which create the item.
/// - Returns: The `VaultListItem` with the cipher and Fido2 credentials.
func createFido2VaultListItem(from cipher: CipherView) async throws -> VaultListItem? {
private func createFido2VaultListItem(from cipher: CipherView) async throws -> VaultListItem? {
let decryptedFido2Credentials = try await clientService
.platform()
.fido2()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,32 @@ class VaultRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_b
)
}

/// `createAutofillListExcludedCredentialSection(from:)` creates a `VaultListSection`
/// from the given excluded credential cipher.
func test_createAutofillListExcludedCredentialSection() async throws {
let cipher = CipherView.fixture()
let expectedCredentialId = Data(repeating: 123, count: 16)
setupDefaultDecryptFido2AutofillCredentialsMocker(expectedCredentialId: expectedCredentialId)

let result = try await subject.createAutofillListExcludedCredentialSection(from: cipher)
XCTAssertEqual(result.id, Localizations.aPasskeyAlreadyExistsForThisApplication)
XCTAssertEqual(result.name, Localizations.aPasskeyAlreadyExistsForThisApplication)
XCTAssertEqual(result.items.count, 1)
XCTAssertEqual(result.items.first?.id, cipher.id)
XCTAssertEqual(result.items.first?.fido2CredentialRpId, "myApp.com")
}

/// `createAutofillListExcludedCredentialSection(from:)` throws when decrypting Fido2 credentials.
func test_createAutofillListExcludedCredentialSection_throws() async throws {
let cipher = CipherView.fixture()
clientService.mockPlatform.fido2Mock.decryptFido2AutofillCredentialsMocker
.throwing(BitwardenTestError.example)

await assertAsyncThrows(error: BitwardenTestError.example) {
_ = try await subject.createAutofillListExcludedCredentialSection(from: cipher)
}
}

/// `deleteCipher()` throws on id errors.
func test_deleteCipher_idError_nil() async throws {
cipherService.deleteCipherWithServerResult = .failure(CipherAPIServiceError.updateMissingId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,19 @@ import Combine
///
@MainActor
protocol Fido2UserInterfaceHelperDelegate: Fido2UserVerificationMediatorDelegate {
// MARK: Properties

/// Whether the Fido2 flow for autofill is from credential list or not.
var isAutofillingFromList: Bool { get }

// MARK: Methods

/// Informs that an excluded credential has been found in the Fido2 registration flow.
func informExcludedCredentialFound(cipherView: CipherView) async
}

// MARK: - Fido2UserInterfaceHelper

/// A helper to extend `Fido2UserInterface` protocol capabilities for Fido2 flows
/// depending on user interaction.
protocol Fido2UserInterfaceHelper: Fido2UserInterface {
Expand Down Expand Up @@ -63,6 +72,8 @@ protocol Fido2UserInterfaceHelper: Fido2UserInterface {
func setupCurrentUserVerificationPreference(userVerificationPreference: Uv)
}

// MARK: - DefaultFido2UserInterfaceHelper

/// Default implemenation of `Fido2UserInterfaceHelper`.
class DefaultFido2UserInterfaceHelper: Fido2UserInterfaceHelper {
/// Mediator which manages user verification on Fido2 flows.
Expand Down Expand Up @@ -110,6 +121,11 @@ class DefaultFido2UserInterfaceHelper: Fido2UserInterfaceHelper {
)
}

if case let .informExcludedCredentialFound(cipherView) = hint {
await fido2UserInterfaceHelperDelegate?.informExcludedCredentialFound(cipherView: cipherView)
return BitwardenSdk.CheckUserResult(userPresent: true, userVerified: false)
}

return BitwardenSdk.CheckUserResult(userPresent: true, userVerified: true)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,22 @@ class Fido2UserInterfaceHelperTests: BitwardenTestCase { // swiftlint:disable:th

// MARK: Tests

/// `checkUser(options:hint:)` with hint `informExcludedCredentialFound` is not possible in iOS so far
/// as the OS doesn't send excluded credentials.
/// `checkUser(options:hint:)` with hint `informExcludedCredentialFound` informs the delegate
/// of the situation and returns that user has not been verified.
@MainActor
func test_checkUser_informExcludedCredentialFoundHint() async throws {
_ = try await subject.checkUser(
subject.setupDelegate(fido2UserInterfaceHelperDelegate: fido2UserInterfaceHelperDelegate)
let cipher = CipherView.fixture()
let result = try await subject.checkUser(
options: CheckUserOptions(requirePresence: true, requireVerification: .discouraged),
hint: .informExcludedCredentialFound(.fixture())
hint: .informExcludedCredentialFound(cipher)
)
throw XCTSkip(
"informExcludedCredentialFound should never be invoked given iOS doesn't send excluded credentials"
XCTAssertEqual(
fido2UserInterfaceHelperDelegate.informExcludedCredentialFoundCalledWith?.id,
cipher.id
)
XCTAssertTrue(result.userPresent)
XCTAssertFalse(result.userVerified)
}

/// `checkUser(options:hint:)` with hint `informNoCredentialsFound` is not possible in iOS so far
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,9 @@ import Foundation
class MockFido2UserInterfaceHelperDelegate:
MockFido2UserVerificationMediatorDelegate, Fido2UserInterfaceHelperDelegate {
var isAutofillingFromList: Bool = false
var informExcludedCredentialFoundCalledWith: CipherView?

func informExcludedCredentialFound(cipherView: CipherView) async {
informExcludedCredentialFoundCalledWith = cipherView
}
}
Loading
0