@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.
- package/ARCHITECTURE.md +12 -2
- package/CHANGELOG.md +19 -0
- package/README.md +6 -1
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/co/algorand/passkeyautofill/CreatePasskeyActivity.kt +97 -54
- package/android/src/main/java/co/algorand/passkeyautofill/GetPasskeyActivity.kt +138 -84
- package/android/src/main/java/co/algorand/passkeyautofill/ReactNativePasskeyAutofillModule.kt +25 -11
- package/android/src/main/java/co/algorand/passkeyautofill/auth/BiometricRequirement.kt +3 -3
- package/android/src/main/java/co/algorand/passkeyautofill/auth/UserVerification.kt +57 -0
- package/android/src/main/java/co/algorand/passkeyautofill/credentials/Credential.kt +7 -0
- package/android/src/main/java/co/algorand/passkeyautofill/credentials/CredentialRepository.kt +236 -89
- package/android/src/main/java/co/algorand/passkeyautofill/credentials/KeystoreRecords.kt +72 -0
- package/android/src/main/java/co/algorand/passkeyautofill/credentials/RelyingParty.kt +80 -0
- package/android/src/main/java/co/algorand/passkeyautofill/service/PasskeyAutofillCredentialProviderService.kt +50 -14
- package/android/src/main/java/co/algorand/passkeyautofill/utils/PasskeyLog.kt +55 -0
- package/android/src/main/java/co/algorand/passkeyautofill/utils/PasskeyUtils.kt +8 -9
- package/android/src/main/java/co/algorand/passkeyautofill/utils/PrivilegedBrowserAllowlist.kt +238 -0
- package/android/src/main/res/raw/gpm_privileged_browser_allowlist.json +980 -0
- package/app.plugin.js +49 -7
- package/build/ReactNativePasskeyAutofillModule.d.ts +11 -0
- package/build/ReactNativePasskeyAutofillModule.d.ts.map +1 -1
- package/build/ReactNativePasskeyAutofillModule.js.map +1 -1
- package/ios/AutofillCredentialProvider/CredentialProviderViewController.swift +8 -1
- package/ios/AutofillCredentialProvider/PasskeyCredentialStore.swift +124 -28
- package/package.json +1 -1
- 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
|
|
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()`:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
76
|
+
PasskeyLog.e(TAG, "Error retrieving request from intent", e)
|
|
67
77
|
null
|
|
68
78
|
}
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
+
PasskeyLog.i(TAG, "systemUnlockedCipher from system: present=${systemUnlockedCipher != null}")
|
|
87
100
|
}
|
|
88
101
|
} catch (e: Exception) {
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
300
|
+
PasskeyLog.d(TAG, "handleCreation started, userVerification=$userVerification")
|
|
282
301
|
lifecycleScope.launch {
|
|
283
|
-
|
|
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
|
-
|
|
308
|
+
PasskeyLog.i(TAG, "Successfully obtained fallback cipher from repository (Single Tap timeout)")
|
|
288
309
|
fallback
|
|
289
310
|
} catch (e: Exception) {
|
|
290
|
-
|
|
311
|
+
PasskeyLog.d(TAG, "Fallback cipher failed: ${e.message}")
|
|
291
312
|
null
|
|
292
313
|
}
|
|
293
314
|
} else {
|
|
294
315
|
null
|
|
295
316
|
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
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
|
-
|
|
359
|
+
PasskeyLog.e(TAG, "Invalid passkey creation request JSON")
|
|
341
360
|
throw e
|
|
342
361
|
}
|
|
343
362
|
|
|
344
|
-
|
|
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
|
-
|
|
351
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
424
|
+
origin = responseOrigin,
|
|
393
425
|
up = true,
|
|
394
|
-
uv =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
560
|
+
PasskeyLog.e(TAG, "Failed to initialize biometric prompt with cipher", e)
|
|
518
561
|
biometricPrompt.authenticate(promptInfo)
|
|
519
562
|
}
|
|
520
563
|
} else {
|