diff --git a/Vault/.swiftpm/xcode/xcshareddata/xcschemes/VaultiOSAutofillTests.xcscheme b/Vault/.swiftpm/xcode/xcshareddata/xcschemes/VaultiOSAutofillTests.xcscheme index 7b570d693..3e17c898b 100644 --- a/Vault/.swiftpm/xcode/xcshareddata/xcschemes/VaultiOSAutofillTests.xcscheme +++ b/Vault/.swiftpm/xcode/xcshareddata/xcschemes/VaultiOSAutofillTests.xcscheme @@ -11,8 +11,13 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES" - shouldAutocreateTestPlan = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES"> + + + + diff --git a/Vault/Sources/VaultiOSAutofill/Presentation/AutofillOTPCredentialResolver.swift b/Vault/Sources/VaultiOSAutofill/Presentation/AutofillOTPCredentialResolver.swift new file mode 100644 index 000000000..aaf203c2f --- /dev/null +++ b/Vault/Sources/VaultiOSAutofill/Presentation/AutofillOTPCredentialResolver.swift @@ -0,0 +1,71 @@ +import Foundation +import VaultCore +import VaultFeed + +/// Resolves a QuickType-bar OTP credential request without user +/// interaction. Extracted from `VaultCredentialProviderViewController` so +/// the security-relevant gating — auth-required items and HOTP counters +/// must never be served without interaction — is unit-testable. +@MainActor +struct AutofillOTPCredentialResolver { + enum Outcome: Equatable { + /// A rendered TOTP code, safe to return without interaction. + case code(String) + /// The item is auth-gated or is an HOTP code (whose counter must + /// not increment without UI). The system shows the extension UI. + case userInteractionRequired + /// The record identifier is missing, malformed, or matches no + /// unlocked OTP item. + case notFound + /// Retrieval or code rendering failed. + case failure + } + + private let retrieveItems: () async throws -> VaultRetrievalResult + private let copyActionHandler: any VaultItemCopyActionHandler + private let clock: any EpochClock + + init( + retrieveItems: @escaping () async throws -> VaultRetrievalResult, + copyActionHandler: any VaultItemCopyActionHandler, + clock: any EpochClock, + ) { + self.retrieveItems = retrieveItems + self.copyActionHandler = copyActionHandler + self.clock = clock + } + + func resolve(recordIdentifier: String?) async -> Outcome { + guard let recordIdentifier, let itemUUID = UUID(uuidString: recordIdentifier) else { + return .notFound + } + + do { + let result = try await retrieveItems() + + guard let vaultItem = result.items.first(where: { $0.id.rawValue == itemUUID }), + let otpCode = vaultItem.item.otpCode + else { + return .notFound + } + + // Check if the item requires authentication to access. + let copyAction = copyActionHandler.textToCopyForVaultItem(id: vaultItem.id) + if copyAction?.requiresAuthenticationToCopy == true { + return .userInteractionRequired + } + + switch otpCode.type { + case let .totp(period): + let totpCode = TOTPAuthCode(period: period, data: otpCode.data) + let epochSeconds = UInt64(clock.currentTime) + return try .code(totpCode.renderCode(epochSeconds: epochSeconds)) + case .hotp: + // HOTP codes require user interaction to increment the counter. + return .userInteractionRequired + } + } catch { + return .failure + } + } +} diff --git a/Vault/Sources/VaultiOSAutofill/VaultCredentialProviderViewController.swift b/Vault/Sources/VaultiOSAutofill/VaultCredentialProviderViewController.swift index bbe7d0411..b3b150b01 100644 --- a/Vault/Sources/VaultiOSAutofill/VaultCredentialProviderViewController.swift +++ b/Vault/Sources/VaultiOSAutofill/VaultCredentialProviderViewController.swift @@ -95,52 +95,22 @@ open class VaultCredentialProviderViewController: ASCredentialProviderViewContro @MainActor private func provideOTPCredential(for request: any ASCredentialRequest) async { - // Extract the credential identity and record identifier - guard let identity = request.credentialIdentity as? ASOneTimeCodeCredentialIdentity, - let recordIdentifier = identity.recordIdentifier, - let itemUUID = UUID(uuidString: recordIdentifier) - else { - extensionContext.cancelRequest(withError: ASExtensionError(.credentialIdentityNotFound)) - return - } - - do { - // Retrieve all items from the vault to find the matching OTP item - let result = try await VaultRoot.vaultStore.retrieve(query: .init()) - - guard let vaultItem = result.items.first(where: { $0.id.rawValue == itemUUID }), - let otpCode = vaultItem.item.otpCode - else { - extensionContext.cancelRequest(withError: ASExtensionError(.credentialIdentityNotFound)) - return - } - - // Check if the item requires authentication to access - let copyAction = VaultRoot.vaultItemCopyHandler.textToCopyForVaultItem(id: vaultItem.id) - if copyAction?.requiresAuthenticationToCopy == true { - // Require user interaction for authentication - extensionContext.cancelRequest(withError: ASExtensionError(.userInteractionRequired)) - return - } - - // Generate the OTP code based on the type - let codeString: String - switch otpCode.type { - case let .totp(period): - let totpCode = TOTPAuthCode(period: period, data: otpCode.data) - let epochSeconds = UInt64(Date().timeIntervalSince1970) - codeString = try totpCode.renderCode(epochSeconds: epochSeconds) - case .hotp: - // HOTP codes require user interaction to increment counter - extensionContext.cancelRequest(withError: ASExtensionError(.userInteractionRequired)) - return - } + let identity = request.credentialIdentity as? ASOneTimeCodeCredentialIdentity + let resolver = AutofillOTPCredentialResolver( + retrieveItems: { try await VaultRoot.vaultStore.retrieve(query: .init()) }, + copyActionHandler: VaultRoot.vaultItemCopyHandler, + clock: VaultRoot.clock, + ) - // Complete the request with the generated code + switch await resolver.resolve(recordIdentifier: identity?.recordIdentifier) { + case let .code(codeString): let credential = ASOneTimeCodeCredential(code: codeString) extensionContext.completeOneTimeCodeRequest(using: credential, completionHandler: nil) - - } catch { + case .userInteractionRequired: + extensionContext.cancelRequest(withError: ASExtensionError(.userInteractionRequired)) + case .notFound: + extensionContext.cancelRequest(withError: ASExtensionError(.credentialIdentityNotFound)) + case .failure: extensionContext.cancelRequest(withError: ASExtensionError(.failed)) } } diff --git a/Vault/Tests/VaultiOSAutofillTests/AutofillOTPCredentialResolverTests.swift b/Vault/Tests/VaultiOSAutofillTests/AutofillOTPCredentialResolverTests.swift new file mode 100644 index 000000000..6a5fd9799 --- /dev/null +++ b/Vault/Tests/VaultiOSAutofillTests/AutofillOTPCredentialResolverTests.swift @@ -0,0 +1,154 @@ +import Foundation +import FoundationExtensions +import Testing +import VaultCore +import VaultFeed +@testable import VaultiOSAutofill + +@MainActor +struct AutofillOTPCredentialResolverTests { + @Test + func resolve_nilRecordIdentifier_returnsNotFound() async { + let sut = makeSUT() + + let outcome = await sut.resolve(recordIdentifier: nil) + + #expect(outcome == .notFound) + } + + @Test + func resolve_malformedRecordIdentifier_returnsNotFound() async { + let sut = makeSUT() + + let outcome = await sut.resolve(recordIdentifier: "not-a-uuid") + + #expect(outcome == .notFound) + } + + @Test + func resolve_unknownItemID_returnsNotFound() async { + let sut = makeSUT(items: [makeOTPItem(type: .totp())]) + + let outcome = await sut.resolve(recordIdentifier: UUID().uuidString) + + #expect(outcome == .notFound) + } + + @Test + func resolve_nonOTPItem_returnsNotFound() async { + let note = anyVaultItem() + let sut = makeSUT(items: [note]) + + let outcome = await sut.resolve(recordIdentifier: note.id.rawValue.uuidString) + + #expect(outcome == .notFound) + } + + @Test + func resolve_authRequiredItem_returnsUserInteractionRequired() async { + // The security gate: an item whose copy action demands device + // authentication must never be served without interaction. + let item = makeOTPItem(type: .totp()) + let sut = makeSUT(items: [item], requiresAuthenticationToCopy: true) + + let outcome = await sut.resolve(recordIdentifier: item.id.rawValue.uuidString) + + #expect(outcome == .userInteractionRequired) + } + + @Test + func resolve_hotpItem_returnsUserInteractionRequired() async { + // HOTP counters must not increment without UI. + let item = makeOTPItem(type: .hotp()) + let sut = makeSUT(items: [item]) + + let outcome = await sut.resolve(recordIdentifier: item.id.rawValue.uuidString) + + #expect(outcome == .userInteractionRequired) + } + + @Test + func resolve_totpItem_returnsCodeRenderedForClockEpoch() async throws { + let code = makeTOTPCode(period: 30) + let item = VaultItem(metadata: anyVaultItemMetadata(), item: .otpCode(code)) + let clock = EpochClockMock(currentTime: 1_234_567_890) + let sut = makeSUT(items: [item], clock: clock) + + let outcome = await sut.resolve(recordIdentifier: item.id.rawValue.uuidString) + + let expected = try TOTPAuthCode(period: 30, data: code.data) + .renderCode(epochSeconds: 1_234_567_890) + #expect(outcome == .code(expected)) + } + + @Test + func resolve_retrievalError_returnsFailure() async { + struct RetrievalError: Error {} + let sut = makeSUT(retrieveItems: { throw RetrievalError() }) + + let outcome = await sut.resolve(recordIdentifier: UUID().uuidString) + + #expect(outcome == .failure) + } +} + +// MARK: - Helpers + +extension AutofillOTPCredentialResolverTests { + private func makeSUT( + items: [VaultItem] = [], + requiresAuthenticationToCopy: Bool = false, + clock: EpochClockMock = EpochClockMock(currentTime: 100), + ) -> AutofillOTPCredentialResolver { + makeSUT( + retrieveItems: { .init(items: items) }, + requiresAuthenticationToCopy: requiresAuthenticationToCopy, + clock: clock, + ) + } + + private func makeSUT( + retrieveItems: @escaping () async throws -> VaultRetrievalResult, + requiresAuthenticationToCopy: Bool = false, + clock: EpochClockMock = EpochClockMock(currentTime: 100), + ) -> AutofillOTPCredentialResolver { + AutofillOTPCredentialResolver( + retrieveItems: retrieveItems, + copyActionHandler: CopyActionHandlerStub(requiresAuthenticationToCopy: requiresAuthenticationToCopy), + clock: clock, + ) + } + + private func makeOTPItem(type: OTPAuthType) -> VaultItem { + VaultItem( + metadata: anyVaultItemMetadata(), + item: .otpCode(OTPAuthCode( + type: type, + data: makeOTPData(), + )), + ) + } + + private func makeTOTPCode(period: UInt64) -> OTPAuthCode { + OTPAuthCode(type: .totp(period: period), data: makeOTPData()) + } + + private func makeOTPData() -> OTPAuthCodeData { + OTPAuthCodeData( + secret: .init(data: Data.random(count: 50), format: .base32), + accountName: "Some Account", + ) + } + + private struct CopyActionHandlerStub: VaultItemCopyActionHandler { + let requiresAuthenticationToCopy: Bool + + func textToCopyForVaultItem(id _: Identifier) -> VaultTextCopyAction? { + VaultTextCopyAction( + text: "123456", + requiresAuthenticationToCopy: requiresAuthenticationToCopy, + contentType: .otp, + ) + } + } +} diff --git a/Vault/Tests/VaultiOSAutofillTests/VaultAutofillViewModelTests.swift b/Vault/Tests/VaultiOSAutofillTests/VaultAutofillViewModelTests.swift new file mode 100644 index 000000000..b59e305b3 --- /dev/null +++ b/Vault/Tests/VaultiOSAutofillTests/VaultAutofillViewModelTests.swift @@ -0,0 +1,75 @@ +import Combine +import Foundation +import FoundationExtensions +import TestHelpers +import Testing +import VaultSettings +@testable import VaultiOSAutofill + +@MainActor +struct VaultAutofillViewModelTests { + @Test + func init_displaysNoFeature() throws { + let sut = try makeSUT() + + #expect(sut.feature == nil) + } + + @Test + func show_setsDisplayedFeature() throws { + let sut = try makeSUT() + + sut.show(feature: .showAllCodesSelector) + + #expect(sut.feature == .showAllCodesSelector) + } + + @Test + func dismissConfiguration_publishesDismiss() throws { + let sut = try makeSUT() + var dismissCount = 0 + let cancellable = sut.configurationDismissPublisher.sink { dismissCount += 1 } + defer { cancellable.cancel() } + + sut.dismissConfiguration() + + #expect(dismissCount == 1) + } + + @Test + func textToInsertPublisher_filtersBlankStrings() throws { + let sut = try makeSUT() + var received = [String]() + let cancellable = sut.textToInsertPublisher.sink { received.append($0) } + defer { cancellable.cancel() } + + sut.textToInsertSubject.send("123456") + sut.textToInsertSubject.send("") + sut.textToInsertSubject.send(" ") + sut.textToInsertSubject.send("654321") + + #expect(received == ["123456", "654321"]) + } + + @Test + func cancelRequestPublisher_forwardsReason() throws { + let sut = try makeSUT() + var received = [VaultAutofillViewModel.RequestCancelReason]() + let cancellable = sut.cancelRequestPublisher.sink { received.append($0) } + defer { cancellable.cancel() } + + sut.cancelRequestSubject.send(.userCancelled) + + #expect(received == [.userCancelled]) + } +} + +// MARK: - Helpers + +extension VaultAutofillViewModelTests { + private func makeSUT() throws -> VaultAutofillViewModel { + try VaultAutofillViewModel( + localSettings: LocalSettings(defaults: Defaults.nonPersistent()), + ) + } +}