From 3efdb58a0724f0da2f87ea7cfd94655cbeb58b36 Mon Sep 17 00:00:00 2001 From: mimi89999 Date: Fri, 13 Feb 2026 22:22:02 +0100 Subject: [PATCH] Add support for keys with on-device user verification --- .../java/pl/lebihan/authnkey/AuthnkeyError.kt | 3 + app/src/main/java/pl/lebihan/authnkey/CTAP.kt | 15 ++ .../lebihan/authnkey/CredentialBottomSheet.kt | 36 +++- .../authnkey/CredentialProviderActivity.kt | 186 +++++++++++++++++- .../java/pl/lebihan/authnkey/ErrorMapping.kt | 3 + .../java/pl/lebihan/authnkey/PinProtocol.kt | 72 +++++++ .../res/layout/bottom_sheet_credential.xml | 11 ++ app/src/main/res/values/strings.xml | 7 + 8 files changed, 324 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/pl/lebihan/authnkey/AuthnkeyError.kt b/app/src/main/java/pl/lebihan/authnkey/AuthnkeyError.kt index 108811e..a489953 100644 --- a/app/src/main/java/pl/lebihan/authnkey/AuthnkeyError.kt +++ b/app/src/main/java/pl/lebihan/authnkey/AuthnkeyError.kt @@ -14,4 +14,7 @@ sealed class AuthnkeyError(message: String) : Exception(message) { // Authentication errors class UserVerificationRequiredNoPin : AuthnkeyError("User verification required but no PIN set") class PinBlocked : AuthnkeyError("PIN is blocked") + + // On-device UV errors + class UvBlocked : AuthnkeyError("Biometric verification is blocked") } diff --git a/app/src/main/java/pl/lebihan/authnkey/CTAP.kt b/app/src/main/java/pl/lebihan/authnkey/CTAP.kt index 737c307..37f2686 100644 --- a/app/src/main/java/pl/lebihan/authnkey/CTAP.kt +++ b/app/src/main/java/pl/lebihan/authnkey/CTAP.kt @@ -103,6 +103,9 @@ data class DeviceInfo( val clientPinSet: Boolean get() = options["clientPin"] == true + + val supportsBuiltInUv: Boolean + get() = options["uv"] == true } object CTAP { @@ -124,6 +127,9 @@ object CTAP { const val PIN_CMD_SET_PIN = 0x03 const val PIN_CMD_CHANGE_PIN = 0x04 const val PIN_CMD_GET_PIN_TOKEN = 0x05 + const val PIN_CMD_GET_PIN_UV_TOKEN_USING_UV = 0x06 + const val PIN_CMD_GET_UV_RETRIES = 0x07 + const val PIN_CMD_GET_PIN_UV_TOKEN_USING_PIN = 0x09 // AuthData flags const val AUTH_DATA_FLAG_UP = 0x01 // User present @@ -288,4 +294,13 @@ object CTAP { } } } + + fun buildGetUvRetriesCommand(): ByteArray { + return byteArrayOf(CMD_CLIENT_PIN.toByte()) + cbor { + map { + 1 to 1 + 2 to PIN_CMD_GET_UV_RETRIES + } + } + } } diff --git a/app/src/main/java/pl/lebihan/authnkey/CredentialBottomSheet.kt b/app/src/main/java/pl/lebihan/authnkey/CredentialBottomSheet.kt index 0e4e74a..1913c80 100644 --- a/app/src/main/java/pl/lebihan/authnkey/CredentialBottomSheet.kt +++ b/app/src/main/java/pl/lebihan/authnkey/CredentialBottomSheet.kt @@ -28,6 +28,7 @@ class CredentialBottomSheet : BottomSheetDialogFragment() { TOUCH, PROCESSING, PIN, + BIOMETRIC, ACCOUNT_SELECT, SUCCESS, TAG_LOST, @@ -44,6 +45,7 @@ class CredentialBottomSheet : BottomSheetDialogFragment() { private lateinit var progressBar: ProgressBar private lateinit var btnCancel: MaterialButton private lateinit var btnContinue: MaterialButton + private lateinit var btnBiometric: MaterialButton private lateinit var pinInputField: PinInputField private lateinit var iconStatus: ImageView private lateinit var iconBackground: View @@ -59,6 +61,7 @@ class CredentialBottomSheet : BottomSheetDialogFragment() { var onCancelClick: (() -> Unit)? = null var onPinEntered: ((String) -> Unit)? = null var onAccountSelected: ((Int) -> Unit)? = null + var onBiometricSelected: (() -> Unit)? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -85,6 +88,7 @@ class CredentialBottomSheet : BottomSheetDialogFragment() { progressBar = view.findViewById(R.id.progressBar) btnCancel = view.findViewById(R.id.btnCancel) btnContinue = view.findViewById(R.id.btnContinue) + btnBiometric = view.findViewById(R.id.btnBiometric) pinInputField = view.findViewById(R.id.pinInputField) iconStatus = view.findViewById(R.id.iconStatus) iconBackground = view.findViewById(R.id.iconBackground) @@ -115,6 +119,10 @@ class CredentialBottomSheet : BottomSheetDialogFragment() { submitPin() } + btnBiometric.setOnClickListener { + onBiometricSelected?.invoke() + } + pinInputField.setOnDoneAction { submitPin() } @@ -166,6 +174,7 @@ class CredentialBottomSheet : BottomSheetDialogFragment() { State.TOUCH -> R.drawable.fingerprint_24 State.PROCESSING -> R.drawable.key_24 State.PIN -> R.drawable.lock_24 + State.BIOMETRIC -> R.drawable.fingerprint_24 State.ACCOUNT_SELECT -> R.drawable.account_circle_24 State.SUCCESS -> R.drawable.check_circle_24 State.TAG_LOST -> R.drawable.sensors_24 @@ -176,7 +185,7 @@ class CredentialBottomSheet : BottomSheetDialogFragment() { iconBackground.backgroundTintList = null when (state) { - State.WAITING, State.TOUCH -> startPulse() + State.WAITING, State.TOUCH, State.BIOMETRIC -> startPulse() State.TAG_LOST -> { iconBackground.backgroundTintList = ColorStateList.valueOf( ContextCompat.getColor(requireContext(), R.color.warning_container) @@ -236,6 +245,8 @@ class CredentialBottomSheet : BottomSheetDialogFragment() { pinInputField.clear() setState(State.PIN) pinInputField.focus() + } else { + hideBiometricOption() } } else { pendingShowPinInput = show @@ -243,12 +254,35 @@ class CredentialBottomSheet : BottomSheetDialogFragment() { } } + fun showBiometricOption(show: Boolean) { + if (::btnBiometric.isInitialized) { + btnBiometric.visibility = if (show) View.VISIBLE else View.GONE + } + } + + fun hideBiometricOption() { + if (::btnBiometric.isInitialized) { + btnBiometric.visibility = View.GONE + } + } + + fun showBiometricWaiting() { + if (::pinInputField.isInitialized) { + pinInputField.visibility = View.GONE + btnContinue.visibility = View.GONE + hideBiometricOption() + hideAccounts() + setState(State.BIOMETRIC) + } + } + fun showAccounts(accounts: List) { if (!::accountList.isInitialized) return setState(State.ACCOUNT_SELECT) pinInputField.visibility = View.GONE btnContinue.visibility = View.GONE + hideBiometricOption() accountList.visibility = View.VISIBLE accountList.adapter = AccountAdapter(accounts) { index -> onAccountSelected?.invoke(index) diff --git a/app/src/main/java/pl/lebihan/authnkey/CredentialProviderActivity.kt b/app/src/main/java/pl/lebihan/authnkey/CredentialProviderActivity.kt index 1406db2..3a1c34c 100644 --- a/app/src/main/java/pl/lebihan/authnkey/CredentialProviderActivity.kt +++ b/app/src/main/java/pl/lebihan/authnkey/CredentialProviderActivity.kt @@ -56,6 +56,8 @@ class CredentialProviderActivity : AppCompatActivity() { private var pendingPin: String? = null // PIN entered before key connection private var userVerification: UserVerification = UserVerification.PREFERRED + private var deviceSupportsUv: Boolean = false + private var usbPermissionRequested = false private enum class UserVerification { @@ -191,6 +193,7 @@ class CredentialProviderActivity : AppCompatActivity() { bottomSheet = CredentialBottomSheet.newInstance(status, instruction).apply { onCancelClick = { cancelOperation() } onPinEntered = { pin -> handlePinEntered(pin) } + onBiometricSelected = { handleBiometricSelected() } } bottomSheet?.show(supportFragmentManager, CredentialBottomSheet.TAG) bottomSheet?.setState(CredentialBottomSheet.State.WAITING) @@ -212,6 +215,20 @@ class CredentialProviderActivity : AppCompatActivity() { } } + private fun handleBiometricSelected() { + if (currentTransport?.isConnected == true) { + val json = JSONObject(requestJson!!) + bottomSheet?.showBiometricWaiting() + setInstruction(getString(R.string.instruction_waiting_biometric)) + showProgress(true) + authenticateWithUvAndExecute(json) + } else { + // Key not connected yet — show waiting state + setInstruction(getString(R.string.instruction_connect_key)) + setState(CredentialBottomSheet.State.WAITING) + } + } + private fun setStatus(text: String) { bottomSheet?.setStatus(text) } @@ -265,6 +282,17 @@ class CredentialProviderActivity : AppCompatActivity() { bottomSheet?.showPinInput(true) } + private fun showPinDialogWithBiometric(retries: Int, requestJson: JSONObject) { + runOnUiThread { + showProgress(false) + bottomSheet?.hideAccounts() + setInstruction(getString(R.string.pin_retries_remaining, retries)) + setState(CredentialBottomSheet.State.PIN) + bottomSheet?.showPinInput(true) + bottomSheet?.showBiometricOption(true) + } + } + override fun onResume() { super.onResume() @@ -459,6 +487,7 @@ class CredentialProviderActivity : AppCompatActivity() { // Check if clientPin is actually set on the device val deviceHasPin = deviceInfo?.clientPinSet == true val alwaysUv = deviceInfo?.options?.get("alwaysUv") == true + deviceSupportsUv = deviceInfo?.supportsBuiltInUv == true when { // We already have PIN from pre-prompt @@ -466,23 +495,41 @@ class CredentialProviderActivity : AppCompatActivity() { setInstruction(getString(R.string.instruction_authenticating)) authenticateAndExecute(pendingPin!!, json) } - // UV required but device has no PIN - fail - userVerification == UserVerification.REQUIRED && !deviceHasPin -> { + // UV required but device has no PIN and no built-in UV - fail + userVerification == UserVerification.REQUIRED && !deviceHasPin && !deviceSupportsUv -> { throw AuthnkeyError.UserVerificationRequiredNoPin() } + // UV required/preferred, device supports built-in UV but no PIN set + // -> go directly to biometric + userVerification != UserVerification.DISCOURAGED && deviceSupportsUv && !deviceHasPin -> { + authenticateWithUvAndExecute(json) + } + // UV required/preferred, device has both PIN and built-in UV + // -> show PIN input with biometric option + userVerification != UserVerification.DISCOURAGED && deviceHasPin && deviceSupportsUv -> { + val retries = withContext(Dispatchers.IO) { protocol.getPinRetries() }.getOrDefault(8) + showPinDialogWithBiometric(retries, json) + } // alwaysUv but device has no PIN - fail - alwaysUv && !deviceHasPin -> { + alwaysUv && !deviceHasPin && !deviceSupportsUv -> { throw AuthnkeyError.UserVerificationRequiredNoPin() } - // UV required/preferred and device has PIN - need to get PIN + // UV required/preferred and device has PIN only - need to get PIN userVerification != UserVerification.DISCOURAGED && deviceHasPin -> { val retries = withContext(Dispatchers.IO) { protocol.getPinRetries() }.getOrDefault(8) showPinDialog(retries, json) } - // UV discouraged but device has alwaysUv - need PIN anyway + // UV discouraged but device has alwaysUv - need PIN or UV anyway userVerification == UserVerification.DISCOURAGED && alwaysUv -> { - val retries = withContext(Dispatchers.IO) { protocol.getPinRetries() }.getOrDefault(8) - showPinDialog(retries, json) + if (deviceSupportsUv) { + // Use built-in UV silently since UV is discouraged but alwaysUv forces it + authenticateWithUvAndExecute(json) + } else if (deviceHasPin) { + val retries = withContext(Dispatchers.IO) { protocol.getPinRetries() }.getOrDefault(8) + showPinDialog(retries, json) + } else { + throw AuthnkeyError.UserVerificationRequiredNoPin() + } } // UV discouraged or preferred with no PIN - try without else -> { @@ -508,7 +555,11 @@ class CredentialProviderActivity : AppCompatActivity() { Log.d(TAG, "Authenticator requires PIN despite UV=discouraged") val protocol = pinProtocol ?: throw AuthnkeyError.PinProtocolNotInitialized() val retries = withContext(Dispatchers.IO) { protocol.getPinRetries() }.getOrDefault(8) - showPinDialog(retries, json) + if (deviceSupportsUv) { + showPinDialogWithBiometric(retries, json) + } else { + showPinDialog(retries, json) + } } else { throw e } @@ -562,6 +613,10 @@ class CredentialProviderActivity : AppCompatActivity() { setInstruction(getString(R.string.pin_incorrect_retries, retries)) setState(CredentialBottomSheet.State.PIN) bottomSheet?.showPinInput(true) + // Re-show biometric option if device supports it + if (deviceSupportsUv) { + bottomSheet?.showBiometricOption(true) + } } } else { throw AuthnkeyError.PinBlocked() @@ -581,6 +636,121 @@ class CredentialProviderActivity : AppCompatActivity() { } } + private fun authenticateWithUvAndExecute(requestJson: JSONObject) { + scope.launch { + try { + val protocol = pinProtocol ?: throw AuthnkeyError.PinProtocolNotInitialized() + + runOnUiThread { + bottomSheet?.showBiometricWaiting() + setInstruction(getString(R.string.instruction_waiting_biometric)) + showProgress(true) + } + + val initialized = withContext(Dispatchers.IO) { protocol.initialize() } + if (!initialized) { + throw AuthnkeyError.PinProtocolInitFailed() + } + + // Determine permissions and rpId + val permissions: Int + val rpId: String? + + if (isCreateRequest) { + permissions = PinProtocol.PERMISSION_MC + rpId = requestJson.getJSONObject("rp").getString("id") + } else { + permissions = PinProtocol.PERMISSION_GA + rpId = requestJson.getString("rpId") + } + + withContext(Dispatchers.IO) { + protocol.requestUvToken(permissions, rpId) + }.onFailure { e -> + if (e is CTAP.Exception) { + when (e.error) { + CTAP.Error.UV_INVALID -> { + // Biometric didn't match — let user retry or switch to PIN + Log.d(TAG, "UV_INVALID: biometric verification failed") + val uvRetries = withContext(Dispatchers.IO) { + protocol.getUvRetries() + }.getOrDefault(0) + + if (uvRetries > 0) { + runOnUiThread { + showProgress(false) + setInstruction(getString(R.string.error_uv_invalid_retries, uvRetries)) + setState(CredentialBottomSheet.State.PIN) + bottomSheet?.showPinInput(true) + bottomSheet?.showBiometricOption(true) + } + } else { + // UV exhausted, fall back to PIN + fallbackToPinAfterUvFailure(requestJson) + } + return@launch + } + CTAP.Error.UV_BLOCKED -> { + Log.d(TAG, "UV_BLOCKED: falling back to PIN") + fallbackToPinAfterUvFailure(requestJson) + return@launch + } + CTAP.Error.INVALID_SUBCOMMAND, + CTAP.Error.INVALID_COMMAND, + CTAP.Error.INVALID_PARAMETER -> { + // Authenticator doesn't actually support UV subcommand, + // fall back to PIN silently + Log.d(TAG, "UV subcommand not supported, falling back to PIN") + deviceSupportsUv = false + fallbackToPinAfterUvFailure(requestJson) + return@launch + } + CTAP.Error.OPERATION_DENIED -> { + // User denied on device (e.g. tapped cancel on the key) + runOnUiThread { + showProgress(false) + setInstruction(getString(R.string.error_operation_denied)) + setState(CredentialBottomSheet.State.PIN) + bottomSheet?.showPinInput(true) + if (deviceSupportsUv) { + bottomSheet?.showBiometricOption(true) + } + } + return@launch + } + else -> throw e + } + } + throw e + } + + // UV succeeded — proceed with the request + executeRequest(requestJson, protocol) + + } catch (e: Exception) { + Log.e(TAG, "UV authentication error", e) + handleError(e) + } + } + } + + private suspend fun fallbackToPinAfterUvFailure(requestJson: JSONObject) { + val deviceHasPin = deviceInfo?.clientPinSet == true + if (deviceHasPin) { + val protocol = pinProtocol ?: throw AuthnkeyError.PinProtocolNotInitialized() + val retries = withContext(Dispatchers.IO) { protocol.getPinRetries() }.getOrDefault(8) + runOnUiThread { + showProgress(false) + setInstruction(getString(R.string.error_uv_blocked)) + setState(CredentialBottomSheet.State.PIN) + bottomSheet?.showPinInput(true) + // Don't show biometric option since UV is blocked/unsupported + } + } else { + throw AuthnkeyError.UvBlocked() + } + } + private suspend fun executeRequest(requestJson: JSONObject, pinProtocol: PinProtocol?) { try { val transport = currentTransport ?: throw AuthnkeyError.NotConnected() diff --git a/app/src/main/java/pl/lebihan/authnkey/ErrorMapping.kt b/app/src/main/java/pl/lebihan/authnkey/ErrorMapping.kt index 2249a52..48d78c9 100644 --- a/app/src/main/java/pl/lebihan/authnkey/ErrorMapping.kt +++ b/app/src/main/java/pl/lebihan/authnkey/ErrorMapping.kt @@ -19,6 +19,7 @@ fun Throwable.toUserMessage(context: Context): String = when (this) { is AuthnkeyError.PinProtocolInitFailed -> context.getString(R.string.error_communication_failed) is AuthnkeyError.UserVerificationRequiredNoPin -> context.getString(R.string.error_uv_required_no_pin) is AuthnkeyError.PinBlocked -> context.getString(R.string.error_pin_blocked) + is AuthnkeyError.UvBlocked -> context.getString(R.string.error_uv_blocked) // Fallback else -> this.message ?: context.getString(R.string.error_unknown) @@ -38,5 +39,7 @@ private fun CTAP.Error.toUserMessage(context: Context): String = when (this) { CTAP.Error.KEY_STORE_FULL -> context.getString(R.string.error_key_store_full) CTAP.Error.UNSUPPORTED_ALGORITHM -> context.getString(R.string.error_unsupported_algorithm) CTAP.Error.KEEPALIVE_CANCEL -> context.getString(R.string.error_operation_cancelled) + CTAP.Error.UV_BLOCKED -> context.getString(R.string.error_uv_blocked) + CTAP.Error.UV_INVALID -> context.getString(R.string.error_uv_failed) else -> context.getString(R.string.error_ctap_unknown, this.name) } diff --git a/app/src/main/java/pl/lebihan/authnkey/PinProtocol.kt b/app/src/main/java/pl/lebihan/authnkey/PinProtocol.kt index dd8fec2..475e1b4 100644 --- a/app/src/main/java/pl/lebihan/authnkey/PinProtocol.kt +++ b/app/src/main/java/pl/lebihan/authnkey/PinProtocol.kt @@ -139,6 +139,36 @@ class PinProtocol(private val transport: FidoTransport) { } } + // CTAP2.1 subCommand 0x06: getPinUvAuthTokenUsingUvWithPermissions + suspend fun requestUvToken(permissions: Int, rpId: String? = null): Result { + val secret = sharedSecret + ?: return Result.failure(Exception("Shared secret not available")) + val pubKey = platformPublicKey + ?: return Result.failure(Exception("Platform key not available")) + + return try { + val command = buildGetUvTokenCommand(pubKey, permissions, rpId) + val response = transport.sendCtapCommand(command) + + if (response.isEmpty()) { + return Result.failure(Exception("Empty response")) + } + + val error = CTAP.getResponseError(response) + if (error != null) { + return Result.failure(CTAP.Exception(error)) + } + + val encryptedToken = parsePinTokenResponse(response) + ?: return Result.failure(Exception("Failed to parse UV token")) + pinToken = aesDecrypt(secret, encryptedToken) + + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + } + suspend fun getPinRetries(): Result { return try { val response = transport.sendCtapCommand(CTAP.buildGetPinRetriesCommand()) @@ -160,6 +190,29 @@ class PinProtocol(private val transport: FidoTransport) { } } + // CTAP2.1 subCommand 0x07: getUvRetries + suspend fun getUvRetries(): Result { + return try { + val response = transport.sendCtapCommand(CTAP.buildGetUvRetriesCommand()) + if (!CTAP.isSuccess(response)) { + return Result.failure(CTAP.Exception( + CTAP.getResponseError(response) ?: CTAP.Error.OTHER + )) + } + + val data = response.drop(1).toByteArray() + val parsed = CborMap.decode(data) + ?: return Result.failure(Exception("Failed to parse response")) + // UV retries are in key 5 per CTAP2.1 spec + val retries = parsed.int(5) + ?: return Result.failure(Exception("Missing UV retries field")) + + Result.success(retries) + } catch (e: Exception) { + Result.failure(e) + } + } + sealed class PinSetError(message: String) : Exception(message) { class PinAlreadySet : PinSetError("A PIN is already set on this authenticator") class PinPolicyViolation : PinSetError("PIN does not meet authenticator requirements") @@ -318,6 +371,25 @@ class PinProtocol(private val transport: FidoTransport) { } } + // subCommand 0x06 — no PIN hash, authenticator performs UV internally + private fun buildGetUvTokenCommand( + platformKey: ECPublicKey, + permissions: Int, + rpId: String? = null + ): ByteArray { + return byteArrayOf(CTAP.CMD_CLIENT_PIN.toByte()) + cbor { + map { + 1 to 1 // pinUvAuthProtocol + 2 to CTAP.PIN_CMD_GET_PIN_UV_TOKEN_USING_UV // subCommand 0x06 + 3 to encodeCoseKey(platformKey) // keyAgreement + 9 to permissions // permissions + if (rpId != null) { + 0x0A to rpId // rpId + } + } + } + } + private fun buildSetPinCommand( platformKey: ECPublicKey, encryptedNewPin: ByteArray, diff --git a/app/src/main/res/layout/bottom_sheet_credential.xml b/app/src/main/res/layout/bottom_sheet_credential.xml index 5415d18..cc6f590 100644 --- a/app/src/main/res/layout/bottom_sheet_credential.xml +++ b/app/src/main/res/layout/bottom_sheet_credential.xml @@ -86,6 +86,17 @@ android:visibility="gone" style="@style/Widget.Material3.Button" /> + + Verifying… Verifying PIN… Lost contact with the security key\n\nReposition and hold until completion + Touch the fingerprint sensor on your security key… Security Key PIN @@ -44,6 +45,12 @@ Enter PIN (%1$d retries remaining) Incorrect PIN. %1$d retries remaining + + Use fingerprint + Biometric verification blocked. Use PIN instead. + Biometric verification failed + Fingerprint not recognized. %1$d retries remaining + %1$s\n\nTry again or cancel %1$s\n\nTry again