@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.
- package/BreezSdkSparkReactNative.podspec +6 -0
- package/android/build.gradle +5 -0
- package/android/src/main/java/com/breeztech/breezsdkspark/BreezSdkSparkReactNativePackage.kt +12 -4
- package/android/src/main/kotlin/com/breeztech/breezsdkspark/BreezSdkSparkPasskeyModule.kt +306 -0
- package/android/src/main/kotlin/technology/breez/spark/passkey/core/CredentialManagerPrfCore.kt +955 -0
- package/cpp/generated/breez_sdk_spark.cpp +168 -11
- package/cpp/generated/breez_sdk_spark.hpp +21 -0
- package/ios/BreezSdkSparkPasskey.m +33 -0
- package/ios/BreezSdkSparkPasskey.swift +201 -0
- package/ios/PasskeyAssertionCore.swift +881 -0
- package/ios/PasskeyPRFHelper.h +52 -0
- package/ios/PasskeyPRFHelper.m +73 -0
- package/lib/commonjs/generated/breez_sdk_spark-ffi.js.map +1 -1
- package/lib/commonjs/generated/breez_sdk_spark.js +225 -35
- package/lib/commonjs/generated/breez_sdk_spark.js.map +1 -1
- package/lib/commonjs/passkey-prf-provider.js +31 -11
- package/lib/commonjs/passkey-prf-provider.js.map +1 -1
- package/lib/module/generated/breez_sdk_spark-ffi.js.map +1 -1
- package/lib/module/generated/breez_sdk_spark.js +224 -34
- package/lib/module/generated/breez_sdk_spark.js.map +1 -1
- package/lib/module/passkey-prf-provider.js +31 -11
- package/lib/module/passkey-prf-provider.js.map +1 -1
- package/lib/typescript/commonjs/src/generated/breez_sdk_spark-ffi.d.ts +8 -2
- package/lib/typescript/commonjs/src/generated/breez_sdk_spark-ffi.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/generated/breez_sdk_spark.d.ts +811 -26
- package/lib/typescript/commonjs/src/generated/breez_sdk_spark.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/passkey-prf-provider.d.ts +24 -6
- package/lib/typescript/commonjs/src/passkey-prf-provider.d.ts.map +1 -1
- package/lib/typescript/module/src/generated/breez_sdk_spark-ffi.d.ts +8 -2
- package/lib/typescript/module/src/generated/breez_sdk_spark-ffi.d.ts.map +1 -1
- package/lib/typescript/module/src/generated/breez_sdk_spark.d.ts +811 -26
- package/lib/typescript/module/src/generated/breez_sdk_spark.d.ts.map +1 -1
- package/lib/typescript/module/src/passkey-prf-provider.d.ts +24 -6
- package/lib/typescript/module/src/passkey-prf-provider.d.ts.map +1 -1
- package/package.json +6 -4
- package/passkey-prf-provider.d.ts +1 -0
- package/passkey-prf-provider.js +5 -0
- package/plugin/build/index.d.ts +1 -1
- package/plugin/build/index.js +3 -1
- package/plugin/build/withAndroid.d.ts +1 -1
- package/plugin/build/withAndroid.js +1 -1
- package/plugin/build/withBinaryArtifacts.d.ts +1 -1
- package/plugin/build/withBinaryArtifacts.js +1 -1
- package/plugin/build/withIOS.d.ts +1 -1
- package/plugin/build/withIOS.js +1 -1
- package/scripts/post-ubrn.js +42 -0
- package/src/generated/breez_sdk_spark-ffi.ts +18 -1
- package/src/generated/breez_sdk_spark.ts +1303 -36
- 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
|
+
}
|