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

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 (26) hide show
  1. package/ARCHITECTURE.md +12 -2
  2. package/CHANGELOG.md +19 -0
  3. package/README.md +6 -1
  4. package/android/src/main/AndroidManifest.xml +2 -0
  5. package/android/src/main/java/co/algorand/passkeyautofill/CreatePasskeyActivity.kt +97 -54
  6. package/android/src/main/java/co/algorand/passkeyautofill/GetPasskeyActivity.kt +138 -84
  7. package/android/src/main/java/co/algorand/passkeyautofill/ReactNativePasskeyAutofillModule.kt +25 -11
  8. package/android/src/main/java/co/algorand/passkeyautofill/auth/BiometricRequirement.kt +3 -3
  9. package/android/src/main/java/co/algorand/passkeyautofill/auth/UserVerification.kt +57 -0
  10. package/android/src/main/java/co/algorand/passkeyautofill/credentials/Credential.kt +7 -0
  11. package/android/src/main/java/co/algorand/passkeyautofill/credentials/CredentialRepository.kt +236 -89
  12. package/android/src/main/java/co/algorand/passkeyautofill/credentials/KeystoreRecords.kt +72 -0
  13. package/android/src/main/java/co/algorand/passkeyautofill/credentials/RelyingParty.kt +80 -0
  14. package/android/src/main/java/co/algorand/passkeyautofill/service/PasskeyAutofillCredentialProviderService.kt +50 -14
  15. package/android/src/main/java/co/algorand/passkeyautofill/utils/PasskeyLog.kt +55 -0
  16. package/android/src/main/java/co/algorand/passkeyautofill/utils/PasskeyUtils.kt +8 -9
  17. package/android/src/main/java/co/algorand/passkeyautofill/utils/PrivilegedBrowserAllowlist.kt +238 -0
  18. package/android/src/main/res/raw/gpm_privileged_browser_allowlist.json +980 -0
  19. package/app.plugin.js +49 -7
  20. package/build/ReactNativePasskeyAutofillModule.d.ts +11 -0
  21. package/build/ReactNativePasskeyAutofillModule.d.ts.map +1 -1
  22. package/build/ReactNativePasskeyAutofillModule.js.map +1 -1
  23. package/ios/AutofillCredentialProvider/CredentialProviderViewController.swift +8 -1
  24. package/ios/AutofillCredentialProvider/PasskeyCredentialStore.swift +124 -28
  25. package/package.json +1 -1
  26. 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,22 @@
1
+ # [1.0.0-canary.28](https://github.com/algorandfoundation/react-native-passkey-autofill/compare/v1.0.0-canary.27...v1.0.0-canary.28) (2026-09-29)
2
+
3
+ ### Bug Fixes
4
+
5
+ - restrict client hash to privileged list ([d17cea0](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/d17cea058745b5de0103d4ccd767885a2d185981))
6
+
7
+ # [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)
8
+
9
+ ### Bug Fixes
10
+
11
+ - **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)
12
+ - **android:** fail closed when the passkey master key is unavailable ([56dbb8b](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/56dbb8ba78b9b517b1f2fa8db8e75cdf527a0909))
13
+ - **android:** offer and sign only credentials scoped to the requesting relying party ([ce715f5](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/ce715f5c680279478baea3b3ac87e4d663794244))
14
+ - **android:** only delete records this module owns from the shared keystore ([2fe3d08](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/2fe3d088dafaaf6c34d755233dc77ba2fd6b9188))
15
+ - **android:** route logging through PasskeyLog and stop logging ceremony data ([15ee108](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/15ee108e4c9171548f27ab1b8a4a9fc98a85812c))
16
+ - **android:** set the WebAuthn UV flag only when a verification ceremony ran ([a2321b5](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/a2321b501bb3211e3e4c617eb806244fcc0eb51a))
17
+ - enumerate passkeys as metadata only; open one private key after selection ([7b03f81](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/7b03f81c171ed686e8410f8e3fe3dbdab5b987cf))
18
+ - **ios:** pin the deterministic-P256 Swift package to a commit revision ([a9e8c85](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/a9e8c85653239d44acc053933f55da803175480f))
19
+
1
20
  # [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
21
 
3
22
  ### 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).
@@ -1,6 +1,8 @@
1
1
  <manifest xmlns:android="http://schemas.android.com/apk/res/android"
2
2
  xmlns:tools="http://schemas.android.com/tools">
3
3
  <uses-permission android:name="android.permission.USE_BIOMETRIC"/>
4
+ <!-- Refreshes Google's privileged-browser allowlist (PrivilegedBrowserAllowlist). -->
5
+ <uses-permission android:name="android.permission.INTERNET"/>
4
6
  <application>
5
7
  <service
6
8
  android:name=".service.PasskeyAutofillCredentialProviderService"
@@ -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,15 +17,19 @@ 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
32
+ import co.algorand.passkeyautofill.utils.PrivilegedBrowserAllowlist
29
33
  import java.security.KeyPair
30
34
  import android.util.Base64 as AndroidBase64
31
35
  import androidx.biometric.BiometricPrompt
@@ -46,6 +50,8 @@ class CreatePasskeyActivity : AppCompatActivity() {
46
50
 
47
51
  private var origin: String = "unknown-origin" // Derivation origin (rpId or hash)
48
52
  private var displayOrigin: String = "unknown-origin"
53
+ /** The verified web origin when the caller is an allow-listed privileged browser; null for every ordinary app. */
54
+ private var privilegedOrigin: String? = null
49
55
  private var userHandle: String = "unknown-user"
50
56
  private var userName: String = "User"
51
57
  private var userId: String = "unknown-id"
@@ -53,29 +59,36 @@ class CreatePasskeyActivity : AppCompatActivity() {
53
59
  private var bundleRequestJson: String? = null
54
60
  private var request: ProviderCreateCredentialRequest? = null
55
61
  private var biometricPromptResult: Any? = null
62
+
63
+ /** The system's Credential Manager prompt ran for this operation and succeeded. */
64
+ private var systemVerified: Boolean = false
56
65
  private var systemUnlockedCipher: javax.crypto.Cipher? = null
57
66
  private var isHandling: Boolean = false
58
67
 
59
68
  override fun onCreate(savedInstanceState: Bundle?) {
60
69
  super.onCreate(savedInstanceState)
61
- Log.i(TAG, "onCreate started")
70
+ PasskeyLog.init(this)
71
+ PasskeyLog.i(TAG, "onCreate started")
62
72
 
63
73
  request = try {
64
74
  PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)
65
75
  } catch (e: Exception) {
66
- Log.e(TAG, "Error retrieving request from intent", e)
76
+ PasskeyLog.e(TAG, "Error retrieving request from intent", e)
67
77
  null
68
78
  }
69
- Log.i(TAG, "Retrieved request: $request")
79
+ PasskeyLog.i(TAG, "Retrieved request: present=${request != null}")
70
80
 
71
81
  // Check for system-provided biometric result (Single Tap flow)
72
82
  try {
73
83
  val biometricResult = request?.biometricPromptResult
74
- Log.i(TAG, "biometricResult from system: $biometricResult")
84
+ PasskeyLog.i(TAG, "biometricResult from system: present=${biometricResult != null}")
75
85
  if (biometricResult != null) {
76
86
  this.biometricPromptResult = biometricResult
87
+ // A result object alone is not verification: the prompt may
88
+ // have failed or been dismissed.
89
+ systemVerified = biometricResult.isSuccessful
77
90
  val authResult = biometricResult.authenticationResult
78
- Log.i(TAG, "authResult from system: $authResult (${authResult?.javaClass?.name})")
91
+ PasskeyLog.i(TAG, "authResult from system: present=${authResult != null}, successful=$systemVerified")
79
92
 
80
93
  // Also try to find it in the biometricResult object itself
81
94
  systemUnlockedCipher = if (authResult != null) {
@@ -83,10 +96,10 @@ class CreatePasskeyActivity : AppCompatActivity() {
83
96
  } else {
84
97
  PasskeyUtils.extractCipher(biometricResult)
85
98
  }
86
- Log.i(TAG, "systemUnlockedCipher from system: $systemUnlockedCipher")
99
+ PasskeyLog.i(TAG, "systemUnlockedCipher from system: present=${systemUnlockedCipher != null}")
87
100
  }
88
101
  } catch (e: Exception) {
89
- Log.e(TAG, "Error processing biometricPromptResult", e)
102
+ PasskeyLog.e(TAG, "Error processing biometricPromptResult", e)
90
103
  }
91
104
 
92
105
  val credentialData = intent.getBundleExtra("CREDENTIAL_DATA")
@@ -98,6 +111,12 @@ class CreatePasskeyActivity : AppCompatActivity() {
98
111
 
99
112
  if (request != null) {
100
113
  try {
114
+ // A caller-asserted web origin is trusted only for an allow-listed
115
+ // privileged browser; everyone else is bound to their app identity.
116
+ val allowlist = PrivilegedBrowserAllowlist.json(this)
117
+ privilegedOrigin = allowlist?.let {
118
+ credentialRepository.getPrivilegedOrigin(request!!.callingAppInfo, it)
119
+ }
101
120
  val systemOrigin = credentialRepository.getOrigin(request!!.callingAppInfo)
102
121
  origin = systemOrigin
103
122
  displayOrigin = systemOrigin
@@ -118,13 +137,13 @@ class CreatePasskeyActivity : AppCompatActivity() {
118
137
  userName = pkJson.optJSONObject("user")?.optString("displayName") ?: userHandle
119
138
  userId = pkJson.optJSONObject("user")?.optString("id") ?: "unknown-id"
120
139
  } catch (e: Exception) {
121
- Log.e(TAG, "Error parsing request", e)
140
+ PasskeyLog.e(TAG, "Error parsing request", e)
122
141
  }
123
142
  }
124
143
 
125
144
  // If the system already showed a biometric prompt (Single Tap), proceed automatically
126
145
  if (biometricPromptResult != null) {
127
- Log.d(TAG, "System already showed biometric prompt (Single Tap), proceeding automatically")
146
+ PasskeyLog.d(TAG, "System already showed biometric prompt (Single Tap), proceeding automatically")
128
147
  handleCreation()
129
148
  return
130
149
  }
@@ -278,48 +297,49 @@ class CreatePasskeyActivity : AppCompatActivity() {
278
297
  private fun handleCreation() {
279
298
  if (isHandling) return
280
299
  isHandling = true
281
- Log.d(TAG, "handleCreation started, userVerification=$userVerification")
300
+ PasskeyLog.d(TAG, "handleCreation started, userVerification=$userVerification")
282
301
  lifecycleScope.launch {
283
- val cipherToUse = systemUnlockedCipher ?: run {
302
+ // A manual BiometricPrompt this activity showed succeeded.
303
+ var manualVerified = false
304
+ var cipherToUse = systemUnlockedCipher ?: run {
284
305
  if (biometricPromptResult != null) {
285
306
  try {
286
307
  val fallback = credentialRepository.getBiometricCipherForEncryption(this@CreatePasskeyActivity, BiometricRequirement.resolve(this@CreatePasskeyActivity))
287
- Log.i(TAG, "Successfully obtained fallback cipher from repository (Single Tap timeout)")
308
+ PasskeyLog.i(TAG, "Successfully obtained fallback cipher from repository (Single Tap timeout)")
288
309
  fallback
289
310
  } catch (e: Exception) {
290
- Log.d(TAG, "Fallback cipher failed: ${e.message}")
311
+ PasskeyLog.d(TAG, "Fallback cipher failed: ${e.message}")
291
312
  null
292
313
  }
293
314
  } else {
294
315
  null
295
316
  }
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
317
+ }
318
+
319
+ // "required" means a verification ceremony MUST run for this
320
+ // operation. If the system's prompt did not succeed (it was not
321
+ // shown, failed, or was dismissed), run our own; "preferred" and
322
+ // "discouraged" proceed without one, and the response says so.
323
+ if (UserVerification.normalize(userVerification) == UserVerification.REQUIRED && !systemVerified) {
324
+ PasskeyLog.i(TAG, "userVerification is required and the system did not verify; running manual biometrics")
325
+ val result = biometrics(true)
326
+ if (result == null) {
327
+ PasskeyLog.w(TAG, "Biometrics failed or was canceled")
328
+ setupUI() // Show UI as fallback
329
+ isHandling = false
330
+ return@launch
313
331
  }
332
+ manualVerified = true
333
+ cipherToUse = result.cryptoObject?.cipher ?: cipherToUse
334
+ } else if (!systemVerified) {
335
+ PasskeyLog.d(TAG, "userVerification is $userVerification, skipping manual biometrics")
314
336
  }
315
-
316
- Log.d(TAG, "cipherToUse: $cipherToUse")
317
-
337
+
318
338
  var finalCipher = cipherToUse
319
339
 
320
340
  try {
321
341
  val req = request ?: throw IllegalStateException("No request found")
322
- Log.d(TAG, "Request found, origin: $origin")
342
+ PasskeyLog.d(TAG, "Request found")
323
343
 
324
344
  // Prefer using the request JSON from the bundle if available
325
345
  val rawRequestJson = bundleRequestJson ?: run {
@@ -333,22 +353,21 @@ class CreatePasskeyActivity : AppCompatActivity() {
333
353
  } else {
334
354
  rawRequestJson
335
355
  }
336
- Log.d(TAG, "Passkey request JSON: $passkeyReqJson")
337
356
  val requestOptions = try {
338
357
  PublicKeyCredentialCreationOptions(passkeyReqJson)
339
358
  } catch (e: org.json.JSONException) {
340
- Log.e(TAG, "Invalid passkey creation request JSON: $passkeyReqJson")
359
+ PasskeyLog.e(TAG, "Invalid passkey creation request JSON")
341
360
  throw e
342
361
  }
343
362
 
344
- Log.d(TAG, "Creating deterministic key pair")
363
+ PasskeyLog.d(TAG, "Creating deterministic key pair")
345
364
  // No requested scheme: a new credential takes the preferred parent
346
365
  // (the wallet's deterministic-P256 main key), and records which one
347
366
  // it got so every later assertion re-derives the same key.
348
367
  val derived = credentialRepository.createDomainKeyPair(this@CreatePasskeyActivity, origin, userHandle)
349
368
  val keyPair: KeyPair = derived.keyPair
350
- Log.d(TAG, "Derived from parent ${derived.parentKeyId} (${derived.derivationScheme})")
351
- Log.d(TAG, "Generating credential ID")
369
+ PasskeyLog.d(TAG, "Derived from parent ${derived.parentKeyId} (${derived.derivationScheme})")
370
+ PasskeyLog.d(TAG, "Generating credential ID")
352
371
  val credentialId = credentialRepository.generateCredentialId(keyPair)
353
372
  val credentialIdBase64 = AndroidBase64.encodeToString(credentialId, AndroidBase64.NO_WRAP)
354
373
 
@@ -364,17 +383,18 @@ class CreatePasskeyActivity : AppCompatActivity() {
364
383
  derivationScheme = derived.derivationScheme
365
384
  )
366
385
 
367
- Log.d(TAG, "Saving credential to repository")
386
+ PasskeyLog.d(TAG, "Saving credential to repository")
368
387
  try {
369
388
  credentialRepository.saveCredential(this@CreatePasskeyActivity, credential, finalCipher)
370
389
  } catch (e: Exception) {
371
390
  if (e.message?.contains("user not authenticated", ignoreCase = true) == true ||
372
391
  e.cause?.message?.contains("user not authenticated", ignoreCase = true) == true) {
373
- Log.i(TAG, "Key is locked, triggering manual biometric prompt")
392
+ PasskeyLog.i(TAG, "Key is locked, triggering manual biometric prompt")
374
393
  val result = biometrics(true)
375
394
  if (result != null) {
395
+ manualVerified = true
376
396
  finalCipher = result.cryptoObject?.cipher
377
- Log.i(TAG, "Retrying save with manual biometric cipher")
397
+ PasskeyLog.i(TAG, "Retrying save with manual biometric cipher")
378
398
  credentialRepository.saveCredential(this@CreatePasskeyActivity, credential, finalCipher)
379
399
  } else {
380
400
  throw e
@@ -384,14 +404,26 @@ class CreatePasskeyActivity : AppCompatActivity() {
384
404
  }
385
405
  }
386
406
 
387
- Log.d(TAG, "Building AuthenticatorAttestationResponse")
407
+ // UV reflects what actually happened in this operation. UP stays set:
408
+ // the user chose this provider's entry in the system chooser (or
409
+ // tapped Create in our sheet), which is the presence gesture.
410
+ val verification = UserVerification.outcome(userVerification, systemVerified, manualVerified)
411
+ check(verification.satisfiesRequest) {
412
+ "Relying party requires user verification but no verification ceremony completed"
413
+ }
414
+ PasskeyLog.d(TAG, "Building AuthenticatorAttestationResponse (uv=${verification.verified})")
415
+ // The origin the registration is bound to: the verified web origin
416
+ // for an allow-listed privileged browser, otherwise the caller's
417
+ // android:apk-key-hash: identity. This is independent of `origin`,
418
+ // which stays the derivation origin (rpId) so keys re-derive stably.
419
+ val responseOrigin = privilegedOrigin ?: credentialRepository.getOrigin(req.callingAppInfo)
388
420
  val response = AuthenticatorAttestationResponse(
389
421
  requestOptions = requestOptions,
390
422
  credentialId = credentialId,
391
423
  credentialPublicKey = credentialRepository.getPublicKeyFromKeyPair(keyPair),
392
- origin = credentialRepository.getOrigin(req.callingAppInfo),
424
+ origin = responseOrigin,
393
425
  up = true,
394
- uv = true,
426
+ uv = verification.verified,
395
427
  be = true,
396
428
  bs = true,
397
429
  packageName = req.callingAppInfo.packageName
@@ -408,14 +440,14 @@ class CreatePasskeyActivity : AppCompatActivity() {
408
440
  val respJson = fullJson.getJSONObject("response")
409
441
 
410
442
  // Ensure compact clientDataJSON for registration too
411
- val sanitizedOrigin = credentialRepository.getOrigin(req.callingAppInfo).replace(Regex("/$"), "")
443
+ val sanitizedOrigin = responseOrigin.replace(Regex("/$"), "")
412
444
  val challenge = if (requestJson.has("publicKey")) {
413
445
  requestJson.getJSONObject("publicKey").getString("challenge")
414
446
  } else {
415
447
  requestJson.getString("challenge")
416
448
  }
417
449
 
418
- Log.d(TAG, "Building clientDataJSON, challenge: $challenge, origin: $sanitizedOrigin")
450
+ PasskeyLog.d(TAG, "Building clientDataJSON")
419
451
  val clientDataJSONString = if (sanitizedOrigin.startsWith("https://") || sanitizedOrigin.startsWith("http://")) {
420
452
  "{\"type\":\"webauthn.create\",\"challenge\":\"$challenge\",\"origin\":\"$sanitizedOrigin\",\"crossOrigin\":false}"
421
453
  } else {
@@ -437,7 +469,7 @@ class CreatePasskeyActivity : AppCompatActivity() {
437
469
  val patched = WebAuthn.injectAaguid(respJson.getString("attestationObject"), aaguid)
438
470
  respJson.put("attestationObject", patched)
439
471
  } catch (e: Exception) {
440
- Log.w(TAG, "Failed to inject AAGUID into attestationObject", e)
472
+ PasskeyLog.w(TAG, "Failed to inject AAGUID into attestationObject", e)
441
473
  }
442
474
  }
443
475
 
@@ -454,11 +486,10 @@ class CreatePasskeyActivity : AppCompatActivity() {
454
486
  val prfResult = JSONObject().put("enabled", true)
455
487
  clientExtensionResults.put("prf", prfResult)
456
488
  fullJson.put("clientExtensionResults", clientExtensionResults)
457
- Log.d(TAG, "Advertised PRF support on registration")
489
+ PasskeyLog.d(TAG, "Advertised PRF support on registration")
458
490
  }
459
491
 
460
492
  val createResponse = CreatePublicKeyCredentialResponse(fullJson.toString())
461
- Log.d(TAG, "CreatePublicKeyCredentialResponse: ${fullJson.toString()}")
462
493
 
463
494
  PendingIntentHandler.setCreateCredentialResponse(
464
495
  resultIntent,
@@ -466,13 +497,25 @@ class CreatePasskeyActivity : AppCompatActivity() {
466
497
  )
467
498
 
468
499
  setResult(Activity.RESULT_OK, resultIntent)
469
- Log.d(TAG, "Result set to OK")
500
+ PasskeyLog.d(TAG, "Result set to OK")
470
501
  ReactNativePasskeyAutofillModule.instance?.sendEvent("onPasskeyAdded", Bundle().apply {
471
502
  putBoolean("success", true)
472
503
  })
473
504
  finish()
505
+ } catch (e: MasterKeyUnavailableException) {
506
+ // Nothing was written. Retrying from the UI cannot help — only the
507
+ // wallet calling setMasterKey can — so report a definite failure to
508
+ // the relying party instead of leaving the sheet open.
509
+ PasskeyLog.e(TAG, "Passkey creation aborted: master key unavailable", e)
510
+ val errorIntent = Intent()
511
+ PendingIntentHandler.setCreateCredentialException(
512
+ errorIntent,
513
+ CreateCredentialUnknownException("Passkey provider is not ready: master key unavailable"),
514
+ )
515
+ setResult(Activity.RESULT_OK, errorIntent)
516
+ finish()
474
517
  } catch (e: Exception) {
475
- Log.e(TAG, "Error during passkey creation", e)
518
+ PasskeyLog.e(TAG, "Error during passkey creation", e)
476
519
  setupUI() // Show UI on error
477
520
  isHandling = false
478
521
  }
@@ -514,7 +557,7 @@ class CreatePasskeyActivity : AppCompatActivity() {
514
557
  val cipher = credentialRepository.getBiometricCipherForEncryption(this, requirement)
515
558
  biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
516
559
  } catch (e: Exception) {
517
- Log.e(TAG, "Failed to initialize biometric prompt with cipher", e)
560
+ PasskeyLog.e(TAG, "Failed to initialize biometric prompt with cipher", e)
518
561
  biometricPrompt.authenticate(promptInfo)
519
562
  }
520
563
  } else {