Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,11 @@ class PasskeyCreateRequest(
)

val keyValue = base64Service.encodeToString(keyPair.private.encoded)
// Generate a fresh 32-byte PRF secret (the "credRandom" equivalent).
// This is stored alongside the credential and used as the HMAC key for all
// future PRF evaluations — it is never derived from the signing key.
val prfSecretBytes = passkeyUtils.generatePrfSecret()
val prfSecret = base64Service.encodeToString(prfSecretBytes)
val discoverable = data.authenticatorSelection.requireResidentKey ||
data.authenticatorSelection.residentKey == "required" ||
data.authenticatorSelection.residentKey == "preferred"
Expand All @@ -188,6 +193,7 @@ class PasskeyCreateRequest(
keyAlgorithm = "ECDSA",
keyCurve = "P-256",
keyValue = keyValue,
prfSecret = prfSecret,
rpId = rpId,
rpName = rpName,
counter = 0,
Expand Down Expand Up @@ -225,7 +231,33 @@ class PasskeyCreateRequest(
put("authenticatorData", authData)
},
)
put("clientExtensionResults", buildJsonObject { })
put("clientExtensionResults", buildJsonObject {
if (data.extensions?.prf != null) {
put("prf", buildJsonObject {
put("enabled", true)
// CTAP 2.2+: if the RP provided eval inputs at creation time and
// user was verified, return the PRF results immediately so the RP
// doesn't need a separate authentication round-trip.
val eval = data.extensions.prf.eval
if (eval != null && userVerified) {
put("results", buildJsonObject {
val firstOutput = passkeyUtils.computePrf(
prfSecretBytes = prfSecretBytes,
prfInput = PasskeyBase64.decode(eval.first),
)
put("first", PasskeyBase64.encodeToString(firstOutput))
eval.second?.let { secondBase64 ->
val secondOutput = passkeyUtils.computePrf(
prfSecretBytes = prfSecretBytes,
prfInput = PasskeyBase64.decode(secondBase64),
)
put("second", PasskeyBase64.encodeToString(secondOutput))
}
})
}
})
}
})
}
val registrationResponseJson = json.encodeToString(registrationResponse)
return CreatePublicKeyCredentialResponse(registrationResponseJson) to local
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import com.artemchep.keyguard.common.model.DPrivilegedApp
import com.artemchep.keyguard.common.model.DSecret
import com.artemchep.keyguard.common.service.crypto.CryptoGenerator
import com.artemchep.keyguard.common.service.text.Base64Service
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObjectBuilder
import kotlinx.serialization.json.buildJsonObject
Expand Down Expand Up @@ -68,9 +69,9 @@ class PasskeyProviderGetRequest(
val credentialIdBytes = PasskeyCredentialId.encode(credential.credentialId)

val factory: KeyFactory = KeyFactory.getInstance("EC")
val privateKeyBytes = base64Service.decode(credential.keyValue)
val privateKey = run {
val privateKeyData = base64Service.decode(credential.keyValue)
val privateKeySpec = PKCS8EncodedKeySpec(privateKeyData)
val privateKeySpec = PKCS8EncodedKeySpec(privateKeyBytes)
factory.generatePrivate(privateKeySpec)
}

Expand Down Expand Up @@ -123,20 +124,92 @@ class PasskeyProviderGetRequest(
put("signature", PasskeyBase64.encodeToString(signature))
put("userHandle", credential.userHandle)
}
val prfEvalInput = resolvePrfEvalInput(opt.requestJson, credentialIdBytes)
// Decode the stored PRF secret (the "credRandom" equivalent) for this credential.
// Only compute PRF results when the user was verified — PRF output must be
// bound to a verified user identity to be meaningful for encryption use cases.
val prfSecretBytes = if (userVerified) {
credential.prfSecret?.let { base64Service.decode(it) }
} else {
null
}
val prfResults = if (prfEvalInput != null && prfSecretBytes != null) {
buildJsonObject {
val firstOutput = passkeyUtils.computePrf(
prfSecretBytes = prfSecretBytes,
prfInput = PasskeyBase64.decode(prfEvalInput.first),
)
put("first", PasskeyBase64.encodeToString(firstOutput))
prfEvalInput.second?.let { secondBase64 ->
val secondOutput = passkeyUtils.computePrf(
prfSecretBytes = prfSecretBytes,
prfInput = PasskeyBase64.decode(secondBase64),
)
put("second", PasskeyBase64.encodeToString(secondOutput))
}
}
} else {
null
}
val authenticationResponse = buildJsonObject {
put("id", PasskeyBase64.encodeToString(credentialIdBytes))
put("rawId", credentialIdBytes)
put("type", "public-key")
put("authenticatorAttachment", "cross-platform")
put("response", r)
put("clientExtensionResults", buildJsonObject { })
put("clientExtensionResults", buildJsonObject {
// Always include prf in clientExtensionResults when PRF was requested,
// even if we cannot produce results (spec: return prf: {} in that case).
if (prfEvalInput != null) {
put("prf", buildJsonObject {
if (prfResults != null) {
put("results", prfResults)
}
})
}
})
}
val authenticationResponseJson = json.encodeToString(authenticationResponse)
val passkeyCredential = PublicKeyCredential(authenticationResponseJson)
return GetCredentialResponse(passkeyCredential)
}

private fun resolvePrfEvalInput(
requestJson: String,
credentialIdBytes: ByteArray,
): PrfEvalInput? {
val options = runCatching {
json.decodeFromString<GetCredentialRequestOptions>(requestJson)
}.getOrNull()
val prf = options?.extensions?.prf ?: return null
val credentialIdBase64 = PasskeyBase64.encodeToString(credentialIdBytes)
return prf.evalByCredential[credentialIdBase64] ?: prf.eval
}

private fun JsonObjectBuilder.put(key: String, data: ByteArray) {
put(key, PasskeyBase64.encodeToString(data))
}

// https://www.w3.org/TR/webauthn-3/#prf-extension
@Serializable
private data class GetCredentialRequestOptions(
val extensions: GetCredentialExtensions? = null,
)

@Serializable
private data class GetCredentialExtensions(
val prf: GetCredentialPrfExtension? = null,
)

@Serializable
private data class GetCredentialPrfExtension(
val eval: PrfEvalInput? = null,
val evalByCredential: Map<String, PrfEvalInput> = emptyMap(),
)

@Serializable
private data class PrfEvalInput(
val first: String,
val second: String? = null,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ class PasskeyUtils(
*/
private const val PASSKEY_PROCESSING_MIN_TIME_MS = 800L

// https://www.w3.org/TR/webauthn-3/#prf-extension
// "WebAuthn PRF" followed by a null byte, as required by the spec.
internal val PRF_LABEL = "WebAuthn PRF\u0000".toByteArray(Charsets.UTF_8)

suspend fun <T> withProcessingMinTime(
block: suspend () -> T,
): T = coroutineScope {
Expand Down Expand Up @@ -406,6 +410,20 @@ class PasskeyUtils(

fun generateCredentialId() = cryptoService.uuid()

/** Generates a fresh 32-byte random secret to use as the PRF key for a new credential. */
fun generatePrfSecret(): ByteArray = cryptoService.seed(32)

/**
* Computes the WebAuthn PRF output for the given PRF secret and input.
*
* prfSalt = SHA-256("WebAuthn PRF\x00" || prfInput)
* prfOutput = HMAC-SHA-256(prfSecretBytes, prfSalt)
*/
fun computePrf(
prfSecretBytes: ByteArray,
prfInput: ByteArray,
): ByteArray = computeWebAuthnPrf(cryptoService, prfSecretBytes, prfInput)

// See:
// https://github.com/1Password/passkey-rs/blob/90c1c282649eceeb7cbe771bb8ce17b1b8463c60/passkey-client/src/lib.rs#L407
// https://github.com/kanidm/webauthn-rs/blame/25bc74ac0dc4280bf67ed3ff53fdf804dbb142c2/webauthn-rs-core/src/core.rs#L866
Expand Down Expand Up @@ -549,3 +567,27 @@ class PasskeyUtils(
return appInfo.getOrigin(privilegedAllowlist)
}
}

/**
* Standalone PRF computation, extracted for testability.
*
* Implements the WebAuthn PRF extension computation as a software authenticator.
* This mirrors what a hardware authenticator does with its internal credRandom key:
*
* prfSalt = SHA-256("WebAuthn PRF\x00" || prfInput)
* prfOutput = HMAC-SHA-256(prfSecretBytes, prfSalt)
*
* The [prfSecretBytes] parameter is the "credRandom" equivalent — a 32-byte random
* secret generated at credential creation time and stored alongside the credential.
*/
internal fun computeWebAuthnPrf(
cryptoService: CryptoGenerator,
prfSecretBytes: ByteArray,
prfInput: ByteArray,
): ByteArray {
val prfSalt = cryptoService.hashSha256(PasskeyUtils.PRF_LABEL + prfInput)
return cryptoService.hmacSha256(
key = prfSecretBytes,
data = prfSalt,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package com.artemchep.keyguard.android

import com.artemchep.keyguard.common.model.Argon2Mode
import com.artemchep.keyguard.common.model.CryptoHashAlgorithm
import com.artemchep.keyguard.common.service.crypto.CryptoGenerator
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertFalse
import org.junit.Test
import java.security.MessageDigest
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec

class PasskeyPrfTest {
private val cryptoGenerator = JvmCryptoGenerator()

@Test
fun `computeWebAuthnPrf returns 32 bytes`() {
val output = computeWebAuthnPrf(
cryptoGenerator,
prfSecretBytes = "prf-secret".toByteArray(),
prfInput = "prf-input".toByteArray(),
)
org.junit.Assert.assertEquals(32, output.size)
}

@Test
fun `computeWebAuthnPrf is deterministic for the same inputs`() {
val prfSecretBytes = "prf-secret".toByteArray()
val prfInput = "prf-input".toByteArray()
val output1 = computeWebAuthnPrf(cryptoGenerator, prfSecretBytes, prfInput)
val output2 = computeWebAuthnPrf(cryptoGenerator, prfSecretBytes, prfInput)
assertArrayEquals(output1, output2)
}

@Test
fun `computeWebAuthnPrf different inputs produce different outputs`() {
val prfSecretBytes = "prf-secret".toByteArray()
val output1 = computeWebAuthnPrf(cryptoGenerator, prfSecretBytes, "input-a".toByteArray())
val output2 = computeWebAuthnPrf(cryptoGenerator, prfSecretBytes, "input-b".toByteArray())
assertFalse(output1.contentEquals(output2))
}

@Test
fun `computeWebAuthnPrf different secrets produce different outputs`() {
val prfInput = "prf-input".toByteArray()
val output1 = computeWebAuthnPrf(cryptoGenerator, "secret-1".toByteArray(), prfInput)
val output2 = computeWebAuthnPrf(cryptoGenerator, "secret-2".toByteArray(), prfInput)
assertFalse(output1.contentEquals(output2))
}

/**
* Verifies the algorithm is:
* prfSalt = SHA-256("WebAuthn PRF\x00" || prfInput)
* prfOutput = HMAC-SHA-256(prfSecretBytes, prfSalt)
*
* The expected value is computed independently using raw JVM crypto so that
* the test does not simply re-execute the same code path it is testing.
*/
@Test
fun `computeWebAuthnPrf matches W3C spec algorithm`() {
val prfSecretBytes = "test-prf-secret".toByteArray(Charsets.UTF_8)
val prfInput = "test-prf-input".toByteArray(Charsets.UTF_8)

val prfSaltInput = PasskeyUtils.PRF_LABEL + prfInput
val prfSalt = MessageDigest.getInstance("SHA-256").digest(prfSaltInput)
val expected = hmacSha256(prfSecretBytes, prfSalt)

val actual = computeWebAuthnPrf(cryptoGenerator, prfSecretBytes, prfInput)
assertArrayEquals(expected, actual)
}

/**
* Verifies output does NOT equal the old (incorrect) two-step HMAC derivation that
* used the signing private key as key material, ensuring we produce a distinct value.
*/
@Test
fun `computeWebAuthnPrf does not match old private-key-derived computation`() {
val signingKeyBytes = "signing-private-key".toByteArray(Charsets.UTF_8)
val prfSecretBytes = "separate-prf-secret".toByteArray(Charsets.UTF_8)
val prfInput = "test-input".toByteArray(Charsets.UTF_8)

val prfSalt = MessageDigest.getInstance("SHA-256").digest(PasskeyUtils.PRF_LABEL + prfInput)
// Old algorithm: HMAC-SHA-256(HMAC-SHA-256(signingKey, "prf"), prfSalt)
val oldHmacKey = hmacSha256(signingKeyBytes, "prf".toByteArray(Charsets.UTF_8))
val oldOutput = hmacSha256(oldHmacKey, prfSalt)

val newOutput = computeWebAuthnPrf(cryptoGenerator, prfSecretBytes, prfInput)
assertFalse("New output must differ from old private-key-derived output", newOutput.contentEquals(oldOutput))
}

private fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray =
Mac.getInstance("HmacSHA256").run {
init(SecretKeySpec(key, "HmacSHA256"))
doFinal(data)
}
}

private class JvmCryptoGenerator : CryptoGenerator {
override fun hmac(key: ByteArray, data: ByteArray, algorithm: CryptoHashAlgorithm): ByteArray {
val name = when (algorithm) {
CryptoHashAlgorithm.SHA_1 -> "HmacSHA1"
CryptoHashAlgorithm.SHA_256 -> "HmacSHA256"
CryptoHashAlgorithm.SHA_512 -> "HmacSHA512"
CryptoHashAlgorithm.MD5 -> "HmacMD5"
}
return Mac.getInstance(name).run {
init(SecretKeySpec(key, name))
doFinal(data)
}
}

override fun hashSha256(data: ByteArray): ByteArray =
MessageDigest.getInstance("SHA-256").digest(data)

override fun hashSha1(data: ByteArray): ByteArray =
MessageDigest.getInstance("SHA-1").digest(data)

override fun hashMd5(data: ByteArray): ByteArray =
MessageDigest.getInstance("MD5").digest(data)

override fun hkdf(seed: ByteArray, salt: ByteArray?, info: ByteArray?, length: Int): ByteArray =
throw UnsupportedOperationException()

override fun pbkdf2(seed: ByteArray, salt: ByteArray, iterations: Int, length: Int): ByteArray =
throw UnsupportedOperationException()

override fun argon2(
mode: Argon2Mode,
seed: ByteArray,
salt: ByteArray,
iterations: Int,
memoryKb: Int,
parallelism: Int,
): ByteArray = throw UnsupportedOperationException()

override fun seed(length: Int): ByteArray = ByteArray(length)

override fun uuid(): String = java.util.UUID.randomUUID().toString()

override fun random(): Int = 0

override fun random(range: IntRange): Int = range.first
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ data class AddCredentialCipherRequestPasskeyData(
val keyAlgorithm: String, // ECDSA
val keyCurve: String, // P-256
val keyValue: String,
/** Base64-encoded 32-byte random secret used as HMAC key for PRF. */
val prfSecret: String,
val rpId: String,
val rpName: String?,
val counter: Int?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,8 @@ data class DSecret(
val keyAlgorithm: String, // ECDSA
val keyCurve: String, // P-256
val keyValue: String,
/** Base64-encoded 32-byte random secret used as HMAC key for PRF (the "credRandom" equivalent). */
val prfSecret: String? = null,
val rpId: String,
val rpName: String?,
val counter: Int?,
Expand Down
Loading
Loading