@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.
Files changed (66) hide show
  1. package/.nvmrc +1 -0
  2. package/.oxfmtrc.json +4 -0
  3. package/.oxlintrc.json +14 -0
  4. package/.releaserc +30 -0
  5. package/ARCHITECTURE.md +8 -0
  6. package/CHANGELOG.md +115 -0
  7. package/CONTRIBUTING.md +15 -5
  8. package/README.md +150 -11
  9. package/android/build.gradle +33 -10
  10. package/android/libs/repo/co/algorand/dP256Android/1.0.0/dP256Android-1.0.0.pom +9 -0
  11. package/android/src/main/AndroidManifest.xml +9 -3
  12. package/android/src/main/java/co/algorand/passkeyautofill/CreatePasskeyActivity.kt +396 -44
  13. package/android/src/main/java/co/algorand/passkeyautofill/GetPasskeyActivity.kt +583 -44
  14. package/android/src/main/java/co/algorand/passkeyautofill/Prf.kt +184 -0
  15. package/android/src/main/java/co/algorand/passkeyautofill/ReactNativePasskeyAutofillModule.kt +190 -2
  16. package/android/src/main/java/co/algorand/passkeyautofill/WebAuthn.kt +135 -0
  17. package/android/src/main/java/co/algorand/passkeyautofill/auth/BiometricRequirement.kt +86 -0
  18. package/android/src/main/java/co/algorand/passkeyautofill/credentials/Credential.kt +2 -1
  19. package/android/src/main/java/co/algorand/passkeyautofill/credentials/CredentialRepository.kt +314 -53
  20. package/android/src/main/java/co/algorand/passkeyautofill/service/PasskeyAutofillCredentialProviderService.kt +152 -38
  21. package/android/src/main/java/co/algorand/passkeyautofill/utils/PasskeyUtils.kt +119 -0
  22. package/android/src/main/res/values/styles.xml +10 -0
  23. package/app.plugin.js +593 -60
  24. package/assets/banner.png +0 -0
  25. package/build/ReactNativePasskeyAutofill.types.d.ts +41 -1
  26. package/build/ReactNativePasskeyAutofill.types.d.ts.map +1 -1
  27. package/build/ReactNativePasskeyAutofill.types.js +3 -1
  28. package/build/ReactNativePasskeyAutofill.types.js.map +1 -1
  29. package/build/ReactNativePasskeyAutofillModule.d.ts +41 -4
  30. package/build/ReactNativePasskeyAutofillModule.d.ts.map +1 -1
  31. package/build/ReactNativePasskeyAutofillModule.js +21 -2
  32. package/build/ReactNativePasskeyAutofillModule.js.map +1 -1
  33. package/build/index.d.ts +2 -2
  34. package/build/index.d.ts.map +1 -1
  35. package/build/index.js +2 -2
  36. package/build/index.js.map +1 -1
  37. package/e2e/README.md +155 -0
  38. package/e2e/driver/adb.ts +73 -0
  39. package/e2e/driver/capabilities.ts +106 -0
  40. package/e2e/driver/credentialProvider.ts +104 -0
  41. package/e2e/driver/fingerprint.ts +885 -0
  42. package/e2e/driver/selectors.ts +103 -0
  43. package/e2e/driver/session.ts +25 -0
  44. package/e2e/jest.config.ts +20 -0
  45. package/e2e/package.json +11 -0
  46. package/e2e/tests/passkey.test.ts +190 -0
  47. package/e2e/tsconfig.json +16 -0
  48. package/ios/AutofillCredentialProvider/BiometricRequirement.swift +34 -0
  49. package/ios/AutofillCredentialProvider/CredentialProviderViewController.swift +570 -0
  50. package/ios/AutofillCredentialProvider/PasskeyAutofillCredentialProvider-Bridging-Header.h +1 -0
  51. package/ios/AutofillCredentialProvider/PasskeyCredentialStore.swift +612 -0
  52. package/ios/AutofillCredentialProvider/PasskeyKeystoreMMKV.h +25 -0
  53. package/ios/AutofillCredentialProvider/PasskeyKeystoreMMKV.mm +86 -0
  54. package/ios/AutofillCredentialProvider/Prf.swift +89 -0
  55. package/ios/AutofillCredentialProvider/WebAuthn.swift +186 -0
  56. package/ios/ReactNativePasskeyAutofill.podspec +11 -2
  57. package/ios/ReactNativePasskeyAutofillModule.swift +170 -15
  58. package/lefthook.yml +14 -0
  59. package/package.json +115 -25
  60. package/pnpm-workspace.yaml +36 -0
  61. package/src/ReactNativePasskeyAutofill.types.ts +38 -0
  62. package/src/ReactNativePasskeyAutofillModule.ts +66 -3
  63. package/src/index.ts +2 -2
  64. package/tsconfig.json +2 -2
  65. package/.eslintrc.js +0 -5
  66. /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"