Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions BitwardenShared/Core/Auth/Repositories/AuthRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1158,6 +1158,16 @@ extension DefaultAuthRepository: AuthRepository {
)
try await stateService.setMasterPasswordHash(hashedPassword)
await updateKdfToMinimumsIfNeeded(password: password)

do {
try await authService.checkMasterPasswordPolicyAfterUnlock(
email: account.profile.email,
masterPassword: password,
)
} catch {
// Don't let a policy-check failure block an unlock that already succeeded.
errorReporter.log(error: error)
}
Comment on lines +1162 to +1170

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ QUESTION: Running this on every master-password unlock means the Update Master Password screen can now be triggered from inside an app extension.

Trace and consideration

VaultUnlockProcessor.unlockVault() calls unlockVaultWithPassword and then coordinator.handleEvent(.didCompleteAuth), which routes through AuthRouter.completeAuthRedirect():

if account.profile.forcePasswordResetReason != nil {
    return .updateMasterPassword
}

if !isInAppExtension {
    ...
}

The forcePasswordResetReason check sits above the isInAppExtension guard, so this path applies in the AutoFill/Share/Action extensions too. An SSO/TDE user whose master password violates the policy β€” precisely the population this PR targets β€” may well perform their first master-password unlock inside the AutoFill extension. They would then be dropped into Update Master Password mid-autofill (which triggers settingsRepository.fetchSync() and, on success, logout) instead of getting their credential.

This was reachable before only via a reason already persisted from a main-app login; this change makes the extension itself a place where the reason first gets set. Was the extension context considered? If enforcement should be main-app-only, gating the new call (or the router's redirect) on isInAppExtension would keep autofill unaffected β€” the reason would still be set on the next main-app unlock. Note AppProcessor.removeMasterPassword already applies exactly this kind of guard.

The PR description discusses the vault-timeout re-lock flow but not extensions, so flagging in case it was an unconsidered side effect rather than an intentional trade-off.

}

func unlockVaultWithPIN(pin: String) async throws {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2866,6 +2866,61 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
XCTAssertTrue(errorReporter.errors.isEmpty)
}

/// `unlockVaultWithPassword(password:)` checks the entered master password against any
/// active organization Master Password policy after a successful unlock.
func test_unlockVaultWithPassword_checksMasterPasswordPolicy() async throws {
let account = Account.fixture(profile: .fixture(
userDecryptionOptions: UserDecryptionOptions(
hasMasterPassword: true,
masterPasswordUnlock: .fixture(),
keyConnectorOption: nil,
trustedDeviceOption: nil,
),
))
stateService.activeAccount = account
stateService.accountEncryptionKeys = [
"1": AccountEncryptionKeys(
cryptographicState: .fixtureV2(),
encryptedUserKey: "USER_KEY",
),
]

try await subject.unlockVaultWithPassword(password: "password")

XCTAssertEqual(authService.checkMasterPasswordPolicyAfterUnlockEmail, "user@bitwarden.com")
XCTAssertEqual(authService.checkMasterPasswordPolicyAfterUnlockMasterPassword, "password")
XCTAssertTrue(errorReporter.errors.isEmpty)
}

/// `unlockVaultWithPassword(password:)` does not let a failure in the Master Password policy
/// check propagate out or block an unlock that already succeeded β€” it logs the error instead.
func test_unlockVaultWithPassword_masterPasswordPolicyCheckError_doesNotThrow() async throws {
let account = Account.fixture(profile: .fixture(
userDecryptionOptions: UserDecryptionOptions(
hasMasterPassword: true,
masterPasswordUnlock: .fixture(),
keyConnectorOption: nil,
trustedDeviceOption: nil,
),
))
stateService.activeAccount = account
stateService.accountEncryptionKeys = [
"1": AccountEncryptionKeys(
cryptographicState: .fixtureV2(),
encryptedUserKey: "USER_KEY",
),
]
authService.checkMasterPasswordPolicyAfterUnlockResult = .failure(BitwardenTestError.example)

await assertAsyncDoesNotThrow {
try await subject.unlockVaultWithPassword(password: "password")
}

XCTAssertFalse(vaultTimeoutService.isLocked(userId: "1"))
XCTAssertEqual(stateService.masterPasswordHashes["1"], "hashed")
XCTAssertEqual(errorReporter.errors.last as? BitwardenTestError, .example)
}

/// `logout` throws an error with no accounts.
func test_logout_noAccounts() async {
stateService.accounts = []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ public extension APITestData {
)
static let identityTokenRefreshStubError = loadFromJsonBundle(resource: "IdentityTokenRefreshStubError")
static let identityTokenTrustedDevice = loadFromJsonBundle(resource: "IdentityTokenTrustedDevice")
static let identityTokenTrustedDeviceWithMPPolicy = loadFromJsonBundle(
resource: "IdentityTokenTrustedDeviceWithMasterPasswordPolicy",
)
static let identityTokenTwoFactorError = loadFromJsonBundle(resource: "IdentityTokenTwoFactorFailure")
static let preValidateSingleSignOn = loadFromJsonBundle(resource: "preValidateSingleSignOn")
static let identityTokenNewDeviceError = loadFromJsonBundle(resource: "IdentityTokenNewDeviceError")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2OTY5MDg4NzksInN1YiI6IjEzNTEyNDY3LTljZmUtNDNiMC05NjlmLTA3NTM0MDg0NzY0YiIsIm5hbWUiOiJCaXR3YXJkZW4gVXNlciIsImVtYWlsIjoidXNlckBiaXR3YXJkZW4uY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImlhdCI6MTUxNjIzOTAyMiwicHJlbWl1bSI6ZmFsc2UsImFtciI6WyJBcHBsaWNhdGlvbiJdfQ.KDqC8kUaOAgBiUY8eeLa0a4xYWN8GmheXTFXmataFwM",
"expires_in": 3600,
"token_type": "Bearer",
"refresh_token": "REFRESH_TOKEN",
"scope": "api offline_access",
"MasterPasswordPolicy": {
"MinComplexity": 1,
"MinLength": 22,
"RequireLower": true,
"RequireUpper": true,
"RequireNumbers": false,
"RequireSpecial": false,
"EnforceOnLogin": true,
"Object": "masterPasswordPolicy"
},
"ForcePasswordReset": false,
"Kdf": 0,
"KdfIterations": 600000,
"KdfMemory": null,
"KdfParallelism": null,
"UserDecryptionOptions": {
"HasMasterPassword": true,
"TrustedDeviceOption": {
"EncryptedPrivateKey": "private-key",
"EncryptedUserKey": "user-key",
"HasAdminApproval": false,
"HasLoginApprovingDevice": true,
"HasManageResetPasswordPermission": false
},
"Object": "userDecryptionOptions"
}
}
48 changes: 48 additions & 0 deletions BitwardenShared/Core/Auth/Services/AuthService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,18 @@ protocol AuthService {
///
func answerLoginRequest(_ request: LoginRequest, approve: Bool) async throws

/// Checks the supplied master password against any active organization Master Password
/// policy after the vault has been unlocked with it, setting `forcePasswordResetReason` if
/// the password does not satisfy the policy. Uses the policy captured during the most recent
/// SSO/TDE login for this email if one is cached, otherwise falls back to whatever policy
/// data is already available via `PolicyService`.
///
/// - Parameters:
/// - email: The email of the account that was unlocked.
/// - masterPassword: The master password used to unlock the vault.
///
func checkMasterPasswordPolicyAfterUnlock(email: String, masterPassword: String) async throws

/// Check the status of the pending login request for the unauthenticated user.
///
func checkPendingLoginRequest(withId id: String) async throws -> LoginRequest
Expand Down Expand Up @@ -355,6 +367,12 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng
/// the callback the `ASWebAuthenticationSession` is configured to match.
private var singleSignOnCallbackUrl: String { callbackUrl(for: .singleSignOn) }

/// The master password policy captured from the identity token response during an SSO/TDE
/// login, cached (keyed by email) until the user unlocks the vault with their master
/// password. Only consumed when the email matches the account being unlocked, to avoid a
/// stale policy from one account leaking into a later unlock for a different account.
private var ssoMasterPasswordPolicy: (email: String, policy: MasterPasswordPolicyResponseModel?)?

/// The service used by the application to manage account state.
private let stateService: StateService

Expand Down Expand Up @@ -456,6 +474,19 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng
_ = try await authAPIService.answerLoginRequest(loginRequest.id, requestModel: requestModel)
}

func checkMasterPasswordPolicyAfterUnlock(email: String, masterPassword: String) async throws {
let cached = ssoMasterPasswordPolicy
ssoMasterPasswordPolicy = nil
let policy = cached?.email == email ? cached?.policy : nil

try await checkMasterPasswordPolicies(
isPreAuth: false,
masterPassword: masterPassword,
masterPasswordPolicy: policy,
username: email,
)
}

func checkPendingLoginRequest(withId id: String) async throws -> LoginRequest {
guard let loginWithDeviceData else { throw AuthError.missingLoginWithDeviceData }

Expand Down Expand Up @@ -756,6 +787,12 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng
email: email,
)

// Cache the master password policy so it can be checked once the user unlocks the vault
// with their master password (covers both the direct master password unlock method and
// the TDE "approve with master password" flow, which is reached via the
// `AuthError.requireDecryptionOptions` throw below).
ssoMasterPasswordPolicy = (email, response.masterPasswordPolicy)

return try await unlockMethod(for: response)
}

Expand All @@ -779,6 +816,17 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng
// Get the identity token to log in to Bitwarden.
let response = try await getIdentityTokenResponse(email: email, request: twoFactorRequest)

// Cache the master password policy so it can be checked once the user unlocks the vault
// with their master password. Only applies to SSO/TDE logins that also required two-factor
// authentication, where the policy from the first (pre-2FA) identity token response was
// never captured because that attempt threw `twoFactorRequired` before reaching the
// caching in `loginWithSingleSignOn`. Plain password logins already have their policy
// checked above via `preAuthForcePasswordResetReason`, so caching it again here would
// just cause it to be redundantly re-checked once the vault is unlocked.
if case .authorizationCode = twoFactorRequest.authenticationMethod {
ssoMasterPasswordPolicy = (email, response.masterPasswordPolicy)
}

// If it's assigned then we need to update the required reset password and remove the cache.
if preAuthForcePasswordResetReason != nil {
try await stateService.setForcePasswordResetReason(.weakMasterPasswordOnLogin)
Expand Down
Loading
Loading