@breeztech/breez-sdk-spark-react-native 0.20.0-dev1 → 0.21.1

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 (49) hide show
  1. package/BreezSdkSparkReactNative.podspec +6 -0
  2. package/android/build.gradle +5 -0
  3. package/android/src/main/java/com/breeztech/breezsdkspark/BreezSdkSparkReactNativePackage.kt +12 -4
  4. package/android/src/main/kotlin/com/breeztech/breezsdkspark/BreezSdkSparkPasskeyModule.kt +306 -0
  5. package/android/src/main/kotlin/technology/breez/spark/passkey/core/CredentialManagerPrfCore.kt +955 -0
  6. package/cpp/generated/breez_sdk_spark.cpp +168 -11
  7. package/cpp/generated/breez_sdk_spark.hpp +21 -0
  8. package/ios/BreezSdkSparkPasskey.m +33 -0
  9. package/ios/BreezSdkSparkPasskey.swift +201 -0
  10. package/ios/PasskeyAssertionCore.swift +881 -0
  11. package/ios/PasskeyPRFHelper.h +52 -0
  12. package/ios/PasskeyPRFHelper.m +73 -0
  13. package/lib/commonjs/generated/breez_sdk_spark-ffi.js.map +1 -1
  14. package/lib/commonjs/generated/breez_sdk_spark.js +225 -35
  15. package/lib/commonjs/generated/breez_sdk_spark.js.map +1 -1
  16. package/lib/commonjs/passkey-prf-provider.js +31 -11
  17. package/lib/commonjs/passkey-prf-provider.js.map +1 -1
  18. package/lib/module/generated/breez_sdk_spark-ffi.js.map +1 -1
  19. package/lib/module/generated/breez_sdk_spark.js +224 -34
  20. package/lib/module/generated/breez_sdk_spark.js.map +1 -1
  21. package/lib/module/passkey-prf-provider.js +31 -11
  22. package/lib/module/passkey-prf-provider.js.map +1 -1
  23. package/lib/typescript/commonjs/src/generated/breez_sdk_spark-ffi.d.ts +8 -2
  24. package/lib/typescript/commonjs/src/generated/breez_sdk_spark-ffi.d.ts.map +1 -1
  25. package/lib/typescript/commonjs/src/generated/breez_sdk_spark.d.ts +811 -26
  26. package/lib/typescript/commonjs/src/generated/breez_sdk_spark.d.ts.map +1 -1
  27. package/lib/typescript/commonjs/src/passkey-prf-provider.d.ts +24 -6
  28. package/lib/typescript/commonjs/src/passkey-prf-provider.d.ts.map +1 -1
  29. package/lib/typescript/module/src/generated/breez_sdk_spark-ffi.d.ts +8 -2
  30. package/lib/typescript/module/src/generated/breez_sdk_spark-ffi.d.ts.map +1 -1
  31. package/lib/typescript/module/src/generated/breez_sdk_spark.d.ts +811 -26
  32. package/lib/typescript/module/src/generated/breez_sdk_spark.d.ts.map +1 -1
  33. package/lib/typescript/module/src/passkey-prf-provider.d.ts +24 -6
  34. package/lib/typescript/module/src/passkey-prf-provider.d.ts.map +1 -1
  35. package/package.json +6 -4
  36. package/passkey-prf-provider.d.ts +1 -0
  37. package/passkey-prf-provider.js +5 -0
  38. package/plugin/build/index.d.ts +1 -1
  39. package/plugin/build/index.js +3 -1
  40. package/plugin/build/withAndroid.d.ts +1 -1
  41. package/plugin/build/withAndroid.js +1 -1
  42. package/plugin/build/withBinaryArtifacts.d.ts +1 -1
  43. package/plugin/build/withBinaryArtifacts.js +1 -1
  44. package/plugin/build/withIOS.d.ts +1 -1
  45. package/plugin/build/withIOS.js +1 -1
  46. package/scripts/post-ubrn.js +42 -0
  47. package/src/generated/breez_sdk_spark-ffi.ts +18 -1
  48. package/src/generated/breez_sdk_spark.ts +1303 -36
  49. package/src/passkey-prf-provider.ts +41 -12
@@ -0,0 +1,881 @@
1
+ import AuthenticationServices
2
+ import Foundation
3
+ // PasskeyPRFHelperObjC is a module only under SPM. The Flutter and React
4
+ // Native podspecs compile PasskeyPRFHelper.h/.m into the same pod as this
5
+ // file, where the helper arrives via the pod's umbrella header instead.
6
+ #if canImport(PasskeyPRFHelperObjC)
7
+ import PasskeyPRFHelperObjC
8
+ #endif
9
+ import Security
10
+ #if canImport(UIKit)
11
+ import UIKit
12
+ #elseif canImport(AppKit)
13
+ import AppKit
14
+ #endif
15
+
16
+ /// Canonical iOS/macOS passkey PRF logic. The upstream Swift
17
+ /// `PrfProvider`, the Flutter MethodChannel plugin, and the React Native
18
+ /// bridge each wrap one `PasskeyAssertionCore` and translate
19
+ /// `PasskeyAssertionError` to their own error type.
20
+ ///
21
+ /// Mirrors Android's `CredentialManagerPrfCore.kt`. Synced via
22
+ /// `cargo xtask sync-passkey-core`.
23
+
24
+ // MARK: - Post-create grace
25
+
26
+ /// A newly-registered passkey is briefly not ready for the immediate
27
+ /// post-create assertion: Apple Passwords drops `prf.second` from a
28
+ /// dual-salt assertion (forcing a second single-salt prompt), and GPM
29
+ /// hides the credential from the picker entirely. Holding the next
30
+ /// derive up to 800ms lets the OS finish indexing.
31
+ @available(iOS 18.0, macOS 15.0, *)
32
+ public actor PostCreateGraceTracker {
33
+ public static let defaultTotal: TimeInterval = 0.8
34
+
35
+ private var deadline: Date?
36
+
37
+ public init() {}
38
+
39
+ public func arm(after interval: TimeInterval = PostCreateGraceTracker.defaultTotal) {
40
+ deadline = Date().addingTimeInterval(interval)
41
+ }
42
+
43
+ public func consume() async {
44
+ guard let d = deadline else { return }
45
+ deadline = nil
46
+ let remaining = d.timeIntervalSinceNow
47
+ if remaining > 0 {
48
+ try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
49
+ }
50
+ }
51
+ }
52
+
53
+ // MARK: - Error type
54
+
55
+ /// Layer-neutral error surface. Wrappers translate to their own typed
56
+ /// errors (UniFFI `PrfProviderError`, `FlutterError`, RCT reject codes).
57
+ @available(iOS 18.0, macOS 15.0, *)
58
+ public enum PasskeyAssertionError: Error {
59
+ case userCancelled
60
+ /// The OS biometric prompt timed out without user interaction
61
+ /// (~55s+ inactivity on iOS). Distinct from `userCancelled`, which
62
+ /// means the user actively dismissed the prompt.
63
+ case userTimedOut
64
+ case credentialNotFound(String)
65
+ case credentialAlreadyExists(String)
66
+ case prfNotSupported
67
+ case prfEvaluationFailed(String)
68
+ case configuration(String)
69
+ case authenticationFailed(String)
70
+ case generic(String)
71
+ }
72
+
73
+ // MARK: - Passkey credential
74
+
75
+ /// A passkey credential from a register or sign-in ceremony. Named `Ios*`
76
+ /// to avoid colliding with the UniFFI-generated `PasskeyCredential` the
77
+ /// Swift wrapper translates to.
78
+ ///
79
+ /// `credentialId` is always set. The attestation fields (`userId`,
80
+ /// `aaguid`, `backupEligible`) are populated on registration and `nil` on
81
+ /// sign-in, where an assertion carries no attestation. `userId` is the
82
+ /// core-minted WebAuthn user handle, never host-supplied.
83
+ @available(iOS 18.0, macOS 15.0, *)
84
+ public struct IosPasskeyCredential {
85
+ public let credentialId: Data
86
+ public let userId: Data?
87
+ public let aaguid: Data?
88
+ public let backupEligible: Bool?
89
+
90
+ public init(credentialId: Data, userId: Data?, aaguid: Data?, backupEligible: Bool?) {
91
+ self.credentialId = credentialId
92
+ self.userId = userId
93
+ self.aaguid = aaguid
94
+ self.backupEligible = backupEligible
95
+ }
96
+ }
97
+
98
+ /// A created credential, plus the PRF outputs when the authenticator
99
+ /// evaluated them during the create ceremony. `seeds` is nil when it did
100
+ /// not, so the caller derives through `deriveSeeds` as before.
101
+ @available(iOS 18.0, macOS 15.0, *)
102
+ public struct IosPasskeyRegistration {
103
+ public let credential: IosPasskeyCredential
104
+ public let seeds: [Data]?
105
+
106
+ public init(credential: IosPasskeyCredential, seeds: [Data]?) {
107
+ self.credential = credential
108
+ self.seeds = seeds
109
+ }
110
+ }
111
+
112
+ /// Result of `deriveSeeds`: one 32-byte PRF output per salt (input
113
+ /// order) plus the asserted credential ID. `credentialId` is `nil` when
114
+ /// no assertion ran (empty `salts`).
115
+ public struct PrfDerivation {
116
+ public let seeds: [Data]
117
+ public let credentialId: Data?
118
+
119
+ public init(seeds: [Data], credentialId: Data?) {
120
+ self.seeds = seeds
121
+ self.credentialId = credentialId
122
+ }
123
+ }
124
+
125
+ // MARK: - Domain association
126
+
127
+ /// Result of an Apple-app-site-association probe against the AASA CDN.
128
+ /// Layer-neutral; wrappers translate to their own representation.
129
+ ///
130
+ /// `Skipped` is the catch-all for verification-level failures (missing
131
+ /// team/bundle ID, network error, malformed JSON). It is advisory: the
132
+ /// SDK never blocks the WebAuthn ceremony on it.
133
+ @available(iOS 18.0, macOS 15.0, *)
134
+ public enum IosDomainAssociation {
135
+ case associated
136
+ case notAssociated(source: String, reason: String)
137
+ case skipped(reason: String)
138
+ }
139
+
140
+ // MARK: - Team ID detection
141
+
142
+ /// Auto-detect the 10-character Apple Developer Team ID from the running
143
+ /// app's signing info, by platform:
144
+ ///
145
+ /// - **macOS**: the `application-identifier` entitlement
146
+ /// (`<TEAM_ID>.<BUNDLE_ID>`, split on the first dot) via
147
+ /// `SecTaskCopyValueForEntitlement`.
148
+ /// - **iOS**: `embedded.mobileprovision`, a PKCS#7-wrapped plist. The
149
+ /// plist bytes are plain-text inside the CMS envelope, so locate the
150
+ /// `<?xml>...</plist>` span and deserialize `TeamIdentifier`.
151
+ ///
152
+ /// Returns nil on simulator / unsigned builds (no provisioning profile),
153
+ /// where `checkDomainAssociation` then reports `.skipped`. Cached at
154
+ /// first read: the team ID is stable for the binary's lifetime.
155
+ @available(iOS 18.0, macOS 15.0, *)
156
+ public enum PasskeyTeamIdDetector {
157
+ private static let cached: String? = {
158
+ #if os(macOS)
159
+ return detectFromSecTask()
160
+ #elseif os(iOS)
161
+ return detectFromProvisioningProfile()
162
+ #else
163
+ return nil
164
+ #endif
165
+ }()
166
+
167
+ public static func detect() -> String? { cached }
168
+
169
+ #if os(macOS)
170
+ private static func detectFromSecTask() -> String? {
171
+ guard let task = SecTaskCreateFromSelf(nil) else { return nil }
172
+ let key = "application-identifier" as CFString
173
+ var error: Unmanaged<CFError>?
174
+ guard let value = SecTaskCopyValueForEntitlement(task, key, &error)
175
+ as? String
176
+ else { return nil }
177
+ return parseFromApplicationIdentifier(value)
178
+ }
179
+ #endif
180
+
181
+ #if os(iOS)
182
+ private static func detectFromProvisioningProfile() -> String? {
183
+ guard let url = Bundle.main.url(forResource: "embedded", withExtension: "mobileprovision"),
184
+ let data = try? Data(contentsOf: url)
185
+ else { return nil }
186
+ // `.isoLatin1` (not `.ascii`) because the PKCS#7 envelope has
187
+ // binary DER bytes > 127; it maps 1:1 and always succeeds. The
188
+ // string only locates the plist span; parsing uses the raw slice.
189
+ guard let raw = String(data: data, encoding: .isoLatin1),
190
+ let startRange = raw.range(of: "<?xml"),
191
+ let endRange = raw.range(of: "</plist>")
192
+ else { return nil }
193
+ let startByteOffset = raw.utf16.distance(
194
+ from: raw.utf16.startIndex,
195
+ to: startRange.lowerBound.samePosition(in: raw.utf16) ?? raw.utf16.startIndex
196
+ )
197
+ let endByteOffset = raw.utf16.distance(
198
+ from: raw.utf16.startIndex,
199
+ to: endRange.upperBound.samePosition(in: raw.utf16) ?? raw.utf16.endIndex
200
+ )
201
+ guard startByteOffset < endByteOffset, endByteOffset <= data.count
202
+ else { return nil }
203
+ let plistData = data.subdata(in: startByteOffset..<endByteOffset)
204
+ guard let plist = try? PropertyListSerialization.propertyList(
205
+ from: plistData, format: nil
206
+ ) as? [String: Any]
207
+ else { return nil }
208
+ if let team = (plist["TeamIdentifier"] as? [String])?.first {
209
+ return validate(team)
210
+ }
211
+ if let prefix = (plist["ApplicationIdentifierPrefix"] as? [String])?.first {
212
+ return validate(prefix)
213
+ }
214
+ return nil
215
+ }
216
+ #endif
217
+
218
+ private static func parseFromApplicationIdentifier(_ value: String) -> String? {
219
+ guard let firstDot = value.firstIndex(of: ".") else { return nil }
220
+ return validate(String(value[..<firstDot]))
221
+ }
222
+
223
+ private static func validate(_ candidate: String) -> String? {
224
+ guard candidate.count == 10,
225
+ candidate.allSatisfy({ $0.isLetter || $0.isNumber })
226
+ else { return nil }
227
+ return candidate
228
+ }
229
+ }
230
+
231
+ // MARK: - Presentation anchor
232
+
233
+ /// Layer-neutral presentation anchor protocol. Wrappers can supply a
234
+ /// custom anchor (e.g. SceneDelegate-aware) or fall back to the
235
+ /// platform default.
236
+ @available(iOS 18.0, macOS 15.0, *)
237
+ public protocol PasskeyPresentationAnchorProvider: AnyObject {
238
+ func presentationAnchor() -> ASPresentationAnchor
239
+ }
240
+
241
+ @available(iOS 18.0, macOS 15.0, *)
242
+ public final class DefaultPasskeyPresentationAnchorProvider: PasskeyPresentationAnchorProvider {
243
+ public init() {}
244
+
245
+ public func presentationAnchor() -> ASPresentationAnchor {
246
+ #if os(iOS)
247
+ if let scene = UIApplication.shared.connectedScenes
248
+ .compactMap({ $0 as? UIWindowScene })
249
+ .first(where: { $0.activationState == .foregroundActive }),
250
+ let window = scene.windows.first(where: { $0.isKeyWindow }) {
251
+ return window
252
+ }
253
+ if let window = UIApplication.shared.connectedScenes
254
+ .compactMap({ $0 as? UIWindowScene })
255
+ .flatMap({ $0.windows })
256
+ .first {
257
+ return window
258
+ }
259
+ return ASPresentationAnchor()
260
+ #elseif os(macOS)
261
+ return NSApplication.shared.keyWindow ?? ASPresentationAnchor()
262
+ #else
263
+ return ASPresentationAnchor()
264
+ #endif
265
+ }
266
+ }
267
+
268
+ // MARK: - Core
269
+
270
+ /// Reusable WebAuthn PRF logic; holds no per-request state. Owns all
271
+ /// ASAuthorizationController orchestration (assertion, bulk derivation,
272
+ /// registration) plus the post-create grace tracker.
273
+ @available(iOS 18.0, macOS 15.0, *)
274
+ public final class PasskeyAssertionCore {
275
+ private let rpId: String
276
+ private let rpName: String
277
+ private let userName: String
278
+ private let userDisplayName: String
279
+ private let explicitTeamId: String?
280
+ private let urlSession: URLSession
281
+ private let anchor: PasskeyPresentationAnchorProvider
282
+ private let graceTracker: PostCreateGraceTracker
283
+ private let postCreateGraceTotal: TimeInterval
284
+
285
+ public init(
286
+ rpId: String,
287
+ rpName: String,
288
+ userName: String,
289
+ userDisplayName: String,
290
+ explicitTeamId: String? = nil,
291
+ urlSession: URLSession = .shared,
292
+ anchorProvider: PasskeyPresentationAnchorProvider? = nil,
293
+ graceTracker: PostCreateGraceTracker = PostCreateGraceTracker(),
294
+ postCreateGraceTotal: TimeInterval = PostCreateGraceTracker.defaultTotal
295
+ ) {
296
+ self.rpId = rpId
297
+ self.rpName = rpName
298
+ self.userName = userName
299
+ self.userDisplayName = userDisplayName
300
+ self.explicitTeamId = explicitTeamId
301
+ self.urlSession = urlSession
302
+ self.anchor = anchorProvider ?? DefaultPasskeyPresentationAnchorProvider()
303
+ self.graceTracker = graceTracker
304
+ self.postCreateGraceTotal = postCreateGraceTotal
305
+ }
306
+
307
+ // MARK: Public entry points
308
+
309
+ /// Derive one 32-byte PRF output per salt in as few authenticator
310
+ /// ceremonies as the platform supports: salts are walked in pairs
311
+ /// (one dual-salt assertion each via `prf.eval.first`/`.second`),
312
+ /// and an authenticator that drops `second` is recovered with a
313
+ /// single-salt re-assert. When no credential exists yet and
314
+ /// [autoRegister] is set, the first miss registers a passkey and
315
+ /// retries. Output ordering matches input ordering.
316
+ public func deriveSeeds(
317
+ salts: [Data],
318
+ autoRegister: Bool,
319
+ allowCredentials: [Data] = [],
320
+ preferImmediatelyAvailableCredentials: Bool = true
321
+ ) async throws -> PrfDerivation {
322
+ var allow = allowCredentials
323
+ // Wait out the post-create grace so the immediate derive doesn't
324
+ // race the credential's PRF-readiness window (see grace tracker).
325
+ await graceTracker.consume()
326
+ if salts.isEmpty { return PrfDerivation(seeds: [], credentialId: nil) }
327
+
328
+ // One assertion for 1-2 salts, registering + retrying once on no
329
+ // credential. Returns (first, second?, credentialId); `second` is
330
+ // nil when the authenticator dropped saltInput2. After the first
331
+ // chunk the caller pins `allow` to its credential, so every chunk
332
+ // resolves to the same one.
333
+ func assertChunk(_ salt1: Data, _ salt2: Data?) async throws -> (Data, Data?, Data) {
334
+ do {
335
+ return try await assertPrf(
336
+ salt1: salt1, salt2: salt2, allowCredentials: allow,
337
+ preferImmediatelyAvailableCredentials: preferImmediatelyAvailableCredentials
338
+ )
339
+ } catch PasskeyAssertionError.credentialNotFound(_) {
340
+ guard autoRegister else {
341
+ throw PasskeyAssertionError.credentialNotFound(
342
+ "No matching credential on this device"
343
+ )
344
+ }
345
+ do {
346
+ _ = try await register()
347
+ } catch PasskeyAssertionError.credentialNotFound(_) {
348
+ throw PasskeyAssertionError.configuration(
349
+ "Associated Domains entitlement not configured. "
350
+ + "Add 'webcredentials:\(rpId)' to your app's entitlements "
351
+ + "and ensure a valid provisioning profile."
352
+ )
353
+ }
354
+ // Retry once. A second miss (e.g. user deleted the pinned
355
+ // credential in Settings) escapes as credentialNotFound for
356
+ // hosts to treat as deletion recovery.
357
+ return try await assertPrf(
358
+ salt1: salt1, salt2: salt2, allowCredentials: allow,
359
+ preferImmediatelyAvailableCredentials: preferImmediatelyAvailableCredentials
360
+ )
361
+ }
362
+ }
363
+
364
+ var out: [Data] = []
365
+ // Asserted credential ID, returned inline so the binding layer can
366
+ // surface it on `SignInResponse.credential_id` without a separate
367
+ // read-and-clear call.
368
+ var observedCredentialId: Data?
369
+ var i = 0
370
+ while i < salts.count {
371
+ if i + 1 < salts.count {
372
+ let (first, second, credId) = try await assertChunk(salts[i], salts[i + 1])
373
+ observedCredentialId = credId
374
+ // Pin every later assertion in this call to the credential the
375
+ // first one resolved to, so all salts derive from one passkey
376
+ // even when a chunk splits (dropped `second`, or 3+ salts).
377
+ allow = [credId]
378
+ out.append(first)
379
+ if let second = second {
380
+ out.append(second)
381
+ } else {
382
+ // Authenticator dropped `second`: single-salt recover,
383
+ // pinned to the same credential as `first`.
384
+ let (recovered, _, _) = try await assertChunk(salts[i + 1], nil)
385
+ out.append(recovered)
386
+ }
387
+ i += 2
388
+ } else {
389
+ let (single, _, credId) = try await assertChunk(salts[i], nil)
390
+ observedCredentialId = credId
391
+ allow = [credId]
392
+ out.append(single)
393
+ i += 1
394
+ }
395
+ }
396
+ return PrfDerivation(seeds: out, credentialId: observedCredentialId)
397
+ }
398
+
399
+ /// Verify the app's bundle ID is listed in `webcredentials.apps` of
400
+ /// the AASA file for `rpId`, via Apple's app-site-association CDN
401
+ /// (`https://app-site-association.cdn-apple.com/a/v1/<rpId>`). This is
402
+ /// the same source the OS uses for Associated Domains, queried up
403
+ /// front so integrators see misconfiguration before a WebAuthn
404
+ /// ceremony fails opaquely.
405
+ ///
406
+ /// Team ID comes from `explicitTeamId` or `PasskeyTeamIdDetector`.
407
+ /// Never throws: every verification-level failure (no bundle/team ID,
408
+ /// network error, malformed JSON) maps to `.skipped`.
409
+ public func checkDomainAssociation() async -> IosDomainAssociation {
410
+ let bundleId = Bundle.main.bundleIdentifier ?? ""
411
+ guard !bundleId.isEmpty else {
412
+ return .skipped(
413
+ reason: "Bundle.main.bundleIdentifier is empty (unsigned / test context?)"
414
+ )
415
+ }
416
+
417
+ guard let teamId = explicitTeamId ?? PasskeyTeamIdDetector.detect() else {
418
+ return .skipped(
419
+ reason:
420
+ "Could not resolve Apple Developer Team ID "
421
+ + "(no explicit teamId and SecTaskCopyValueForEntitlement "
422
+ + "lookup failed)"
423
+ )
424
+ }
425
+
426
+ let fullAppId = "\(teamId).\(bundleId)"
427
+ let cdnUrl = "https://app-site-association.cdn-apple.com/a/v1/\(rpId)"
428
+ guard let url = URL(string: cdnUrl) else {
429
+ return .skipped(reason: "Invalid AASA CDN URL: \(cdnUrl)")
430
+ }
431
+
432
+ var request = URLRequest(url: url)
433
+ request.timeoutInterval = 3.0
434
+ request.httpMethod = "GET"
435
+
436
+ do {
437
+ let (data, response) = try await urlSession.data(for: request)
438
+ guard let httpResponse = response as? HTTPURLResponse else {
439
+ return .skipped(reason: "AASA CDN returned non-HTTP response")
440
+ }
441
+ guard httpResponse.statusCode == 200 else {
442
+ return .skipped(
443
+ reason: "AASA CDN returned HTTP \(httpResponse.statusCode)"
444
+ )
445
+ }
446
+ guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
447
+ let webcredentials = json["webcredentials"] as? [String: Any],
448
+ let apps = webcredentials["apps"] as? [String]
449
+ else {
450
+ return .skipped(
451
+ reason:
452
+ "AASA CDN returned unparseable JSON or missing "
453
+ + "webcredentials.apps for \(rpId)"
454
+ )
455
+ }
456
+
457
+ if apps.contains(fullAppId) {
458
+ return .associated
459
+ } else {
460
+ return .notAssociated(
461
+ source: "Apple app-site-association CDN",
462
+ reason:
463
+ "Bundle ID \(fullAppId) not in webcredentials.apps "
464
+ + "for \(rpId). CDN listed: [\(apps.joined(separator: ", "))]"
465
+ )
466
+ }
467
+ } catch {
468
+ return .skipped(
469
+ reason: "AASA CDN fetch failed: \(error.localizedDescription)"
470
+ )
471
+ }
472
+ }
473
+
474
+ // MARK: Private helpers
475
+
476
+ /// Build an assertion request with rpId, challenge, allow-credentials,
477
+ /// and the caller-supplied PRF setup. Shared by single- and dual-salt.
478
+ private func makeAssertionRequest(
479
+ rpId: String,
480
+ explicitAllowCredentials: [Data],
481
+ configurePrf: (ASAuthorizationPlatformPublicKeyCredentialAssertionRequest) -> Void
482
+ ) -> ASAuthorizationPlatformPublicKeyCredentialAssertionRequest {
483
+ let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: rpId)
484
+ let request = provider.createCredentialAssertionRequest(challenge: Self.randomBytes(count: 32))
485
+ applyAllowedCredentials(
486
+ to: request,
487
+ explicitAllowCredentials: explicitAllowCredentials
488
+ )
489
+ configurePrf(request)
490
+ return request
491
+ }
492
+
493
+ /// Pin the assertion to caller-supplied credential IDs. With this
494
+ /// set + `preferImmediatelyAvailableCredentials`, iOS auto-routes
495
+ /// to a single matching credential. Empty means fully discoverable.
496
+ private func applyAllowedCredentials(
497
+ to request: ASAuthorizationPlatformPublicKeyCredentialAssertionRequest,
498
+ explicitAllowCredentials: [Data]
499
+ ) {
500
+ if !explicitAllowCredentials.isEmpty {
501
+ request.allowedCredentials = explicitAllowCredentials.map {
502
+ ASAuthorizationPlatformPublicKeyCredentialDescriptor(credentialID: $0)
503
+ }
504
+ }
505
+ }
506
+
507
+ /// Run one assertion ceremony for `salt1` (+ optional `salt2`),
508
+ /// returning `(first, second?, credentialId)`. `second` is nil for a
509
+ /// single salt or when the authenticator dropped `saltInput2`. The
510
+ /// ObjC helper treats nil `salt2` as single-salt, so this serves both.
511
+ private func assertPrf(
512
+ salt1: Data,
513
+ salt2: Data?,
514
+ allowCredentials: [Data],
515
+ preferImmediatelyAvailableCredentials: Bool = true
516
+ ) async throws -> (Data, Data?, Data) {
517
+ let request = makeAssertionRequest(
518
+ rpId: rpId,
519
+ explicitAllowCredentials: allowCredentials
520
+ ) { req in
521
+ // PRF types are NS_REFINED_FOR_SWIFT with no accessible Swift
522
+ // initializers; the ObjC helper sets them via runtime KVC.
523
+ PasskeyPRFHelper.setAssertionPRFOn(req, withSalt1: salt1, salt2: salt2)
524
+ }
525
+
526
+ let delegate = PasskeyDelegate()
527
+ let controller = ASAuthorizationController(authorizationRequests: [request])
528
+ controller.delegate = delegate
529
+ controller.presentationContextProvider = delegate
530
+ delegate.anchor = anchor.presentationAnchor()
531
+
532
+ let preferImmediate = preferImmediatelyAvailableCredentials
533
+ let result = try await withCheckedThrowingContinuation { continuation in
534
+ delegate.assertionContinuation = continuation
535
+ delegate.extractPrf = true
536
+ DispatchQueue.main.async {
537
+ // Capture start time inside the closure so the wait for
538
+ // the main-thread dispatch doesn't count as ceremony time
539
+ // (a busy main thread can push a sub-300ms fast-fail past
540
+ // the no-credential threshold and misclassify it).
541
+ delegate.ceremonyStartedAt = Date()
542
+ Self.performAssertionRequest(
543
+ controller,
544
+ preferImmediatelyAvailableCredentials: preferImmediate
545
+ )
546
+ }
547
+ }
548
+
549
+ return result
550
+ }
551
+
552
+ /// Wraps `controller.performRequests`, suppressing the hybrid
553
+ /// (cross-device QR) sign-in option. Wallet-style integrators target
554
+ /// only local credentials, so a fast `.canceled` beats a confusing QR
555
+ /// sheet when no passkey is on the device.
556
+ private static func performAssertionRequest(
557
+ _ controller: ASAuthorizationController,
558
+ preferImmediatelyAvailableCredentials: Bool = true
559
+ ) {
560
+ if #available(iOS 16.0, macOS 13.0, *), preferImmediatelyAvailableCredentials {
561
+ controller.performRequests(options: .preferImmediatelyAvailableCredentials)
562
+ } else {
563
+ controller.performRequests()
564
+ }
565
+ }
566
+
567
+ /// Register a new passkey with PRF support. `excludeCredentials` lists
568
+ /// already-registered IDs the platform must refuse as duplicates.
569
+ /// `userId` is never host-supplied: the core mints a fresh random
570
+ /// 16-byte value and returns it on `IosPasskeyCredential.userId`.
571
+ ///
572
+ /// Asking the create ceremony to evaluate PRF for `salts` makes
573
+ /// registration a single ceremony, so no assertion follows it.
574
+ /// `IosPasskeyRegistration.seeds` is nil when the authenticator
575
+ /// reported PRF support without evaluating, and when it returned fewer
576
+ /// outputs than salts; callers derive through `deriveSeeds` in both,
577
+ /// and the post-create grace tracker is armed only in those cases.
578
+ @discardableResult
579
+ public func register(
580
+ excludeCredentials: [Data] = [],
581
+ salts: [String] = []
582
+ ) async throws -> IosPasskeyRegistration {
583
+ let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: rpId)
584
+ let challenge = Self.randomBytes(count: 32)
585
+ let resolvedUserId = Self.randomBytes(count: 16)
586
+ // The platform provider only exposes the 3-arg
587
+ // challenge:name:userID: overload on current SDKs; the 4-arg
588
+ // displayName overload is security-key-only. Password managers
589
+ // (Apple Passwords, GPM) show `user.name` as the primary label.
590
+ _ = userDisplayName // accepted for parity with caller signatures; not consumed by the platform overload
591
+ let request = provider.createCredentialRegistrationRequest(
592
+ challenge: challenge,
593
+ name: userName,
594
+ userID: resolvedUserId
595
+ )
596
+
597
+ if !excludeCredentials.isEmpty {
598
+ request.excludedCredentials = excludeCredentials.map {
599
+ ASAuthorizationPlatformPublicKeyCredentialDescriptor(credentialID: $0)
600
+ }
601
+ }
602
+
603
+ // Asking the create ceremony to evaluate PRF removes the
604
+ // assertion that would otherwise follow it.
605
+ PasskeyPRFHelper.setRegistrationPRFOn(
606
+ request,
607
+ withSalt1: salts.first.map { Data($0.utf8) },
608
+ salt2: salts.count > 1 ? Data(salts[1].utf8) : nil
609
+ )
610
+
611
+ let delegate = PasskeyDelegate()
612
+ let controller = ASAuthorizationController(authorizationRequests: [request])
613
+ controller.delegate = delegate
614
+ controller.presentationContextProvider = delegate
615
+ delegate.anchor = anchor.presentationAnchor()
616
+ // The platform never echoes `user.id` back, so hand the delegate
617
+ // our minted handle to attach to the returned credential.
618
+ delegate.registrationUserId = resolvedUserId
619
+
620
+ let registration: IosPasskeyRegistration = try await withCheckedThrowingContinuation { continuation in
621
+ delegate.registrationContinuation = continuation
622
+ delegate.registrationSaltCount = salts.count
623
+ delegate.extractPrf = false
624
+ DispatchQueue.main.async {
625
+ // Capture start time inside the closure so the wait for
626
+ // the main-thread dispatch doesn't count as ceremony time.
627
+ delegate.ceremonyStartedAt = Date()
628
+ controller.performRequests()
629
+ }
630
+ }
631
+
632
+ // Arm the post-create grace only when a derive still has to run:
633
+ // the window exists for that assertion. Arming it on the
634
+ // inline-seeds path would leave it set for whatever derive came
635
+ // next, which is a different ceremony entirely.
636
+ if registration.seeds == nil {
637
+ await graceTracker.arm(after: postCreateGraceTotal)
638
+ }
639
+ return registration
640
+ }
641
+
642
+ public static func randomBytes(count: Int) -> Data {
643
+ var bytes = [UInt8](repeating: 0, count: count)
644
+ _ = SecRandomCopyBytes(kSecRandomDefault, count, &bytes)
645
+ return Data(bytes)
646
+ }
647
+ }
648
+
649
+ // MARK: - Registered credential metadata
650
+
651
+ /// Extract AAGUID + BE flag from the attestation object's authenticator
652
+ /// data via byte-pattern search for the "authData" CBOR key. Returns nil
653
+ /// when not found or too short.
654
+ ///
655
+ /// authData layout when AT flag is set (always on a successful create):
656
+ /// [32] flags (UP=0, UV=2, BE=3, BS=4, AT=6)
657
+ /// [37..53) AAGUID (16 bytes)
658
+ @available(iOS 18.0, macOS 15.0, *)
659
+ public func extractRegistrationMetadata(from attestation: Data) -> (aaguid: Data, backupEligible: Bool)? {
660
+ let bytes = [UInt8](attestation)
661
+ // CBOR text key "authData": 0x68 = major type 3 (text) + length 8.
662
+ let key: [UInt8] = [0x68, 0x61, 0x75, 0x74, 0x68, 0x44, 0x61, 0x74, 0x61]
663
+ guard bytes.count >= key.count else { return nil }
664
+ var keyEnd = -1
665
+ for i in 0...(bytes.count - key.count) {
666
+ var match = true
667
+ for j in 0..<key.count where bytes[i + j] != key[j] {
668
+ match = false
669
+ break
670
+ }
671
+ if match { keyEnd = i + key.count; break }
672
+ }
673
+ guard keyEnd >= 0 && keyEnd < bytes.count else { return nil }
674
+
675
+ // Parse CBOR byte string (major type 2) at keyEnd.
676
+ let header = bytes[keyEnd]
677
+ guard header >> 5 == 2 else { return nil }
678
+ let minor = Int(header & 0x1f)
679
+ let length: Int
680
+ let dataStart: Int
681
+ switch minor {
682
+ case 0..<24:
683
+ length = minor
684
+ dataStart = keyEnd + 1
685
+ case 24:
686
+ guard keyEnd + 1 < bytes.count else { return nil }
687
+ length = Int(bytes[keyEnd + 1])
688
+ dataStart = keyEnd + 2
689
+ case 25:
690
+ guard keyEnd + 2 < bytes.count else { return nil }
691
+ length = (Int(bytes[keyEnd + 1]) << 8) | Int(bytes[keyEnd + 2])
692
+ dataStart = keyEnd + 3
693
+ case 26:
694
+ guard keyEnd + 4 < bytes.count else { return nil }
695
+ length = (Int(bytes[keyEnd + 1]) << 24) | (Int(bytes[keyEnd + 2]) << 16)
696
+ | (Int(bytes[keyEnd + 3]) << 8) | Int(bytes[keyEnd + 4])
697
+ dataStart = keyEnd + 5
698
+ default:
699
+ return nil
700
+ }
701
+ guard dataStart + length <= bytes.count, length >= 53 else { return nil }
702
+ let flags = bytes[dataStart + 32]
703
+ guard flags & 0x40 != 0 else { return nil }
704
+ let backupEligible = flags & 0x08 != 0
705
+ let aaguid = Data(bytes[(dataStart + 37)..<(dataStart + 53)])
706
+ return (aaguid: aaguid, backupEligible: backupEligible)
707
+ }
708
+
709
+ // MARK: - Authorization Delegate
710
+
711
+ /// Delegate handling both the assertion and registration ceremonies,
712
+ /// selected at the call site by which continuation is set
713
+ /// (`assertionContinuation` or `registrationContinuation`).
714
+ @available(iOS 18.0, macOS 15.0, *)
715
+ private final class PasskeyDelegate: NSObject, ASAuthorizationControllerDelegate,
716
+ ASAuthorizationControllerPresentationContextProviding
717
+ {
718
+ /// Resolves `(first, second?, credentialId)`; `second` is nil for a
719
+ /// single salt or a dropped `saltInput2`.
720
+ var assertionContinuation: CheckedContinuation<(Data, Data?, Data), Error>?
721
+ var registrationContinuation: CheckedContinuation<IosPasskeyRegistration, Error>?
722
+ /// Salts requested at create, so the delegate knows how many PRF
723
+ /// outputs a complete result carries.
724
+ var registrationSaltCount: Int = 0
725
+ /// User handle the core minted for the in-flight registration; copied
726
+ /// into the returned `IosPasskeyCredential.userId` (the platform
727
+ /// never echoes `user.id` back).
728
+ var registrationUserId: Data = Data()
729
+ var anchor: ASPresentationAnchor = ASPresentationAnchor()
730
+ /// `true` for assertion ceremonies, `false` for registration.
731
+ var extractPrf = true
732
+ /// Set when `performRequests()` fires; `mapPasskeyError` uses the
733
+ /// elapsed time to tell the biometric inactivity timeout from a
734
+ /// user-dismissed prompt (both arrive as `.canceled`).
735
+ var ceremonyStartedAt: Date?
736
+
737
+ func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
738
+ return anchor
739
+ }
740
+
741
+ func authorizationController(
742
+ controller: ASAuthorizationController,
743
+ didCompleteWithAuthorization authorization: ASAuthorization
744
+ ) {
745
+ if extractPrf {
746
+ guard let credential = authorization.credential
747
+ as? ASAuthorizationPlatformPublicKeyCredentialAssertion
748
+ else {
749
+ assertionContinuation?.resume(
750
+ throwing: PasskeyAssertionError.authenticationFailed("Unexpected credential type"))
751
+ return
752
+ }
753
+
754
+ guard let prfFirst = PasskeyPRFHelper.extractPRFOutput(from: credential) else {
755
+ assertionContinuation?.resume(throwing: PasskeyAssertionError.prfNotSupported)
756
+ return
757
+ }
758
+
759
+ assertionContinuation?.resume(
760
+ returning: (
761
+ prfFirst,
762
+ PasskeyPRFHelper.extractSecondPRFOutput(from: credential),
763
+ credential.credentialID
764
+ ))
765
+ } else {
766
+ guard let credential = authorization.credential
767
+ as? ASAuthorizationPlatformPublicKeyCredentialRegistration
768
+ else {
769
+ registrationContinuation?.resume(
770
+ throwing: PasskeyAssertionError.authenticationFailed("Unexpected credential type"))
771
+ return
772
+ }
773
+ var aaguid: Data? = nil
774
+ var backupEligible: Bool? = nil
775
+ if let attestation = credential.rawAttestationObject,
776
+ let meta = extractRegistrationMetadata(from: attestation)
777
+ {
778
+ aaguid = meta.aaguid
779
+ backupEligible = meta.backupEligible
780
+ }
781
+ // Only usable as a complete set: Apple Passwords is known to
782
+ // drop `prf.second` on assertions, and a partial derive is no
783
+ // derive at all, so anything short of one output per salt
784
+ // falls back to the assertion path.
785
+ var seeds: [Data]? = nil
786
+ if registrationSaltCount > 0 {
787
+ var collected: [Data] = []
788
+ if let first = PasskeyPRFHelper.extractPRFOutput(from: credential) {
789
+ collected.append(first)
790
+ if let second = PasskeyPRFHelper.extractSecondPRFOutput(from: credential) {
791
+ collected.append(second)
792
+ }
793
+ }
794
+ if collected.count == registrationSaltCount {
795
+ seeds = collected
796
+ }
797
+ }
798
+ registrationContinuation?.resume(
799
+ returning: IosPasskeyRegistration(
800
+ credential: IosPasskeyCredential(
801
+ credentialId: credential.credentialID,
802
+ userId: registrationUserId,
803
+ aaguid: aaguid,
804
+ backupEligible: backupEligible
805
+ ),
806
+ seeds: seeds
807
+ ))
808
+ }
809
+ }
810
+
811
+ func authorizationController(
812
+ controller: ASAuthorizationController,
813
+ didCompleteWithError error: Error
814
+ ) {
815
+ let elapsedMs: Double? = ceremonyStartedAt
816
+ .map { Date().timeIntervalSince($0) * 1000.0 }
817
+ let mapped = mapPasskeyError(error, elapsedMs: elapsedMs)
818
+ assertionContinuation?.resume(throwing: mapped)
819
+ registrationContinuation?.resume(throwing: mapped)
820
+ }
821
+ }
822
+
823
+ /// Map an `ASAuthorizationError` to `PasskeyAssertionError`.
824
+ ///
825
+ /// The OS collapses three distinct `.canceled` cases into one code (the
826
+ /// suppressed QR sheet leaves no in-process signal to disambiguate): no
827
+ /// matching credential (fast-fail before any UI), user-dismissed prompt,
828
+ /// and the biometric inactivity timeout (sheet torn down at ~55s).
829
+ /// Elapsed wall-clock time separates them:
830
+ /// - `< 300ms` -> `.credentialNotFound`
831
+ /// - `>= 55_000ms` -> `.userTimedOut`
832
+ /// - in between -> `.userCancelled`
833
+ @available(iOS 18.0, macOS 15.0, *)
834
+ public func mapPasskeyError(
835
+ _ error: Error,
836
+ elapsedMs: Double? = nil
837
+ ) -> PasskeyAssertionError {
838
+ let nsError = error as NSError
839
+ if nsError.domain == ASAuthorizationError.errorDomain {
840
+ switch ASAuthorizationError.Code(rawValue: nsError.code) {
841
+ case .canceled:
842
+ return classifyCanceled(elapsedMs: elapsedMs)
843
+ case .unknown:
844
+ if nsError.localizedDescription.contains("no credential")
845
+ || nsError.localizedDescription.contains("No credentials")
846
+ {
847
+ return .credentialNotFound(nsError.localizedDescription)
848
+ }
849
+ return .authenticationFailed(nsError.localizedDescription)
850
+ case .invalidResponse:
851
+ return .prfEvaluationFailed(nsError.localizedDescription)
852
+ case .notHandled:
853
+ return .credentialNotFound(nsError.localizedDescription)
854
+ case .failed:
855
+ return .authenticationFailed(nsError.localizedDescription)
856
+ case .notInteractive:
857
+ return .authenticationFailed("User interaction required")
858
+ case .matchedExcludedCredential:
859
+ return .credentialAlreadyExists("Credential already registered")
860
+ default:
861
+ return .generic(nsError.localizedDescription)
862
+ }
863
+ }
864
+ return .generic(error.localizedDescription)
865
+ }
866
+
867
+ /// Apply the `.canceled` timing thresholds (see `mapPasskeyError`).
868
+ @available(iOS 18.0, macOS 15.0, *)
869
+ private func classifyCanceled(elapsedMs: Double?) -> PasskeyAssertionError {
870
+ guard let elapsed = elapsedMs else {
871
+ // No timing context: default to userCancelled.
872
+ return .userCancelled
873
+ }
874
+ if elapsed < 300 {
875
+ return .credentialNotFound("Credential not found")
876
+ }
877
+ if elapsed >= 55_000 {
878
+ return .userTimedOut
879
+ }
880
+ return .userCancelled
881
+ }