diff --git a/TestHarnessShared/Core/Autofill/Passkey/CipherStorageService.swift b/TestHarnessShared/Core/Autofill/Passkey/CipherStorageService.swift new file mode 100644 index 0000000000..5924fbb1df --- /dev/null +++ b/TestHarnessShared/Core/Autofill/Passkey/CipherStorageService.swift @@ -0,0 +1,65 @@ +import BitwardenSdk +import Foundation + +// MARK: - CipherStorageService + +// sourcery: AutoMockable +/// A service that locally persists the SDK-encrypted `Cipher`s created by the SDK-backed passkey +/// scenarios, so they survive app relaunches. +/// +protocol CipherStorageService: AnyObject { + /// Loads the persisted ciphers, if any. + /// + /// - Returns: The persisted ciphers, in the order they were saved. Empty if none are stored. + /// + func loadCiphers() -> [Cipher] + + /// Persists the given ciphers, replacing whatever was previously stored. + /// + /// - Parameter ciphers: The full list of ciphers to persist. + /// + func save(ciphers: [Cipher]) +} + +// MARK: - DefaultCipherStorageService + +/// The default `CipherStorageService` implementation, backed by `UserDefaults`. This is safe +/// to store outside the keychain because every sensitive field is already SDK ciphertext — +/// useless without the synthetic identity's key, which is kept in the keychain (see +/// `SyntheticIdentity`). +/// +final class DefaultCipherStorageService: CipherStorageService { + // MARK: Private Properties + + /// The `UserDefaults` key under which the persisted ciphers are stored. + private static let storageKey = "PasskeyStoredCiphers" + + /// The `UserDefaults` instance used for persistence. + private let userDefaults: UserDefaults + + // MARK: Initialization + + /// Initializes a `DefaultCipherStorageService`. + /// + /// - Parameter userDefaults: The `UserDefaults` instance used for persistence. + /// + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + } + + // MARK: Methods + + func loadCiphers() -> [Cipher] { + guard let data = userDefaults.data(forKey: Self.storageKey), + let storedCiphers = try? JSONDecoder().decode([StoredCipher].self, from: data) else { + return [] + } + return storedCiphers.map(\.cipher) + } + + func save(ciphers: [Cipher]) { + let storedCiphers = ciphers.compactMap(StoredCipher.init(cipher:)) + guard let data = try? JSONEncoder().encode(storedCiphers) else { return } + userDefaults.set(data, forKey: Self.storageKey) + } +} diff --git a/TestHarnessShared/Core/Autofill/Passkey/ClientManagedTokensProvider.swift b/TestHarnessShared/Core/Autofill/Passkey/ClientManagedTokensProvider.swift new file mode 100644 index 0000000000..4d74aa11b4 --- /dev/null +++ b/TestHarnessShared/Core/Autofill/Passkey/ClientManagedTokensProvider.swift @@ -0,0 +1,12 @@ +import BitwardenSdk + +// MARK: - ClientManagedTokensProvider + +/// A `ClientManagedTokens` implementation for the SDK-backed passkey scenarios, which never +/// make network requests and so never have an access token to provide. +/// +final class ClientManagedTokensProvider: ClientManagedTokens { + func getAccessToken() async -> String? { + nil + } +} diff --git a/TestHarnessShared/Core/Autofill/Passkey/DefaultCipherStorageServiceTests.swift b/TestHarnessShared/Core/Autofill/Passkey/DefaultCipherStorageServiceTests.swift new file mode 100644 index 0000000000..b025ceaeda --- /dev/null +++ b/TestHarnessShared/Core/Autofill/Passkey/DefaultCipherStorageServiceTests.swift @@ -0,0 +1,74 @@ +import BitwardenKitMocks +import BitwardenSdk +import TestHelpers +import XCTest + +@testable import TestHarnessShared + +// MARK: - DefaultCipherStorageServiceTests + +/// Tests for `DefaultCipherStorageService`. +/// +class DefaultCipherStorageServiceTests: BitwardenTestCase { + // MARK: Properties + + var subject: DefaultCipherStorageService! + var userDefaults: UserDefaults! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + userDefaults = UserDefaults(suiteName: "DefaultCipherStorageServiceTests") + userDefaults.removePersistentDomain(forName: "DefaultCipherStorageServiceTests") + subject = DefaultCipherStorageService(userDefaults: userDefaults) + } + + override func tearDown() { + super.tearDown() + userDefaults.removePersistentDomain(forName: "DefaultCipherStorageServiceTests") + subject = nil + userDefaults = nil + } + + // MARK: Tests + + /// `loadCiphers()` returns an empty list when nothing has been saved. + func test_loadCiphers_empty() { + XCTAssertTrue(subject.loadCiphers().isEmpty) + } + + /// `save(ciphers:)` followed by `loadCiphers()` returns the persisted ciphers. + func test_save_thenLoadCiphers_returnsPersistedCiphers() { + let cipher = Cipher(cipherView: .fixture( + login: .fixture(fido2Credentials: [Fido2Credential(fido2CredentialView: .fixture())]), + )) + + subject.save(ciphers: [cipher]) + + XCTAssertEqual(subject.loadCiphers(), [cipher]) + } + + /// `save(ciphers:)` silently skips ciphers with no Fido2 credential, since this storage + /// service only knows how to persist the login+Fido2 shape these scenarios create. + func test_save_ciphersWithoutFido2Credential_areSkipped() { + let cipher = Cipher(cipherView: .fixture(login: .fixture(fido2Credentials: nil))) + + subject.save(ciphers: [cipher]) + + XCTAssertTrue(subject.loadCiphers().isEmpty) + } + + /// Persisted ciphers remain available to a new `DefaultCipherStorageService` instance + /// backed by the same `UserDefaults`, simulating an app relaunch. + func test_save_persistsAcrossInstances() { + let cipher = Cipher(cipherView: .fixture( + login: .fixture(fido2Credentials: [Fido2Credential(fido2CredentialView: .fixture())]), + )) + subject.save(ciphers: [cipher]) + + let relaunchedSubject = DefaultCipherStorageService(userDefaults: userDefaults) + + XCTAssertEqual(relaunchedSubject.loadCiphers(), [cipher]) + } +} diff --git a/TestHarnessShared/Core/Autofill/Passkey/Fixtures/CipherView+Fixtures.swift b/TestHarnessShared/Core/Autofill/Passkey/Fixtures/CipherView+Fixtures.swift new file mode 100644 index 0000000000..fe09f0362c --- /dev/null +++ b/TestHarnessShared/Core/Autofill/Passkey/Fixtures/CipherView+Fixtures.swift @@ -0,0 +1,61 @@ +import BitwardenSdk +import Foundation + +extension CipherView { + static func fixture( + id: CipherId? = nil, + login: LoginView? = .fixture(), + name: String = "Example", + ) -> CipherView { + CipherView( + id: id, + organizationId: nil, + folderId: nil, + collectionIds: [], + key: nil, + name: name, + notes: nil, + type: .login, + login: login, + identity: nil, + card: nil, + secureNote: nil, + sshKey: nil, + bankAccount: nil, + driversLicense: nil, + passport: nil, + favorite: false, + reprompt: .none, + organizationUseTotp: false, + edit: true, + permissions: nil, + viewPassword: true, + localData: nil, + attachments: nil, + attachmentDecryptionFailures: nil, + fields: nil, + passwordHistory: nil, + creationDate: Date(timeIntervalSince1970: 0), + deletedDate: nil, + revisionDate: Date(timeIntervalSince1970: 0), + archivedDate: nil, + ) + } +} + +extension LoginView { + static func fixture( + fido2Credentials: [Fido2Credential]? = nil, + username: String? = "user@example.com", + ) -> LoginView { + LoginView( + username: username, + password: nil, + passwordRevisionDate: nil, + uris: nil, + totp: nil, + autofillOnPageLoad: nil, + fido2Credentials: fido2Credentials, + ) + } +} diff --git a/TestHarnessShared/Core/Autofill/Passkey/Fixtures/Fido2CredentialAutofillView+Fixtures.swift b/TestHarnessShared/Core/Autofill/Passkey/Fixtures/Fido2CredentialAutofillView+Fixtures.swift new file mode 100644 index 0000000000..2608c50e75 --- /dev/null +++ b/TestHarnessShared/Core/Autofill/Passkey/Fixtures/Fido2CredentialAutofillView+Fixtures.swift @@ -0,0 +1,22 @@ +import BitwardenSdk +import Foundation + +extension Fido2CredentialAutofillView { + static func fixture( + credentialId: Data = Data([0x01]), + cipherId: String = "cipher-id", + rpId: String = "bitwarden.com", + userNameForUi: String? = "user@example.com", + userHandle: Data = Data([0x02]), + hasCounter: Bool = false, + ) -> Fido2CredentialAutofillView { + Fido2CredentialAutofillView( + credentialId: credentialId, + cipherId: cipherId, + rpId: rpId, + userNameForUi: userNameForUi, + userHandle: userHandle, + hasCounter: hasCounter, + ) + } +} diff --git a/TestHarnessShared/Core/Autofill/Passkey/Fixtures/Fido2CredentialView+Fixtures.swift b/TestHarnessShared/Core/Autofill/Passkey/Fixtures/Fido2CredentialView+Fixtures.swift new file mode 100644 index 0000000000..c09288ca5f --- /dev/null +++ b/TestHarnessShared/Core/Autofill/Passkey/Fixtures/Fido2CredentialView+Fixtures.swift @@ -0,0 +1,26 @@ +import BitwardenSdk +import Foundation + +extension Fido2CredentialView { + static func fixture( + credentialId: String = "credential-id", + rpId: String = "bitwarden.com", + userName: String? = "user@example.com", + ) -> Fido2CredentialView { + Fido2CredentialView( + credentialId: credentialId, + keyType: "public-key", + keyAlgorithm: "ECDSA", + keyCurve: "P-256", + keyValue: "keyValue", + rpId: rpId, + userHandle: nil, + userName: userName, + counter: "0", + rpName: nil, + userDisplayName: nil, + discoverable: "true", + creationDate: Date(timeIntervalSince1970: 0), + ) + } +} diff --git a/TestHarnessShared/Core/Autofill/Passkey/PasskeyError.swift b/TestHarnessShared/Core/Autofill/Passkey/PasskeyError.swift new file mode 100644 index 0000000000..e8c9345432 --- /dev/null +++ b/TestHarnessShared/Core/Autofill/Passkey/PasskeyError.swift @@ -0,0 +1,23 @@ +import Foundation + +// MARK: - PasskeyError + +/// Errors produced by the SDK-backed passkey scenarios. +/// +enum PasskeyError: Equatable, Error, LocalizedError { + /// More than one stored credential matched the requested relying party; picking between + /// them isn't supported by this scenario yet. + case ambiguousCredential + + /// No stored credential matched the requested relying party. + case noMatchingCredential + + var errorDescription: String? { + switch self { + case .ambiguousCredential: + Localizations.ambiguousCredentialReceived + case .noMatchingCredential: + Localizations.noMatchingCredentialReceived + } + } +} diff --git a/TestHarnessShared/Core/Autofill/Passkey/PasskeyKeychainItem.swift b/TestHarnessShared/Core/Autofill/Passkey/PasskeyKeychainItem.swift new file mode 100644 index 0000000000..75a22417f4 --- /dev/null +++ b/TestHarnessShared/Core/Autofill/Passkey/PasskeyKeychainItem.swift @@ -0,0 +1,31 @@ +import BitwardenKit +import Foundation + +// MARK: - PasskeyKeychainItem + +/// The keychain items used by the SDK-backed passkey scenarios. +/// +enum PasskeyKeychainItem: Equatable, KeychainItem { + /// The keychain item for the synthetic identity used to bootstrap the SDK client, so the + /// same identity — and therefore the same crypto keys — can be reconstructed across app + /// launches. + case syntheticIdentity + + /// The `SecAccessControlCreateFlags` protection level for this keychain item. No extra + /// protection is needed since this is throwaway, synthetic identity material with no real + /// security value. + /// + var accessControlFlags: SecAccessControlCreateFlags? { nil } + + /// The protection level for this keychain item. + var protection: CFTypeRef { kSecAttrAccessibleWhenUnlockedThisDeviceOnly } + + /// The storage key for this keychain item. + /// + var unformattedKey: String { + switch self { + case .syntheticIdentity: + "sdkPasskeySyntheticIdentity" + } + } +} diff --git a/TestHarnessShared/Core/Autofill/Passkey/StoredCipher.swift b/TestHarnessShared/Core/Autofill/Passkey/StoredCipher.swift new file mode 100644 index 0000000000..3dc7e00346 --- /dev/null +++ b/TestHarnessShared/Core/Autofill/Passkey/StoredCipher.swift @@ -0,0 +1,163 @@ +import BitwardenSdk +import Foundation + +// MARK: - StoredCipher + +/// A `Codable` mirror of the handful of `Cipher`/`Fido2Credential` fields the SDK-backed passkey +/// scenarios actually populate, since `BitwardenSdk.Cipher` doesn't itself conform to `Codable`. +/// Every cipher these scenarios create is a login-type cipher with exactly one Fido2 credential, +/// so the remaining `Cipher` fields are reconstructed with the same fixed defaults used when the +/// cipher was originally created rather than persisted. +/// +struct StoredCipher: Codable, Equatable { + /// The Fido2 credential's signature counter. + let counter: String + + /// The cipher's creation date. + let creationDate: Date + + /// The Fido2 credential's ID. + let credentialId: String + + /// Whether the Fido2 credential is discoverable. + let discoverable: String + + /// The Fido2 credential's creation date. + let fido2CreationDate: Date + + /// The cipher's ID. + let id: String? + + /// The cipher's individual encryption key. + let key: String? + + /// The Fido2 credential's key algorithm. + let keyAlgorithm: String + + /// The Fido2 credential's key curve. + let keyCurve: String + + /// The Fido2 credential's key type. + let keyType: String + + /// The Fido2 credential's private key value. + let keyValue: String + + /// The cipher's name. + let name: String? + + /// The cipher's revision date. + let revisionDate: Date + + /// The Fido2 credential's relying party ID. + let rpId: String + + /// The Fido2 credential's relying party name. + let rpName: String? + + /// The Fido2 credential's user display name. + let userDisplayName: String? + + /// The Fido2 credential's user handle. + let userHandle: String? + + /// The Fido2 credential's username. + let userName: String? + + /// The cipher's login username. + let username: String? +} + +extension StoredCipher { + /// Reconstructs the `Cipher` this `StoredCipher` mirrors. + var cipher: Cipher { + Cipher( + id: id, + organizationId: nil, + folderId: nil, + collectionIds: [], + key: key, + name: name, + notes: nil, + type: .login, + login: Login( + username: username, + password: nil, + passwordRevisionDate: nil, + uris: nil, + totp: nil, + autofillOnPageLoad: nil, + fido2Credentials: [ + Fido2Credential( + credentialId: credentialId, + keyType: keyType, + keyAlgorithm: keyAlgorithm, + keyCurve: keyCurve, + keyValue: keyValue, + rpId: rpId, + userHandle: userHandle, + userName: userName, + counter: counter, + rpName: rpName, + userDisplayName: userDisplayName, + discoverable: discoverable, + creationDate: fido2CreationDate, + ), + ], + ), + identity: nil, + card: nil, + secureNote: nil, + sshKey: nil, + bankAccount: nil, + driversLicense: nil, + passport: nil, + favorite: false, + reprompt: .none, + organizationUseTotp: false, + edit: true, + permissions: nil, + viewPassword: true, + localData: nil, + attachments: nil, + fields: nil, + passwordHistory: nil, + creationDate: creationDate, + deletedDate: nil, + revisionDate: revisionDate, + archivedDate: nil, + data: nil, + ) + } + + /// Initializes an `StoredCipher` from a `Cipher`. + /// + /// - Parameter cipher: The cipher to mirror. Returns `nil` if it isn't a login cipher with a + /// Fido2 credential, since that's the only shape this scenario ever creates. + /// + init?(cipher: Cipher) { + guard let login = cipher.login, let fido2Credential = login.fido2Credentials?.first else { + return nil + } + + counter = fido2Credential.counter + creationDate = cipher.creationDate + credentialId = fido2Credential.credentialId + discoverable = fido2Credential.discoverable + fido2CreationDate = fido2Credential.creationDate + id = cipher.id + key = cipher.key + keyAlgorithm = fido2Credential.keyAlgorithm + keyCurve = fido2Credential.keyCurve + keyType = fido2Credential.keyType + keyValue = fido2Credential.keyValue + name = cipher.name + revisionDate = cipher.revisionDate + rpId = fido2Credential.rpId + rpName = fido2Credential.rpName + userDisplayName = fido2Credential.userDisplayName + userHandle = fido2Credential.userHandle + userName = fido2Credential.userName + username = login.username + } +} diff --git a/TestHarnessShared/Core/Autofill/Passkey/StoredCipherTests.swift b/TestHarnessShared/Core/Autofill/Passkey/StoredCipherTests.swift new file mode 100644 index 0000000000..3a594cdd74 --- /dev/null +++ b/TestHarnessShared/Core/Autofill/Passkey/StoredCipherTests.swift @@ -0,0 +1,52 @@ +import BitwardenKitMocks +import BitwardenSdk +import TestHelpers +import XCTest + +@testable import TestHarnessShared + +// MARK: - StoredCipherTests + +/// Tests for `StoredCipher`. +/// +class StoredCipherTests: BitwardenTestCase { + // MARK: Tests + + /// `init(cipher:)` returns `nil` for a cipher with no login. + func test_init_cipherWithoutLogin_returnsNil() { + let cipher = Cipher(cipherView: .fixture(login: nil)) + XCTAssertNil(StoredCipher(cipher: cipher)) + } + + /// `init(cipher:)` returns `nil` for a login cipher with no Fido2 credential. + func test_init_cipherWithoutFido2Credential_returnsNil() { + let cipher = Cipher(cipherView: .fixture(login: .fixture(fido2Credentials: nil))) + XCTAssertNil(StoredCipher(cipher: cipher)) + } + + /// `init(cipher:)` followed by `.cipher` round-trips back to an equal `Cipher`. + func test_init_thenCipher_roundTrips() throws { + let fido2Credential = Fido2Credential(fido2CredentialView: .fixture()) + let originalCipher = Cipher(cipherView: .fixture( + id: "cipher-id", + login: .fixture(fido2Credentials: [fido2Credential]), + name: "Example", + )) + + let storedCipher = try XCTUnwrap(StoredCipher(cipher: originalCipher)) + + XCTAssertEqual(storedCipher.cipher, originalCipher) + } + + /// An `StoredCipher` round-trips through JSON encoding/decoding unchanged. + func test_codable_roundTrips() throws { + let fido2Credential = Fido2Credential(fido2CredentialView: .fixture()) + let cipher = Cipher(cipherView: .fixture(login: .fixture(fido2Credentials: [fido2Credential]))) + let storedCipher = try XCTUnwrap(StoredCipher(cipher: cipher)) + + let data = try JSONEncoder().encode(storedCipher) + let decoded = try JSONDecoder().decode(StoredCipher.self, from: data) + + XCTAssertEqual(decoded, storedCipher) + } +} diff --git a/TestHarnessShared/Core/Autofill/Passkey/SyntheticIdentity.swift b/TestHarnessShared/Core/Autofill/Passkey/SyntheticIdentity.swift new file mode 100644 index 0000000000..f04a09e843 --- /dev/null +++ b/TestHarnessShared/Core/Autofill/Passkey/SyntheticIdentity.swift @@ -0,0 +1,28 @@ +import Foundation + +// MARK: - SyntheticIdentity + +/// The synthetic, throwaway identity used to bootstrap the SDK-backed passkey scenarios' ephemeral +/// `BitwardenSdk.Client`. Persisted in the keychain so the same crypto keys can be reconstructed +/// across app launches — without it, a freshly generated identity on the next launch couldn't +/// decrypt any previously-registered credential. +/// +struct SyntheticIdentity: Codable, Equatable { + /// The synthetic account's email address. + let email: String + + /// The master-key-wrapped user key returned when the identity's keys were generated. + let encryptedUserKey: String + + /// The number of PBKDF2 iterations used to derive this identity's keys. + let kdfIterations: UInt32 + + /// The synthetic account's master password. + let password: String + + /// The synthetic account's wrapped private key. + let privateKey: String + + /// The synthetic account's user ID. + let userId: String +} diff --git a/TestHarnessShared/Sourcery/sourcery.yml b/TestHarnessShared/Sourcery/sourcery.yml index cef3d5439a..2d7b69b13f 100644 --- a/TestHarnessShared/Sourcery/sourcery.yml +++ b/TestHarnessShared/Sourcery/sourcery.yml @@ -14,5 +14,5 @@ exclude: - Fixtures args: - autoMockableImports: ["BitwardenKit"] + autoMockableImports: ["BitwardenKit", "BitwardenSdk"] autoMockableTestableImports: ["TestHarnessShared"] diff --git a/TestHarnessShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings b/TestHarnessShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings index b8cc661b28..2aee3c9c85 100644 --- a/TestHarnessShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings +++ b/TestHarnessShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings @@ -1,5 +1,6 @@ "AccountCreatedSuccessfully" = "Account created successfully"; "AccountDetails" = "Account Details"; +"AmbiguousCredentialReceived" = "Multiple stored credentials match this relying party ID; picking between them is not yet supported."; "CardAutofillForm" = "Card Autofill Form"; "CardAutofillFormDescriptionLong" = "Long press a field, then select Autofill and Password to autofill with Bitwarden. Requires Bitwarden PM as AutoFill provider and a saved Card cipher."; "CardDetails" = "Card Details"; @@ -18,6 +19,7 @@ "ExpirationYear" = "Expiration Year"; "FileShare" = "File Share"; "FormValues" = "Form Values"; +"NoMatchingCredentialReceived" = "No stored credential matches this relying party ID."; "PasskeyAutofill" = "Passkey Autofill"; "Result" = "Result"; "Password" = "Password"; diff --git a/project-bwth.yml b/project-bwth.yml index b58c17cd20..7fde1cd2cf 100644 --- a/project-bwth.yml +++ b/project-bwth.yml @@ -131,6 +131,7 @@ targets: dependencies: - target: BitwardenKit/BitwardenKit - target: BitwardenKit/BitwardenResources + - package: BitwardenSdk preBuildScripts: - name: Sourcery script: | @@ -174,4 +175,5 @@ targets: - target: TestHarnessShared - target: BitwardenKit/BitwardenKitMocks - target: BitwardenKit/TestHelpers + - package: BitwardenSdk randomExecutionOrder: true