Add support for keys with on-device user verification
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user