@algorandfoundation/react-native-passkey-autofill 1.0.0-canary.2 → 1.0.0-canary.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.nvmrc +1 -0
- package/.oxfmtrc.json +4 -0
- package/.oxlintrc.json +14 -0
- package/.releaserc +30 -0
- package/ARCHITECTURE.md +8 -0
- package/CHANGELOG.md +115 -0
- package/CONTRIBUTING.md +15 -5
- package/README.md +150 -11
- package/android/build.gradle +33 -10
- package/android/libs/repo/co/algorand/dP256Android/1.0.0/dP256Android-1.0.0.pom +9 -0
- package/android/src/main/AndroidManifest.xml +9 -3
- package/android/src/main/java/co/algorand/passkeyautofill/CreatePasskeyActivity.kt +396 -44
- package/android/src/main/java/co/algorand/passkeyautofill/GetPasskeyActivity.kt +583 -44
- package/android/src/main/java/co/algorand/passkeyautofill/Prf.kt +184 -0
- package/android/src/main/java/co/algorand/passkeyautofill/ReactNativePasskeyAutofillModule.kt +190 -2
- package/android/src/main/java/co/algorand/passkeyautofill/WebAuthn.kt +135 -0
- package/android/src/main/java/co/algorand/passkeyautofill/auth/BiometricRequirement.kt +86 -0
- package/android/src/main/java/co/algorand/passkeyautofill/credentials/Credential.kt +2 -1
- package/android/src/main/java/co/algorand/passkeyautofill/credentials/CredentialRepository.kt +314 -53
- package/android/src/main/java/co/algorand/passkeyautofill/service/PasskeyAutofillCredentialProviderService.kt +152 -38
- package/android/src/main/java/co/algorand/passkeyautofill/utils/PasskeyUtils.kt +119 -0
- package/android/src/main/res/values/styles.xml +10 -0
- package/app.plugin.js +593 -60
- package/assets/banner.png +0 -0
- package/build/ReactNativePasskeyAutofill.types.d.ts +41 -1
- package/build/ReactNativePasskeyAutofill.types.d.ts.map +1 -1
- package/build/ReactNativePasskeyAutofill.types.js +3 -1
- package/build/ReactNativePasskeyAutofill.types.js.map +1 -1
- package/build/ReactNativePasskeyAutofillModule.d.ts +41 -4
- package/build/ReactNativePasskeyAutofillModule.d.ts.map +1 -1
- package/build/ReactNativePasskeyAutofillModule.js +21 -2
- package/build/ReactNativePasskeyAutofillModule.js.map +1 -1
- package/build/index.d.ts +2 -2
- package/build/index.d.ts.map +1 -1
- package/build/index.js +2 -2
- package/build/index.js.map +1 -1
- package/e2e/README.md +155 -0
- package/e2e/driver/adb.ts +73 -0
- package/e2e/driver/capabilities.ts +106 -0
- package/e2e/driver/credentialProvider.ts +104 -0
- package/e2e/driver/fingerprint.ts +885 -0
- package/e2e/driver/selectors.ts +103 -0
- package/e2e/driver/session.ts +25 -0
- package/e2e/jest.config.ts +20 -0
- package/e2e/package.json +11 -0
- package/e2e/tests/passkey.test.ts +190 -0
- package/e2e/tsconfig.json +16 -0
- package/ios/AutofillCredentialProvider/BiometricRequirement.swift +34 -0
- package/ios/AutofillCredentialProvider/CredentialProviderViewController.swift +570 -0
- package/ios/AutofillCredentialProvider/PasskeyAutofillCredentialProvider-Bridging-Header.h +1 -0
- package/ios/AutofillCredentialProvider/PasskeyCredentialStore.swift +612 -0
- package/ios/AutofillCredentialProvider/PasskeyKeystoreMMKV.h +25 -0
- package/ios/AutofillCredentialProvider/PasskeyKeystoreMMKV.mm +86 -0
- package/ios/AutofillCredentialProvider/Prf.swift +89 -0
- package/ios/AutofillCredentialProvider/WebAuthn.swift +186 -0
- package/ios/ReactNativePasskeyAutofill.podspec +11 -2
- package/ios/ReactNativePasskeyAutofillModule.swift +170 -15
- package/lefthook.yml +14 -0
- package/package.json +115 -25
- package/pnpm-workspace.yaml +36 -0
- package/src/ReactNativePasskeyAutofill.types.ts +38 -0
- package/src/ReactNativePasskeyAutofillModule.ts +66 -3
- package/src/index.ts +2 -2
- package/tsconfig.json +2 -2
- package/.eslintrc.js +0 -5
- /package/android/libs/{dP256Android-release.aar → repo/co/algorand/dP256Android/1.0.0/dP256Android-1.0.0.aar} +0 -0
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
import AuthenticationServices
|
|
2
|
+
import CryptoKit
|
|
3
|
+
import Foundation
|
|
4
|
+
import LocalAuthentication
|
|
5
|
+
import Security
|
|
6
|
+
import UIKit
|
|
7
|
+
|
|
8
|
+
final class CredentialProviderViewController: ASCredentialProviderViewController {
|
|
9
|
+
private let store = PasskeyCredentialStore()
|
|
10
|
+
private var pendingRegistrationRequest: ASPasskeyCredentialRequest?
|
|
11
|
+
private var pendingRegistrationIdentity: ASPasskeyCredentialIdentity?
|
|
12
|
+
private var pendingAssertionCredential: StoredPasskeyCredential?
|
|
13
|
+
private var pendingAssertionClientDataHash: Data?
|
|
14
|
+
private var pendingAssertionRelyingPartyIdentifier: String?
|
|
15
|
+
private var pendingAssertionPrfInput: PrfInput?
|
|
16
|
+
private var pendingRegistrationPrfInput: PrfInput?
|
|
17
|
+
private var isCompletingRegistration = false
|
|
18
|
+
private var isCompletingAssertion = false
|
|
19
|
+
private var hasPresentedInterface = false
|
|
20
|
+
private var authContext: LAContext?
|
|
21
|
+
private let activityIndicator = UIActivityIndicatorView(style: .large)
|
|
22
|
+
private let statusLabel = UILabel()
|
|
23
|
+
|
|
24
|
+
override func viewDidLoad() {
|
|
25
|
+
super.viewDidLoad()
|
|
26
|
+
configureView()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
override func viewDidAppear(_ animated: Bool) {
|
|
30
|
+
super.viewDidAppear(animated)
|
|
31
|
+
hasPresentedInterface = true
|
|
32
|
+
|
|
33
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
|
|
34
|
+
guard let self else {
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
if self.pendingRegistrationRequest != nil {
|
|
38
|
+
self.authenticateAndCompleteRegistration()
|
|
39
|
+
} else if self.pendingAssertionCredential != nil {
|
|
40
|
+
self.authenticateAndCompleteAssertion()
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
override func prepareCredentialList(
|
|
46
|
+
for serviceIdentifiers: [ASCredentialServiceIdentifier],
|
|
47
|
+
requestParameters: ASPasskeyCredentialRequestParameters
|
|
48
|
+
) {
|
|
49
|
+
guard #available(iOSApplicationExtension 17.0, *) else {
|
|
50
|
+
cancel(code: .failed, message: "Passkeys require iOS 17 or newer.")
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let relyingPartyIdentifier = requestParameters.relyingPartyIdentifier
|
|
55
|
+
let allowedCredentials = Set(requestParameters.allowedCredentials.map { $0.base64URLEncodedString() })
|
|
56
|
+
guard let credential = store?.credentials(
|
|
57
|
+
relyingPartyIdentifier: relyingPartyIdentifier
|
|
58
|
+
).first(where: { allowedCredentials.isEmpty || allowedCredentials.contains($0.credentialIdData.base64URLEncodedString()) })
|
|
59
|
+
else {
|
|
60
|
+
cancel(code: .credentialIdentityNotFound, message: "No passkey is available.")
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
prepareAssertion(
|
|
65
|
+
credential: credential,
|
|
66
|
+
clientDataHash: requestParameters.clientDataHash,
|
|
67
|
+
relyingPartyIdentifier: relyingPartyIdentifier,
|
|
68
|
+
prfInput: Self.prfInput(fromAssertion: requestParameters)
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
override func provideCredentialWithoutUserInteraction(for credentialRequest: ASCredentialRequest) {
|
|
73
|
+
guard #available(iOSApplicationExtension 17.0, *),
|
|
74
|
+
let request = credentialRequest as? ASPasskeyCredentialRequest
|
|
75
|
+
else {
|
|
76
|
+
cancel(code: .failed, message: "Unsupported credential request.")
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
guard request.credentialIdentity is ASPasskeyCredentialIdentity else {
|
|
81
|
+
cancel(code: .credentialIdentityNotFound, message: "Credential identity not found.")
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
cancel(code: .userInteractionRequired, message: "User verification is required.")
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
override func prepareInterfaceToProvideCredential(for credentialRequest: ASCredentialRequest) {
|
|
89
|
+
guard #available(iOSApplicationExtension 17.0, *),
|
|
90
|
+
let request = credentialRequest as? ASPasskeyCredentialRequest
|
|
91
|
+
else {
|
|
92
|
+
cancel(code: .failed, message: "Unsupported credential request.")
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
guard let identity = request.credentialIdentity as? ASPasskeyCredentialIdentity,
|
|
97
|
+
let credential = store?.credential(id: identity.credentialID)
|
|
98
|
+
else {
|
|
99
|
+
cancel(code: .credentialIdentityNotFound, message: "Credential identity not found.")
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
prepareAssertion(
|
|
104
|
+
credential: credential,
|
|
105
|
+
clientDataHash: request.clientDataHash,
|
|
106
|
+
relyingPartyIdentifier: identity.relyingPartyIdentifier,
|
|
107
|
+
prfInput: Self.prfInput(fromAssertion: request)
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
override func prepareInterface(forPasskeyRegistration request: ASCredentialRequest) {
|
|
112
|
+
store?.appendDiagnostic("prepareInterface(forPasskeyRegistration)")
|
|
113
|
+
guard #available(iOSApplicationExtension 17.0, *),
|
|
114
|
+
let request = request as? ASPasskeyCredentialRequest,
|
|
115
|
+
let identity = request.credentialIdentity as? ASPasskeyCredentialIdentity
|
|
116
|
+
else {
|
|
117
|
+
cancel(code: .failed, message: "Unsupported passkey registration request.")
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
pendingRegistrationRequest = request
|
|
122
|
+
pendingRegistrationIdentity = identity
|
|
123
|
+
pendingRegistrationPrfInput = Self.prfInput(fromRegistration: request)
|
|
124
|
+
showCheckingPasskeys()
|
|
125
|
+
|
|
126
|
+
if hasPresentedInterface {
|
|
127
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
|
|
128
|
+
self?.authenticateAndCompleteRegistration()
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
override func prepareInterfaceForExtensionConfiguration() {
|
|
134
|
+
extensionContext.completeExtensionConfigurationRequest()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private func completePendingRegistration() {
|
|
138
|
+
store?.appendDiagnostic("completePendingRegistration")
|
|
139
|
+
showCheckingPasskeys()
|
|
140
|
+
|
|
141
|
+
guard #available(iOSApplicationExtension 17.0, *),
|
|
142
|
+
let request = pendingRegistrationRequest,
|
|
143
|
+
let identity = pendingRegistrationIdentity
|
|
144
|
+
else {
|
|
145
|
+
cancel(code: .failed, message: "Unsupported passkey registration request.")
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
do {
|
|
150
|
+
guard let store else {
|
|
151
|
+
self.store?.appendDiagnostic("missing wallet root-key state")
|
|
152
|
+
showFailure("Wallet root key is not available. Open Rocca Wallet once, unlock it, then try again.")
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let parentKeyId = store.hdRootKeyId()
|
|
157
|
+
let derivedParentSecret = try store.hdRootKeySecret()
|
|
158
|
+
let userHandle = identity.userHandleString
|
|
159
|
+
let privateKey = try Self.domainSpecificKeyPair(
|
|
160
|
+
derivedParentSecret: derivedParentSecret,
|
|
161
|
+
origin: identity.relyingPartyIdentifier,
|
|
162
|
+
userHandle: userHandle.lowercased()
|
|
163
|
+
)
|
|
164
|
+
let publicKey = privateKey.publicKey.derRepresentation
|
|
165
|
+
let credentialId = WebAuthn.credentialId(publicKey: publicKey)
|
|
166
|
+
let storedCredential = StoredPasskeyCredential(
|
|
167
|
+
credentialId: credentialId.base64EncodedString(),
|
|
168
|
+
relyingPartyIdentifier: identity.relyingPartyIdentifier,
|
|
169
|
+
userName: identity.userName.passkeyDisplayName,
|
|
170
|
+
userHandle: identity.userHandle.base64EncodedString(),
|
|
171
|
+
privateKey: privateKey.rawRepresentation.base64EncodedString(),
|
|
172
|
+
publicKey: publicKey.base64EncodedString(),
|
|
173
|
+
createdAt: Date().timeIntervalSince1970,
|
|
174
|
+
lastUsedAt: nil,
|
|
175
|
+
parentKeyId: parentKeyId
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
let authData = try WebAuthn.authenticatorDataForAttestation(
|
|
179
|
+
relyingPartyIdentifier: identity.relyingPartyIdentifier,
|
|
180
|
+
credentialId: credentialId,
|
|
181
|
+
publicKey: publicKey
|
|
182
|
+
)
|
|
183
|
+
let registrationCredential = ASPasskeyRegistrationCredential(
|
|
184
|
+
relyingParty: identity.relyingPartyIdentifier,
|
|
185
|
+
clientDataHash: request.clientDataHash,
|
|
186
|
+
credentialID: credentialId,
|
|
187
|
+
attestationObject: WebAuthn.attestationObject(authenticatorData: authData)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
attachPrfRegistrationOutput(
|
|
191
|
+
to: registrationCredential,
|
|
192
|
+
derivedParentSecret: derivedParentSecret,
|
|
193
|
+
relyingPartyIdentifier: identity.relyingPartyIdentifier,
|
|
194
|
+
userHandle: userHandle
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
try store.save(storedCredential)
|
|
198
|
+
store.appendDiagnostic("stored passkey credential")
|
|
199
|
+
|
|
200
|
+
Task {
|
|
201
|
+
do {
|
|
202
|
+
try await store.replaceIdentityStore()
|
|
203
|
+
store.appendDiagnostic("replaceIdentityStore after registration succeeded")
|
|
204
|
+
} catch {
|
|
205
|
+
store.appendDiagnostic("replaceIdentityStore after registration failed: \(error.localizedDescription)")
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
store.appendDiagnostic("completeRegistrationRequest")
|
|
210
|
+
extensionContext.completeRegistrationRequest(using: registrationCredential) { [weak self] completed in
|
|
211
|
+
self?.store?.appendDiagnostic("completeRegistrationRequest completion: \(completed)")
|
|
212
|
+
self?.authContext = nil
|
|
213
|
+
}
|
|
214
|
+
} catch {
|
|
215
|
+
store?.appendDiagnostic("registration error: \(error.localizedDescription)")
|
|
216
|
+
showFailure(error.localizedDescription)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private func completeAssertion(
|
|
221
|
+
credential: StoredPasskeyCredential,
|
|
222
|
+
clientDataHash: Data,
|
|
223
|
+
relyingPartyIdentifier: String
|
|
224
|
+
) {
|
|
225
|
+
do {
|
|
226
|
+
let authenticatorData = WebAuthn.authenticatorDataForAssertion(
|
|
227
|
+
relyingPartyIdentifier: relyingPartyIdentifier
|
|
228
|
+
)
|
|
229
|
+
let signature = try credential.sign(authenticatorData + clientDataHash)
|
|
230
|
+
let assertionCredential = ASPasskeyAssertionCredential(
|
|
231
|
+
userHandle: credential.userHandleData,
|
|
232
|
+
relyingParty: relyingPartyIdentifier,
|
|
233
|
+
signature: signature,
|
|
234
|
+
clientDataHash: clientDataHash,
|
|
235
|
+
authenticatorData: authenticatorData,
|
|
236
|
+
credentialID: credential.credentialIdData
|
|
237
|
+
)
|
|
238
|
+
attachPrfAssertionOutput(
|
|
239
|
+
to: assertionCredential,
|
|
240
|
+
credential: credential,
|
|
241
|
+
relyingPartyIdentifier: relyingPartyIdentifier
|
|
242
|
+
)
|
|
243
|
+
extensionContext.completeAssertionRequest(using: assertionCredential) { [weak self] _ in
|
|
244
|
+
self?.store?.recordCredentialUsage(id: credential.credentialId)
|
|
245
|
+
self?.authContext = nil
|
|
246
|
+
self?.isCompletingAssertion = false
|
|
247
|
+
self?.pendingAssertionCredential = nil
|
|
248
|
+
self?.pendingAssertionClientDataHash = nil
|
|
249
|
+
self?.pendingAssertionRelyingPartyIdentifier = nil
|
|
250
|
+
self?.pendingAssertionPrfInput = nil
|
|
251
|
+
}
|
|
252
|
+
} catch {
|
|
253
|
+
isCompletingAssertion = false
|
|
254
|
+
cancel(code: .failed, message: error.localizedDescription)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private func prepareAssertion(
|
|
259
|
+
credential: StoredPasskeyCredential,
|
|
260
|
+
clientDataHash: Data,
|
|
261
|
+
relyingPartyIdentifier: String,
|
|
262
|
+
prfInput: PrfInput?
|
|
263
|
+
) {
|
|
264
|
+
pendingAssertionCredential = credential
|
|
265
|
+
pendingAssertionClientDataHash = clientDataHash
|
|
266
|
+
pendingAssertionRelyingPartyIdentifier = relyingPartyIdentifier
|
|
267
|
+
pendingAssertionPrfInput = prfInput
|
|
268
|
+
showCheckingPasskeys()
|
|
269
|
+
|
|
270
|
+
if hasPresentedInterface {
|
|
271
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
|
|
272
|
+
self?.authenticateAndCompleteAssertion()
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private func authenticateAndCompleteAssertion() {
|
|
278
|
+
guard !isCompletingAssertion else {
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
isCompletingAssertion = true
|
|
282
|
+
|
|
283
|
+
guard let credential = pendingAssertionCredential,
|
|
284
|
+
let clientDataHash = pendingAssertionClientDataHash,
|
|
285
|
+
let relyingPartyIdentifier = pendingAssertionRelyingPartyIdentifier
|
|
286
|
+
else {
|
|
287
|
+
isCompletingAssertion = false
|
|
288
|
+
cancel(code: .credentialIdentityNotFound, message: "Credential identity not found.")
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
let context = LAContext()
|
|
293
|
+
authContext = context
|
|
294
|
+
context.localizedCancelTitle = "Cancel"
|
|
295
|
+
context.localizedFallbackTitle = ""
|
|
296
|
+
|
|
297
|
+
let policy: LAPolicy = BiometricRequirement.current.laPolicy
|
|
298
|
+
|
|
299
|
+
var error: NSError?
|
|
300
|
+
guard context.canEvaluatePolicy(policy, error: &error) else {
|
|
301
|
+
authContext = nil
|
|
302
|
+
isCompletingAssertion = false
|
|
303
|
+
showFailure(error?.localizedDescription ?? "Device authentication is not available.")
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
store?.appendDiagnostic("evaluatePolicy assertion start")
|
|
308
|
+
context.evaluatePolicy(policy, localizedReason: "Use passkeys with Rocca Wallet") { [weak self] success, authenticationError in
|
|
309
|
+
DispatchQueue.main.async {
|
|
310
|
+
guard let self else {
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if success {
|
|
315
|
+
self.store?.appendDiagnostic("evaluatePolicy assertion success")
|
|
316
|
+
self.completeAssertion(
|
|
317
|
+
credential: credential,
|
|
318
|
+
clientDataHash: clientDataHash,
|
|
319
|
+
relyingPartyIdentifier: relyingPartyIdentifier
|
|
320
|
+
)
|
|
321
|
+
} else {
|
|
322
|
+
self.authContext = nil
|
|
323
|
+
self.isCompletingAssertion = false
|
|
324
|
+
self.store?.appendDiagnostic(
|
|
325
|
+
"evaluatePolicy assertion failed: \(authenticationError?.localizedDescription ?? "unknown")"
|
|
326
|
+
)
|
|
327
|
+
self.cancel(
|
|
328
|
+
code: .userCanceled,
|
|
329
|
+
message: authenticationError?.localizedDescription ?? "Biometric authentication was canceled."
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private func cancel(code: ASExtensionError.Code, message: String) {
|
|
337
|
+
store?.appendDiagnostic("cancel: \(message)")
|
|
338
|
+
extensionContext.cancelRequest(
|
|
339
|
+
withError: NSError(
|
|
340
|
+
domain: ASExtensionErrorDomain,
|
|
341
|
+
code: code.rawValue,
|
|
342
|
+
userInfo: [NSLocalizedDescriptionKey: message]
|
|
343
|
+
)
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private func configureView() {
|
|
348
|
+
view.backgroundColor = .systemBackground
|
|
349
|
+
|
|
350
|
+
activityIndicator.hidesWhenStopped = true
|
|
351
|
+
activityIndicator.startAnimating()
|
|
352
|
+
|
|
353
|
+
statusLabel.text = "checking passkeys..."
|
|
354
|
+
statusLabel.font = .preferredFont(forTextStyle: .body)
|
|
355
|
+
statusLabel.textColor = .secondaryLabel
|
|
356
|
+
statusLabel.textAlignment = .center
|
|
357
|
+
statusLabel.numberOfLines = 0
|
|
358
|
+
statusLabel.adjustsFontForContentSizeCategory = true
|
|
359
|
+
|
|
360
|
+
let stack = UIStackView(arrangedSubviews: [activityIndicator, statusLabel])
|
|
361
|
+
stack.axis = .vertical
|
|
362
|
+
stack.alignment = .center
|
|
363
|
+
stack.spacing = 14
|
|
364
|
+
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
365
|
+
|
|
366
|
+
view.addSubview(stack)
|
|
367
|
+
NSLayoutConstraint.activate([
|
|
368
|
+
stack.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
|
|
369
|
+
stack.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
|
|
370
|
+
stack.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
|
371
|
+
])
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private func showCheckingPasskeys() {
|
|
375
|
+
activityIndicator.startAnimating()
|
|
376
|
+
statusLabel.text = "checking passkeys..."
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private func showFailure(_ message: String) {
|
|
380
|
+
store?.appendDiagnostic("showFailure: \(message)")
|
|
381
|
+
activityIndicator.stopAnimating()
|
|
382
|
+
statusLabel.text = message
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private func authenticateAndCompleteRegistration() {
|
|
386
|
+
guard !isCompletingRegistration else {
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
isCompletingRegistration = true
|
|
390
|
+
|
|
391
|
+
let context = LAContext()
|
|
392
|
+
authContext = context
|
|
393
|
+
context.localizedCancelTitle = "Cancel"
|
|
394
|
+
context.localizedFallbackTitle = ""
|
|
395
|
+
|
|
396
|
+
let policy: LAPolicy = BiometricRequirement.current.laPolicy
|
|
397
|
+
|
|
398
|
+
var error: NSError?
|
|
399
|
+
guard context.canEvaluatePolicy(policy, error: &error) else {
|
|
400
|
+
authContext = nil
|
|
401
|
+
showFailure(error?.localizedDescription ?? "Device authentication is not available.")
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
store?.appendDiagnostic("evaluatePolicy start")
|
|
406
|
+
context.evaluatePolicy(policy, localizedReason: "Create passkeys with Rocca Wallet") { [weak self] success, authenticationError in
|
|
407
|
+
DispatchQueue.main.async {
|
|
408
|
+
guard let self else {
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if success {
|
|
413
|
+
self.store?.appendDiagnostic("evaluatePolicy success")
|
|
414
|
+
self.completePendingRegistration()
|
|
415
|
+
} else {
|
|
416
|
+
self.authContext = nil
|
|
417
|
+
self.store?.appendDiagnostic(
|
|
418
|
+
"evaluatePolicy failed: \(authenticationError?.localizedDescription ?? "unknown")"
|
|
419
|
+
)
|
|
420
|
+
self.cancel(
|
|
421
|
+
code: .userCanceled,
|
|
422
|
+
message: authenticationError?.localizedDescription ?? "Biometric authentication was canceled."
|
|
423
|
+
)
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private static func domainSpecificKeyPair(
|
|
430
|
+
derivedParentSecret: Data,
|
|
431
|
+
origin: String,
|
|
432
|
+
userHandle: String,
|
|
433
|
+
counter: UInt32 = 0
|
|
434
|
+
) throws -> P256.Signing.PrivateKey {
|
|
435
|
+
var input = Data()
|
|
436
|
+
input.append(derivedParentSecret)
|
|
437
|
+
input.append(contentsOf: origin.utf8)
|
|
438
|
+
input.append(contentsOf: userHandle.utf8)
|
|
439
|
+
|
|
440
|
+
for attempt in counter..<(counter + 16) {
|
|
441
|
+
var candidateInput = input
|
|
442
|
+
var bigEndianAttempt = attempt.bigEndian
|
|
443
|
+
withUnsafeBytes(of: &bigEndianAttempt) { candidateInput.append(contentsOf: $0) }
|
|
444
|
+
|
|
445
|
+
let digest = SHA512.hash(data: candidateInput)
|
|
446
|
+
if let key = try? P256.Signing.PrivateKey(rawRepresentation: Data(digest.prefix(32))) {
|
|
447
|
+
return key
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
throw PasskeyCredentialStoreError.invalidPrivateKey
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
private extension ASPasskeyCredentialIdentity {
|
|
456
|
+
var userHandleString: String {
|
|
457
|
+
String(data: userHandle, encoding: .utf8) ?? userHandle.base64URLEncodedString()
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// MARK: - PRF extension helpers
|
|
462
|
+
|
|
463
|
+
extension CredentialProviderViewController {
|
|
464
|
+
/// Extract PRF inputs from a passkey assertion request. The platform passes
|
|
465
|
+
/// the RP-supplied salts down to credential providers via `extensionInput`
|
|
466
|
+
/// (iOS 17.4+ for the type itself, iOS 18+ for the `prf` property).
|
|
467
|
+
static func prfInput(fromAssertion request: ASPasskeyCredentialRequest) -> PrfInput? {
|
|
468
|
+
if #available(iOSApplicationExtension 18.0, *) {
|
|
469
|
+
guard case let .assertion(input) = request.extensionInput,
|
|
470
|
+
let prf = input.prf else { return nil }
|
|
471
|
+
return prfInput(fromAssertionInput: prf)
|
|
472
|
+
}
|
|
473
|
+
return nil
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/// Variant for the `prepareCredentialList` flow which gives us the
|
|
477
|
+
/// `ASPasskeyCredentialRequestParameters` directly.
|
|
478
|
+
static func prfInput(fromAssertion parameters: ASPasskeyCredentialRequestParameters) -> PrfInput? {
|
|
479
|
+
if #available(iOSApplicationExtension 18.0, *) {
|
|
480
|
+
guard let prf = parameters.extensionInput?.prf else { return nil }
|
|
481
|
+
return prfInput(fromAssertionInput: prf)
|
|
482
|
+
}
|
|
483
|
+
return nil
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/// Extract PRF inputs from a passkey registration request. Most relying
|
|
487
|
+
/// parties only set `prf.enabled` on create, but a few also pass eval salts
|
|
488
|
+
/// to immediately derive a secret.
|
|
489
|
+
static func prfInput(fromRegistration request: ASPasskeyCredentialRequest) -> PrfInput? {
|
|
490
|
+
if #available(iOSApplicationExtension 18.0, *) {
|
|
491
|
+
guard case let .registration(input) = request.extensionInput,
|
|
492
|
+
let prf = input.prf else { return nil }
|
|
493
|
+
return prfInput(fromRegistrationInput: prf)
|
|
494
|
+
}
|
|
495
|
+
return nil
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
@available(iOSApplicationExtension 18.0, *)
|
|
499
|
+
private static func prfInput(
|
|
500
|
+
fromAssertionInput prf: ASAuthorizationPublicKeyCredentialPRFAssertionInput
|
|
501
|
+
) -> PrfInput? {
|
|
502
|
+
// `inputValues` (when present) overrides `saltInput1/2` per WebAuthn's
|
|
503
|
+
// `evalByCredential` semantics. The platform exposes whichever is
|
|
504
|
+
// applicable to the credential the user actually picked.
|
|
505
|
+
if let values = prf.inputValues {
|
|
506
|
+
return PrfInput(first: values.saltInput1, second: values.saltInput2)
|
|
507
|
+
}
|
|
508
|
+
return nil
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
@available(iOSApplicationExtension 18.0, *)
|
|
512
|
+
private static func prfInput(
|
|
513
|
+
fromRegistrationInput prf: ASAuthorizationPublicKeyCredentialPRFRegistrationInput
|
|
514
|
+
) -> PrfInput? {
|
|
515
|
+
if let values = prf.inputValues {
|
|
516
|
+
return PrfInput(first: values.saltInput1, second: values.saltInput2)
|
|
517
|
+
}
|
|
518
|
+
return nil
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/// Compute and attach PRF outputs to an assertion credential. No-op on
|
|
522
|
+
/// pre-iOS 18 systems, and a no-op when the RP did not supply PRF inputs.
|
|
523
|
+
func attachPrfAssertionOutput(
|
|
524
|
+
to assertionCredential: ASPasskeyAssertionCredential,
|
|
525
|
+
credential: StoredPasskeyCredential,
|
|
526
|
+
relyingPartyIdentifier: String
|
|
527
|
+
) {
|
|
528
|
+
guard #available(iOSApplicationExtension 18.0, *),
|
|
529
|
+
let input = pendingAssertionPrfInput else { return }
|
|
530
|
+
|
|
531
|
+
do {
|
|
532
|
+
guard let store else { return }
|
|
533
|
+
let derivedParentSecret = try store.hdRootKeySecret()
|
|
534
|
+
let userHandle = credential.userHandle
|
|
535
|
+
let credRandom = Prf.credRandom(
|
|
536
|
+
hdRootSecret: derivedParentSecret,
|
|
537
|
+
relyingPartyIdentifier: relyingPartyIdentifier,
|
|
538
|
+
userHandle: userHandle
|
|
539
|
+
)
|
|
540
|
+
let first = SymmetricKey(data: Prf.evaluate(credRandom: credRandom, salt: input.first))
|
|
541
|
+
let second = input.second.map { SymmetricKey(data: Prf.evaluate(credRandom: credRandom, salt: $0)) }
|
|
542
|
+
let prfOutput = ASAuthorizationPublicKeyCredentialPRFAssertionOutput(
|
|
543
|
+
first: first,
|
|
544
|
+
second: second
|
|
545
|
+
)
|
|
546
|
+
assertionCredential.extensionOutput = ASPasskeyAssertionCredentialExtensionOutput(prf: prfOutput)
|
|
547
|
+
store.appendDiagnostic("attached PRF assertion output")
|
|
548
|
+
} catch {
|
|
549
|
+
store?.appendDiagnostic("failed to attach PRF assertion output: \(error.localizedDescription)")
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/// Attach PRF output to a registration credential.
|
|
554
|
+
///
|
|
555
|
+
/// Per the agreed scope, we always advertise `prf.enabled = true` on
|
|
556
|
+
/// registration to signal the credential supports PRF. If the RP also sent
|
|
557
|
+
/// eval salts we still defer the actual evaluation to a follow-up assertion
|
|
558
|
+
/// (matching the behaviour of most platform authenticators).
|
|
559
|
+
func attachPrfRegistrationOutput(
|
|
560
|
+
to registrationCredential: ASPasskeyRegistrationCredential,
|
|
561
|
+
derivedParentSecret: Data,
|
|
562
|
+
relyingPartyIdentifier: String,
|
|
563
|
+
userHandle: String
|
|
564
|
+
) {
|
|
565
|
+
guard #available(iOSApplicationExtension 18.0, *) else { return }
|
|
566
|
+
let prfOutput = ASAuthorizationPublicKeyCredentialPRFRegistrationOutput.supported
|
|
567
|
+
registrationCredential.extensionOutput = ASPasskeyRegistrationCredentialExtensionOutput(prf: prfOutput)
|
|
568
|
+
store?.appendDiagnostic("attached PRF registration output (enabled)")
|
|
569
|
+
}
|
|
570
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#import "PasskeyKeystoreMMKV.h"
|