@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 +6 -0
- package/CHANGELOG.md +11 -7
- package/android/src/main/java/co/algorand/passkeyautofill/CreatePasskeyActivity.kt +16 -0
- package/android/src/main/java/co/algorand/passkeyautofill/GetPasskeyActivity.kt +38 -0
- package/android/src/main/java/co/algorand/passkeyautofill/Prf.kt +184 -0
- package/android/src/main/java/co/algorand/passkeyautofill/credentials/CredentialRepository.kt +37 -26
- package/build/ReactNativePasskeyAutofill.types.d.ts +15 -0
- package/build/ReactNativePasskeyAutofill.types.d.ts.map +1 -1
- package/build/ReactNativePasskeyAutofill.types.js +3 -1
- package/build/ReactNativePasskeyAutofill.types.js.map +1 -1
- package/ios/AutofillCredentialProvider/CredentialProviderViewController.swift +132 -3
- package/ios/AutofillCredentialProvider/Prf.swift +89 -0
- package/package.json +2 -1
- package/src/ReactNativePasskeyAutofill.types.ts +19 -0
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.
|
|
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
|
-
|
|
5
|
+
- PRF extension support for deterministically derived secrets ([4b5b05a](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/4b5b05ab146e39b191855ccb01fd4aca5ed32819))
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
+
}
|
package/android/src/main/java/co/algorand/passkeyautofill/credentials/CredentialRepository.kt
CHANGED
|
@@ -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
|
|
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) ?:
|
|
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) ?:
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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 +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.
|
|
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
|
+
};
|