@breeztech/breez-sdk-spark-react-native 0.21.0 → 0.22.0

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.
@@ -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()) {
@@ -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 if (name == BreezSdkSparkReactNativeModule.NAME) {
14
- BreezSdkSparkReactNativeModule(reactContext)
15
- } else {
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
+ }