Skip to content

Commit a3499ba

Browse files
committed
Add keychain item for biometric registration id
1 parent 7eaec64 commit a3499ba

File tree

6 files changed

+109
-51
lines changed

6 files changed

+109
-51
lines changed

Sources/StytchCore/KeychainClient/KeychainClient+Item.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,10 @@ extension KeychainClient {
5959
}
6060

6161
extension KeychainClient.Item {
62+
// The private key registration is central to biometric authentication, and this item should be protected by biometrics unless explicitly specified otherwise by the caller.
6263
static let privateKeyRegistration: Self = .init(kind: .privateKey, name: "stytch_private_key_registration")
64+
// This was introduced in version 0.54.0 to store the biometric registration ID in a keychain item that is not protected by biometrics.
65+
static let biometricKeyRegistration: Self = .init(kind: .object, name: "stytch_biometric_key_registration")
6366

6467
static let sessionToken: Self = .init(kind: .token, name: SessionToken.Kind.opaque.name)
6568
static let sessionJwt: Self = .init(kind: .token, name: SessionToken.Kind.jwt.name)

Sources/StytchCore/Networking/NetworkingRouter.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,9 @@ public extension NetworkingRouter {
139139
hostUrl: configuration.hostUrl
140140
)
141141
userStorage.update(sessionResponse.user)
142+
#if !os(tvOS) && !os(watchOS)
142143
StytchClient.biometrics.cleanupPotentiallyOrphanedBiometricRegistrations()
144+
#endif
143145
} else if let sessionResponse = dataContainer.data as? B2BAuthenticateResponseType {
144146
sessionManager.updateSession(
145147
sessionType: .member(sessionResponse.memberSession),

Sources/StytchCore/StytchClient/Biometrics/StytchClient+Biometrics.swift

Lines changed: 66 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ public extension StytchClient.Biometrics {
99
case availableRegistered
1010
}
1111
}
12+
13+
public extension StytchClient {
14+
/// The interface for interacting with biometrics products.
15+
static var biometrics: Biometrics { .init(router: router.scopedRouter { $0.biometrics }) }
16+
}
1217
#endif
1318

1419
public extension StytchClient {
@@ -29,39 +34,39 @@ public extension StytchClient {
2934

3035
/// Indicates if there is an existing biometric registration on device.
3136
public var registrationAvailable: Bool {
32-
keychainClient.valueExistsForItem(.privateKeyRegistration)
37+
keychainClient.valueExistsForItem(.biometricKeyRegistration)
3338
}
34-
39+
3540
#if !os(tvOS) && !os(watchOS)
3641
/// Indicates if biometrics are available
3742
public var availability: Availability {
38-
let context = LAContext()
39-
var error: NSError?
40-
switch (context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error), registrationAvailable) {
41-
case (false, _):
42-
return .systemUnavailable((error as? LAError)?.code)
43-
case (true, false):
44-
return .availableNoRegistration
45-
case (true, true):
46-
return .availableRegistered
47-
}
48-
}
49-
#endif
50-
43+
let context = LAContext()
44+
var error: NSError?
45+
switch (context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error), registrationAvailable) {
46+
case (false, _):
47+
return .systemUnavailable((error as? LAError)?.code)
48+
case (true, false):
49+
return .availableNoRegistration
50+
case (true, true):
51+
return .availableRegistered
52+
}
53+
}
54+
#endif
55+
5156
// sourcery: AsyncVariants, (NOTE: - must use /// doc comment styling)
5257
/// Removes the current device's existing biometric registration from both the device itself and from the server.
5358
public func removeRegistration() async throws {
54-
guard let queryResult: KeychainClient.QueryResult = try? keychainClient.get(.privateKeyRegistration).first else {
59+
guard let queryResult = try? keychainClient.get(.biometricKeyRegistration).first else {
5560
return
5661
}
5762

5863
// Delete registration from backend
59-
if let registration = try queryResult.generic.map({ try jsonDecoder.decode(KeychainClient.KeyRegistration.self, from: $0) }) {
60-
_ = try await StytchClient.user.deleteFactor(.biometricRegistration(id: registration.registrationId))
64+
if let biometricRegistrationId = queryResult.stringValue {
65+
_ = try await StytchClient.user.deleteFactor(.biometricRegistration(id: User.BiometricRegistration.ID(stringLiteral: biometricRegistrationId)))
6166
}
6267

63-
// Remove local registration
64-
try keychainClient.removeItem(.privateKeyRegistration)
68+
// Remove local registrations
69+
try clearBiometricRegistrations()
6570
}
6671

6772
// sourcery: AsyncVariants, (NOTE: - must use /// doc comment styling)
@@ -104,23 +109,29 @@ public extension StytchClient {
104109
registrationId: finishResponse.biometricRegistrationId
105110
)
106111

112+
// Set the .privateKeyRegistration
107113
try keychainClient.set(
108114
key: privateKey,
109115
registration: registration,
110116
accessPolicy: parameters.accessPolicy.keychainValue
111117
)
112118

119+
// Set the .biometricKeyRegistration
120+
try keychainClient.set(registration.registrationId.rawValue, for: .biometricKeyRegistration)
121+
113122
return finishResponse
114123
}
115124

116125
// sourcery: AsyncVariants, (NOTE: - must use /// doc comment styling)
117126
/// If a valid biometric registration exists, this method confirms the current device owner via the device's built-in biometric reader and returns an updated session object by either starting a new session or adding a the biometric factor to an existing session.
118127
public func authenticate(parameters: AuthenticateParameters) async throws -> AuthenticateResponse {
119-
guard let queryResult: KeychainClient.QueryResult = try keychainClient.get(.privateKeyRegistration).first else {
128+
guard let privateKeyRegistrationQueryResult: KeychainClient.QueryResult = try keychainClient.get(.privateKeyRegistration).first else {
120129
throw StytchSDKError.noBiometricRegistration
121130
}
122131

123-
let privateKey = queryResult.data
132+
try copyBiometricRegistrationIDToKeychainIfNeeded(privateKeyRegistrationQueryResult)
133+
134+
let privateKey = privateKeyRegistrationQueryResult.data
124135
let publicKey = try cryptoClient.publicKeyForPrivateKey(privateKey)
125136

126137
let startResponse: AuthenticateStartResponse = try await router.post(
@@ -143,45 +154,53 @@ public extension StytchClient {
143154
return authenticateResponse
144155
}
145156

157+
// Clear both the .privateKeyRegistration and the .biometricKeyRegistration
158+
func clearBiometricRegistrations() throws {
159+
try keychainClient.removeItem(.privateKeyRegistration)
160+
try keychainClient.removeItem(.biometricKeyRegistration)
161+
}
162+
163+
// if we have a local biometric registration that doesn't exist on the user object, delete the local
146164
func cleanupPotentiallyOrphanedBiometricRegistrations() {
147165
guard let user = StytchClient.user.getSync() else {
148166
return
149167
}
150168

151-
// if we have a local biometric registration that doesn't exist on the user object, delete the local
152169
if user.biometricRegistrations.isEmpty {
153-
try? keychainClient.removeItem(.privateKeyRegistration)
154-
} else if !user.biometricRegistrations.isEmpty {
155-
let queryResult = try? keychainClient.get(.privateKeyRegistration).first
156-
cleanupBiometricRegistrationIfOrphaned(queryResult: queryResult, user: user)
170+
try? clearBiometricRegistrations()
171+
} else {
172+
let queryResult = try? keychainClient.get(.biometricKeyRegistration).first
173+
174+
// Check if the user's biometric registrations contain the ID
175+
var userBiometricRegistrationIds = [String]()
176+
for biometricRegistration in user.biometricRegistrations {
177+
userBiometricRegistrationIds.append(biometricRegistration.biometricRegistrationId.rawValue)
178+
}
179+
180+
if let biometricRegistrationId = queryResult?.stringValue, userBiometricRegistrationIds.contains(biometricRegistrationId) == false {
181+
// Remove the orphaned biometric registration
182+
try? clearBiometricRegistrations()
183+
}
157184
}
158185
}
159186

160-
private func cleanupBiometricRegistrationIfOrphaned(
161-
queryResult: KeychainClient.QueryResult?,
162-
user: User
163-
) {
164-
// Decode the biometric registration ID from the query result
165-
let biometricRegistrationId = try? queryResult?.generic.map {
166-
try jsonDecoder.decode(KeychainClient.KeyRegistration.self, from: $0)
167-
}
168-
169-
// Check if the user's biometric registrations contain the ID
170-
if user.biometricRegistrations.map(\.id).contains(biometricRegistrationId?.registrationId) == false {
171-
// Remove the orphaned biometric registration
172-
try? keychainClient.removeItem(.privateKeyRegistration)
187+
/*
188+
After introducing the .biometricKeyRegistration keychain item in version 0.54.0, we needed a way for versions prior to 0.54.0
189+
to copy the value stored in the biometrically protected .privateKeyRegistration keychain item into the non-biometric
190+
.biometricKeyRegistration keychain item without triggering unnecessary Face ID prompts. Since a Face ID prompt is already
191+
being shown here for authentication, we decided to use this as an opportunity to perform the migration by copying the
192+
registration ID into the .biometricKeyRegistration keychain item. For versions after 0.54.0, this action occurs during
193+
registration, and it should only happen here if the .biometricKeyRegistration keychain item is empty.
194+
*/
195+
func copyBiometricRegistrationIDToKeychainIfNeeded(_ queryResult: KeychainClient.QueryResult) throws {
196+
let biometricKeyRegistration = try? keychainClient.get(.biometricKeyRegistration).first
197+
if biometricKeyRegistration == nil, let registration = try queryResult.generic.map({ try jsonDecoder.decode(KeychainClient.KeyRegistration.self, from: $0) }) {
198+
try keychainClient.set(registration.registrationId.rawValue, for: .biometricKeyRegistration)
173199
}
174200
}
175201
}
176202
}
177203

178-
#if !os(tvOS) && !os(watchOS)
179-
public extension StytchClient {
180-
/// The interface for interacting with biometrics products.
181-
static var biometrics: Biometrics { .init(router: router.scopedRouter { $0.biometrics }) }
182-
}
183-
#endif
184-
185204
public extension StytchClient.Biometrics {
186205
typealias RegisterCompleteResponse = Response<RegisterCompleteResponseData>
187206

Stytch/DemoApps/StytchBiometrics/Base.lproj/Main.storyboard

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
</connections>
4242
</button>
4343
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="o5j-Hf-UlQ">
44-
<rect key="frame" x="71.666666666666686" y="373" width="250" height="44"/>
44+
<rect key="frame" x="71.666666666666686" y="433" width="250" height="44"/>
4545
<constraints>
4646
<constraint firstAttribute="width" constant="250" id="hIN-Ow-4mf"/>
4747
<constraint firstAttribute="height" constant="44" id="nbd-GJ-kdA"/>
@@ -64,15 +64,29 @@
6464
<action selector="sendAndAuthenticateOTPTapped:" destination="BYZ-38-t0r" eventType="touchUpInside" id="aYn-xv-OWJ"/>
6565
</connections>
6666
</button>
67+
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="NSS-yR-4kA">
68+
<rect key="frame" x="71.666666666666686" y="373" width="250" height="44"/>
69+
<constraints>
70+
<constraint firstAttribute="width" constant="250" id="AYF-4d-BBS"/>
71+
<constraint firstAttribute="height" constant="44" id="duf-p7-MbL"/>
72+
</constraints>
73+
<state key="normal" title="Button"/>
74+
<buttonConfiguration key="configuration" style="filled" title="Delete Registrations"/>
75+
<connections>
76+
<action selector="deleteRegistrationsTapped:" destination="BYZ-38-t0r" eventType="touchUpInside" id="HjC-Qh-k2i"/>
77+
</connections>
78+
</button>
6779
</subviews>
6880
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
6981
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
7082
<constraints>
83+
<constraint firstItem="o5j-Hf-UlQ" firstAttribute="top" secondItem="NSS-yR-4kA" secondAttribute="bottom" constant="16" id="H9h-yE-V0K"/>
84+
<constraint firstItem="NSS-yR-4kA" firstAttribute="centerX" secondItem="8bC-Xf-vdC" secondAttribute="centerX" id="KE2-75-zfD"/>
7185
<constraint firstItem="Z8n-4O-9aK" firstAttribute="centerX" secondItem="8bC-Xf-vdC" secondAttribute="centerX" id="TpE-1F-BQX"/>
7286
<constraint firstItem="Y0i-2o-kvb" firstAttribute="centerX" secondItem="8bC-Xf-vdC" secondAttribute="centerX" id="Xtd-4b-HJC"/>
87+
<constraint firstItem="NSS-yR-4kA" firstAttribute="top" secondItem="Z8n-4O-9aK" secondAttribute="bottom" constant="16" id="YiG-Oq-E3S"/>
7388
<constraint firstItem="2xr-u3-24A" firstAttribute="centerX" secondItem="8bC-Xf-vdC" secondAttribute="centerX" id="bu9-mF-yLw"/>
7489
<constraint firstItem="Y0i-2o-kvb" firstAttribute="top" secondItem="2xr-u3-24A" secondAttribute="bottom" constant="50" id="ezs-TN-mbV"/>
75-
<constraint firstItem="o5j-Hf-UlQ" firstAttribute="top" secondItem="Z8n-4O-9aK" secondAttribute="bottom" constant="16" id="gcR-o5-cWl"/>
7690
<constraint firstItem="2xr-u3-24A" firstAttribute="top" secondItem="6Tk-OE-BBY" secondAttribute="top" constant="100" id="hWt-qx-X3u"/>
7791
<constraint firstItem="Z8n-4O-9aK" firstAttribute="top" secondItem="Y0i-2o-kvb" secondAttribute="bottom" constant="16" id="kLx-k0-BQh"/>
7892
<constraint firstItem="o5j-Hf-UlQ" firstAttribute="centerX" secondItem="8bC-Xf-vdC" secondAttribute="centerX" id="y8Z-rQ-8cR"/>

Stytch/DemoApps/StytchBiometrics/ViewController.swift

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,23 @@ class ViewController: UIViewController {
6868
}
6969
}
7070

71+
@IBAction func deleteRegistrationsTapped(_: Any) {
72+
clearAllBiometricRegistrations(user: StytchClient.user.getSync())
73+
Task {
74+
do {
75+
let user = try await StytchClient.user.get().wrapped
76+
logBiometricRegistrations(user: user, identifier: "delete registrations")
77+
} catch {
78+
print(error.errorInfo)
79+
}
80+
}
81+
}
82+
7183
func clearAllBiometricRegistrations(user: User?) {
84+
guard let user else { return }
7285
Task {
7386
do {
74-
guard let biometricRegistrations = user?.biometricRegistrations else { return }
75-
for registration in biometricRegistrations {
87+
for registration in user.biometricRegistrations {
7688
_ = try await StytchClient.user.deleteFactor(.biometricRegistration(id: registration.id))
7789
}
7890
} catch {

Tests/StytchCoreTests/BiometricsTestCase.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,16 @@ final class BiometricsTestCase: BaseTestCase {
5454
.init((0..<challenge.count).map { UInt8($0) })
5555
}
5656

57+
// Set the .privateKeyRegistration
5758
try Current.keychainClient.set(
5859
key: privateKey.rawRepresentation,
5960
registration: .init(userId: userId, userLabel: email, registrationId: regId),
6061
accessPolicy: .deviceOwnerAuthenticationWithBiometrics
6162
)
6263

64+
// Set the .biometricKeyRegistration
65+
try Current.keychainClient.set(regId.rawValue, for: .biometricKeyRegistration)
66+
6367
XCTAssertTrue(StytchClient.biometrics.registrationAvailable)
6468

6569
networkInterceptor.responses {
@@ -95,12 +99,16 @@ final class BiometricsTestCase: BaseTestCase {
9599
.init((0..<challenge.count).map { UInt8($0) })
96100
}
97101

102+
// Set the .privateKeyRegistration
98103
try Current.keychainClient.set(
99104
key: privateKey.rawRepresentation,
100105
registration: .init(userId: userId, userLabel: email, registrationId: regId),
101106
accessPolicy: .deviceOwnerAuthenticationWithBiometrics
102107
)
103108

109+
// Set the .biometricKeyRegistration
110+
try Current.keychainClient.set(regId.rawValue, for: .biometricKeyRegistration)
111+
104112
XCTAssertTrue(StytchClient.biometrics.registrationAvailable)
105113

106114
networkInterceptor.responses {

0 commit comments

Comments
 (0)