Add support for keys with on-device user verification

This commit is contained in:
mimi89999
2026-02-13 22:22:02 +01:00
parent e6c7dc6870
commit 3efdb58a07
8 changed files with 324 additions and 9 deletions
@@ -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")
}
@@ -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
}
}
}
}
@@ -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<AccountInfo>) {
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)
@@ -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()
@@ -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)
}
@@ -139,6 +139,36 @@ class PinProtocol(private val transport: FidoTransport) {
}
}
// CTAP2.1 subCommand 0x06: getPinUvAuthTokenUsingUvWithPermissions
suspend fun requestUvToken(permissions: Int, rpId: String? = null): Result<Unit> {
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<Int> {
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<Int> {
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,
@@ -86,6 +86,17 @@
android:visibility="gone"
style="@style/Widget.Material3.Button" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnBiometric"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="@string/btn_use_biometric"
app:icon="@drawable/fingerprint_24"
app:iconGravity="textStart"
android:visibility="gone"
style="@style/Widget.Material3.Button.OutlinedButton" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
+7
View File
@@ -31,6 +31,7 @@
<string name="instruction_verifying">Verifying…</string>
<string name="instruction_verifying_pin">Verifying PIN…</string>
<string name="instruction_tag_lost">Lost contact with the security key\n\nReposition and hold until completion</string>
<string name="instruction_waiting_biometric">Touch the fingerprint sensor on your security key…</string>
<!-- PIN Dialog -->
<string name="pin_dialog_title">Security Key PIN</string>
@@ -44,6 +45,12 @@
<string name="pin_retries_remaining">Enter PIN (%1$d retries remaining)</string>
<string name="pin_incorrect_retries">Incorrect PIN. %1$d retries remaining</string>
<!-- Biometric / On-device UV -->
<string name="btn_use_biometric">Use fingerprint</string>
<string name="error_uv_blocked">Biometric verification blocked. Use PIN instead.</string>
<string name="error_uv_failed">Biometric verification failed</string>
<string name="error_uv_invalid_retries">Fingerprint not recognized. %1$d retries remaining</string>
<!-- Errors -->
<string name="error_format">%1$s\n\nTry again or cancel</string>
<string name="error_retry_format">%1$s\n\nTry again</string>