@algorandfoundation/react-native-passkey-autofill 1.0.0-canary.17 → 1.0.0-canary.18

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/.releaserc CHANGED
@@ -11,6 +11,12 @@
11
11
  "@semantic-release/commit-analyzer",
12
12
  "@semantic-release/release-notes-generator",
13
13
  "@semantic-release/changelog",
14
+ [
15
+ "@semantic-release/exec",
16
+ {
17
+ "prepareCmd": "pnpm fmt"
18
+ }
19
+ ],
14
20
  "@semantic-release/npm",
15
21
  [
16
22
  "@semantic-release/git",
package/CHANGELOG.md CHANGED
@@ -1,17 +1,21 @@
1
- # [1.0.0-canary.17](https://github.com/algorandfoundation/react-native-passkey-autofill/compare/v1.0.0-canary.16...v1.0.0-canary.17) (2026-05-28)
1
+ # [1.0.0-canary.18](https://github.com/algorandfoundation/react-native-passkey-autofill/compare/v1.0.0-canary.17...v1.0.0-canary.18) (2026-06-09)
2
2
 
3
+ ### Features
3
4
 
4
- ### Bug Fixes
5
+ - PRF extension support for deterministically derived secrets ([4b5b05a](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/4b5b05ab146e39b191855ccb01fd4aca5ed32819))
5
6
 
6
- * allow configurable AAGUID ([d5a8176](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/d5a81765ba150e9873e18875f0ac9f7bc4aede93))
7
- * **Android:** add NoOp methods for iOS-specific functionality ([e2c64db](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/e2c64db479cda70c7c61b7ab3021b70db949a15b))
8
- * compile issue ([16b939c](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/16b939cb555e75bd052ce57a41a838810a2770cd))
7
+ # [1.0.0-canary.17](https://github.com/algorandfoundation/react-native-passkey-autofill/compare/v1.0.0-canary.16...v1.0.0-canary.17) (2026-05-28)
8
+
9
+ ### Bug Fixes
9
10
 
11
+ - allow configurable AAGUID ([d5a8176](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/d5a81765ba150e9873e18875f0ac9f7bc4aede93))
12
+ - **Android:** add NoOp methods for iOS-specific functionality ([e2c64db](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/e2c64db479cda70c7c61b7ab3021b70db949a15b))
13
+ - compile issue ([16b939c](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/16b939cb555e75bd052ce57a41a838810a2770cd))
10
14
 
11
15
  ### Features
12
16
 
13
- * configurable credential requirement ([dc09916](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/dc09916d1ee5c8c9d33e53b57f00171472f4f665))
14
- * record lastUsedAt/count and fix error handling ([47b40ab](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/47b40abf692c0f7cffee6f2266c66f61f8006a65))
17
+ - configurable credential requirement ([dc09916](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/dc09916d1ee5c8c9d33e53b57f00171472f4f665))
18
+ - record lastUsedAt/count and fix error handling ([47b40ab](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/47b40abf692c0f7cffee6f2266c66f61f8006a65))
15
19
 
16
20
  # [1.0.0-canary.16](https://github.com/algorandfoundation/react-native-passkey-autofill/compare/v1.0.0-canary.15...v1.0.0-canary.16) (2026-05-15)
17
21
 
@@ -434,6 +434,22 @@ class CreatePasskeyActivity : AppCompatActivity() {
434
434
  }
435
435
  }
436
436
 
437
+ // WebAuthn `prf` extension (hmac-secret).
438
+ // On registration we advertise `enabled = true` whenever the RP
439
+ // asked for the extension, signalling that the credential can be
440
+ // used as a PRF source on subsequent assertions. We deliberately
441
+ // do not evaluate salts on create — this matches the behaviour of
442
+ // most platform authenticators.
443
+ val requestExtensions = requestJson.optJSONObject("publicKey")?.optJSONObject("extensions")
444
+ ?: requestJson.optJSONObject("extensions")
445
+ if (requestExtensions?.has("prf") == true || requestExtensions?.has("prfAlreadyHashed") == true) {
446
+ val clientExtensionResults = fullJson.optJSONObject("clientExtensionResults") ?: JSONObject()
447
+ val prfResult = JSONObject().put("enabled", true)
448
+ clientExtensionResults.put("prf", prfResult)
449
+ fullJson.put("clientExtensionResults", clientExtensionResults)
450
+ Log.d(TAG, "Advertised PRF support on registration")
451
+ }
452
+
437
453
  val createResponse = CreatePublicKeyCredentialResponse(fullJson.toString())
438
454
  Log.d(TAG, "CreatePublicKeyCredentialResponse: ${fullJson.toString()}")
439
455
 
@@ -572,6 +572,44 @@ class GetPasskeyActivity : AppCompatActivity() {
572
572
  val credProps = JSONObject()
573
573
  credProps.put("rk", true)
574
574
  clientExtensionResults.put("credProps", credProps)
575
+
576
+ // WebAuthn `prf` extension (hmac-secret). If the RP requested PRF
577
+ // evaluation for this assertion, derive the per-credential
578
+ // `credRandom` from the wallet HD root secret and compute
579
+ // HMAC-SHA256-based outputs for the supplied salt(s).
580
+ try {
581
+ val credentialIdB64Url = AndroidBase64.encodeToString(
582
+ credId,
583
+ AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP or AndroidBase64.NO_PADDING,
584
+ )
585
+ val prfInput = Prf.parseInput(passkeyRequestJsonObj, credentialIdB64Url)
586
+ if (prfInput != null) {
587
+ val hdRootSecret = credentialRepository.getHdRootSecret(this@GetPasskeyActivity)
588
+ if (hdRootSecret == null) {
589
+ Log.w(TAG, "PRF input present but HD root secret unavailable; skipping PRF output")
590
+ } else {
591
+ val credRandom = Prf.credRandom(
592
+ hdRootSecret = hdRootSecret,
593
+ relyingPartyIdentifier = displayOrigin,
594
+ userHandle = dbCred.userHandle,
595
+ )
596
+ val first = Prf.evaluate(credRandom, prfInput.first, prfInput.alreadyHashed)
597
+ val second = prfInput.second?.let { Prf.evaluate(credRandom, it, prfInput.alreadyHashed) }
598
+
599
+ val prfResults = JSONObject().put("first", Prf.encodeOutput(first))
600
+ if (second != null) {
601
+ prfResults.put("second", Prf.encodeOutput(second))
602
+ }
603
+ clientExtensionResults.put("prf", JSONObject().put("results", prfResults))
604
+ Log.d(TAG, "Computed PRF assertion output (second present=${second != null})")
605
+ }
606
+ }
607
+ } catch (e: Exception) {
608
+ // Never fail the assertion because of a PRF error; just log
609
+ // and omit the extension result.
610
+ Log.w(TAG, "Failed to compute PRF assertion output", e)
611
+ }
612
+
575
613
  fullJson.put("clientExtensionResults", clientExtensionResults)
576
614
 
577
615
  val credentialJson = fullJson.toString()
@@ -0,0 +1,184 @@
1
+ package co.algorand.passkeyautofill
2
+
3
+ import android.util.Base64 as AndroidBase64
4
+ import java.security.MessageDigest
5
+ import javax.crypto.Mac
6
+ import javax.crypto.spec.SecretKeySpec
7
+ import org.json.JSONObject
8
+
9
+ /**
10
+ * WebAuthn PRF (`prf`) extension helpers, backed by the authenticator
11
+ * `hmac-secret` mechanism (CTAP2.1).
12
+ *
13
+ * Output formula per the WebAuthn spec:
14
+ *
15
+ * output = HMAC-SHA256(credRandom, SHA256("WebAuthn PRF" || 0x00 || salt))
16
+ *
17
+ * `credRandom` is a per-credential 32-byte secret. We derive it
18
+ * deterministically (HKDF-SHA256) from the wallet HD root secret, the
19
+ * relying-party identifier, and the user handle — the same inputs that
20
+ * produce the deterministic signing key. This avoids a storage migration
21
+ * and ensures restoring the wallet seed reproduces the same PRF outputs
22
+ * on another device.
23
+ */
24
+ object Prf {
25
+ private const val CRED_RANDOM_INFO = "WebAuthn-PRF-credRandom"
26
+ private val SALT_PREFIX: ByteArray = "WebAuthn PRF".toByteArray(Charsets.UTF_8) + byteArrayOf(0x00)
27
+
28
+ /**
29
+ * Derive a 32-byte per-credential PRF secret from the HD root secret.
30
+ * Deterministic over `(hdRootSecret, rpId, userHandle)`.
31
+ */
32
+ fun credRandom(
33
+ hdRootSecret: ByteArray,
34
+ relyingPartyIdentifier: String,
35
+ userHandle: String,
36
+ ): ByteArray {
37
+ val salt = relyingPartyIdentifier.lowercase().toByteArray(Charsets.UTF_8) +
38
+ byteArrayOf(0x00) +
39
+ userHandle.lowercase().toByteArray(Charsets.UTF_8)
40
+ return hkdfSha256(
41
+ ikm = hdRootSecret,
42
+ salt = salt,
43
+ info = CRED_RANDOM_INFO.toByteArray(Charsets.UTF_8),
44
+ length = 32,
45
+ )
46
+ }
47
+
48
+ /**
49
+ * Evaluate the PRF for a single salt. Returns 32 bytes of output material.
50
+ *
51
+ * Per the WebAuthn spec the value the authenticator HMACs is
52
+ * `SHA-256("WebAuthn PRF" || 0x00 || rpSalt)`. The Chromium-on-Android
53
+ * client manager performs this hashing on behalf of the RP and passes the
54
+ * result through the `prfAlreadyHashed` extension; in that case the
55
+ * `salt` is already the inner hash and we must NOT hash again. Standard
56
+ * `prf` requests send the raw RP salt and require hashing.
57
+ */
58
+ fun evaluate(credRandom: ByteArray, salt: ByteArray, alreadyHashed: Boolean = true): ByteArray {
59
+ val macInput = if (alreadyHashed) {
60
+ salt
61
+ } else {
62
+ MessageDigest.getInstance("SHA-256").digest(SALT_PREFIX + salt)
63
+ }
64
+ val mac = Mac.getInstance("HmacSHA256").apply {
65
+ init(SecretKeySpec(credRandom, "HmacSHA256"))
66
+ }
67
+ return mac.doFinal(macInput)
68
+ }
69
+
70
+ /**
71
+ * Parsed PRF inputs from the relying party. Reduced to at most two salts
72
+ * (first + optional second) — already resolved against `evalByCredential`
73
+ * for the credential the user is asserting with.
74
+ *
75
+ * @property alreadyHashed `true` when the salts have already been passed
76
+ * through `SHA-256("WebAuthn PRF" || 0x00 || ...)` by the platform (the
77
+ * case for both the Chromium `prfAlreadyHashed` extension on Android
78
+ * and Apple's `ASAuthorizationPublicKeyCredentialPRFAssertionInput`).
79
+ */
80
+ data class Input(val first: ByteArray, val second: ByteArray?, val alreadyHashed: Boolean)
81
+
82
+ /**
83
+ * Parse PRF eval salts out of a WebAuthn request JSON.
84
+ *
85
+ * Recognises:
86
+ * - `extensions.prf.eval` / `extensions.prf.evalByCredential[<credId>]`
87
+ * — raw RP-supplied salts that still need to be hashed before HMAC.
88
+ * - `extensions.prfAlreadyHashed.eval` /
89
+ * `extensions.prfAlreadyHashed.evalByCredential[<credId>]` — Chromium
90
+ * on Android emits this when forwarding a `prf` request to a credential
91
+ * provider so the provider doesn't need to know the spec-mandated hash
92
+ * prefix. The salts here are already 32-byte SHA-256 outputs and must
93
+ * not be re-hashed.
94
+ *
95
+ * @param requestJson the inner `publicKey` request JSON object.
96
+ * @param credentialIdB64Url base64url-encoded credentialId we're asserting
97
+ * with (used to look up the per-credential salts).
98
+ */
99
+ fun parseInput(requestJson: JSONObject, credentialIdB64Url: String?): Input? {
100
+ val extensions = requestJson.optJSONObject("extensions") ?: return null
101
+
102
+ // Pre-hashed form wins — when Chromium sends both for compatibility,
103
+ // honour the already-hashed values to match what the browser expects.
104
+ extensions.optJSONObject("prfAlreadyHashed")?.let { node ->
105
+ parseExtensionNode(node, credentialIdB64Url, alreadyHashed = true)?.let { return it }
106
+ }
107
+ extensions.optJSONObject("prf")?.let { node ->
108
+ parseExtensionNode(node, credentialIdB64Url, alreadyHashed = false)?.let { return it }
109
+ }
110
+ return null
111
+ }
112
+
113
+ private fun parseExtensionNode(
114
+ node: JSONObject,
115
+ credentialIdB64Url: String?,
116
+ alreadyHashed: Boolean,
117
+ ): Input? {
118
+ // Per-credential salts win over the global `eval` block.
119
+ if (credentialIdB64Url != null) {
120
+ val byCred = node.optJSONObject("evalByCredential")
121
+ if (byCred != null) {
122
+ for (key in byCred.keys()) {
123
+ if (normalizeBase64Url(key) == normalizeBase64Url(credentialIdB64Url)) {
124
+ byCred.optJSONObject(key)?.let { return parseSalts(it, alreadyHashed) }
125
+ }
126
+ }
127
+ }
128
+ }
129
+ return node.optJSONObject("eval")?.let { parseSalts(it, alreadyHashed) }
130
+ }
131
+
132
+ private fun parseSalts(obj: JSONObject, alreadyHashed: Boolean): Input? {
133
+ val first = obj.optString("first", "").takeIf { it.isNotEmpty() } ?: return null
134
+ val firstBytes = decodeBase64UrlOrBase64(first)
135
+ val secondBytes = obj.optString("second", "")
136
+ .takeIf { it.isNotEmpty() }
137
+ ?.let { decodeBase64UrlOrBase64(it) }
138
+ return Input(firstBytes, secondBytes, alreadyHashed)
139
+ }
140
+
141
+ private fun normalizeBase64Url(value: String): String =
142
+ value.trim().trimEnd('=').replace('+', '-').replace('/', '_')
143
+
144
+ private fun decodeBase64UrlOrBase64(value: String): ByteArray {
145
+ val flags = AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP or AndroidBase64.NO_PADDING
146
+ return try {
147
+ AndroidBase64.decode(value, flags)
148
+ } catch (e: IllegalArgumentException) {
149
+ AndroidBase64.decode(value, AndroidBase64.NO_WRAP)
150
+ }
151
+ }
152
+
153
+ /** Encode a 32-byte PRF output for inclusion in a WebAuthn response JSON. */
154
+ fun encodeOutput(bytes: ByteArray): String =
155
+ AndroidBase64.encodeToString(bytes, AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP or AndroidBase64.NO_PADDING)
156
+
157
+ // RFC 5869 HKDF-SHA256.
158
+ private fun hkdfSha256(ikm: ByteArray, salt: ByteArray, info: ByteArray, length: Int): ByteArray {
159
+ val mac = Mac.getInstance("HmacSHA256")
160
+ val effectiveSalt = if (salt.isEmpty()) ByteArray(32) else salt
161
+ // Extract.
162
+ mac.init(SecretKeySpec(effectiveSalt, "HmacSHA256"))
163
+ val prk = mac.doFinal(ikm)
164
+ // Expand.
165
+ mac.init(SecretKeySpec(prk, "HmacSHA256"))
166
+ val output = ByteArray(length)
167
+ var t = ByteArray(0)
168
+ var offset = 0
169
+ var counter: Byte = 1
170
+ while (offset < length) {
171
+ mac.reset()
172
+ mac.init(SecretKeySpec(prk, "HmacSHA256"))
173
+ mac.update(t)
174
+ mac.update(info)
175
+ mac.update(counter)
176
+ t = mac.doFinal()
177
+ val take = minOf(t.size, length - offset)
178
+ System.arraycopy(t, 0, output, offset, take)
179
+ offset += take
180
+ counter++
181
+ }
182
+ return output
183
+ }
184
+ }
@@ -52,6 +52,13 @@ interface CredentialRepository {
52
52
  fun getBiometricCipherForEncryption(context: Context, requirement: BiometricRequirement): Cipher
53
53
  fun getBiometricCipherForDecryption(context: Context, iv: ByteArray, requirement: BiometricRequirement): Cipher
54
54
 
55
+ /**
56
+ * Returns the raw HD root secret (seed bytes) that is used to derive
57
+ * domain-specific signing keys. Required by the PRF extension to
58
+ * deterministically derive the per-credential `credRandom` secret.
59
+ */
60
+ fun getHdRootSecret(context: Context): ByteArray?
61
+
55
62
  companion object {
56
63
  const val TAG = "CredentialRepository"
57
64
  const val PASSKEYS_MMKV_ID = "keystore"
@@ -325,47 +332,51 @@ class Repository() : CredentialRepository {
325
332
  biometricCipher: Cipher?
326
333
  ): KeyPair {
327
334
  Log.d(CredentialRepository.TAG, "createDeterministicKeyPair for origin: $origin, userHandle: $userHandle")
328
- val masterKey = getMasterKey(context) ?: throw IllegalStateException("Master key not found in Keystore. Ensure you have called setMasterKey(key) from JavaScript.")
329
-
335
+ val derivedParentSecret = getHdRootSecret(context)
336
+ ?: throw IllegalStateException("HD Root Key not available. Ensure setMasterKey(key) and setHdRootKeyId(id) have been called.")
337
+ return dP256.genDomainSpecificKeypair(derivedParentSecret, origin, userHandle.lowercase())
338
+ }
339
+
340
+ override fun getHdRootSecret(context: Context): ByteArray? {
341
+ val masterKey = getMasterKey(context) ?: return null
330
342
  val mmkvAutofill = getAutofillMMKV(context)
331
- val hdRootKeyId = mmkvAutofill.decodeString(CredentialRepository.HD_ROOT_KEY_ID_KEY) ?: throw IllegalStateException("HD Root Key ID not found. Ensure you have called setHdRootKeyId(id) from JavaScript.")
332
-
343
+ val hdRootKeyId = mmkvAutofill.decodeString(CredentialRepository.HD_ROOT_KEY_ID_KEY) ?: return null
333
344
  val mmkvKeystore = getPasskeysMMKV(context)
334
- val hdRootKeyPayload = mmkvKeystore.decodeString(hdRootKeyId) ?: throw IllegalStateException("HD Root Key not found in keystore for ID: $hdRootKeyId")
335
-
336
- val hdRootKeyData = decodeKeyData(hdRootKeyPayload, masterKey)
337
-
345
+ val hdRootKeyPayload = mmkvKeystore.decodeString(hdRootKeyId) ?: return null
346
+ val hdRootKeyData = try {
347
+ decodeKeyData(hdRootKeyPayload, masterKey)
348
+ } catch (e: Exception) {
349
+ Log.w(CredentialRepository.TAG, "Failed to decode HD root key", e)
350
+ return null
351
+ }
352
+
338
353
  // In react-native-keystore, the private key/seed are stored as arrays of numbers in JSON
339
354
  // due to how JSON.stringify handles Uint8Array.
340
- // Our decode method in decryptHdRootKey might have already handled it if it matched react-native-keystore's decode.
341
-
342
355
  val seedArray = hdRootKeyData.optJSONArray("seed") ?: hdRootKeyData.optJSONArray("privateKey")
343
- val derivedParentSecret = if (seedArray != null) {
344
- Log.d(CredentialRepository.TAG, "Found seedArray in hdRootKeyData")
356
+ if (seedArray != null) {
345
357
  val bytes = ByteArray(seedArray.length())
346
358
  for (i in 0 until seedArray.length()) {
347
359
  bytes[i] = seedArray.getInt(i).toByte()
348
360
  }
349
- bytes
361
+ return bytes
362
+ }
363
+
364
+ val seed = (if (hdRootKeyData.has("seed")) hdRootKeyData.getString("seed") else null)
365
+ ?: (if (hdRootKeyData.has("privateKey")) hdRootKeyData.getString("privateKey") else null)
366
+ ?: return null
367
+ return if (seed.startsWith("0x")) {
368
+ hexToBytes(seed.substring(2))
350
369
  } else {
351
- val seed = (if (hdRootKeyData.has("seed")) hdRootKeyData.getString("seed") else null)
352
- ?: (if (hdRootKeyData.has("privateKey")) hdRootKeyData.getString("privateKey") else null)
353
- ?: throw IllegalStateException("HD Root Key does not contain a seed or privateKey")
354
-
355
- Log.d(CredentialRepository.TAG, "Found seed string in hdRootKeyData")
356
- if (seed.startsWith("0x")) {
357
- hexToBytes(seed.substring(2))
358
- } else {
359
- // It might be base64url encoded or just hex
370
+ try {
371
+ hexToBytes(seed)
372
+ } catch (e: Exception) {
360
373
  try {
361
- hexToBytes(seed)
362
- } catch (e: Exception) {
363
374
  AndroidBase64.decode(seed, AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP)
375
+ } catch (e2: Exception) {
376
+ null
364
377
  }
365
378
  }
366
379
  }
367
-
368
- return dP256.genDomainSpecificKeypair(derivedParentSecret, origin, userHandle.lowercase())
369
380
  }
370
381
 
371
382
  private fun encryptData(key: ByteArray, data: String): String {
@@ -24,4 +24,19 @@ export type PasskeyAutofillCredentialIdentity = {
24
24
  lastUsedAt?: number;
25
25
  parentKeyId?: string;
26
26
  };
27
+ /**
28
+ * Capabilities advertised by this credential provider. Exposed so the
29
+ * surrounding wallet UI / RP-side wallet can know which WebAuthn extensions
30
+ * are wired up natively before issuing a passkey request.
31
+ */
32
+ export type PasskeyAutofillCapabilities = {
33
+ /**
34
+ * Whether the WebAuthn `prf` extension (a.k.a. `hmac-secret`) is supported
35
+ * by this credential provider. PRF outputs are derived deterministically
36
+ * from the wallet HD root secret so that restoring the wallet seed
37
+ * reproduces the same secrets on another device.
38
+ */
39
+ prf: boolean;
40
+ };
41
+ export declare const PASSKEY_AUTOFILL_CAPABILITIES: PasskeyAutofillCapabilities;
27
42
  //# sourceMappingURL=ReactNativePasskeyAutofill.types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ReactNativePasskeyAutofill.types.d.ts","sourceRoot":"","sources":["../src/ReactNativePasskeyAutofill.types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,sCAAsC,GAAG;IACnD,cAAc,EAAE,CAAC,KAAK,EAAE;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,CAAC;IACtD,sBAAsB,EAAE,CAAC,KAAK,EAAE;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;CACtF,CAAC;AAEF,MAAM,MAAM,iCAAiC,GAAG;IAC9C,YAAY,EAAE,MAAM,CAAC;IACrB,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC"}
1
+ {"version":3,"file":"ReactNativePasskeyAutofill.types.d.ts","sourceRoot":"","sources":["../src/ReactNativePasskeyAutofill.types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,sCAAsC,GAAG;IACnD,cAAc,EAAE,CAAC,KAAK,EAAE;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,CAAC;IACtD,sBAAsB,EAAE,CAAC,KAAK,EAAE;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;CACtF,CAAC;AAEF,MAAM,MAAM,iCAAiC,GAAG;IAC9C,YAAY,EAAE,MAAM,CAAC;IACrB,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF;;;;GAIG;AACH,MAAM,MAAM,2BAA2B,GAAG;IACxC;;;;;OAKG;IACH,GAAG,EAAE,OAAO,CAAC;CACd,CAAC;AAEF,eAAO,MAAM,6BAA6B,EAAE,2BAE3C,CAAC"}
@@ -1,2 +1,4 @@
1
- export {};
1
+ export const PASSKEY_AUTOFILL_CAPABILITIES = {
2
+ prf: true,
3
+ };
2
4
  //# sourceMappingURL=ReactNativePasskeyAutofill.types.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"ReactNativePasskeyAutofill.types.js","sourceRoot":"","sources":["../src/ReactNativePasskeyAutofill.types.ts"],"names":[],"mappings":"","sourcesContent":["export type ReactNativePasskeyAutofillModuleEvents = {\n onPasskeyAdded: (event: { success: boolean }) => void;\n onPasskeyAuthenticated: (event: { success: boolean; credentialId?: string }) => void;\n};\n\nexport type PasskeyAutofillCredentialIdentity = {\n credentialId: string;\n relyingPartyIdentifier?: string;\n rpId?: string;\n origin?: string;\n userName?: string;\n name?: string;\n userHandle: string;\n userId?: string;\n privateKey?: string;\n privateKeyBase64?: string;\n publicKey?: string;\n publicKeyBase64?: string;\n createdAt?: number;\n lastUsedAt?: number;\n parentKeyId?: string;\n};\n"]}
1
+ {"version":3,"file":"ReactNativePasskeyAutofill.types.js","sourceRoot":"","sources":["../src/ReactNativePasskeyAutofill.types.ts"],"names":[],"mappings":"AAsCA,MAAM,CAAC,MAAM,6BAA6B,GAAgC;IACxE,GAAG,EAAE,IAAI;CACV,CAAC","sourcesContent":["export type ReactNativePasskeyAutofillModuleEvents = {\n onPasskeyAdded: (event: { success: boolean }) => void;\n onPasskeyAuthenticated: (event: { success: boolean; credentialId?: string }) => void;\n};\n\nexport type PasskeyAutofillCredentialIdentity = {\n credentialId: string;\n relyingPartyIdentifier?: string;\n rpId?: string;\n origin?: string;\n userName?: string;\n name?: string;\n userHandle: string;\n userId?: string;\n privateKey?: string;\n privateKeyBase64?: string;\n publicKey?: string;\n publicKeyBase64?: string;\n createdAt?: number;\n lastUsedAt?: number;\n parentKeyId?: string;\n};\n\n/**\n * Capabilities advertised by this credential provider. Exposed so the\n * surrounding wallet UI / RP-side wallet can know which WebAuthn extensions\n * are wired up natively before issuing a passkey request.\n */\nexport type PasskeyAutofillCapabilities = {\n /**\n * Whether the WebAuthn `prf` extension (a.k.a. `hmac-secret`) is supported\n * by this credential provider. PRF outputs are derived deterministically\n * from the wallet HD root secret so that restoring the wallet seed\n * reproduces the same secrets on another device.\n */\n prf: boolean;\n};\n\nexport const PASSKEY_AUTOFILL_CAPABILITIES: PasskeyAutofillCapabilities = {\n prf: true,\n};\n"]}
@@ -12,6 +12,8 @@ final class CredentialProviderViewController: ASCredentialProviderViewController
12
12
  private var pendingAssertionCredential: StoredPasskeyCredential?
13
13
  private var pendingAssertionClientDataHash: Data?
14
14
  private var pendingAssertionRelyingPartyIdentifier: String?
15
+ private var pendingAssertionPrfInput: PrfInput?
16
+ private var pendingRegistrationPrfInput: PrfInput?
15
17
  private var isCompletingRegistration = false
16
18
  private var isCompletingAssertion = false
17
19
  private var hasPresentedInterface = false
@@ -62,7 +64,8 @@ final class CredentialProviderViewController: ASCredentialProviderViewController
62
64
  prepareAssertion(
63
65
  credential: credential,
64
66
  clientDataHash: requestParameters.clientDataHash,
65
- relyingPartyIdentifier: relyingPartyIdentifier
67
+ relyingPartyIdentifier: relyingPartyIdentifier,
68
+ prfInput: Self.prfInput(fromAssertion: requestParameters)
66
69
  )
67
70
  }
68
71
 
@@ -100,7 +103,8 @@ final class CredentialProviderViewController: ASCredentialProviderViewController
100
103
  prepareAssertion(
101
104
  credential: credential,
102
105
  clientDataHash: request.clientDataHash,
103
- relyingPartyIdentifier: identity.relyingPartyIdentifier
106
+ relyingPartyIdentifier: identity.relyingPartyIdentifier,
107
+ prfInput: Self.prfInput(fromAssertion: request)
104
108
  )
105
109
  }
106
110
 
@@ -116,6 +120,7 @@ final class CredentialProviderViewController: ASCredentialProviderViewController
116
120
 
117
121
  pendingRegistrationRequest = request
118
122
  pendingRegistrationIdentity = identity
123
+ pendingRegistrationPrfInput = Self.prfInput(fromRegistration: request)
119
124
  showCheckingPasskeys()
120
125
 
121
126
  if hasPresentedInterface {
@@ -182,6 +187,13 @@ final class CredentialProviderViewController: ASCredentialProviderViewController
182
187
  attestationObject: WebAuthn.attestationObject(authenticatorData: authData)
183
188
  )
184
189
 
190
+ attachPrfRegistrationOutput(
191
+ to: registrationCredential,
192
+ derivedParentSecret: derivedParentSecret,
193
+ relyingPartyIdentifier: identity.relyingPartyIdentifier,
194
+ userHandle: userHandle
195
+ )
196
+
185
197
  try store.save(storedCredential)
186
198
  store.appendDiagnostic("stored passkey credential")
187
199
 
@@ -223,6 +235,11 @@ final class CredentialProviderViewController: ASCredentialProviderViewController
223
235
  authenticatorData: authenticatorData,
224
236
  credentialID: credential.credentialIdData
225
237
  )
238
+ attachPrfAssertionOutput(
239
+ to: assertionCredential,
240
+ credential: credential,
241
+ relyingPartyIdentifier: relyingPartyIdentifier
242
+ )
226
243
  extensionContext.completeAssertionRequest(using: assertionCredential) { [weak self] _ in
227
244
  self?.store?.recordCredentialUsage(id: credential.credentialId)
228
245
  self?.authContext = nil
@@ -230,6 +247,7 @@ final class CredentialProviderViewController: ASCredentialProviderViewController
230
247
  self?.pendingAssertionCredential = nil
231
248
  self?.pendingAssertionClientDataHash = nil
232
249
  self?.pendingAssertionRelyingPartyIdentifier = nil
250
+ self?.pendingAssertionPrfInput = nil
233
251
  }
234
252
  } catch {
235
253
  isCompletingAssertion = false
@@ -240,11 +258,13 @@ final class CredentialProviderViewController: ASCredentialProviderViewController
240
258
  private func prepareAssertion(
241
259
  credential: StoredPasskeyCredential,
242
260
  clientDataHash: Data,
243
- relyingPartyIdentifier: String
261
+ relyingPartyIdentifier: String,
262
+ prfInput: PrfInput?
244
263
  ) {
245
264
  pendingAssertionCredential = credential
246
265
  pendingAssertionClientDataHash = clientDataHash
247
266
  pendingAssertionRelyingPartyIdentifier = relyingPartyIdentifier
267
+ pendingAssertionPrfInput = prfInput
248
268
  showCheckingPasskeys()
249
269
 
250
270
  if hasPresentedInterface {
@@ -437,3 +457,112 @@ private extension ASPasskeyCredentialIdentity {
437
457
  String(data: userHandle, encoding: .utf8) ?? userHandle.base64URLEncodedString()
438
458
  }
439
459
  }
460
+
461
+ // MARK: - PRF extension helpers
462
+
463
+ extension CredentialProviderViewController {
464
+ /// Extract PRF inputs from a passkey assertion request. The platform passes
465
+ /// the RP-supplied salts down to credential providers via `extensionInput`
466
+ /// (iOS 17.4+ for the type itself, iOS 18+ for the `prf` property).
467
+ static func prfInput(fromAssertion request: ASPasskeyCredentialRequest) -> PrfInput? {
468
+ if #available(iOSApplicationExtension 18.0, *) {
469
+ guard let prf = request.extensionInput?.prf else { return nil }
470
+ return prfInput(fromAssertionInput: prf)
471
+ }
472
+ return nil
473
+ }
474
+
475
+ /// Variant for the `prepareCredentialList` flow which gives us the
476
+ /// `ASPasskeyCredentialRequestParameters` directly.
477
+ static func prfInput(fromAssertion parameters: ASPasskeyCredentialRequestParameters) -> PrfInput? {
478
+ if #available(iOSApplicationExtension 18.0, *) {
479
+ guard let prf = parameters.extensionInput?.prf else { return nil }
480
+ return prfInput(fromAssertionInput: prf)
481
+ }
482
+ return nil
483
+ }
484
+
485
+ /// Extract PRF inputs from a passkey registration request. Most relying
486
+ /// parties only set `prf.enabled` on create, but a few also pass eval salts
487
+ /// to immediately derive a secret.
488
+ static func prfInput(fromRegistration request: ASPasskeyCredentialRequest) -> PrfInput? {
489
+ if #available(iOSApplicationExtension 18.0, *) {
490
+ guard let prf = request.extensionInput?.prf else { return nil }
491
+ return prfInput(fromRegistrationInput: prf)
492
+ }
493
+ return nil
494
+ }
495
+
496
+ @available(iOSApplicationExtension 18.0, *)
497
+ private static func prfInput(
498
+ fromAssertionInput prf: ASAuthorizationPublicKeyCredentialPRFAssertionInput
499
+ ) -> PrfInput? {
500
+ // `inputValues` (when present) overrides `saltInput1/2` per WebAuthn's
501
+ // `evalByCredential` semantics. The platform exposes whichever is
502
+ // applicable to the credential the user actually picked.
503
+ if let values = prf.inputValues {
504
+ return PrfInput(first: values.saltInput1, second: values.saltInput2)
505
+ }
506
+ return nil
507
+ }
508
+
509
+ @available(iOSApplicationExtension 18.0, *)
510
+ private static func prfInput(
511
+ fromRegistrationInput prf: ASAuthorizationPublicKeyCredentialPRFRegistrationInput
512
+ ) -> PrfInput? {
513
+ if let values = prf.inputValues {
514
+ return PrfInput(first: values.saltInput1, second: values.saltInput2)
515
+ }
516
+ return nil
517
+ }
518
+
519
+ /// Compute and attach PRF outputs to an assertion credential. No-op on
520
+ /// pre-iOS 18 systems, and a no-op when the RP did not supply PRF inputs.
521
+ func attachPrfAssertionOutput(
522
+ to assertionCredential: ASPasskeyAssertionCredential,
523
+ credential: StoredPasskeyCredential,
524
+ relyingPartyIdentifier: String
525
+ ) {
526
+ guard #available(iOSApplicationExtension 18.0, *),
527
+ let input = pendingAssertionPrfInput else { return }
528
+
529
+ do {
530
+ guard let store else { return }
531
+ let derivedParentSecret = try store.hdRootKeySecret()
532
+ let userHandle = credential.userHandle
533
+ let credRandom = Prf.credRandom(
534
+ hdRootSecret: derivedParentSecret,
535
+ relyingPartyIdentifier: relyingPartyIdentifier,
536
+ userHandle: userHandle
537
+ )
538
+ let first = Prf.evaluate(credRandom: credRandom, salt: input.first)
539
+ let second = input.second.map { Prf.evaluate(credRandom: credRandom, salt: $0) }
540
+ let prfOutput = ASAuthorizationPublicKeyCredentialPRFAssertionOutput(
541
+ first: first,
542
+ second: second
543
+ )
544
+ assertionCredential.extensionOutput = ASPasskeyAssertionCredentialExtensionOutput(prf: prfOutput)
545
+ store.appendDiagnostic("attached PRF assertion output")
546
+ } catch {
547
+ store?.appendDiagnostic("failed to attach PRF assertion output: \(error.localizedDescription)")
548
+ }
549
+ }
550
+
551
+ /// Attach PRF output to a registration credential.
552
+ ///
553
+ /// Per the agreed scope, we always advertise `prf.enabled = true` on
554
+ /// registration to signal the credential supports PRF. If the RP also sent
555
+ /// eval salts we still defer the actual evaluation to a follow-up assertion
556
+ /// (matching the behaviour of most platform authenticators).
557
+ func attachPrfRegistrationOutput(
558
+ to registrationCredential: ASPasskeyRegistrationCredential,
559
+ derivedParentSecret: Data,
560
+ relyingPartyIdentifier: String,
561
+ userHandle: String
562
+ ) {
563
+ guard #available(iOSApplicationExtension 18.0, *) else { return }
564
+ let prfOutput = ASAuthorizationPublicKeyCredentialPRFRegistrationOutput.supported()
565
+ registrationCredential.extensionOutput = ASPasskeyRegistrationCredentialExtensionOutput(prf: prfOutput)
566
+ store?.appendDiagnostic("attached PRF registration output (enabled)")
567
+ }
568
+ }
@@ -0,0 +1,89 @@
1
+ import CryptoKit
2
+ import Foundation
3
+
4
+ /// WebAuthn PRF (`prf`) extension helpers, backed by the authenticator
5
+ /// `hmac-secret` mechanism (CTAP2.1).
6
+ ///
7
+ /// Per the WebAuthn spec, for each input salt the authenticator computes:
8
+ ///
9
+ /// output = HMAC-SHA256(credRandom, SHA256("WebAuthn PRF" || 0x00 || salt))
10
+ ///
11
+ /// where `credRandom` is a 32-byte per-credential secret. To avoid a storage
12
+ /// migration we derive `credRandom` deterministically from the wallet's HD
13
+ /// root secret and the credential's (rpId, userHandle) — the same inputs that
14
+ /// already produce the deterministic P-256 signing key. As a result, restoring
15
+ /// the wallet seed on another device produces the same PRF outputs.
16
+ enum Prf {
17
+ /// Domain separator used to derive `credRandom` from the HD root secret.
18
+ /// Kept under a distinct label from the P-256 key derivation to ensure
19
+ /// the credRandom value can never collide with private-key material.
20
+ static let credRandomInfo = "WebAuthn-PRF-credRandom"
21
+
22
+ /// WebAuthn-defined prefix for PRF salt hashing.
23
+ /// See: https://www.w3.org/TR/webauthn-3/#prf-extension
24
+ static let saltPrefix: [UInt8] = Array("WebAuthn PRF".utf8) + [0x00]
25
+
26
+ /// Derive a 32-byte per-credential PRF secret (`credRandom`) from the wallet
27
+ /// HD root secret. Deterministic over `(hdRootSecret, rpId, userHandle)`.
28
+ static func credRandom(
29
+ hdRootSecret: Data,
30
+ relyingPartyIdentifier: String,
31
+ userHandle: String
32
+ ) -> Data {
33
+ var salt = Data()
34
+ salt.append(contentsOf: relyingPartyIdentifier.lowercased().utf8)
35
+ salt.append(0x00)
36
+ salt.append(contentsOf: userHandle.lowercased().utf8)
37
+
38
+ let info = Data(credRandomInfo.utf8)
39
+ let key = SymmetricKey(data: hdRootSecret)
40
+ let derived = HKDF<SHA256>.deriveKey(
41
+ inputKeyMaterial: key,
42
+ salt: salt,
43
+ info: info,
44
+ outputByteCount: 32
45
+ )
46
+ return derived.withUnsafeBytes { Data($0) }
47
+ }
48
+
49
+ /// Evaluate the PRF for a single salt. Returns 32 bytes of output material.
50
+ ///
51
+ /// Per the WebAuthn spec the PRF input the authenticator sees is the
52
+ /// hash `SHA-256("WebAuthn PRF" || 0x00 || rpSalt)`. The platform layer
53
+ /// (Apple AuthenticationServices on iOS, the system credential manager on
54
+ /// Android with `prfAlreadyHashed`) performs this hashing before passing
55
+ /// the salt down to credential providers. Pass `alreadyHashed = true` in
56
+ /// that case so we don't hash twice.
57
+ static func evaluate(credRandom: Data, salt: Data, alreadyHashed: Bool = true) -> Data {
58
+ let macInput: Data
59
+ if alreadyHashed {
60
+ macInput = salt
61
+ } else {
62
+ var hashInput = Data(saltPrefix)
63
+ hashInput.append(salt)
64
+ macInput = Data(SHA256.hash(data: hashInput))
65
+ }
66
+ let mac = HMAC<SHA256>.authenticationCode(
67
+ for: macInput,
68
+ using: SymmetricKey(data: credRandom)
69
+ )
70
+ return Data(mac)
71
+ }
72
+ }
73
+
74
+ /// Parsed PRF extension input as supplied by the relying party.
75
+ ///
76
+ /// At the WebAuthn layer the RP can either supply a single global pair of
77
+ /// salts (`eval`) or a per-credential map (`evalByCredential`). We always
78
+ /// reduce that down to at most two salts for the credential we are about to
79
+ /// assert with, matching the platform behaviour on iOS 18 and Android.
80
+ struct PrfInput {
81
+ let first: Data
82
+ let second: Data?
83
+ }
84
+
85
+ /// Computed PRF outputs ready to attach to a WebAuthn response.
86
+ struct PrfOutput {
87
+ let first: Data
88
+ let second: Data?
89
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@algorandfoundation/react-native-passkey-autofill",
3
- "version": "1.0.0-canary.17",
3
+ "version": "1.0.0-canary.18",
4
4
  "description": "Passkey AutoFill React Native",
5
5
  "keywords": [
6
6
  "@algorandfoundation/react-native-passkey-autofill",
@@ -54,6 +54,7 @@
54
54
  "@expo/config-plugins": "~55.0.8",
55
55
  "@expo/npm-proofread": "^1.0.1",
56
56
  "@semantic-release/changelog": "^6.0.3",
57
+ "@semantic-release/exec": "^7.1.0",
57
58
  "@semantic-release/git": "^10.0.1",
58
59
  "@semantic-release/github": "^12.0.6",
59
60
  "@semantic-release/npm": "^13.1.5",
@@ -20,3 +20,22 @@ export type PasskeyAutofillCredentialIdentity = {
20
20
  lastUsedAt?: number;
21
21
  parentKeyId?: string;
22
22
  };
23
+
24
+ /**
25
+ * Capabilities advertised by this credential provider. Exposed so the
26
+ * surrounding wallet UI / RP-side wallet can know which WebAuthn extensions
27
+ * are wired up natively before issuing a passkey request.
28
+ */
29
+ export type PasskeyAutofillCapabilities = {
30
+ /**
31
+ * Whether the WebAuthn `prf` extension (a.k.a. `hmac-secret`) is supported
32
+ * by this credential provider. PRF outputs are derived deterministically
33
+ * from the wallet HD root secret so that restoring the wallet seed
34
+ * reproduces the same secrets on another device.
35
+ */
36
+ prf: boolean;
37
+ };
38
+
39
+ export const PASSKEY_AUTOFILL_CAPABILITIES: PasskeyAutofillCapabilities = {
40
+ prf: true,
41
+ };