@algorandfoundation/react-native-passkey-autofill 1.0.0-canary.26 → 1.0.0-canary.27

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.
Files changed (23) hide show
  1. package/ARCHITECTURE.md +12 -2
  2. package/CHANGELOG.md +13 -0
  3. package/README.md +6 -1
  4. package/android/src/main/java/co/algorand/passkeyautofill/CreatePasskeyActivity.kt +81 -52
  5. package/android/src/main/java/co/algorand/passkeyautofill/GetPasskeyActivity.kt +107 -81
  6. package/android/src/main/java/co/algorand/passkeyautofill/ReactNativePasskeyAutofillModule.kt +25 -11
  7. package/android/src/main/java/co/algorand/passkeyautofill/auth/BiometricRequirement.kt +3 -3
  8. package/android/src/main/java/co/algorand/passkeyautofill/auth/UserVerification.kt +57 -0
  9. package/android/src/main/java/co/algorand/passkeyautofill/credentials/Credential.kt +7 -0
  10. package/android/src/main/java/co/algorand/passkeyautofill/credentials/CredentialRepository.kt +203 -89
  11. package/android/src/main/java/co/algorand/passkeyautofill/credentials/KeystoreRecords.kt +72 -0
  12. package/android/src/main/java/co/algorand/passkeyautofill/credentials/RelyingParty.kt +80 -0
  13. package/android/src/main/java/co/algorand/passkeyautofill/service/PasskeyAutofillCredentialProviderService.kt +41 -14
  14. package/android/src/main/java/co/algorand/passkeyautofill/utils/PasskeyLog.kt +55 -0
  15. package/android/src/main/java/co/algorand/passkeyautofill/utils/PasskeyUtils.kt +8 -9
  16. package/app.plugin.js +49 -7
  17. package/build/ReactNativePasskeyAutofillModule.d.ts +11 -0
  18. package/build/ReactNativePasskeyAutofillModule.d.ts.map +1 -1
  19. package/build/ReactNativePasskeyAutofillModule.js.map +1 -1
  20. package/ios/AutofillCredentialProvider/CredentialProviderViewController.swift +8 -1
  21. package/ios/AutofillCredentialProvider/PasskeyCredentialStore.swift +124 -28
  22. package/package.json +1 -1
  23. package/src/ReactNativePasskeyAutofillModule.ts +11 -0
package/ARCHITECTURE.md CHANGED
@@ -43,7 +43,7 @@ Introduced in Android 14 (API level 34) and backported to Android 9 via the Jetp
43
43
  The "Chain of Trust" ensures that passkeys are only used by the legitimate owners of a domain or application. This is handled through several layers of validation:
44
44
 
45
45
  1. **Digital Asset Links**: For a passkey to be used across a website and an Android app, the website must host a `/.well-known/assetlinks.json` file that explicitly authorizes the Android app (via its package name and certificate fingerprint). This establishes a cryptographically verified link between the web origin and the mobile application.
46
- 2. **Relying Party ID (rpId) Validation**: When the service receives a `BeginGetCredentialRequest` or `BeginCreateCredentialRequest`, it includes information about the `rpId` (e.g., `example.com`). The system and the provider must ensure that the requesting app is authorized to use credentials for that `rpId`.
46
+ 2. **Relying Party ID (rpId) Validation**: When the service receives a `BeginGetCredentialRequest` or `BeginCreateCredentialRequest`, it includes information about the `rpId` (e.g., `example.com`). The system verifies that the requesting app is authorized for that `rpId`; the provider then enforces credential scope itself (`credentials/RelyingParty.kt`): a get request is resolved to an effective RP ID (the request's `rpId`, else the calling app's `android:apk-key-hash:` origin), only credentials stored for that RP are offered as entries, and `GetPasskeyActivity` re-checks the selected credential against the request before any private material is loaded. A request that names no relying party gets no entries. Related Origin Requests are not supported.
47
47
  3. **App Signature Verification**: The Android system verifies the signature of the app requesting the credential. It will only offer credentials to apps that can prove their identity through the Digital Asset Link chain.
48
48
  4. **User Consent**: The `CredentialProviderService` does not directly return the credential. Instead, it returns a list of "Entries" (like `PublicKeyCredentialEntry`). When a user selects an entry, a `PendingIntent` is triggered, which typically launches an activity (like `GetPasskeyActivity`) to perform user verification (e.g., biometric check) before the actual passkey is released to the requesting app.
49
49
 
@@ -62,7 +62,7 @@ The following methods are exposed to the JavaScript layer:
62
62
  - `setMainKeyId(id: string)`: Sets the ID for the P-256 main key (the parent secret for passkey derivation). The legacy `setHdRootKeyId` remains as a deprecated alias.
63
63
  - `getMainKeyId()`: Retrieves the current P-256 main key ID.
64
64
  - `configureIntentActions(getPasskeyAction: string, createPasskeyAction: string)`: Configures the intent actions used for Passkey flows.
65
- - `clearCredentials()`: Clears all stored credentials.
65
+ - `clearCredentials()`: Removes every passkey this module owns. On Android the passkeys MMKV instance is the wallet's own keystore namespace, so this is a record-by-record sweep of positively identified passkey records, never a `clearAll()`; `deleteCredential(id)` applies the same ownership check before removing anything.
66
66
  - `isProviderActive()`: Returns `true` if this app is the user-selected system credential/autofill provider. Uses Android's `Settings.Secure("credential_service"[_primary])` (API 34+) and iOS's `ASCredentialIdentityStore.getState`. Useful both for gating passkey UI at runtime and for E2E tests, which need to confirm that an OS passkey prompt is served by _this_ provider rather than any other installed one.
67
67
  - `openProviderSettings()`: Deep-links the user to the OS credential/autofill provider settings so they can enable this app as the active provider. Resolves to `true` if a settings screen could be launched.
68
68
 
@@ -70,6 +70,16 @@ The following methods are exposed to the JavaScript layer:
70
70
 
71
71
  As this module handles sensitive information (Passkeys), keys and secrets are handled securely in the native layers. The master key is stored in platform secure storage — never in plaintext: on Android it is AES/GCM-encrypted under the AndroidKeyStore, and on iOS it is held in the Keychain (`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`) within a shared keychain access group so the app and the AutoFill extension can both read it. It is deliberately not biometric-gated, because the extension must read it to enumerate credentials before the user authenticates; biometric verification is applied at the assertion step instead. The master key crosses the JS↔native bridge as raw bytes (`Uint8Array`), never as a hex string.
72
72
 
73
+ Encryption is a precondition, not a best effort. On Android, `setMasterKey` rejects its promise (code `ERR_MASTER_KEY`) if the key is not 32 bytes, cannot be stored in the AndroidKeyStore-backed Keychain, does not read back, or fails a seal/open round trip; nothing is silently logged and swallowed. `saveCredential` refuses to write a record when no master key is available (it throws `MasterKeyUnavailableException` and the create flow returns a `CreateCredentialUnknownException` to the relying party), so a P-256 private key is never persisted without AES-256-GCM. The Credential Provider service only offers `CreateEntry` / credential entries once the master key both reads back and passes the round trip.
74
+
75
+ ### Logging
76
+
77
+ All Android logging goes through `utils/PasskeyLog.kt`. Debug and info lines are emitted only when the host app is debuggable and are stripped from release builds; warnings and errors are always emitted but carry fixed messages and non-sensitive fields only. Request and response JSON, credential ids, user ids and handles, challenges, ciphers, signatures, PRF outputs and key material are never logged at any level. `LoggingPolicyTest` fails the unit test run if any other file in the module imports `android.util.Log`.
78
+
79
+ ### User verification and the UV flag
80
+
81
+ The `UV` bit in `authenticatorData` is only set when a user-verification ceremony ran for that operation (`auth/UserVerification.kt`). Both activities track whether the system's Credential Manager prompt reported success and whether a `BiometricPrompt` they showed succeeded, and derive the flag from those two facts when the response is built. A request with `userVerification: "required"` runs a manual prompt whenever the system did not verify, and fails if no ceremony completes; `preferred` and `discouraged` may proceed without one, with `UV` clear. `UP` stays set, since choosing the entry in the system chooser is the presence gesture.
82
+
73
83
  ## End-to-End Tests
74
84
 
75
85
  The `e2e/` workspace drives the `example/` app with Appium 2 + WebdriverIO, executed through Jest. The Android job uses the UiAutomator2 driver; the iOS job uses XCUITest. The happy-path spec mirrors the example and exercises passkey registration and assertion against `https://debug.liquidauth.com`. See [`e2e/README.md`](./e2e/README.md) for local usage and the [`E2E` workflow](./.github/workflows/e2e.yml) for CI.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
1
+ # [1.0.0-canary.27](https://github.com/algorandfoundation/react-native-passkey-autofill/compare/v1.0.0-canary.26...v1.0.0-canary.27) (2026-09-22)
2
+
3
+ ### Bug Fixes
4
+
5
+ - **android:** drop the legacy re-seal migration; the unsealed write path was unreachable ([12e16f4](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/12e16f4c9a1ac3e6c583e3a1026d9799a10801e3)), closes [#24](https://github.com/algorandfoundation/react-native-passkey-autofill/issues/24)
6
+ - **android:** fail closed when the passkey master key is unavailable ([56dbb8b](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/56dbb8ba78b9b517b1f2fa8db8e75cdf527a0909))
7
+ - **android:** offer and sign only credentials scoped to the requesting relying party ([ce715f5](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/ce715f5c680279478baea3b3ac87e4d663794244))
8
+ - **android:** only delete records this module owns from the shared keystore ([2fe3d08](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/2fe3d088dafaaf6c34d755233dc77ba2fd6b9188))
9
+ - **android:** route logging through PasskeyLog and stop logging ceremony data ([15ee108](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/15ee108e4c9171548f27ab1b8a4a9fc98a85812c))
10
+ - **android:** set the WebAuthn UV flag only when a verification ceremony ran ([a2321b5](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/a2321b501bb3211e3e4c617eb806244fcc0eb51a))
11
+ - enumerate passkeys as metadata only; open one private key after selection ([7b03f81](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/7b03f81c171ed686e8410f8e3fe3dbdab5b987cf))
12
+ - **ios:** pin the deterministic-P256 Swift package to a commit revision ([a9e8c85](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/a9e8c85653239d44acc053933f55da803175480f))
13
+
1
14
  # [1.0.0-canary.26](https://github.com/algorandfoundation/react-native-passkey-autofill/compare/v1.0.0-canary.25...v1.0.0-canary.26) (2026-08-17)
2
15
 
3
16
  ### Bug Fixes
package/README.md CHANGED
@@ -98,6 +98,9 @@ For iOS integration, make sure that:
98
98
  - The app has a `webcredentials:<domain>` associated domain, and that domain serves a valid `apple-app-site-association` file for the app identifier.
99
99
  - The deployment target is iOS 17 or newer for passkey credential provider support.
100
100
  - The generated extension target can link `AuthenticationServices.framework`, `CryptoKit.framework`, `MMKVCore`, and the deterministic P-256 Swift package.
101
+
102
+ The deterministic P-256 Swift package is pinned to an exact upstream commit, not a branch, because it derives passkey keys from the wallet's root material and produces the signatures; a branch head can change between two prebuilds of the same wallet commit. To move the pin, set `deterministicP256PackageRevision` in the plugin props to a full 40-character commit SHA you have reviewed. The former `deterministicP256PackageBranch` prop is rejected. CI checks the generated Xcode project for any Swift package requirement that is not a commit revision.
103
+
101
104
  - `NSFaceIDUsageDescription` is present when biometric authentication is used.
102
105
 
103
106
  At runtime, the app must provide the native side with the master key, identify the P-256 main key (the parent secret for passkey derivation) stored in MMKV, and keep the iOS identity store in sync:
@@ -118,7 +121,9 @@ Call `refreshCredentialIdentities()` after creating, importing, deleting, or res
118
121
  import ReactNativePasskeyAutofill from "@algorandfoundation/react-native-passkey-autofill";
119
122
 
120
123
  // 1. Set the master key for encryption (raw bytes — never a hex string, so
121
- // the secret isn't materialized as a non-zeroable JS string)
124
+ // the secret isn't materialized as a non-zeroable JS string). The promise
125
+ // REJECTS if the key cannot be stored and verified; until it resolves no
126
+ // passkey can be created, so surface the failure rather than continuing.
122
127
  await ReactNativePasskeyAutofill.setMasterKey(masterKeyBytes);
123
128
 
124
129
  // 2. Set the P-256 main key ID (parent secret for passkey derivation).
@@ -5,7 +5,7 @@ import android.app.Activity
5
5
  import android.content.Intent
6
6
  import android.os.Build
7
7
  import android.os.Bundle
8
- import android.util.Log
8
+ import co.algorand.passkeyautofill.utils.PasskeyLog
9
9
  import android.graphics.Color
10
10
  import android.graphics.Typeface
11
11
  import android.view.Gravity
@@ -17,14 +17,17 @@ import androidx.annotation.RequiresApi
17
17
  import androidx.appcompat.app.AppCompatActivity
18
18
  import androidx.credentials.CreatePublicKeyCredentialRequest
19
19
  import androidx.credentials.CreatePublicKeyCredentialResponse
20
+ import androidx.credentials.exceptions.CreateCredentialUnknownException
20
21
  import androidx.credentials.provider.PendingIntentHandler
21
22
  import androidx.credentials.provider.ProviderCreateCredentialRequest
22
23
  import androidx.credentials.webauthn.AuthenticatorAttestationResponse
23
24
  import androidx.credentials.webauthn.FidoPublicKeyCredential
24
25
  import androidx.credentials.webauthn.PublicKeyCredentialCreationOptions
25
26
  import co.algorand.passkeyautofill.auth.BiometricRequirement
27
+ import co.algorand.passkeyautofill.auth.UserVerification
26
28
  import co.algorand.passkeyautofill.credentials.CredentialRepository
27
29
  import co.algorand.passkeyautofill.credentials.Credential
30
+ import co.algorand.passkeyautofill.credentials.MasterKeyUnavailableException
28
31
  import co.algorand.passkeyautofill.utils.PasskeyUtils
29
32
  import java.security.KeyPair
30
33
  import android.util.Base64 as AndroidBase64
@@ -53,29 +56,36 @@ class CreatePasskeyActivity : AppCompatActivity() {
53
56
  private var bundleRequestJson: String? = null
54
57
  private var request: ProviderCreateCredentialRequest? = null
55
58
  private var biometricPromptResult: Any? = null
59
+
60
+ /** The system's Credential Manager prompt ran for this operation and succeeded. */
61
+ private var systemVerified: Boolean = false
56
62
  private var systemUnlockedCipher: javax.crypto.Cipher? = null
57
63
  private var isHandling: Boolean = false
58
64
 
59
65
  override fun onCreate(savedInstanceState: Bundle?) {
60
66
  super.onCreate(savedInstanceState)
61
- Log.i(TAG, "onCreate started")
67
+ PasskeyLog.init(this)
68
+ PasskeyLog.i(TAG, "onCreate started")
62
69
 
63
70
  request = try {
64
71
  PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)
65
72
  } catch (e: Exception) {
66
- Log.e(TAG, "Error retrieving request from intent", e)
73
+ PasskeyLog.e(TAG, "Error retrieving request from intent", e)
67
74
  null
68
75
  }
69
- Log.i(TAG, "Retrieved request: $request")
76
+ PasskeyLog.i(TAG, "Retrieved request: present=${request != null}")
70
77
 
71
78
  // Check for system-provided biometric result (Single Tap flow)
72
79
  try {
73
80
  val biometricResult = request?.biometricPromptResult
74
- Log.i(TAG, "biometricResult from system: $biometricResult")
81
+ PasskeyLog.i(TAG, "biometricResult from system: present=${biometricResult != null}")
75
82
  if (biometricResult != null) {
76
83
  this.biometricPromptResult = biometricResult
84
+ // A result object alone is not verification: the prompt may
85
+ // have failed or been dismissed.
86
+ systemVerified = biometricResult.isSuccessful
77
87
  val authResult = biometricResult.authenticationResult
78
- Log.i(TAG, "authResult from system: $authResult (${authResult?.javaClass?.name})")
88
+ PasskeyLog.i(TAG, "authResult from system: present=${authResult != null}, successful=$systemVerified")
79
89
 
80
90
  // Also try to find it in the biometricResult object itself
81
91
  systemUnlockedCipher = if (authResult != null) {
@@ -83,10 +93,10 @@ class CreatePasskeyActivity : AppCompatActivity() {
83
93
  } else {
84
94
  PasskeyUtils.extractCipher(biometricResult)
85
95
  }
86
- Log.i(TAG, "systemUnlockedCipher from system: $systemUnlockedCipher")
96
+ PasskeyLog.i(TAG, "systemUnlockedCipher from system: present=${systemUnlockedCipher != null}")
87
97
  }
88
98
  } catch (e: Exception) {
89
- Log.e(TAG, "Error processing biometricPromptResult", e)
99
+ PasskeyLog.e(TAG, "Error processing biometricPromptResult", e)
90
100
  }
91
101
 
92
102
  val credentialData = intent.getBundleExtra("CREDENTIAL_DATA")
@@ -118,13 +128,13 @@ class CreatePasskeyActivity : AppCompatActivity() {
118
128
  userName = pkJson.optJSONObject("user")?.optString("displayName") ?: userHandle
119
129
  userId = pkJson.optJSONObject("user")?.optString("id") ?: "unknown-id"
120
130
  } catch (e: Exception) {
121
- Log.e(TAG, "Error parsing request", e)
131
+ PasskeyLog.e(TAG, "Error parsing request", e)
122
132
  }
123
133
  }
124
134
 
125
135
  // If the system already showed a biometric prompt (Single Tap), proceed automatically
126
136
  if (biometricPromptResult != null) {
127
- Log.d(TAG, "System already showed biometric prompt (Single Tap), proceeding automatically")
137
+ PasskeyLog.d(TAG, "System already showed biometric prompt (Single Tap), proceeding automatically")
128
138
  handleCreation()
129
139
  return
130
140
  }
@@ -278,48 +288,49 @@ class CreatePasskeyActivity : AppCompatActivity() {
278
288
  private fun handleCreation() {
279
289
  if (isHandling) return
280
290
  isHandling = true
281
- Log.d(TAG, "handleCreation started, userVerification=$userVerification")
291
+ PasskeyLog.d(TAG, "handleCreation started, userVerification=$userVerification")
282
292
  lifecycleScope.launch {
283
- val cipherToUse = systemUnlockedCipher ?: run {
293
+ // A manual BiometricPrompt this activity showed succeeded.
294
+ var manualVerified = false
295
+ var cipherToUse = systemUnlockedCipher ?: run {
284
296
  if (biometricPromptResult != null) {
285
297
  try {
286
298
  val fallback = credentialRepository.getBiometricCipherForEncryption(this@CreatePasskeyActivity, BiometricRequirement.resolve(this@CreatePasskeyActivity))
287
- Log.i(TAG, "Successfully obtained fallback cipher from repository (Single Tap timeout)")
299
+ PasskeyLog.i(TAG, "Successfully obtained fallback cipher from repository (Single Tap timeout)")
288
300
  fallback
289
301
  } catch (e: Exception) {
290
- Log.d(TAG, "Fallback cipher failed: ${e.message}")
302
+ PasskeyLog.d(TAG, "Fallback cipher failed: ${e.message}")
291
303
  null
292
304
  }
293
305
  } else {
294
306
  null
295
307
  }
296
- } ?: run {
297
- // Only prompt for biometrics if userVerification is "required"
298
- // For "preferred" and "discouraged", we can proceed without a manual biometric prompt
299
- if (userVerification == "required") {
300
- Log.i(TAG, "userVerification is required, running manual biometrics")
301
- val result = biometrics(true)
302
- Log.d(TAG, "Manual biometrics result: $result")
303
- if (result == null) {
304
- Log.w(TAG, "Biometrics failed or was canceled")
305
- setupUI() // Show UI as fallback
306
- isHandling = false
307
- return@launch
308
- }
309
- result.cryptoObject?.cipher
310
- } else {
311
- Log.d(TAG, "userVerification is $userVerification, skipping manual biometrics")
312
- null
308
+ }
309
+
310
+ // "required" means a verification ceremony MUST run for this
311
+ // operation. If the system's prompt did not succeed (it was not
312
+ // shown, failed, or was dismissed), run our own; "preferred" and
313
+ // "discouraged" proceed without one, and the response says so.
314
+ if (UserVerification.normalize(userVerification) == UserVerification.REQUIRED && !systemVerified) {
315
+ PasskeyLog.i(TAG, "userVerification is required and the system did not verify; running manual biometrics")
316
+ val result = biometrics(true)
317
+ if (result == null) {
318
+ PasskeyLog.w(TAG, "Biometrics failed or was canceled")
319
+ setupUI() // Show UI as fallback
320
+ isHandling = false
321
+ return@launch
313
322
  }
323
+ manualVerified = true
324
+ cipherToUse = result.cryptoObject?.cipher ?: cipherToUse
325
+ } else if (!systemVerified) {
326
+ PasskeyLog.d(TAG, "userVerification is $userVerification, skipping manual biometrics")
314
327
  }
315
-
316
- Log.d(TAG, "cipherToUse: $cipherToUse")
317
-
328
+
318
329
  var finalCipher = cipherToUse
319
330
 
320
331
  try {
321
332
  val req = request ?: throw IllegalStateException("No request found")
322
- Log.d(TAG, "Request found, origin: $origin")
333
+ PasskeyLog.d(TAG, "Request found")
323
334
 
324
335
  // Prefer using the request JSON from the bundle if available
325
336
  val rawRequestJson = bundleRequestJson ?: run {
@@ -333,22 +344,21 @@ class CreatePasskeyActivity : AppCompatActivity() {
333
344
  } else {
334
345
  rawRequestJson
335
346
  }
336
- Log.d(TAG, "Passkey request JSON: $passkeyReqJson")
337
347
  val requestOptions = try {
338
348
  PublicKeyCredentialCreationOptions(passkeyReqJson)
339
349
  } catch (e: org.json.JSONException) {
340
- Log.e(TAG, "Invalid passkey creation request JSON: $passkeyReqJson")
350
+ PasskeyLog.e(TAG, "Invalid passkey creation request JSON")
341
351
  throw e
342
352
  }
343
353
 
344
- Log.d(TAG, "Creating deterministic key pair")
354
+ PasskeyLog.d(TAG, "Creating deterministic key pair")
345
355
  // No requested scheme: a new credential takes the preferred parent
346
356
  // (the wallet's deterministic-P256 main key), and records which one
347
357
  // it got so every later assertion re-derives the same key.
348
358
  val derived = credentialRepository.createDomainKeyPair(this@CreatePasskeyActivity, origin, userHandle)
349
359
  val keyPair: KeyPair = derived.keyPair
350
- Log.d(TAG, "Derived from parent ${derived.parentKeyId} (${derived.derivationScheme})")
351
- Log.d(TAG, "Generating credential ID")
360
+ PasskeyLog.d(TAG, "Derived from parent ${derived.parentKeyId} (${derived.derivationScheme})")
361
+ PasskeyLog.d(TAG, "Generating credential ID")
352
362
  val credentialId = credentialRepository.generateCredentialId(keyPair)
353
363
  val credentialIdBase64 = AndroidBase64.encodeToString(credentialId, AndroidBase64.NO_WRAP)
354
364
 
@@ -364,17 +374,18 @@ class CreatePasskeyActivity : AppCompatActivity() {
364
374
  derivationScheme = derived.derivationScheme
365
375
  )
366
376
 
367
- Log.d(TAG, "Saving credential to repository")
377
+ PasskeyLog.d(TAG, "Saving credential to repository")
368
378
  try {
369
379
  credentialRepository.saveCredential(this@CreatePasskeyActivity, credential, finalCipher)
370
380
  } catch (e: Exception) {
371
381
  if (e.message?.contains("user not authenticated", ignoreCase = true) == true ||
372
382
  e.cause?.message?.contains("user not authenticated", ignoreCase = true) == true) {
373
- Log.i(TAG, "Key is locked, triggering manual biometric prompt")
383
+ PasskeyLog.i(TAG, "Key is locked, triggering manual biometric prompt")
374
384
  val result = biometrics(true)
375
385
  if (result != null) {
386
+ manualVerified = true
376
387
  finalCipher = result.cryptoObject?.cipher
377
- Log.i(TAG, "Retrying save with manual biometric cipher")
388
+ PasskeyLog.i(TAG, "Retrying save with manual biometric cipher")
378
389
  credentialRepository.saveCredential(this@CreatePasskeyActivity, credential, finalCipher)
379
390
  } else {
380
391
  throw e
@@ -384,14 +395,21 @@ class CreatePasskeyActivity : AppCompatActivity() {
384
395
  }
385
396
  }
386
397
 
387
- Log.d(TAG, "Building AuthenticatorAttestationResponse")
398
+ // UV reflects what actually happened in this operation. UP stays set:
399
+ // the user chose this provider's entry in the system chooser (or
400
+ // tapped Create in our sheet), which is the presence gesture.
401
+ val verification = UserVerification.outcome(userVerification, systemVerified, manualVerified)
402
+ check(verification.satisfiesRequest) {
403
+ "Relying party requires user verification but no verification ceremony completed"
404
+ }
405
+ PasskeyLog.d(TAG, "Building AuthenticatorAttestationResponse (uv=${verification.verified})")
388
406
  val response = AuthenticatorAttestationResponse(
389
407
  requestOptions = requestOptions,
390
408
  credentialId = credentialId,
391
409
  credentialPublicKey = credentialRepository.getPublicKeyFromKeyPair(keyPair),
392
410
  origin = credentialRepository.getOrigin(req.callingAppInfo),
393
411
  up = true,
394
- uv = true,
412
+ uv = verification.verified,
395
413
  be = true,
396
414
  bs = true,
397
415
  packageName = req.callingAppInfo.packageName
@@ -415,7 +433,7 @@ class CreatePasskeyActivity : AppCompatActivity() {
415
433
  requestJson.getString("challenge")
416
434
  }
417
435
 
418
- Log.d(TAG, "Building clientDataJSON, challenge: $challenge, origin: $sanitizedOrigin")
436
+ PasskeyLog.d(TAG, "Building clientDataJSON")
419
437
  val clientDataJSONString = if (sanitizedOrigin.startsWith("https://") || sanitizedOrigin.startsWith("http://")) {
420
438
  "{\"type\":\"webauthn.create\",\"challenge\":\"$challenge\",\"origin\":\"$sanitizedOrigin\",\"crossOrigin\":false}"
421
439
  } else {
@@ -437,7 +455,7 @@ class CreatePasskeyActivity : AppCompatActivity() {
437
455
  val patched = WebAuthn.injectAaguid(respJson.getString("attestationObject"), aaguid)
438
456
  respJson.put("attestationObject", patched)
439
457
  } catch (e: Exception) {
440
- Log.w(TAG, "Failed to inject AAGUID into attestationObject", e)
458
+ PasskeyLog.w(TAG, "Failed to inject AAGUID into attestationObject", e)
441
459
  }
442
460
  }
443
461
 
@@ -454,11 +472,10 @@ class CreatePasskeyActivity : AppCompatActivity() {
454
472
  val prfResult = JSONObject().put("enabled", true)
455
473
  clientExtensionResults.put("prf", prfResult)
456
474
  fullJson.put("clientExtensionResults", clientExtensionResults)
457
- Log.d(TAG, "Advertised PRF support on registration")
475
+ PasskeyLog.d(TAG, "Advertised PRF support on registration")
458
476
  }
459
477
 
460
478
  val createResponse = CreatePublicKeyCredentialResponse(fullJson.toString())
461
- Log.d(TAG, "CreatePublicKeyCredentialResponse: ${fullJson.toString()}")
462
479
 
463
480
  PendingIntentHandler.setCreateCredentialResponse(
464
481
  resultIntent,
@@ -466,13 +483,25 @@ class CreatePasskeyActivity : AppCompatActivity() {
466
483
  )
467
484
 
468
485
  setResult(Activity.RESULT_OK, resultIntent)
469
- Log.d(TAG, "Result set to OK")
486
+ PasskeyLog.d(TAG, "Result set to OK")
470
487
  ReactNativePasskeyAutofillModule.instance?.sendEvent("onPasskeyAdded", Bundle().apply {
471
488
  putBoolean("success", true)
472
489
  })
473
490
  finish()
491
+ } catch (e: MasterKeyUnavailableException) {
492
+ // Nothing was written. Retrying from the UI cannot help — only the
493
+ // wallet calling setMasterKey can — so report a definite failure to
494
+ // the relying party instead of leaving the sheet open.
495
+ PasskeyLog.e(TAG, "Passkey creation aborted: master key unavailable", e)
496
+ val errorIntent = Intent()
497
+ PendingIntentHandler.setCreateCredentialException(
498
+ errorIntent,
499
+ CreateCredentialUnknownException("Passkey provider is not ready: master key unavailable"),
500
+ )
501
+ setResult(Activity.RESULT_OK, errorIntent)
502
+ finish()
474
503
  } catch (e: Exception) {
475
- Log.e(TAG, "Error during passkey creation", e)
504
+ PasskeyLog.e(TAG, "Error during passkey creation", e)
476
505
  setupUI() // Show UI on error
477
506
  isHandling = false
478
507
  }
@@ -514,7 +543,7 @@ class CreatePasskeyActivity : AppCompatActivity() {
514
543
  val cipher = credentialRepository.getBiometricCipherForEncryption(this, requirement)
515
544
  biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
516
545
  } catch (e: Exception) {
517
- Log.e(TAG, "Failed to initialize biometric prompt with cipher", e)
546
+ PasskeyLog.e(TAG, "Failed to initialize biometric prompt with cipher", e)
518
547
  biometricPrompt.authenticate(promptInfo)
519
548
  }
520
549
  } else {