Add PRF extension support
This commit is contained in:
@@ -24,7 +24,8 @@ data class AuthenticatorData(
|
||||
val rpIdHash: ByteArray,
|
||||
val flags: Int,
|
||||
val signCount: Long,
|
||||
val attestedCredentialData: AttestedCredentialData?
|
||||
val attestedCredentialData: AttestedCredentialData?,
|
||||
val extensions: CborMap?
|
||||
) {
|
||||
val userPresent: Boolean
|
||||
get() = (flags and CTAP.AUTH_DATA_FLAG_UP) != 0
|
||||
@@ -51,6 +52,7 @@ data class AuthenticatorData(
|
||||
|
||||
var offset = 37
|
||||
val hasAt = (flags and CTAP.AUTH_DATA_FLAG_AT) != 0
|
||||
val hasEd = (flags and CTAP.AUTH_DATA_FLAG_ED) != 0
|
||||
|
||||
val attestedCredentialData = if (hasAt) {
|
||||
if (data.size < offset + 18) return null
|
||||
@@ -66,14 +68,21 @@ data class AuthenticatorData(
|
||||
val credentialId = data.sliceArray(offset until offset + credIdLen)
|
||||
offset += credIdLen
|
||||
|
||||
val credentialPublicKey = CborDecoder.decode(
|
||||
data.sliceArray(offset until data.size)
|
||||
) as? Map<*, *> ?: return null
|
||||
val remaining = data.sliceArray(offset until data.size)
|
||||
val credentialPublicKey = CborDecoder.decode(remaining) as? Map<*, *> ?: return null
|
||||
// Advance offset past the COSE key
|
||||
val consumed = CborDecoder.measureFirstValue(remaining)
|
||||
offset += consumed
|
||||
|
||||
AttestedCredentialData(aaguid, credentialId, credentialPublicKey)
|
||||
} else null
|
||||
|
||||
return AuthenticatorData(rpIdHash, flags, signCount, attestedCredentialData)
|
||||
val extensions = if (hasEd && offset < data.size) {
|
||||
val remaining = data.sliceArray(offset until data.size)
|
||||
CborMap.decode(remaining)
|
||||
} else null
|
||||
|
||||
return AuthenticatorData(rpIdHash, flags, signCount, attestedCredentialData, extensions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +158,15 @@ class CborDecoder private constructor(private val data: ByteArray) {
|
||||
|
||||
companion object {
|
||||
fun decode(data: ByteArray): Any? = CborDecoder(data).readValue()
|
||||
|
||||
/**
|
||||
* Measure how many bytes the first CBOR value in the data occupies.
|
||||
*/
|
||||
fun measureFirstValue(data: ByteArray): Int {
|
||||
val decoder = CborDecoder(data)
|
||||
decoder.readValue()
|
||||
return decoder.pos
|
||||
}
|
||||
}
|
||||
|
||||
private fun readValue(): Any? {
|
||||
|
||||
@@ -813,6 +813,20 @@ class CredentialProviderActivity : AppCompatActivity() {
|
||||
authSelection?.optString("residentKey", "preferred")
|
||||
)
|
||||
|
||||
// Parse extensions
|
||||
val extensions = requestJson.optJSONObject("extensions")
|
||||
val credPropsRequested = extensions?.optBoolean("credProps", false) ?: false
|
||||
val prfRequested = extensions?.has("prf") == true
|
||||
|
||||
// Check if authenticator supports hmac-secret (CTAP2 backing for PRF)
|
||||
val authenticatorSupportsHmacSecret = deviceInfo?.extensions?.contains("hmac-secret") == true
|
||||
|
||||
// Build CTAP extensions map
|
||||
val ctapExtensions = mutableMapOf<String, Any>()
|
||||
if (prfRequested && authenticatorSupportsHmacSecret) {
|
||||
ctapExtensions["hmac-secret"] = true
|
||||
}
|
||||
|
||||
// Build clientDataJSON with proper origin
|
||||
val origin = computeOrigin()
|
||||
val clientData = providedClientDataHash.let { hash ->
|
||||
@@ -847,6 +861,7 @@ class CredentialProviderActivity : AppCompatActivity() {
|
||||
excludeList = if (excludeList.isNotEmpty()) excludeList else null,
|
||||
requireResidentKey = residentKey.requiresResidentKey(),
|
||||
requireUserVerification = false, // UV is provided by pinUvAuthParam
|
||||
extensions = if (ctapExtensions.isNotEmpty()) ctapExtensions else null,
|
||||
pinUvAuthParam = pinUvAuthParam,
|
||||
pinUvAuthProtocol = if (pinProtocol != null) 1 else null
|
||||
)
|
||||
@@ -876,15 +891,19 @@ class CredentialProviderActivity : AppCompatActivity() {
|
||||
val authData = AuthenticatorData.parse(makeCredResult.authData)
|
||||
val credentialId = authData?.attestedCredentialData?.credentialId ?: ByteArray(0)
|
||||
|
||||
// Check if credProps extension was requested
|
||||
val extensions = requestJson.optJSONObject("extensions")
|
||||
val credPropsRequested = extensions?.optBoolean("credProps", false) ?: false
|
||||
|
||||
// Determine if credential is actually discoverable
|
||||
// If we requested rk AND the authenticator supports it AND succeeded, it's discoverable
|
||||
val supportsResidentKey = deviceInfo?.options?.get("rk") ?: true
|
||||
val isDiscoverable = residentKey.requiresResidentKey() && supportsResidentKey
|
||||
|
||||
// Check if hmac-secret was confirmed in the authenticator's authData extensions
|
||||
val hmacSecretEnabled = if (prfRequested && authenticatorSupportsHmacSecret) {
|
||||
// If the authenticator supports hmac-secret and we requested it,
|
||||
// check if the authData extensions confirm it
|
||||
val extData = authData?.extensions
|
||||
extData?.bool("hmac-secret") ?: true // null means it was accepted without explicit confirmation
|
||||
} else false
|
||||
|
||||
// Build response JSON
|
||||
val responseJson = JSONObject().apply {
|
||||
put("id", Base64.encodeToString(credentialId, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP))
|
||||
@@ -928,6 +947,11 @@ class CredentialProviderActivity : AppCompatActivity() {
|
||||
put("rk", isDiscoverable)
|
||||
})
|
||||
}
|
||||
if (prfRequested) {
|
||||
put("prf", JSONObject().apply {
|
||||
put("enabled", hmacSecretEnabled)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -959,6 +983,77 @@ class CredentialProviderActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// Parse PRF extension
|
||||
val extensions = requestJson.optJSONObject("extensions")
|
||||
val prfExtension = extensions?.optJSONObject("prf")
|
||||
val prfEval = prfExtension?.optJSONObject("eval")
|
||||
val authenticatorSupportsHmacSecret = deviceInfo?.extensions?.contains("hmac-secret") == true
|
||||
val prfRequested = prfEval != null && authenticatorSupportsHmacSecret
|
||||
|
||||
// Prepare hmac-secret extension for getAssertion if PRF is requested
|
||||
var hmacSecretExtensions: CborRaw? = null
|
||||
var prfHasTwoSalts = false
|
||||
var effectiveProtocol = pinProtocol
|
||||
|
||||
if (prfRequested) {
|
||||
val protocol = pinProtocol ?: this.pinProtocol
|
||||
?: throw Exception("PIN protocol not available for PRF")
|
||||
|
||||
// Ensure key agreement is initialized (needed for hmac-secret even without PIN)
|
||||
if (!protocol.isInitialized) {
|
||||
val initialized = withContext(Dispatchers.IO) { protocol.initialize() }
|
||||
if (!initialized) {
|
||||
throw Exception("Failed to initialize key agreement for PRF")
|
||||
}
|
||||
}
|
||||
effectiveProtocol = protocol
|
||||
|
||||
// Extract and convert PRF salts to hmac-secret salts
|
||||
// PRF salt → hmac-secret salt: SHA-256("WebAuthn PRF" || 0x00 || salt)
|
||||
val sha256 = MessageDigest.getInstance("SHA-256")
|
||||
val prfPrefix = "WebAuthn PRF".toByteArray(Charsets.UTF_8)
|
||||
|
||||
val firstSaltRaw = Base64.decode(
|
||||
prfEval!!.getString("first"),
|
||||
Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP
|
||||
)
|
||||
sha256.reset()
|
||||
sha256.update(prfPrefix)
|
||||
sha256.update(0x00.toByte())
|
||||
val salt1 = sha256.digest(firstSaltRaw)
|
||||
|
||||
var salt2: ByteArray? = null
|
||||
if (prfEval.has("second")) {
|
||||
val secondSaltRaw = Base64.decode(
|
||||
prfEval.getString("second"),
|
||||
Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP
|
||||
)
|
||||
sha256.reset()
|
||||
sha256.update(prfPrefix)
|
||||
sha256.update(0x00.toByte())
|
||||
salt2 = sha256.digest(secondSaltRaw)
|
||||
prfHasTwoSalts = true
|
||||
}
|
||||
|
||||
// Build hmac-secret extension input
|
||||
val hmacInput = protocol.buildHmacSecretInput(salt1, salt2)
|
||||
?: throw Exception("Failed to build hmac-secret input")
|
||||
|
||||
// Build the CBOR extensions map: {"hmac-secret": {1: coseKey, 2: saltEnc, 3: saltAuth}}
|
||||
val coseKey = protocol.encodePlatformCoseKeyBytes()
|
||||
?: throw Exception("Failed to encode platform key")
|
||||
|
||||
hmacSecretExtensions = cbor {
|
||||
map {
|
||||
"hmac-secret" to map {
|
||||
1 to coseKey
|
||||
2 to bytes(hmacInput.saltEnc)
|
||||
3 to bytes(hmacInput.saltAuth)
|
||||
}
|
||||
}
|
||||
}.let { CborRaw(it.toList()) }
|
||||
}
|
||||
|
||||
// Build clientDataJSON with proper origin
|
||||
val origin = computeOrigin()
|
||||
val clientData = providedClientDataHash.let { hash ->
|
||||
@@ -977,8 +1072,8 @@ class CredentialProviderActivity : AppCompatActivity() {
|
||||
|
||||
// Compute pinUvAuthParam if needed
|
||||
var pinUvAuthParam: ByteArray? = null
|
||||
if (pinProtocol != null) {
|
||||
pinUvAuthParam = pinProtocol.computeAuthParam(clientData.hash)
|
||||
if (effectiveProtocol != null && effectiveProtocol.hasPinToken()) {
|
||||
pinUvAuthParam = effectiveProtocol.computeAuthParam(clientData.hash)
|
||||
}
|
||||
|
||||
// Build and send command
|
||||
@@ -987,8 +1082,9 @@ class CredentialProviderActivity : AppCompatActivity() {
|
||||
clientDataHash = clientData.hash,
|
||||
allowList = if (allowList.isNotEmpty()) allowList else null,
|
||||
requireUserVerification = false, // UV is provided by pinUvAuthParam
|
||||
extensions = hmacSecretExtensions,
|
||||
pinUvAuthParam = pinUvAuthParam,
|
||||
pinUvAuthProtocol = if (pinProtocol != null) 1 else null
|
||||
pinUvAuthProtocol = if (effectiveProtocol != null && effectiveProtocol.hasPinToken()) 1 else null
|
||||
)
|
||||
|
||||
runOnUiThread {
|
||||
@@ -1028,6 +1124,31 @@ class CredentialProviderActivity : AppCompatActivity() {
|
||||
val credentialId = selectedAssertion.credential?.id
|
||||
?: if (allowList.isNotEmpty()) allowList[0] else ByteArray(0)
|
||||
|
||||
// Parse PRF results from authenticator data extensions
|
||||
var prfResults: JSONObject? = null
|
||||
if (prfRequested && effectiveProtocol != null) {
|
||||
val authData = AuthenticatorData.parse(selectedAssertion.authData)
|
||||
val hmacSecretOutput = authData?.extensions?.bytes("hmac-secret")
|
||||
|
||||
if (hmacSecretOutput != null) {
|
||||
val decrypted = effectiveProtocol.decryptHmacSecretOutput(hmacSecretOutput)
|
||||
if (decrypted != null) {
|
||||
prfResults = JSONObject().apply {
|
||||
val first = decrypted.sliceArray(0 until 32)
|
||||
put("first", Base64.encodeToString(
|
||||
first, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP
|
||||
))
|
||||
if (prfHasTwoSalts && decrypted.size >= 64) {
|
||||
val second = decrypted.sliceArray(32 until 64)
|
||||
put("second", Base64.encodeToString(
|
||||
second, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build response JSON
|
||||
val responseJson = JSONObject().apply {
|
||||
put("id", Base64.encodeToString(credentialId, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP))
|
||||
@@ -1056,7 +1177,13 @@ class CredentialProviderActivity : AppCompatActivity() {
|
||||
))
|
||||
}
|
||||
})
|
||||
put("clientExtensionResults", JSONObject())
|
||||
put("clientExtensionResults", JSONObject().apply {
|
||||
if (prfResults != null) {
|
||||
put("prf", JSONObject().apply {
|
||||
put("results", prfResults)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
returnGetResult(responseJson.toString())
|
||||
|
||||
@@ -15,6 +15,7 @@ object FidoCommands {
|
||||
excludeList: List<ByteArray>? = null,
|
||||
requireResidentKey: Boolean = true,
|
||||
requireUserVerification: Boolean = true,
|
||||
extensions: Map<String, Any>? = null,
|
||||
pinUvAuthParam: ByteArray? = null,
|
||||
pinUvAuthProtocol: Int? = null
|
||||
): ByteArray {
|
||||
@@ -53,6 +54,14 @@ object FidoCommands {
|
||||
}
|
||||
}
|
||||
|
||||
if (extensions != null && extensions.isNotEmpty()) {
|
||||
6 to map {
|
||||
for ((key, value) in extensions) {
|
||||
key to value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
7 to map { "rk" to requireResidentKey }
|
||||
|
||||
if (pinUvAuthParam != null) {
|
||||
@@ -73,6 +82,7 @@ object FidoCommands {
|
||||
clientDataHash: ByteArray,
|
||||
allowList: List<ByteArray>? = null,
|
||||
requireUserVerification: Boolean = true,
|
||||
extensions: CborRaw? = null,
|
||||
pinUvAuthParam: ByteArray? = null,
|
||||
pinUvAuthProtocol: Int? = null
|
||||
): ByteArray {
|
||||
@@ -92,6 +102,10 @@ object FidoCommands {
|
||||
}
|
||||
}
|
||||
|
||||
if (extensions != null) {
|
||||
4 to extensions
|
||||
}
|
||||
|
||||
5 to map { "up" to true }
|
||||
|
||||
if (pinUvAuthParam != null) {
|
||||
|
||||
@@ -318,6 +318,9 @@ class PinProtocol(private val transport: FidoTransport) {
|
||||
|
||||
fun hasPinToken(): Boolean = pinToken != null
|
||||
|
||||
val isInitialized: Boolean
|
||||
get() = sharedSecret != null && platformPublicKey != null
|
||||
|
||||
fun computeAuthParam(message: ByteArray): ByteArray? {
|
||||
val token = pinToken ?: return null
|
||||
|
||||
@@ -331,6 +334,46 @@ class PinProtocol(private val transport: FidoTransport) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the hmac-secret extension input for getAssertion.
|
||||
*
|
||||
* @param salt1 First 32-byte salt (required)
|
||||
* @param salt2 Second 32-byte salt (optional)
|
||||
* @return Encrypted salts and authentication tag, or null if not initialized
|
||||
*/
|
||||
fun buildHmacSecretInput(salt1: ByteArray, salt2: ByteArray? = null): HmacSecretInput? {
|
||||
val secret = sharedSecret ?: return null
|
||||
|
||||
val salts = if (salt2 != null) salt1 + salt2 else salt1
|
||||
val saltEnc = aesEncrypt(secret, salts)
|
||||
|
||||
val mac = Mac.getInstance("HmacSHA256")
|
||||
mac.init(SecretKeySpec(secret, "HmacSHA256"))
|
||||
val saltAuth = mac.doFinal(saltEnc).copyOf(16)
|
||||
|
||||
return HmacSecretInput(saltEnc, saltAuth)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt the hmac-secret output from the authenticator.
|
||||
*
|
||||
* @param encrypted The encrypted output bytes from the authenticator's authData extensions
|
||||
* @return Decrypted output (32 bytes for one salt, 64 for two)
|
||||
*/
|
||||
fun decryptHmacSecretOutput(encrypted: ByteArray): ByteArray? {
|
||||
val secret = sharedSecret ?: return null
|
||||
return try {
|
||||
aesDecrypt(secret, encrypted)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
data class HmacSecretInput(
|
||||
val saltEnc: ByteArray,
|
||||
val saltAuth: ByteArray
|
||||
)
|
||||
|
||||
private fun buildGetKeyAgreementCommand(): ByteArray {
|
||||
return byteArrayOf(CTAP.CMD_CLIENT_PIN.toByte()) + cbor {
|
||||
map {
|
||||
@@ -340,6 +383,29 @@ class PinProtocol(private val transport: FidoTransport) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode an EC public key as a COSE_Key map using the CBOR builder.
|
||||
* Exposed for use by hmac-secret extension building.
|
||||
*/
|
||||
fun encodePlatformCoseKeyBytes(): CborRaw? {
|
||||
val pubKey = platformPublicKey ?: return null
|
||||
val point = pubKey.w
|
||||
val x = bigIntegerToBytes(point.affineX, 32)
|
||||
val y = bigIntegerToBytes(point.affineY, 32)
|
||||
|
||||
val bytes = cbor {
|
||||
map {
|
||||
1 to 2
|
||||
3 to -25
|
||||
-1 to 1
|
||||
-2 to bytes(x)
|
||||
-3 to bytes(y)
|
||||
}
|
||||
}
|
||||
// The cbor {} block wraps it in a map header, which is already what we need
|
||||
return CborRaw(bytes.toList())
|
||||
}
|
||||
|
||||
private fun buildGetPinTokenCommand(platformKey: ECPublicKey, encryptedPinHash: ByteArray): ByteArray {
|
||||
return byteArrayOf(CTAP.CMD_CLIENT_PIN.toByte()) + cbor {
|
||||
map {
|
||||
|
||||
Reference in New Issue
Block a user