@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
package/android/build.gradle
CHANGED
|
@@ -67,6 +67,7 @@ android {
|
|
|
67
67
|
minSdkVersion getExtOrIntegerDefault("minSdkVersion")
|
|
68
68
|
targetSdkVersion getExtOrIntegerDefault("targetSdkVersion")
|
|
69
69
|
buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString()
|
|
70
|
+
consumerProguardFiles 'proguard-rules.pro'
|
|
70
71
|
|
|
71
72
|
buildFeatures {
|
|
72
73
|
prefab true
|
|
@@ -109,6 +110,7 @@ android {
|
|
|
109
110
|
|
|
110
111
|
sourceSets {
|
|
111
112
|
main {
|
|
113
|
+
main.kotlin.srcDirs += 'src/main/kotlin'
|
|
112
114
|
if (isNewArchitectureEnabled()) {
|
|
113
115
|
java.srcDirs += [
|
|
114
116
|
"generated/java",
|
|
@@ -132,6 +134,9 @@ dependencies {
|
|
|
132
134
|
//noinspection GradleDynamicVersion
|
|
133
135
|
implementation "com.facebook.react:react-native:+"
|
|
134
136
|
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
|
137
|
+
implementation "androidx.credentials:credentials:1.3.0"
|
|
138
|
+
implementation "androidx.credentials:credentials-play-services-auth:1.3.0"
|
|
139
|
+
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0"
|
|
135
140
|
}
|
|
136
141
|
|
|
137
142
|
if (isNewArchitectureEnabled()) {
|
package/android/src/main/java/com/breeztech/breezsdkspark/BreezSdkSparkReactNativePackage.kt
CHANGED
|
@@ -10,10 +10,10 @@ import java.util.HashMap
|
|
|
10
10
|
|
|
11
11
|
class BreezSdkSparkReactNativePackage : TurboReactPackage() {
|
|
12
12
|
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
|
|
13
|
-
return
|
|
14
|
-
BreezSdkSparkReactNativeModule(reactContext)
|
|
15
|
-
|
|
16
|
-
null
|
|
13
|
+
return when (name) {
|
|
14
|
+
BreezSdkSparkReactNativeModule.NAME -> BreezSdkSparkReactNativeModule(reactContext)
|
|
15
|
+
BreezSdkSparkPasskeyModule.NAME -> BreezSdkSparkPasskeyModule(reactContext)
|
|
16
|
+
else -> null
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
|
|
@@ -28,6 +28,14 @@ class BreezSdkSparkReactNativePackage : TurboReactPackage() {
|
|
|
28
28
|
false, // isCxxModule
|
|
29
29
|
true // isTurboModule
|
|
30
30
|
)
|
|
31
|
+
moduleInfos[BreezSdkSparkPasskeyModule.NAME] = ReactModuleInfo(
|
|
32
|
+
BreezSdkSparkPasskeyModule.NAME,
|
|
33
|
+
BreezSdkSparkPasskeyModule.NAME,
|
|
34
|
+
false, // canOverrideExistingModule
|
|
35
|
+
false, // needsEagerInit
|
|
36
|
+
false, // isCxxModule
|
|
37
|
+
false // isTurboModule (standard native module)
|
|
38
|
+
)
|
|
31
39
|
moduleInfos
|
|
32
40
|
}
|
|
33
41
|
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
package com.breeztech.breezsdkspark
|
|
2
|
+
|
|
3
|
+
import android.util.Base64
|
|
4
|
+
import com.facebook.react.bridge.Arguments
|
|
5
|
+
import com.facebook.react.bridge.Promise
|
|
6
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
7
|
+
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
|
8
|
+
import com.facebook.react.bridge.ReactMethod
|
|
9
|
+
import com.facebook.react.module.annotations.ReactModule
|
|
10
|
+
import kotlinx.coroutines.CoroutineScope
|
|
11
|
+
import kotlinx.coroutines.Dispatchers
|
|
12
|
+
import kotlinx.coroutines.SupervisorJob
|
|
13
|
+
import kotlinx.coroutines.cancel
|
|
14
|
+
import kotlinx.coroutines.launch
|
|
15
|
+
import technology.breez.spark.passkey.core.CredentialManagerPrfCore
|
|
16
|
+
import technology.breez.spark.passkey.core.CredentialManagerPrfCoreException
|
|
17
|
+
import technology.breez.spark.passkey.core.PostCreateGraceTracker
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* React Native native module for passkey PRF operations on Android.
|
|
21
|
+
*
|
|
22
|
+
* Thin React-bridge wrapper around [CredentialManagerPrfCore]. All of the
|
|
23
|
+
* WebAuthn JSON, Credential Manager, and PRF-extraction plumbing lives in
|
|
24
|
+
* the core helper; this file only translates React Native arguments and
|
|
25
|
+
* maps [CredentialManagerPrfCoreException] into Promise rejection codes
|
|
26
|
+
* understood by the JS side.
|
|
27
|
+
*
|
|
28
|
+
* Do not hand-edit [CredentialManagerPrfCore.kt] here: it is a generated
|
|
29
|
+
* mirror of the canonical copy under
|
|
30
|
+
* `crates/breez-sdk/bindings/langs/shared/android-passkey/`. Run
|
|
31
|
+
* `cargo xtask sync-passkey-core` after editing the canonical file.
|
|
32
|
+
*/
|
|
33
|
+
@ReactModule(name = BreezSdkSparkPasskeyModule.NAME)
|
|
34
|
+
class BreezSdkSparkPasskeyModule(
|
|
35
|
+
private val reactContext: ReactApplicationContext,
|
|
36
|
+
) : ReactContextBaseJavaModule(reactContext) {
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Module-scoped coroutine scope. Cancelled in [onCatalystInstanceDestroy]
|
|
40
|
+
* so any in-flight passkey ceremony does not outlive the React context
|
|
41
|
+
* and leak the captured Activity. SupervisorJob keeps siblings alive if
|
|
42
|
+
* one branch fails, matching the per-call try/catch pattern below.
|
|
43
|
+
*/
|
|
44
|
+
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Shared across each per-call [CredentialManagerPrfCore] instance so the
|
|
48
|
+
* post-create grace armed by one ceremony is consumed by the next.
|
|
49
|
+
* Without sharing, every fresh-per-call core would have its own empty
|
|
50
|
+
* tracker and the cross-call grace would never fire.
|
|
51
|
+
*/
|
|
52
|
+
private val graceTracker = PostCreateGraceTracker()
|
|
53
|
+
|
|
54
|
+
override fun getName(): String = NAME
|
|
55
|
+
|
|
56
|
+
override fun onCatalystInstanceDestroy() {
|
|
57
|
+
scope.cancel()
|
|
58
|
+
super.onCatalystInstanceDestroy()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Derive multiple 32-byte PRF seeds in a single ceremony when supported
|
|
63
|
+
* (dual-salt assertion). Falls back to per-salt single-salt assertion
|
|
64
|
+
* if the authenticator drops the second salt. The `salts.size == 1`
|
|
65
|
+
* case short-circuits to a single-salt assertion (one prompt).
|
|
66
|
+
*
|
|
67
|
+
* @param promise Resolves with a list of base64-encoded 32-byte PRF outputs.
|
|
68
|
+
*/
|
|
69
|
+
@ReactMethod
|
|
70
|
+
fun deriveSeeds(
|
|
71
|
+
saltsArg: com.facebook.react.bridge.ReadableArray,
|
|
72
|
+
rpId: String,
|
|
73
|
+
rpName: String,
|
|
74
|
+
userName: String,
|
|
75
|
+
userDisplayName: String,
|
|
76
|
+
autoRegister: Boolean,
|
|
77
|
+
allowCredentialsArg: com.facebook.react.bridge.ReadableArray,
|
|
78
|
+
preferImmediatelyAvailableCredentials: Boolean?,
|
|
79
|
+
promise: Promise,
|
|
80
|
+
) {
|
|
81
|
+
val activity = currentActivity
|
|
82
|
+
if (activity == null) {
|
|
83
|
+
promise.reject("ERR_NO_ACTIVITY", "No current activity available")
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
val salts = mutableListOf<String>()
|
|
88
|
+
for (i in 0 until saltsArg.size()) {
|
|
89
|
+
val s = saltsArg.getString(i)
|
|
90
|
+
if (s == null) {
|
|
91
|
+
promise.reject("ERR_PASSKEY", "Invalid salt at index $i")
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
salts.add(s)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
val allowIds = mutableListOf<ByteArray>()
|
|
98
|
+
for (i in 0 until allowCredentialsArg.size()) {
|
|
99
|
+
val b64 = allowCredentialsArg.getString(i) ?: continue
|
|
100
|
+
allowIds.add(Base64.decode(b64, Base64.NO_WRAP))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
scope.launch {
|
|
104
|
+
try {
|
|
105
|
+
val derivation = CredentialManagerPrfCore(
|
|
106
|
+
rpId = rpId,
|
|
107
|
+
rpName = rpName,
|
|
108
|
+
userName = userName,
|
|
109
|
+
userDisplayName = userDisplayName,
|
|
110
|
+
activityProvider = { activity },
|
|
111
|
+
graceTracker = graceTracker,
|
|
112
|
+
).deriveSeeds(
|
|
113
|
+
salts = salts,
|
|
114
|
+
autoRegister = autoRegister,
|
|
115
|
+
allowCredentials = allowIds,
|
|
116
|
+
preferImmediatelyAvailableCredentials = preferImmediatelyAvailableCredentials ?: true,
|
|
117
|
+
)
|
|
118
|
+
// Encode each seed as base64 so the React bridge can carry
|
|
119
|
+
// them as an array of strings, plus the asserted credential
|
|
120
|
+
// ID. JS side base64-decodes back to Uint8Array.
|
|
121
|
+
val seedsArr = Arguments.createArray()
|
|
122
|
+
for (seed in derivation.seeds) {
|
|
123
|
+
seedsArr.pushString(Base64.encodeToString(seed, Base64.NO_WRAP))
|
|
124
|
+
}
|
|
125
|
+
val result = Arguments.createMap()
|
|
126
|
+
result.putArray("seeds", seedsArr)
|
|
127
|
+
val credentialId = derivation.credentialId
|
|
128
|
+
if (credentialId != null) {
|
|
129
|
+
result.putString("credentialId", Base64.encodeToString(credentialId, Base64.NO_WRAP))
|
|
130
|
+
} else {
|
|
131
|
+
result.putNull("credentialId")
|
|
132
|
+
}
|
|
133
|
+
promise.resolve(result)
|
|
134
|
+
} catch (e: CredentialManagerPrfCoreException) {
|
|
135
|
+
promise.reject(e.errorCode, e.message ?: e.defaultMessage)
|
|
136
|
+
} catch (e: Exception) {
|
|
137
|
+
promise.reject("ERR_PASSKEY", e.message ?: e.toString())
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Domain association check. Mirrors Flutter Android: degrades
|
|
144
|
+
* `NotAssociated` results from the public Digital Asset Links API
|
|
145
|
+
* to `Skipped`, since CredentialManager runs its own check
|
|
146
|
+
* internally with a fresher GMS-cached statement set.
|
|
147
|
+
*/
|
|
148
|
+
@ReactMethod
|
|
149
|
+
fun checkDomainAssociation(rpId: String, promise: Promise) {
|
|
150
|
+
val activity = currentActivity
|
|
151
|
+
if (activity == null) {
|
|
152
|
+
promise.reject("ERR_NO_ACTIVITY", "No current activity available")
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
scope.launch {
|
|
156
|
+
try {
|
|
157
|
+
// Branding fields are unused by the domain check; pass
|
|
158
|
+
// rpId as a placeholder since this is a check-only core.
|
|
159
|
+
val outcome = CredentialManagerPrfCore(
|
|
160
|
+
rpId = rpId,
|
|
161
|
+
rpName = rpId,
|
|
162
|
+
userName = rpId,
|
|
163
|
+
userDisplayName = rpId,
|
|
164
|
+
activityProvider = { activity },
|
|
165
|
+
).checkDomainAssociation()
|
|
166
|
+
val map = Arguments.createMap()
|
|
167
|
+
when (outcome) {
|
|
168
|
+
is technology.breez.spark.passkey.core.DomainAssociationResult.Associated -> {
|
|
169
|
+
map.putString("kind", "Associated")
|
|
170
|
+
}
|
|
171
|
+
is technology.breez.spark.passkey.core.DomainAssociationResult.NotAssociated -> {
|
|
172
|
+
map.putString("kind", "Skipped")
|
|
173
|
+
map.putString("reason", "[soft-fail on Android] ${outcome.reason}")
|
|
174
|
+
}
|
|
175
|
+
is technology.breez.spark.passkey.core.DomainAssociationResult.Skipped -> {
|
|
176
|
+
map.putString("kind", "Skipped")
|
|
177
|
+
map.putString("reason", outcome.reason)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
promise.resolve(map)
|
|
181
|
+
} catch (e: Exception) {
|
|
182
|
+
val map = Arguments.createMap()
|
|
183
|
+
map.putString("kind", "Skipped")
|
|
184
|
+
map.putString("reason", "Domain association probe failed: ${e.message ?: e.toString()}")
|
|
185
|
+
promise.resolve(map)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Create a new passkey with PRF support.
|
|
192
|
+
*
|
|
193
|
+
* Only registers the credential, no seed derivation. Triggers exactly
|
|
194
|
+
* one platform prompt. Use for multi-step onboarding flows.
|
|
195
|
+
*/
|
|
196
|
+
@ReactMethod
|
|
197
|
+
fun createPasskey(
|
|
198
|
+
rpId: String,
|
|
199
|
+
rpName: String,
|
|
200
|
+
userName: String,
|
|
201
|
+
userDisplayName: String,
|
|
202
|
+
excludeCredentialsBase64: com.facebook.react.bridge.ReadableArray,
|
|
203
|
+
registerSaltsArg: com.facebook.react.bridge.ReadableArray,
|
|
204
|
+
promise: Promise,
|
|
205
|
+
) {
|
|
206
|
+
val activity = currentActivity
|
|
207
|
+
if (activity == null) {
|
|
208
|
+
promise.reject("ERR_NO_ACTIVITY", "No current activity available")
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
val excludeIds = mutableListOf<ByteArray>()
|
|
213
|
+
for (i in 0 until excludeCredentialsBase64.size()) {
|
|
214
|
+
val b64 = excludeCredentialsBase64.getString(i)
|
|
215
|
+
excludeIds.add(Base64.decode(b64, Base64.NO_WRAP))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
val registerSalts = mutableListOf<String>()
|
|
219
|
+
for (i in 0 until registerSaltsArg.size()) {
|
|
220
|
+
registerSaltsArg.getString(i)?.let { registerSalts.add(it) }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
scope.launch {
|
|
224
|
+
try {
|
|
225
|
+
val registration = CredentialManagerPrfCore(
|
|
226
|
+
rpId = rpId,
|
|
227
|
+
rpName = rpName,
|
|
228
|
+
userName = userName,
|
|
229
|
+
userDisplayName = userDisplayName,
|
|
230
|
+
activityProvider = { activity },
|
|
231
|
+
graceTracker = graceTracker,
|
|
232
|
+
).register(excludeIds, registerSalts)
|
|
233
|
+
val credential = registration.credential
|
|
234
|
+
// The core's `register` arms the shared grace tracker so the
|
|
235
|
+
// next `deriveSeeds` call holds out the credential's
|
|
236
|
+
// PRF-readiness window without the wrapper having to, and
|
|
237
|
+
// skips arming it when seeds came back inline.
|
|
238
|
+
val map = Arguments.createMap()
|
|
239
|
+
map.putString("credentialId", Base64.encodeToString(credential.credentialId, Base64.NO_WRAP))
|
|
240
|
+
map.putString("userId", Base64.encodeToString(credential.userId, Base64.NO_WRAP))
|
|
241
|
+
if (credential.aaguid != null) {
|
|
242
|
+
map.putString("aaguid", Base64.encodeToString(credential.aaguid, Base64.NO_WRAP))
|
|
243
|
+
} else {
|
|
244
|
+
map.putNull("aaguid")
|
|
245
|
+
}
|
|
246
|
+
if (credential.backupEligible != null) {
|
|
247
|
+
map.putBoolean("backupEligible", credential.backupEligible!!)
|
|
248
|
+
} else {
|
|
249
|
+
map.putNull("backupEligible")
|
|
250
|
+
}
|
|
251
|
+
// Null unless the authenticator evaluated PRF during the
|
|
252
|
+
// create ceremony and returned one output per salt. The JS
|
|
253
|
+
// side then skips the assertion entirely.
|
|
254
|
+
val seeds = registration.seeds
|
|
255
|
+
if (seeds != null) {
|
|
256
|
+
val seedsArr = Arguments.createArray()
|
|
257
|
+
for (seed in seeds) {
|
|
258
|
+
seedsArr.pushString(Base64.encodeToString(seed, Base64.NO_WRAP))
|
|
259
|
+
}
|
|
260
|
+
map.putArray("seeds", seedsArr)
|
|
261
|
+
} else {
|
|
262
|
+
map.putNull("seeds")
|
|
263
|
+
}
|
|
264
|
+
promise.resolve(map)
|
|
265
|
+
} catch (e: CredentialManagerPrfCoreException) {
|
|
266
|
+
promise.reject(e.errorCode, e.message ?: e.defaultMessage)
|
|
267
|
+
} catch (e: Exception) {
|
|
268
|
+
promise.reject("ERR_PASSKEY", e.message ?: e.toString())
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Check if PRF-capable passkeys are available on this device. */
|
|
274
|
+
@ReactMethod
|
|
275
|
+
fun isSupported(promise: Promise) {
|
|
276
|
+
promise.resolve(CredentialManagerPrfCore.isSupported())
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private val CredentialManagerPrfCoreException.errorCode: String
|
|
280
|
+
get() = when (kind) {
|
|
281
|
+
CredentialManagerPrfCore.Kind.UserCancelled -> "ERR_USER_CANCELLED"
|
|
282
|
+
CredentialManagerPrfCore.Kind.UserTimedOut -> "ERR_USER_TIMED_OUT"
|
|
283
|
+
CredentialManagerPrfCore.Kind.CredentialNotFound -> "ERR_NO_CREDENTIAL"
|
|
284
|
+
CredentialManagerPrfCore.Kind.PrfNotSupported -> "ERR_PRF_NOT_SUPPORTED"
|
|
285
|
+
CredentialManagerPrfCore.Kind.Configuration -> "ERR_CONFIGURATION"
|
|
286
|
+
CredentialManagerPrfCore.Kind.CredentialAlreadyExists -> "ERR_CREDENTIAL_ALREADY_EXISTS"
|
|
287
|
+
else -> "ERR_PASSKEY"
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private val CredentialManagerPrfCoreException.defaultMessage: String
|
|
291
|
+
get() = when (kind) {
|
|
292
|
+
CredentialManagerPrfCore.Kind.UserCancelled -> "User cancelled the passkey operation"
|
|
293
|
+
CredentialManagerPrfCore.Kind.UserTimedOut -> "Authenticator timed out"
|
|
294
|
+
CredentialManagerPrfCore.Kind.CredentialNotFound -> "No passkey credential found for this domain"
|
|
295
|
+
CredentialManagerPrfCore.Kind.PrfNotSupported -> "PRF not supported by authenticator"
|
|
296
|
+
CredentialManagerPrfCore.Kind.AuthenticationFailed -> "Passkey authentication failed"
|
|
297
|
+
CredentialManagerPrfCore.Kind.PrfEvaluationFailed -> "PRF evaluation failed"
|
|
298
|
+
CredentialManagerPrfCore.Kind.Configuration -> "Platform or app configuration error"
|
|
299
|
+
CredentialManagerPrfCore.Kind.CredentialAlreadyExists -> "A passkey for this app already exists on this device"
|
|
300
|
+
CredentialManagerPrfCore.Kind.Generic -> "Passkey operation failed"
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
companion object {
|
|
304
|
+
const val NAME = "BreezSdkSparkPasskey"
|
|
305
|
+
}
|
|
306
|
+
}
|