diff --git a/.swiftlint.yml b/.swiftlint.yml index 8a492d617..edf1ee694 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -84,6 +84,7 @@ identifier_name: inclusive_language: override_allowed_terms: + - masterKey - masterPassword custom_rules: diff --git a/BitwardenShared/Core/Auth/Models/Response/Fixtures/IdentityTokenResponseModel+Fixtures.swift b/BitwardenShared/Core/Auth/Models/Response/Fixtures/IdentityTokenResponseModel+Fixtures.swift index 225df10f4..fab77c19c 100644 --- a/BitwardenShared/Core/Auth/Models/Response/Fixtures/IdentityTokenResponseModel+Fixtures.swift +++ b/BitwardenShared/Core/Auth/Models/Response/Fixtures/IdentityTokenResponseModel+Fixtures.swift @@ -46,6 +46,7 @@ extension IdentityTokenResponseModel { kdfMemory: Int? = nil, kdfParallelism: Int? = nil, key: String? = "KEY", + keyConnectorUrl: String? = nil, masterPasswordPolicy: MasterPasswordPolicyResponseModel? = nil, privateKey: String? = "PRIVATE_KEY", resetMasterPassword: Bool = false, @@ -68,6 +69,7 @@ extension IdentityTokenResponseModel { kdfMemory: kdfMemory, kdfParallelism: kdfParallelism, key: key, + keyConnectorUrl: keyConnectorUrl, masterPasswordPolicy: masterPasswordPolicy, privateKey: privateKey, resetMasterPassword: resetMasterPassword, diff --git a/BitwardenShared/Core/Auth/Models/Response/IdentityTokenResponseModel.swift b/BitwardenShared/Core/Auth/Models/Response/IdentityTokenResponseModel.swift index 77252781a..5ff246428 100644 --- a/BitwardenShared/Core/Auth/Models/Response/IdentityTokenResponseModel.swift +++ b/BitwardenShared/Core/Auth/Models/Response/IdentityTokenResponseModel.swift @@ -26,6 +26,9 @@ struct IdentityTokenResponseModel: Equatable, JSONResponse, KdfConfigProtocol { /// The user's key. let key: String? + /// The user's key connector URL. + let keyConnectorUrl: String? + /// Policies related to the user's master password. let masterPasswordPolicy: MasterPasswordPolicyResponseModel? diff --git a/BitwardenShared/Core/Auth/Repositories/AuthRepository.swift b/BitwardenShared/Core/Auth/Repositories/AuthRepository.swift index 2c1dd1590..5cc8378a9 100644 --- a/BitwardenShared/Core/Auth/Repositories/AuthRepository.swift +++ b/BitwardenShared/Core/Auth/Repositories/AuthRepository.swift @@ -192,6 +192,10 @@ protocol AuthRepository: AnyObject { /// func unlockVaultWithDeviceKey() async throws + /// Attempts to unlock the user's vault with the user's Key Connector key. + /// + func unlockVaultWithKeyConnectorKey() async throws + /// Attempts to unlock the user's vault with the stored neverlock key. /// func unlockVaultWithNeverlockKey() async throws @@ -336,6 +340,9 @@ class DefaultAuthRepository { /// The keychain service used by this repository. private let keychainService: KeychainRepository + /// The service used by the application to manage Key Connector. + private let keyConnectorService: KeyConnectorService + /// The service used by the application to make organization-related API requests. private let organizationAPIService: OrganizationAPIService @@ -366,6 +373,7 @@ class DefaultAuthRepository { /// - configService: The service to get server-specified configuration. /// - environmentService: The service used by the application to manage the environment settings. /// - keychainService: The keychain service used by the application. + /// - keyConnectorService: The service used by the application to manage Key Connector. /// - organizationAPIService: The service used by the application to make organization-related API requests. /// - organizationService: The service used to manage syncing and updates to the user's organizations. /// - organizationUserAPIService: The service used by the application to make organization @@ -382,6 +390,7 @@ class DefaultAuthRepository { configService: ConfigService, environmentService: EnvironmentService, keychainService: KeychainRepository, + keyConnectorService: KeyConnectorService, organizationAPIService: OrganizationAPIService, organizationService: OrganizationService, organizationUserAPIService: OrganizationUserAPIService, @@ -396,6 +405,7 @@ class DefaultAuthRepository { self.configService = configService self.environmentService = environmentService self.keychainService = keychainService + self.keyConnectorService = keyConnectorService self.organizationAPIService = organizationAPIService self.organizationService = organizationService self.organizationUserAPIService = organizationUserAPIService @@ -738,6 +748,15 @@ extension DefaultAuthRepository: AuthRepository { )) } + func unlockVaultWithKeyConnectorKey() async throws { + let account = try await stateService.getActiveAccount() + let encryptionKeys = try await stateService.getAccountEncryptionKeys(userId: account.profile.userId) + guard let encryptedUserKey = encryptionKeys.encryptedUserKey else { throw StateServiceError.noEncUserKey } + + let masterKey = try await keyConnectorService.getMasterKeyFromKeyConnector() + try await unlockVault(method: .keyConnector(masterKey: masterKey, userKey: encryptedUserKey)) + } + func unlockVaultWithNeverlockKey() async throws { let id = try await stateService.getActiveAccountId() let key = KeychainItem.neverLock(userId: id) diff --git a/BitwardenShared/Core/Auth/Repositories/AuthRepositoryTests.swift b/BitwardenShared/Core/Auth/Repositories/AuthRepositoryTests.swift index d5766e41f..59ddab7a3 100644 --- a/BitwardenShared/Core/Auth/Repositories/AuthRepositoryTests.swift +++ b/BitwardenShared/Core/Auth/Repositories/AuthRepositoryTests.swift @@ -14,6 +14,7 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo var clientService: MockClientService! var configService: MockConfigService! var environmentService: MockEnvironmentService! + var keyConnectorService: MockKeyConnectorService! var keychainService: MockKeychainRepository! var organizationService: MockOrganizationService! var subject: DefaultAuthRepository! @@ -89,6 +90,7 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo biometricsRepository = MockBiometricsRepository() configService = MockConfigService() environmentService = MockEnvironmentService() + keyConnectorService = MockKeyConnectorService() keychainService = MockKeychainRepository() organizationService = MockOrganizationService() stateService = MockStateService() @@ -103,6 +105,7 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo configService: configService, environmentService: environmentService, keychainService: keychainService, + keyConnectorService: keyConnectorService, organizationAPIService: APIService(client: client), organizationService: organizationService, organizationUserAPIService: APIService(client: client), @@ -1315,6 +1318,56 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo XCTAssertTrue(vaultTimeoutService.unlockVaultHadUserInteraction) } + /// `unlockVaultWithKeyConnectorKey()` unlocks the user's vault with their key connector key. + func test_unlockVaultWithKeyConnectorKey() async { + clientService.mockCrypto.initializeUserCryptoResult = .success(()) + keyConnectorService.getMasterKeyFromKeyConnectorResult = .success("key") + stateService.accountEncryptionKeys = [ + "1": AccountEncryptionKeys( + encryptedPrivateKey: "private", + encryptedUserKey: "user" + ), + ] + stateService.activeAccount = .fixture() + + await assertAsyncDoesNotThrow { + try await subject.unlockVaultWithKeyConnectorKey() + } + + XCTAssertEqual( + clientService.mockCrypto.initializeUserCryptoRequest, + InitUserCryptoRequest( + kdfParams: KdfConfig().sdkKdf, + email: "user@bitwarden.com", + privateKey: "private", + method: .keyConnector(masterKey: "key", userKey: "user") + ) + ) + XCTAssertTrue(vaultTimeoutService.unlockVaultHadUserInteraction) + } + + /// `unlockVaultWithKeyConnectorKey()` throws an error if the user is missing an encrypted user key. + func test_unlockVaultWithKeyConnectorKey_missingEncryptedUserKey() async { + stateService.activeAccount = .fixture() + stateService.accountEncryptionKeys = [ + "1": AccountEncryptionKeys( + encryptedPrivateKey: "private", + encryptedUserKey: nil + ), + ] + + await assertAsyncThrows(error: StateServiceError.noEncUserKey) { + try await subject.unlockVaultWithKeyConnectorKey() + } + } + + /// `unlockVaultWithKeyConnectorKey()` throws an error if there's no active account. + func test_unlockVaultWithKeyConnectorKey_noActiveAccount() async { + await assertAsyncThrows(error: StateServiceError.noActiveAccount) { + try await subject.unlockVaultWithKeyConnectorKey() + } + } + /// `logout` throws an error with no accounts. func test_logout_noAccounts() async { stateService.accounts = [] diff --git a/BitwardenShared/Core/Auth/Repositories/TestHelpers/MockAuthRepository.swift b/BitwardenShared/Core/Auth/Repositories/TestHelpers/MockAuthRepository.swift index e5992f320..c2173c400 100644 --- a/BitwardenShared/Core/Auth/Repositories/TestHelpers/MockAuthRepository.swift +++ b/BitwardenShared/Core/Auth/Repositories/TestHelpers/MockAuthRepository.swift @@ -56,7 +56,9 @@ class MockAuthRepository: AuthRepository { // swiftlint:disable:this type_body_l var unlockVaultResult: Result = .success(()) var unlockVaultWithBiometricsResult: Result = .success(()) + var unlockVaultWithDeviceKeyCalled = false var unlockVaultWithDeviceKeyResult: Result = .success(()) + var unlockVaultWithKeyConnectorKeyCalled = false var unlockVaultWithKeyConnectorKeyResult: Result = .success(()) var unlockVaultWithNeverlockKeyCalled = false var unlockVaultWithNeverlockResult: Result = .success(()) @@ -252,10 +254,12 @@ class MockAuthRepository: AuthRepository { // swiftlint:disable:this type_body_l } func unlockVaultWithDeviceKey() async throws { + unlockVaultWithDeviceKeyCalled = true try unlockVaultWithDeviceKeyResult.get() } func unlockVaultWithKeyConnectorKey() async throws { + unlockVaultWithKeyConnectorKeyCalled = true try unlockVaultWithKeyConnectorKeyResult.get() } diff --git a/BitwardenShared/Core/Auth/Services/API/Auth/Fixtures/APITestData+Auth.swift b/BitwardenShared/Core/Auth/Services/API/Auth/Fixtures/APITestData+Auth.swift index c1b817dae..a4e037f90 100644 --- a/BitwardenShared/Core/Auth/Services/API/Auth/Fixtures/APITestData+Auth.swift +++ b/BitwardenShared/Core/Auth/Services/API/Auth/Fixtures/APITestData+Auth.swift @@ -10,8 +10,10 @@ extension APITestData { ) static let identityTokenSuccessTwoFactorToken = loadFromJsonBundle(resource: "IdentityTokenSuccessTwoFactorToken") static let identityTokenCaptchaError = loadFromJsonBundle(resource: "IdentityTokenCaptchaFailure") + static let identityTokenKeyConnector = loadFromJsonBundle(resource: "IdentityTokenKeyConnector") static let identityTokenNoMasterPassword = loadFromJsonBundle(resource: "IdentityTokenNoMasterPassword") static let identityTokenRefresh = loadFromJsonBundle(resource: "identityTokenRefresh") + static let identityTokenTrustedDevice = loadFromJsonBundle(resource: "IdentityTokenTrustedDevice") static let identityTokenTwoFactorError = loadFromJsonBundle(resource: "IdentityTokenTwoFactorFailure") static let preValidateSingleSignOn = loadFromJsonBundle(resource: "preValidateSingleSignOn") } diff --git a/BitwardenShared/Core/Auth/Services/API/Auth/Fixtures/IdentityTokenKeyConnector.json b/BitwardenShared/Core/Auth/Services/API/Auth/Fixtures/IdentityTokenKeyConnector.json new file mode 100644 index 000000000..1ee651022 --- /dev/null +++ b/BitwardenShared/Core/Auth/Services/API/Auth/Fixtures/IdentityTokenKeyConnector.json @@ -0,0 +1,23 @@ +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2OTY5MDg4NzksInN1YiI6IjEzNTEyNDY3LTljZmUtNDNiMC05NjlmLTA3NTM0MDg0NzY0YiIsIm5hbWUiOiJCaXR3YXJkZW4gVXNlciIsImVtYWlsIjoidXNlckBiaXR3YXJkZW4uY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImlhdCI6MTUxNjIzOTAyMiwicHJlbWl1bSI6ZmFsc2UsImFtciI6WyJBcHBsaWNhdGlvbiJdfQ.KDqC8kUaOAgBiUY8eeLa0a4xYWN8GmheXTFXmataFwM", + "expires_in": 3600, + "token_type": "Bearer", + "refresh_token": "REFRESH_TOKEN", + "scope": "api offline_access", + "PrivateKey": "PRIVATE_KEY", + "Key": "KEY", + "MasterPasswordPolicy": null, + "ForcePasswordReset": false, + "ResetMasterPassword": false, + "Kdf": 0, + "KdfIterations": 600000, + "KdfMemory": null, + "KdfParallelism": null, + "UserDecryptionOptions": { + "HasMasterPassword": false, + "KeyConnectorOption": { + "KeyConnectorUrl": "https://vault.bitwarden.com/key-connector" + }, + "Object": "userDecryptionOptions" + } +} diff --git a/BitwardenShared/Core/Auth/Services/API/Auth/Fixtures/IdentityTokenTrustedDevice.json b/BitwardenShared/Core/Auth/Services/API/Auth/Fixtures/IdentityTokenTrustedDevice.json new file mode 100644 index 000000000..71a0a926c --- /dev/null +++ b/BitwardenShared/Core/Auth/Services/API/Auth/Fixtures/IdentityTokenTrustedDevice.json @@ -0,0 +1,25 @@ +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2OTY5MDg4NzksInN1YiI6IjEzNTEyNDY3LTljZmUtNDNiMC05NjlmLTA3NTM0MDg0NzY0YiIsIm5hbWUiOiJCaXR3YXJkZW4gVXNlciIsImVtYWlsIjoidXNlckBiaXR3YXJkZW4uY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImlhdCI6MTUxNjIzOTAyMiwicHJlbWl1bSI6ZmFsc2UsImFtciI6WyJBcHBsaWNhdGlvbiJdfQ.KDqC8kUaOAgBiUY8eeLa0a4xYWN8GmheXTFXmataFwM", + "expires_in": 3600, + "token_type": "Bearer", + "refresh_token": "REFRESH_TOKEN", + "scope": "api offline_access", + "MasterPasswordPolicy": null, + "ForcePasswordReset": false, + "ResetMasterPassword": false, + "Kdf": 0, + "KdfIterations": 600000, + "KdfMemory": null, + "KdfParallelism": null, + "UserDecryptionOptions": { + "HasMasterPassword": false, + "TrustedDeviceOption": { + "EncryptedPrivateKey": "private-key", + "EncryptedUserKey": "user-key", + "HasAdminApproval": false, + "HasLoginApprovingDevice": true, + "HasManageResetPasswordPermission": false + }, + "Object": "userDecryptionOptions" + } +} diff --git a/BitwardenShared/Core/Auth/Services/API/KeyConnector/KeyConnectorAPIService.swift b/BitwardenShared/Core/Auth/Services/API/KeyConnector/KeyConnectorAPIService.swift index 9d8b8c9c8..297260dc4 100644 --- a/BitwardenShared/Core/Auth/Services/API/KeyConnector/KeyConnectorAPIService.swift +++ b/BitwardenShared/Core/Auth/Services/API/KeyConnector/KeyConnectorAPIService.swift @@ -1,7 +1,5 @@ import Foundation -// swiftlint:disable inclusive_language - // MARK: - KeyConnectorAPIService /// A protocol for an API service used to make key connector requests. @@ -38,5 +36,3 @@ extension APIService: KeyConnectorAPIService { _ = try await service.send(PostKeyConnectorUserKeyRequest(body: body)) } } - -// swiftlint:enable inclusive_language diff --git a/BitwardenShared/Core/Auth/Services/API/KeyConnector/KeyConnectorAPIServiceTests.swift b/BitwardenShared/Core/Auth/Services/API/KeyConnector/KeyConnectorAPIServiceTests.swift index 91681ef14..182d3b1cd 100644 --- a/BitwardenShared/Core/Auth/Services/API/KeyConnector/KeyConnectorAPIServiceTests.swift +++ b/BitwardenShared/Core/Auth/Services/API/KeyConnector/KeyConnectorAPIServiceTests.swift @@ -2,8 +2,6 @@ import XCTest @testable import BitwardenShared -// swiftlint:disable inclusive_language - class KeyConnectorAPIServiceTests: BitwardenTestCase { // MARK: Properties @@ -80,5 +78,3 @@ class KeyConnectorAPIServiceTests: BitwardenTestCase { } } } - -// swiftlint:enable inclusive_language diff --git a/BitwardenShared/Core/Auth/Services/AuthService.swift b/BitwardenShared/Core/Auth/Services/AuthService.swift index 0bb31c664..bdf6d27ab 100644 --- a/BitwardenShared/Core/Auth/Services/AuthService.swift +++ b/BitwardenShared/Core/Auth/Services/AuthService.swift @@ -51,6 +51,21 @@ enum AuthError: Error { case unableToResendEmail } +// MARK: - LoginUnlockMethod + +/// An enumeration of vault unlock methods that can be used when a user is logging in. +/// +enum LoginUnlockMethod: Equatable { + /// The user uses a device key to unlock the vault. + case deviceKey + + /// The user needs to unlock their vault with their master password. + case masterPassword(Account) + + /// The user uses key connector to unlock the vault. + case keyConnector +} + // MARK: - AuthService /// A protocol for a service used that handles the auth logic. @@ -152,9 +167,9 @@ protocol AuthService { /// - code: The code received from the single sign on WebAuth flow. /// - email: The user's email address. /// - /// - Returns: The account to unlock the vault for, or nil if the vault does not need to be unlocked. + /// - Returns: The vault unlock method to use after login. /// - func loginWithSingleSignOn(code: String, email: String) async throws -> Account? + func loginWithSingleSignOn(code: String, email: String) async throws -> LoginUnlockMethod /// Continue the previous login attempt with the addition of the two-factor information. /// @@ -165,7 +180,7 @@ protocol AuthService { /// - remember: Whether to remember the two-factor code. /// - captchaToken: An optional captcha token value to add to the token request. /// - /// - Returns: The account to unlock the vault for. + /// - Returns: The vault unlock method to use after login. /// func loginWithTwoFactorCode( email: String, @@ -173,7 +188,7 @@ protocol AuthService { method: TwoFactorAuthMethod, remember: Bool, captchaToken: String? - ) async throws -> Account? + ) async throws -> LoginUnlockMethod /// Evaluates the supplied master password against the master password policy provided by the Identity response. /// - Parameters: @@ -554,7 +569,12 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng } } - /// Check TDE user decryption options to see if can unlock with trusted deviceKey or needs further actions + /// Check TDE user decryption options to see if can unlock with trusted deviceKey or needs + /// further actions. + /// + /// - Parameter response: The response received from the identity token request. + /// - Returns: Whether the vault can be unlocked with the trusted device key. + /// private func canUnlockWithDeviceKey(_ response: IdentityTokenResponseModel) async throws -> Bool { if let decryptionOptions = response.userDecryptionOptions, let trustedDeviceOption = decryptionOptions.trustedDeviceOption { @@ -586,7 +606,7 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng return false } - func loginWithSingleSignOn(code: String, email: String) async throws -> Account? { + func loginWithSingleSignOn(code: String, email: String) async throws -> LoginUnlockMethod { // Get the identity token to log in to Bitwarden. let response = try await getIdentityTokenResponse( authenticationMethod: .authorizationCode( @@ -597,13 +617,7 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng email: email ) - if try await canUnlockWithDeviceKey(response) { - return nil - } - - // Return the account if the vault still needs to be unlocked and nil otherwise. - // TODO: BIT-1392 Wait for SDK to support unlocking vault for TDE accounts. - return try await stateService.getActiveAccount() + return try await unlockMethod(for: response) } func loginWithTwoFactorCode( @@ -612,7 +626,7 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng method: TwoFactorAuthMethod, remember: Bool, captchaToken: String? = nil - ) async throws -> Account? { + ) async throws -> LoginUnlockMethod { guard var twoFactorRequest else { throw AuthError.missingTwoFactorRequest } // Add the two factor information to the request. @@ -635,12 +649,7 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng self.twoFactorRequest = nil resendEmailModel = nil - if try await canUnlockWithDeviceKey(response) { - return nil - } - - // Return the account if the vault still needs to be unlocked. - return try await stateService.getActiveAccount() + return try await unlockMethod(for: response) } func requirePasswordChange( @@ -704,6 +713,20 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng // MARK: Private Methods + /// Check whether the user can unlock their vault with a Key Connector key. + /// + /// - Parameter response: The response received from the identity token request. + /// - Returns: Whether the vault can be unlocked with a Key Connector key. + /// + private func canUnlockWithKeyConnectorKey(_ response: IdentityTokenResponseModel) async throws -> Bool { + guard let keyConnectorUrl = response.keyConnectorUrl ?? + response.userDecryptionOptions?.keyConnectorOption?.keyConnectorUrl, + !keyConnectorUrl.isEmpty + else { return false } + + return true + } + /// Get the fingerprint phrase from the public key of a login request. /// /// - Parameters: @@ -801,6 +824,24 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng } } + /// Returns a `LoginUnlockMethod` based on the identity token response. + /// + /// - Parameter response: The API response for the identity token request, used to determine + /// the unlock method used after login. + /// - Returns: The `LoginUnlockMethod` that should be used to unlock the vault after login. + /// + private func unlockMethod(for response: IdentityTokenResponseModel) async throws -> LoginUnlockMethod { + if try await canUnlockWithDeviceKey(response) { + return .deviceKey + } + + if try await canUnlockWithKeyConnectorKey(response) { + return .keyConnector + } + + return try await .masterPassword(stateService.getActiveAccount()) + } + /// Saves the user's account information. /// /// - Parameters: diff --git a/BitwardenShared/Core/Auth/Services/AuthServiceTests.swift b/BitwardenShared/Core/Auth/Services/AuthServiceTests.swift index f66188a60..c5bbe4aad 100644 --- a/BitwardenShared/Core/Auth/Services/AuthServiceTests.swift +++ b/BitwardenShared/Core/Auth/Services/AuthServiceTests.swift @@ -452,6 +452,26 @@ class AuthServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_ XCTAssertNil(cachedToken) } + /// `loginWithSingleSignOn(code:email:)` returns the device key unlock method if the user + /// uses trusted device encryption. + func test_loginSingleSignOn_deviceKey() async throws { + client.result = .httpSuccess(testData: .identityTokenTrustedDevice) + + let unlockMethod = try await subject.loginWithSingleSignOn(code: "super_cool_secret_code", email: "") + + XCTAssertEqual(unlockMethod, .deviceKey) + } + + /// `loginWithSingleSignOn(code:email:)` returns the key connector unlock method if the user + /// uses key connector. + func test_loginSingleSignOn_keyConnector() async throws { + client.result = .httpSuccess(testData: .identityTokenKeyConnector) + + let unlockMethod = try await subject.loginWithSingleSignOn(code: "super_cool_secret_code", email: "") + + XCTAssertEqual(unlockMethod, .keyConnector) + } + /// `loginWithSingleSignOn(code:email:)` throws an error if the user doesn't have a master password set. func test_loginSingleSignOn_noMasterPassword() async { client.result = .httpSuccess(testData: .identityTokenNoMasterPassword) @@ -470,7 +490,7 @@ class AuthServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_ systemDevice.modelIdentifier = "Model id" // Attempt to login. - let account = try await subject.loginWithSingleSignOn(code: "super_cool_secret_code", email: "") + let unlockMethod = try await subject.loginWithSingleSignOn(code: "super_cool_secret_code", email: "") // Verify the results. let tokenRequest = IdentityTokenRequestModel( @@ -508,7 +528,7 @@ class AuthServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_ IdentityTokenResponseModel.fixture().refreshToken ) - XCTAssertEqual(account, .fixtureAccountLogin()) + XCTAssertEqual(unlockMethod, .masterPassword(.fixtureAccountLogin())) } /// `loginWithTwoFactorCode(email:code:method:remember:captchaToken:)` uses the cached request but with two factor @@ -547,7 +567,7 @@ class AuthServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_ } // Login with the two-factor code. - let account = try await subject.loginWithTwoFactorCode( + let unlockMethod = try await subject.loginWithTwoFactorCode( email: "email@example.com", code: "just_a_lil_code", method: .email, @@ -581,7 +601,82 @@ class AuthServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_ IdentityTokenResponseModel.fixture().refreshToken ) - XCTAssertEqual(account, .fixtureAccountLogin()) + XCTAssertEqual(unlockMethod, .masterPassword(.fixtureAccountLogin())) + } + + /// `loginWithTwoFactorCode()` returns the device key unlock method if the user uses trusted + /// device encryption. + func test_loginWithTwoFactorCode_deviceKey() async throws { + client.results = [ + .httpSuccess(testData: .preLoginSuccess), + .httpFailure( + statusCode: 400, + headers: [:], + data: APITestData.identityTokenTwoFactorError.data + ), + .httpSuccess(testData: .identityTokenTrustedDevice), + ] + + // First login with the master password so that the request will be saved. + let authMethodsData = AuthMethodsData.fixture() + await assertAsyncThrows( + error: IdentityTokenRequestError.twoFactorRequired( + authMethodsData, + "exampleToken", + "BWCaptchaBypass_ABCXYZ" + ) + ) { + try await subject.loginWithMasterPassword( + "Password1234!", + username: "email@example.com", + captchaToken: nil + ) + } + + let unlockMethod = try await subject.loginWithTwoFactorCode( + email: "email@example.com", + code: "just_a_lil_code", + method: .email, + remember: true + ) + XCTAssertEqual(unlockMethod, .deviceKey) + } + + /// `loginWithTwoFactorCode()` returns the key connector unlock method if the user uses key connector. + func test_loginWithTwoFactorCode_keyConnector() async throws { + client.results = [ + .httpSuccess(testData: .preLoginSuccess), + .httpFailure( + statusCode: 400, + headers: [:], + data: APITestData.identityTokenTwoFactorError.data + ), + .httpSuccess(testData: .identityTokenKeyConnector), + ] + + // First login with the master password so that the request will be saved. + let authMethodsData = AuthMethodsData.fixture() + await assertAsyncThrows( + error: IdentityTokenRequestError.twoFactorRequired( + authMethodsData, + "exampleToken", + "BWCaptchaBypass_ABCXYZ" + ) + ) { + try await subject.loginWithMasterPassword( + "Password1234!", + username: "email@example.com", + captchaToken: nil + ) + } + + let unlockMethod = try await subject.loginWithTwoFactorCode( + email: "email@example.com", + code: "just_a_lil_code", + method: .email, + remember: true + ) + XCTAssertEqual(unlockMethod, .keyConnector) } /// `requirePasswordChange(email:masterPassword:policy)` returns `false` if there diff --git a/BitwardenShared/Core/Auth/Services/TestHelpers/MockAuthService.swift b/BitwardenShared/Core/Auth/Services/TestHelpers/MockAuthService.swift index ef758508f..3226765ef 100644 --- a/BitwardenShared/Core/Auth/Services/TestHelpers/MockAuthService.swift +++ b/BitwardenShared/Core/Auth/Services/TestHelpers/MockAuthService.swift @@ -48,14 +48,14 @@ class MockAuthService: AuthService { var loginWithMasterPasswordResult: Result = .success(()) var loginWithSingleSignOnCode: String? - var loginWithSingleSignOnResult: Result = .success(nil) + var loginWithSingleSignOnResult: Result = .success(.masterPassword(.fixture())) var loginWithTwoFactorCodeEmail: String? var loginWithTwoFactorCodeCode: String? var loginWithTwoFactorCodeMethod: TwoFactorAuthMethod? var loginWithTwoFactorCodeRemember: Bool? var loginWithTwoFactorCodeCaptchaToken: String? - var loginWithTwoFactorCodeResult: Result = .success(.fixture()) + var loginWithTwoFactorCodeResult: Result = .success(.masterPassword(.fixture())) var publicKey: String = "" var requirePasswordChangeResult: Result = .success(false) var resendVerificationCodeEmailResult: Result = .success(()) @@ -132,7 +132,7 @@ class MockAuthService: AuthService { try loginWithMasterPasswordResult.get() } - func loginWithSingleSignOn(code: String, email _: String) async throws -> Account? { + func loginWithSingleSignOn(code: String, email _: String) async throws -> LoginUnlockMethod { loginWithSingleSignOnCode = code return try loginWithSingleSignOnResult.get() } @@ -143,7 +143,7 @@ class MockAuthService: AuthService { method: TwoFactorAuthMethod, remember: Bool, captchaToken: String? - ) async throws -> Account? { + ) async throws -> LoginUnlockMethod { loginWithTwoFactorCodeEmail = email loginWithTwoFactorCodeCode = code loginWithTwoFactorCodeMethod = method diff --git a/BitwardenShared/Core/Platform/Services/KeyConnectorService.swift b/BitwardenShared/Core/Platform/Services/KeyConnectorService.swift new file mode 100644 index 000000000..f28e47a7b --- /dev/null +++ b/BitwardenShared/Core/Platform/Services/KeyConnectorService.swift @@ -0,0 +1,65 @@ +import Foundation + +// MARK: - KeyConnectorService + +/// A protocol for a `KeyConnectorService` which manages Key Connector. +/// +protocol KeyConnectorService { + /// Fetches the user's master key from Key Connector. + /// + /// - Returns: The user's master key. + /// + func getMasterKeyFromKeyConnector() async throws -> String +} + +// MARK: - KeyConnectorServiceError + +/// The errors thrown from a `KeyConnectorService`. +/// +enum KeyConnectorServiceError: Error { + /// The key connector URL doesn't exist for the user. + case missingKeyConnectorUrl +} + +// MARK: - DefaultKeyConnectorService + +/// A default implementation of `KeyConnectorService`. +/// +class DefaultKeyConnectorService { + // MARK: Properties + + /// The API service used to make key connector requests. + private let keyConnectorAPIService: KeyConnectorAPIService + + /// The service used by the application to manage account state. + private let stateService: StateService + + // MARK: Initialization + + /// Initialize a `DefaultKeyConnectorService`. + /// + /// - Parameters: + /// - keyConnectorAPIService: The API service used to make key connector requests. + /// - stateService: The service used by the application to manage account state. + /// + init( + keyConnectorAPIService: KeyConnectorAPIService, + stateService: StateService + ) { + self.keyConnectorAPIService = keyConnectorAPIService + self.stateService = stateService + } +} + +extension DefaultKeyConnectorService: KeyConnectorService { + func getMasterKeyFromKeyConnector() async throws -> String { + let account = try await stateService.getActiveAccount() + let keyConnectorUrlString = account.profile.userDecryptionOptions?.keyConnectorOption?.keyConnectorUrl + guard let keyConnectorUrlString, + let keyConnectorUrl = URL(string: keyConnectorUrlString) else { + throw KeyConnectorServiceError.missingKeyConnectorUrl + } + + return try await keyConnectorAPIService.getMasterKeyFromKeyConnector(keyConnectorUrl: keyConnectorUrl) + } +} diff --git a/BitwardenShared/Core/Platform/Services/KeyConnectorServiceTests.swift b/BitwardenShared/Core/Platform/Services/KeyConnectorServiceTests.swift new file mode 100644 index 000000000..9016cfd0d --- /dev/null +++ b/BitwardenShared/Core/Platform/Services/KeyConnectorServiceTests.swift @@ -0,0 +1,60 @@ +import XCTest + +@testable import BitwardenShared + +class KeyConnectorServiceTests: BitwardenTestCase { + // MARK: Properties + + var client: MockHTTPClient! + var subject: DefaultKeyConnectorService! + var stateService: MockStateService! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + + client = MockHTTPClient() + stateService = MockStateService() + + subject = DefaultKeyConnectorService( + keyConnectorAPIService: APIService(client: client), + stateService: stateService + ) + } + + override func tearDown() { + super.tearDown() + + client = nil + subject = nil + stateService = nil + } + + // MARK: Tests + + /// `getMasterKeyFromKeyConnector()` returns the user's master key from the Key Connector API. + func test_getMasterKeyFromKeyConnector() async throws { + client.result = .httpSuccess(testData: .keyConnectorUserKey) + stateService.activeAccount = .fixture( + profile: .fixture( + userDecryptionOptions: UserDecryptionOptions( + hasMasterPassword: false, + keyConnectorOption: KeyConnectorUserDecryptionOption(keyConnectorUrl: "https://example.com"), + trustedDeviceOption: nil + ) + ) + ) + + let key = try await subject.getMasterKeyFromKeyConnector() + XCTAssertEqual(key, "EXsYYd2Wx4H/9dhzmINS0P30lpG8bZ44RRn/T15tVA8=") + } + + /// `getMasterKeyFromKeyConnector()` throws an error if the key connector URL is missing. + func test_getMasterKeyFromKeyConnector_missingUrl() async throws { + stateService.activeAccount = .fixture() + await assertAsyncThrows(error: KeyConnectorServiceError.missingKeyConnectorUrl) { + _ = try await subject.getMasterKeyFromKeyConnector() + } + } +} diff --git a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift index 151aeb2fb..84c8a91a4 100644 --- a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift +++ b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift @@ -407,6 +407,11 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le stateService: stateService ) + let keyConnectorService = DefaultKeyConnectorService( + keyConnectorAPIService: apiService, + stateService: stateService + ) + let syncService = DefaultSyncService( accountAPIService: apiService, cipherService: cipherService, @@ -469,6 +474,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le configService: configService, environmentService: environmentService, keychainService: keychainRepository, + keyConnectorService: keyConnectorService, organizationAPIService: apiService, organizationService: organizationService, organizationUserAPIService: apiService, diff --git a/BitwardenShared/Core/Platform/Services/TestHelpers/MockKeyConnectorService.swift b/BitwardenShared/Core/Platform/Services/TestHelpers/MockKeyConnectorService.swift new file mode 100644 index 000000000..ccfa304da --- /dev/null +++ b/BitwardenShared/Core/Platform/Services/TestHelpers/MockKeyConnectorService.swift @@ -0,0 +1,9 @@ +@testable import BitwardenShared + +class MockKeyConnectorService: KeyConnectorService { + var getMasterKeyFromKeyConnectorResult: Result = .success("key") + + func getMasterKeyFromKeyConnector() async throws -> String { + try getMasterKeyFromKeyConnectorResult.get() + } +} diff --git a/BitwardenShared/UI/Auth/Login/SingleSignOn/SingleSignOnProcessor.swift b/BitwardenShared/UI/Auth/Login/SingleSignOn/SingleSignOnProcessor.swift index 68f5f09ed..987732577 100644 --- a/BitwardenShared/UI/Auth/Login/SingleSignOn/SingleSignOnProcessor.swift +++ b/BitwardenShared/UI/Auth/Login/SingleSignOn/SingleSignOnProcessor.swift @@ -163,7 +163,10 @@ extension SingleSignOnProcessor: SingleSignOnFlowDelegate { Task { do { // Use the code to authenticate the user with Bitwarden. - let account = try await self.services.authService.loginWithSingleSignOn(code: code, email: state.email) + let unlockMethod = try await self.services.authService.loginWithSingleSignOn( + code: code, + email: state.email + ) // Remember the organization identifier after successfully logging on. services.stateService.rememberedOrgIdentifier = state.identifierText @@ -172,7 +175,12 @@ extension SingleSignOnProcessor: SingleSignOnFlowDelegate { coordinator.hideLoadingOverlay() // Show the appropriate view and dismiss this sheet. - if let account { + switch unlockMethod { + case .deviceKey: + // Attempt to unlock the vault with tde. + try await services.authRepository.unlockVaultWithDeviceKey() + coordinator.navigate(to: .complete) + case let .masterPassword(account): coordinator.navigate( to: .vaultUnlock( account, @@ -181,11 +189,11 @@ extension SingleSignOnProcessor: SingleSignOnFlowDelegate { didSwitchAccountAutomatically: false ) ) - } else { - // Attempt to unlock the vault with tde. - try await services.authRepository.unlockVaultWithDeviceKey() + case .keyConnector: + try await services.authRepository.unlockVaultWithKeyConnectorKey() coordinator.navigate(to: .complete) } + coordinator.navigate(to: .dismiss) } catch { // The delay is necessary in order to ensure the alert displays over the WebAuth view. diff --git a/BitwardenShared/UI/Auth/Login/SingleSignOn/SingleSignOnProcessorTests.swift b/BitwardenShared/UI/Auth/Login/SingleSignOn/SingleSignOnProcessorTests.swift index c6910c80a..0543eaf93 100644 --- a/BitwardenShared/UI/Auth/Login/SingleSignOn/SingleSignOnProcessorTests.swift +++ b/BitwardenShared/UI/Auth/Login/SingleSignOn/SingleSignOnProcessorTests.swift @@ -5,6 +5,7 @@ import XCTest class SingleSignOnProcessorTests: BitwardenTestCase { // MARK: Properties + var authRepository: MockAuthRepository! var authService: MockAuthService! var client: MockHTTPClient! var coordinator: MockCoordinator! @@ -17,12 +18,14 @@ class SingleSignOnProcessorTests: BitwardenTestCase { override func setUp() { super.setUp() + authRepository = MockAuthRepository() authService = MockAuthService() client = MockHTTPClient() coordinator = MockCoordinator() errorReporter = MockErrorReporter() stateService = MockStateService() let services = ServiceContainer.withMocks( + authRepository: authRepository, authService: authService, errorReporter: errorReporter, httpClient: client, @@ -39,6 +42,7 @@ class SingleSignOnProcessorTests: BitwardenTestCase { override func tearDown() { super.tearDown() + authRepository = nil authService = nil client = nil coordinator = nil @@ -202,7 +206,7 @@ class SingleSignOnProcessorTests: BitwardenTestCase { @MainActor func test_singleSignOnCompleted_vaultLocked() { // Set up the mock data. - authService.loginWithSingleSignOnResult = .success(.fixtureAccountLogin()) + authService.loginWithSingleSignOnResult = .success(.masterPassword(.fixtureAccountLogin())) subject.state.identifierText = "BestOrganization" // Receive the completed code. @@ -227,11 +231,11 @@ class SingleSignOnProcessorTests: BitwardenTestCase { ) } - /// `singleSignOnCompleted(code:)` navigates to the complete route if the vault is unlocked. + /// `singleSignOnCompleted(code:)` navigates to the complete route if the user uses Key Connector. @MainActor - func test_singleSignOnCompleted_vaultUnlocked() { + func test_singleSignOnCompleted_vaultUnlockedKeyConnector() { // Set up the mock data. - authService.loginWithSingleSignOnResult = .success(nil) + authService.loginWithSingleSignOnResult = .success(.keyConnector) subject.state.identifierText = "BestOrganization" // Receive the completed code. @@ -239,6 +243,26 @@ class SingleSignOnProcessorTests: BitwardenTestCase { waitFor(!coordinator.routes.isEmpty) // Verify the results. + XCTAssertTrue(authRepository.unlockVaultWithKeyConnectorKeyCalled) + XCTAssertEqual(authService.loginWithSingleSignOnCode, "super_cool_secret_code") + XCTAssertEqual(stateService.rememberedOrgIdentifier, "BestOrganization") + XCTAssertFalse(coordinator.isLoadingOverlayShowing) + XCTAssertEqual(coordinator.routes, [.complete, .dismiss]) + } + + /// `singleSignOnCompleted(code:)` navigates to the complete route if the user uses TDE. + @MainActor + func test_singleSignOnCompleted_vaultUnlockedTDE() { + // Set up the mock data. + authService.loginWithSingleSignOnResult = .success(.deviceKey) + subject.state.identifierText = "BestOrganization" + + // Receive the completed code. + subject.singleSignOnCompleted(code: "super_cool_secret_code") + waitFor(!coordinator.routes.isEmpty) + + // Verify the results. + XCTAssertTrue(authRepository.unlockVaultWithDeviceKeyCalled) XCTAssertEqual(authService.loginWithSingleSignOnCode, "super_cool_secret_code") XCTAssertEqual(stateService.rememberedOrgIdentifier, "BestOrganization") XCTAssertFalse(coordinator.isLoadingOverlayShowing) diff --git a/BitwardenShared/UI/Auth/Login/TwoFactorAuth/TwoFactorAuthProcessor.swift b/BitwardenShared/UI/Auth/Login/TwoFactorAuth/TwoFactorAuthProcessor.swift index 1609a1e0d..7b55bfd07 100644 --- a/BitwardenShared/UI/Auth/Login/TwoFactorAuth/TwoFactorAuthProcessor.swift +++ b/BitwardenShared/UI/Auth/Login/TwoFactorAuth/TwoFactorAuthProcessor.swift @@ -159,7 +159,7 @@ final class TwoFactorAuthProcessor: StateProcessor