Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import BitwardenSdkMocks

public extension MockUserCryptoManagementClientService {
/// The `MockPinSettingsClientProtocol` wired to `pinSettingsReturnValue`.
var mockPinSettings: MockPinSettingsClientProtocol {
guard let mock = pinSettingsReturnValue as? MockPinSettingsClientProtocol else {
preconditionFailure(
"pinSettingsReturnValue is not a MockPinSettingsClientProtocol. "
+ "Use MockUserCryptoManagementClientService.withMocks() to ensure it is wired correctly.",
)
}
return mock
}

/// Creates a `MockUserCryptoManagementClientService` with nested mocks pre-wired as return values.
///
/// - Parameter pinSettings: The mock to return from `pinSettings()`. Defaults to a new
/// `MockPinSettingsClientProtocol`.
///
static func withMocks(
pinSettings: MockPinSettingsClientProtocol = MockPinSettingsClientProtocol(),
) -> MockUserCryptoManagementClientService {
let mock = MockUserCryptoManagementClientService()
mock.pinSettingsReturnValue = pinSettings
return mock
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import BitwardenSdk
import Foundation

/// A protocol for a service that handles SDK-managed user crypto state tasks. This is similar to
/// `UserCryptoManagementClientProtocol` but returns protocols so they can be mocked for testing.
///
public protocol UserCryptoManagementClientService: AnyObject, Sendable { // sourcery: AutoMockable
/// Changes the user's KDF settings.
///
func changeKdf(password: String, newKdf: Kdf) async throws

/// Returns the PIN settings sub-client.
///
func pinSettings() -> PinSettingsClientProtocol

/// Backfills the server with the id of the user's current user key, if missing.
///
func userKeyIdBackfill() async throws
}

// MARK: UserCryptoManagementClient

extension UserCryptoManagementClient: UserCryptoManagementClientService {
public func pinSettings() -> PinSettingsClientProtocol {
pinSettings() as PinSettingsClient
}
}
1 change: 1 addition & 0 deletions BitwardenSdkMocks/BitwardenSdkMocks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ extension Fido2CredentialStore {}
extension FoldersClientProtocol {}
extension GeneratorClientsProtocol {}
extension PasswordHistoryClientProtocol {}
extension PinSettingsClientProtocol {}
extension PoliciesClientProtocol {}
extension RegistrationClientProtocol {}
extension SendClientProtocol {}
Expand Down
74 changes: 47 additions & 27 deletions BitwardenShared/Core/Auth/Repositories/AuthRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -746,7 +746,12 @@ extension DefaultAuthRepository: AuthRepository {
}

func clearPins() async throws {
try await stateService.clearPins()
guard await configService.getFeatureFlag(.sdkManagedPinUnlock) else {
try await stateService.clearPins()
return
}

try await clientService.userCryptoManagement().pinSettings().unsetPin()
}

func deleteAccount(otp: String?, passwordText: String?) async throws {
Expand Down Expand Up @@ -1041,11 +1046,17 @@ extension DefaultAuthRepository: AuthRepository {
}

func setPins(_ pin: String, requirePasswordAfterRestart: Bool) async throws {
let enrollPinResponse = try await clientService.crypto().enrollPin(pin: pin)
try await stateService.setPinKeys(
enrollPinResponse: enrollPinResponse,
requirePasswordAfterRestart: requirePasswordAfterRestart,
)
guard await configService.getFeatureFlag(.sdkManagedPinUnlock) else {
let enrollPinResponse = try await clientService.crypto().enrollPin(pin: pin)
try await stateService.setPinKeys(
enrollPinResponse: enrollPinResponse,
requirePasswordAfterRestart: requirePasswordAfterRestart,
)
return
}

let lockType: PinLockType = requirePasswordAfterRestart ? .afterFirstUnlock : .beforeFirstUnlock
try await clientService.userCryptoManagement().pinSettings().setPin(pin: pin, lockType: lockType)
}

func setLastActiveAccountTime() async throws {
Expand Down Expand Up @@ -1161,22 +1172,27 @@ extension DefaultAuthRepository: AuthRepository {
}

func unlockVaultWithPIN(pin: String) async throws {
if let pinProtectedUserKeyEnvelope = try await stateService.pinProtectedUserKeyEnvelope() {
try await unlockVault(
method: .pinEnvelope(
pin: pin,
pinProtectedUserKeyEnvelope: pinProtectedUserKeyEnvelope,
),
)
} else {
// This is needed to support unlocking with a legacy pin protected user key. Once the
// vault is unlocked, the user's pin protected user key is migrated to a pin protected
// user key envelope.
guard let pinProtectedUserKey = try await stateService.pinProtectedUserKey() else {
throw StateServiceError.noPinProtectedUserKey
guard await configService.getFeatureFlag(.sdkManagedPinUnlock) else {
if let pinProtectedUserKeyEnvelope = try await stateService.pinProtectedUserKeyEnvelope() {
try await unlockVault(
method: .pinEnvelope(
pin: pin,
pinProtectedUserKeyEnvelope: pinProtectedUserKeyEnvelope,
),
)
} else {
// This is needed to support unlocking with a legacy pin protected user key. Once the
// vault is unlocked, the user's pin protected user key is migrated to a pin protected
// user key envelope.
guard let pinProtectedUserKey = try await stateService.pinProtectedUserKey() else {
throw StateServiceError.noPinProtectedUserKey
}
try await unlockVault(method: .pin(pin: pin, pinProtectedUserKey: pinProtectedUserKey))
}
try await unlockVault(method: .pin(pin: pin, pinProtectedUserKey: pinProtectedUserKey))
return
}

try await unlockVault(method: .pinState(pin: pin))
}

func validatePassword(_ password: String) async throws -> Bool {
Expand Down Expand Up @@ -1204,14 +1220,18 @@ extension DefaultAuthRepository: AuthRepository {
}

func validatePin(pin: String) async throws -> Bool {
guard let pinProtectedUserKeyEnvelope = try await stateService.pinProtectedUserKeyEnvelope() else {
return false
guard await configService.getFeatureFlag(.sdkManagedPinUnlock) else {
guard let pinProtectedUserKeyEnvelope = try await stateService.pinProtectedUserKeyEnvelope() else {
return false
}

return try await clientService.auth().validatePinProtectedUserKeyEnvelope(
pin: pin,
pinProtectedUserKeyEnvelope: pinProtectedUserKeyEnvelope,
)
}

return try await clientService.auth().validatePinProtectedUserKeyEnvelope(
pin: pin,
pinProtectedUserKeyEnvelope: pinProtectedUserKeyEnvelope,
)
return try await clientService.userCryptoManagement().pinSettings().validatePin(pin: pin)
}

func verifyOtp(_ otp: String) async throws {
Expand Down Expand Up @@ -1452,7 +1472,7 @@ extension DefaultAuthRepository: AuthRepository {
// Note: We handle all errors broadly here because the SDK doesn't provide specific
// error types to distinguish key rotation failures from other errors. Clearing the
// PIN keys on any error is the safest approach to maintain data consistency.
try await stateService.clearPins()
try await clearPins()
// Return `nil` instead of throwing to avoid erroring out of the unlock process.
return nil
}
Expand Down
108 changes: 106 additions & 2 deletions BitwardenShared/Core/Auth/Repositories/AuthRepositoryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,8 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
XCTAssertFalse(result)
}

/// `.clearPins()` clears the user's pins.
/// `.clearPins()` clears the user's legacy pins when the `sdkManagedPinUnlock` feature flag
/// is disabled.
func test_clearPins() async throws {
stateService.activeAccount = Account.fixture()
let userId = Account.fixture().profile.userId
Expand All @@ -262,6 +263,21 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
XCTAssertNil(stateService.pinProtectedUserKeyValue[userId])
XCTAssertNil(stateService.encryptedPinByUserId[userId])
XCTAssertNil(stateService.accountVolatileData[userId]?.pinProtectedUserKey)
XCTAssertFalse(clientService.mockUserCryptoManagement.mockPinSettings.unsetPinCalled)
}

/// `.clearPins()` clears the SDK-managed PIN state when the `sdkManagedPinUnlock` feature
/// flag is enabled, leaving legacy PIN state untouched.
func test_clearPins_sdkManagedPinUnlock() async throws {
configService.featureFlagsBool[.sdkManagedPinUnlock] = true
stateService.activeAccount = Account.fixture()
let userId = Account.fixture().profile.userId
stateService.pinProtectedUserKeyValue[userId] = "123"

try await subject.clearPins()

XCTAssertTrue(clientService.mockUserCryptoManagement.mockPinSettings.unsetPinCalled)
XCTAssertEqual(stateService.pinProtectedUserKeyValue[userId], "123")
}

/// `convertNewUserToKeyConnector()` converts a new user to key connector and unlocks the vault.
Expand Down Expand Up @@ -2302,7 +2318,8 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
}
}

/// `setPins(_:requirePasswordAfterRestart:)` sets the user's pins.
/// `setPins(_:requirePasswordAfterRestart:)` sets the user's legacy pins when the
/// `sdkManagedPinUnlock` feature flag is disabled.
func test_setPins() async throws {
let account = Account.fixture()
stateService.activeAccount = account
Expand All @@ -2321,6 +2338,7 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
XCTAssertEqual(stateService.encryptedPinByUserId[userId], "userKeyEncryptedPin")
XCTAssertEqual(stateService.pinProtectedUserKeyEnvelopeValue[userId], "pinProtectedUserKeyEnvelope")
XCTAssertNil(stateService.pinProtectedUserKeyValue[userId])
XCTAssertFalse(clientService.mockUserCryptoManagement.mockPinSettings.setPinCalled)
}

/// `setPins(_:requirePasswordAfterRestart:)` throws an error if one occurs.
Expand All @@ -2332,6 +2350,47 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
}
}

/// `setPins(_:requirePasswordAfterRestart:)` sets the SDK-managed PIN with a
/// `.afterFirstUnlock` lock type when the `sdkManagedPinUnlock` feature flag is enabled and
/// master password is required after restart, without touching legacy PIN state.
func test_setPins_sdkManagedPinUnlock() async throws {
configService.featureFlagsBool[.sdkManagedPinUnlock] = true
let account = Account.fixture()
stateService.activeAccount = account

try await subject.setPins("123", requirePasswordAfterRestart: true)

let setPinArguments = clientService.mockUserCryptoManagement.mockPinSettings.setPinReceivedArguments
XCTAssertEqual(setPinArguments?.pin, "123")
XCTAssertEqual(setPinArguments?.lockType, .afterFirstUnlock)
XCTAssertFalse(clientService.mockCrypto.enrollPinCalled)
XCTAssertNil(stateService.pinProtectedUserKeyEnvelopeValue[account.profile.userId])
}

/// `setPins(_:requirePasswordAfterRestart:)` sets the SDK-managed PIN lock type to
/// `.beforeFirstUnlock` when master password isn't required after restart.
func test_setPins_sdkManagedPinUnlock_beforeFirstUnlock() async throws {
configService.featureFlagsBool[.sdkManagedPinUnlock] = true
stateService.activeAccount = .fixture()

try await subject.setPins("123", requirePasswordAfterRestart: false)

let setPinArguments = clientService.mockUserCryptoManagement.mockPinSettings.setPinReceivedArguments
XCTAssertEqual(setPinArguments?.pin, "123")
XCTAssertEqual(setPinArguments?.lockType, .beforeFirstUnlock)
}

/// `setPins(_:requirePasswordAfterRestart:)` throws an error from the SDK-managed PIN
/// enrollment call when the `sdkManagedPinUnlock` feature flag is enabled.
func test_setPins_sdkManagedPinUnlock_error() async throws {
configService.featureFlagsBool[.sdkManagedPinUnlock] = true
clientService.mockUserCryptoManagement.mockPinSettings.setPinThrowableError = BitwardenTestError.example

await assertAsyncThrows(error: BitwardenTestError.example) {
try await subject.setPins("123", requirePasswordAfterRestart: true)
}
}

/// `.shouldPerformMasterPasswordReprompt(reprompt:)` when reprompt password
/// and master password hash exists.
func test_shouldPerformMasterPasswordReprompt_true() async throws {
Expand Down Expand Up @@ -3325,6 +3384,37 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
XCTAssertEqual(stateService.manuallyLockedAccounts["1"], false)
}

/// `unlockVaultWithPIN(_:)` unlocks the vault via the SDK-managed PIN state when the
/// `sdkManagedPinUnlock` feature flag is enabled.
func test_unlockVaultWithPIN_sdkManagedPinUnlock() async throws {
configService.featureFlagsBool[.sdkManagedPinUnlock] = true
let account = Account.fixture()
stateService.activeAccount = account
stateService.accountEncryptionKeys = [
"1": AccountEncryptionKeys(
cryptographicState: .fixtureV2(),
encryptedUserKey: "USER_KEY",
),
]

try await subject.unlockVaultWithPIN(pin: "123")

XCTAssertEqual(
clientService.mockCrypto.initializeUserCryptoReceivedReq,
InitUserCryptoRequest(
userId: "1",
kdfParams: .pbkdf2(iterations: UInt32(Constants.pbkdf2Iterations)),
email: "user@bitwarden.com",
accountCryptographicState: .fixtureV2(),
method: .pinState(pin: "123"),
upgradeToken: nil,
),
)
XCTAssertFalse(vaultTimeoutService.isLocked(userId: "1"))
XCTAssertTrue(vaultTimeoutService.unlockVaultHadUserInteraction)
XCTAssertEqual(stateService.manuallyLockedAccounts["1"], false)
}

/// `unlockVaultWithPassword` restores the biometric key after a successful unlock when biometrics is enabled.
func test_unlockVaultWithPassword_restoresBiometricKeyWhenEnabled() async throws {
let account = Account.fixture(profile: .fixture(
Expand Down Expand Up @@ -3608,6 +3698,20 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
XCTAssertFalse(isPinValid)
}

/// `validatePin(_:)` validates via the SDK-managed PIN settings client when the
/// `sdkManagedPinUnlock` feature flag is enabled.
func test_validatePin_sdkManagedPinUnlock() async throws {
configService.featureFlagsBool[.sdkManagedPinUnlock] = true
stateService.activeAccount = .fixture()
clientService.mockUserCryptoManagement.mockPinSettings.validatePinReturnValue = true

let isPinValid = try await subject.validatePin(pin: "123")

XCTAssertTrue(isPinValid)
XCTAssertEqual(clientService.mockUserCryptoManagement.mockPinSettings.validatePinReceivedPin, "123")
XCTAssertFalse(clientService.mockAuth.validatePinProtectedUserKeyEnvelopeCalled)
}

/// `validatePin(_:)` throws if the there is no active account.
func test_validatePin_noActiveAccount() async throws {
await assertAsyncThrows(error: StateServiceError.noActiveAccount) {
Expand Down
8 changes: 8 additions & 0 deletions BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ extension FeatureFlag: @retroactive CaseIterable {
/// Flag to enable/disable Premium upgrade path.
static let premiumUpgradePath = FeatureFlag(rawValue: "pm-31697-premium-upgrade-path")

/// Flag to enable/disable SDK-managed PIN unlock.
///
/// When enabled, PIN unlock, enrollment, and validation route through the Bitwarden SDK's
/// client-managed PIN state (`InitUserCryptoMethod.pinState`, `PinSettingsClient`) instead
/// of the legacy iOS-managed PIN-protected key envelope.
static let sdkManagedPinUnlock = FeatureFlag(rawValue: "pm-31059-sdk-pin-unlock")

/// Flag to enable/disable the Send Controls policy, which supersedes the `disableSend` and
/// `sendOptions` policies when active.
static let sendControls = FeatureFlag(rawValue: "pm-31885-send-controls")
Expand All @@ -91,6 +98,7 @@ extension FeatureFlag: @retroactive CaseIterable {
.organizationUserNotificationBanner,
.policiesInAcceptedState,
.premiumUpgradePath,
.sdkManagedPinUnlock,
.sendControls,
.vfo1Foundation,
]
Expand Down
Loading
Loading