@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.
@@ -0,0 +1,955 @@
1
+ package technology.breez.spark.passkey.core
2
+
3
+ import android.app.Activity
4
+ import android.content.Context
5
+ import android.content.pm.PackageManager
6
+ import android.content.pm.Signature
7
+ import android.os.Build
8
+ import android.util.Base64
9
+ import android.util.Log
10
+ import androidx.credentials.CreatePublicKeyCredentialRequest
11
+ import androidx.credentials.CredentialManager
12
+ import androidx.credentials.GetCredentialRequest
13
+ import androidx.credentials.GetPublicKeyCredentialOption
14
+ import androidx.credentials.exceptions.CreateCredentialCancellationException
15
+ import androidx.credentials.exceptions.CreateCredentialException
16
+ import androidx.credentials.exceptions.GetCredentialCancellationException
17
+ import androidx.credentials.exceptions.GetCredentialException
18
+ import androidx.credentials.exceptions.NoCredentialException
19
+ import androidx.credentials.exceptions.domerrors.InvalidStateError
20
+ import androidx.credentials.exceptions.publickeycredential.CreatePublicKeyCredentialDomException
21
+ import kotlinx.coroutines.Dispatchers
22
+ import kotlinx.coroutines.delay
23
+ import kotlinx.coroutines.sync.Mutex
24
+ import kotlinx.coroutines.sync.withLock
25
+ import kotlinx.coroutines.withContext
26
+ import org.json.JSONArray
27
+ import org.json.JSONObject
28
+ import java.net.HttpURLConnection
29
+ import java.net.URL
30
+ import java.net.URLEncoder
31
+ import java.security.MessageDigest
32
+ import java.security.SecureRandom
33
+
34
+ // =====================================================================
35
+ // !!! SOURCE-OF-TRUTH NOTICE !!!
36
+ //
37
+ // Canonical copy:
38
+ // crates/breez-sdk/bindings/langs/shared/android-passkey/src/main/kotlin/
39
+ // technology/breez/spark/passkey/core/CredentialManagerPrfCore.kt
40
+ //
41
+ // Shared into four Android artifacts via gradle `srcDirs`
42
+ // (bindings-android + breez-sdk-spark-kmp) and `cargo xtask
43
+ // sync-passkey-core` (packages/flutter + packages/react-native). Never
44
+ // hand-edit a copy: edit this file, run the xtask, commit the diff. CI
45
+ // fails if a copy drifts.
46
+ // =====================================================================
47
+
48
+ /**
49
+ * Framework-agnostic helper wrapping AndroidX Credential Manager + the
50
+ * WebAuthn PRF extension for passkey-based seed derivation.
51
+ *
52
+ * Wrappers (UniFFI `PasskeyProvider`, Flutter MethodChannel, React Native
53
+ * module) delegate here and add only framework glue: error mapping,
54
+ * activity retrieval, call-site boilerplate. Throws
55
+ * [CredentialManagerPrfCoreException] for every well-known failure so
56
+ * wrappers can switch on [Kind] without touching Credential Manager
57
+ * internals.
58
+ */
59
+
60
+ /**
61
+ * A passkey credential from a register or sign-in ceremony.
62
+ * [credentialId] is always set. The remaining fields are populated on
63
+ * registration and null on sign-in (an assertion carries no
64
+ * attestation). [aaguid] is the 16-byte Authenticator Attestation GUID
65
+ * (provider identifier), unverified attestation: a display hint only,
66
+ * never a trust decision. [backupEligible] is the BE flag (can the
67
+ * credential sync across devices). [userId] is the core-minted WebAuthn
68
+ * user handle, never host-supplied. Persist [credentialId] to drive
69
+ * `excludeCredentials` / `allowCredentials` on later calls.
70
+ */
71
+ public data class PasskeyCredential(
72
+ public val credentialId: ByteArray,
73
+ public val userId: ByteArray?,
74
+ public val aaguid: ByteArray?,
75
+ public val backupEligible: Boolean?,
76
+ )
77
+
78
+ private const val CORE_TAG = "PasskeyPrfCore"
79
+
80
+ // =====================================================================
81
+ // Post-create grace
82
+ // =====================================================================
83
+
84
+ /**
85
+ * A newly-registered passkey is briefly not discoverable, so the
86
+ * immediate post-create assertion can miss a credential that exists.
87
+ * [arm] opens a window in which the next derive re-asks instead of
88
+ * trusting that first miss.
89
+ *
90
+ * Scoped to that case only: the retry catches `NoCredentialException`,
91
+ * so a dropped `prf.second` is not covered and still falls through to
92
+ * the single-salt recover in [CredentialManagerPrfCore.deriveSeeds].
93
+ *
94
+ * The window is a ceiling, not a wait: indexing takes as long as the
95
+ * device takes, so a fixed sleep either returns before the credential is
96
+ * queryable or taxes every registration. Retrying costs nothing once the
97
+ * credential resolves.
98
+ *
99
+ * Named after iOS's `PostCreateGraceTracker`, which still waits the
100
+ * window out rather than re-asking; an instance lives inside
101
+ * [CredentialManagerPrfCore] so every consumer that holds onto a single
102
+ * core (e.g. `PasskeyProvider` for UniFFI / KMM consumers) inherits the
103
+ * grace without per-wrapper plumbing.
104
+ */
105
+ public class PostCreateGraceTracker {
106
+ private val mutex = Mutex()
107
+ @Volatile private var deadlineMs: Long = 0L
108
+
109
+ public suspend fun arm(durationMs: Long = DEFAULT_DURATION_MS) {
110
+ mutex.withLock {
111
+ deadlineMs = System.currentTimeMillis() + durationMs
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Absolute time until which a post-create assertion may re-ask, or 0
117
+ * when no registration preceded this derive. Clears the window, so a
118
+ * later derive is not covered by it.
119
+ */
120
+ public suspend fun consumeDeadline(): Long = mutex.withLock {
121
+ val deadline = deadlineMs
122
+ deadlineMs = 0L
123
+ deadline
124
+ }
125
+
126
+ public companion object {
127
+ public const val DEFAULT_DURATION_MS: Long = 5_000L
128
+
129
+ /** Gap between re-asks; each miss is a fast local no-UI failure. */
130
+ public const val RETRY_INTERVAL_MS: Long = 100L
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Platform PRF engine: holds the relying-party identity for one
136
+ * provider's lifetime, exposing `deriveSeeds`, `register`,
137
+ * `checkDomainAssociation`, and `isSupported`. Per-call methods take
138
+ * only per-ceremony arguments; everything else is fixed at
139
+ * construction. Each consumer maps the typed
140
+ * [CredentialManagerPrfCoreException] onto its own error surface.
141
+ *
142
+ * @param activityProvider Resolves the current top Activity lazily on
143
+ * each call, so a stale instance is never held across rotation.
144
+ * @param graceTracker Post-create grace state. Defaults to a fresh
145
+ * tracker per core; consumers that pool cores across the same wrapper
146
+ * instance can share one tracker explicitly.
147
+ * @param postCreateGraceMs How long [register] arms the grace for.
148
+ * Defaults to [PostCreateGraceTracker.DEFAULT_DURATION_MS].
149
+ */
150
+ public class CredentialManagerPrfCore(
151
+ private val rpId: String,
152
+ private val rpName: String,
153
+ private val userName: String,
154
+ private val userDisplayName: String,
155
+ private val activityProvider: () -> Activity,
156
+ private val graceTracker: PostCreateGraceTracker = PostCreateGraceTracker(),
157
+ private val postCreateGraceMs: Long = PostCreateGraceTracker.DEFAULT_DURATION_MS,
158
+ ) {
159
+
160
+ public companion object {
161
+ /** Default Relying Party ID for cross-platform credential sharing. */
162
+ public const val DEFAULT_RP_ID: String = "keys.breez.technology"
163
+
164
+ /**
165
+ * `true` if the OS version could support passkey PRF (API 28+).
166
+ * Checks the platform version only, not whether a credential
167
+ * provider is installed or biometrics are enrolled.
168
+ */
169
+ public fun isSupported(): Boolean =
170
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
171
+
172
+ /** Lazily initialised; first-use entropy gathering can dominate the cold path. */
173
+ private val secureRandom: SecureRandom by lazy { SecureRandom() }
174
+
175
+ /**
176
+ * Cached `CredentialManager`, held process-wide against the
177
+ * application context (lifecycle-safe across rotation) so per-call
178
+ * core instances don't each re-allocate it.
179
+ */
180
+ @Volatile
181
+ private var cachedCredentialManager: CredentialManager? = null
182
+
183
+ private fun credentialManager(activity: Activity): CredentialManager =
184
+ cachedCredentialManager ?: synchronized(this) {
185
+ cachedCredentialManager ?: CredentialManager.create(activity.applicationContext).also {
186
+ cachedCredentialManager = it
187
+ }
188
+ }
189
+
190
+ private fun encodeBase64Url(bytes: ByteArray): String =
191
+ Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
192
+
193
+ private fun decodeBase64Url(s: String): ByteArray =
194
+ Base64.decode(s, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
195
+
196
+ /**
197
+ * Decode URL-safe base64 from a provider response, logging +
198
+ * returning null on malformed input (a provider/protocol fault).
199
+ */
200
+ private fun decodeBase64UrlOrNull(s: String, what: String): ByteArray? =
201
+ try {
202
+ decodeBase64Url(s)
203
+ } catch (e: IllegalArgumentException) {
204
+ Log.w(CORE_TAG, "Malformed base64url in provider response ($what)", e)
205
+ null
206
+ }
207
+
208
+ private fun randomBase64Url(byteCount: Int): String {
209
+ val bytes = ByteArray(byteCount)
210
+ secureRandom.nextBytes(bytes)
211
+ return encodeBase64Url(bytes)
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Derive one 32-byte PRF output per salt in as few authenticator
217
+ * ceremonies as the platform supports: salts are walked in pairs
218
+ * (one dual-salt assertion each via `prf.eval.first`/`.second`),
219
+ * and an authenticator that drops `second` is recovered with a
220
+ * single-salt re-assert. When no credential exists yet and
221
+ * [autoRegister] is set, the first miss registers a passkey and
222
+ * retries. Output ordering matches input ordering.
223
+ *
224
+ * Prompt count for the common 2-salt setup: 1 on a conformant
225
+ * authenticator. Worst case is 3 (assert-miss, register, then a
226
+ * dual-assert that drops `prf.second` plus a single-salt recover) on
227
+ * a provider that both lacks a credential and drops `second`. Register
228
+ * runs at most once per call, so prompts never grow unbounded.
229
+ */
230
+ public suspend fun deriveSeeds(
231
+ salts: List<String>,
232
+ autoRegister: Boolean = true,
233
+ allowCredentials: List<ByteArray> = emptyList(),
234
+ preferImmediatelyAvailableCredentials: Boolean = true,
235
+ ): PrfDerivation = withContext(Dispatchers.Main) {
236
+ // Re-ask, rather than sleep, while a just-created credential is
237
+ // still being indexed (see grace tracker). 0 when no register
238
+ // preceded this call, which disables the retry entirely.
239
+ val indexRetryUntilMs = graceTracker.consumeDeadline()
240
+ // Pinned to the first asserted credential after the first chunk so
241
+ // every salt in this call derives from one passkey.
242
+ var allow = allowCredentials
243
+ if (salts.isEmpty()) return@withContext PrfDerivation(emptyList(), null)
244
+
245
+ // One assertion for 1-2 salts, registering + retrying once on no
246
+ // credential. Returns one output per salt the authenticator
247
+ // evaluated (a dropped `second` yields one) plus the asserted
248
+ // credential ID. After the first chunk the caller pins `allow` to
249
+ // it, so every chunk resolves to the same credential.
250
+ suspend fun assertChunk(chunk: List<String>): Pair<List<ByteArray>, ByteArray?> =
251
+ try {
252
+ assertPrfAwaitingIndex(
253
+ chunk,
254
+ allow,
255
+ preferImmediatelyAvailableCredentials,
256
+ indexRetryUntilMs,
257
+ )
258
+ } catch (e: NoCredentialException) {
259
+ if (!autoRegister) {
260
+ throw CredentialManagerPrfCoreException(
261
+ Kind.CredentialNotFound,
262
+ e.message ?: "",
263
+ )
264
+ }
265
+ register()
266
+ // Retry once. A second miss (e.g. user deleted the pinned
267
+ // credential in Settings) escapes as CredentialNotFound for
268
+ // hosts to treat as deletion recovery.
269
+ assertPrf(chunk, allow, preferImmediatelyAvailableCredentials)
270
+ }
271
+
272
+ val startedAtMs = System.currentTimeMillis()
273
+ try {
274
+ val output = ArrayList<ByteArray>(salts.size)
275
+ // Asserted credential ID, returned so the binding layer can
276
+ // surface it on `SignInResponse.credential_id`.
277
+ var observedCredentialId: ByteArray? = null
278
+ var idx = 0
279
+ while (idx < salts.size) {
280
+ if (idx + 1 < salts.size) {
281
+ val (outputs, credId) = assertChunk(listOf(salts[idx], salts[idx + 1]))
282
+ observedCredentialId = credId
283
+ // Pin every later assertion in this call to the credential
284
+ // the first one resolved to, so all salts derive from one
285
+ // passkey even when a chunk splits (dropped `second`, 3+ salts).
286
+ credId?.let { allow = listOf(it) }
287
+ output.add(outputs[0])
288
+ if (outputs.size > 1) {
289
+ output.add(outputs[1])
290
+ } else {
291
+ // Authenticator dropped `second`: single-salt recover,
292
+ // pinned to the same credential as the first output.
293
+ val (recovered, _) = assertChunk(listOf(salts[idx + 1]))
294
+ output.add(recovered[0])
295
+ }
296
+ idx += 2
297
+ } else {
298
+ val (single, credId) = assertChunk(listOf(salts[idx]))
299
+ observedCredentialId = credId
300
+ credId?.let { allow = listOf(it) }
301
+ output.add(single[0])
302
+ idx += 1
303
+ }
304
+ }
305
+ PrfDerivation(output, observedCredentialId)
306
+ } catch (e: CredentialManagerPrfCoreException) {
307
+ throw e
308
+ } catch (e: Exception) {
309
+ throw e.toCoreException(System.currentTimeMillis() - startedAtMs)
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Verify the app is listed by Google's Digital Asset Links API for
315
+ * [rpId] with `get_login_creds` permission, queried up front because
316
+ * Credential Manager otherwise surfaces a stale or missing
317
+ * assetlinks.json as an opaque `CredentialNotFound` / "cannot be
318
+ * validated" error indistinguishable from "no credential found":
319
+ *
320
+ * `GET https://digitalassetlinks.googleapis.com/v1/statements:list`
321
+ * `?source.web.site=https://<rpId>`
322
+ * `&relation=delegate_permission/common.get_login_creds`
323
+ *
324
+ * Requires an `android_app` statement matching this app's package name
325
+ * AND signing-cert SHA-256: a package-only match would accept a MITM'd
326
+ * package signed by a different key.
327
+ *
328
+ * Returns `Associated` on a match, `NotAssociated` when the endpoint
329
+ * is reachable but no statement matches, `Skipped` on any
330
+ * network/timeout/non-200/parse failure (caller proceeds anyway).
331
+ *
332
+ * @param connectTimeoutMs HTTP connect timeout. Default 3000ms.
333
+ * @param readTimeoutMs HTTP read timeout. Default 3000ms.
334
+ */
335
+ public suspend fun checkDomainAssociation(
336
+ connectTimeoutMs: Int = 3000,
337
+ readTimeoutMs: Int = 3000,
338
+ ): DomainAssociationResult = withContext(Dispatchers.IO) {
339
+ val context = activityProvider().applicationContext
340
+ val packageName = context.packageName
341
+ val signingCertSha256 = try {
342
+ computeSigningCertSha256(context, packageName)
343
+ } catch (e: Exception) {
344
+ return@withContext DomainAssociationResult.Skipped(
345
+ "Could not read app signing certificate: ${e.message}"
346
+ )
347
+ } ?: return@withContext DomainAssociationResult.Skipped(
348
+ "App has no signing certificate (unsigned debug build?)"
349
+ )
350
+
351
+ val encodedRpId = URLEncoder.encode(rpId, "UTF-8")
352
+ val url = URL(
353
+ "https://digitalassetlinks.googleapis.com/v1/statements:list" +
354
+ "?source.web.site=https://$encodedRpId" +
355
+ "&relation=delegate_permission/common.get_login_creds"
356
+ )
357
+
358
+ val responseJson = try {
359
+ val connection = url.openConnection() as HttpURLConnection
360
+ connection.connectTimeout = connectTimeoutMs
361
+ connection.readTimeout = readTimeoutMs
362
+ connection.requestMethod = "GET"
363
+ try {
364
+ if (connection.responseCode != 200) {
365
+ return@withContext DomainAssociationResult.Skipped(
366
+ "Digital Asset Links API returned HTTP ${connection.responseCode}"
367
+ )
368
+ }
369
+ connection.inputStream.bufferedReader().use { it.readText() }
370
+ } finally {
371
+ connection.disconnect()
372
+ }
373
+ } catch (e: Exception) {
374
+ return@withContext DomainAssociationResult.Skipped(
375
+ "Digital Asset Links API fetch failed: ${e.message}"
376
+ )
377
+ }
378
+
379
+ val statements = try {
380
+ JSONObject(responseJson).optJSONArray("statements") ?: JSONArray()
381
+ } catch (e: Exception) {
382
+ return@withContext DomainAssociationResult.Skipped(
383
+ "Digital Asset Links API returned unparseable JSON: ${e.message}"
384
+ )
385
+ }
386
+
387
+ // The API response format differs from the assetlinks.json FILE:
388
+ // it's proto3 JSON with camelCase keys and de-nests each
389
+ // fingerprint into its own statement (file-format snake_case keys
390
+ // never match). A matching statement looks like:
391
+ //
392
+ // { "target": { "androidApp": {
393
+ // "packageName": "technology.breez.glow",
394
+ // "certificate": { "sha256Fingerprint": "AA:BB:..." }
395
+ // } } }
396
+ val listedFingerprints = mutableListOf<String>()
397
+ for (i in 0 until statements.length()) {
398
+ val stmt = statements.optJSONObject(i) ?: continue
399
+ val target = stmt.optJSONObject("target") ?: continue
400
+ val androidApp = target.optJSONObject("androidApp") ?: continue
401
+ if (androidApp.optString("packageName") != packageName) continue
402
+ val fingerprint = androidApp.optJSONObject("certificate")
403
+ ?.optString("sha256Fingerprint") ?: continue
404
+ if (fingerprint.isEmpty()) continue
405
+ listedFingerprints.add(fingerprint)
406
+ if (fingerprint.equals(signingCertSha256, ignoreCase = true)) {
407
+ return@withContext DomainAssociationResult.Associated
408
+ }
409
+ }
410
+
411
+ DomainAssociationResult.NotAssociated(
412
+ source = "Google Digital Asset Links API",
413
+ reason = "Package $packageName " +
414
+ (if (listedFingerprints.isEmpty())
415
+ "has no android_app statement for https://$rpId " +
416
+ "(relation: delegate_permission/common.get_login_creds)."
417
+ else
418
+ "is listed for https://$rpId but none of the statement " +
419
+ "fingerprints match this app's signing certificate " +
420
+ "$signingCertSha256. Listed: [${listedFingerprints.joinToString()}]."
421
+ )
422
+ )
423
+ }
424
+
425
+ /**
426
+ * SHA-256 of the app's signing certificate in colon-separated
427
+ * uppercase hex (the format Digital Asset Links uses). Null if the app
428
+ * has no signing certificate. Uses `GET_SIGNING_CERTIFICATES`, always
429
+ * available given the API 28+ contract.
430
+ */
431
+ @Suppress("DEPRECATION")
432
+ private fun computeSigningCertSha256(context: Context, packageName: String): String? {
433
+ val signatures: Array<Signature>? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
434
+ val packageInfo = context.packageManager.getPackageInfo(
435
+ packageName,
436
+ PackageManager.GET_SIGNING_CERTIFICATES,
437
+ )
438
+ // Current signer only: assetlinks.json matches the current
439
+ // signing cert, not past signers.
440
+ val signingInfo = packageInfo.signingInfo ?: return null
441
+ if (signingInfo.hasMultipleSigners()) {
442
+ signingInfo.apkContentsSigners
443
+ } else {
444
+ signingInfo.signingCertificateHistory
445
+ }
446
+ } else {
447
+ // Defensive only: the API 28+ contract makes this unreachable.
448
+ val packageInfo = context.packageManager.getPackageInfo(
449
+ packageName,
450
+ PackageManager.GET_SIGNATURES,
451
+ )
452
+ packageInfo.signatures
453
+ }
454
+
455
+ val signature = signatures?.firstOrNull() ?: return null
456
+ val digest = MessageDigest.getInstance("SHA-256").digest(signature.toByteArray())
457
+ return digest.joinToString(":") { "%02X".format(it) }
458
+ }
459
+
460
+ // ------------------------------------------------------------------
461
+ // Private
462
+ // ------------------------------------------------------------------
463
+
464
+ /**
465
+ * PRF outputs carried on a registration response, or null when the
466
+ * authenticator reported support without evaluating (`prf.enabled`
467
+ * with no `results`), which is the pre-eval-at-create behavior.
468
+ */
469
+ private fun readRegistrationPrfResults(responseJson: JSONObject): List<ByteArray>? {
470
+ // Never throw: the passkey exists by now, so a decode failure that
471
+ // escapes fails the whole registration, and the caller's natural
472
+ // recovery is to register again and strand this one. Null falls
473
+ // back to the assertion path, which derives from the same passkey.
474
+ val results = responseJson
475
+ .optJSONObject("clientExtensionResults")
476
+ ?.optJSONObject("prf")
477
+ ?.optJSONObject("results")
478
+ ?: return null
479
+ // Never throw: the passkey exists by now, so a decode failure that
480
+ // escapes fails the whole registration, and the caller's natural
481
+ // recovery is to register again and strand this one. Null falls
482
+ // back to the assertion path, which derives from the same passkey.
483
+ val first = results.optString("first").takeIf { it.isNotEmpty() } ?: return null
484
+ val out = ArrayList<ByteArray>(2)
485
+ out.add(decodeBase64UrlOrNull(first, "registration prf.first") ?: return null)
486
+ results.optString("second").takeIf { it.isNotEmpty() }?.let {
487
+ out.add(decodeBase64UrlOrNull(it, "registration prf.second") ?: return null)
488
+ }
489
+ return out
490
+ }
491
+
492
+ /**
493
+ * [assertPrf], re-asking until [retryUntilMs] while Credential Manager
494
+ * still reports no credential. A passkey that exists but is not yet
495
+ * queryable is indistinguishable from an absent one (both raise
496
+ * `NoCredentialException`), so asking again is the only way to tell
497
+ * them apart, and the caller has just created one.
498
+ *
499
+ * Only retries under [preferImmediatelyAvailableCredentials], where a
500
+ * miss is a fast local failure with no UI. Without it a miss means the
501
+ * user saw and dismissed a picker, which must not be re-shown.
502
+ */
503
+ private suspend fun assertPrfAwaitingIndex(
504
+ salts: List<String>,
505
+ allowCredentials: List<ByteArray>,
506
+ preferImmediatelyAvailableCredentials: Boolean,
507
+ retryUntilMs: Long,
508
+ ): Pair<List<ByteArray>, ByteArray?> {
509
+ while (true) {
510
+ try {
511
+ return assertPrf(salts, allowCredentials, preferImmediatelyAvailableCredentials)
512
+ } catch (e: NoCredentialException) {
513
+ if (!preferImmediatelyAvailableCredentials
514
+ || System.currentTimeMillis() >= retryUntilMs
515
+ ) {
516
+ throw e
517
+ }
518
+ Log.d(CORE_TAG, "Credential not indexed yet, re-asking")
519
+ delay(PostCreateGraceTracker.RETRY_INTERVAL_MS)
520
+ }
521
+ }
522
+ }
523
+
524
+ /**
525
+ * Run one assertion ceremony for [salts] (1 or 2): build the WebAuthn
526
+ * request (cross-device hybrid suppressed unless
527
+ * [preferImmediatelyAvailableCredentials] is false), evaluate PRF, and
528
+ * return one 32-byte output per salt the authenticator evaluated plus
529
+ * the asserted credential ID. A dropped `results.second` yields a
530
+ * single-element list.
531
+ */
532
+
533
+ private suspend fun assertPrf(
534
+ salts: List<String>,
535
+ allowCredentials: List<ByteArray>,
536
+ preferImmediatelyAvailableCredentials: Boolean,
537
+ ): Pair<List<ByteArray>, ByteArray?> {
538
+ val activity = activityProvider()
539
+ // JSONObject (not string interpolation) so integrator-supplied rpId
540
+ // is escaped, not breaking on quotes/backslashes inside Credential
541
+ // Manager.
542
+ val requestJson = JSONObject().apply {
543
+ put("challenge", randomBase64Url(32))
544
+ put("rpId", rpId)
545
+ put("allowCredentials", JSONArray().apply {
546
+ for (credId in allowCredentials) {
547
+ put(JSONObject().apply {
548
+ put("type", "public-key")
549
+ put("id", encodeBase64Url(credId))
550
+ })
551
+ }
552
+ })
553
+ put("userVerification", "required")
554
+ put("extensions", JSONObject().apply {
555
+ put("prf", JSONObject().apply {
556
+ put("eval", JSONObject().apply {
557
+ put("first", encodeBase64Url(salts[0].toByteArray(Charsets.UTF_8)))
558
+ if (salts.size > 1) {
559
+ put("second", encodeBase64Url(salts[1].toByteArray(Charsets.UTF_8)))
560
+ }
561
+ })
562
+ })
563
+ })
564
+ }.toString()
565
+
566
+ // `preferImmediatelyAvailableCredentials = true` suppresses the
567
+ // cross-device QR sheet so a missing local credential surfaces
568
+ // as NoCredentialException; `false` opts back into the picker.
569
+ val request = GetCredentialRequest.Builder()
570
+ .addCredentialOption(GetPublicKeyCredentialOption(requestJson))
571
+ .setPreferImmediatelyAvailableCredentials(preferImmediatelyAvailableCredentials)
572
+ .build()
573
+ val response = credentialManager(activity).getCredential(activity, request)
574
+
575
+ val authResponseJson = response.credential.data.getString(
576
+ "androidx.credentials.BUNDLE_KEY_AUTHENTICATION_RESPONSE_JSON",
577
+ ) ?: throw CredentialManagerPrfCoreException(
578
+ Kind.AuthenticationFailed, "No credential response",
579
+ )
580
+ val responseJson = JSONObject(authResponseJson)
581
+
582
+ // Asserted credential ID, returned inline for the binding layer.
583
+ val credentialId = responseJson.optString("rawId", "").takeIf { it.isNotEmpty() }
584
+ ?.let { decodeBase64UrlOrNull(it, "assertion rawId") }
585
+
586
+ val extensions = responseJson.optJSONObject("clientExtensionResults")
587
+ ?: throw CredentialManagerPrfCoreException(Kind.PrfNotSupported)
588
+ val prf = extensions.optJSONObject("prf")
589
+ ?: throw CredentialManagerPrfCoreException(Kind.PrfNotSupported)
590
+ val results = prf.optJSONObject("results")
591
+ ?: throw CredentialManagerPrfCoreException(Kind.PrfEvaluationFailed, "no results")
592
+
593
+ val first = results.optString("first")
594
+ if (first.isNullOrEmpty()) {
595
+ throw CredentialManagerPrfCoreException(Kind.PrfEvaluationFailed, "empty result")
596
+ }
597
+ val out = ArrayList<ByteArray>(salts.size)
598
+ out.add(decodeBase64Url(first))
599
+ // Older Credential Manager implementations silently drop
600
+ // saltInput2; omit it so the caller re-asserts single-salt.
601
+ if (salts.size > 1) {
602
+ results.optString("second", "").takeIf { it.isNotEmpty() }
603
+ ?.let { decodeBase64UrlOrNull(it, "prf.results.second") }
604
+ ?.let { out.add(it) }
605
+ }
606
+ return Pair(out, credentialId)
607
+ }
608
+
609
+ /**
610
+ * Register a new passkey (one platform prompt, no seed derivation).
611
+ * [excludeCredentials] is passed straight through so the platform
612
+ * refuses to register a credential already on the device (raising
613
+ * `CredentialAlreadyExists`, which callers route to sign-in). Returns
614
+ * the credential ID plus AAGUID / backup-eligibility from the
615
+ * attestation (null when unparseable).
616
+ */
617
+ public suspend fun register(
618
+ excludeCredentials: List<ByteArray> = emptyList(),
619
+ salts: List<String> = emptyList(),
620
+ ): PasskeyRegistration = withContext(Dispatchers.Main) {
621
+ val startedAtMs = System.currentTimeMillis()
622
+ try {
623
+ val activity = activityProvider()
624
+ // Mint a fresh random 16-byte user handle per call (never
625
+ // host-supplied); the JSON base64url string and the raw
626
+ // PasskeyCredential.userId bytes share this buffer.
627
+ val userIdBytes = ByteArray(16).also { secureRandom.nextBytes(it) }
628
+
629
+ // JSONObject (not string interpolation) so integrator strings
630
+ // are escaped, not breaking on quotes/backslashes inside
631
+ // Credential Manager.
632
+ val requestJson = JSONObject().apply {
633
+ put("challenge", randomBase64Url(32))
634
+ put("rp", JSONObject().apply {
635
+ put("id", rpId)
636
+ put("name", rpName)
637
+ })
638
+ put("user", JSONObject().apply {
639
+ put("id", encodeBase64Url(userIdBytes))
640
+ put("name", userName)
641
+ put("displayName", userDisplayName)
642
+ })
643
+ put("pubKeyCredParams", JSONArray().apply {
644
+ put(JSONObject().apply { put("type", "public-key"); put("alg", -7) })
645
+ put(JSONObject().apply { put("type", "public-key"); put("alg", -257) })
646
+ })
647
+ if (excludeCredentials.isNotEmpty()) {
648
+ put("excludeCredentials", JSONArray().apply {
649
+ for (credId in excludeCredentials) {
650
+ put(JSONObject().apply {
651
+ put("type", "public-key")
652
+ put("id", encodeBase64Url(credId))
653
+ })
654
+ }
655
+ })
656
+ }
657
+ put("authenticatorSelection", JSONObject().apply {
658
+ put("residentKey", "required")
659
+ put("requireResidentKey", true)
660
+ put("userVerification", "required")
661
+ })
662
+ put("extensions", JSONObject().apply {
663
+ // Ask the create ceremony to evaluate PRF as well as
664
+ // report support. An authenticator that answers turns
665
+ // registration into a single ceremony, so no assertion
666
+ // has to race the credential becoming resolvable. One
667
+ // that ignores it returns `enabled` only, and the
668
+ // caller falls back to `deriveSeeds`.
669
+ put("prf", JSONObject().apply {
670
+ if (salts.isNotEmpty()) {
671
+ put("eval", JSONObject().apply {
672
+ put("first", encodeBase64Url(salts[0].toByteArray(Charsets.UTF_8)))
673
+ if (salts.size > 1) {
674
+ put(
675
+ "second",
676
+ encodeBase64Url(salts[1].toByteArray(Charsets.UTF_8)),
677
+ )
678
+ }
679
+ })
680
+ }
681
+ })
682
+ })
683
+ }.toString()
684
+
685
+ val response = credentialManager(activity).createCredential(
686
+ activity,
687
+ CreatePublicKeyCredentialRequest(requestJson),
688
+ )
689
+ val registrationJson = response.data.getString(
690
+ "androidx.credentials.BUNDLE_KEY_REGISTRATION_RESPONSE_JSON"
691
+ ) ?: throw CredentialManagerPrfCoreException(
692
+ Kind.AuthenticationFailed, "No registration response",
693
+ )
694
+ val responseJson = JSONObject(registrationJson)
695
+ val rawId = responseJson.optString("rawId", "")
696
+ if (rawId.isEmpty()) {
697
+ throw CredentialManagerPrfCoreException(
698
+ Kind.AuthenticationFailed, "No credential ID in registration response",
699
+ )
700
+ }
701
+ val credentialId = decodeBase64Url(rawId)
702
+ var aaguid: ByteArray? = null
703
+ var backupEligible: Boolean? = null
704
+ val attestationB64 = responseJson.optJSONObject("response")?.optString("attestationObject", "") ?: ""
705
+ if (attestationB64.isNotEmpty()) {
706
+ extractRegistrationMetadata(decodeBase64Url(attestationB64))?.let { meta ->
707
+ aaguid = meta.first
708
+ backupEligible = meta.second
709
+ }
710
+ }
711
+ // Only usable as a complete set: a dropped `prf.eval.second`
712
+ // yields one output where the caller asked for two, and a
713
+ // partial derive is no derive at all.
714
+ val seeds = readRegistrationPrfResults(responseJson)
715
+ ?.takeIf { it.size == salts.size }
716
+ // Arm the post-create grace only when a derive still has to
717
+ // run: the window exists for that assertion. Arming it on the
718
+ // inline-seeds path would leave it set for whatever derive
719
+ // came next, which is a different ceremony entirely.
720
+ if (seeds == null) {
721
+ graceTracker.arm(postCreateGraceMs)
722
+ }
723
+ PasskeyRegistration(
724
+ PasskeyCredential(credentialId, userIdBytes, aaguid, backupEligible),
725
+ seeds,
726
+ )
727
+ } catch (e: CredentialManagerPrfCoreException) {
728
+ throw e
729
+ } catch (e: Exception) {
730
+ throw e.toCoreException(System.currentTimeMillis() - startedAtMs)
731
+ }
732
+ }
733
+
734
+ /**
735
+ * Extract AAGUID + BE flag from the attestation object's authenticator
736
+ * data via byte-pattern search for the "authData" CBOR key. Returns
737
+ * null when not found or too short.
738
+ *
739
+ * authData layout when AT flag is set (always on a successful create):
740
+ * [32] flags (UP=0, UV=2, BE=3, BS=4, AT=6)
741
+ * [37..53) AAGUID (16 bytes)
742
+ */
743
+ private fun extractRegistrationMetadata(attestation: ByteArray): Pair<ByteArray, Boolean>? {
744
+ // CBOR text key "authData": 0x68 = major type 3 (text) + length 8.
745
+ val key = byteArrayOf(0x68, 0x61, 0x75, 0x74, 0x68, 0x44, 0x61, 0x74, 0x61)
746
+ if (attestation.size < key.size) return null
747
+ var keyEnd = -1
748
+ for (i in 0..(attestation.size - key.size)) {
749
+ var match = true
750
+ for (j in key.indices) {
751
+ if (attestation[i + j] != key[j]) { match = false; break }
752
+ }
753
+ if (match) { keyEnd = i + key.size; break }
754
+ }
755
+ if (keyEnd < 0 || keyEnd >= attestation.size) return null
756
+
757
+ val header = attestation[keyEnd].toInt() and 0xff
758
+ if (header shr 5 != 2) return null
759
+ val minor = header and 0x1f
760
+ val length: Int
761
+ val dataStart: Int
762
+ when {
763
+ minor < 24 -> { length = minor; dataStart = keyEnd + 1 }
764
+ minor == 24 -> {
765
+ if (keyEnd + 1 >= attestation.size) return null
766
+ length = attestation[keyEnd + 1].toInt() and 0xff
767
+ dataStart = keyEnd + 2
768
+ }
769
+ minor == 25 -> {
770
+ if (keyEnd + 2 >= attestation.size) return null
771
+ length = ((attestation[keyEnd + 1].toInt() and 0xff) shl 8) or
772
+ (attestation[keyEnd + 2].toInt() and 0xff)
773
+ dataStart = keyEnd + 3
774
+ }
775
+ minor == 26 -> {
776
+ if (keyEnd + 4 >= attestation.size) return null
777
+ length = ((attestation[keyEnd + 1].toInt() and 0xff) shl 24) or
778
+ ((attestation[keyEnd + 2].toInt() and 0xff) shl 16) or
779
+ ((attestation[keyEnd + 3].toInt() and 0xff) shl 8) or
780
+ (attestation[keyEnd + 4].toInt() and 0xff)
781
+ dataStart = keyEnd + 5
782
+ }
783
+ else -> return null
784
+ }
785
+ if (dataStart + length > attestation.size || length < 53) return null
786
+ val flags = attestation[dataStart + 32].toInt() and 0xff
787
+ if (flags and 0x40 == 0) return null
788
+ val backupEligible = flags and 0x08 != 0
789
+ val aaguid = attestation.copyOfRange(dataStart + 37, dataStart + 53)
790
+ return Pair(aaguid, backupEligible)
791
+ }
792
+
793
+ /**
794
+ * Map a Credential Manager exception into the typed core exception.
795
+ * `elapsedMs` is the ceremony's wall-clock duration: a cancellation
796
+ * beyond ~55s reclassifies to [Kind.UserTimedOut] (biometric
797
+ * inactivity timeout) instead of [Kind.UserCancelled]. Pass `null`
798
+ * when timing is unknown to default to `UserCancelled`.
799
+ */
800
+ private fun Exception.toCoreException(
801
+ elapsedMs: Long? = null,
802
+ ): CredentialManagerPrfCoreException = when (this) {
803
+ is GetCredentialCancellationException,
804
+ is CreateCredentialCancellationException ->
805
+ CredentialManagerPrfCoreException(classifyCancellation(elapsedMs), cause = this)
806
+
807
+ is NoCredentialException ->
808
+ CredentialManagerPrfCoreException(
809
+ Kind.CredentialNotFound,
810
+ message ?: "No matching credential on this device",
811
+ this,
812
+ )
813
+
814
+ // Credential Manager wraps WebAuthn DOM errors here with the
815
+ // spec-level error in `domError`; an InvalidStateError is the
816
+ // duplicate-prevention check, routed to sign-in. Must precede the
817
+ // generic CreateCredentialException case (this is a subclass).
818
+ is CreatePublicKeyCredentialDomException ->
819
+ if (domError is InvalidStateError) {
820
+ CredentialManagerPrfCoreException(
821
+ Kind.CredentialAlreadyExists,
822
+ message ?: "Credential already registered for this RP",
823
+ this,
824
+ )
825
+ } else {
826
+ CredentialManagerPrfCoreException(
827
+ Kind.AuthenticationFailed,
828
+ "${type}: ${message ?: toString()}",
829
+ this,
830
+ )
831
+ }
832
+
833
+ is GetCredentialException ->
834
+ CredentialManagerPrfCoreException(
835
+ Kind.AuthenticationFailed,
836
+ "${type}: ${message ?: toString()}",
837
+ this,
838
+ )
839
+
840
+ is CreateCredentialException ->
841
+ CredentialManagerPrfCoreException(
842
+ Kind.AuthenticationFailed,
843
+ "${type}: ${message ?: toString()}",
844
+ this,
845
+ )
846
+
847
+ else -> {
848
+ val raw = message ?: toString()
849
+ // Actionable hints for common misconfigurations.
850
+ val hint = when {
851
+ raw.contains("cannot be validated", ignoreCase = true) ->
852
+ "Domain verification failed. Passkeys require a physical device with " +
853
+ "Google Play Services and a valid /.well-known/assetlinks.json " +
854
+ "for the RP domain. Emulators are not supported."
855
+ raw.contains("not supported", ignoreCase = true) ->
856
+ "Passkeys require Android 9+ with Google Play Services, or Android " +
857
+ "14+ with a compatible Credential Manager provider."
858
+ else -> raw
859
+ }
860
+ CredentialManagerPrfCoreException(Kind.Generic, hint, this)
861
+ }
862
+ }
863
+
864
+ /**
865
+ * Tell a user-dismissed prompt from the biometric inactivity timeout:
866
+ * AndroidX surfaces both as the same `*CancellationException`, so the
867
+ * ceremony's elapsed time is the only signal. The prompt is torn down
868
+ * at ~55s+, so anything beyond that is [Kind.UserTimedOut].
869
+ */
870
+ private fun classifyCancellation(elapsedMs: Long?): Kind {
871
+ if (elapsedMs != null && elapsedMs >= 55_000L) {
872
+ return Kind.UserTimedOut
873
+ }
874
+ return Kind.UserCancelled
875
+ }
876
+
877
+ /** Discriminator for [CredentialManagerPrfCoreException]. */
878
+ public enum class Kind {
879
+ /** The authenticator does not support the WebAuthn PRF extension. */
880
+ PrfNotSupported,
881
+ /** The user dismissed the passkey prompt or cancelled the operation. */
882
+ UserCancelled,
883
+ /**
884
+ * The biometric prompt timed out without user interaction (~55s+).
885
+ * Distinct from [UserCancelled] (active dismissal): hosts may
886
+ * auto-retry or re-prompt rather than treating it as abandonment.
887
+ */
888
+ UserTimedOut,
889
+ /** No credential exists for the RP and auto-registration was not attempted. */
890
+ CredentialNotFound,
891
+ /** Credential Manager reported an authentication / registration failure. */
892
+ AuthenticationFailed,
893
+ /** PRF evaluation produced an empty or malformed response. */
894
+ PrfEvaluationFailed,
895
+ /**
896
+ * Platform or app configuration error (e.g. missing
897
+ * assetlinks.json, misconfigured RP ID). Reserved for parity with
898
+ * the cross-platform taxonomy; Credential Manager reports these as
899
+ * `AuthenticationFailed`, so this kind is never emitted here.
900
+ */
901
+ Configuration,
902
+ /**
903
+ * Registration refused because a credential in `excludeCredentials`
904
+ * is already on the authenticator. The duplicate-prevention check,
905
+ * surfaced as a typed kind so callers route to sign-in.
906
+ */
907
+ CredentialAlreadyExists,
908
+ /** Any other unexpected error: message contains the details. */
909
+ Generic,
910
+ }
911
+ }
912
+
913
+ /**
914
+ * Result of [CredentialManagerPrfCore.deriveSeeds]: one 32-byte PRF
915
+ * output per salt (input order) plus the asserted credential ID.
916
+ * [credentialId] is `null` when no assertion ran (empty `salts`).
917
+ */
918
+ public data class PrfDerivation(
919
+ public val seeds: List<ByteArray>,
920
+ public val credentialId: ByteArray?,
921
+ )
922
+
923
+ /**
924
+ * A created credential, plus the PRF outputs when the authenticator
925
+ * evaluated them during the create ceremony. `seeds` null means it did
926
+ * not (or returned fewer than asked for), so the caller derives through
927
+ * [CredentialManagerPrfCore.deriveSeeds].
928
+ */
929
+ public data class PasskeyRegistration(
930
+ public val credential: PasskeyCredential,
931
+ public val seeds: List<ByteArray>?,
932
+ )
933
+
934
+ /**
935
+ * Typed exception thrown by [CredentialManagerPrfCore]. Wrappers should
936
+ * switch on [kind] to map to their own framework-specific error type.
937
+ */
938
+ public class CredentialManagerPrfCoreException(
939
+ public val kind: CredentialManagerPrfCore.Kind,
940
+ message: String? = null,
941
+ cause: Throwable? = null,
942
+ ) : Exception(message, cause)
943
+
944
+ /**
945
+ * Result of a domain-association check. Mirrors the Rust
946
+ * `DomainAssociation` enum one-to-one so the wrapper maps it losslessly.
947
+ */
948
+ public sealed class DomainAssociationResult {
949
+ public object Associated : DomainAssociationResult()
950
+ public data class NotAssociated(
951
+ public val source: String,
952
+ public val reason: String,
953
+ ) : DomainAssociationResult()
954
+ public data class Skipped(public val reason: String) : DomainAssociationResult()
955
+ }