@breeztech/breez-sdk-spark-react-native 0.21.0 → 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/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/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/package.json +5 -3
- package/passkey-prf-provider.d.ts +1 -0
- package/passkey-prf-provider.js +5 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#import <React/RCTBridgeModule.h>
|
|
2
|
+
|
|
3
|
+
@interface RCT_EXTERN_MODULE(BreezSdkSparkPasskey, NSObject)
|
|
4
|
+
|
|
5
|
+
RCT_EXTERN_METHOD(deriveSeeds:(NSArray *)salts
|
|
6
|
+
rpId:(NSString *)rpId
|
|
7
|
+
rpName:(NSString *)rpName
|
|
8
|
+
userName:(NSString *)userName
|
|
9
|
+
userDisplayName:(NSString *)userDisplayName
|
|
10
|
+
autoRegister:(BOOL)autoRegister
|
|
11
|
+
allowCredentials:(NSArray *)allowCredentials
|
|
12
|
+
preferImmediatelyAvailableCredentials:(nullable NSNumber *)preferImmediatelyAvailableCredentials
|
|
13
|
+
resolve:(RCTPromiseResolveBlock)resolve
|
|
14
|
+
reject:(RCTPromiseRejectBlock)reject)
|
|
15
|
+
|
|
16
|
+
RCT_EXTERN_METHOD(createPasskey:(NSString *)rpId
|
|
17
|
+
rpName:(NSString *)rpName
|
|
18
|
+
userName:(NSString *)userName
|
|
19
|
+
userDisplayName:(NSString *)userDisplayName
|
|
20
|
+
excludeCredentials:(NSArray *)excludeCredentials
|
|
21
|
+
registerSalts:(NSArray *)registerSalts
|
|
22
|
+
resolve:(RCTPromiseResolveBlock)resolve
|
|
23
|
+
reject:(RCTPromiseRejectBlock)reject)
|
|
24
|
+
|
|
25
|
+
RCT_EXTERN_METHOD(isSupported:(RCTPromiseResolveBlock)resolve
|
|
26
|
+
reject:(RCTPromiseRejectBlock)reject)
|
|
27
|
+
|
|
28
|
+
RCT_EXTERN_METHOD(checkDomainAssociation:(NSString *)rpId
|
|
29
|
+
teamId:(NSString *)teamId
|
|
30
|
+
resolve:(RCTPromiseResolveBlock)resolve
|
|
31
|
+
reject:(RCTPromiseRejectBlock)reject)
|
|
32
|
+
|
|
33
|
+
@end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import AuthenticationServices
|
|
2
|
+
import Foundation
|
|
3
|
+
import React
|
|
4
|
+
|
|
5
|
+
/// React Native native module for passkey PRF operations on iOS.
|
|
6
|
+
///
|
|
7
|
+
/// Thin RCT_EXTERN_MODULE bridge over the shared `PasskeyAssertionCore`.
|
|
8
|
+
/// Behavioral parity with the upstream Swift `PasskeyProvider` and the
|
|
9
|
+
/// Flutter plugin is enforced by routing all ASAuthorizationController
|
|
10
|
+
/// orchestration through the same canonical core.
|
|
11
|
+
@available(iOS 18.0, *)
|
|
12
|
+
@objc(BreezSdkSparkPasskey)
|
|
13
|
+
class BreezSdkSparkPasskey: NSObject {
|
|
14
|
+
|
|
15
|
+
/// Shared across the per-call cores so the post-create PRF-readiness
|
|
16
|
+
/// grace armed by `createPasskey` survives to an immediately-following
|
|
17
|
+
/// `deriveSeeds` (sparing a second prompt). The RN module is a singleton
|
|
18
|
+
/// per bridge, so this persists across calls; mirrors the Android module.
|
|
19
|
+
private let graceTracker = PostCreateGraceTracker()
|
|
20
|
+
|
|
21
|
+
@objc
|
|
22
|
+
static func requiresMainQueueSetup() -> Bool {
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// Derive multiple PRF seeds in a single ceremony when supported.
|
|
27
|
+
///
|
|
28
|
+
/// Walks `salts` two-at-a-time using a dual-salt assertion. Falls back
|
|
29
|
+
/// to per-salt single-salt assertion if the authenticator drops the
|
|
30
|
+
/// second salt. The `salts.count == 1` case short-circuits to a
|
|
31
|
+
/// single-salt assertion under the hood (one prompt).
|
|
32
|
+
@objc
|
|
33
|
+
func deriveSeeds(
|
|
34
|
+
_ salts: [String],
|
|
35
|
+
rpId: String,
|
|
36
|
+
rpName: String,
|
|
37
|
+
userName: String,
|
|
38
|
+
userDisplayName: String,
|
|
39
|
+
autoRegister: Bool,
|
|
40
|
+
allowCredentials: [String],
|
|
41
|
+
preferImmediatelyAvailableCredentials: NSNumber?,
|
|
42
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
43
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
44
|
+
) {
|
|
45
|
+
// Encode salts as UTF-8 bytes; the OS uses these directly as
|
|
46
|
+
// PRF eval inputs (saltInput1, saltInput2).
|
|
47
|
+
let saltDatas: [Data] = salts.compactMap { $0.data(using: .utf8) }
|
|
48
|
+
guard saltDatas.count == salts.count else {
|
|
49
|
+
reject("ERR_PASSKEY", "Failed to encode salts as UTF-8", nil)
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let allowIds: [Data] = allowCredentials.compactMap { Data(base64Encoded: $0) }
|
|
54
|
+
let preferImmediate = preferImmediatelyAvailableCredentials?.boolValue
|
|
55
|
+
|
|
56
|
+
let core = PasskeyAssertionCore(
|
|
57
|
+
rpId: rpId, rpName: rpName, userName: userName, userDisplayName: userDisplayName,
|
|
58
|
+
graceTracker: graceTracker
|
|
59
|
+
)
|
|
60
|
+
Task { @MainActor in
|
|
61
|
+
do {
|
|
62
|
+
let derivation = try await core.deriveSeeds(
|
|
63
|
+
salts: saltDatas,
|
|
64
|
+
autoRegister: autoRegister,
|
|
65
|
+
allowCredentials: allowIds,
|
|
66
|
+
preferImmediatelyAvailableCredentials: preferImmediate ?? true
|
|
67
|
+
)
|
|
68
|
+
resolve([
|
|
69
|
+
"seeds": derivation.seeds.map { $0.base64EncodedString() },
|
|
70
|
+
"credentialId": derivation.credentialId.map { $0.base64EncodedString() } ?? NSNull(),
|
|
71
|
+
])
|
|
72
|
+
} catch let err as PasskeyAssertionError {
|
|
73
|
+
Self.reject(err, reject: reject, defaultMessage: "User cancelled authentication")
|
|
74
|
+
} catch {
|
|
75
|
+
reject("ERR_PASSKEY", error.localizedDescription, nil)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/// Create a new passkey with PRF support.
|
|
81
|
+
///
|
|
82
|
+
/// Only registers the credential, no seed derivation. Triggers exactly
|
|
83
|
+
/// 1 platform prompt. Use for multi-step onboarding flows. Per-call
|
|
84
|
+
/// overrides on userId, userName, userDisplayName fall back to the
|
|
85
|
+
/// constructor values when null.
|
|
86
|
+
@objc
|
|
87
|
+
func createPasskey(
|
|
88
|
+
_ rpId: String,
|
|
89
|
+
rpName: String,
|
|
90
|
+
userName: String,
|
|
91
|
+
userDisplayName: String,
|
|
92
|
+
excludeCredentials: [String],
|
|
93
|
+
registerSalts: [String],
|
|
94
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
95
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
96
|
+
) {
|
|
97
|
+
let excludeIds: [Data] = excludeCredentials.compactMap { Data(base64Encoded: $0) }
|
|
98
|
+
|
|
99
|
+
let core = PasskeyAssertionCore(
|
|
100
|
+
rpId: rpId, rpName: rpName, userName: userName, userDisplayName: userDisplayName,
|
|
101
|
+
graceTracker: graceTracker
|
|
102
|
+
)
|
|
103
|
+
Task { @MainActor in
|
|
104
|
+
do {
|
|
105
|
+
let registration = try await core.register(
|
|
106
|
+
excludeCredentials: excludeIds,
|
|
107
|
+
salts: registerSalts
|
|
108
|
+
)
|
|
109
|
+
let registered = registration.credential
|
|
110
|
+
// Null unless the authenticator evaluated PRF during the
|
|
111
|
+
// create ceremony and returned one output per salt. The JS
|
|
112
|
+
// side then skips the assertion entirely.
|
|
113
|
+
resolve([
|
|
114
|
+
"credentialId": registered.credentialId.base64EncodedString(),
|
|
115
|
+
"userId": registered.userId?.base64EncodedString() as Any?,
|
|
116
|
+
"aaguid": registered.aaguid?.base64EncodedString() as Any?,
|
|
117
|
+
"backupEligible": registered.backupEligible as Any?,
|
|
118
|
+
"seeds": registration.seeds?.map { $0.base64EncodedString() } as Any?,
|
|
119
|
+
])
|
|
120
|
+
} catch let err as PasskeyAssertionError {
|
|
121
|
+
Self.reject(err, reject: reject, defaultMessage: "User cancelled registration")
|
|
122
|
+
} catch {
|
|
123
|
+
reject("ERR_PASSKEY", error.localizedDescription, nil)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/// Check if PRF-capable passkeys are available on this device.
|
|
129
|
+
@objc
|
|
130
|
+
func isSupported(
|
|
131
|
+
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
132
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
133
|
+
) {
|
|
134
|
+
if #available(iOS 18.0, *) {
|
|
135
|
+
resolve(true)
|
|
136
|
+
} else {
|
|
137
|
+
resolve(false)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/// Domain association check. Delegates to the canonical
|
|
142
|
+
/// `PasskeyAssertionCore` which probes Apple's AASA CDN
|
|
143
|
+
/// (`app-site-association.cdn-apple.com`) for the bundle ID's
|
|
144
|
+
/// `webcredentials` listing. Verification-level failures (no
|
|
145
|
+
/// team ID, network errors, malformed JSON) all map to `Skipped`
|
|
146
|
+
///: the SDK never blocks on this check, it's diagnostic.
|
|
147
|
+
@objc
|
|
148
|
+
func checkDomainAssociation(
|
|
149
|
+
_ rpId: String,
|
|
150
|
+
teamId: String?,
|
|
151
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
152
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
153
|
+
) {
|
|
154
|
+
// Branding fields are unused by the domain check; pass rpId as
|
|
155
|
+
// a placeholder since this is a check-only core.
|
|
156
|
+
let core = PasskeyAssertionCore(
|
|
157
|
+
rpId: rpId, rpName: rpId, userName: rpId, userDisplayName: rpId,
|
|
158
|
+
explicitTeamId: teamId
|
|
159
|
+
)
|
|
160
|
+
Task { @MainActor in
|
|
161
|
+
let outcome = await core.checkDomainAssociation()
|
|
162
|
+
switch outcome {
|
|
163
|
+
case .associated:
|
|
164
|
+
resolve(["kind": "Associated"])
|
|
165
|
+
case .notAssociated(let source, let reason):
|
|
166
|
+
resolve(["kind": "NotAssociated", "source": source, "reason": reason])
|
|
167
|
+
case .skipped(let reason):
|
|
168
|
+
resolve(["kind": "Skipped", "reason": reason])
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// MARK: - Error mapping
|
|
174
|
+
|
|
175
|
+
private static func reject(
|
|
176
|
+
_ err: PasskeyAssertionError,
|
|
177
|
+
reject: @escaping RCTPromiseRejectBlock,
|
|
178
|
+
defaultMessage: String
|
|
179
|
+
) {
|
|
180
|
+
switch err {
|
|
181
|
+
case .userCancelled:
|
|
182
|
+
reject("ERR_USER_CANCELLED", defaultMessage, nil)
|
|
183
|
+
case .userTimedOut:
|
|
184
|
+
reject("ERR_USER_TIMED_OUT", "Authenticator timed out", nil)
|
|
185
|
+
case .credentialNotFound(let msg):
|
|
186
|
+
reject("ERR_NO_CREDENTIAL", msg, nil)
|
|
187
|
+
case .credentialAlreadyExists(let msg):
|
|
188
|
+
reject("ERR_CREDENTIAL_ALREADY_EXISTS", msg, nil)
|
|
189
|
+
case .prfNotSupported:
|
|
190
|
+
reject("ERR_PRF_NOT_SUPPORTED", "PRF not supported by authenticator", nil)
|
|
191
|
+
case .prfEvaluationFailed(let msg):
|
|
192
|
+
reject("ERR_PRF_NOT_SUPPORTED", msg, nil)
|
|
193
|
+
case .configuration(let msg):
|
|
194
|
+
reject("ERR_CONFIGURATION", msg, nil)
|
|
195
|
+
case .authenticationFailed(let msg):
|
|
196
|
+
reject("ERR_PASSKEY", msg, nil)
|
|
197
|
+
case .generic(let msg):
|
|
198
|
+
reject("ERR_PASSKEY", msg, nil)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|