Map errors to user-friendly messages

Closes #14
This commit is contained in:
mimi89999
2025-12-22 21:24:34 +01:00
parent 723e8d56e1
commit 24137ea2d8
5 changed files with 133 additions and 60 deletions

View File

@@ -0,0 +1,17 @@
package pl.lebihan.authnkey
sealed class AuthnkeyError(message: String) : Exception(message) {
// Connection errors
class NotIsoDepTag : AuthnkeyError("Not an ISO-DEP tag")
class FidoAppletNotFound : AuthnkeyError("FIDO applet not found")
class ConnectionFailed : AuthnkeyError("Connection failed")
class NotConnected : AuthnkeyError("Not connected")
// PIN protocol errors
class PinProtocolNotInitialized : AuthnkeyError("PIN protocol not initialized")
class PinProtocolInitFailed : AuthnkeyError("PIN protocol initialization failed")
// Authentication errors
class UserVerificationRequiredNoPin : AuthnkeyError("User verification required but no PIN set")
class PinBlocked : AuthnkeyError("PIN is blocked")
}

View File

@@ -10,6 +10,7 @@ import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager
import android.nfc.NfcAdapter
import android.nfc.Tag
import android.nfc.TagLostException
import android.nfc.tech.IsoDep
import android.os.Build
import android.os.Bundle
@@ -369,11 +370,11 @@ class CredentialProviderActivity : AppCompatActivity() {
bottomSheet?.getCurrentPinIfValid()?.let { pendingPin = it }
bottomSheet?.showPinInput(false)
val isoDep = IsoDep.get(tag) ?: throw Exception("Not an ISO-DEP tag")
val isoDep = IsoDep.get(tag) ?: throw AuthnkeyError.NotIsoDepTag()
val transport = NfcTransport(isoDep)
if (!transport.selectFidoApplet()) {
throw Exception("Failed to select FIDO applet")
throw AuthnkeyError.FidoAppletNotFound()
}
currentTransport = transport
@@ -387,7 +388,7 @@ class CredentialProviderActivity : AppCompatActivity() {
} catch (e: Exception) {
Log.e(TAG, "NFC error", e)
setInstruction(getString(R.string.error_retry_format, e.message ?: "Unknown error"))
setInstruction(getString(R.string.error_retry_format, e.toUserMessage(this@CredentialProviderActivity)))
setState(CredentialBottomSheet.State.ERROR)
showProgress(false)
}
@@ -408,7 +409,7 @@ class CredentialProviderActivity : AppCompatActivity() {
val transport = withContext(Dispatchers.IO) {
UsbTransport.create(usbManager, device)
} ?: throw Exception("Failed to initialize USB connection")
} ?: throw AuthnkeyError.ConnectionFailed()
currentTransport = transport
pinProtocol = PinProtocol(transport)
@@ -420,7 +421,7 @@ class CredentialProviderActivity : AppCompatActivity() {
} catch (e: Exception) {
Log.e(TAG, "USB error", e)
setInstruction(getString(R.string.error_retry_format, e.message ?: "Unknown error"))
setInstruction(getString(R.string.error_retry_format, e.toUserMessage(this@CredentialProviderActivity)))
setState(CredentialBottomSheet.State.ERROR)
showProgress(false)
}
@@ -431,8 +432,8 @@ class CredentialProviderActivity : AppCompatActivity() {
scope.launch {
try {
val json = JSONObject(requestJson!!)
val transport = currentTransport ?: throw Exception("No transport")
val protocol = pinProtocol ?: throw Exception("No PIN protocol")
val transport = currentTransport ?: throw AuthnkeyError.NotConnected()
val protocol = pinProtocol ?: throw AuthnkeyError.PinProtocolNotInitialized()
// Get device info to check PIN requirements and CTAP version
val infoResponse = withContext(Dispatchers.IO) {
@@ -452,7 +453,7 @@ class CredentialProviderActivity : AppCompatActivity() {
}
// UV required but device has no PIN - fail
userVerification == UserVerification.REQUIRED && !deviceHasPin -> {
throw Exception(getString(R.string.error_uv_required_no_pin))
throw AuthnkeyError.UserVerificationRequiredNoPin()
}
// UV required/preferred and device has PIN - need to get PIN
userVerification != UserVerification.DISCOURAGED && deviceHasPin -> {
@@ -486,7 +487,7 @@ class CredentialProviderActivity : AppCompatActivity() {
if (e.error == CTAP.Error.PIN_REQUIRED ||
e.error == CTAP.Error.PIN_AUTH_INVALID) {
Log.d(TAG, "Authenticator requires PIN despite UV=discouraged")
val protocol = pinProtocol ?: throw Exception("No PIN protocol")
val protocol = pinProtocol ?: throw AuthnkeyError.PinProtocolNotInitialized()
val retries = withContext(Dispatchers.IO) { protocol.getPinRetries() }.getOrDefault(8)
showPinDialog(retries, json)
} else {
@@ -509,12 +510,12 @@ class CredentialProviderActivity : AppCompatActivity() {
private fun authenticateAndExecute(pin: String, requestJson: JSONObject) {
scope.launch {
try {
val protocol = pinProtocol ?: throw Exception("PIN protocol not initialized")
val protocol = pinProtocol ?: throw AuthnkeyError.PinProtocolNotInitialized()
setInstruction(getString(R.string.instruction_initializing))
val initialized = withContext(Dispatchers.IO) { protocol.initialize() }
if (!initialized) {
throw Exception("Failed to initialize PIN protocol")
throw AuthnkeyError.PinProtocolInitFailed()
}
// Determine permissions and rpId based on operation type
@@ -539,12 +540,12 @@ class CredentialProviderActivity : AppCompatActivity() {
if (retries > 0) {
runOnUiThread {
showProgress(false)
setInstruction(getString(R.string.pin_invalid_retries, retries))
setInstruction(getString(R.string.pin_incorrect_retries, retries))
setState(CredentialBottomSheet.State.PIN)
bottomSheet?.showPinInput(true)
}
} else {
throw Exception(getString(R.string.error_pin_blocked))
throw AuthnkeyError.PinBlocked()
}
} else {
throw e
@@ -563,7 +564,7 @@ class CredentialProviderActivity : AppCompatActivity() {
private suspend fun executeRequest(requestJson: JSONObject, pinProtocol: PinProtocol?) {
try {
val transport = currentTransport ?: throw Exception("No transport")
val transport = currentTransport ?: throw AuthnkeyError.NotConnected()
if (isCreateRequest) {
executeCreateCredential(transport, requestJson, pinProtocol)
@@ -1074,7 +1075,7 @@ class CredentialProviderActivity : AppCompatActivity() {
private fun handleError(e: Exception) {
runOnUiThread {
showProgress(false)
setInstruction(getString(R.string.error_format, e.message ?: "Unknown error"))
setInstruction(getString(R.string.error_format, e.toUserMessage(this)))
setState(CredentialBottomSheet.State.ERROR)
}
}

View File

@@ -0,0 +1,42 @@
package pl.lebihan.authnkey
import android.content.Context
import android.nfc.TagLostException
fun Throwable.toUserMessage(context: Context): String = when (this) {
// Android NFC exception
is TagLostException -> context.getString(R.string.error_tag_lost)
// CTAP protocol errors
is CTAP.Exception -> this.error.toUserMessage(context)
// App-specific errors
is AuthnkeyError.NotIsoDepTag -> context.getString(R.string.error_not_supported_tag)
is AuthnkeyError.FidoAppletNotFound -> context.getString(R.string.error_not_security_key)
is AuthnkeyError.ConnectionFailed -> context.getString(R.string.error_connection_failed)
is AuthnkeyError.NotConnected -> context.getString(R.string.error_not_connected)
is AuthnkeyError.PinProtocolNotInitialized -> context.getString(R.string.error_key_disconnected)
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)
// Fallback
else -> this.message ?: context.getString(R.string.error_unknown)
}
private fun CTAP.Error.toUserMessage(context: Context): String = when (this) {
CTAP.Error.NO_CREDENTIALS -> context.getString(R.string.error_no_credentials)
CTAP.Error.PIN_INVALID -> context.getString(R.string.error_pin_incorrect)
CTAP.Error.PIN_BLOCKED -> context.getString(R.string.error_pin_blocked)
CTAP.Error.PIN_NOT_SET -> context.getString(R.string.error_pin_not_set)
CTAP.Error.PIN_AUTH_INVALID -> context.getString(R.string.error_pin_auth_invalid)
CTAP.Error.PIN_AUTH_BLOCKED -> context.getString(R.string.error_pin_auth_blocked)
CTAP.Error.CREDENTIAL_EXCLUDED -> context.getString(R.string.error_credential_excluded)
CTAP.Error.OPERATION_DENIED -> context.getString(R.string.error_operation_denied)
CTAP.Error.USER_ACTION_TIMEOUT -> context.getString(R.string.error_user_action_timeout)
CTAP.Error.TIMEOUT -> context.getString(R.string.error_timeout)
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)
else -> context.getString(R.string.error_ctap_unknown, this.name)
}

View File

@@ -11,6 +11,7 @@ import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager
import android.nfc.NfcAdapter
import android.nfc.Tag
import android.nfc.TagLostException
import android.nfc.tech.IsoDep
import android.os.Build
import android.os.Bundle
@@ -185,11 +186,11 @@ class MainActivity : AppCompatActivity() {
currentTransport?.close()
currentTransport = null
val isoDep = IsoDep.get(tag) ?: throw Exception("Not an ISO-DEP tag")
val isoDep = IsoDep.get(tag) ?: throw AuthnkeyError.NotIsoDepTag()
val transport = NfcTransport(isoDep)
if (!transport.selectFidoApplet()) {
throw Exception("Failed to select FIDO applet")
throw AuthnkeyError.FidoAppletNotFound()
}
currentTransport = transport
@@ -216,7 +217,7 @@ class MainActivity : AppCompatActivity() {
}
} catch (e: Exception) {
statusText.text = getString(R.string.nfc_error, e.message ?: "")
statusText.text = getString(R.string.nfc_error, e.toUserMessage(this@MainActivity))
updateConnectionStatus()
}
}
@@ -299,7 +300,7 @@ class MainActivity : AppCompatActivity() {
val transport = withContext(Dispatchers.IO) {
UsbTransport.create(usbManager, device)
} ?: throw Exception("Failed to initialize FIDO communication")
} ?: throw AuthnkeyError.ConnectionFailed()
currentTransport = transport
pinProtocol = PinProtocol(transport)
@@ -309,7 +310,7 @@ class MainActivity : AppCompatActivity() {
statusText.text = getString(R.string.usb_connected)
} catch (e: Exception) {
statusText.text = getString(R.string.usb_error, e.message ?: "")
statusText.text = getString(R.string.usb_error, e.toUserMessage(this@MainActivity))
updateConnectionStatus()
}
}
@@ -362,7 +363,7 @@ class MainActivity : AppCompatActivity() {
scope.launch {
try {
val transport = currentTransport ?: throw Exception("No key connected")
val transport = currentTransport ?: throw AuthnkeyError.NotConnected()
resultText.text = getString(R.string.reading_device_info)
@@ -389,7 +390,7 @@ class MainActivity : AppCompatActivity() {
if (isNfcDisconnected()) {
showNfcReconnectDialog()
} else {
resultText.text = getString(R.string.error_generic, e.message ?: "")
resultText.text = e.toUserMessage(this@MainActivity)
pendingAction = null
handleDisconnect()
}
@@ -402,7 +403,7 @@ class MainActivity : AppCompatActivity() {
scope.launch {
try {
val transport = currentTransport ?: throw Exception("No key connected")
val transport = currentTransport ?: throw AuthnkeyError.NotConnected()
resultText.text = getString(R.string.checking_cred_mgmt)
@@ -426,7 +427,7 @@ class MainActivity : AppCompatActivity() {
return@launch
}
val protocol = pinProtocol ?: throw Exception("PIN protocol not initialized")
val protocol = pinProtocol ?: throw AuthnkeyError.PinProtocolNotInitialized()
val retries = withContext(Dispatchers.IO) { protocol.getPinRetries() }.getOrElse { e ->
if (e is java.io.IOException) throw e
resultText.text = getString(R.string.error_could_not_get_pin_status)
@@ -448,7 +449,7 @@ class MainActivity : AppCompatActivity() {
if (isNfcDisconnected()) {
showNfcReconnectDialog()
} else {
resultText.text = getString(R.string.error_generic, e.message ?: "")
resultText.text = e.toUserMessage(this@MainActivity)
pendingAction = null
handleDisconnect()
}
@@ -486,8 +487,8 @@ class MainActivity : AppCompatActivity() {
scope.launch {
try {
val transport = currentTransport ?: throw Exception("No key connected")
val protocol = pinProtocol ?: throw Exception("PIN protocol not initialized")
val transport = currentTransport ?: throw AuthnkeyError.NotConnected()
val protocol = pinProtocol ?: throw AuthnkeyError.PinProtocolNotInitialized()
resultText.text = getString(R.string.authenticating)
@@ -507,18 +508,8 @@ class MainActivity : AppCompatActivity() {
if (usePreviewCommand) protocol.requestPinToken(pin)
else protocol.requestPinToken(pin, PinProtocol.PERMISSION_CM)
}.onFailure { e ->
when {
e is java.io.IOException -> throw e
e is CTAP.Exception && e.error == CTAP.Error.PIN_INVALID -> {
resultText.text = getString(R.string.error_invalid_pin)
}
e is CTAP.Exception && e.error == CTAP.Error.PIN_BLOCKED -> {
resultText.text = getString(R.string.error_pin_blocked)
}
else -> {
resultText.text = getString(R.string.error_generic, e.message ?: "")
}
}
if (e is java.io.IOException) throw e
resultText.text = e.toUserMessage(this@MainActivity)
pendingAction = null
return@launch
}
@@ -533,7 +524,7 @@ class MainActivity : AppCompatActivity() {
if (isNfcDisconnected()) {
showNfcReconnectDialog()
} else {
resultText.text = getString(R.string.error_metadata, it.message ?: "")
resultText.text = getString(R.string.error_metadata, it.toUserMessage(this@MainActivity))
pendingAction = null
}
return@launch
@@ -555,7 +546,7 @@ class MainActivity : AppCompatActivity() {
if (isNfcDisconnected()) {
showNfcReconnectDialog()
} else {
resultText.text = outputFormatter.formatEnumerateRpsError(metadata, it.message ?: "")
resultText.text = outputFormatter.formatEnumerateRpsError(metadata, it.toUserMessage(this@MainActivity))
pendingAction = null
}
return@launch
@@ -586,7 +577,7 @@ class MainActivity : AppCompatActivity() {
OutputFormatter.RelyingPartyWithCredentials(
relyingParty = rp,
credentials = null,
error = credsResult.exceptionOrNull()?.message
error = credsResult.exceptionOrNull()?.toUserMessage(this@MainActivity)
)
)
} else {
@@ -614,7 +605,7 @@ class MainActivity : AppCompatActivity() {
if (isNfcDisconnected()) {
showNfcReconnectDialog()
} else {
resultText.text = getString(R.string.error_generic_with_trace, e.message ?: "", e.stackTraceToString())
resultText.text = e.toUserMessage(this@MainActivity)
pendingAction = null
handleDisconnect()
}
@@ -627,7 +618,7 @@ class MainActivity : AppCompatActivity() {
scope.launch {
try {
val protocol = pinProtocol ?: throw Exception("PIN protocol not initialized")
val protocol = pinProtocol ?: throw AuthnkeyError.PinProtocolNotInitialized()
resultText.text = getString(R.string.checking_pin_status)
@@ -639,9 +630,9 @@ class MainActivity : AppCompatActivity() {
val confirmPinEdit = dialogView.findViewById<EditText>(R.id.confirmPin)
val message = if (retries != null) {
"PIN retries remaining: $retries"
getString(R.string.pin_retries_status, retries)
} else {
"Could not get PIN status"
getString(R.string.error_pin_status)
}
pendingAction = null
@@ -676,7 +667,7 @@ class MainActivity : AppCompatActivity() {
if (isNfcDisconnected()) {
showNfcReconnectDialog()
} else {
resultText.text = getString(R.string.error_generic, e.message ?: "")
resultText.text = e.toUserMessage(this@MainActivity)
pendingAction = null
handleDisconnect()
}
@@ -689,7 +680,7 @@ class MainActivity : AppCompatActivity() {
scope.launch {
try {
val protocol = pinProtocol ?: throw Exception("PIN protocol not initialized")
val protocol = pinProtocol ?: throw AuthnkeyError.PinProtocolNotInitialized()
resultText.text = getString(R.string.initializing_pin_protocol)
@@ -730,7 +721,7 @@ class MainActivity : AppCompatActivity() {
if (isNfcDisconnected()) {
showNfcReconnectDialog()
} else {
resultText.text = getString(R.string.error_generic, e.message ?: "")
resultText.text = e.toUserMessage(this@MainActivity)
pendingAction = null
}
}

View File

@@ -35,19 +35,43 @@
<string name="pin_continue">Continue</string>
<string name="pin_too_short">PIN must be at least 4 characters</string>
<string name="pin_retries_remaining">Enter PIN (%1$d retries remaining)</string>
<string name="pin_invalid_retries">Invalid PIN. %1$d retries remaining</string>
<string name="pin_incorrect_retries">Incorrect PIN. %1$d retries remaining</string>
<!-- Errors -->
<string name="error_format">Error: %1$s\n\nTry again or cancel</string>
<string name="error_retry_format">Error: %1$s\n\nTry again</string>
<string name="error_format">%1$s\n\nTry again or cancel</string>
<string name="error_retry_format">%1$s\n\nTry again</string>
<string name="error_user_cancelled">User cancelled</string>
<string name="error_no_request">No valid request found</string>
<string name="error_generic">Error: %1$s</string>
<string name="error_generic_with_trace">Error: %1$s\n\n%2$s</string>
<string name="error_metadata">Error getting metadata: %1$s</string>
<string name="error_could_not_get_pin_status">Error: Could not get PIN status</string>
<string name="error_pins_dont_match">Error: New PINs don\'t match</string>
<string name="error_uv_required_no_pin">User verification required but no PIN is set on your security key</string>
<string name="error_could_not_get_pin_status">Could not get PIN status</string>
<string name="error_pins_dont_match">New PINs don\'t match</string>
<string name="error_uv_required_no_pin">Verification required but no PIN is set on your security key</string>
<!-- User-friendly error messages (used by ErrorMapping) -->
<string name="error_tag_lost">Lost contact with the security key</string>
<string name="error_not_supported_tag">This NFC tag is not a security key</string>
<string name="error_not_security_key">Security key not recognized</string>
<string name="error_connection_failed">Could not connect to the security key</string>
<string name="error_not_connected">No security key connected</string>
<string name="error_key_disconnected">The security key disconnected</string>
<string name="error_communication_failed">Could not communicate with the security key</string>
<string name="error_no_credentials">No passkeys found on this security key</string>
<string name="error_pin_incorrect">Incorrect PIN</string>
<string name="error_pin_blocked">PIN is blocked</string>
<string name="error_pin_not_set">No PIN configured on this security key</string>
<string name="error_pin_auth_invalid">PIN authentication failed</string>
<string name="error_pin_auth_blocked">Too many failed PIN attempts</string>
<string name="error_credential_excluded">This passkey already exists</string>
<string name="error_operation_denied">Operation denied by security key</string>
<string name="error_operation_cancelled">Operation cancelled</string>
<string name="error_user_action_timeout">Touch was not detected in time</string>
<string name="error_timeout">Operation timed out</string>
<string name="error_key_store_full">Security key storage is full</string>
<string name="error_unsupported_algorithm">Unsupported security algorithm</string>
<string name="error_ctap_unknown">Security key error: %1$s</string>
<string name="error_unknown">An unexpected error occurred</string>
<!-- Content descriptions -->
<string name="security_key_icon_desc">Security Key</string>
@@ -75,16 +99,14 @@
<string name="operation_cancelled">Operation cancelled</string>
<string name="reading_device_info">Reading device info…</string>
<string name="checking_cred_mgmt">Checking credential management support…</string>
<string name="error_parse_device_info">Error: Could not parse device info</string>
<string name="error_pin_blocked">Error: PIN is blocked. Device needs to be reset.</string>
<string name="error_parse_device_info">Could not parse device info</string>
<string name="error_pin_status">Could not get PIN status</string>
<string name="pin_required_title">PIN Required</string>
<string name="pin_required_message">Enter your PIN to list credentials.\n\nRetries remaining: %1$d</string>
<string name="error_pin_min_length">Error: PIN must be at least 4 characters</string>
<string name="error_pin_min_length">PIN must be at least 4 characters</string>
<string name="authenticating">Authenticating…</string>
<string name="error_init_pin_protocol">Error: Failed to initialize PIN protocol</string>
<string name="error_init_pin_protocol">Could not establish secure connection</string>
<string name="verifying_pin">Verifying PIN…</string>
<string name="error_invalid_pin">Error: Invalid PIN</string>
<string name="getting_metadata">Getting credential metadata…</string>
<string name="enumerating_rps">Enumerating relying parties…</string>
<string name="loading_credentials_for">Loading credentials for %1$s…</string>