[PM-8828] Fido2 autofill without user interaction (#744)

This commit is contained in:
Federico Maccaroni 2024-07-19 11:56:44 -03:00 committed by GitHub
parent 81d62a3655
commit 8eccee5020
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 596 additions and 22 deletions

View File

@ -1,4 +1,5 @@
import AuthenticationServices
import BitwardenSdk
import BitwardenShared
import OSLog
@ -61,6 +62,22 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
provideCredential(for: recordIdentifier)
}
@available(iOSApplicationExtension 17.0, *)
override func provideCredentialWithoutUserInteraction(for credentialRequest: any ASCredentialRequest) {
switch credentialRequest {
case let passwordRequest as ASPasswordCredentialRequest:
provideCredentialWithoutUserInteraction(for: passwordRequest)
case let passkeyRequest as ASPasskeyCredentialRequest:
initializeApp(
with: DefaultCredentialProviderContext(.autofillFido2Credential(passkeyRequest)),
userInteraction: false
)
provideFido2Credential(for: passkeyRequest)
default:
break
}
}
// MARK: Private
/// Cancels the extension request and dismisses the extension's view controller.
@ -135,6 +152,28 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
}
}
}
/// Provides a Fido2 credential for a passkey request.
/// - Parameter passkeyRequest: Request to get the credential.
@available(iOSApplicationExtension 17.0, *)
private func provideFido2Credential(for passkeyRequest: ASPasskeyCredentialRequest) {
guard let appProcessor else {
cancel(error: ASExtensionError(.failed))
return
}
Task {
do {
let credential = try await appProcessor.provideFido2Credential(
for: passkeyRequest
)
await extensionContext.completeAssertionRequest(using: credential)
} catch {
Logger.appExtension.error("Error providing credential without user interaction: \(error)")
cancel(error: error)
}
}
}
}
// MARK: - AppExtensionDelegate

View File

@ -4,9 +4,8 @@ import BitwardenSdk
class MockClientFido2Authenticator: ClientFido2AuthenticatorProtocol {
var credentialsForAutofillResult: Result<[Fido2CredentialAutofillView], Error> = .success([])
var getAssertionResult: Result<BitwardenSdk.GetAssertionResult, Error> = .success(
BitwardenSdk.GetAssertionResult.fixture()
)
var getAssertionMocker = InvocationMockerWithThrowingResult<GetAssertionRequest, GetAssertionResult>()
.withResult(.fixture())
var makeCredentialMocker = InvocationMockerWithThrowingResult<MakeCredentialRequest, MakeCredentialResult>()
.withResult(.fixture())
var silentlyDiscoverCredentialsResult: Result<[Fido2CredentialAutofillView], Error> = .success([])
@ -16,7 +15,7 @@ class MockClientFido2Authenticator: ClientFido2AuthenticatorProtocol {
}
func getAssertion(request: BitwardenSdk.GetAssertionRequest) async throws -> BitwardenSdk.GetAssertionResult {
try getAssertionResult.get()
try getAssertionMocker.invoke(param: request)
}
func makeCredential(request: BitwardenSdk.MakeCredentialRequest) async throws -> BitwardenSdk.MakeCredentialResult {

View File

@ -0,0 +1,17 @@
import AuthenticationServices
import BitwardenSdk
@available(iOSApplicationExtension 17.0, *)
extension Fido2CredentialAutofillView {
/// Converts this credential view into an `ASPasskeyCredentialIdentity`.
/// - Returns: A `ASPasskeyCredentialIdentity` from the values of this object.
func toFido2CredentialIdentity() -> ASPasskeyCredentialIdentity {
ASPasskeyCredentialIdentity(
relyingPartyIdentifier: rpId,
userName: safeUsernameForUi,
credentialID: credentialId,
userHandle: userHandle,
recordIdentifier: cipherId
)
}
}

View File

@ -0,0 +1,47 @@
import BitwardenSdk
import XCTest
@testable import BitwardenShared
class Fido2CredentialAutofillViewExtensionsTests: BitwardenTestCase { // swiftlint:disable:this type_name
// MARK: Tests
/// `toFido2CredentialIdentity()` returns the converted `ASPasskeyCredentialIdentity`.
func test_toFido2CredentialIdentity() throws {
let subject = Fido2CredentialAutofillView(
credentialId: Data(repeating: 1, count: 16),
cipherId: "1",
rpId: "myApp.com",
userNameForUi: "username",
userHandle: Data(repeating: 1, count: 16)
)
let identity = subject.toFido2CredentialIdentity()
XCTAssertTrue(
identity.relyingPartyIdentifier == subject.rpId
&& identity.userName == subject.userNameForUi
&& identity.credentialID == subject.credentialId
&& identity.userHandle == subject.userHandle
&& identity.recordIdentifier == subject.cipherId
)
}
/// `toFido2CredentialIdentity()` returns the converted `ASPasskeyCredentialIdentity`
/// when `userNameForUI` is `nil`.
func test_toFido2CredentialIdentity_userNameForUINil() throws {
let subject = Fido2CredentialAutofillView(
credentialId: Data(repeating: 1, count: 16),
cipherId: "1",
rpId: "myApp.com",
userNameForUi: nil,
userHandle: Data(repeating: 1, count: 16)
)
let identity = subject.toFido2CredentialIdentity()
XCTAssertTrue(
identity.relyingPartyIdentifier == subject.rpId
&& identity.userName == Localizations.unknownAccount
&& identity.credentialID == subject.credentialId
&& identity.userHandle == subject.userHandle
&& identity.recordIdentifier == subject.cipherId
)
}
}

View File

@ -35,6 +35,13 @@ class DefaultAutofillCredentialService {
/// The service to manage events.
private let eventService: EventService
/// A store to be used on Fido2 flows to get/save credentials.
let fido2CredentialStore: Fido2CredentialStore
/// A helper to be used on Fido2 flows that requires user interaction and extends the capabilities
/// of the `Fido2UserInterface` from the SDK.
let fido2UserInterfaceHelper: Fido2UserInterfaceHelper
/// The service used to manage the credentials available for AutoFill suggestions.
private let identityStore: CredentialIdentityStore
@ -60,6 +67,9 @@ class DefaultAutofillCredentialService {
/// - clientService: The service that handles common client functionality such as encryption and decryption.
/// - errorReporter: The service used by the application to report non-fatal errors.
/// - eventService: The service to manage events.
/// - fido2UserInterfaceHelper: A helper to be used on Fido2 flows that requires user interaction
/// and extends the capabilities of the `Fido2UserInterface` from the SDK.
/// - fido2CredentialStore: A store to be used on Fido2 flows to get/save credentials.
/// - identityStore: The service used to manage the credentials available for AutoFill suggestions.
/// - pasteboardService: The service used to manage copy/pasting from the device's clipboard.
/// - stateService: The service used by the application to manage account state.
@ -70,6 +80,8 @@ class DefaultAutofillCredentialService {
clientService: ClientService,
errorReporter: ErrorReporter,
eventService: EventService,
fido2CredentialStore: Fido2CredentialStore,
fido2UserInterfaceHelper: Fido2UserInterfaceHelper,
identityStore: CredentialIdentityStore = ASCredentialIdentityStore.shared,
pasteboardService: PasteboardService,
stateService: StateService,
@ -79,6 +91,8 @@ class DefaultAutofillCredentialService {
self.clientService = clientService
self.errorReporter = errorReporter
self.eventService = eventService
self.fido2CredentialStore = fido2CredentialStore
self.fido2UserInterfaceHelper = fido2UserInterfaceHelper
self.identityStore = identityStore
self.pasteboardService = pasteboardService
self.stateService = stateService
@ -151,7 +165,15 @@ class DefaultAutofillCredentialService {
if #available(iOS 17, *) {
let identities = decryptedCiphers.compactMap(\.credentialIdentity)
try await identityStore.replaceCredentialIdentities(identities)
let fido2Identities = try await clientService.platform().fido2()
.authenticator(
userInterface: fido2UserInterfaceHelper,
credentialStore: fido2CredentialStore
)
.credentialsForAutofill()
.compactMap { $0.toFido2CredentialIdentity() }
try await identityStore.replaceCredentialIdentities(identities + fido2Identities)
Logger.application.info("AutofillCredentialService: replaced \(identities.count) credential identities")
} else {
let identities = decryptedCiphers.compactMap(\.passwordCredentialIdentity)
@ -210,7 +232,10 @@ extension DefaultAutofillCredentialService: AutofillCredentialService {
private extension CipherView {
@available(iOS 17, *)
var credentialIdentity: (any ASCredentialIdentity)? {
passwordCredentialIdentity
guard shouldGetPasswordCredentialIdentity else {
return nil
}
return passwordCredentialIdentity
}
var passwordCredentialIdentity: ASPasswordCredentialIdentity? {
@ -228,6 +253,12 @@ private extension CipherView {
recordIdentifier: id
)
}
/// Whether the `ASPasswordCredentialIdentity` should be gotten.
/// Otherwise a passkey identity will be provided.
var shouldGetPasswordCredentialIdentity: Bool {
!hasFido2Credentials || login?.password != nil
}
}
// MARK: - CredentialIdentityStore

View File

@ -10,6 +10,8 @@ class AutofillCredentialServiceTests: BitwardenTestCase {
var clientService: MockClientService!
var errorReporter: MockErrorReporter!
var eventService: MockEventService!
var fido2CredentialStore: MockFido2CredentialStore!
var fido2UserInterfaceHelper: MockFido2UserInterfaceHelper!
var identityStore: MockCredentialIdentityStore!
var pasteboardService: MockPasteboardService!
var stateService: MockStateService!
@ -25,6 +27,8 @@ class AutofillCredentialServiceTests: BitwardenTestCase {
clientService = MockClientService()
errorReporter = MockErrorReporter()
eventService = MockEventService()
fido2CredentialStore = MockFido2CredentialStore()
fido2UserInterfaceHelper = MockFido2UserInterfaceHelper()
identityStore = MockCredentialIdentityStore()
pasteboardService = MockPasteboardService()
stateService = MockStateService()
@ -35,6 +39,8 @@ class AutofillCredentialServiceTests: BitwardenTestCase {
clientService: clientService,
errorReporter: errorReporter,
eventService: eventService,
fido2CredentialStore: fido2CredentialStore,
fido2UserInterfaceHelper: fido2UserInterfaceHelper,
identityStore: identityStore,
pasteboardService: pasteboardService,
stateService: stateService,
@ -49,6 +55,8 @@ class AutofillCredentialServiceTests: BitwardenTestCase {
clientService = nil
errorReporter = nil
eventService = nil
fido2CredentialStore = nil
fido2UserInterfaceHelper = nil
identityStore = nil
pasteboardService = nil
stateService = nil

View File

@ -0,0 +1,49 @@
#if DEBUG
import BitwardenSdk
import Foundation
/// Report with traceability about Fido2 flows.
public struct Fido2DebuggingReport {
var allCredentialsResult: Result<[BitwardenSdk.CipherView], Error>?
var findCredentialsResult: Result<[BitwardenSdk.CipherView], Error>?
var getAssertionRequest: GetAssertionRequest?
var getAssertionResult: Result<GetAssertionResult, Error>?
var saveCredentialCipher: Result<BitwardenSdk.Cipher, Error>?
}
/// Fido2 builder for debugging report.
public struct Fido2DebuggingReportBuilder {
/// Builder for Fido2 debugging report.
public static var builder = Fido2DebuggingReportBuilder()
var report = Fido2DebuggingReport()
/// Gets the report for Fido2 debugging.
/// - Returns: Fido2 report.
public func getReport() -> Fido2DebuggingReport? {
report
}
mutating func withAllCredentialsResult(_ result: Result<[BitwardenSdk.CipherView], Error>) {
report.allCredentialsResult = result
}
mutating func withFindCredentialsResult(_ result: Result<[BitwardenSdk.CipherView], Error>) {
report.findCredentialsResult = result
}
mutating func withGetAssertionRequest(_ request: GetAssertionRequest) {
report.getAssertionRequest = request
}
mutating func withGetAssertionResult(_ result: Result<GetAssertionResult, Error>) {
report.getAssertionResult = result
}
mutating func withSaveCredentialCipher(_ credential: Result<BitwardenSdk.Cipher, Error>) {
report.saveCredentialCipher = credential
}
}
#endif

View File

@ -456,16 +456,6 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
trustDeviceService: trustDeviceService
)
let autofillCredentialService = DefaultAutofillCredentialService(
cipherService: cipherService,
clientService: clientService,
errorReporter: errorReporter,
eventService: eventService,
pasteboardService: pasteboardService,
stateService: stateService,
vaultTimeoutService: vaultTimeoutService
)
let authRepository = DefaultAuthRepository(
accountAPIService: apiService,
authService: authService,
@ -552,9 +542,34 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
)
)
#if DEBUG
let fido2CredentialStore = DebuggingFido2CredentialStoreService(
fido2CredentialStore: Fido2CredentialStoreService(
cipherService: cipherService,
clientService: clientService,
errorReporter: errorReporter,
syncService: syncService
)
)
#else
let fido2CredentialStore = Fido2CredentialStoreService(
cipherService: cipherService,
clientService: clientService
clientService: clientService,
errorReporter: errorReporter,
syncService: syncService
)
#endif
let autofillCredentialService = DefaultAutofillCredentialService(
cipherService: cipherService,
clientService: clientService,
errorReporter: errorReporter,
eventService: eventService,
fido2CredentialStore: fido2CredentialStore,
fido2UserInterfaceHelper: fido2UserInterfaceHelper,
pasteboardService: pasteboardService,
stateService: stateService,
vaultTimeoutService: vaultTimeoutService
)
self.init(

View File

@ -1,6 +1,8 @@
import BitwardenSdk
import Foundation
/// The Fido2 credential store implementation that the SDK needs
/// which handles getting/saving credentials for Fido2 flows.
class Fido2CredentialStoreService: Fido2CredentialStore {
// MARK: Properties
@ -10,21 +12,42 @@ class Fido2CredentialStoreService: Fido2CredentialStore {
/// The service that handles common client functionality such as encryption and decryption.
private let clientService: ClientService
/// The service used by the application to report non-fatal errors.
private let errorReporter: ErrorReporter
/// The service used to handle syncing vault data with the API
private let syncService: SyncService
// MARK: Initialization
/// Initializes a `Fido2CredentialStoreService`
/// - Parameters:
/// - cipherService: The service used to manage syncing and updates to the user's ciphers.
/// - clientService: The service that handles common client functionality such as encryption and decryption.
init(cipherService: CipherService, clientService: ClientService) {
/// - errorReporter: The service used by the application to report non-fatal errors.
/// - syncService: The service used to handle syncing vault data with the API.
init(
cipherService: CipherService,
clientService: ClientService,
errorReporter: ErrorReporter,
syncService: SyncService
) {
self.cipherService = cipherService
self.clientService = clientService
self.errorReporter = errorReporter
self.syncService = syncService
}
/// Gets all the active login ciphers that have Fido2 credentials.
/// - Returns: Array of active login ciphers that have Fido2 credentials.
func allCredentials() async throws -> [BitwardenSdk.CipherView] {
try await cipherService.fetchAllCiphers()
do {
try await syncService.fetchSync(forceSync: false)
} catch {
errorReporter.log(error: error)
}
return try await cipherService.fetchAllCiphers()
.filter(\.isActiveWithFido2Credentials)
.asyncMap { cipher in
try await self.clientService.vault().ciphers().decrypt(cipher: cipher)
@ -81,3 +104,48 @@ private extension Cipher {
&& login?.fido2Credentials?.isEmpty == false
}
}
#if DEBUG
/// A wrapper of a `Fido2CredentialStore` which adds debugging info for the `Fido2DebugginReportBuilder`.
class DebuggingFido2CredentialStoreService: Fido2CredentialStore {
let fido2CredentialStore: Fido2CredentialStore
init(fido2CredentialStore: Fido2CredentialStore) {
self.fido2CredentialStore = fido2CredentialStore
}
func findCredentials(ids: [Data]?, ripId: String) async throws -> [BitwardenSdk.CipherView] {
do {
let result = try await fido2CredentialStore.findCredentials(ids: ids, ripId: ripId)
Fido2DebuggingReportBuilder.builder.withFindCredentialsResult(.success(result))
return result
} catch {
Fido2DebuggingReportBuilder.builder.withFindCredentialsResult(.failure(error))
throw error
}
}
func allCredentials() async throws -> [BitwardenSdk.CipherView] {
do {
let result = try await fido2CredentialStore.allCredentials()
Fido2DebuggingReportBuilder.builder.withAllCredentialsResult(.success(result))
return result
} catch {
Fido2DebuggingReportBuilder.builder.withFindCredentialsResult(.failure(error))
throw error
}
}
func saveCredential(cred: BitwardenSdk.Cipher) async throws {
do {
try await fido2CredentialStore.saveCredential(cred: cred)
Fido2DebuggingReportBuilder.builder.withSaveCredentialCipher(.success(cred))
} catch {
Fido2DebuggingReportBuilder.builder.withFindCredentialsResult(.failure(error))
throw error
}
}
}
#endif

View File

@ -1,14 +1,18 @@
import BitwardenSdk
import XCTest
// swiftlint:disable file_length
@testable import BitwardenShared
class Fido2CredentialStoreServiceTests: BitwardenTestCase {
class Fido2CredentialStoreServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_length
// MARK: Properties
var cipherService: MockCipherService!
var clientService: MockClientService!
var errorReporter: MockErrorReporter!
var subject: Fido2CredentialStoreService!
var syncService: MockSyncService!
// MARK: Setup & Teardown
@ -17,10 +21,14 @@ class Fido2CredentialStoreServiceTests: BitwardenTestCase {
cipherService = MockCipherService()
clientService = MockClientService()
errorReporter = MockErrorReporter()
syncService = MockSyncService()
subject = Fido2CredentialStoreService(
cipherService: cipherService,
clientService: clientService
clientService: clientService,
errorReporter: errorReporter,
syncService: syncService
)
}
@ -29,7 +37,9 @@ class Fido2CredentialStoreServiceTests: BitwardenTestCase {
cipherService = nil
clientService = nil
errorReporter = nil
subject = nil
syncService = nil
}
// MARK: Tests
@ -56,6 +66,7 @@ class Fido2CredentialStoreServiceTests: BitwardenTestCase {
let result = try await subject.allCredentials()
XCTAssertTrue(syncService.didFetchSync)
XCTAssertTrue(result.count == 1)
XCTAssertTrue(result[0].id == "5")
}
@ -91,6 +102,16 @@ class Fido2CredentialStoreServiceTests: BitwardenTestCase {
}
}
/// `.allCredentials()` throws when syncing.
func test_allCredentials_throwsSync() async throws {
syncService.fetchSyncResult = .failure(BitwardenTestError.example)
_ = try await subject.allCredentials()
XCTAssertFalse(errorReporter.errors.isEmpty)
XCTAssertTrue(cipherService.fetchAllCiphersCalled)
}
/// `.findCredentials(ids:ripId:)` returns the login ciphers that are active, have Fido2 credentials
/// and match the `ripId` and the credential `ids` if any.
func test_findCredentials() async throws {
@ -128,6 +149,7 @@ class Fido2CredentialStoreServiceTests: BitwardenTestCase {
let result = try await subject.findCredentials(ids: credentialIds, ripId: expectedRpId)
XCTAssertTrue(syncService.didFetchSync)
XCTAssertTrue(result.count == 1)
XCTAssertTrue(result[0].id == expectedCipherId)
}
@ -307,3 +329,96 @@ class Fido2CredentialStoreServiceTests: BitwardenTestCase {
])
}
}
class DebuggingFido2CredentialStoreServiceTests: BitwardenTestCase { // swiftlint:disable:this type_name
// MARK: Properties
var fido2CredentialStore: MockFido2CredentialStore!
var subject: DebuggingFido2CredentialStoreService!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
fido2CredentialStore = MockFido2CredentialStore()
subject = DebuggingFido2CredentialStoreService(
fido2CredentialStore: fido2CredentialStore
)
}
override func tearDown() {
super.tearDown()
fido2CredentialStore = nil
subject = nil
}
// MARK: Tests
/// `.allCredentials()` returns all credentials and reports it.
func test_allCredentials() async throws {
fido2CredentialStore.allCredentialsResult = .success([.fixture()])
let result = try await subject.allCredentials()
XCTAssert(result.count == 1)
XCTAssertFalse(
(try? Fido2DebuggingReportBuilder.builder
.getReport()?.allCredentialsResult?.get().isEmpty) ?? true
)
}
/// `.allCredentials()` throws and reports it.
func test_allCredentials_throws() async throws {
fido2CredentialStore.allCredentialsResult = .failure(BitwardenTestError.example)
await assertAsyncThrows(error: BitwardenTestError.example) {
_ = try await subject.allCredentials()
}
XCTAssertNil(try? Fido2DebuggingReportBuilder.builder.getReport()?
.allCredentialsResult?.get()
)
}
/// `.findCredentials(ids:ripId:)` returns found credentials and reports it.
func test_findCredentials() async throws {
fido2CredentialStore.findCredentialsResult = .success([.fixture()])
let result = try await subject.findCredentials(ids: nil, ripId: "something")
XCTAssert(result.count == 1)
XCTAssertFalse(
(try? Fido2DebuggingReportBuilder.builder
.getReport()?.findCredentialsResult?.get().isEmpty) ?? true
)
}
/// `.findCredentials(ids:ripId:)` throws and reports it.
func test_findCredentialsthrows() async throws {
fido2CredentialStore.findCredentialsResult = .failure(BitwardenTestError.example)
await assertAsyncThrows(error: BitwardenTestError.example) {
_ = try await subject.findCredentials(ids: nil, ripId: "something")
}
XCTAssertNil(try? Fido2DebuggingReportBuilder.builder.getReport()?
.findCredentialsResult?.get()
)
}
/// `.saveCredential(cred:)` saves credentials and adds it to the report.
func test_saveCredential() async throws {
try await subject.saveCredential(cred: .fixture(id: "1"))
XCTAssertTrue(fido2CredentialStore.saveCredentialCalled)
XCTAssertTrue(
(try? Fido2DebuggingReportBuilder.builder
.getReport()?.saveCredentialCipher?.get().id) == "1"
)
}
/// `.saveCredential(cred:)` throws and reports it.
func test_saveCredential_throws() async throws {
fido2CredentialStore.saveCredentialError = BitwardenTestError.example
await assertAsyncThrows(error: BitwardenTestError.example) {
try await subject.saveCredential(cred: .fixture(id: "1"))
}
XCTAssertNil(try? Fido2DebuggingReportBuilder.builder.getReport()?
.saveCredentialCipher?.get()
)
}
}

View File

@ -22,6 +22,7 @@ class MockCipherService: CipherService {
var fetchCipherId: String?
var fetchCipherResult: Result<Cipher?, Error> = .success(nil)
var fetchAllCiphersCalled = false
var fetchAllCiphersResult: Result<[Cipher], Error> = .success([])
var deleteCipherId: String?
@ -86,7 +87,8 @@ class MockCipherService: CipherService {
}
func fetchAllCiphers() async throws -> [Cipher] {
try fetchAllCiphersResult.get()
fetchAllCiphersCalled = true
return try fetchAllCiphersResult.get()
}
func fetchCipher(withId id: String) async throws -> Cipher? {

View File

@ -7,6 +7,7 @@ class MockFido2CredentialStore: Fido2CredentialStore {
var findCredentialsResult: Result<[BitwardenSdk.CipherView], Error> = .success([])
var allCredentialsResult: Result<[BitwardenSdk.CipherView], Error> = .success([])
var saveCredentialCalled = false
var saveCredentialError: (any Error)?
func findCredentials(ids: [Data]?, ripId: String) async throws -> [BitwardenSdk.CipherView] {
try findCredentialsResult.get()
@ -18,5 +19,8 @@ class MockFido2CredentialStore: Fido2CredentialStore {
func saveCredential(cred: BitwardenSdk.Cipher) async throws {
saveCredentialCalled = true
if let saveCredentialError {
throw saveCredentialError
}
}
}

View File

@ -94,6 +94,9 @@ class DefaultFido2UserInterfaceHelper: Fido2UserInterfaceHelper {
func pickCredentialForAuthentication(
availableCredentials: [BitwardenSdk.CipherView]
) async throws -> BitwardenSdk.CipherViewWrapper {
if availableCredentials.count == 1 {
return CipherViewWrapper(cipher: availableCredentials[0])
}
// TODO: PM-8829 implement pick credential for auth
throw Fido2Error.invalidOperationError
}

View File

@ -1,4 +1,5 @@
import AuthenticationServices
import BitwardenSdk
import Combine
import Foundation
import UIKit
@ -323,3 +324,88 @@ extension AppProcessor: SyncServiceDelegate {
}
}
}
// MARK: - Fido2 credentials
public extension AppProcessor {
/// Provides a Fido2 credential for a passkey request.
/// - Parameter passkeyRequest: Request to get the credential.
@available(iOSApplicationExtension 17.0, *)
func provideFido2Credential( // swiftlint:disable:this function_body_length
for passkeyRequest: ASPasskeyCredentialRequest
) async throws -> ASPasskeyAssertionCredential {
guard let credentialIdentiy = passkeyRequest.credentialIdentity as? ASPasskeyCredentialIdentity else {
throw AppProcessorError.invalidOperation
}
let isLocked = try? await services.authRepository.isLocked()
let vaultTimeout = try? await services.vaultTimeoutService.sessionTimeoutValue(userId: nil)
switch (vaultTimeout, isLocked) {
case (.never, true):
// If the user has enabled Never Lock, but the vault is locked,
// unlock the vault before continuing.
try await services.authRepository.unlockVaultWithNeverlockKey()
case (_, false):
break
default:
break
}
let request = GetAssertionRequest(
rpId: credentialIdentiy.relyingPartyIdentifier,
clientDataHash: passkeyRequest.clientDataHash,
allowList: [
PublicKeyCredentialDescriptor(
ty: "public-key",
id: credentialIdentiy.credentialID,
transports: nil
),
],
options: Options(
rk: false,
uv: BitwardenSdk.Uv(preference: passkeyRequest.userVerificationPreference)
),
extensions: nil
)
#if DEBUG
Fido2DebuggingReportBuilder.builder.withGetAssertionRequest(request)
#endif
do {
let assertionResult = try await services.clientService.platform().fido2()
.authenticator(
userInterface: services.fido2UserInterfaceHelper,
credentialStore: services.fido2CredentialStore
)
.getAssertion(request: request)
#if DEBUG
Fido2DebuggingReportBuilder.builder.withGetAssertionResult(.success(assertionResult))
#endif
return ASPasskeyAssertionCredential(
userHandle: assertionResult.userHandle,
relyingParty: credentialIdentiy.relyingPartyIdentifier,
signature: assertionResult.signature,
clientDataHash: passkeyRequest.clientDataHash,
authenticatorData: assertionResult.authenticatorData,
credentialID: assertionResult.credentialId
)
} catch {
#if DEBUG
Fido2DebuggingReportBuilder.builder.withGetAssertionResult(.failure(error))
#endif
throw error
}
}
}
// MARK: - AppProcessorError
/// Errors that can happen inside the `AppProcessor`.
enum AppProcessorError: Error {
/// The operation to execute is invalid.
case invalidOperation
} // swiftlint:disable:this file_length

View File

@ -1,4 +1,5 @@
import AuthenticationServices
import BitwardenSdk
import Foundation
import XCTest
@ -200,6 +201,96 @@ class AppProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_body
XCTAssertIdentical(syncService.delegate, subject)
}
/// `provideFido2Credential(for:)` succeeds
@available(iOS 17.0, *)
func test_provideFido2Credential_succeeds() async throws {
authRepository.isLockedResult = .success(false)
let passkeyIdentity = ASPasskeyCredentialIdentity.fixture()
let passkeyRequest = ASPasskeyCredentialRequest.fixture(credentialIdentity: passkeyIdentity)
let expectedAssertionResult = GetAssertionResult.fixture()
clientService.mockPlatform.fido2Mock
.clientFido2AuthenticatorMock
.getAssertionMocker
.withVerification { request in
request.rpId == passkeyIdentity.relyingPartyIdentifier
&& request.clientDataHash == passkeyRequest.clientDataHash
&& request.allowList?[0].id == passkeyIdentity.credentialID
&& request.allowList?[0].ty == "public-key"
&& request.allowList?[0].transports == nil
&& !request.options.rk
&& request.options.uv == .discouraged
&& request.extensions == nil
}
.withResult(expectedAssertionResult)
let result = try await subject.provideFido2Credential(for: passkeyRequest)
XCTAssertFalse(authRepository.unlockVaultWithNeverlockKeyCalled)
XCTAssertEqual(result.userHandle, expectedAssertionResult.userHandle)
XCTAssertEqual(result.relyingParty, passkeyIdentity.relyingPartyIdentifier)
XCTAssertEqual(result.signature, expectedAssertionResult.signature)
XCTAssertEqual(result.clientDataHash, passkeyRequest.clientDataHash)
XCTAssertEqual(result.authenticatorData, expectedAssertionResult.authenticatorData)
XCTAssertEqual(result.credentialID, expectedAssertionResult.credentialId)
}
/// `provideFido2Credential(for:)` succeeds when unlocking with never key.
@available(iOS 17.0, *)
func test_provideFido2Credential_succeedsWithUnlockingNeverKey() async throws {
authRepository.isLockedResult = .success(true)
vaultTimeoutService.vaultTimeout["1"] = .never
let passkeyIdentity = ASPasskeyCredentialIdentity.fixture()
let passkeyRequest = ASPasskeyCredentialRequest.fixture(credentialIdentity: passkeyIdentity)
let expectedAssertionResult = GetAssertionResult.fixture()
clientService.mockPlatform.fido2Mock
.clientFido2AuthenticatorMock
.getAssertionMocker
.withVerification { request in
request.rpId == passkeyIdentity.relyingPartyIdentifier
&& request.clientDataHash == passkeyRequest.clientDataHash
&& request.allowList?[0].id == passkeyIdentity.credentialID
&& request.allowList?[0].ty == "public-key"
&& request.allowList?[0].transports == nil
&& !request.options.rk
&& request.options.uv == .discouraged
&& request.extensions == nil
}
.withResult(expectedAssertionResult)
let result = try await subject.provideFido2Credential(for: passkeyRequest)
XCTAssertTrue(authRepository.unlockVaultWithNeverlockKeyCalled)
XCTAssertEqual(result.userHandle, expectedAssertionResult.userHandle)
XCTAssertEqual(result.relyingParty, passkeyIdentity.relyingPartyIdentifier)
XCTAssertEqual(result.signature, expectedAssertionResult.signature)
XCTAssertEqual(result.clientDataHash, passkeyRequest.clientDataHash)
XCTAssertEqual(result.authenticatorData, expectedAssertionResult.authenticatorData)
XCTAssertEqual(result.credentialID, expectedAssertionResult.credentialId)
}
/// `provideFido2Credential(for:)` throws when getting assertion.
@available(iOS 17.0, *)
func test_provideFido2Credential_throws() async throws {
authRepository.isLockedResult = .success(false)
let passkeyIdentity = ASPasskeyCredentialIdentity.fixture()
let passkeyRequest = ASPasskeyCredentialRequest.fixture(credentialIdentity: passkeyIdentity)
clientService.mockPlatform.fido2Mock
.clientFido2AuthenticatorMock
.getAssertionMocker
.throwing(BitwardenTestError.example)
await assertAsyncThrows(error: BitwardenTestError.example) {
_ = try await subject.provideFido2Credential(for: passkeyRequest)
}
}
/// `messageReceived(_:notificationDismissed:notificationTapped)` passes the data to the notification service.
func test_messageReceived() async {
let message: [AnyHashable: Any] = ["knock knock": "who's there?"]