diff --git a/app/src/main/java/pl/lebihan/authnkey/CTAP.kt b/app/src/main/java/pl/lebihan/authnkey/CTAP.kt index 37f2686..fe145ec 100644 --- a/app/src/main/java/pl/lebihan/authnkey/CTAP.kt +++ b/app/src/main/java/pl/lebihan/authnkey/CTAP.kt @@ -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) } } } diff --git a/app/src/main/java/pl/lebihan/authnkey/Cbor.kt b/app/src/main/java/pl/lebihan/authnkey/Cbor.kt index 9df4ea6..66e0df5 100644 --- a/app/src/main/java/pl/lebihan/authnkey/Cbor.kt +++ b/app/src/main/java/pl/lebihan/authnkey/Cbor.kt @@ -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? { diff --git a/app/src/main/java/pl/lebihan/authnkey/CredentialProviderActivity.kt b/app/src/main/java/pl/lebihan/authnkey/CredentialProviderActivity.kt index 3a1c34c..409b6ae 100644 --- a/app/src/main/java/pl/lebihan/authnkey/CredentialProviderActivity.kt +++ b/app/src/main/java/pl/lebihan/authnkey/CredentialProviderActivity.kt @@ -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() + 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()) diff --git a/app/src/main/java/pl/lebihan/authnkey/FidoCommands.kt b/app/src/main/java/pl/lebihan/authnkey/FidoCommands.kt index 4c3da24..efb33e5 100644 --- a/app/src/main/java/pl/lebihan/authnkey/FidoCommands.kt +++ b/app/src/main/java/pl/lebihan/authnkey/FidoCommands.kt @@ -15,6 +15,7 @@ object FidoCommands { excludeList: List? = null, requireResidentKey: Boolean = true, requireUserVerification: Boolean = true, + extensions: Map? = 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? = 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) { diff --git a/app/src/main/java/pl/lebihan/authnkey/PinProtocol.kt b/app/src/main/java/pl/lebihan/authnkey/PinProtocol.kt index 475e1b4..df37437 100644 --- a/app/src/main/java/pl/lebihan/authnkey/PinProtocol.kt +++ b/app/src/main/java/pl/lebihan/authnkey/PinProtocol.kt @@ -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 {