Skip to content

Commit 79968d3

Browse files
fedemkrKatherineInCodematt-livefront
authored
[PM-8352] Fido2 creation user verification (#736)
Co-authored-by: Katherine Bertelsen <[email protected]> Co-authored-by: Matt Czech <[email protected]>
1 parent 63ce1dc commit 79968d3

15 files changed

+633
-39
lines changed

BitwardenShared/Core/Auth/Repositories/AuthRepository.swift

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,11 @@ protocol AuthRepository: AnyObject {
230230
///
231231
func validatePassword(_ password: String) async throws -> Bool
232232

233+
/// Validates thes user's entered PIN.
234+
/// - Parameter pin: Pin to validate.
235+
/// - Returns: `true` if valid, `false` otherwise.
236+
func validatePin(pin: String) async -> Bool
237+
233238
/// Verifies that the entered one-time password matches the one sent to the user.
234239
///
235240
/// - Parameter otp: The user's one-time password to verify.
@@ -774,6 +779,35 @@ extension DefaultAuthRepository: AuthRepository {
774779
}
775780
}
776781

782+
func validatePin(pin: String) async -> Bool {
783+
guard let pinProtectedUserKey = try? await stateService.pinProtectedUserKey() else {
784+
return false
785+
}
786+
787+
// HACK: As the SDK doesn't provide a way to directly validate the pin yet, we have this method
788+
// which just tries to initialize the user crypto and if it succeeds then the PIN is correct, otherwise
789+
// the PIN is incorrect.
790+
791+
do {
792+
let account = try await stateService.getActiveAccount()
793+
let encryptionKeys = try await stateService.getAccountEncryptionKeys()
794+
795+
try await clientService.crypto().initializeUserCrypto(
796+
req: InitUserCryptoRequest(
797+
kdfParams: account.kdf.sdkKdf,
798+
email: account.profile.email,
799+
privateKey: encryptionKeys.encryptedPrivateKey,
800+
method: .pin(pin: pin, pinProtectedUserKey: pinProtectedUserKey)
801+
)
802+
)
803+
try await organizationService.initializeOrganizationCrypto()
804+
805+
return true
806+
} catch {
807+
return false
808+
}
809+
}
810+
777811
func verifyOtp(_ otp: String) async throws {
778812
try await accountAPIService.verifyOtp(otp)
779813
}

BitwardenShared/Core/Auth/Repositories/AuthRepositoryTests.swift

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1557,6 +1557,119 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
15571557
XCTAssertFalse(isValid)
15581558
}
15591559

1560+
/// `validatePin(_:)` returns `true` if the pin is valid when initializing the user crypto.
1561+
func test_validatePin() async throws {
1562+
let account = Account.fixture()
1563+
stateService.activeAccount = account
1564+
1565+
stateService.accountEncryptionKeys = [
1566+
"1": AccountEncryptionKeys(encryptedPrivateKey: "PRIVATE_KEY", encryptedUserKey: "USER_KEY"),
1567+
]
1568+
1569+
stateService.encryptedPinByUserId[account.profile.userId] = "123"
1570+
stateService.pinProtectedUserKeyValue[account.profile.userId] = "123"
1571+
1572+
let isPinValid = await subject.validatePin(pin: "123")
1573+
1574+
XCTAssertEqual(
1575+
clientService.mockCrypto.initializeUserCryptoRequest,
1576+
InitUserCryptoRequest(
1577+
kdfParams: .pbkdf2(iterations: UInt32(Constants.pbkdf2Iterations)),
1578+
1579+
privateKey: "PRIVATE_KEY",
1580+
method: .pin(pin: "123", pinProtectedUserKey: "123")
1581+
)
1582+
)
1583+
XCTAssertTrue(isPinValid)
1584+
}
1585+
1586+
/// `validatePin(_:)` returns `false` if the there is no active account.
1587+
func test_validatePin_noActiveAccount() async throws {
1588+
let account = Account.fixture()
1589+
1590+
stateService.accountEncryptionKeys = [
1591+
"1": AccountEncryptionKeys(encryptedPrivateKey: "PRIVATE_KEY", encryptedUserKey: "USER_KEY"),
1592+
]
1593+
1594+
stateService.encryptedPinByUserId[account.profile.userId] = "123"
1595+
1596+
let isPinValid = await subject.validatePin(pin: "123")
1597+
1598+
XCTAssertNotEqual(
1599+
clientService.mockCrypto.initializeUserCryptoRequest,
1600+
InitUserCryptoRequest(
1601+
kdfParams: .pbkdf2(iterations: UInt32(Constants.pbkdf2Iterations)),
1602+
1603+
privateKey: "PRIVATE_KEY",
1604+
method: .pin(pin: "123", pinProtectedUserKey: "123")
1605+
)
1606+
)
1607+
XCTAssertFalse(isPinValid)
1608+
}
1609+
1610+
/// `validatePin(_:)` returns `false` if the there is no pin protected user key.
1611+
func test_validatePin_noPinProtectedUserKey() async throws {
1612+
let account = Account.fixture()
1613+
stateService.activeAccount = account
1614+
1615+
stateService.accountEncryptionKeys = [
1616+
"1": AccountEncryptionKeys(encryptedPrivateKey: "PRIVATE_KEY", encryptedUserKey: "USER_KEY"),
1617+
]
1618+
1619+
stateService.encryptedPinByUserId[account.profile.userId] = "123"
1620+
1621+
let isPinValid = await subject.validatePin(pin: "123")
1622+
1623+
XCTAssertNotEqual(
1624+
clientService.mockCrypto.initializeUserCryptoRequest,
1625+
InitUserCryptoRequest(
1626+
kdfParams: .pbkdf2(iterations: UInt32(Constants.pbkdf2Iterations)),
1627+
1628+
privateKey: "PRIVATE_KEY",
1629+
method: .pin(pin: "123", pinProtectedUserKey: "123")
1630+
)
1631+
)
1632+
XCTAssertFalse(isPinValid)
1633+
}
1634+
1635+
/// `validatePin(_:)` returns `false` if initializing user crypto throws.
1636+
func test_validatePin_initializeUserCryptoThrows() async throws {
1637+
let account = Account.fixture()
1638+
stateService.activeAccount = account
1639+
1640+
stateService.accountEncryptionKeys = [
1641+
"1": AccountEncryptionKeys(encryptedPrivateKey: "PRIVATE_KEY", encryptedUserKey: "USER_KEY"),
1642+
]
1643+
1644+
stateService.encryptedPinByUserId[account.profile.userId] = "123"
1645+
stateService.pinProtectedUserKeyValue[account.profile.userId] = "123"
1646+
1647+
clientService.mockCrypto.initializeUserCryptoResult = .failure(BitwardenTestError.example)
1648+
1649+
let isPinValid = await subject.validatePin(pin: "123")
1650+
1651+
XCTAssertFalse(isPinValid)
1652+
}
1653+
1654+
/// `validatePin(_:)` returns `false` if initializing org crypto throws.
1655+
func test_validatePin_initializeOrgCryptoThrows() async throws {
1656+
let account = Account.fixture()
1657+
stateService.activeAccount = account
1658+
1659+
stateService.accountEncryptionKeys = [
1660+
"1": AccountEncryptionKeys(encryptedPrivateKey: "PRIVATE_KEY", encryptedUserKey: "USER_KEY"),
1661+
]
1662+
1663+
stateService.encryptedPinByUserId[account.profile.userId] = "123"
1664+
stateService.pinProtectedUserKeyValue[account.profile.userId] = "123"
1665+
1666+
organizationService.initializeOrganizationCryptoError = BitwardenTestError.example
1667+
1668+
let isPinValid = await subject.validatePin(pin: "123")
1669+
1670+
XCTAssertFalse(isPinValid)
1671+
}
1672+
15601673
/// `verifyOtp(_:)` makes an API request to verify an OTP code.
15611674
func test_verifyOtp() async throws {
15621675
client.result = .httpSuccess(testData: .emptyResponse)

BitwardenShared/Core/Auth/Repositories/TestHelpers/MockAuthRepository.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ class MockAuthRepository: AuthRepository { // swiftlint:disable:this type_body_l
7676
var validatePasswordPasswords = [String]()
7777
var validatePasswordResult: Result<Bool, Error> = .success(true)
7878

79+
var validatePinResult: Bool = true
80+
7981
var vaultTimeout = [String: SessionTimeoutValue]()
8082

8183
func allowBioMetricUnlock(_ enabled: Bool) async throws {
@@ -303,6 +305,10 @@ class MockAuthRepository: AuthRepository { // swiftlint:disable:this type_body_l
303305
return try validatePasswordResult.get()
304306
}
305307

308+
func validatePin(pin: String) async -> Bool {
309+
validatePinResult
310+
}
311+
306312
func verifyOtp(_ otp: String) async throws {
307313
verifyOtpOpt = otp
308314
try verifyOtpResult.get()

BitwardenShared/UI/Auth/Utilities/UserVerificationHelper.swift

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,18 @@ extension DefaultUserVerificationHelper: UserVerificationHelper {
133133
continuation.resume(throwing: UserVerificationError.cancelled)
134134
},
135135
settingUp: false,
136-
completion: { _ in
137-
// TODO: PM-8388 Perform PIN verification when method available from SDK
138-
continuation.resume(returning: .notVerified)
136+
completion: { pin in
137+
guard await self.authRepository.validatePin(pin: pin) else {
138+
self.userVerificationDelegate?.showAlert(
139+
.defaultAlert(title: Localizations.invalidPIN),
140+
onDismissed: {
141+
continuation.resume(returning: .notVerified)
142+
}
143+
)
144+
return
145+
}
146+
147+
continuation.resume(returning: .verified)
139148
}
140149
)
141150

BitwardenShared/UI/Auth/Utilities/UserVerificationHelperTests.swift

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -219,26 +219,102 @@ class UserVerificationHelperTests: BitwardenTestCase {
219219
}
220220

221221
/// `verifyPin()` unable to perform when auth repository pin unlock is not available.
222-
func test_verifyPin_unableToPerformR3c0rdables() async throws {
222+
func test_verifyPin_unableToPerform() async throws {
223223
authRepository.isPinUnlockAvailableResult = .success(false)
224224

225225
let result = try await subject.verifyPin()
226226

227227
XCTAssertEqual(result, .unableToPerform)
228228
}
229229

230-
// TODO: PM-8388 Add more tests for `verifyPin`
230+
/// `verifyPin()` with cancelled verification.
231+
func test_verifyPin_cancelled() async throws {
232+
authRepository.isPinUnlockAvailableResult = .success(true)
233+
let task = Task {
234+
try await self.subject.verifyPin()
235+
}
236+
237+
try await waitForAsync {
238+
!self.userVerificationDelegate.alertShown.isEmpty
239+
}
240+
241+
let alert = try XCTUnwrap(userVerificationDelegate.alertShown.last)
242+
try await alert.tapAction(title: Localizations.cancel)
243+
244+
await assertAsyncThrows(error: UserVerificationError.cancelled) {
245+
_ = try await task.value
246+
}
247+
}
248+
249+
/// `verifyPin()` with verified PIN.
250+
func test_verifyPin_verified() async throws {
251+
authRepository.isPinUnlockAvailableResult = .success(true)
252+
authRepository.validatePinResult = true
253+
254+
let task = Task {
255+
try await self.subject.verifyPin()
256+
}
257+
258+
try await waitForAsync {
259+
!self.userVerificationDelegate.alertShown.isEmpty
260+
}
261+
262+
try await enterPinInAlertAndSubmit()
263+
264+
let result = try await task.value
265+
266+
XCTAssertEqual(result, .verified)
267+
}
268+
269+
/// `verifyPin()` with not verified PIN.
270+
func test_verifyPin_notVerified() async throws {
271+
authRepository.isPinUnlockAvailableResult = .success(true)
272+
authRepository.validatePinResult = false
273+
274+
let task = Task {
275+
try await self.subject.verifyPin()
276+
}
277+
278+
try await waitForAsync {
279+
!self.userVerificationDelegate.alertShown.isEmpty
280+
}
281+
282+
try await enterPinInAlertAndSubmit()
283+
284+
try await waitForAsync {
285+
self.userVerificationDelegate.alertShown
286+
.last?.title == Localizations.invalidPIN
287+
}
288+
289+
let alert = try XCTUnwrap(userVerificationDelegate.alertShown.last)
290+
291+
XCTAssertEqual(alert, .defaultAlert(title: Localizations.invalidPIN))
292+
293+
try await alert.tapAction(title: Localizations.ok)
294+
295+
userVerificationDelegate.alertOnDismissed?()
296+
297+
let result = try await task.value
298+
299+
XCTAssertEqual(result, .notVerified)
300+
}
231301

232302
// MARK: Private
233303

234304
private func enterMasterPasswordInAlertAndSubmit() async throws {
235305
let alert = try XCTUnwrap(userVerificationDelegate.alertShown.last)
236-
237306
XCTAssertEqual(alert, .masterPasswordPrompt { _ in })
238-
var textField = try XCTUnwrap(alert.alertTextFields.first)
239-
textField = AlertTextField(id: "password", text: "password")
240307

241-
try await alert.tapAction(title: Localizations.submit, alertTextFields: [textField])
308+
try alert.setText("password", forTextFieldWithId: "password")
309+
try await alert.tapAction(title: Localizations.submit)
310+
}
311+
312+
private func enterPinInAlertAndSubmit() async throws {
313+
let alert = try XCTUnwrap(userVerificationDelegate.alertShown.last)
314+
XCTAssertEqual(alert, .enterPINCode(settingUp: false) { _ in })
315+
316+
try alert.setText("pin", forTextFieldWithId: "pin")
317+
try await alert.tapAction(title: Localizations.submit)
242318
}
243319
}
244320

0 commit comments

Comments
 (0)