@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.
- package/ARCHITECTURE.md +12 -2
- package/CHANGELOG.md +13 -0
- package/README.md +6 -1
- package/android/src/main/java/co/algorand/passkeyautofill/CreatePasskeyActivity.kt +81 -52
- package/android/src/main/java/co/algorand/passkeyautofill/GetPasskeyActivity.kt +107 -81
- 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 +203 -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 +41 -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/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,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
|
|
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
|
-
|
|
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
|
-
|
|
73
|
+
PasskeyLog.e(TAG, "Error retrieving request from intent", e)
|
|
67
74
|
null
|
|
68
75
|
}
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
+
PasskeyLog.i(TAG, "systemUnlockedCipher from system: present=${systemUnlockedCipher != null}")
|
|
87
97
|
}
|
|
88
98
|
} catch (e: Exception) {
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
291
|
+
PasskeyLog.d(TAG, "handleCreation started, userVerification=$userVerification")
|
|
282
292
|
lifecycleScope.launch {
|
|
283
|
-
|
|
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
|
-
|
|
299
|
+
PasskeyLog.i(TAG, "Successfully obtained fallback cipher from repository (Single Tap timeout)")
|
|
288
300
|
fallback
|
|
289
301
|
} catch (e: Exception) {
|
|
290
|
-
|
|
302
|
+
PasskeyLog.d(TAG, "Fallback cipher failed: ${e.message}")
|
|
291
303
|
null
|
|
292
304
|
}
|
|
293
305
|
} else {
|
|
294
306
|
null
|
|
295
307
|
}
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
350
|
+
PasskeyLog.e(TAG, "Invalid passkey creation request JSON")
|
|
341
351
|
throw e
|
|
342
352
|
}
|
|
343
353
|
|
|
344
|
-
|
|
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
|
-
|
|
351
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
546
|
+
PasskeyLog.e(TAG, "Failed to initialize biometric prompt with cipher", e)
|
|
518
547
|
biometricPrompt.authenticate(promptInfo)
|
|
519
548
|
}
|
|
520
549
|
} else {
|