From 1c460fbdeccc24c498312b99fecc008c626ea964 Mon Sep 17 00:00:00 2001 From: "bw-ghapp[bot]" <178206702+bw-ghapp[bot]@users.noreply.github.com> Date: Wed, 24 Sep 2025 14:42:04 +0000 Subject: [PATCH 01/15] SDK Update - b10919f (1.0.0-2487-c975847) --- Bitwarden.xcworkspace/xcshareddata/swiftpm/Package.resolved | 2 +- project-common.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Bitwarden.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitwarden.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3ac5812445..6426187a4e 100644 --- a/Bitwarden.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitwarden.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -123,7 +123,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/bitwarden/sdk-swift", "state" : { - "revision" : "f3c527ff2c53b576743e090ed118f0a5a7c613d3" + "revision" : "b10919f8b50d3d27b7d3e0d9f674203c0f0bd9ec" } }, { diff --git a/project-common.yml b/project-common.yml index 88b73d7b59..1c8e0b14b8 100644 --- a/project-common.yml +++ b/project-common.yml @@ -14,7 +14,7 @@ include: packages: BitwardenSdk: url: https://github.com/bitwarden/sdk-swift - revision: f3c527ff2c53b576743e090ed118f0a5a7c613d3 # 1.0.0-2469-1ca5a58 + revision: b10919f8b50d3d27b7d3e0d9f674203c0f0bd9ec # 1.0.0-2487-c975847 branch: unstable Firebase: url: https://github.com/firebase/firebase-ios-sdk From 4c8e2d19a753c1703d0039a953833b59deec62f5 Mon Sep 17 00:00:00 2001 From: "bw-ghapp[bot]" <178206702+bw-ghapp[bot]@users.noreply.github.com> Date: Thu, 25 Sep 2025 15:05:57 +0000 Subject: [PATCH 02/15] SDK Update - 1e78e85 (1.0.0-2506-9947387) --- Bitwarden.xcworkspace/xcshareddata/swiftpm/Package.resolved | 2 +- project-common.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Bitwarden.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitwarden.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6426187a4e..ec95552ced 100644 --- a/Bitwarden.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitwarden.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -123,7 +123,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/bitwarden/sdk-swift", "state" : { - "revision" : "b10919f8b50d3d27b7d3e0d9f674203c0f0bd9ec" + "revision" : "1e78e85bd3d45d14cf76592b0f109ba54c119d4b" } }, { diff --git a/project-common.yml b/project-common.yml index 1c8e0b14b8..2bbe4714bf 100644 --- a/project-common.yml +++ b/project-common.yml @@ -14,7 +14,7 @@ include: packages: BitwardenSdk: url: https://github.com/bitwarden/sdk-swift - revision: b10919f8b50d3d27b7d3e0d9f674203c0f0bd9ec # 1.0.0-2487-c975847 + revision: 1e78e85bd3d45d14cf76592b0f109ba54c119d4b # 1.0.0-2506-9947387 branch: unstable Firebase: url: https://github.com/firebase/firebase-ios-sdk From 3ffb9dd32ed3551937bd7d064f689a84290a219d Mon Sep 17 00:00:00 2001 From: Federico Maccaroni Date: Thu, 25 Sep 2025 17:10:38 -0300 Subject: [PATCH 03/15] PM-26137 Update SDK to handle token provider for the SDK and also typed SDK errors. --- .../Extensions/BitwardenSdk+Platform.swift | 5 +-- .../BitwardenSdk+PlatformTests.swift | 12 +++--- .../Services/ClientBuilderTests.swift | 6 ++- .../Platform/Services/ClientService.swift | 10 ++++- .../Platform/Services/ServiceContainer.swift | 9 ++++- .../Core/Platform/Services/TokenService.swift | 26 +++++++++++- .../Platform/Services/TokenServiceTests.swift | 40 +++++++++++++++++-- .../TestHelpers/MockClientManagedTokens.swift | 12 ++++++ .../TestHelpers/MockVaultClientService.swift | 2 +- .../RemoveMasterPasswordProcessorTests.swift | 6 ++- .../Extensions/Alert+Networking.swift | 4 +- .../DebugMenu/DebugMenuProcessor.swift | 4 +- .../DebugMenu/DebugMenuProcessorTests.swift | 4 +- 13 files changed, 113 insertions(+), 27 deletions(-) create mode 100644 BitwardenShared/Core/Platform/Utilities/TestHelpers/MockClientManagedTokens.swift diff --git a/BitwardenShared/Core/Platform/Extensions/BitwardenSdk+Platform.swift b/BitwardenShared/Core/Platform/Extensions/BitwardenSdk+Platform.swift index 69f496fd83..7178207d48 100644 --- a/BitwardenShared/Core/Platform/Extensions/BitwardenSdk+Platform.swift +++ b/BitwardenShared/Core/Platform/Extensions/BitwardenSdk+Platform.swift @@ -6,9 +6,6 @@ import Foundation extension BitwardenSdk.BitwardenError: @retroactive CustomNSError { /// The user-info dictionary. public var errorUserInfo: [String: Any] { - guard case let .E(message) = self else { - return [:] - } - return ["Message": message] + ["SpecificError": String(describing: self)] } } diff --git a/BitwardenShared/Core/Platform/Extensions/BitwardenSdk+PlatformTests.swift b/BitwardenShared/Core/Platform/Extensions/BitwardenSdk+PlatformTests.swift index 13cf543794..f590af2e6e 100644 --- a/BitwardenShared/Core/Platform/Extensions/BitwardenSdk+PlatformTests.swift +++ b/BitwardenShared/Core/Platform/Extensions/BitwardenSdk+PlatformTests.swift @@ -8,11 +8,13 @@ import XCTest class BitwardenErrorTests: BitwardenTestCase { // MARK: Tests - /// `getter:errorUserInfo` gets the appropriate user info based on the message of the error `E` + /// `getter:errorUserInfo` gets the appropriate user info based on the message + /// of the internal `BitwardenSdk.BitwardenError`. func test_errorUserInfo() { - let expectedMessage = "expectedMessage" - let error = BitwardenSdk.BitwardenError.E(message: expectedMessage) - let userInfo = error.errorUserInfo - XCTAssertEqual(userInfo["Message"] as? String, expectedMessage) + let expectedMessage = "Crypto(BitwardenSdk.CryptoError.Fingerprint(message: \"internal error\"))" + let error = BitwardenSdk.BitwardenError.Crypto(CryptoError.Fingerprint(message: "internal error")) + let nsError = error as NSError + let userInfo = nsError.userInfo + XCTAssertEqual(userInfo["SpecificError"] as? String, expectedMessage) } } diff --git a/BitwardenShared/Core/Platform/Services/ClientBuilderTests.swift b/BitwardenShared/Core/Platform/Services/ClientBuilderTests.swift index bbe0c04a9a..1922c509c2 100644 --- a/BitwardenShared/Core/Platform/Services/ClientBuilderTests.swift +++ b/BitwardenShared/Core/Platform/Services/ClientBuilderTests.swift @@ -6,6 +6,7 @@ import XCTest class ClientBuilderTests: BitwardenTestCase { // MARK: Properties + var clientManagedTokens: MockClientManagedTokens! var errorReporter: MockErrorReporter! var mockPlatform: MockPlatformClientService! var subject: DefaultClientBuilder! @@ -15,16 +16,19 @@ class ClientBuilderTests: BitwardenTestCase { override func setUp() { super.setUp() + clientManagedTokens = MockClientManagedTokens() errorReporter = MockErrorReporter() mockPlatform = MockPlatformClientService() subject = DefaultClientBuilder( - errorReporter: errorReporter + errorReporter: errorReporter, + tokenProvider: clientManagedTokens ) } override func tearDown() { super.tearDown() + clientManagedTokens = nil errorReporter = nil mockPlatform = nil subject = nil diff --git a/BitwardenShared/Core/Platform/Services/ClientService.swift b/BitwardenShared/Core/Platform/Services/ClientService.swift index 2c20427b6a..699ff2661f 100644 --- a/BitwardenShared/Core/Platform/Services/ClientService.swift +++ b/BitwardenShared/Core/Platform/Services/ClientService.swift @@ -351,6 +351,9 @@ class DefaultClientBuilder: ClientBuilder { /// The settings applied to the client. private let settings: ClientSettings? + /// The token provider to pass to the SDK. + private let tokenProvider: ClientManagedTokens + // MARK: Initialization /// Initializes a new client. @@ -358,18 +361,21 @@ class DefaultClientBuilder: ClientBuilder { /// - Parameters: /// - errorReporter: The service used by the application to report non-fatal errors. /// - settings: The settings applied to the client. + /// - tokenProvider: The token provider to pass to the SDK. init( errorReporter: ErrorReporter, - settings: ClientSettings? = nil + settings: ClientSettings? = nil, + tokenProvider: ClientManagedTokens ) { self.errorReporter = errorReporter self.settings = settings + self.tokenProvider = tokenProvider } // MARK: Methods func buildClient() -> BitwardenSdkClient { - Client(settings: settings) + Client(tokenProvider: tokenProvider, settings: settings) } } diff --git a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift index ccb8ee6677..87832d804b 100644 --- a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift +++ b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift @@ -429,7 +429,11 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le let environmentService = DefaultEnvironmentService(errorReporter: errorReporter, stateService: stateService) let collectionService = DefaultCollectionService(collectionDataStore: dataStore, stateService: stateService) let settingsService = DefaultSettingsService(settingsDataStore: dataStore, stateService: stateService) - let tokenService = DefaultTokenService(keychainRepository: keychainRepository, stateService: stateService) + let tokenService = DefaultTokenService( + errorReporter: errorReporter, + keychainRepository: keychainRepository, + stateService: stateService + ) let apiService = APIService( environmentService: environmentService, flightRecorder: flightRecorder, @@ -457,7 +461,8 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le ) let clientBuilder = DefaultClientBuilder( - errorReporter: errorReporter + errorReporter: errorReporter, + tokenProvider: tokenService ) let clientService = DefaultClientService( clientBuilder: clientBuilder, diff --git a/BitwardenShared/Core/Platform/Services/TokenService.swift b/BitwardenShared/Core/Platform/Services/TokenService.swift index 0052e86986..a0ad7eff58 100644 --- a/BitwardenShared/Core/Platform/Services/TokenService.swift +++ b/BitwardenShared/Core/Platform/Services/TokenService.swift @@ -1,3 +1,6 @@ +import BitwardenKit +import BitwardenSdk + /// A protocol for a `TokenService` which manages accessing and updating the active account's tokens. /// protocol TokenService: AnyObject { @@ -35,6 +38,9 @@ protocol TokenService: AnyObject { actor DefaultTokenService: TokenService { // MARK: Properties + /// The service used by the application to report non-fatal errors. + let errorReporter: ErrorReporter + /// The repository used to manages keychain items. let keychainRepository: KeychainRepository @@ -46,13 +52,16 @@ actor DefaultTokenService: TokenService { /// Initialize a `DefaultTokenService`. /// /// - Parameters + /// - errorReporter: The service used by the application to report non-fatal errors. /// - keychainRepository: The repository used to manages keychain items. /// - stateService: The service that manages the account state. /// init( + errorReporter: ErrorReporter, keychainRepository: KeychainRepository, stateService: StateService ) { + self.errorReporter = errorReporter self.keychainRepository = keychainRepository self.stateService = stateService } @@ -65,7 +74,7 @@ actor DefaultTokenService: TokenService { } func getIsExternal() async throws -> Bool { - let accessToken = try await getAccessToken() + let accessToken: String = try await getAccessToken() let tokenPayload = try TokenParser.parseToken(accessToken) return tokenPayload.isExternal } @@ -81,3 +90,18 @@ actor DefaultTokenService: TokenService { try await keychainRepository.setRefreshToken(refreshToken, userId: userId) } } + +// MARK: ClientManagedTokens (SDK) + +extension DefaultTokenService: ClientManagedTokens { + /// Gets the access token for the SDK, nil if any errors are thrown. + func getAccessToken() async -> String? { + do { + let accessToken: String = try await getAccessToken() + return accessToken + } catch { + errorReporter.log(error: error) + return nil + } + } +} diff --git a/BitwardenShared/Core/Platform/Services/TokenServiceTests.swift b/BitwardenShared/Core/Platform/Services/TokenServiceTests.swift index d67ad71dd7..80ebe25239 100644 --- a/BitwardenShared/Core/Platform/Services/TokenServiceTests.swift +++ b/BitwardenShared/Core/Platform/Services/TokenServiceTests.swift @@ -1,3 +1,4 @@ +import BitwardenKitMocks import TestHelpers import XCTest @@ -6,6 +7,7 @@ import XCTest class TokenServiceTests: BitwardenTestCase { // MARK: Properties + var errorReporter: MockErrorReporter! var keychainRepository: MockKeychainRepository! var stateService: MockStateService! var subject: DefaultTokenService! @@ -15,15 +17,21 @@ class TokenServiceTests: BitwardenTestCase { override func setUp() { super.setUp() + errorReporter = MockErrorReporter() keychainRepository = MockKeychainRepository() stateService = MockStateService() - subject = DefaultTokenService(keychainRepository: keychainRepository, stateService: stateService) + subject = DefaultTokenService( + errorReporter: errorReporter, + keychainRepository: keychainRepository, + stateService: stateService + ) } override func tearDown() { super.tearDown() + errorReporter = nil keychainRepository = nil stateService = nil subject = nil @@ -35,12 +43,12 @@ class TokenServiceTests: BitwardenTestCase { func test_getAccessToken() async throws { stateService.activeAccount = .fixture() - let accessToken = try await subject.getAccessToken() + let accessToken: String = try await subject.getAccessToken() XCTAssertEqual(accessToken, "ACCESS_TOKEN") keychainRepository.getAccessTokenResult = .success("🔑") - let updatedAccessToken = try await subject.getAccessToken() + let updatedAccessToken: String = try await subject.getAccessToken() XCTAssertEqual(updatedAccessToken, "🔑") } @@ -49,10 +57,34 @@ class TokenServiceTests: BitwardenTestCase { stateService.activeAccount = nil await assertAsyncThrows(error: StateServiceError.noActiveAccount) { - _ = try await subject.getAccessToken() + let accessToken: String = try await subject.getAccessToken() } } + /// `getAccessToken()` returns the access token stored in the state service for the active account + /// for the `ClientManagedTokens` function. + func test_getAccessToken_sdk() async throws { + stateService.activeAccount = .fixture() + + let accessToken: String? = await subject.getAccessToken() + XCTAssertEqual(accessToken, "ACCESS_TOKEN") + + keychainRepository.getAccessTokenResult = .success("🔑") + + let updatedAccessToken: String? = await subject.getAccessToken() + XCTAssertEqual(updatedAccessToken, "🔑") + } + + /// `getAccessToken()` returns nil if there isn't an active account + /// for the `ClientManagedTokens` function. + func test_getAccessToken_sdkNoAccountNil() async { + stateService.activeAccount = nil + + let accessToken: String? = await subject.getAccessToken() + XCTAssertNil(accessToken) + XCTAssertEqual(errorReporter.errors as? [StateServiceError], [.noActiveAccount]) + } + /// `getIsExternal()` returns false if the user isn't an external user. func test_getIsExternal_false() async throws { // swiftlint:disable:next line_length diff --git a/BitwardenShared/Core/Platform/Utilities/TestHelpers/MockClientManagedTokens.swift b/BitwardenShared/Core/Platform/Utilities/TestHelpers/MockClientManagedTokens.swift new file mode 100644 index 0000000000..430057eab3 --- /dev/null +++ b/BitwardenShared/Core/Platform/Utilities/TestHelpers/MockClientManagedTokens.swift @@ -0,0 +1,12 @@ +import BitwardenSdk + +@testable import BitwardenShared + +@MainActor +final class MockClientManagedTokens: ClientManagedTokens { + var getAccessTokenReturnValue: String? + + func getAccessToken() async -> String? { + getAccessTokenReturnValue + } +} diff --git a/BitwardenShared/Core/Vault/Services/TestHelpers/MockVaultClientService.swift b/BitwardenShared/Core/Vault/Services/TestHelpers/MockVaultClientService.swift index ba5bc1b1e7..3a316e786c 100644 --- a/BitwardenShared/Core/Vault/Services/TestHelpers/MockVaultClientService.swift +++ b/BitwardenShared/Core/Vault/Services/TestHelpers/MockVaultClientService.swift @@ -199,7 +199,7 @@ class MockClientCollections: CollectionsClientProtocol { func getCollectionTree(collections: [BitwardenSdk.CollectionView]) -> BitwardenSdk.CollectionViewTree { getCollectionTreeReceivedCollection = collections - return getCollectionTreeReturnValue ?? BitwardenSdk.CollectionViewTree(noPointer: .init()) + return getCollectionTreeReturnValue ?? BitwardenSdk.CollectionViewTree(noHandle: .init()) } } diff --git a/BitwardenShared/UI/Auth/RemoveMasterPassword/RemoveMasterPasswordProcessorTests.swift b/BitwardenShared/UI/Auth/RemoveMasterPassword/RemoveMasterPasswordProcessorTests.swift index 240f27fd49..11f543a07f 100644 --- a/BitwardenShared/UI/Auth/RemoveMasterPassword/RemoveMasterPasswordProcessorTests.swift +++ b/BitwardenShared/UI/Auth/RemoveMasterPassword/RemoveMasterPasswordProcessorTests.swift @@ -81,7 +81,11 @@ class RemoveMasterPasswordProcessorTests: BitwardenTestCase { @MainActor func test_perform_continueFlow_invalidPassword() async throws { authRepository.migrateUserToKeyConnectorResult = .failure( - BitwardenSdk.BitwardenError.E(message: "invalid master password") + BitwardenSdk.BitwardenError.AuthValidate( + AuthValidateError.WrongPassword( + message: "invalid master password" + ) + ) ) subject.state.masterPassword = "password" diff --git a/BitwardenShared/UI/Platform/Application/Extensions/Alert+Networking.swift b/BitwardenShared/UI/Platform/Application/Extensions/Alert+Networking.swift index 19b31274cc..cd4828adf9 100644 --- a/BitwardenShared/UI/Platform/Application/Extensions/Alert+Networking.swift +++ b/BitwardenShared/UI/Platform/Application/Extensions/Alert+Networking.swift @@ -50,10 +50,10 @@ extension Alert { title: Localizations.anErrorHasOccurred, message: serverError.message ) - case let BitwardenSdk.BitwardenError.E(message): + case let sdkError as BitwardenSdk.BitwardenError: return defaultAlert( title: Localizations.anErrorHasOccurred, - message: message + message: sdkError.errorDescription ) case let error as URLError where error.code == .notConnectedToInternet || error.code == .networkConnectionLost: return internetConnectionError(tryAgain) diff --git a/BitwardenShared/UI/Platform/DebugMenu/DebugMenuProcessor.swift b/BitwardenShared/UI/Platform/DebugMenu/DebugMenuProcessor.swift index e7cefd7da0..6e2e4840a2 100644 --- a/BitwardenShared/UI/Platform/DebugMenu/DebugMenuProcessor.swift +++ b/BitwardenShared/UI/Platform/DebugMenu/DebugMenuProcessor.swift @@ -48,9 +48,9 @@ final class DebugMenuProcessor: StateProcessor Date: Thu, 25 Sep 2025 17:47:58 -0300 Subject: [PATCH 04/15] PM-26137 Updated BWA to new SDK version. --- .../Core/Platform/Services/ClientService.swift | 13 ++++++++++++- .../TestHelpers/MockVaultClientService.swift | 2 +- .../Application/Extensions/Alert+Networking.swift | 4 ++-- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/AuthenticatorShared/Core/Platform/Services/ClientService.swift b/AuthenticatorShared/Core/Platform/Services/ClientService.swift index e2c063a3d0..a022b9069a 100644 --- a/AuthenticatorShared/Core/Platform/Services/ClientService.swift +++ b/AuthenticatorShared/Core/Platform/Services/ClientService.swift @@ -285,7 +285,7 @@ class DefaultClientBuilder: ClientBuilder { // MARK: Methods func buildClient() -> BitwardenSdkClient { - Client(settings: settings) + Client(tokenProvider: DefaultClientManagedTokensProvider(), settings: settings) } } @@ -347,3 +347,14 @@ extension Client: BitwardenSdkClient { vault() as VaultClient } } + +// MARK: DefaultTokenProvider + +/// Default implementation of the SDK's `ClientManagedTokens`. +/// Given that we are not performing authenticated API calls in BWA +/// we just return `nil` for the access token. +final class DefaultClientManagedTokensProvider: ClientManagedTokens { + func getAccessToken() async -> String? { + nil + } +} diff --git a/AuthenticatorShared/Core/Vault/Services/TestHelpers/MockVaultClientService.swift b/AuthenticatorShared/Core/Vault/Services/TestHelpers/MockVaultClientService.swift index b3c80fab8b..764f28e388 100644 --- a/AuthenticatorShared/Core/Vault/Services/TestHelpers/MockVaultClientService.swift +++ b/AuthenticatorShared/Core/Vault/Services/TestHelpers/MockVaultClientService.swift @@ -163,7 +163,7 @@ class MockClientCollections: CollectionsClientProtocol { func getCollectionTree(collections: [BitwardenSdk.CollectionView]) -> BitwardenSdk.CollectionViewTree { getCollectionTreeReceivedCollection = collections - return getCollectionTreeReturnValue ?? BitwardenSdk.CollectionViewTree(noPointer: .init()) + return getCollectionTreeReturnValue ?? BitwardenSdk.CollectionViewTree(noHandle: .init()) } } diff --git a/AuthenticatorShared/UI/Platform/Application/Extensions/Alert+Networking.swift b/AuthenticatorShared/UI/Platform/Application/Extensions/Alert+Networking.swift index 9667f45617..a241de2395 100644 --- a/AuthenticatorShared/UI/Platform/Application/Extensions/Alert+Networking.swift +++ b/AuthenticatorShared/UI/Platform/Application/Extensions/Alert+Networking.swift @@ -45,10 +45,10 @@ extension Alert { title: Localizations.anErrorHasOccurred, message: serverError.message ) - case let BitwardenSdk.BitwardenError.E(message): + case let sdkError as BitwardenSdk.BitwardenError: return defaultAlert( title: Localizations.anErrorHasOccurred, - message: message + message: sdkError.errorDescription ) case let error as URLError where error.code == .notConnectedToInternet || error.code == .networkConnectionLost: return internetConnectionError(tryAgain) From 41a1e64c79081819d05a19fe47e437a3a6cf95d1 Mon Sep 17 00:00:00 2001 From: Federico Maccaroni Date: Fri, 26 Sep 2025 11:42:17 -0300 Subject: [PATCH 05/15] PM-26137 Temporarily return `nil` for the access token for the SDK as it expects to be valid / not expired in order to be passed. So TODO to add validation before passing to SDK. --- .../Core/Platform/Services/TokenService.swift | 10 +++------- .../Platform/Services/TokenServiceTests.swift | 20 ++----------------- 2 files changed, 5 insertions(+), 25 deletions(-) diff --git a/BitwardenShared/Core/Platform/Services/TokenService.swift b/BitwardenShared/Core/Platform/Services/TokenService.swift index a0ad7eff58..5713276900 100644 --- a/BitwardenShared/Core/Platform/Services/TokenService.swift +++ b/BitwardenShared/Core/Platform/Services/TokenService.swift @@ -96,12 +96,8 @@ actor DefaultTokenService: TokenService { extension DefaultTokenService: ClientManagedTokens { /// Gets the access token for the SDK, nil if any errors are thrown. func getAccessToken() async -> String? { - do { - let accessToken: String = try await getAccessToken() - return accessToken - } catch { - errorReporter.log(error: error) - return nil - } + // TODO: PM-21846 Returning `nil` temporarily until we add validation + // given that the SDK expects non-expired token. + return nil } } diff --git a/BitwardenShared/Core/Platform/Services/TokenServiceTests.swift b/BitwardenShared/Core/Platform/Services/TokenServiceTests.swift index 80ebe25239..f46003f159 100644 --- a/BitwardenShared/Core/Platform/Services/TokenServiceTests.swift +++ b/BitwardenShared/Core/Platform/Services/TokenServiceTests.swift @@ -62,27 +62,11 @@ class TokenServiceTests: BitwardenTestCase { } /// `getAccessToken()` returns the access token stored in the state service for the active account - /// for the `ClientManagedTokens` function. + /// for the `ClientManagedTokens` function. Now it temporarily returns `nil` until the expired validation is added + /// as the SDK always expect a valid token. func test_getAccessToken_sdk() async throws { - stateService.activeAccount = .fixture() - - let accessToken: String? = await subject.getAccessToken() - XCTAssertEqual(accessToken, "ACCESS_TOKEN") - - keychainRepository.getAccessTokenResult = .success("🔑") - - let updatedAccessToken: String? = await subject.getAccessToken() - XCTAssertEqual(updatedAccessToken, "🔑") - } - - /// `getAccessToken()` returns nil if there isn't an active account - /// for the `ClientManagedTokens` function. - func test_getAccessToken_sdkNoAccountNil() async { - stateService.activeAccount = nil - let accessToken: String? = await subject.getAccessToken() XCTAssertNil(accessToken) - XCTAssertEqual(errorReporter.errors as? [StateServiceError], [.noActiveAccount]) } /// `getIsExternal()` returns false if the user isn't an external user. From 1d5a7fe7b2d2730ab191b24be0b0f250cfde0374 Mon Sep 17 00:00:00 2001 From: Federico Maccaroni Date: Fri, 26 Sep 2025 14:14:17 -0300 Subject: [PATCH 06/15] PM-26137 Removed SDK error handling from the general alert networking so when it happens it just shows the general error message to avoid cryptic messages be shown to end users as they have the share sheet now. --- .../Platform/Application/Extensions/Alert+Networking.swift | 5 ----- .../Platform/Application/Extensions/Alert+Networking.swift | 5 ----- 2 files changed, 10 deletions(-) diff --git a/AuthenticatorShared/UI/Platform/Application/Extensions/Alert+Networking.swift b/AuthenticatorShared/UI/Platform/Application/Extensions/Alert+Networking.swift index a241de2395..a5c910c291 100644 --- a/AuthenticatorShared/UI/Platform/Application/Extensions/Alert+Networking.swift +++ b/AuthenticatorShared/UI/Platform/Application/Extensions/Alert+Networking.swift @@ -45,11 +45,6 @@ extension Alert { title: Localizations.anErrorHasOccurred, message: serverError.message ) - case let sdkError as BitwardenSdk.BitwardenError: - return defaultAlert( - title: Localizations.anErrorHasOccurred, - message: sdkError.errorDescription - ) case let error as URLError where error.code == .notConnectedToInternet || error.code == .networkConnectionLost: return internetConnectionError(tryAgain) case let error as URLError where error.code == .timedOut: diff --git a/BitwardenShared/UI/Platform/Application/Extensions/Alert+Networking.swift b/BitwardenShared/UI/Platform/Application/Extensions/Alert+Networking.swift index cd4828adf9..783cc10452 100644 --- a/BitwardenShared/UI/Platform/Application/Extensions/Alert+Networking.swift +++ b/BitwardenShared/UI/Platform/Application/Extensions/Alert+Networking.swift @@ -50,11 +50,6 @@ extension Alert { title: Localizations.anErrorHasOccurred, message: serverError.message ) - case let sdkError as BitwardenSdk.BitwardenError: - return defaultAlert( - title: Localizations.anErrorHasOccurred, - message: sdkError.errorDescription - ) case let error as URLError where error.code == .notConnectedToInternet || error.code == .networkConnectionLost: return internetConnectionError(tryAgain) case let error as URLError where error.code == .timedOut: From c9740a113f9831aa4af0cb0afaf88257858fad77 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Tue, 23 Sep 2025 15:02:08 -0500 Subject: [PATCH 07/15] Add models for WebAuthn API requests --- .../SecretVerificationRequestModel.swift | 7 +++ ...AuthnLoginSaveCredentialRequestModel.swift | 48 +++++++++++++++ ...LoginCredentialCreateOptionsResponse.swift | 16 +++++ ...inCredentialAssertionOptionsResponse.swift | 0 ...ginCredentialCreationOptionsResponse.swift | 59 +++++++++++++++++++ ...GetCredentialAssertionOptionsRequest.swift | 23 ++++++++ ...nGetCredentialCreationOptionsRequest.swift | 15 +++++ .../WebAuthnLoginSaveCredentialRequest.swift | 15 +++++ 8 files changed, 183 insertions(+) create mode 100644 BitwardenShared/Core/Auth/Models/Request/SecretVerificationRequestModel.swift create mode 100644 BitwardenShared/Core/Auth/Models/Request/WebAuthnLoginSaveCredentialRequestModel.swift create mode 100644 BitwardenShared/Core/Auth/Models/Request/WebauthnLoginCredentialCreateOptionsResponse.swift create mode 100644 BitwardenShared/Core/Auth/Models/Response/WebAuthnLoginCredentialAssertionOptionsResponse.swift create mode 100644 BitwardenShared/Core/Auth/Models/Response/WebAuthnLoginCredentialCreationOptionsResponse.swift create mode 100644 BitwardenShared/Core/Auth/Services/API/Auth/Requests/WebAuthnLoginGetCredentialAssertionOptionsRequest.swift create mode 100644 BitwardenShared/Core/Auth/Services/API/Auth/Requests/WebAuthnLoginGetCredentialCreationOptionsRequest.swift create mode 100644 BitwardenShared/Core/Auth/Services/API/Auth/Requests/WebAuthnLoginSaveCredentialRequest.swift diff --git a/BitwardenShared/Core/Auth/Models/Request/SecretVerificationRequestModel.swift b/BitwardenShared/Core/Auth/Models/Request/SecretVerificationRequestModel.swift new file mode 100644 index 0000000000..f79020ed3c --- /dev/null +++ b/BitwardenShared/Core/Auth/Models/Request/SecretVerificationRequestModel.swift @@ -0,0 +1,7 @@ +// +// SecretRequestVerificationModel.swift +// Bitwarden +// +// Created by Isaiah Inuwa on 2025-09-19. +// + diff --git a/BitwardenShared/Core/Auth/Models/Request/WebAuthnLoginSaveCredentialRequestModel.swift b/BitwardenShared/Core/Auth/Models/Request/WebAuthnLoginSaveCredentialRequestModel.swift new file mode 100644 index 0000000000..354b492dd7 --- /dev/null +++ b/BitwardenShared/Core/Auth/Models/Request/WebAuthnLoginSaveCredentialRequestModel.swift @@ -0,0 +1,48 @@ +import Foundation +import Networking + +// MARK: - SaveCredentialRequestModel + +/// The request body for an answer login request request. +/// +struct SaveCredentialRequestModel: JSONRequestBody, Equatable { + static let encoder = JSONEncoder() + + // MARK: Properties + // The response received from the authenticator. + // This contains all information needed for future authentication flows. + let deviceResponse: WebAuthnLoginAttestationResponseRequest + + // Nickname chosen by the user to identify this credential + let name: String + + // Token required by the server to complete the creation. + // It contains encrypted information that the server needs to verify the credential. + let token: String + + // True if the credential was created with PRF support. + let supportsPrf: Bool + + // Used for vault encryption. See {@link RotateableKeySet.encryptedUserKey } + let encryptedUserKey: String? + + // Used for vault encryption. See {@link RotateableKeySet.encryptedPublicKey } + let encryptedPublicKey: String? + + // Used for vault encryption. See {@link RotateableKeySet.encryptedPrivateKey } + let encryptedPrivateKey: String? +} + +struct WebAuthnLoginAttestationResponseRequest: Encodable, Equatable { + let id: String + let rawId: String + let type: String + // let extensions: [String: Any] + let response: WebAuthnLoginAttestationResponseRequestInner +} + +struct WebAuthnLoginAttestationResponseRequestInner: Encodable, Equatable { + let attestation_object: String + let client_data_json: String + +} diff --git a/BitwardenShared/Core/Auth/Models/Request/WebauthnLoginCredentialCreateOptionsResponse.swift b/BitwardenShared/Core/Auth/Models/Request/WebauthnLoginCredentialCreateOptionsResponse.swift new file mode 100644 index 0000000000..f8390b244a --- /dev/null +++ b/BitwardenShared/Core/Auth/Models/Request/WebauthnLoginCredentialCreateOptionsResponse.swift @@ -0,0 +1,16 @@ +export class WebauthnLoginCredentialCreateOptionsResponse extends BaseResponse { + /** Options to be provided to the webauthn authenticator */ + options: ChallengeResponse; + + /** + * Contains an encrypted version of the {@link options}. + * Used by the server to validate the attestation response of newly created credentials. + */ + token: string; + + constructor(response: unknown) { + super(response); + this.options = new ChallengeResponse(this.getResponseProperty("options")); + this.token = this.getResponseProperty("token"); + } +} \ No newline at end of file diff --git a/BitwardenShared/Core/Auth/Models/Response/WebAuthnLoginCredentialAssertionOptionsResponse.swift b/BitwardenShared/Core/Auth/Models/Response/WebAuthnLoginCredentialAssertionOptionsResponse.swift new file mode 100644 index 0000000000..e69de29bb2 diff --git a/BitwardenShared/Core/Auth/Models/Response/WebAuthnLoginCredentialCreationOptionsResponse.swift b/BitwardenShared/Core/Auth/Models/Response/WebAuthnLoginCredentialCreationOptionsResponse.swift new file mode 100644 index 0000000000..44d32201ce --- /dev/null +++ b/BitwardenShared/Core/Auth/Models/Response/WebAuthnLoginCredentialCreationOptionsResponse.swift @@ -0,0 +1,59 @@ +import Networking +import Foundation + +struct WebAuthnLoginCredentialCreateOptionsResponse: JSONResponse, Equatable, Sendable { + /// Options to be provided to the webauthn authenticator. + let options: PublicKeyCredentialCreationOptions; + + /// Contains an encrypted version of the {@link options}. + /// Used by the server to validate the attestation response of newly created credentials. + let token: String; +} + +struct PublicKeyCredentialCreationOptions: Codable, Equatable, Hashable { + // attestation?: AttestationConveyancePreference + // let authenticatorSelection: AuthenticatorSelectionCriteria? + let challenge: String + let excludeCredentials: [PublicKeyCredentialDescriptor]? + let extensions: AuthenticationExtensionsClientInputs? + let pubKeyCredParams: [PublicKeyCredentialParameters] + let rp: PublicKeyCredentialRpEntity + let timeout: Int? + let user: PublicKeyCredentialUserEntity +} + + +struct AuthenticationExtensionsClientInputs: Codable, Equatable, Hashable { + let prf: AuthenticationExtensionsPRFInputs +} + +struct AuthenticationExtensionsPRFInputs: Codable, Equatable, Hashable { + let eval: AuthenticationExtensionsPRFValues? + let evalByCredential: [String: AuthenticationExtensionsPRFValues]? +} + +struct AuthenticationExtensionsPRFValues: Codable, Equatable, Hashable { + let first: String + let second: String? +} + +struct PublicKeyCredentialDescriptor: Codable, Equatable, Hashable { + let type: String + let id: String + // let transports: [String]? +} + +struct PublicKeyCredentialParameters: Codable, Equatable, Hashable { + let type: String + let alg: Int +} + +struct PublicKeyCredentialRpEntity: Codable, Equatable, Hashable { + let id: String + let name: String +} + +struct PublicKeyCredentialUserEntity: Codable, Equatable, Hashable { + let id: String + let name: String +} diff --git a/BitwardenShared/Core/Auth/Services/API/Auth/Requests/WebAuthnLoginGetCredentialAssertionOptionsRequest.swift b/BitwardenShared/Core/Auth/Services/API/Auth/Requests/WebAuthnLoginGetCredentialAssertionOptionsRequest.swift new file mode 100644 index 0000000000..fc30369eab --- /dev/null +++ b/BitwardenShared/Core/Auth/Services/API/Auth/Requests/WebAuthnLoginGetCredentialAssertionOptionsRequest.swift @@ -0,0 +1,23 @@ +// +// WebAuthnLoginGetCredentialCreationOptionsRequest 2.swift +// Bitwarden +// +// Created by Isaiah Inuwa on 2025-09-19. +// + + +import Networking + +struct WebAuthnLoginGetCredentialCreationOptionsRequest : Request { + typealias Response = WebAuthnLoginCredentialCreationOptionsResponse + + var body: SecretVerificationRequestModel { requestModel } + + var path: String { "/webauthn/attestation-options" } + + var method: HTTPMethod { .post } + + let requestModel: SecretVerificationRequestModel + + +} diff --git a/BitwardenShared/Core/Auth/Services/API/Auth/Requests/WebAuthnLoginGetCredentialCreationOptionsRequest.swift b/BitwardenShared/Core/Auth/Services/API/Auth/Requests/WebAuthnLoginGetCredentialCreationOptionsRequest.swift new file mode 100644 index 0000000000..3be684c279 --- /dev/null +++ b/BitwardenShared/Core/Auth/Services/API/Auth/Requests/WebAuthnLoginGetCredentialCreationOptionsRequest.swift @@ -0,0 +1,15 @@ +import Networking + +struct WebAuthnLoginGetCredentialCreationOptionsRequest : Request { + typealias Response = WebAuthnLoginCredentialCreateOptionsResponse + + var body: SecretVerificationRequestModel { requestModel } + + var path: String { "/webauthn/attestation-options" } + + var method: HTTPMethod { .post } + + let requestModel: SecretVerificationRequestModel + + +} diff --git a/BitwardenShared/Core/Auth/Services/API/Auth/Requests/WebAuthnLoginSaveCredentialRequest.swift b/BitwardenShared/Core/Auth/Services/API/Auth/Requests/WebAuthnLoginSaveCredentialRequest.swift new file mode 100644 index 0000000000..52c44ec389 --- /dev/null +++ b/BitwardenShared/Core/Auth/Services/API/Auth/Requests/WebAuthnLoginSaveCredentialRequest.swift @@ -0,0 +1,15 @@ +import Networking + +struct WebAuthnLoginSaveCredentialRequest : Request { + typealias Response = EmptyResponse + + var body: WebAuthnLoginSaveCredentialRequestModel { requestModel } + + var path: String { "/webauthn" } + + var method: HTTPMethod { .post } + + let requestModel: WebAuthnLoginSaveCredentialRequestModel + + +} From 058a1ca9518f94a066f11095de84c9639cb652ea Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Tue, 23 Sep 2025 15:02:34 -0500 Subject: [PATCH 08/15] Save device passkey to keychain --- .../SecretVerificationRequestModel.swift | 36 ++- ...AuthnLoginSaveCredentialRequestModel.swift | 6 +- ...LoginCredentialCreateOptionsResponse.swift | 16 - ...inCredentialAssertionOptionsResponse.swift | 19 ++ ...ginCredentialCreationOptionsResponse.swift | 32 +- .../Services/API/Auth/AuthAPIService.swift | 24 ++ ...GetCredentialAssertionOptionsRequest.swift | 18 +- ...nGetCredentialCreationOptionsRequest.swift | 6 +- .../WebAuthnLoginSaveCredentialRequest.swift | 4 +- .../Core/Auth/Services/AuthService.swift | 301 +++++++++++++++++- .../Auth/Services/KeychainRepository.swift | 48 ++- .../Platform/Services/ServiceContainer.swift | 7 + .../Fido2CredentialStoreService.swift | 34 ++ .../CompleteRegistrationProcessor.swift | 1 + 14 files changed, 485 insertions(+), 67 deletions(-) delete mode 100644 BitwardenShared/Core/Auth/Models/Request/WebauthnLoginCredentialCreateOptionsResponse.swift diff --git a/BitwardenShared/Core/Auth/Models/Request/SecretVerificationRequestModel.swift b/BitwardenShared/Core/Auth/Models/Request/SecretVerificationRequestModel.swift index f79020ed3c..bf7961ef54 100644 --- a/BitwardenShared/Core/Auth/Models/Request/SecretVerificationRequestModel.swift +++ b/BitwardenShared/Core/Auth/Models/Request/SecretVerificationRequestModel.swift @@ -1,7 +1,31 @@ -// -// SecretRequestVerificationModel.swift -// Bitwarden -// -// Created by Isaiah Inuwa on 2025-09-19. -// +import Foundation +import Networking +struct SecretVerificationRequestModel: JSONRequestBody, Equatable { + static let encoder = JSONEncoder() + + // MARK: Properties + + let authRequestAccessCode: String? + let masterPasswordHash: String? + let otp: String? + + + init(passwordHash: String) { + authRequestAccessCode = nil + masterPasswordHash = passwordHash + otp = nil + } + + init(otp: String) { + masterPasswordHash = nil + self.otp = otp + authRequestAccessCode = nil + } + + init(accessCode: String) { + authRequestAccessCode = accessCode + masterPasswordHash = nil + otp = nil + } +} diff --git a/BitwardenShared/Core/Auth/Models/Request/WebAuthnLoginSaveCredentialRequestModel.swift b/BitwardenShared/Core/Auth/Models/Request/WebAuthnLoginSaveCredentialRequestModel.swift index 354b492dd7..c1a70f7540 100644 --- a/BitwardenShared/Core/Auth/Models/Request/WebAuthnLoginSaveCredentialRequestModel.swift +++ b/BitwardenShared/Core/Auth/Models/Request/WebAuthnLoginSaveCredentialRequestModel.swift @@ -5,7 +5,7 @@ import Networking /// The request body for an answer login request request. /// -struct SaveCredentialRequestModel: JSONRequestBody, Equatable { +struct WebAuthnLoginSaveCredentialRequestModel: JSONRequestBody, Equatable { static let encoder = JSONEncoder() // MARK: Properties @@ -42,7 +42,7 @@ struct WebAuthnLoginAttestationResponseRequest: Encodable, Equatable { } struct WebAuthnLoginAttestationResponseRequestInner: Encodable, Equatable { - let attestation_object: String - let client_data_json: String + let attestationObject: String + let clientDataJson: String } diff --git a/BitwardenShared/Core/Auth/Models/Request/WebauthnLoginCredentialCreateOptionsResponse.swift b/BitwardenShared/Core/Auth/Models/Request/WebauthnLoginCredentialCreateOptionsResponse.swift deleted file mode 100644 index f8390b244a..0000000000 --- a/BitwardenShared/Core/Auth/Models/Request/WebauthnLoginCredentialCreateOptionsResponse.swift +++ /dev/null @@ -1,16 +0,0 @@ -export class WebauthnLoginCredentialCreateOptionsResponse extends BaseResponse { - /** Options to be provided to the webauthn authenticator */ - options: ChallengeResponse; - - /** - * Contains an encrypted version of the {@link options}. - * Used by the server to validate the attestation response of newly created credentials. - */ - token: string; - - constructor(response: unknown) { - super(response); - this.options = new ChallengeResponse(this.getResponseProperty("options")); - this.token = this.getResponseProperty("token"); - } -} \ No newline at end of file diff --git a/BitwardenShared/Core/Auth/Models/Response/WebAuthnLoginCredentialAssertionOptionsResponse.swift b/BitwardenShared/Core/Auth/Models/Response/WebAuthnLoginCredentialAssertionOptionsResponse.swift index e69de29bb2..7cce01dec0 100644 --- a/BitwardenShared/Core/Auth/Models/Response/WebAuthnLoginCredentialAssertionOptionsResponse.swift +++ b/BitwardenShared/Core/Auth/Models/Response/WebAuthnLoginCredentialAssertionOptionsResponse.swift @@ -0,0 +1,19 @@ +import Foundation +import Networking + +struct WebAuthnLoginCredentialAssertionOptionsResponse: JSONResponse, Equatable, Sendable { + /// Options to be provided to the webauthn authenticator. + let options: PublicKeyCredentialAssertionOptions; + + /// Contains an encrypted version of the {@link options}. + /// Used by the server to validate the attestation response of newly created credentials. + let token: String; +} + +struct PublicKeyCredentialAssertionOptions: Codable, Equatable, Hashable { + let allowCredentials: [BwPublicKeyCredentialDescriptor]? + let challenge: String + let extensions: AuthenticationExtensionsClientInputs? + let rpId: String + let timeout: Int? +} diff --git a/BitwardenShared/Core/Auth/Models/Response/WebAuthnLoginCredentialCreationOptionsResponse.swift b/BitwardenShared/Core/Auth/Models/Response/WebAuthnLoginCredentialCreationOptionsResponse.swift index 44d32201ce..0e0f89d26a 100644 --- a/BitwardenShared/Core/Auth/Models/Response/WebAuthnLoginCredentialCreationOptionsResponse.swift +++ b/BitwardenShared/Core/Auth/Models/Response/WebAuthnLoginCredentialCreationOptionsResponse.swift @@ -1,30 +1,30 @@ -import Networking import Foundation +import Networking -struct WebAuthnLoginCredentialCreateOptionsResponse: JSONResponse, Equatable, Sendable { - /// Options to be provided to the webauthn authenticator. - let options: PublicKeyCredentialCreationOptions; +struct WebAuthnLoginCredentialCreationOptionsResponse: JSONResponse, Equatable, Sendable { + /// Options to be provided to the webauthn authenticator. + let options: PublicKeyCredentialCreationOptions; - /// Contains an encrypted version of the {@link options}. - /// Used by the server to validate the attestation response of newly created credentials. - let token: String; + /// Contains an encrypted version of the {@link options}. + /// Used by the server to validate the attestation response of newly created credentials. + let token: String; } struct PublicKeyCredentialCreationOptions: Codable, Equatable, Hashable { // attestation?: AttestationConveyancePreference // let authenticatorSelection: AuthenticatorSelectionCriteria? let challenge: String - let excludeCredentials: [PublicKeyCredentialDescriptor]? + let excludeCredentials: [BwPublicKeyCredentialDescriptor]? let extensions: AuthenticationExtensionsClientInputs? - let pubKeyCredParams: [PublicKeyCredentialParameters] - let rp: PublicKeyCredentialRpEntity + let pubKeyCredParams: [BwPublicKeyCredentialParameters] + let rp: BwPublicKeyCredentialRpEntity let timeout: Int? - let user: PublicKeyCredentialUserEntity + let user: BwPublicKeyCredentialUserEntity } struct AuthenticationExtensionsClientInputs: Codable, Equatable, Hashable { - let prf: AuthenticationExtensionsPRFInputs + let prf: AuthenticationExtensionsPRFInputs? } struct AuthenticationExtensionsPRFInputs: Codable, Equatable, Hashable { @@ -37,23 +37,23 @@ struct AuthenticationExtensionsPRFValues: Codable, Equatable, Hashable { let second: String? } -struct PublicKeyCredentialDescriptor: Codable, Equatable, Hashable { +struct BwPublicKeyCredentialDescriptor: Codable, Equatable, Hashable { let type: String let id: String // let transports: [String]? } -struct PublicKeyCredentialParameters: Codable, Equatable, Hashable { +struct BwPublicKeyCredentialParameters: Codable, Equatable, Hashable { let type: String let alg: Int } -struct PublicKeyCredentialRpEntity: Codable, Equatable, Hashable { +struct BwPublicKeyCredentialRpEntity: Codable, Equatable, Hashable { let id: String let name: String } -struct PublicKeyCredentialUserEntity: Codable, Equatable, Hashable { +struct BwPublicKeyCredentialUserEntity: Codable, Equatable, Hashable { let id: String let name: String } diff --git a/BitwardenShared/Core/Auth/Services/API/Auth/AuthAPIService.swift b/BitwardenShared/Core/Auth/Services/API/Auth/AuthAPIService.swift index 2c7e0085b4..1c6dbf137b 100644 --- a/BitwardenShared/Core/Auth/Services/API/Auth/AuthAPIService.swift +++ b/BitwardenShared/Core/Auth/Services/API/Auth/AuthAPIService.swift @@ -20,6 +20,16 @@ protocol AuthAPIService { /// - Returns: The pending login request. /// func checkPendingLoginRequest(withId id: String, accessCode: String) async throws -> LoginRequest + + /// Retrieves the parameters for creating a new WebAuthn credential. + /// - Parameters: + /// - request: The data needed to send the request. + func getCredentialCreationOptions(_ request: SecretVerificationRequestModel) async throws -> WebAuthnLoginCredentialCreationOptionsResponse + + /// Retrieves the parameters for authenticating with a WebAuthn credential. + /// - Parameters: + /// - request: The data needed to send the request. + func getCredentialAssertionOptions(_ request: SecretVerificationRequestModel) async throws -> WebAuthnLoginCredentialAssertionOptionsResponse /// Performs the identity token request and returns the response. /// @@ -83,6 +93,8 @@ protocol AuthAPIService { /// - model: The data needed to send the request. /// func updateTrustedDeviceKeys(deviceIdentifier: String, model: TrustedDeviceKeysRequestModel) async throws + + func saveCredential(_ model: WebAuthnLoginSaveCredentialRequestModel) async throws } extension APIService: AuthAPIService { @@ -93,6 +105,14 @@ extension APIService: AuthAPIService { func checkPendingLoginRequest(withId id: String, accessCode: String) async throws -> LoginRequest { try await apiUnauthenticatedService.send(CheckLoginRequestRequest(accessCode: accessCode, id: id)) } + + func getCredentialCreationOptions(_ request: SecretVerificationRequestModel) async throws -> WebAuthnLoginCredentialCreationOptionsResponse { + try await apiService.send(WebAuthnLoginGetCredentialCreationOptionsRequest(requestModel: request)) + } + + func getCredentialAssertionOptions(_ request: SecretVerificationRequestModel) async throws -> WebAuthnLoginCredentialAssertionOptionsResponse { + try await apiService.send(WebAuthnLoginGetCredentialAssertionOptionsRequest(requestModel: request)) + } func getIdentityToken(_ request: IdentityTokenRequestModel) async throws -> IdentityTokenResponseModel { try await identityService.send(IdentityTokenRequest(requestModel: request)) @@ -133,6 +153,10 @@ extension APIService: AuthAPIService { _ = try await apiUnauthenticatedService.send(ResendNewDeviceOtpRequest(model: model)) } + func saveCredential(_ model: WebAuthnLoginSaveCredentialRequestModel) async throws { + _ = try await apiService.send(WebAuthnLoginSaveCredentialRequest(requestModel: model)) + } + func updateTrustedDeviceKeys(deviceIdentifier: String, model: TrustedDeviceKeysRequestModel) async throws { _ = try await apiService.send(TrustedDeviceKeysRequest(deviceIdentifier: deviceIdentifier, requestModel: model)) } diff --git a/BitwardenShared/Core/Auth/Services/API/Auth/Requests/WebAuthnLoginGetCredentialAssertionOptionsRequest.swift b/BitwardenShared/Core/Auth/Services/API/Auth/Requests/WebAuthnLoginGetCredentialAssertionOptionsRequest.swift index fc30369eab..5ce9b5de3b 100644 --- a/BitwardenShared/Core/Auth/Services/API/Auth/Requests/WebAuthnLoginGetCredentialAssertionOptionsRequest.swift +++ b/BitwardenShared/Core/Auth/Services/API/Auth/Requests/WebAuthnLoginGetCredentialAssertionOptionsRequest.swift @@ -1,23 +1,13 @@ -// -// WebAuthnLoginGetCredentialCreationOptionsRequest 2.swift -// Bitwarden -// -// Created by Isaiah Inuwa on 2025-09-19. -// - - import Networking -struct WebAuthnLoginGetCredentialCreationOptionsRequest : Request { - typealias Response = WebAuthnLoginCredentialCreationOptionsResponse +struct WebAuthnLoginGetCredentialAssertionOptionsRequest : Request { + typealias Response = WebAuthnLoginCredentialAssertionOptionsResponse - var body: SecretVerificationRequestModel { requestModel } + var body: SecretVerificationRequestModel? { requestModel } - var path: String { "/webauthn/attestation-options" } + var path: String { "/webauthn/assertion-options" } var method: HTTPMethod { .post } let requestModel: SecretVerificationRequestModel - - } diff --git a/BitwardenShared/Core/Auth/Services/API/Auth/Requests/WebAuthnLoginGetCredentialCreationOptionsRequest.swift b/BitwardenShared/Core/Auth/Services/API/Auth/Requests/WebAuthnLoginGetCredentialCreationOptionsRequest.swift index 3be684c279..d7665fddcf 100644 --- a/BitwardenShared/Core/Auth/Services/API/Auth/Requests/WebAuthnLoginGetCredentialCreationOptionsRequest.swift +++ b/BitwardenShared/Core/Auth/Services/API/Auth/Requests/WebAuthnLoginGetCredentialCreationOptionsRequest.swift @@ -1,15 +1,13 @@ import Networking struct WebAuthnLoginGetCredentialCreationOptionsRequest : Request { - typealias Response = WebAuthnLoginCredentialCreateOptionsResponse + typealias Response = WebAuthnLoginCredentialCreationOptionsResponse - var body: SecretVerificationRequestModel { requestModel } + var body: SecretVerificationRequestModel? { requestModel } var path: String { "/webauthn/attestation-options" } var method: HTTPMethod { .post } let requestModel: SecretVerificationRequestModel - - } diff --git a/BitwardenShared/Core/Auth/Services/API/Auth/Requests/WebAuthnLoginSaveCredentialRequest.swift b/BitwardenShared/Core/Auth/Services/API/Auth/Requests/WebAuthnLoginSaveCredentialRequest.swift index 52c44ec389..395b39bf60 100644 --- a/BitwardenShared/Core/Auth/Services/API/Auth/Requests/WebAuthnLoginSaveCredentialRequest.swift +++ b/BitwardenShared/Core/Auth/Services/API/Auth/Requests/WebAuthnLoginSaveCredentialRequest.swift @@ -3,13 +3,11 @@ import Networking struct WebAuthnLoginSaveCredentialRequest : Request { typealias Response = EmptyResponse - var body: WebAuthnLoginSaveCredentialRequestModel { requestModel } + var body: WebAuthnLoginSaveCredentialRequestModel? { requestModel } var path: String { "/webauthn" } var method: HTTPMethod { .post } let requestModel: WebAuthnLoginSaveCredentialRequestModel - - } diff --git a/BitwardenShared/Core/Auth/Services/AuthService.swift b/BitwardenShared/Core/Auth/Services/AuthService.swift index ac591b15e5..e07bc080c6 100644 --- a/BitwardenShared/Core/Auth/Services/AuthService.swift +++ b/BitwardenShared/Core/Auth/Services/AuthService.swift @@ -53,6 +53,9 @@ enum AuthError: Error { /// There was a problem generating the request to resend the email with new device otp. case unableToResendNewDeviceOtp + + /// There was a problem generating the device passkey or PRF encryption key. + case unableToCreateDevicePasskey } // MARK: - LoginUnlockMethod @@ -89,6 +92,9 @@ protocol AuthService { /// Check the status of the pending login request for the unauthenticated user. /// func checkPendingLoginRequest(withId id: String) async throws -> LoginRequest + + /// Create device passkey with PRF encryption key. + func createDevicePasskey(masterPasswordHash: String) async throws /// Deny all the pending login requests. /// @@ -241,6 +247,18 @@ extension AuthService { } } +struct DevicePasskeyRecord: Decodable, Encodable { + let credId: String + let privKey: String + let prfSeed: String + let rpId: String + let rpName: String? + let userId: String? + let userName: String? + let userDisplayName: String? + let creationDate: DateTime +} + // MARK: - DefaultAuthService /// The default implementation of `AuthService`. @@ -278,6 +296,13 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng /// The service used by the application to report non-fatal errors. private let errorReporter: ErrorReporter + // TODO: Exposing this externally so we can do late binding + /// The service to provide the UI for FIDO2 ceremonies. + var fido2UserInterfaceHelper: Fido2UserInterfaceHelper? + + /// The store to place FIDO2 credentials into. + var fido2CredentialStore: Fido2CredentialStore? + /// The repository used to manages keychain items. private let keychainRepository: KeychainRepository @@ -343,6 +368,8 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng credentialIdentityStore: CredentialIdentityStore = ASCredentialIdentityStore.shared, environmentService: EnvironmentService, errorReporter: ErrorReporter, + // fido2UserInterfaceHelper: Fido2UserInterfaceHelper, + // fido2CredentialStore: Fido2CredentialStore, keychainRepository: KeychainRepository, policyService: PolicyService, stateService: StateService, @@ -362,6 +389,8 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng self.stateService = stateService self.systemDevice = systemDevice self.trustDeviceService = trustDeviceService + // self.fido2UserInterfaceHelper = fido2UserInterfaceHelper + // self.fido2CredentialStore = fido2CredentialStore } // MARK: Methods @@ -589,7 +618,7 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng email: username, masterPassword: masterPassword ) - + // Save the master password hash. try await saveMasterPasswordHash(password: masterPassword) @@ -997,4 +1026,272 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng purpose: .localAuthorization )) } -} // swiftlint:disable:this file_length + + /// Create device passkey with PRF encryption key. + func createDevicePasskey(masterPasswordHash: String) async throws { + // try await clientService.crypto().initializeUserCrypto(req: cryptoInitRequest) + let response = try await authAPIService.getCredentialCreationOptions(SecretVerificationRequestModel(passwordHash: masterPasswordHash)) + let options = response.options + let token = response.token + // TODO: Does server request PRF extension? + // let credResponse: PublicKeyCredentialAuthenticatorAttestationResponse = try await BitwardenSdk.CreateWebAuthnCredential(options) + let loginWithPrfSalt = Data(SHA256.hash(data: "passwordless-login".data(using: .utf8)!)) + let excludeCredentials: [PublicKeyCredentialDescriptor]? = if options.excludeCredentials != nil { + options.excludeCredentials!.map { + return PublicKeyCredentialDescriptor(ty: $0.type, id: Data(base64Encoded: normalizeBase64url($0.id))!, transports: nil) + } + } + else { nil } + let credParams = options.pubKeyCredParams.map { + PublicKeyCredentialParameters(ty: $0.type, alg: Int64($0.alg)) + } + // Would be nice if Fido2Net allowed specifying clientDataJson or hash + // TODO: This doesn't validate URLs as proper web origins (no path, with port + scheme, etc.) + let origin = environmentService.webVaultURL.absoluteString + let clientDataJson = #"{"type":"webauthn.create","challenge":"\#(options.challenge)","origin":"\#(origin)"}"# + let clientDataHash = Data(SHA256.hash(data: clientDataJson.data(using: .utf8)!)) + let credRequest = MakeCredentialRequest( + clientDataHash: clientDataHash, + rp: PublicKeyCredentialRpEntity(id: options.rp.id, name: options.rp.name), + user: PublicKeyCredentialUserEntity( + id: Data(base64Encoded: normalizeBase64url(options.user.id))!, + displayName: options.user.name, + name: options.user.name + ), + pubKeyCredParams: credParams, + excludeList: excludeCredentials, + options: Options( + rk: true, + // TODO: hard-coding + uv: .required + ), + extensions: """ + { + "prf": { + "eval": { "first": "\(loginWithPrfSalt.base64EncodedString())" } + } + } + """ + ) + /* + let createdCredential = try await clientService.platform().fido2() + .authenticator( + userInterface: fido2UserInterfaceHelper!, + credentialStore: fido2CredentialStore! + ) + .makeCredential(request: credRequest) + */ + let makeResult = try makeWebAuthnCredential(request: credRequest) + let createdCredential = makeResult.credential + let prfResult = makeResult.prfResult + let credRecord = DevicePasskeyRecord( + credId: createdCredential.credentialId.base64EncodedString(), + privKey: makeResult.privKey.rawRepresentation.base64EncodedString(), + prfSeed: makeResult.prfSeed.base64EncodedString(), + rpId: credRequest.rp.id, + rpName: credRequest.rp.name, + userId: credRequest.user.id.base64UrlEncodedString(trimPadding: false), + userName: credRequest.user.name, + userDisplayName: credRequest.user.displayName, + creationDate: CurrentTime().presentTime, + + ) + let encoder = JSONEncoder() + let recordJson = try String(data: encoder.encode(credRecord), encoding: .utf8)! + try await keychainRepository.setDevicePasskey(recordJson, userId: stateService.getActiveAccountId()) + // This may be filled in if the device supports the hmac-secret-mc extension + /* + let authData = credResponse.response.authenticatorData + let flags = authData[32] + let AUTH_DATA_AT: UInt8 = 0b0100_0000 + let AUTH_DATA_ED: UInt8 = 0b1000_0000 + let hasExtensions = flags & AUTH_DATA_ED > 0 + let hasAttestationData = flags & AUTH_DATA_AT > 0 + var pos = 37 + 16 + let credIdLen = authData[pos..pos + 2].toUint16 + pos += 2 + credIdLen + // have to parse CBOR to know how long this thing is... + // pos += ??? + let extensionCbor = authData[pos..] + // [parse CBOR to get this out of here + // This is bogus, we should add helper methods to Rust SDK so we don't have to do this manually. + */ + /* + let prfResult = + if createdCredential.extensions?.prf?.enabled == true + && credResponse.authData.extensions?.prf?.results.first != nil { + credResponse.authData.extensions.prf.results.first + } + // otherwise, send a attestation request to get the PRF value + else { + // We should request a new challenge rather than reusing this one. + // The server should be modified to accept two signatures and make sure the assertion signature matches the credential in the attestation. + let nativeOptions = PublicKeyCredentialAssertionOptions( + rpId: options.rp.id, + challenge: options.challenge, + allowCredentials: [[ "id": credResponse.rawId, "type": "public-key" ]], + timeout: options.timeout, + userVerification: options.authenticatorSelection.userVerification, + extensions: """ + { + prf: { eval: { first: "\(loginWithPrfSalt)" } }, + } + """ + ) + let assertion = try await BitwardenSdk.GetAssertion(nativeOptions) + assertion.authData.extensions?.prf?.first + } + */ + + // Since we have alternative methods of signing with biometrics/local OS auth, + // the only purpose of this key is to provide PRF for remote clients. + // So let's assert that we can create the PRF. + guard prfResult != nil else { throw AuthError.unableToCreateDevicePasskey } + let prfKeyResponse = try await clientService.crypto().derivePrfKey(prf: prfResult.base64EncodedString()) + print(prfKeyResponse) + + // TODO: Get real app name + let clientName = "My local device" + // authAPIService.saveWebAuthnCredential(clientName, credResponse, supportsPrf: true, keySet) + let request = WebAuthnLoginSaveCredentialRequestModel( + deviceResponse: WebAuthnLoginAttestationResponseRequest( + id: createdCredential.credentialId.base64UrlEncodedString(trimPadding: false), + rawId: createdCredential.credentialId.base64UrlEncodedString(trimPadding: false), + type: "public-key", + response: WebAuthnLoginAttestationResponseRequestInner( + attestationObject: createdCredential.attestationObject.base64UrlEncodedString(trimPadding: false), + clientDataJson: clientDataJson.data(using: .utf8)!.base64UrlEncodedString(trimPadding: false), + ), + ), + name: clientName, + token: token, + supportsPrf: true, + encryptedUserKey: prfKeyResponse.encapsulatedUserKey, + encryptedPublicKey: prfKeyResponse.encryptedEncapsulationKey, + encryptedPrivateKey: prfKeyResponse.prfKeyEncryptedDecapsulationKey, + ) + try await authAPIService.saveCredential(request) + } + + private func normalizeBase64url(_ str: String) -> String { + let hasPadding = str.last == "=" + let padding = if !hasPadding { + switch str.count % 4 { + case 2: "==" + case 3: "=" + default: "" + } + } else { "" } + return str + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + + padding + } + + private func makeWebAuthnCredential(request: MakeCredentialRequest) throws -> DevicePasskeyResult { + // attested credential data + let aaguid = Data(count:16) + let credId = Data(repeating: 0xf1, count: 16) + let privKey = P256.Signing.PrivateKey(compactRepresentable: false) + let publicKeyBytes = privKey.publicKey.rawRepresentation + let pointX = publicKeyBytes[1..<33] + let pointY = publicKeyBytes[33...] + var cosePubKey = Data() + cosePubKey.append(contentsOf: [ + 0xA5, // Map, length 5 + 0x01, 0x02, // 1 (kty): 2 (EC2) + 0x03, 0x26, // 3 (alg): -7 (ES256) + 0x20, 0x01, // -1 (crv): 1 (P256) + ]) + cosePubKey.append(contentsOf: [ + 0x21, 0x58, 0x20// -2 (x): bytes, len 32 + ]) + cosePubKey.append(contentsOf: pointX) + cosePubKey.append(contentsOf: [ + 0x22, 0x58, 0x20// -3 (x): bytes, len 32 + ]) + cosePubKey.append(contentsOf: pointY) + let attestedCredentialData = aaguid + UInt16(credId.count).bytes + credId + cosePubKey + + // extensions + // prf + let prfSeed = "this is a PRF secret, I promise!".data(using: .utf8)! + let saltPrefix = "WebAuthn PRF\0".data(using: .utf8)! + // hard-coding instead of parsing extensions from request + let loginWithPrfSalt = Data(SHA256.hash(data: "passwordless-login".data(using: .utf8)!)) + let salt1 = saltPrefix + loginWithPrfSalt + // This should be encrypted with a shared secret between the client and authenticator so that the RP doesn't see the PRF output. Skipping that for now. + let prfResult = Data(HMAC.authenticationCode(for: salt1, using: SymmetricKey(data: prfSeed))) + var extensions = Data() + extensions.append(contentsOf:[ + 0xA1, // map, length 1 + 0x63, 0x70, 0x72, 0x66, // string, len 3 "prf" + 0xA2, // map, length 2 + 0x67, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, // text, length 7 "enabled" + 0xF5, // true + 0x67, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, // text, length 7 "results + 0xA1, // map, length 1 + 0x65, 0x66, 0x69, 0x72, 0x73, 0x74, // text, length 5, "first" + 0x58, 0x20, // bytes, length 32 + ]) + extensions.append(contentsOf: prfResult) + + // authenticatorData + let rpIdHash = Data(SHA256.hash(data: request.rp.id.data(using: .utf8)!)) + let flags = 0b11000101 // ED, AT, UV, UP + let signCount = UInt32(0) + let authData = rpIdHash + UInt8(flags).bytes + signCount.bigEndian.bytes + attestedCredentialData + extensions + + // signature + let payload = authData + request.clientDataHash + let sig = try privKey.signature(for: payload).derRepresentation + + // attestation object + var attObj = Data() + attObj.append(contentsOf: [ + 0xA3, // map, length 3 + 0x63, 0x66, 0x6d, 0x74, // string, len 3 "fmt" + // 0x64, 0x6e, 0x6f, 0x6e, 0x65, // string, len 3 "none" + // 0x67, 0x61, 0x74, 0x74, 0x53, 0x74, 0x6d, 0x74, // string, len 7, "attStmt" + // 0xA0, // map, length 0 + 0x66, 0x70, 0x61, 0x63, 0x6b, 0x65, 0x64, // string, len 6, "packed" + 0x67, 0x61, 0x74, 0x74, 0x53, 0x74, 0x6d, 0x74, // string, len 7, "attStmt" + 0xA2, // map, length 2 + 0x63, 0x61, 0x6c, 0x67, // string, len 3, "alg" + 0x26, // -7 (P256) + 0x63, 0x73, 0x69, 0x67, // string, len 3, "sig" + 0x58, // bytes, length specified in following byte + ]) + attObj.append(contentsOf: UInt8(sig.count).bytes) + attObj.append(contentsOf: sig) + attObj.append(contentsOf:[ + 0x68, 0x61, 0x75, 0x74, 0x68, 0x44, 0x61, 0x74, 0x61, // string, len 8, "authData" + 0x58, // bytes, length specified in following byte. + ]) + attObj.append(contentsOf: UInt8(authData.count).bytes) + attObj.append(contentsOf: authData) + let result = MakeCredentialResult(authenticatorData: authData, attestationObject: attObj, credentialId: credId) + // Even though prfResult is included in extensions, we'd have to parse CBOR, so just including it for now + return DevicePasskeyResult(credential: result, privKey: privKey, prfSeed: prfSeed, prfResult: prfResult) + } + struct DevicePasskeyResult { + let credential: MakeCredentialResult + let privKey: P256.Signing.PrivateKey + let prfSeed: Data + let prfResult: Data + } +} + +extension Data { + func base64UrlEncodedString(trimPadding: Bool? = true) -> String { + let shouldTrim = if trimPadding != nil { trimPadding! } else { true } + let encoded = base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_") + if shouldTrim { + return encoded.trimmingCharacters(in: CharacterSet(["="])) + } else { + return encoded + } + } +} + +// swiftlint:disable:this file_length diff --git a/BitwardenShared/Core/Auth/Services/KeychainRepository.swift b/BitwardenShared/Core/Auth/Services/KeychainRepository.swift index c468da9c1e..f3da20475a 100644 --- a/BitwardenShared/Core/Auth/Services/KeychainRepository.swift +++ b/BitwardenShared/Core/Auth/Services/KeychainRepository.swift @@ -27,6 +27,8 @@ enum KeychainItem: Equatable { /// The keychain item for a user's refresh token. case refreshToken(userId: String) + case devicePasskey(userId: String) + /// The `SecAccessControlCreateFlags` level for this keychain item. /// If `nil`, no extra protection is applied. /// @@ -37,7 +39,8 @@ enum KeychainItem: Equatable { .deviceKey, .neverLock, .pendingAdminLoginRequest, - .refreshToken: + .refreshToken, + .devicePasskey: nil case .biometrics: .biometryCurrentSet @@ -56,6 +59,8 @@ enum KeychainItem: Equatable { .authenticatorVaultKey, .refreshToken: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + // TODO: These keys are not restored on backups, maybe this is a bad idea + case .devicePasskey: kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly } } @@ -77,6 +82,8 @@ enum KeychainItem: Equatable { "pendingAdminLoginRequest_\(userId)" case let .refreshToken(userId): "refreshToken_\(userId)" + case let .devicePasskey(userId: id): + "devicePasskey_\(id)" } } } @@ -114,9 +121,14 @@ protocol KeychainRepository: AnyObject { /// Attempts to delete the pending admin login request from the keychain. /// - /// - Parameter userId: The user ID associated with the stored device key. + /// - Parameter userId: The user ID associated with the pending admin login request. /// func deletePendingAdminLoginRequest(userId: String) async throws + + /// Attempts to delete the device passkey from the keychain. + /// + /// - Parameter userId: The user ID associated with the stored device passkey. + func deleteDevicePasskey(userId: String) async throws /// Gets the stored access token for a user from the keychain. /// @@ -152,7 +164,14 @@ protocol KeychainRepository: AnyObject { /// - Returns: The pending admin login request. /// func getPendingAdminLoginRequest(userId: String) async throws -> String? - + + /// Gets the stored device passkey for a user from the keychain. + /// + /// - Parameter userId: The user ID associated with the stored device passkey. + /// - Returns: The device key. + /// + func getDevicePasskey(userId: String) async throws -> String? + /// Gets a user auth key value. /// /// - Parameter item: The storage key of the user auth key. @@ -199,6 +218,14 @@ protocol KeychainRepository: AnyObject { /// - userId: The user ID associated with the pending admin login request. /// func setPendingAdminLoginRequest(_ value: String, userId: String) async throws + + /// Sets the device passkey for a user ID. + /// + /// - Parameters: + /// - value: The passkey to store + /// - userId: The user ID associated with the device passkey. + /// + func setDevicePasskey(_ value: String, userId: String) async throws /// Sets a user auth key/value pair. /// @@ -386,6 +413,7 @@ extension DefaultKeychainRepository { // Exclude `pendingAdminLoginRequest` since if a TDE user is logged out before the request // is approved, the next login for the user will succeed with the pending request. .refreshToken(userId: userId), + // Exclude `devicePasskey` since it is used to log back into an account. ] for keychainItem in keychainItems { try await keychainService.delete(query: keychainQueryValues(for: keychainItem)) @@ -409,6 +437,12 @@ extension DefaultKeychainRepository { query: keychainQueryValues(for: .pendingAdminLoginRequest(userId: userId)) ) } + + func deleteDevicePasskey(userId: String) async throws { + try await keychainService.delete( + query: keychainQueryValues(for: .devicePasskey(userId: userId)) + ) + } func getAccessToken(userId: String) async throws -> String { try await getValue(for: .accessToken(userId: userId)) @@ -430,6 +464,10 @@ extension DefaultKeychainRepository { try await getValue(for: .pendingAdminLoginRequest(userId: userId)) } + func getDevicePasskey(userId: String) async throws -> String? { + try await getValue(for: .devicePasskey(userId: userId)) + } + func getUserAuthKeyValue(for item: KeychainItem) async throws -> String { try await getValue(for: item) } @@ -453,6 +491,10 @@ extension DefaultKeychainRepository { func setPendingAdminLoginRequest(_ value: String, userId: String) async throws { try await setValue(value, for: .pendingAdminLoginRequest(userId: userId)) } + + func setDevicePasskey(_ value: String, userId: String) async throws { + try await setValue(value, for: .devicePasskey(userId: userId)) + } func setUserAuthKey(for item: KeychainItem, value: String) async throws { try await setValue(value, for: item) diff --git a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift index 8d95c5fd92..a51e9562e1 100644 --- a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift +++ b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift @@ -769,6 +769,8 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le cipherService: cipherService, clientService: clientService, errorReporter: errorReporter, + keychainRepository: keychainRepository, + stateService: stateService, syncService: syncService ) ) @@ -777,6 +779,8 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le cipherService: cipherService, clientService: clientService, errorReporter: errorReporter, + keychainRepository: keychainRepository, + stateService: stateService, syncService: syncService ) #endif @@ -796,6 +800,9 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le totpService: totpService, vaultTimeoutService: vaultTimeoutService ) + // HACK: To avoid a circular dependency, we're using late binding. + authService.fido2UserInterfaceHelper = fido2UserInterfaceHelper + authService.fido2CredentialStore = fido2CredentialStore let credentialManagerFactory = DefaultCredentialManagerFactory() let cxfCredentialsResultBuilder = DefaultCXFCredentialsResultBuilder() diff --git a/BitwardenShared/Core/Vault/Services/Fido2CredentialStoreService.swift b/BitwardenShared/Core/Vault/Services/Fido2CredentialStoreService.swift index abdf2a6e32..d7ba559449 100644 --- a/BitwardenShared/Core/Vault/Services/Fido2CredentialStoreService.swift +++ b/BitwardenShared/Core/Vault/Services/Fido2CredentialStoreService.swift @@ -15,6 +15,10 @@ class Fido2CredentialStoreService: Fido2CredentialStore { /// The service used by the application to report non-fatal errors. private let errorReporter: ErrorReporter + + private let keychainRepository: KeychainRepository + + private let stateService: StateService /// The service used to handle syncing vault data with the API private let syncService: SyncService @@ -31,11 +35,15 @@ class Fido2CredentialStoreService: Fido2CredentialStore { cipherService: CipherService, clientService: ClientService, errorReporter: ErrorReporter, + keychainRepository: KeychainRepository, + stateService: StateService, syncService: SyncService ) { self.cipherService = cipherService self.clientService = clientService self.errorReporter = errorReporter + self.keychainRepository = keychainRepository + self.stateService = stateService self.syncService = syncService } @@ -85,6 +93,32 @@ class Fido2CredentialStoreService: Fido2CredentialStore { result.append(cipherView) } + // let webVaultRpId = services.environmentService.webVaultURL.domain + let webVaultRpId = "localhost" + if webVaultRpId == ripId { + let json = try await keychainRepository.getDevicePasskey(userId: stateService.getActiveAccountId()) + guard json != nil else { + print("Matched Bitwarden Web Vault rpID, but no device passkey found. Skipping") + return result + } + let decoder = JSONDecoder() + let record: DevicePasskeyRecord = try decoder.decode(DevicePasskeyRecord.self, from: json!.data(using: .utf8)!) + let cipherView = BitwardenSdk.CipherView(fido2CredentialNewView: Fido2CredentialNewView( + credentialId: record.credId, + keyType: "public-key", + keyAlgorithm: "ECDSA", + keyCurve: "P-256", + rpId: record.rpId, + userHandle: record.userId, + userName: record.userName, + counter: "0", + rpName: record.rpName, + userDisplayName: record.userDisplayName, + creationDate: record.creationDate,), + timeProvider: CurrentTime(), + ) + result.append(cipherView) + } return result } diff --git a/BitwardenShared/UI/Auth/CompleteRegistration/CompleteRegistrationProcessor.swift b/BitwardenShared/UI/Auth/CompleteRegistration/CompleteRegistrationProcessor.swift index 47ab024841..47c3d3c153 100644 --- a/BitwardenShared/UI/Auth/CompleteRegistration/CompleteRegistrationProcessor.swift +++ b/BitwardenShared/UI/Auth/CompleteRegistration/CompleteRegistrationProcessor.swift @@ -234,6 +234,7 @@ class CompleteRegistrationProcessor: StateProcessor< ) try await services.authRepository.unlockVaultWithPassword(password: state.passwordText) + try await services.authService.createDevicePasskey(masterPasswordHash: services.authService.hashPassword(password: state.passwordText, purpose: .serverAuthorization)) await coordinator.handleEvent(.didCompleteAuth) coordinator.navigate(to: .dismiss) From a2da0ec49725e01f3d15d2a0868ef9d0ee135888 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Thu, 25 Sep 2025 10:44:34 -0500 Subject: [PATCH 09/15] Provide device passkey assertion --- .../Extensions/BitwardenSdk+Autofill.swift | 41 +++- .../Services/AutofillCredentialService.swift | 198 ++++++++++++++++-- .../Platform/Services/ServiceContainer.swift | 1 + .../Fido2CredentialStoreService.swift | 26 --- .../VaultAutofillListProcessor.swift | 1 + 5 files changed, 223 insertions(+), 44 deletions(-) diff --git a/BitwardenShared/Core/Autofill/Extensions/BitwardenSdk+Autofill.swift b/BitwardenShared/Core/Autofill/Extensions/BitwardenSdk+Autofill.swift index 656eb46374..39391e3503 100644 --- a/BitwardenShared/Core/Autofill/Extensions/BitwardenSdk+Autofill.swift +++ b/BitwardenShared/Core/Autofill/Extensions/BitwardenSdk+Autofill.swift @@ -47,11 +47,50 @@ extension GetAssertionRequest { rk: false, uv: BitwardenSdk.Uv(preference: passkeyRequest.userVerificationPreference) ), - extensions: nil + extensions: createExtensionJson(passkeyRequest: passkeyRequest), ) } } +@available(iOSApplicationExtension 17.0, *) +private func createExtensionJson(passkeyRequest: ASPasskeyCredentialRequest) -> String? { + guard #available(iOSApplicationExtension 18.0, *) else { + return nil + } + let prf: (Data, Data?)? = switch passkeyRequest.extensionInput { + case let .assertion(ext): + if let input = ext.prf?.inputValues { + (input.saltInput1, input.saltInput2) + } + else { + nil + } + case let .registration(ext): + if let input = ext.prf?.inputValues { + (input.saltInput1, input.saltInput2) + } + else { + nil + } + default: + nil + } + guard let prf else { return nil } + + let encoder = JSONEncoder() + let salt2 = if let salt2 = prf.1 { + "\"" + salt2.base64UrlEncodedString(trimPadding: true) + "\"" + } + else { "null" } + let eval = #""" + { + "first": "\#(prf.0.base64UrlEncodedString(trimPadding: true))", + "second": \#(salt2) + } + """# + return #"{"prf":{"eval":\#(eval)}"# +} + // MARK: - MakeCredentialRequest extension BitwardenSdk.MakeCredentialRequest: @retroactive CustomDebugStringConvertible { diff --git a/BitwardenShared/Core/Autofill/Services/AutofillCredentialService.swift b/BitwardenShared/Core/Autofill/Services/AutofillCredentialService.swift index 90dc74953e..944914fea6 100644 --- a/BitwardenShared/Core/Autofill/Services/AutofillCredentialService.swift +++ b/BitwardenShared/Core/Autofill/Services/AutofillCredentialService.swift @@ -1,6 +1,7 @@ import AuthenticationServices import BitwardenKit import BitwardenSdk +import CryptoKit import OSLog // swiftlint:disable file_length @@ -108,6 +109,8 @@ class DefaultAutofillCredentialService { /// The service used to manage the credentials available for AutoFill suggestions. private let identityStore: CredentialIdentityStore + private let keychainRepository: KeychainRepository + /// The last user ID that had their identities synced. private var lastSyncedUserId: String? @@ -122,7 +125,7 @@ class DefaultAutofillCredentialService { /// The service used by the application to manage account state. private let stateService: StateService - + /// A reference to the task used to sync the user's ciphers to the identity store. This allows /// the task to be cancelled and recreated when the user changes. private var syncTask: Task? @@ -144,6 +147,7 @@ class DefaultAutofillCredentialService { /// 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. + /// - keychainRepository: 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. /// - timeProvider: Provides the present time. @@ -159,6 +163,7 @@ class DefaultAutofillCredentialService { fido2CredentialStore: Fido2CredentialStore, fido2UserInterfaceHelper: Fido2UserInterfaceHelper, identityStore: CredentialIdentityStore = ASCredentialIdentityStore.shared, + keychainRepository: KeychainRepository, pasteboardService: PasteboardService, stateService: StateService, timeProvider: TimeProvider, @@ -173,6 +178,7 @@ class DefaultAutofillCredentialService { self.fido2CredentialStore = fido2CredentialStore self.fido2UserInterfaceHelper = fido2UserInterfaceHelper self.identityStore = identityStore + self.keychainRepository = keychainRepository self.pasteboardService = pasteboardService self.stateService = stateService self.timeProvider = timeProvider @@ -366,7 +372,7 @@ extension DefaultAutofillCredentialService: AutofillCredentialService { let request = GetAssertionRequest( passkeyRequest: passkeyRequest, credentialIdentity: credentialIdentity ) - + return try await provideFido2Credential( with: request, fido2UserInterfaceHelperDelegate: fido2UserInterfaceHelperDelegate, @@ -457,6 +463,8 @@ extension DefaultAutofillCredentialService: AutofillCredentialService { rpId: String, clientDataHash: Data ) async throws -> ASPasskeyAssertionCredential { + let logger = Logger() + logger.info("Starting provideFido2Credential") await fido2UserInterfaceHelper.setupDelegate( fido2UserInterfaceHelperDelegate: fido2UserInterfaceHelperDelegate ) @@ -469,12 +477,28 @@ extension DefaultAutofillCredentialService: AutofillCredentialService { #endif do { - let assertionResult = try await clientService.platform().fido2() - .authenticator( - userInterface: fido2UserInterfaceHelper, - credentialStore: fido2CredentialStore - ) - .getAssertion(request: request) + let devicePasskeyResult = try await useDevicePasskey(for: request, rpId: rpId, clientDataHash: clientDataHash) + let (assertionResult, prfResult): (GetAssertionResult, Data?) = if let devicePasskeyResult { + devicePasskeyResult + } else { + (try await clientService.platform().fido2() + .authenticator( + userInterface: fido2UserInterfaceHelper, + credentialStore: fido2CredentialStore + ) + .getAssertion(request: request) + , nil as Data?) + } + + print(request) + logger.debug("clientDataHash: \(request.clientDataHash.base64EncodedString())") + logger.debug("rpId: \(request.rpId)") + logger.debug("Passkey result") + logger.debug("authData: \(assertionResult.authenticatorData.base64EncodedString())") + logger.debug("credId: \(assertionResult.credentialId.base64EncodedString())") + logger.debug("signature: \(assertionResult.signature.base64EncodedString())") + logger.debug("userHandle: \(assertionResult.userHandle.base64EncodedString())") + logger.debug("prfResult: \(prfResult?.base64EncodedString() ?? "")") #if DEBUG Fido2DebuggingReportBuilder.builder.withGetAssertionResult(.success(assertionResult)) @@ -485,15 +509,37 @@ extension DefaultAutofillCredentialService: AutofillCredentialService { } catch { errorReporter.log(error: error) } - - return ASPasskeyAssertionCredential( - userHandle: assertionResult.userHandle, - relyingParty: rpId, - signature: assertionResult.signature, - clientDataHash: clientDataHash, - authenticatorData: assertionResult.authenticatorData, - credentialID: assertionResult.credentialId - ) + + + if #available(iOSApplicationExtension 18.0, *) { + let extOutput = if let prfResult { + ASPasskeyAssertionCredentialExtensionOutput( + largeBlob: nil, + prf: ASAuthorizationPublicKeyCredentialPRFAssertionOutput(first: SymmetricKey(data: prfResult), second: nil)) + } + else { + nil as ASPasskeyAssertionCredentialExtensionOutput? + } + return ASPasskeyAssertionCredential( + userHandle: assertionResult.userHandle, + relyingParty: rpId, + signature: assertionResult.signature, + clientDataHash: clientDataHash, + authenticatorData: assertionResult.authenticatorData, + credentialID: assertionResult.credentialId, + extensionOutput: extOutput, + ) + } + else { + return ASPasskeyAssertionCredential( + userHandle: assertionResult.userHandle, + relyingParty: rpId, + signature: assertionResult.signature, + clientDataHash: clientDataHash, + authenticatorData: assertionResult.authenticatorData, + credentialID: assertionResult.credentialId, + ) + } } catch { #if DEBUG Fido2DebuggingReportBuilder.builder.withGetAssertionResult(.failure(error)) @@ -501,6 +547,124 @@ extension DefaultAutofillCredentialService: AutofillCredentialService { throw error } } + + private func useDevicePasskey(for request: GetAssertionRequest, rpId: String, clientDataHash: Data) async throws -> (GetAssertionResult, Data?)? { + // let webVaultRpId = services.environmentService.webVaultURL.domain + let webVaultRpId = "localhost" + guard webVaultRpId == rpId else { return nil } + guard let json = try await keychainRepository.getDevicePasskey(userId: stateService.getActiveAccountId()) else { + print("Matched Bitwarden Web Vault rpID, but no device passkey found. Forwarding to main implementation") + return nil + } + + let decoder = JSONDecoder() + let loginWithPrfSalt = Data(SHA256.hash(data: "passwordless-login".data(using: .utf8)!)) + let saltInput1 = try getPrfInput(extensionsInput: request.extensions) ?? loginWithPrfSalt + let record: DevicePasskeyRecord = try decoder.decode(DevicePasskeyRecord.self, from: json.data(using: .utf8)!) + + // extensions + // prf + let prfSeed = Data(base64Encoded: record.prfSeed)! + let saltPrefix = "WebAuthn PRF\0".data(using: .utf8)! + // hard-coding instead of parsing extensions from request + let salt1 = saltPrefix + saltInput1 + // This should be encrypted with a shared secret between the client and authenticator so that the RP doesn't see the PRF output. Skipping that for now. + let prfResult = Data(HMAC.authenticationCode(for: salt1, using: SymmetricKey(data: prfSeed))) + var extensions = Data() + /* + extensions.append(contentsOf:[ + 0xA1, // map, length 1 + 0x63, 0x70, 0x72, 0x66, // string, len 3 "prf" + 0xA1, // map, length 1 + 0x67, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, // text, length 7 "results + 0xA1, // map, length 1 + 0x65, 0x66, 0x69, 0x72, 0x73, 0x74, // text, length 5, "first" + 0x58, 0x20, // bytes, length 32 + ]) + extensions.append(contentsOf: prfResult) + */ + + // authenticatorData + let rpIdHash = Data(SHA256.hash(data: rpId.data(using: .utf8)!)) + let flags = 0b0001_1101 // UV, UP, BE and BS also set because macOS requires it :( + let signCount = UInt32(0) + let authData = rpIdHash + UInt8(flags).bytes + signCount.bytes // + extensions + + // signature + let payload = authData + request.clientDataHash + let privKey = try P256.Signing.PrivateKey(rawRepresentation: Data(base64Encoded: record.privKey)!) + let sig = try privKey.signature(for: payload).derRepresentation + + // attestation object + var attObj = Data() + attObj.append(contentsOf: [ + 0xA3, // map, length 3 + 0x63, 0x66, 0x6d, 0x74, // string, len 3 "fmt" + 0x66, 0x70, 0x61, 0x63, 0x6b, 0x65, 0x64, // string, len 6, "packed" + 0x67, 0x61, 0x74, 0x74, 0x53, 0x74, 0x6d, 0x74, // string, len 7, "attStmt" + 0xA2, // map, length 2 + 0x63, 0x61, 0x6c, 0x67, // string, len 3, "alg" + 0x26, // -7 (P256) + 0x63, 0x73, 0x69, 0x67, // string, len 3, "sig" + 0x58, // bytes, length specified in following byte + ]) + attObj.append(contentsOf: UInt8(sig.count).bytes) + attObj.append(contentsOf: sig) + attObj.append(contentsOf:[ + 0x68, 0x61, 0x75, 0x74, 0x68, 0x44, 0x61, 0x74, 0x61, // string, len 8, "authData" + 0x58, // bytes, length specified in following byte. + ]) + attObj.append(contentsOf: UInt8(authData.count).bytes) + attObj.append(contentsOf: authData) + let fido2View = Fido2CredentialView( + credentialId: record.credId, + keyType: "public-key", + keyAlgorithm: "ECDSA", + keyCurve: "P-256", + keyValue: EncString(), + rpId: record.rpId, + userHandle: nil, + userName: nil, + counter: "0", + rpName: nil, + userDisplayName: nil, + discoverable: "true", + creationDate: record.creationDate, + ) + let fido2NewView = Fido2CredentialNewView( + credentialId: record.credId, + keyType: "public-key", + keyAlgorithm: "ECDSA", + keyCurve: "P-256", + rpId: record.rpId, + userHandle: nil, + userName: nil, + counter: "0", + rpName: nil, + userDisplayName: nil, + creationDate: record.creationDate, + ) + let credId = Data(base64Encoded: record.credId)! + let userHandle = Data(base64Encoded: record.userId!)! + let result = GetAssertionResult( + credentialId: credId, + authenticatorData: authData, + signature: sig, + userHandle: userHandle, + selectedCredential: SelectedCredential(cipher: CipherView(fido2CredentialNewView: fido2NewView, timeProvider: CurrentTime()), credential: fido2View), + ) + return (result, prfResult) + // Even though prfResult is included in extensions, we'd have to parse CBOR, so just including it for now + // return DevicePasskeyResult(credential: result, privKey: privKey, prfSeed: prfSeed, prfResult: prfResult) + } +} + +private func getPrfInput(extensionsInput extensions: String?) throws -> Data? { + guard let extensions else { return nil } + let decoder = JSONDecoder() + let extInputs = try decoder.decode(AuthenticationExtensionsClientInputs.self, from: extensions.data(using: .utf8)!) + guard let first = extInputs.prf?.eval?.first else { return nil } + return Data(base64Encoded: first) } // MARK: - CredentialIdentityStore diff --git a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift index a51e9562e1..e3d6e8e366 100644 --- a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift +++ b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift @@ -794,6 +794,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le eventService: eventService, fido2CredentialStore: fido2CredentialStore, fido2UserInterfaceHelper: fido2UserInterfaceHelper, + keychainRepository: keychainRepository, pasteboardService: pasteboardService, stateService: stateService, timeProvider: timeProvider, diff --git a/BitwardenShared/Core/Vault/Services/Fido2CredentialStoreService.swift b/BitwardenShared/Core/Vault/Services/Fido2CredentialStoreService.swift index d7ba559449..001a3f2cdb 100644 --- a/BitwardenShared/Core/Vault/Services/Fido2CredentialStoreService.swift +++ b/BitwardenShared/Core/Vault/Services/Fido2CredentialStoreService.swift @@ -93,32 +93,6 @@ class Fido2CredentialStoreService: Fido2CredentialStore { result.append(cipherView) } - // let webVaultRpId = services.environmentService.webVaultURL.domain - let webVaultRpId = "localhost" - if webVaultRpId == ripId { - let json = try await keychainRepository.getDevicePasskey(userId: stateService.getActiveAccountId()) - guard json != nil else { - print("Matched Bitwarden Web Vault rpID, but no device passkey found. Skipping") - return result - } - let decoder = JSONDecoder() - let record: DevicePasskeyRecord = try decoder.decode(DevicePasskeyRecord.self, from: json!.data(using: .utf8)!) - let cipherView = BitwardenSdk.CipherView(fido2CredentialNewView: Fido2CredentialNewView( - credentialId: record.credId, - keyType: "public-key", - keyAlgorithm: "ECDSA", - keyCurve: "P-256", - rpId: record.rpId, - userHandle: record.userId, - userName: record.userName, - counter: "0", - rpName: record.rpName, - userDisplayName: record.userDisplayName, - creationDate: record.creationDate,), - timeProvider: CurrentTime(), - ) - result.append(cipherView) - } return result } diff --git a/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListProcessor.swift b/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListProcessor.swift index 38bc3db03f..00e8d4891f 100644 --- a/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListProcessor.swift +++ b/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListProcessor.swift @@ -578,6 +578,7 @@ extension VaultAutofillListProcessor { for: fido2RequestParameters, fido2UserInterfaceHelperDelegate: self ) + print(assertionCredential) autofillAppExtensionDelegate.completeAssertionRequest(assertionCredential: assertionCredential) } catch { From a8fab21d783219d8d54398a4239d3fd6f3fc3686 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Fri, 26 Sep 2025 12:47:23 -0500 Subject: [PATCH 10/15] Clean up internal passkey implementation --- .../Platform/Utilities/Base64UrlEncoder.swift | 33 ++ .../Core/Auth/Services/AuthService.swift | 292 +------------- .../Auth/Services/DevicePasskeyService.swift | 372 ++++++++++++++++++ .../Auth/Services/KeychainRepository.swift | 6 +- .../Services/AutofillCredentialService.swift | 126 +----- .../Platform/Services/ServiceContainer.swift | 22 +- 6 files changed, 446 insertions(+), 405 deletions(-) create mode 100644 BitwardenKit/Core/Platform/Utilities/Base64UrlEncoder.swift create mode 100644 BitwardenShared/Core/Auth/Services/DevicePasskeyService.swift diff --git a/BitwardenKit/Core/Platform/Utilities/Base64UrlEncoder.swift b/BitwardenKit/Core/Platform/Utilities/Base64UrlEncoder.swift new file mode 100644 index 0000000000..bf95c86352 --- /dev/null +++ b/BitwardenKit/Core/Platform/Utilities/Base64UrlEncoder.swift @@ -0,0 +1,33 @@ +import Foundation + +extension Data { + public func base64UrlEncodedString(trimPadding: Bool? = true) -> String { + let shouldTrim = if trimPadding != nil { trimPadding! } else { true } + let encoded = base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_") + if shouldTrim { + return encoded.trimmingCharacters(in: CharacterSet(["="])) + } else { + return encoded + } + } + + public init?(base64UrlEncoded str: String) { + self.init(base64Encoded: normalizeBase64Url(str)) + } +} + +private func normalizeBase64Url(_ str: String) -> String { + let hasPadding = str.last == "=" + let padding = if !hasPadding { + switch str.count % 4 { + case 2: "==" + case 3: "=" + default: "" + } + } else { "" } + return str + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + + padding +} + diff --git a/BitwardenShared/Core/Auth/Services/AuthService.swift b/BitwardenShared/Core/Auth/Services/AuthService.swift index e07bc080c6..cba950ae9f 100644 --- a/BitwardenShared/Core/Auth/Services/AuthService.swift +++ b/BitwardenShared/Core/Auth/Services/AuthService.swift @@ -290,19 +290,15 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng /// The store which makes credential identities available to the system for AutoFill suggestions. private let credentialIdentityStore: CredentialIdentityStore + /// The service used to create and assert the device passkey for logging into remote clients. + private let devicePasskeyService: DevicePasskeyService + /// The service used by the application to manage the environment settings. private let environmentService: EnvironmentService /// The service used by the application to report non-fatal errors. private let errorReporter: ErrorReporter - // TODO: Exposing this externally so we can do late binding - /// The service to provide the UI for FIDO2 ceremonies. - var fido2UserInterfaceHelper: Fido2UserInterfaceHelper? - - /// The store to place FIDO2 credentials into. - var fido2CredentialStore: Fido2CredentialStore? - /// The repository used to manages keychain items. private let keychainRepository: KeychainRepository @@ -351,6 +347,7 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng /// - configService: The service to get server-specified configuration. /// - credentialIdentityStore: The store which makes credential identities available to the /// system for AutoFill suggestions. + /// - devicePasskeyService: The service used to create and assert the device passkey for logging into remote clients. /// - environmentService: The service used by the application to manage the environment settings. /// - errorReporter: The service used by the application to report non-fatal errors. /// - keychainRepository: The repository used to manages keychain items. @@ -366,10 +363,9 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng clientService: ClientService, configService: ConfigService, credentialIdentityStore: CredentialIdentityStore = ASCredentialIdentityStore.shared, + devicePasskeyService: DevicePasskeyService, environmentService: EnvironmentService, errorReporter: ErrorReporter, - // fido2UserInterfaceHelper: Fido2UserInterfaceHelper, - // fido2CredentialStore: Fido2CredentialStore, keychainRepository: KeychainRepository, policyService: PolicyService, stateService: StateService, @@ -382,6 +378,7 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng self.clientService = clientService self.configService = configService self.credentialIdentityStore = credentialIdentityStore + self.devicePasskeyService = devicePasskeyService self.environmentService = environmentService self.errorReporter = errorReporter self.keychainRepository = keychainRepository @@ -389,8 +386,6 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng self.stateService = stateService self.systemDevice = systemDevice self.trustDeviceService = trustDeviceService - // self.fido2UserInterfaceHelper = fido2UserInterfaceHelper - // self.fido2CredentialStore = fido2CredentialStore } // MARK: Methods @@ -709,6 +704,11 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng return false } + + /// Create device passkey with PRF encryption key. + func createDevicePasskey(masterPasswordHash: String) async throws { + try await devicePasskeyService.createDevicePasskey(masterPasswordHash: masterPasswordHash) + } func loginWithSingleSignOn(code: String, email: String) async throws -> LoginUnlockMethod { // Get the identity token to log in to Bitwarden. @@ -1026,272 +1026,4 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng purpose: .localAuthorization )) } - - /// Create device passkey with PRF encryption key. - func createDevicePasskey(masterPasswordHash: String) async throws { - // try await clientService.crypto().initializeUserCrypto(req: cryptoInitRequest) - let response = try await authAPIService.getCredentialCreationOptions(SecretVerificationRequestModel(passwordHash: masterPasswordHash)) - let options = response.options - let token = response.token - // TODO: Does server request PRF extension? - // let credResponse: PublicKeyCredentialAuthenticatorAttestationResponse = try await BitwardenSdk.CreateWebAuthnCredential(options) - let loginWithPrfSalt = Data(SHA256.hash(data: "passwordless-login".data(using: .utf8)!)) - let excludeCredentials: [PublicKeyCredentialDescriptor]? = if options.excludeCredentials != nil { - options.excludeCredentials!.map { - return PublicKeyCredentialDescriptor(ty: $0.type, id: Data(base64Encoded: normalizeBase64url($0.id))!, transports: nil) - } - } - else { nil } - let credParams = options.pubKeyCredParams.map { - PublicKeyCredentialParameters(ty: $0.type, alg: Int64($0.alg)) - } - // Would be nice if Fido2Net allowed specifying clientDataJson or hash - // TODO: This doesn't validate URLs as proper web origins (no path, with port + scheme, etc.) - let origin = environmentService.webVaultURL.absoluteString - let clientDataJson = #"{"type":"webauthn.create","challenge":"\#(options.challenge)","origin":"\#(origin)"}"# - let clientDataHash = Data(SHA256.hash(data: clientDataJson.data(using: .utf8)!)) - let credRequest = MakeCredentialRequest( - clientDataHash: clientDataHash, - rp: PublicKeyCredentialRpEntity(id: options.rp.id, name: options.rp.name), - user: PublicKeyCredentialUserEntity( - id: Data(base64Encoded: normalizeBase64url(options.user.id))!, - displayName: options.user.name, - name: options.user.name - ), - pubKeyCredParams: credParams, - excludeList: excludeCredentials, - options: Options( - rk: true, - // TODO: hard-coding - uv: .required - ), - extensions: """ - { - "prf": { - "eval": { "first": "\(loginWithPrfSalt.base64EncodedString())" } - } - } - """ - ) - /* - let createdCredential = try await clientService.platform().fido2() - .authenticator( - userInterface: fido2UserInterfaceHelper!, - credentialStore: fido2CredentialStore! - ) - .makeCredential(request: credRequest) - */ - let makeResult = try makeWebAuthnCredential(request: credRequest) - let createdCredential = makeResult.credential - let prfResult = makeResult.prfResult - let credRecord = DevicePasskeyRecord( - credId: createdCredential.credentialId.base64EncodedString(), - privKey: makeResult.privKey.rawRepresentation.base64EncodedString(), - prfSeed: makeResult.prfSeed.base64EncodedString(), - rpId: credRequest.rp.id, - rpName: credRequest.rp.name, - userId: credRequest.user.id.base64UrlEncodedString(trimPadding: false), - userName: credRequest.user.name, - userDisplayName: credRequest.user.displayName, - creationDate: CurrentTime().presentTime, - - ) - let encoder = JSONEncoder() - let recordJson = try String(data: encoder.encode(credRecord), encoding: .utf8)! - try await keychainRepository.setDevicePasskey(recordJson, userId: stateService.getActiveAccountId()) - // This may be filled in if the device supports the hmac-secret-mc extension - /* - let authData = credResponse.response.authenticatorData - let flags = authData[32] - let AUTH_DATA_AT: UInt8 = 0b0100_0000 - let AUTH_DATA_ED: UInt8 = 0b1000_0000 - let hasExtensions = flags & AUTH_DATA_ED > 0 - let hasAttestationData = flags & AUTH_DATA_AT > 0 - var pos = 37 + 16 - let credIdLen = authData[pos..pos + 2].toUint16 - pos += 2 + credIdLen - // have to parse CBOR to know how long this thing is... - // pos += ??? - let extensionCbor = authData[pos..] - // [parse CBOR to get this out of here - // This is bogus, we should add helper methods to Rust SDK so we don't have to do this manually. - */ - /* - let prfResult = - if createdCredential.extensions?.prf?.enabled == true - && credResponse.authData.extensions?.prf?.results.first != nil { - credResponse.authData.extensions.prf.results.first - } - // otherwise, send a attestation request to get the PRF value - else { - // We should request a new challenge rather than reusing this one. - // The server should be modified to accept two signatures and make sure the assertion signature matches the credential in the attestation. - let nativeOptions = PublicKeyCredentialAssertionOptions( - rpId: options.rp.id, - challenge: options.challenge, - allowCredentials: [[ "id": credResponse.rawId, "type": "public-key" ]], - timeout: options.timeout, - userVerification: options.authenticatorSelection.userVerification, - extensions: """ - { - prf: { eval: { first: "\(loginWithPrfSalt)" } }, - } - """ - ) - let assertion = try await BitwardenSdk.GetAssertion(nativeOptions) - assertion.authData.extensions?.prf?.first - } - */ - - // Since we have alternative methods of signing with biometrics/local OS auth, - // the only purpose of this key is to provide PRF for remote clients. - // So let's assert that we can create the PRF. - guard prfResult != nil else { throw AuthError.unableToCreateDevicePasskey } - let prfKeyResponse = try await clientService.crypto().derivePrfKey(prf: prfResult.base64EncodedString()) - print(prfKeyResponse) - - // TODO: Get real app name - let clientName = "My local device" - // authAPIService.saveWebAuthnCredential(clientName, credResponse, supportsPrf: true, keySet) - let request = WebAuthnLoginSaveCredentialRequestModel( - deviceResponse: WebAuthnLoginAttestationResponseRequest( - id: createdCredential.credentialId.base64UrlEncodedString(trimPadding: false), - rawId: createdCredential.credentialId.base64UrlEncodedString(trimPadding: false), - type: "public-key", - response: WebAuthnLoginAttestationResponseRequestInner( - attestationObject: createdCredential.attestationObject.base64UrlEncodedString(trimPadding: false), - clientDataJson: clientDataJson.data(using: .utf8)!.base64UrlEncodedString(trimPadding: false), - ), - ), - name: clientName, - token: token, - supportsPrf: true, - encryptedUserKey: prfKeyResponse.encapsulatedUserKey, - encryptedPublicKey: prfKeyResponse.encryptedEncapsulationKey, - encryptedPrivateKey: prfKeyResponse.prfKeyEncryptedDecapsulationKey, - ) - try await authAPIService.saveCredential(request) - } - - private func normalizeBase64url(_ str: String) -> String { - let hasPadding = str.last == "=" - let padding = if !hasPadding { - switch str.count % 4 { - case 2: "==" - case 3: "=" - default: "" - } - } else { "" } - return str - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - + padding - } - - private func makeWebAuthnCredential(request: MakeCredentialRequest) throws -> DevicePasskeyResult { - // attested credential data - let aaguid = Data(count:16) - let credId = Data(repeating: 0xf1, count: 16) - let privKey = P256.Signing.PrivateKey(compactRepresentable: false) - let publicKeyBytes = privKey.publicKey.rawRepresentation - let pointX = publicKeyBytes[1..<33] - let pointY = publicKeyBytes[33...] - var cosePubKey = Data() - cosePubKey.append(contentsOf: [ - 0xA5, // Map, length 5 - 0x01, 0x02, // 1 (kty): 2 (EC2) - 0x03, 0x26, // 3 (alg): -7 (ES256) - 0x20, 0x01, // -1 (crv): 1 (P256) - ]) - cosePubKey.append(contentsOf: [ - 0x21, 0x58, 0x20// -2 (x): bytes, len 32 - ]) - cosePubKey.append(contentsOf: pointX) - cosePubKey.append(contentsOf: [ - 0x22, 0x58, 0x20// -3 (x): bytes, len 32 - ]) - cosePubKey.append(contentsOf: pointY) - let attestedCredentialData = aaguid + UInt16(credId.count).bytes + credId + cosePubKey - - // extensions - // prf - let prfSeed = "this is a PRF secret, I promise!".data(using: .utf8)! - let saltPrefix = "WebAuthn PRF\0".data(using: .utf8)! - // hard-coding instead of parsing extensions from request - let loginWithPrfSalt = Data(SHA256.hash(data: "passwordless-login".data(using: .utf8)!)) - let salt1 = saltPrefix + loginWithPrfSalt - // This should be encrypted with a shared secret between the client and authenticator so that the RP doesn't see the PRF output. Skipping that for now. - let prfResult = Data(HMAC.authenticationCode(for: salt1, using: SymmetricKey(data: prfSeed))) - var extensions = Data() - extensions.append(contentsOf:[ - 0xA1, // map, length 1 - 0x63, 0x70, 0x72, 0x66, // string, len 3 "prf" - 0xA2, // map, length 2 - 0x67, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, // text, length 7 "enabled" - 0xF5, // true - 0x67, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, // text, length 7 "results - 0xA1, // map, length 1 - 0x65, 0x66, 0x69, 0x72, 0x73, 0x74, // text, length 5, "first" - 0x58, 0x20, // bytes, length 32 - ]) - extensions.append(contentsOf: prfResult) - - // authenticatorData - let rpIdHash = Data(SHA256.hash(data: request.rp.id.data(using: .utf8)!)) - let flags = 0b11000101 // ED, AT, UV, UP - let signCount = UInt32(0) - let authData = rpIdHash + UInt8(flags).bytes + signCount.bigEndian.bytes + attestedCredentialData + extensions - - // signature - let payload = authData + request.clientDataHash - let sig = try privKey.signature(for: payload).derRepresentation - - // attestation object - var attObj = Data() - attObj.append(contentsOf: [ - 0xA3, // map, length 3 - 0x63, 0x66, 0x6d, 0x74, // string, len 3 "fmt" - // 0x64, 0x6e, 0x6f, 0x6e, 0x65, // string, len 3 "none" - // 0x67, 0x61, 0x74, 0x74, 0x53, 0x74, 0x6d, 0x74, // string, len 7, "attStmt" - // 0xA0, // map, length 0 - 0x66, 0x70, 0x61, 0x63, 0x6b, 0x65, 0x64, // string, len 6, "packed" - 0x67, 0x61, 0x74, 0x74, 0x53, 0x74, 0x6d, 0x74, // string, len 7, "attStmt" - 0xA2, // map, length 2 - 0x63, 0x61, 0x6c, 0x67, // string, len 3, "alg" - 0x26, // -7 (P256) - 0x63, 0x73, 0x69, 0x67, // string, len 3, "sig" - 0x58, // bytes, length specified in following byte - ]) - attObj.append(contentsOf: UInt8(sig.count).bytes) - attObj.append(contentsOf: sig) - attObj.append(contentsOf:[ - 0x68, 0x61, 0x75, 0x74, 0x68, 0x44, 0x61, 0x74, 0x61, // string, len 8, "authData" - 0x58, // bytes, length specified in following byte. - ]) - attObj.append(contentsOf: UInt8(authData.count).bytes) - attObj.append(contentsOf: authData) - let result = MakeCredentialResult(authenticatorData: authData, attestationObject: attObj, credentialId: credId) - // Even though prfResult is included in extensions, we'd have to parse CBOR, so just including it for now - return DevicePasskeyResult(credential: result, privKey: privKey, prfSeed: prfSeed, prfResult: prfResult) - } - struct DevicePasskeyResult { - let credential: MakeCredentialResult - let privKey: P256.Signing.PrivateKey - let prfSeed: Data - let prfResult: Data - } -} - -extension Data { - func base64UrlEncodedString(trimPadding: Bool? = true) -> String { - let shouldTrim = if trimPadding != nil { trimPadding! } else { true } - let encoded = base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_") - if shouldTrim { - return encoded.trimmingCharacters(in: CharacterSet(["="])) - } else { - return encoded - } - } -} - -// swiftlint:disable:this file_length +} // swiftlint:disable:this file_length diff --git a/BitwardenShared/Core/Auth/Services/DevicePasskeyService.swift b/BitwardenShared/Core/Auth/Services/DevicePasskeyService.swift new file mode 100644 index 0000000000..b53fef615d --- /dev/null +++ b/BitwardenShared/Core/Auth/Services/DevicePasskeyService.swift @@ -0,0 +1,372 @@ +import CryptoKit +import Foundation +import ObjectiveC +import os.log +import UIKit + +import BitwardenSdk +import BitwardenKit + +protocol DevicePasskeyService { + /// Create device passkey with PRF encryption key. + /// + /// Before calling, vault must be unlocked to wrap user encryption key. + /// - Parameters: + /// - masterPasswordHash: Master password hash suitable for server authentication + func createDevicePasskey(masterPasswordHash: String) async throws + + /// Use device passkey to assert a credential, outputting PRF output. + func useDevicePasskey(for request: GetAssertionRequest) async throws -> (GetAssertionResult, Data?)? +} + +struct DefaultDevicePasskeyService : DevicePasskeyService { + static private let decoder = JSONDecoder() + static private let encoder = JSONEncoder() + + private let authAPIService: AuthAPIService + private let clientService: ClientService + private let environmentService: EnvironmentService + private let keychainRepository: KeychainRepository + private let stateService: StateService + + /// This is the AAGUID for the Bitwarden Passkey provider (d548826e-79b4-db40-a3d8-11116f7e8349) + /// It is used for the Relaying Parties to identify the authenticator during registration + private let aaguid = Data([ + 0xd5, 0x48, 0x82, 0x6e, 0x79, 0xb4, 0xdb, 0x40, 0xa3, 0xd8, 0x11, 0x11, 0x6f, 0x7e, 0x83, 0x49, + ]); + + /// Default PRF salt input to use if none is received from WebAuthn client. + private let defaultLoginWithPrfSalt = Data(SHA256.hash(data: "passwordless-login".data(using: .utf8)!)) + + init( + authAPIService: AuthAPIService, + clientService: ClientService, + environmentService: EnvironmentService, + keychainRepository: KeychainRepository, + stateService: StateService + ) { + self.authAPIService = authAPIService + self.clientService = clientService + self.environmentService = environmentService + self.keychainRepository = keychainRepository + self.stateService = stateService + } + + /// Create device passkey with PRF encryption key. + /// + /// Before calling, vault must be unlocked to wrap user encryption key. + func createDevicePasskey(masterPasswordHash: String) async throws { + let response = try await authAPIService.getCredentialCreationOptions(SecretVerificationRequestModel(passwordHash: masterPasswordHash)) + let options = response.options + let token = response.token + + let (prfInput, _) = try getPrfInput(extensionsInput: response.options.extensions) + + let excludeCredentials: [PublicKeyCredentialDescriptor]? = if options.excludeCredentials != nil { + // TODO: return early if exclude credentials matches + options.excludeCredentials!.map { + return PublicKeyCredentialDescriptor(ty: $0.type, id: Data(base64UrlEncoded: $0.id)!, transports: nil) + } + } + else { nil } + let credParams = options.pubKeyCredParams.map { + PublicKeyCredentialParameters(ty: $0.type, alg: Int64($0.alg)) + } + + let origin = deriveWebOrigin() + let clientDataJson = #"{"type":"webauthn.create","challenge":"\#(options.challenge)","origin":"\#(origin)"}"# + let clientDataHash = Data(SHA256.hash(data: clientDataJson.data(using: .utf8)!)) + + let credRequest = MakeCredentialRequest( + clientDataHash: clientDataHash, + rp: PublicKeyCredentialRpEntity(id: options.rp.id, name: options.rp.name), + user: PublicKeyCredentialUserEntity( + id: Data(base64UrlEncoded: options.user.id)!, + displayName: options.user.name, + name: options.user.name + ), + pubKeyCredParams: credParams, + excludeList: excludeCredentials, + options: Options( + rk: true, + uv: .required + ), + extensions: nil, + ) + + let makeResult = try makeWebAuthnCredential(request: credRequest, prfInput: prfInput) + let createdCredential = makeResult.credential + let prfKeyResponse = try await clientService.crypto().derivePrfKey(prf: makeResult.prfResult.base64EncodedString()) + + // Store the passkey with its PRF seed + let credRecord = DevicePasskeyRecord( + credId: createdCredential.credentialId.base64EncodedString(), + privKey: makeResult.privKey.rawRepresentation.base64EncodedString(), + prfSeed: makeResult.prfSeed.withUnsafeBytes{ + Data(Array($0)).base64EncodedString() + }, + rpId: credRequest.rp.id, + rpName: credRequest.rp.name, + userId: credRequest.user.id.base64EncodedString(), + userName: credRequest.user.name, + userDisplayName: credRequest.user.displayName, + creationDate: CurrentTime().presentTime, + ) + let encoder = JSONEncoder() + let recordJson = try String(data: encoder.encode(credRecord), encoding: .utf8)! + try await keychainRepository.setDevicePasskey(recordJson, userId: stateService.getActiveAccountId()) + + // Register the credential keyset with the server. + // TODO: This only returns generic names like `iPhone`. + // If there is a more specific name available (e.g., user-chosen), + // that would be helpful to disambiguate in the menu. + let clientName = "Bitwarden App on \(await UIKit.UIDevice.current.name)" + let request = WebAuthnLoginSaveCredentialRequestModel( + deviceResponse: WebAuthnLoginAttestationResponseRequest( + id: createdCredential.credentialId.base64UrlEncodedString(trimPadding: false), + rawId: createdCredential.credentialId.base64UrlEncodedString(trimPadding: false), + type: "public-key", + response: WebAuthnLoginAttestationResponseRequestInner( + attestationObject: createdCredential.attestationObject.base64UrlEncodedString(trimPadding: false), + clientDataJson: clientDataJson.data(using: .utf8)!.base64UrlEncodedString(trimPadding: false), + ), + ), + name: clientName, + token: token, + supportsPrf: true, + encryptedUserKey: prfKeyResponse.encapsulatedUserKey, + encryptedPublicKey: prfKeyResponse.encryptedEncapsulationKey, + encryptedPrivateKey: prfKeyResponse.wrappedDecapsulationKey, + ) + try await authAPIService.saveCredential(request) + } + + /// Emulates a FIDO2 authenticator. + private func makeWebAuthnCredential(request: MakeCredentialRequest, prfInput: Data) throws -> DevicePasskeyResult { + // attested credential data + let credId = try getSecureRandomBytes(count: 32) + let privKey = P256.Signing.PrivateKey(compactRepresentable: false) + let publicKeyBytes = privKey.publicKey.rawRepresentation + let pointX = publicKeyBytes[1..<33] + let pointY = publicKeyBytes[33...] + var cosePubKey = Data() + cosePubKey.append(contentsOf: [ + 0xA5, // Map, length 5 + 0x01, 0x02, // 1 (kty): 2 (EC2) + 0x03, 0x26, // 3 (alg): -7 (ES256) + 0x20, 0x01, // -1 (crv): 1 (P256) + ]) + cosePubKey.append(contentsOf: [ + 0x21, 0x58, 0x20// -2 (x): bytes, len 32 + ]) + cosePubKey.append(contentsOf: pointX) + cosePubKey.append(contentsOf: [ + 0x22, 0x58, 0x20// -3 (x): bytes, len 32 + ]) + cosePubKey.append(contentsOf: pointY) + let attestedCredentialData = aaguid + UInt16(credId.count).bytes + credId + cosePubKey + + // PRF + // We're processing this as a WebAuthn extension, not a CTAP2 extension, + // so we're not writing this to the extension data in the authenticator data. + let prfSeed = SymmetricKey(size: SymmetricKeySize(bitCount: 256)) + let prfResult = generatePrf(using: prfInput, from: prfSeed) + + // authenticatorData + let authData = buildAuthenticatorData(rpId: request.rp.id, attestedCredentialData: attestedCredentialData) + + // signature + let response = try createAttestationObject( + withKey: privKey, + authenticatorData: authData, + clientDataHash: request.clientDataHash) + let result = MakeCredentialResult( + authenticatorData: authData, + attestationObject: response.attestationObject, + credentialId: credId) + return DevicePasskeyResult(credential: result, privKey: privKey, prfSeed: prfSeed, prfResult: prfResult) + } + + /// Use device passkey to assert a credential, outputting PRF output. + func useDevicePasskey(for request: GetAssertionRequest) async throws -> (GetAssertionResult, Data?)? { + let webVaultRpId = deriveRpId() + guard webVaultRpId == request.rpId else { return nil } + guard let json = try await keychainRepository.getDevicePasskey(userId: stateService.getActiveAccountId()) else { + Logger.application.warning("Matched Bitwarden Web Vault rpID, but no device passkey found.") + return nil + } + + let record: DevicePasskeyRecord = try DefaultDevicePasskeyService.decoder.decode(DevicePasskeyRecord.self, from: json.data(using: .utf8)!) + + // extensions + // prf + let extInputs = if let extJson = request.extensions { + try DefaultDevicePasskeyService.decoder.decode(AuthenticationExtensionsClientInputs.self, from: extJson.data(using: .utf8)!) + } else { nil as AuthenticationExtensionsClientInputs? } + let (prfInput, _) = try getPrfInput(extensionsInput: extInputs) + let prfSeed = SymmetricKey(data: Data(base64Encoded: record.prfSeed)!) + + // TODO: this is unused, but appears in GetAssertionResult signature. + let fido2View = Fido2CredentialView( + credentialId: record.credId, + keyType: "public-key", + keyAlgorithm: "ECDSA", + keyCurve: "P-256", + keyValue: EncString(), + rpId: record.rpId, + userHandle: nil, + userName: nil, + counter: "0", + rpName: nil, + userDisplayName: nil, + discoverable: "true", + creationDate: record.creationDate, + ) + let fido2NewView = Fido2CredentialNewView( + credentialId: record.credId, + keyType: "public-key", + keyAlgorithm: "ECDSA", + keyCurve: "P-256", + rpId: record.rpId, + userHandle: nil, + userName: nil, + counter: "0", + rpName: nil, + userDisplayName: nil, + creationDate: record.creationDate, + ) + let credId = Data(base64Encoded: record.credId)! + let userHandle = Data(base64Encoded: record.userId!)! + let privKey = try P256.Signing.PrivateKey(rawRepresentation: Data(base64Encoded: record.privKey)!) + let assertion = try assertWebAuthnCredential( + withKey: privKey, + rpId: request.rpId, + clientDataHash: request.clientDataHash, + prfSeed: prfSeed, + prfInput: prfInput) + let result = GetAssertionResult( + credentialId: credId, + authenticatorData: assertion.authenticatorData, + signature: assertion.signature, + userHandle: userHandle, + selectedCredential: SelectedCredential(cipher: CipherView(fido2CredentialNewView: fido2NewView, timeProvider: CurrentTime()), credential: fido2View), + ) + return (result, assertion.prfResult) + } + + private func deriveRpId() -> String { + // TODO: Should we be using the web vault as the origin, and is this the best way to get it? + environmentService.webVaultURL.domain! + } + + private func deriveWebOrigin() -> String { + // TODO: Should we be using the web vault as the origin, and is this the best way to get it? + let url = environmentService.webVaultURL + return "\(url.scheme ?? "http")://\(url.hostWithPort!)" + } + + private func getPrfInput(extensionsInput extInputs: AuthenticationExtensionsClientInputs?) throws -> (salt1: Data, salt2: Data?) { + if let prfInputs = extInputs?.prf?.eval { + let input1 = Data(base64Encoded: prfInputs.first)! + let input2: Data? = if let second = prfInputs.second { + Data(base64Encoded: second) + } else { nil } + return (input1, input2) + } + else { + return (defaultLoginWithPrfSalt, nil) + } + } + + struct DevicePasskeyResult { + let credential: MakeCredentialResult + let privKey: P256.Signing.PrivateKey + let prfSeed: SymmetricKey + let prfResult: Data + } +} + +private func assertWebAuthnCredential( + withKey privKey: P256.Signing.PrivateKey, + rpId: String, + clientDataHash: Data, + prfSeed: SymmetricKey, + prfInput: Data +) throws -> (authenticatorData: Data, signature: Data, prfResult: Data) { + // authenticatorData + let authData = buildAuthenticatorData(rpId: rpId, attestedCredentialData: nil) + + // signature + let response = try createAttestationObject( + withKey: privKey, + authenticatorData: authData, + clientDataHash: clientDataHash) + + let prfResult = generatePrf(using: prfInput, from: prfSeed) + return (authData, response.signature, prfResult) +} + +private func buildAuthenticatorData(rpId: String, attestedCredentialData: Data?) -> Data { + let rpIdHash = Data(SHA256.hash(data: rpId.data(using: .utf8)!)) + let signCount = UInt32(0) + if let credential = attestedCredentialData { + // Attesting/creating credential + let flags = 0b01000101 // AT, UV, UP + return rpIdHash + UInt8(flags).bytes + signCount.bytes + credential + } + else { + // Asserting credential + let flags = 0b0001_1101 // UV, UP; BE and BS also set because macOS requires it on assertions :( + return rpIdHash + UInt8(flags).bytes + signCount.bytes + } +} + +private func createAttestationObject( + withKey privKey: P256.Signing.PrivateKey, + authenticatorData authData: Data, + clientDataHash: Data +) throws -> (attestationObject: Data, signature: Data) { + // signature + let payload = authData + clientDataHash + // let privKey = try P256.Signing.PrivateKey(rawRepresentation: Data(base64Encoded: record.privKey)!) + let sig = try privKey.signature(for: payload).derRepresentation + + // attestation object + var attObj = Data() + attObj.append(contentsOf: [ + 0xA3, // map, length 3 + 0x63, 0x66, 0x6d, 0x74, // string, len 3 "fmt" + 0x66, 0x70, 0x61, 0x63, 0x6b, 0x65, 0x64, // string, len 6, "packed" + 0x67, 0x61, 0x74, 0x74, 0x53, 0x74, 0x6d, 0x74, // string, len 7, "attStmt" + 0xA2, // map, length 2 + 0x63, 0x61, 0x6c, 0x67, // string, len 3, "alg" + 0x26, // -7 (P256) + 0x63, 0x73, 0x69, 0x67, // string, len 3, "sig" + 0x58, // bytes, length specified in following byte + ]) + attObj.append(contentsOf: UInt8(sig.count).bytes) + attObj.append(contentsOf: sig) + attObj.append(contentsOf:[ + 0x68, 0x61, 0x75, 0x74, 0x68, 0x44, 0x61, 0x74, 0x61, // string, len 8, "authData" + 0x58, // bytes, length specified in following byte. + ]) + attObj.append(contentsOf: UInt8(authData.count).bytes) + attObj.append(contentsOf: authData) + return (attObj, sig) +} + +private func generatePrf(using prfInput: Data, from seed: SymmetricKey) -> Data { + let saltPrefix = "WebAuthn PRF\0".data(using: .utf8)! + let salt1 = saltPrefix + prfInput + // CTAP2 uses HMAC to expand salt into a PRF, so we're doing the same. + return Data(HMAC.authenticationCode(for: salt1, using: seed)) +} + +private func getSecureRandomBytes(count: Int) throws -> Data { + var bytes = [UInt8](repeating: 0, count: count) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + guard status == errSecSuccess else { + throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) + } + return Data(bytes) +} diff --git a/BitwardenShared/Core/Auth/Services/KeychainRepository.swift b/BitwardenShared/Core/Auth/Services/KeychainRepository.swift index f3da20475a..1f4e8b4d94 100644 --- a/BitwardenShared/Core/Auth/Services/KeychainRepository.swift +++ b/BitwardenShared/Core/Auth/Services/KeychainRepository.swift @@ -39,10 +39,10 @@ enum KeychainItem: Equatable { .deviceKey, .neverLock, .pendingAdminLoginRequest, - .refreshToken, - .devicePasskey: + .refreshToken: nil - case .biometrics: + case .biometrics, + .devicePasskey: .biometryCurrentSet } } diff --git a/BitwardenShared/Core/Autofill/Services/AutofillCredentialService.swift b/BitwardenShared/Core/Autofill/Services/AutofillCredentialService.swift index 944914fea6..6e0967d634 100644 --- a/BitwardenShared/Core/Autofill/Services/AutofillCredentialService.swift +++ b/BitwardenShared/Core/Autofill/Services/AutofillCredentialService.swift @@ -93,6 +93,9 @@ class DefaultAutofillCredentialService { /// The factory to create credential identities. private let credentialIdentityFactory: CredentialIdentityFactory + /// The service used to create and assert the device passkey for logging into remote clients. + private let devicePasskeyService: DevicePasskeyService + /// The service used by the application to report non-fatal errors. private let errorReporter: ErrorReporter @@ -141,6 +144,7 @@ class DefaultAutofillCredentialService { /// - 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. /// - credentialIdentityFactory: The factory to create credential identities. + /// - devicePasskeyService: The service used to create and assert the device passkey for logging into remote clients. /// - 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 @@ -158,6 +162,7 @@ class DefaultAutofillCredentialService { cipherService: CipherService, clientService: ClientService, credentialIdentityFactory: CredentialIdentityFactory, + devicePasskeyService: DevicePasskeyService, errorReporter: ErrorReporter, eventService: EventService, fido2CredentialStore: Fido2CredentialStore, @@ -173,6 +178,7 @@ class DefaultAutofillCredentialService { self.cipherService = cipherService self.clientService = clientService self.credentialIdentityFactory = credentialIdentityFactory + self.devicePasskeyService = devicePasskeyService self.errorReporter = errorReporter self.eventService = eventService self.fido2CredentialStore = fido2CredentialStore @@ -477,7 +483,7 @@ extension DefaultAutofillCredentialService: AutofillCredentialService { #endif do { - let devicePasskeyResult = try await useDevicePasskey(for: request, rpId: rpId, clientDataHash: clientDataHash) + let devicePasskeyResult = try await devicePasskeyService.useDevicePasskey(for: request) let (assertionResult, prfResult): (GetAssertionResult, Data?) = if let devicePasskeyResult { devicePasskeyResult } else { @@ -547,124 +553,6 @@ extension DefaultAutofillCredentialService: AutofillCredentialService { throw error } } - - private func useDevicePasskey(for request: GetAssertionRequest, rpId: String, clientDataHash: Data) async throws -> (GetAssertionResult, Data?)? { - // let webVaultRpId = services.environmentService.webVaultURL.domain - let webVaultRpId = "localhost" - guard webVaultRpId == rpId else { return nil } - guard let json = try await keychainRepository.getDevicePasskey(userId: stateService.getActiveAccountId()) else { - print("Matched Bitwarden Web Vault rpID, but no device passkey found. Forwarding to main implementation") - return nil - } - - let decoder = JSONDecoder() - let loginWithPrfSalt = Data(SHA256.hash(data: "passwordless-login".data(using: .utf8)!)) - let saltInput1 = try getPrfInput(extensionsInput: request.extensions) ?? loginWithPrfSalt - let record: DevicePasskeyRecord = try decoder.decode(DevicePasskeyRecord.self, from: json.data(using: .utf8)!) - - // extensions - // prf - let prfSeed = Data(base64Encoded: record.prfSeed)! - let saltPrefix = "WebAuthn PRF\0".data(using: .utf8)! - // hard-coding instead of parsing extensions from request - let salt1 = saltPrefix + saltInput1 - // This should be encrypted with a shared secret between the client and authenticator so that the RP doesn't see the PRF output. Skipping that for now. - let prfResult = Data(HMAC.authenticationCode(for: salt1, using: SymmetricKey(data: prfSeed))) - var extensions = Data() - /* - extensions.append(contentsOf:[ - 0xA1, // map, length 1 - 0x63, 0x70, 0x72, 0x66, // string, len 3 "prf" - 0xA1, // map, length 1 - 0x67, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, // text, length 7 "results - 0xA1, // map, length 1 - 0x65, 0x66, 0x69, 0x72, 0x73, 0x74, // text, length 5, "first" - 0x58, 0x20, // bytes, length 32 - ]) - extensions.append(contentsOf: prfResult) - */ - - // authenticatorData - let rpIdHash = Data(SHA256.hash(data: rpId.data(using: .utf8)!)) - let flags = 0b0001_1101 // UV, UP, BE and BS also set because macOS requires it :( - let signCount = UInt32(0) - let authData = rpIdHash + UInt8(flags).bytes + signCount.bytes // + extensions - - // signature - let payload = authData + request.clientDataHash - let privKey = try P256.Signing.PrivateKey(rawRepresentation: Data(base64Encoded: record.privKey)!) - let sig = try privKey.signature(for: payload).derRepresentation - - // attestation object - var attObj = Data() - attObj.append(contentsOf: [ - 0xA3, // map, length 3 - 0x63, 0x66, 0x6d, 0x74, // string, len 3 "fmt" - 0x66, 0x70, 0x61, 0x63, 0x6b, 0x65, 0x64, // string, len 6, "packed" - 0x67, 0x61, 0x74, 0x74, 0x53, 0x74, 0x6d, 0x74, // string, len 7, "attStmt" - 0xA2, // map, length 2 - 0x63, 0x61, 0x6c, 0x67, // string, len 3, "alg" - 0x26, // -7 (P256) - 0x63, 0x73, 0x69, 0x67, // string, len 3, "sig" - 0x58, // bytes, length specified in following byte - ]) - attObj.append(contentsOf: UInt8(sig.count).bytes) - attObj.append(contentsOf: sig) - attObj.append(contentsOf:[ - 0x68, 0x61, 0x75, 0x74, 0x68, 0x44, 0x61, 0x74, 0x61, // string, len 8, "authData" - 0x58, // bytes, length specified in following byte. - ]) - attObj.append(contentsOf: UInt8(authData.count).bytes) - attObj.append(contentsOf: authData) - let fido2View = Fido2CredentialView( - credentialId: record.credId, - keyType: "public-key", - keyAlgorithm: "ECDSA", - keyCurve: "P-256", - keyValue: EncString(), - rpId: record.rpId, - userHandle: nil, - userName: nil, - counter: "0", - rpName: nil, - userDisplayName: nil, - discoverable: "true", - creationDate: record.creationDate, - ) - let fido2NewView = Fido2CredentialNewView( - credentialId: record.credId, - keyType: "public-key", - keyAlgorithm: "ECDSA", - keyCurve: "P-256", - rpId: record.rpId, - userHandle: nil, - userName: nil, - counter: "0", - rpName: nil, - userDisplayName: nil, - creationDate: record.creationDate, - ) - let credId = Data(base64Encoded: record.credId)! - let userHandle = Data(base64Encoded: record.userId!)! - let result = GetAssertionResult( - credentialId: credId, - authenticatorData: authData, - signature: sig, - userHandle: userHandle, - selectedCredential: SelectedCredential(cipher: CipherView(fido2CredentialNewView: fido2NewView, timeProvider: CurrentTime()), credential: fido2View), - ) - return (result, prfResult) - // Even though prfResult is included in extensions, we'd have to parse CBOR, so just including it for now - // return DevicePasskeyResult(credential: result, privKey: privKey, prfSeed: prfSeed, prfResult: prfResult) - } -} - -private func getPrfInput(extensionsInput extensions: String?) throws -> Data? { - guard let extensions else { return nil } - let decoder = JSONDecoder() - let extInputs = try decoder.decode(AuthenticationExtensionsClientInputs.self, from: extensions.data(using: .utf8)!) - guard let first = extInputs.prf?.eval?.first else { return nil } - return Data(base64Encoded: first) } // MARK: - CredentialIdentityStore diff --git a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift index e3d6e8e366..5d93ab2b48 100644 --- a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift +++ b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift @@ -68,6 +68,11 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le /// The service to get server-specified configuration let configService: ConfigService + + /// The service used to create and use the device passkey for decrypting a vault for a remote client. + /// + /// This service should not be used for creating or asserting passkeys from the user's vault. + let devicePasskeyService: DevicePasskeyService /// The service used by the application to manage the environment settings. let environmentService: EnvironmentService @@ -213,6 +218,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le /// - cameraService: The service used by the application to manage camera use. /// - clientService: The service used by the application to handle encryption and decryption tasks. /// - configService: The service to get server-specified configuration. + /// - devicePasskeyService: The service used to create and assert the device passkey. /// - environmentService: The service used by the application to manage the environment settings. /// - errorReportBuilder: A helper for building an error report containing the details of an /// error that occurred. @@ -273,6 +279,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le cameraService: CameraService, clientService: ClientService, configService: ConfigService, + devicePasskeyService: DevicePasskeyService, environmentService: EnvironmentService, errorReportBuilder: ErrorReportBuilder, errorReporter: ErrorReporter, @@ -329,6 +336,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le self.cameraService = cameraService self.clientService = clientService self.configService = configService + self.devicePasskeyService = devicePasskeyService self.environmentService = environmentService self.errorReportBuilder = errorReportBuilder self.errorReporter = errorReporter @@ -613,12 +621,21 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le stateService: stateService ) + let devicePasskeyService = DefaultDevicePasskeyService( + authAPIService: apiService, + clientService: clientService, + environmentService: environmentService, + keychainRepository: keychainRepository, + stateService: stateService, + ) + let authService = DefaultAuthService( accountAPIService: apiService, appIdService: appIdService, authAPIService: apiService, clientService: clientService, configService: configService, + devicePasskeyService: devicePasskeyService, environmentService: environmentService, errorReporter: errorReporter, keychainRepository: keychainRepository, @@ -790,6 +807,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le cipherService: cipherService, clientService: clientService, credentialIdentityFactory: credentialIdentityFactory, + devicePasskeyService: devicePasskeyService, errorReporter: errorReporter, eventService: eventService, fido2CredentialStore: fido2CredentialStore, @@ -801,9 +819,6 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le totpService: totpService, vaultTimeoutService: vaultTimeoutService ) - // HACK: To avoid a circular dependency, we're using late binding. - authService.fido2UserInterfaceHelper = fido2UserInterfaceHelper - authService.fido2CredentialStore = fido2CredentialStore let credentialManagerFactory = DefaultCredentialManagerFactory() let cxfCredentialsResultBuilder = DefaultCXFCredentialsResultBuilder() @@ -903,6 +918,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le cameraService: DefaultCameraService(), clientService: clientService, configService: configService, + devicePasskeyService: devicePasskeyService, environmentService: environmentService, errorReportBuilder: errorReportBuilder, errorReporter: errorReporter, From 93712281907094003aeebbb323a048920de1c8ba Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Fri, 26 Sep 2025 13:40:28 -0500 Subject: [PATCH 11/15] Fix JSON serialization of PRF extension input --- BitwardenShared/Core/Auth/Services/DevicePasskeyService.swift | 4 ++-- .../Core/Autofill/Extensions/BitwardenSdk+Autofill.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/BitwardenShared/Core/Auth/Services/DevicePasskeyService.swift b/BitwardenShared/Core/Auth/Services/DevicePasskeyService.swift index b53fef615d..61d0065e22 100644 --- a/BitwardenShared/Core/Auth/Services/DevicePasskeyService.swift +++ b/BitwardenShared/Core/Auth/Services/DevicePasskeyService.swift @@ -267,9 +267,9 @@ struct DefaultDevicePasskeyService : DevicePasskeyService { private func getPrfInput(extensionsInput extInputs: AuthenticationExtensionsClientInputs?) throws -> (salt1: Data, salt2: Data?) { if let prfInputs = extInputs?.prf?.eval { - let input1 = Data(base64Encoded: prfInputs.first)! + let input1 = Data(base64UrlEncoded: prfInputs.first)! let input2: Data? = if let second = prfInputs.second { - Data(base64Encoded: second) + Data(base64UrlEncoded: second) } else { nil } return (input1, input2) } diff --git a/BitwardenShared/Core/Autofill/Extensions/BitwardenSdk+Autofill.swift b/BitwardenShared/Core/Autofill/Extensions/BitwardenSdk+Autofill.swift index 39391e3503..1272c3c025 100644 --- a/BitwardenShared/Core/Autofill/Extensions/BitwardenSdk+Autofill.swift +++ b/BitwardenShared/Core/Autofill/Extensions/BitwardenSdk+Autofill.swift @@ -88,7 +88,7 @@ private func createExtensionJson(passkeyRequest: ASPasskeyCredentialRequest) -> "second": \#(salt2) } """# - return #"{"prf":{"eval":\#(eval)}"# + return #"{"prf":{"eval":\#(eval)}}"# } // MARK: - MakeCredentialRequest From a5f0bc4891660125a98b0e4971e9a4e0d88e7e00 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Fri, 26 Sep 2025 13:41:00 -0500 Subject: [PATCH 12/15] Add device passkey to passkey store --- .../Services/AutofillCredentialService.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/BitwardenShared/Core/Autofill/Services/AutofillCredentialService.swift b/BitwardenShared/Core/Autofill/Services/AutofillCredentialService.swift index 6e0967d634..4762d68c73 100644 --- a/BitwardenShared/Core/Autofill/Services/AutofillCredentialService.swift +++ b/BitwardenShared/Core/Autofill/Services/AutofillCredentialService.swift @@ -272,6 +272,18 @@ class DefaultAutofillCredentialService { .credentialsForAutofill() .compactMap { $0.toFido2CredentialIdentity() } identities.append(contentsOf: fido2Identities) + // Add device passkey + if let json = try await keychainRepository.getDevicePasskey(userId: userId) { + let decoder = JSONDecoder() + let record = try decoder.decode(DevicePasskeyRecord.self, from: json.data(using: .utf8)!) + identities.append(ASPasskeyCredentialIdentity( + relyingPartyIdentifier: record.rpId, + userName: record.userName!, + credentialID: Data(base64Encoded: record.credId)!, + userHandle: Data(base64Encoded: record.userId!)!, + recordIdentifier: "DEVICE_PASSKEY", + )) + } try await identityStore.replaceCredentialIdentities(identities) Logger.application.info("AutofillCredentialService: replaced \(identities.count) credential identities") From aeae5b367394a465ee6314d2a7f28ade8f3adc7b Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Fri, 26 Sep 2025 13:49:01 -0500 Subject: [PATCH 13/15] Remove debugging statements --- .../Services/AutofillCredentialService.swift | 12 ------------ .../AutofillList/VaultAutofillListProcessor.swift | 1 - 2 files changed, 13 deletions(-) diff --git a/BitwardenShared/Core/Autofill/Services/AutofillCredentialService.swift b/BitwardenShared/Core/Autofill/Services/AutofillCredentialService.swift index 4762d68c73..e2aabf17dd 100644 --- a/BitwardenShared/Core/Autofill/Services/AutofillCredentialService.swift +++ b/BitwardenShared/Core/Autofill/Services/AutofillCredentialService.swift @@ -481,8 +481,6 @@ extension DefaultAutofillCredentialService: AutofillCredentialService { rpId: String, clientDataHash: Data ) async throws -> ASPasskeyAssertionCredential { - let logger = Logger() - logger.info("Starting provideFido2Credential") await fido2UserInterfaceHelper.setupDelegate( fido2UserInterfaceHelperDelegate: fido2UserInterfaceHelperDelegate ) @@ -507,16 +505,6 @@ extension DefaultAutofillCredentialService: AutofillCredentialService { .getAssertion(request: request) , nil as Data?) } - - print(request) - logger.debug("clientDataHash: \(request.clientDataHash.base64EncodedString())") - logger.debug("rpId: \(request.rpId)") - logger.debug("Passkey result") - logger.debug("authData: \(assertionResult.authenticatorData.base64EncodedString())") - logger.debug("credId: \(assertionResult.credentialId.base64EncodedString())") - logger.debug("signature: \(assertionResult.signature.base64EncodedString())") - logger.debug("userHandle: \(assertionResult.userHandle.base64EncodedString())") - logger.debug("prfResult: \(prfResult?.base64EncodedString() ?? "")") #if DEBUG Fido2DebuggingReportBuilder.builder.withGetAssertionResult(.success(assertionResult)) diff --git a/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListProcessor.swift b/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListProcessor.swift index 00e8d4891f..38bc3db03f 100644 --- a/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListProcessor.swift +++ b/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListProcessor.swift @@ -578,7 +578,6 @@ extension VaultAutofillListProcessor { for: fido2RequestParameters, fido2UserInterfaceHelperDelegate: self ) - print(assertionCredential) autofillAppExtensionDelegate.completeAssertionRequest(assertionCredential: assertionCredential) } catch { From a9a283f245b435b8b2272975a3920856e80efb3a Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Wed, 1 Oct 2025 14:06:53 -0500 Subject: [PATCH 14/15] Temporarily hardcode PRF input value --- .../Core/Auth/Services/DevicePasskeyService.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/BitwardenShared/Core/Auth/Services/DevicePasskeyService.swift b/BitwardenShared/Core/Auth/Services/DevicePasskeyService.swift index e59b401d37..7f58f8a3bc 100644 --- a/BitwardenShared/Core/Auth/Services/DevicePasskeyService.swift +++ b/BitwardenShared/Core/Auth/Services/DevicePasskeyService.swift @@ -78,11 +78,11 @@ struct DefaultDevicePasskeyService : DevicePasskeyService { let credParams = options.pubKeyCredParams.map { PublicKeyCredentialParameters(ty: $0.type, alg: Int64($0.alg)) } - + let origin = deriveWebOrigin() let clientDataJson = #"{"type":"webauthn.create","challenge":"\#(options.challenge)","origin":"\#(origin)"}"# let clientDataHash = Data(SHA256.hash(data: clientDataJson.data(using: .utf8)!)) - + let credRequest = MakeCredentialRequest( clientDataHash: clientDataHash, rp: PublicKeyCredentialRpEntity(id: options.rp.id, name: options.rp.name), @@ -146,7 +146,7 @@ struct DefaultDevicePasskeyService : DevicePasskeyService { ) try await authAPIService.saveCredential(request) } - + /// Emulates a FIDO2 authenticator. private func makeWebAuthnCredential(request: MakeCredentialRequest, prfInput: Data) throws -> DevicePasskeyResult { // attested credential data @@ -272,6 +272,8 @@ struct DefaultDevicePasskeyService : DevicePasskeyService { } private func getPrfInput(extensionsInput extInputs: AuthenticationExtensionsClientInputs?) throws -> (salt1: Data, salt2: Data?) { + return (defaultLoginWithPrfSalt, nil) + /* if let prfInputs = extInputs?.prf?.eval { let input1 = Data(base64UrlEncoded: prfInputs.first)! let input2: Data? = if let second = prfInputs.second { @@ -282,6 +284,7 @@ struct DefaultDevicePasskeyService : DevicePasskeyService { else { return (defaultLoginWithPrfSalt, nil) } + */ } struct DevicePasskeyResult { From 192e272387ccf723505fa29353a51f4b92871e60 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Wed, 1 Oct 2025 14:06:53 -0500 Subject: [PATCH 15/15] Log PRF seed and input for debugging --- .../Core/Auth/Services/DevicePasskeyService.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/BitwardenShared/Core/Auth/Services/DevicePasskeyService.swift b/BitwardenShared/Core/Auth/Services/DevicePasskeyService.swift index 7f58f8a3bc..4738b76238 100644 --- a/BitwardenShared/Core/Auth/Services/DevicePasskeyService.swift +++ b/BitwardenShared/Core/Auth/Services/DevicePasskeyService.swift @@ -367,6 +367,11 @@ private func createAttestationObject( private func generatePrf(using prfInput: Data, from seed: SymmetricKey) -> Data { let saltPrefix = "WebAuthn PRF\0".data(using: .utf8)! let salt1 = saltPrefix + prfInput + let logger = Logger() + seed.withUnsafeBytes{ + let seedBytes = Data(Array($0)) + logger.debug("PRF Input: \(salt1.base64EncodedString())\nPRF Seed: \(seedBytes.base64UrlEncodedString())") + } // CTAP2 uses HMAC to expand salt into a PRF, so we're doing the same. return Data(HMAC.authenticationCode(for: salt1, using: seed)) }