From 5e3cc32e5790914f0e7b356fefd615d05b8cd1e1 Mon Sep 17 00:00:00 2001 From: mimi89999 Date: Sat, 13 Dec 2025 19:40:04 +0100 Subject: [PATCH] Initial commit --- .gitignore | 10 + LICENSE | 21 + README.md | 40 + app/.gitignore | 1 + app/build.gradle.kts | 49 + app/proguard-rules.pro | 21 + .../authnkey/ExampleInstrumentedTest.kt | 24 + app/src/main/AndroidManifest.xml | 56 + app/src/main/ic_launcher-playstore.png | Bin 0 -> 10802 bytes .../authnkey/AuthnkeyCredentialService.kt | 198 +++ app/src/main/java/pl/lebihan/authnkey/CTAP.kt | 198 +++ app/src/main/java/pl/lebihan/authnkey/Cbor.kt | 311 +++++ .../lebihan/authnkey/CredentialBottomSheet.kt | 312 +++++ .../lebihan/authnkey/CredentialManagement.kt | 300 +++++ .../authnkey/CredentialProviderActivity.kt | 1086 +++++++++++++++++ .../java/pl/lebihan/authnkey/FidoCommands.kt | 226 ++++ .../java/pl/lebihan/authnkey/FidoTransport.kt | 22 + .../java/pl/lebihan/authnkey/MainActivity.kt | 817 +++++++++++++ .../java/pl/lebihan/authnkey/NfcTransport.kt | 136 +++ .../pl/lebihan/authnkey/OutputFormatter.kt | 298 +++++ .../java/pl/lebihan/authnkey/PinProtocol.kt | 350 ++++++ .../java/pl/lebihan/authnkey/UsbTransport.kt | 292 +++++ app/src/main/res/anim/pulse.xml | 11 + .../main/res/drawable/account_circle_24.xml | 10 + app/src/main/res/drawable/bg_icon_circle.xml | 5 + app/src/main/res/drawable/check_circle_24.xml | 10 + app/src/main/res/drawable/error_24.xml | 10 + app/src/main/res/drawable/fingerprint_24.xml | 10 + .../res/drawable/ic_launcher_background.xml | 170 +++ .../res/drawable/ic_launcher_foreground.xml | 14 + app/src/main/res/drawable/key_24.xml | 10 + app/src/main/res/drawable/lock_24.xml | 10 + app/src/main/res/drawable/sensors_24.xml | 10 + app/src/main/res/layout/activity_main.xml | 113 ++ .../res/layout/bottom_sheet_credential.xml | 116 ++ app/src/main/res/layout/dialog_pin.xml | 46 + .../main/res/layout/dialog_usb_devices.xml | 22 + app/src/main/res/layout/item_account.xml | 45 + app/src/main/res/layout/item_usb_device.xml | 23 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 922 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2458 bytes app/src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 744 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1624 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1236 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3484 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 1766 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5300 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 2296 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 7496 bytes app/src/main/res/raw/privileged_apps.json | 820 +++++++++++++ app/src/main/res/values-night/colors.xml | 10 + app/src/main/res/values-night/themes.xml | 28 + app/src/main/res/values/colors.xml | 10 + .../res/values/ic_launcher_background.xml | 4 + app/src/main/res/values/strings.xml | 161 +++ app/src/main/res/values/themes.xml | 28 + app/src/main/res/xml/backup_rules.xml | 6 + .../res/xml/credential_provider_config.xml | 6 + .../main/res/xml/data_extraction_rules.xml | 17 + .../pl/lebihan/authnkey/ExampleUnitTest.kt | 17 + build.gradle.kts | 5 + .../android/en-US/full_description.txt | 16 + .../android/en-US/short_description.txt | 1 + gradle.properties | 23 + gradle/libs.versions.toml | 26 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes gradle/wrapper/gradle-wrapper.properties | 8 + gradlew | 251 ++++ gradlew.bat | 94 ++ settings.gradle.kts | 23 + 72 files changed, 6967 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app/.gitignore create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/androidTest/java/pl/lebihan/authnkey/ExampleInstrumentedTest.kt create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/ic_launcher-playstore.png create mode 100644 app/src/main/java/pl/lebihan/authnkey/AuthnkeyCredentialService.kt create mode 100644 app/src/main/java/pl/lebihan/authnkey/CTAP.kt create mode 100644 app/src/main/java/pl/lebihan/authnkey/Cbor.kt create mode 100644 app/src/main/java/pl/lebihan/authnkey/CredentialBottomSheet.kt create mode 100644 app/src/main/java/pl/lebihan/authnkey/CredentialManagement.kt create mode 100644 app/src/main/java/pl/lebihan/authnkey/CredentialProviderActivity.kt create mode 100644 app/src/main/java/pl/lebihan/authnkey/FidoCommands.kt create mode 100644 app/src/main/java/pl/lebihan/authnkey/FidoTransport.kt create mode 100644 app/src/main/java/pl/lebihan/authnkey/MainActivity.kt create mode 100644 app/src/main/java/pl/lebihan/authnkey/NfcTransport.kt create mode 100644 app/src/main/java/pl/lebihan/authnkey/OutputFormatter.kt create mode 100644 app/src/main/java/pl/lebihan/authnkey/PinProtocol.kt create mode 100644 app/src/main/java/pl/lebihan/authnkey/UsbTransport.kt create mode 100644 app/src/main/res/anim/pulse.xml create mode 100644 app/src/main/res/drawable/account_circle_24.xml create mode 100644 app/src/main/res/drawable/bg_icon_circle.xml create mode 100644 app/src/main/res/drawable/check_circle_24.xml create mode 100644 app/src/main/res/drawable/error_24.xml create mode 100644 app/src/main/res/drawable/fingerprint_24.xml create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/drawable/key_24.xml create mode 100644 app/src/main/res/drawable/lock_24.xml create mode 100644 app/src/main/res/drawable/sensors_24.xml create mode 100644 app/src/main/res/layout/activity_main.xml create mode 100644 app/src/main/res/layout/bottom_sheet_credential.xml create mode 100644 app/src/main/res/layout/dialog_pin.xml create mode 100644 app/src/main/res/layout/dialog_usb_devices.xml create mode 100644 app/src/main/res/layout/item_account.xml create mode 100644 app/src/main/res/layout/item_usb_device.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/raw/privileged_apps.json create mode 100644 app/src/main/res/values-night/colors.xml create mode 100644 app/src/main/res/values-night/themes.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/ic_launcher_background.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/credential_provider_config.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml create mode 100644 app/src/test/java/pl/lebihan/authnkey/ExampleUnitTest.kt create mode 100644 build.gradle.kts create mode 100644 fastlane/metadata/android/en-US/full_description.txt create mode 100644 fastlane/metadata/android/en-US/short_description.txt create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100755 gradlew.bat create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..10cfdbf --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +*.iml +.gradle +/local.properties +/.idea +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..886d31f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Michel Le Bihan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a673d6b --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# Authnkey + +A credential provider for Android that enables FIDO2/CTAP2 security keys over NFC. + +## Background + +Android does not support CTAP2 over NFC. The built-in WebAuthn implementation only handles basic U2F-style authentication for NFC keys, which means no PIN verification and no discoverable credentials (passkeys). USB-C keys have better support, but NFC keys are limited to tap-to-authenticate without user verification. + +Authnkey implements the CTAP2 protocol directly, allowing full passkey functionality with NFC security keys like YubiKey or SoloKey. + +Additionally, Android's FIDO2 support depends on Google Play Services. Authnkey works on devices without GApps since it implements the protocol independently. + +## Features + +- Passkey creation and authentication over NFC and USB +- PIN verification (CTAP2 clientPin) +- Discoverable credentials +- Multiple account selection +- No Google Play Services required + +## Requirements + +- Android 14+ (API 34) +- A FIDO2-compatible security key + +## Usage + +1. Install the app +2. Enable Authnkey in Settings → Passwords & accounts → Passwords, passkeys, and data services +3. When a site or app requests a passkey, select "Security Key" from the credential provider options + +## Building + +``` +./gradlew assembleDebug +``` + +## License + +MIT diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..44c36ad --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,49 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "pl.lebihan.authnkey" + compileSdk { + version = release(36) + } + + defaultConfig { + applicationId = "pl.lebihan.authnkey" + minSdk = 34 + targetSdk = 36 + versionCode = 1 + versionName = "1.0.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.credentials) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.material) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/pl/lebihan/authnkey/ExampleInstrumentedTest.kt b/app/src/androidTest/java/pl/lebihan/authnkey/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..29048ed --- /dev/null +++ b/app/src/androidTest/java/pl/lebihan/authnkey/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package pl.lebihan.authnkey + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("pl.lebihan.authnkey", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a184fec --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000000000000000000000000000000000000..cd69de81304d461c7e2a0ac8d2293df7750482d8 GIT binary patch literal 10802 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 Zj47|&dnzYrGcYhPc)I$ztaD0e0sxmNl)C@` literal 0 HcmV?d00001 diff --git a/app/src/main/java/pl/lebihan/authnkey/AuthnkeyCredentialService.kt b/app/src/main/java/pl/lebihan/authnkey/AuthnkeyCredentialService.kt new file mode 100644 index 0000000..6f9a39d --- /dev/null +++ b/app/src/main/java/pl/lebihan/authnkey/AuthnkeyCredentialService.kt @@ -0,0 +1,198 @@ +package pl.lebihan.authnkey + +import android.app.PendingIntent +import android.content.Intent +import android.os.Build +import android.os.CancellationSignal +import android.os.OutcomeReceiver +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.credentials.exceptions.ClearCredentialException +import androidx.credentials.exceptions.CreateCredentialException +import androidx.credentials.exceptions.CreateCredentialUnknownException +import androidx.credentials.exceptions.GetCredentialException +import androidx.credentials.exceptions.GetCredentialUnknownException +import androidx.credentials.provider.BeginCreateCredentialRequest +import androidx.credentials.provider.BeginCreateCredentialResponse +import androidx.credentials.provider.BeginCreatePublicKeyCredentialRequest +import androidx.credentials.provider.BeginGetCredentialRequest +import androidx.credentials.provider.BeginGetCredentialResponse +import androidx.credentials.provider.BeginGetPublicKeyCredentialOption +import androidx.credentials.provider.CreateEntry +import androidx.credentials.provider.CredentialEntry +import androidx.credentials.provider.CredentialProviderService +import androidx.credentials.provider.ProviderClearCredentialStateRequest +import androidx.credentials.provider.PublicKeyCredentialEntry +import org.json.JSONObject + +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +class AuthnkeyCredentialService : CredentialProviderService() { + + companion object { + private const val TAG = "AuthnkeyCredService" + + const val ACTION_CREATE_PASSKEY = "pl.lebihan.authnkey.CREATE_PASSKEY" + const val ACTION_GET_PASSKEY = "pl.lebihan.authnkey.GET_PASSKEY" + } + + override fun onBeginCreateCredentialRequest( + request: BeginCreateCredentialRequest, + cancellationSignal: CancellationSignal, + callback: OutcomeReceiver + ) { + try { + when (request) { + is BeginCreatePublicKeyCredentialRequest -> { + handleBeginCreatePasskey(request, callback) + } + else -> { + Log.w(TAG, "Unsupported credential type: ${request.type}") + callback.onError(CreateCredentialUnknownException("Unsupported credential type")) + } + } + } catch (e: Exception) { + Log.e(TAG, "Error in onBeginCreateCredentialRequest", e) + callback.onError(CreateCredentialUnknownException(e.message)) + } + } + + override fun onBeginGetCredentialRequest( + request: BeginGetCredentialRequest, + cancellationSignal: CancellationSignal, + callback: OutcomeReceiver + ) { + try { + val credentialEntries = mutableListOf() + + for (option in request.beginGetCredentialOptions) { + when (option) { + is BeginGetPublicKeyCredentialOption -> { + val entries = handleBeginGetPasskey(option) + credentialEntries.addAll(entries) + } + } + } + + if (credentialEntries.isEmpty()) { + // Still show an option to use security key + val intent = Intent(this, CredentialProviderActivity::class.java).apply { + action = ACTION_GET_PASSKEY + } + val pendingIntent = PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + val entry = PublicKeyCredentialEntry.Builder( + this, + getString(R.string.credential_entry_use), + pendingIntent, + request.beginGetCredentialOptions.first() as BeginGetPublicKeyCredentialOption + ) + .setDisplayName(getString(R.string.credential_entry_tap)) + .build() + + credentialEntries.add(entry) + } + + val response = BeginGetCredentialResponse.Builder() + .setCredentialEntries(credentialEntries) + .build() + + callback.onResult(response) + + } catch (e: Exception) { + Log.e(TAG, "Error in onBeginGetCredentialRequest", e) + callback.onError(GetCredentialUnknownException(e.message)) + } + } + + override fun onClearCredentialStateRequest( + request: ProviderClearCredentialStateRequest, + cancellationSignal: CancellationSignal, + callback: OutcomeReceiver + ) { + // Nothing to clear - credentials are on the physical key + callback.onResult(null) + } + + private fun handleBeginCreatePasskey( + request: BeginCreatePublicKeyCredentialRequest, + callback: OutcomeReceiver + ) { + try { + val json = JSONObject(request.requestJson) + val rp = json.getJSONObject("rp") + val rpName = rp.optString("name", rp.getString("id")) + + // Create pending intent to launch our activity + // The system will attach the full request via PendingIntentHandler + val intent = Intent(this, CredentialProviderActivity::class.java).apply { + action = ACTION_CREATE_PASSKEY + } + + val pendingIntent = PendingIntent.getActivity( + this, + System.currentTimeMillis().toInt(), + intent, + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + val createEntry = CreateEntry.Builder(getString(R.string.credential_entry_title), pendingIntent) + .setDescription(getString(R.string.credential_create_description, rpName)) + .build() + + val response = BeginCreateCredentialResponse.Builder() + .setCreateEntries(listOf(createEntry)) + .build() + + callback.onResult(response) + + } catch (e: Exception) { + Log.e(TAG, "Error parsing create request", e) + callback.onError(CreateCredentialUnknownException(e.message)) + } + } + + private fun handleBeginGetPasskey( + option: BeginGetPublicKeyCredentialOption + ): List { + val entries = mutableListOf() + + try { + val json = JSONObject(option.requestJson) + val rpId = json.optString("rpId", "") + + // Create pending intent to launch our activity + // The system will attach the full request via PendingIntentHandler + val intent = Intent(this, CredentialProviderActivity::class.java).apply { + action = ACTION_GET_PASSKEY + } + + val pendingIntent = PendingIntent.getActivity( + this, + System.currentTimeMillis().toInt(), + intent, + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + val entry = PublicKeyCredentialEntry.Builder( + this, + getString(R.string.credential_entry_title), + pendingIntent, + option + ) + .setDisplayName(getString(R.string.credential_get_description, rpId)) + .build() + + entries.add(entry) + + } catch (e: Exception) { + Log.e(TAG, "Error parsing get request", e) + } + + return entries + } +} diff --git a/app/src/main/java/pl/lebihan/authnkey/CTAP.kt b/app/src/main/java/pl/lebihan/authnkey/CTAP.kt new file mode 100644 index 0000000..cd597b2 --- /dev/null +++ b/app/src/main/java/pl/lebihan/authnkey/CTAP.kt @@ -0,0 +1,198 @@ +package pl.lebihan.authnkey + +data class AlgorithmInfo( + val type: String?, + val alg: Int? +) + +data class DeviceInfo( + val versions: List = emptyList(), + val extensions: List = emptyList(), + val aaguid: ByteArray? = null, + val options: Map = emptyMap(), + val maxMsgSize: Int? = null, + val pinUvAuthProtocols: List = emptyList(), + val maxCredentialCountInList: Int? = null, + val maxCredentialIdLength: Int? = null, + val transports: List = emptyList(), + val algorithms: List = emptyList(), + val firmwareVersion: Int? = null, + val minPinLength: Int? = null +) { + val supportsCredMgmt: Boolean + get() = options["credMgmt"] == true + + val supportsCredMgmtPreview: Boolean + get() = options["credentialMgmtPreview"] == true + + val usePreviewCommand: Boolean + get() = supportsCredMgmtPreview && !supportsCredMgmt +} + +object CTAP { + const val CMD_MAKE_CREDENTIAL = 0x01 + const val CMD_GET_ASSERTION = 0x02 + const val CMD_GET_INFO = 0x04 + const val CMD_CLIENT_PIN = 0x06 + const val CMD_RESET = 0x07 + const val CMD_GET_NEXT_ASSERTION = 0x08 + const val CMD_CREDENTIAL_MANAGEMENT = 0x0A + const val CMD_CREDENTIAL_MANAGEMENT_PREVIEW = 0x41 + const val CMD_SELECTION = 0x0B + const val CMD_LARGE_BLOBS = 0x0C + const val CMD_CONFIG = 0x0D + + const val PIN_CMD_GET_RETRIES = 0x01 + const val PIN_CMD_GET_KEY_AGREEMENT = 0x02 + const val PIN_CMD_SET_PIN = 0x03 + const val PIN_CMD_CHANGE_PIN = 0x04 + const val PIN_CMD_GET_PIN_TOKEN = 0x05 + + private const val STATUS_SUCCESS: Byte = 0x00 + + enum class Error(val code: Int) { + SUCCESS(0x00), + INVALID_COMMAND(0x01), + INVALID_PARAMETER(0x02), + INVALID_LENGTH(0x03), + INVALID_SEQ(0x04), + TIMEOUT(0x05), + CHANNEL_BUSY(0x06), + LOCK_REQUIRED(0x0A), + INVALID_CHANNEL(0x0B), + CBOR_UNEXPECTED_TYPE(0x11), + INVALID_CBOR(0x12), + MISSING_PARAMETER(0x14), + LIMIT_EXCEEDED(0x15), + CREDENTIAL_EXCLUDED(0x19), + PROCESSING(0x21), + INVALID_CREDENTIAL(0x22), + USER_ACTION_PENDING(0x23), + OPERATION_PENDING(0x24), + NO_OPERATIONS(0x25), + UNSUPPORTED_ALGORITHM(0x26), + OPERATION_DENIED(0x27), + KEY_STORE_FULL(0x28), + UNSUPPORTED_OPTION(0x2B), + INVALID_OPTION(0x2C), + KEEPALIVE_CANCEL(0x2D), + NO_CREDENTIALS(0x2E), + USER_ACTION_TIMEOUT(0x2F), + NOT_ALLOWED(0x30), + PIN_INVALID(0x31), + PIN_BLOCKED(0x32), + PIN_AUTH_INVALID(0x33), + PIN_AUTH_BLOCKED(0x34), + PIN_NOT_SET(0x35), + PIN_REQUIRED(0x36), + PIN_POLICY_VIOLATION(0x37), + PIN_TOKEN_EXPIRED(0x38), + REQUEST_TOO_LARGE(0x39), + ACTION_TIMEOUT(0x3A), + UP_REQUIRED(0x3B), + UV_BLOCKED(0x3C), + INTEGRITY_FAILURE(0x3D), + INVALID_SUBCOMMAND(0x3E), + UV_INVALID(0x3F), + UNAUTHORIZED_PERMISSION(0x40), + OTHER(0x7F); + + companion object { + private val byCode = entries.associateBy { it.code } + fun fromCode(code: Int): Error? = byCode[code] + } + } + + fun getErrorName(code: Byte): String { + val intCode = code.toInt() and 0xFF + return Error.fromCode(intCode)?.name ?: "UNKNOWN_ERROR (0x${String.format("%02X", code)})" + } + + fun isSuccess(response: ByteArray): Boolean { + return response.isNotEmpty() && response[0] == STATUS_SUCCESS + } + + fun getResponseError(response: ByteArray): Error? { + if (response.isEmpty()) return Error.OTHER + val code = response[0].toInt() and 0xFF + return if (code == 0) null else (Error.fromCode(code) ?: Error.OTHER) + } + + fun getResponseErrorMessage(response: ByteArray): String? { + if (response.isEmpty()) return "Empty response" + val code = response[0].toInt() and 0xFF + return if (code == 0) null else getErrorName(response[0]) + } + + fun buildCommand(cmd: Int): ByteArray { + return byteArrayOf(cmd.toByte()) + } + + fun parseGetInfoStructured(response: ByteArray): DeviceInfo? { + if (!isSuccess(response)) { + return null + } + + val data = response.drop(1).toByteArray() + + return try { + val parsed = CborMap.decode(data) ?: return null + + val versions = parsed.list(1) ?: emptyList() + val extensions = parsed.list(2) ?: emptyList() + val aaguid = parsed.bytes(3) + + val options = mutableMapOf() + parsed.map(4)?.let { opts -> + val raw = CborDecoder.decode(data) as? Map<*, *> + (raw?.get(4L) as? Map<*, *>)?.forEach { (k, v) -> + if (k is String && v is Boolean) { + options[k] = v + } + } + } + + val maxMsgSize = parsed.int(5) + val pinUvAuthProtocols = parsed.list(6)?.map { it.toInt() } ?: emptyList() + val maxCredentialCountInList = parsed.int(7) + val maxCredentialIdLength = parsed.int(8) + val transports = parsed.list(9) ?: emptyList() + + val algorithms = parsed.mapList(10)?.mapNotNull { alg -> + AlgorithmInfo( + type = alg.string("type"), + alg = alg.int("alg") + ) + } ?: emptyList() + + val minPinLength = parsed.int(13) + val firmwareVersion = parsed.int(14) + + DeviceInfo( + versions = versions, + extensions = extensions, + aaguid = aaguid, + options = options, + maxMsgSize = maxMsgSize, + pinUvAuthProtocols = pinUvAuthProtocols, + maxCredentialCountInList = maxCredentialCountInList, + maxCredentialIdLength = maxCredentialIdLength, + transports = transports, + algorithms = algorithms, + firmwareVersion = firmwareVersion, + minPinLength = minPinLength + ) + } catch (e: Exception) { + null + } + } + + fun buildGetPinRetriesCommand(): ByteArray { + return byteArrayOf(CMD_CLIENT_PIN.toByte()) + cbor { + map { + 1 to 1 + 2 to 1 + } + } + } +} diff --git a/app/src/main/java/pl/lebihan/authnkey/Cbor.kt b/app/src/main/java/pl/lebihan/authnkey/Cbor.kt new file mode 100644 index 0000000..9df4ea6 --- /dev/null +++ b/app/src/main/java/pl/lebihan/authnkey/Cbor.kt @@ -0,0 +1,311 @@ +package pl.lebihan.authnkey + +fun cbor(block: CborEncoder.() -> Unit): ByteArray { + val encoder = CborEncoder() + encoder.block() + return encoder.toByteArray() +} + +class CborEncoder { + private val out = mutableListOf() + + fun map(block: CborMapEncoder.() -> Unit) { + val map = CborMapEncoder() + map.block() + writeHeader(5, map.entries.size) + map.entries.forEach { out.addAll(it) } + } + + fun toByteArray(): ByteArray = out.toByteArray() + + private fun writeHeader(major: Int, value: Int) { + out.addAll(encodeHeader(major, value)) + } +} + +class CborMapEncoder { + internal val entries = mutableListOf>() + + infix fun Int.to(value: Any?) { + entries.add(encodeInt(this) + encodeValue(value)) + } + + infix fun String.to(value: Any?) { + entries.add(encodeText(this) + encodeValue(value)) + } + + fun map(block: CborMapEncoder.() -> Unit): CborRaw { + val nested = CborMapEncoder() + nested.block() + val bytes = mutableListOf() + bytes.addAll(encodeHeader(5, nested.entries.size)) + nested.entries.forEach { bytes.addAll(it) } + return CborRaw(bytes) + } + + fun array(block: CborArrayEncoder.() -> Unit): CborRaw { + val nested = CborArrayEncoder() + nested.block() + val bytes = mutableListOf() + bytes.addAll(encodeHeader(4, nested.items.size)) + nested.items.forEach { bytes.addAll(it) } + return CborRaw(bytes) + } + + fun bytes(data: ByteArray) = CborRaw(encodeBytes(data)) +} + +class CborArrayEncoder { + internal val items = mutableListOf>() + + fun add(value: Any?) { + items.add(encodeValue(value)) + } + + fun map(block: CborMapEncoder.() -> Unit) { + val nested = CborMapEncoder() + nested.block() + val bytes = mutableListOf() + bytes.addAll(encodeHeader(5, nested.entries.size)) + nested.entries.forEach { bytes.addAll(it) } + items.add(bytes) + } +} + +@JvmInline +value class CborRaw(val bytes: List) + +private fun encodeValue(value: Any?): List = when (value) { + null -> listOf(0xF6.toByte()) + is Boolean -> listOf(if (value) 0xF5.toByte() else 0xF4.toByte()) + is Int -> encodeInt(value) + is Long -> encodeLong(value) + is String -> encodeText(value) + is ByteArray -> encodeBytes(value) + is CborRaw -> value.bytes + is List<*> -> { + val items = value.map { encodeValue(it) } + encodeHeader(4, items.size) + items.flatten() + } + else -> throw IllegalArgumentException("Unsupported: ${value::class}") +} + +private fun encodeHeader(major: Int, value: Int): List = when { + value < 24 -> listOf(((major shl 5) or value).toByte()) + value < 0x100 -> listOf(((major shl 5) or 24).toByte(), value.toByte()) + value < 0x10000 -> listOf( + ((major shl 5) or 25).toByte(), + (value shr 8).toByte(), + value.toByte() + ) + else -> listOf( + ((major shl 5) or 26).toByte(), + (value shr 24).toByte(), + (value shr 16).toByte(), + (value shr 8).toByte(), + value.toByte() + ) +} + +private fun encodeHeaderLong(major: Int, value: Long): List = when { + value < 24 -> listOf(((major shl 5) or value.toInt()).toByte()) + value < 0x100 -> listOf(((major shl 5) or 24).toByte(), value.toByte()) + value < 0x10000 -> listOf( + ((major shl 5) or 25).toByte(), + (value shr 8).toByte(), + value.toByte() + ) + value < 0x100000000 -> listOf( + ((major shl 5) or 26).toByte(), + (value shr 24).toByte(), + (value shr 16).toByte(), + (value shr 8).toByte(), + value.toByte() + ) + else -> listOf( + ((major shl 5) or 27).toByte(), + (value shr 56).toByte(), + (value shr 48).toByte(), + (value shr 40).toByte(), + (value shr 32).toByte(), + (value shr 24).toByte(), + (value shr 16).toByte(), + (value shr 8).toByte(), + value.toByte() + ) +} + +private fun encodeInt(value: Int): List = + if (value >= 0) encodeHeader(0, value) + else encodeHeader(1, -1 - value) + +private fun encodeLong(value: Long): List = + if (value >= 0) encodeHeaderLong(0, value) + else encodeHeaderLong(1, -1 - value) + +private fun encodeText(s: String): List { + val bytes = s.toByteArray(Charsets.UTF_8) + return encodeHeader(3, bytes.size) + bytes.toList() +} + +private fun encodeBytes(b: ByteArray): List = + encodeHeader(2, b.size) + b.toList() + +// ============================================================ + +class CborDecoder private constructor(private val data: ByteArray) { + private var pos = 0 + + companion object { + fun decode(data: ByteArray): Any? = CborDecoder(data).readValue() + } + + private fun readValue(): Any? { + if (pos >= data.size) return null + + val initial = data[pos++].toInt() and 0xFF + val major = initial shr 5 + val info = initial and 0x1F + + return when (major) { + 0 -> readUnsigned(info) + 1 -> -1L - readUnsigned(info) + 2 -> readByteString(info) + 3 -> readTextString(info) + 4 -> readArray(info) + 5 -> readMap(info) + 7 -> when (info) { + 20 -> false + 21 -> true + 22, 23 -> null + else -> null + } + else -> null + } + } + + private fun readUnsigned(info: Int): Long = when { + info < 24 -> info.toLong() + info == 24 -> (data[pos++].toInt() and 0xFF).toLong() + info == 25 -> { + val r = ((data[pos].toInt() and 0xFF) shl 8) or (data[pos + 1].toInt() and 0xFF) + pos += 2 + r.toLong() + } + info == 26 -> { + val r = ((data[pos].toLong() and 0xFF) shl 24) or + ((data[pos + 1].toLong() and 0xFF) shl 16) or + ((data[pos + 2].toLong() and 0xFF) shl 8) or + (data[pos + 3].toLong() and 0xFF) + pos += 4 + r + } + info == 27 -> { + val r = ((data[pos].toLong() and 0xFF) shl 56) or + ((data[pos + 1].toLong() and 0xFF) shl 48) or + ((data[pos + 2].toLong() and 0xFF) shl 40) or + ((data[pos + 3].toLong() and 0xFF) shl 32) or + ((data[pos + 4].toLong() and 0xFF) shl 24) or + ((data[pos + 5].toLong() and 0xFF) shl 16) or + ((data[pos + 6].toLong() and 0xFF) shl 8) or + (data[pos + 7].toLong() and 0xFF) + pos += 8 + r + } + else -> 0L + } + + private fun readByteString(info: Int): ByteArray { + val len = readUnsigned(info).toInt() + val result = data.sliceArray(pos until pos + len) + pos += len + return result + } + + private fun readTextString(info: Int): String { + val len = readUnsigned(info).toInt() + val result = String(data, pos, len, Charsets.UTF_8) + pos += len + return result + } + + private fun readArray(info: Int): List { + val len = readUnsigned(info).toInt() + return (0 until len).map { readValue() } + } + + private fun readMap(info: Int): Map { + val len = readUnsigned(info).toInt() + val result = linkedMapOf() + repeat(len) { + val key = readValue() + val value = readValue() + result[key] = value + } + return result + } +} + +class CborMap(private val raw: Map) { + + companion object { + fun decode(data: ByteArray): CborMap? { + val decoded = CborDecoder.decode(data) as? Map<*, *> ?: return null + @Suppress("UNCHECKED_CAST") + return CborMap(decoded as Map) + } + } + + operator fun get(key: Int): Any? = raw[key.toLong()] ?: raw[key] + operator fun get(key: String): Any? = raw[key] + + fun int(key: Int): Int? = (this[key] as? Number)?.toInt() + fun int(key: String): Int? = (this[key] as? Number)?.toInt() + + fun long(key: Int): Long? = (this[key] as? Number)?.toLong() + fun long(key: String): Long? = (this[key] as? Number)?.toLong() + + fun bool(key: Int): Boolean? = this[key] as? Boolean + fun bool(key: String): Boolean? = this[key] as? Boolean + + fun string(key: Int): String? = this[key] as? String + fun string(key: String): String? = this[key] as? String + + fun bytes(key: Int): ByteArray? = this[key] as? ByteArray + fun bytes(key: String): ByteArray? = this[key] as? ByteArray + + fun map(key: Int): CborMap? = (this[key] as? Map<*, *>)?.let { + @Suppress("UNCHECKED_CAST") + CborMap(it as Map) + } + fun map(key: String): CborMap? = (this[key] as? Map<*, *>)?.let { + @Suppress("UNCHECKED_CAST") + CborMap(it as Map) + } + + fun list(key: Int): List? { + @Suppress("UNCHECKED_CAST") + return this[key] as? List + } + fun list(key: String): List? { + @Suppress("UNCHECKED_CAST") + return this[key] as? List + } + + fun mapList(key: Int): List? = list>(key)?.map { CborMap(it) } + fun mapList(key: String): List? = list>(key)?.map { CborMap(it) } + + fun containsKey(key: Int): Boolean = raw.containsKey(key.toLong()) || raw.containsKey(key) + fun containsKey(key: String): Boolean = raw.containsKey(key) +} + +fun ByteArray.toHex(): String = joinToString("") { "%02X".format(it) } + +fun String.hexToByteArray(): ByteArray { + val len = length + val out = ByteArray(len / 2) + for (i in 0 until len step 2) { + out[i / 2] = ((Character.digit(this[i], 16) shl 4) + Character.digit(this[i + 1], 16)).toByte() + } + return out +} diff --git a/app/src/main/java/pl/lebihan/authnkey/CredentialBottomSheet.kt b/app/src/main/java/pl/lebihan/authnkey/CredentialBottomSheet.kt new file mode 100644 index 0000000..dbed234 --- /dev/null +++ b/app/src/main/java/pl/lebihan/authnkey/CredentialBottomSheet.kt @@ -0,0 +1,312 @@ +package pl.lebihan.authnkey + +import android.animation.ObjectAnimator +import android.content.DialogInterface +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.animation.AccelerateDecelerateInterpolator +import android.view.inputmethod.EditorInfo +import android.widget.ImageView +import android.widget.ProgressBar +import android.widget.TextView +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.button.MaterialButton +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout + +class CredentialBottomSheet : BottomSheetDialogFragment() { + + enum class State { + WAITING, + TOUCH, + PROCESSING, + PIN, + ACCOUNT_SELECT, + SUCCESS, + ERROR + } + + data class AccountInfo( + val displayName: String, + val subtitle: String? = null + ) + + private lateinit var statusText: TextView + private lateinit var instructionText: TextView + private lateinit var progressBar: ProgressBar + private lateinit var btnCancel: MaterialButton + private lateinit var btnContinue: MaterialButton + private lateinit var pinInputLayout: TextInputLayout + private lateinit var pinEditText: TextInputEditText + private lateinit var iconStatus: ImageView + private lateinit var iconBackground: View + private lateinit var accountList: RecyclerView + + private var pulseAnimator: ObjectAnimator? = null + + private var pendingStatus: String? = null + private var pendingInstruction: String? = null + private var pendingShowPinInput: Boolean = false + private var pendingState: State = State.WAITING + + var onCancelClick: (() -> Unit)? = null + var onPinEntered: ((String) -> Unit)? = null + var onAccountSelected: ((Int) -> Unit)? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + arguments?.let { + pendingStatus = it.getString(ARG_STATUS) + pendingInstruction = it.getString(ARG_INSTRUCTION) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.bottom_sheet_credential, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + statusText = view.findViewById(R.id.statusText) + instructionText = view.findViewById(R.id.instructionText) + progressBar = view.findViewById(R.id.progressBar) + btnCancel = view.findViewById(R.id.btnCancel) + btnContinue = view.findViewById(R.id.btnContinue) + pinInputLayout = view.findViewById(R.id.pinInputLayout) + pinEditText = view.findViewById(R.id.pinEditText) + iconStatus = view.findViewById(R.id.iconStatus) + iconBackground = view.findViewById(R.id.iconBackground) + accountList = view.findViewById(R.id.accountList) + + accountList.layoutManager = LinearLayoutManager(context) + + pendingStatus?.let { statusText.text = it } + pendingInstruction?.let { instructionText.text = it } + + if (pendingShowPinInput) { + pinInputLayout.visibility = View.VISIBLE + btnContinue.visibility = View.VISIBLE + pinEditText.requestFocus() + } + + applyState(pendingState) + + btnCancel.setOnClickListener { + onCancelClick?.invoke() + } + + btnContinue.setOnClickListener { + submitPin() + } + + pinEditText.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + submitPin() + true + } else { + false + } + } + + (dialog as? BottomSheetDialog)?.behavior?.apply { + state = BottomSheetBehavior.STATE_EXPANDED + skipCollapsed = true + } + } + + override fun onDestroyView() { + stopPulse() + super.onDestroyView() + } + + override fun onCancel(dialog: DialogInterface) { + super.onCancel(dialog) + onCancelClick?.invoke() + } + + private fun submitPin() { + val pin = pinEditText.text?.toString() ?: "" + if (pin.length >= 4) { + pinInputLayout.error = null + onPinEntered?.invoke(pin) + } else { + pinInputLayout.error = getString(R.string.pin_too_short) + } + } + + fun setState(state: State) { + if (::iconStatus.isInitialized) { + applyState(state) + } else { + pendingState = state + } + } + + private fun applyState(state: State) { + stopPulse() + + val iconRes = when (state) { + State.WAITING -> R.drawable.sensors_24 + State.TOUCH -> R.drawable.fingerprint_24 + State.PROCESSING -> R.drawable.key_24 + State.PIN -> R.drawable.lock_24 + State.ACCOUNT_SELECT -> R.drawable.account_circle_24 + State.SUCCESS -> R.drawable.check_circle_24 + State.ERROR -> R.drawable.error_24 + } + + iconStatus.setImageResource(iconRes) + + when (state) { + State.WAITING, State.TOUCH -> startPulse() + else -> {} + } + } + + private fun startPulse() { + pulseAnimator = ObjectAnimator.ofFloat(iconBackground, View.ALPHA, 1f, 0.3f).apply { + duration = 1000 + repeatCount = ObjectAnimator.INFINITE + repeatMode = ObjectAnimator.REVERSE + interpolator = AccelerateDecelerateInterpolator() + start() + } + } + + private fun stopPulse() { + pulseAnimator?.cancel() + pulseAnimator = null + if (::iconBackground.isInitialized) { + iconBackground.alpha = 1f + } + } + + fun setStatus(text: String) { + if (::statusText.isInitialized) { + statusText.text = text + } else { + pendingStatus = text + } + } + + fun setInstruction(text: String) { + if (::instructionText.isInitialized) { + instructionText.text = text + } else { + pendingInstruction = text + } + } + + fun showProgress(show: Boolean) { + if (::progressBar.isInitialized) { + progressBar.visibility = if (show) View.VISIBLE else View.GONE + } + } + + fun showPinInput(show: Boolean) { + if (::pinInputLayout.isInitialized) { + pinInputLayout.visibility = if (show) View.VISIBLE else View.GONE + btnContinue.visibility = if (show) View.VISIBLE else View.GONE + if (show) { + hideAccounts() + pinEditText.text?.clear() + pinInputLayout.error = null + pinEditText.requestFocus() + setState(State.PIN) + } + } else { + pendingShowPinInput = show + if (show) pendingState = State.PIN + } + } + + fun showAccounts(accounts: List) { + if (!::accountList.isInitialized) return + + setState(State.ACCOUNT_SELECT) + pinInputLayout.visibility = View.GONE + btnContinue.visibility = View.GONE + accountList.visibility = View.VISIBLE + accountList.adapter = AccountAdapter(accounts) { index -> + onAccountSelected?.invoke(index) + } + } + + fun hideAccounts() { + if (::accountList.isInitialized) { + accountList.visibility = View.GONE + } + } + + fun setPinError(error: String?) { + if (::pinInputLayout.isInitialized) { + pinInputLayout.error = error + } + } + + fun getCurrentPinIfValid(): String? { + if (!::pinEditText.isInitialized) return null + val pin = pinEditText.text?.toString() ?: return null + return if (pin.length >= 4) pin else null + } + + private class AccountAdapter( + private val accounts: List, + private val onItemClick: (Int) -> Unit + ) : RecyclerView.Adapter() { + + class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + val name: TextView = view.findViewById(R.id.accountName) + val subtitle: TextView = view.findViewById(R.id.accountSubtitle) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_account, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val account = accounts[position] + holder.name.text = account.displayName + if (account.subtitle != null) { + holder.subtitle.text = account.subtitle + holder.subtitle.visibility = View.VISIBLE + } else { + holder.subtitle.visibility = View.GONE + } + holder.itemView.setOnClickListener { + onItemClick(position) + } + } + + override fun getItemCount() = accounts.size + } + + companion object { + const val TAG = "CredentialBottomSheet" + private const val ARG_STATUS = "status" + private const val ARG_INSTRUCTION = "instruction" + + fun newInstance(status: String, instruction: String): CredentialBottomSheet { + return CredentialBottomSheet().apply { + arguments = Bundle().apply { + putString(ARG_STATUS, status) + putString(ARG_INSTRUCTION, instruction) + } + } + } + } +} diff --git a/app/src/main/java/pl/lebihan/authnkey/CredentialManagement.kt b/app/src/main/java/pl/lebihan/authnkey/CredentialManagement.kt new file mode 100644 index 0000000..8bf940a --- /dev/null +++ b/app/src/main/java/pl/lebihan/authnkey/CredentialManagement.kt @@ -0,0 +1,300 @@ +package pl.lebihan.authnkey + +class CredentialManagement( + private val transport: FidoTransport, + private val pinProtocol: PinProtocol, + private val usePreviewCommand: Boolean = false +) { + + private val credMgmtCommand: Byte = if (usePreviewCommand) + CTAP.CMD_CREDENTIAL_MANAGEMENT_PREVIEW.toByte() + else + CTAP.CMD_CREDENTIAL_MANAGEMENT.toByte() + + companion object { + const val CMD_GET_CREDS_METADATA = 0x01 + const val CMD_ENUMERATE_RPS_BEGIN = 0x02 + const val CMD_ENUMERATE_RPS_NEXT = 0x03 + const val CMD_ENUMERATE_CREDS_BEGIN = 0x04 + const val CMD_ENUMERATE_CREDS_NEXT = 0x05 + const val CMD_DELETE_CREDENTIAL = 0x06 + const val CMD_UPDATE_USER_INFO = 0x07 + } + + data class RelyingParty( + val rpIdHash: ByteArray, + val rpId: String?, + val rpName: String?, + val totalCredentials: Int? + ) + + data class Credential( + val credentialId: ByteArray, + val rpId: String?, + val userId: ByteArray?, + val userName: String?, + val userDisplayName: String?, + val publicKey: Map<*, *>?, + val credProtect: Int?, + val largeBlobKey: ByteArray? + ) + + data class CredentialMetadata( + val existingResidentCredentialsCount: Int, + val maxPossibleRemainingCredentials: Int + ) + + suspend fun getCredentialsMetadata(): Result { + if (!pinProtocol.hasPinToken()) { + return Result.failure(Exception("PIN token not available")) + } + + try { + val command = buildCredMgmtCommand(CMD_GET_CREDS_METADATA, null) + val response = transport.sendCtapCommand(command) + + val error = CTAP.getResponseError(response) + if (error != null) { + return Result.failure(Exception(error.name)) + } + + val data = response.drop(1).toByteArray() + val parsed = CborMap.decode(data) + ?: return Result.failure(Exception("Invalid CBOR response")) + + val existing = parsed.int(1) ?: 0 + val remaining = parsed.int(2) ?: 0 + + return Result.success(CredentialMetadata(existing, remaining)) + + } catch (e: Exception) { + return Result.failure(e) + } + } + + suspend fun enumerateRelyingParties(): Result> { + if (!pinProtocol.hasPinToken()) { + return Result.failure(Exception("PIN token not available")) + } + + val relyingParties = mutableListOf() + + try { + val beginCommand = buildCredMgmtCommand(CMD_ENUMERATE_RPS_BEGIN, null) + val beginResponse = transport.sendCtapCommand(beginCommand) + + val error = CTAP.getResponseError(beginResponse) + if (error != null) { + if (error == CTAP.Error.NO_CREDENTIALS) { + return Result.success(emptyList()) + } + return Result.failure(Exception(error.name)) + } + + val firstRp = parseRelyingPartyResponse(beginResponse) + if (firstRp != null) { + relyingParties.add(firstRp.first) + + val totalRps = firstRp.second + for (i in 1 until totalRps) { + val nextCommand = buildCredMgmtCommand(CMD_ENUMERATE_RPS_NEXT, null, includeAuth = false) + val nextResponse = transport.sendCtapCommand(nextCommand) + + if (CTAP.isSuccess(nextResponse)) { + parseRelyingPartyResponse(nextResponse)?.let { (rp, _) -> + relyingParties.add(rp) + } + } + } + } + + return Result.success(relyingParties) + + } catch (e: Exception) { + return Result.failure(e) + } + } + + suspend fun enumerateCredentials(rpIdHash: ByteArray): Result> { + if (!pinProtocol.hasPinToken()) { + return Result.failure(Exception("PIN token not available")) + } + + val credentials = mutableListOf() + + try { + val params = buildRpIdHashParam(rpIdHash) + + val beginCommand = buildCredMgmtCommand(CMD_ENUMERATE_CREDS_BEGIN, params) + val beginResponse = transport.sendCtapCommand(beginCommand) + + val error = CTAP.getResponseError(beginResponse) + if (error != null) { + if (error == CTAP.Error.NO_CREDENTIALS) { + return Result.success(emptyList()) + } + return Result.failure(Exception(error.name)) + } + + val firstCred = parseCredentialResponse(beginResponse) + if (firstCred != null) { + credentials.add(firstCred.first) + + val totalCreds = firstCred.second + for (i in 1 until totalCreds) { + val nextCommand = buildCredMgmtCommand(CMD_ENUMERATE_CREDS_NEXT, null, includeAuth = false) + val nextResponse = transport.sendCtapCommand(nextCommand) + + if (CTAP.isSuccess(nextResponse)) { + parseCredentialResponse(nextResponse)?.let { (cred, _) -> + credentials.add(cred) + } + } + } + } + + return Result.success(credentials) + + } catch (e: Exception) { + return Result.failure(e) + } + } + + suspend fun deleteCredential(credentialId: ByteArray): Result { + if (!pinProtocol.hasPinToken()) { + return Result.failure(Exception("PIN token not available")) + } + + try { + val params = buildCredentialIdParam(credentialId) + val command = buildCredMgmtCommand(CMD_DELETE_CREDENTIAL, params) + val response = transport.sendCtapCommand(command) + + val error = CTAP.getResponseError(response) + if (error != null) { + return Result.failure(Exception(error.name)) + } + + return Result.success(Unit) + + } catch (e: Exception) { + return Result.failure(e) + } + } + + private fun buildCredMgmtCommand( + subCommand: Int, + subCommandParams: ByteArray?, + includeAuth: Boolean = true + ): ByteArray { + val authParam = if (includeAuth) { + val authMessage = mutableListOf() + authMessage.add(subCommand.toByte()) + if (subCommandParams != null) { + authMessage.addAll(subCommandParams.toList()) + } + pinProtocol.computeAuthParam(authMessage.toByteArray()) + ?: throw Exception("Failed to compute auth param") + } else null + + val payload = cbor { + map { + 1 to subCommand + + if (subCommandParams != null) { + 2 to CborRaw(subCommandParams.toList()) + } + + if (includeAuth && authParam != null) { + 3 to 1 + 4 to bytes(authParam) + } + } + } + + return byteArrayOf(credMgmtCommand) + payload + } + + private fun buildRpIdHashParam(rpIdHash: ByteArray): ByteArray { + return cbor { + map { + 1 to bytes(rpIdHash) + } + } + } + + private fun buildCredentialIdParam(credentialId: ByteArray): ByteArray { + return cbor { + map { + 2 to map { + "type" to "public-key" + "id" to bytes(credentialId) + } + } + } + } + + private fun parseRelyingPartyResponse(response: ByteArray): Pair? { + try { + val data = response.drop(1).toByteArray() + val parsed = CborMap.decode(data) ?: return null + + val rp = parsed.map(3) + val rpId = rp?.string("id") + val rpName = rp?.string("name") + + val rpIdHash = parsed.bytes(4) ?: return null + + val totalRps = parsed.int(5) ?: 1 + + return Pair( + RelyingParty(rpIdHash, rpId, rpName, null), + totalRps + ) + } catch (e: Exception) { + e.printStackTrace() + return null + } + } + + private fun parseCredentialResponse(response: ByteArray): Pair? { + try { + val data = response.drop(1).toByteArray() + val parsed = CborMap.decode(data) ?: return null + + val user = parsed.map(6) + val userId = user?.bytes("id") + val userName = user?.string("name") + val userDisplayName = user?.string("displayName") + + val credDesc = parsed.map(7) + val credentialId = credDesc?.bytes("id") ?: return null + + val rawDecoded = CborDecoder.decode(data) as? Map<*, *> + val publicKey = (rawDecoded?.get(8L) ?: rawDecoded?.get(8)) as? Map<*, *> + + val totalCreds = parsed.int(9) ?: 1 + + val credProtect = parsed.int(10) + + val largeBlobKey = parsed.bytes(11) + + return Pair( + Credential( + credentialId = credentialId, + rpId = null, + userId = userId, + userName = userName, + userDisplayName = userDisplayName, + publicKey = publicKey, + credProtect = credProtect, + largeBlobKey = largeBlobKey + ), + totalCreds + ) + } catch (e: Exception) { + e.printStackTrace() + return null + } + } +} diff --git a/app/src/main/java/pl/lebihan/authnkey/CredentialProviderActivity.kt b/app/src/main/java/pl/lebihan/authnkey/CredentialProviderActivity.kt new file mode 100644 index 0000000..63a4006 --- /dev/null +++ b/app/src/main/java/pl/lebihan/authnkey/CredentialProviderActivity.kt @@ -0,0 +1,1086 @@ +package pl.lebihan.authnkey + +import android.app.ActivityOptions +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbManager +import android.nfc.NfcAdapter +import android.nfc.Tag +import android.nfc.tech.IsoDep +import android.os.Build +import android.os.Bundle +import android.util.Base64 +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AppCompatActivity +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.CreatePublicKeyCredentialResponse +import androidx.credentials.GetCredentialResponse +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.PublicKeyCredential +import androidx.credentials.exceptions.CreateCredentialUnknownException +import androidx.credentials.exceptions.GetCredentialUnknownException +import androidx.credentials.provider.CallingAppInfo +import androidx.credentials.provider.PendingIntentHandler +import androidx.credentials.provider.ProviderCreateCredentialRequest +import androidx.credentials.provider.ProviderGetCredentialRequest +import kotlinx.coroutines.* +import kotlinx.coroutines.suspendCancellableCoroutine +import org.json.JSONObject +import java.security.MessageDigest + +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +class CredentialProviderActivity : AppCompatActivity() { + + private var nfcAdapter: NfcAdapter? = null + private lateinit var usbManager: UsbManager + + private var bottomSheet: CredentialBottomSheet? = null + + private var currentTransport: FidoTransport? = null + private var pinProtocol: PinProtocol? = null + private var deviceInfo: DeviceInfo? = null + + private var createRequest: ProviderCreateCredentialRequest? = null + private var getRequest: ProviderGetCredentialRequest? = null + private var callingAppInfo: CallingAppInfo? = null + private var requestJson: String? = null + private var isCreateRequest: Boolean = false + private var pendingPin: String? = null // PIN entered before key connection + private var userVerification: UserVerification = UserVerification.PREFERRED + + private enum class UserVerification { + REQUIRED, + PREFERRED, + DISCOURAGED; + + companion object { + fun fromString(value: String?): UserVerification = when (value) { + "required" -> REQUIRED + "discouraged" -> DISCOURAGED + else -> PREFERRED + } + } + } + + private enum class ResidentKeyRequirement { + REQUIRED, + PREFERRED, + DISCOURAGED; + + companion object { + fun fromString(value: String?): ResidentKeyRequirement = when (value) { + "required" -> REQUIRED + "discouraged" -> DISCOURAGED + else -> PREFERRED + } + } + + fun requiresResidentKey(): Boolean = this != DISCOURAGED + } + + private val scope = CoroutineScope(Dispatchers.Main + Job()) + + private val usbPermissionReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == ACTION_USB_PERMISSION) { + val device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(UsbManager.EXTRA_DEVICE) + } + val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false) + + if (granted && device != null) { + connectToUsbDevice(device) + } else { + setInstruction(getString(R.string.instruction_usb_permission_denied)) + } + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + nfcAdapter = NfcAdapter.getDefaultAdapter(this) + usbManager = getSystemService(USB_SERVICE) as UsbManager + + // Register USB permission receiver + val filter = IntentFilter(ACTION_USB_PERMISSION) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(usbPermissionReceiver, filter, RECEIVER_NOT_EXPORTED) + } else { + registerReceiver(usbPermissionReceiver, filter) + } + + // Use PendingIntentHandler to retrieve the proper request objects + // This is the correct way per Android documentation + createRequest = PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent) + getRequest = PendingIntentHandler.retrieveProviderGetCredentialRequest(intent) + + when { + createRequest != null -> { + isCreateRequest = true + callingAppInfo = createRequest!!.callingAppInfo + val publicKeyRequest = createRequest!!.callingRequest as? CreatePublicKeyCredentialRequest + requestJson = publicKeyRequest?.requestJson + showBottomSheet(getString(R.string.create_passkey), getString(R.string.instruction_connect_key)) + } + getRequest != null -> { + isCreateRequest = false + callingAppInfo = getRequest!!.callingAppInfo + val options = getRequest!!.credentialOptions + val publicKeyOption = options.firstOrNull { it is GetPublicKeyCredentialOption } as? GetPublicKeyCredentialOption + requestJson = publicKeyOption?.requestJson + showBottomSheet(getString(R.string.sign_in), getString(R.string.instruction_connect_key)) + } + else -> { + Log.e(TAG, "No valid request found in intent") + cancelOperation() + return + } + } + + if (requestJson == null) { + Log.e(TAG, "No request JSON") + cancelOperation() + return + } + + // Check if PIN is likely required based on userVerification preference + checkPinRequirement() + } + + private fun showBottomSheet(status: String, instruction: String) { + bottomSheet = CredentialBottomSheet.newInstance(status, instruction).apply { + onCancelClick = { cancelOperation() } + onPinEntered = { pin -> handlePinEntered(pin) } + } + bottomSheet?.show(supportFragmentManager, CredentialBottomSheet.TAG) + bottomSheet?.setState(CredentialBottomSheet.State.WAITING) + } + + private fun handlePinEntered(pin: String) { + if (currentTransport?.isConnected == true) { + val json = JSONObject(requestJson!!) + showProgress(true) + setInstruction(getString(R.string.instruction_verifying)) + setState(CredentialBottomSheet.State.PROCESSING) + bottomSheet?.showPinInput(false) + authenticateAndExecute(pin, json) + } else { + pendingPin = pin + setInstruction(getString(R.string.instruction_connect_key)) + setState(CredentialBottomSheet.State.WAITING) + bottomSheet?.showPinInput(false) + } + } + + private fun setStatus(text: String) { + bottomSheet?.setStatus(text) + } + + private fun setInstruction(text: String) { + bottomSheet?.setInstruction(text) + } + + private fun showProgress(show: Boolean) { + bottomSheet?.showProgress(show) + } + + private fun setState(state: CredentialBottomSheet.State) { + bottomSheet?.setState(state) + } + + private fun checkPinRequirement() { + try { + val json = JSONObject(requestJson!!) + + // Check userVerification in authenticatorSelection (create) or directly (get) + val uvString = if (isCreateRequest) { + json.optJSONObject("authenticatorSelection")?.optString("userVerification", "preferred") + } else { + json.optString("userVerification", "preferred") + } + userVerification = UserVerification.fromString(uvString) + + // Check if allowCredentials is empty (discoverable credential flow needs PIN) + val allowCredentialsEmpty = if (!isCreateRequest) { + !json.has("allowCredentials") || json.getJSONArray("allowCredentials").length() == 0 + } else false + + // For required/preferred, or discoverable flow, ask for PIN upfront to minimize NFC taps + if (userVerification != UserVerification.DISCOURAGED || allowCredentialsEmpty) { + showPinDialogFirst() + } + // If discouraged with allowCredentials, just wait for key connection + + } catch (e: Exception) { + Log.e(TAG, "Error checking PIN requirement", e) + // Assume preferred + userVerification = UserVerification.PREFERRED + showPinDialogFirst() + } + } + + private fun showPinDialogFirst() { + setInstruction(getString(R.string.instruction_enter_pin)) + setState(CredentialBottomSheet.State.PIN) + bottomSheet?.showPinInput(true) + } + + override fun onResume() { + super.onResume() + + nfcAdapter?.let { adapter -> + val intent = Intent(this, javaClass).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + + val pendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val options = ActivityOptions.makeBasic().apply { + pendingIntentCreatorBackgroundActivityStartMode = + ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED + } + PendingIntent.getActivity( + this, 0, intent, + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + options.toBundle() + ) + } else { + PendingIntent.getActivity( + this, 0, intent, + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + } + + val filters = arrayOf(IntentFilter(NfcAdapter.ACTION_TECH_DISCOVERED)) + val techLists = arrayOf(arrayOf(IsoDep::class.java.name)) + adapter.enableForegroundDispatch(this, pendingIntent, filters, techLists) + } + + if (currentTransport == null) { + checkForUsbDevice() + } + } + + override fun onPause() { + super.onPause() + nfcAdapter?.disableForegroundDispatch(this) + } + + override fun onDestroy() { + super.onDestroy() + try { + unregisterReceiver(usbPermissionReceiver) + } catch (e: Exception) { + // Ignore + } + scope.cancel() + currentTransport?.close() + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + + if (intent.action == NfcAdapter.ACTION_TECH_DISCOVERED) { + val tag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(NfcAdapter.EXTRA_TAG) + } + tag?.let { handleNfcTag(it) } + } + } + + private fun checkForUsbDevice() { + val devices = usbManager.deviceList.values.filter { UsbTransport.isFidoDevice(it) } + if (devices.isNotEmpty()) { + val device = devices.first() + if (usbManager.hasPermission(device)) { + connectToUsbDevice(device) + } else { + requestUsbPermission(device) + } + } + } + + private fun requestUsbPermission(device: UsbDevice) { + val intent = Intent(ACTION_USB_PERMISSION).apply { + setPackage(packageName) + } + val permissionIntent = PendingIntent.getBroadcast( + this, 0, + intent, + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + usbManager.requestPermission(device, permissionIntent) + } + + private fun handleNfcTag(tag: Tag) { + scope.launch { + try { + currentTransport?.close() + + // Capture any valid PIN and hide input + bottomSheet?.getCurrentPinIfValid()?.let { pendingPin = it } + bottomSheet?.showPinInput(false) + + val isoDep = IsoDep.get(tag) ?: throw Exception("Not an ISO-DEP tag") + val transport = NfcTransport(isoDep) + + if (!transport.selectFidoApplet()) { + throw Exception("Failed to select FIDO applet") + } + + currentTransport = transport + pinProtocol = PinProtocol(transport) + + setInstruction(getString(R.string.instruction_key_connected)) + setState(CredentialBottomSheet.State.PROCESSING) + showProgress(true) + + processRequest() + + } catch (e: Exception) { + Log.e(TAG, "NFC error", e) + setInstruction(getString(R.string.error_retry_format, e.message ?: "Unknown error")) + setState(CredentialBottomSheet.State.ERROR) + showProgress(false) + } + } + } + + private fun connectToUsbDevice(device: UsbDevice) { + scope.launch { + try { + currentTransport?.close() + + // Capture any valid PIN and hide input + bottomSheet?.getCurrentPinIfValid()?.let { pendingPin = it } + bottomSheet?.showPinInput(false) + + setInstruction(getString(R.string.instruction_connecting_usb)) + setState(CredentialBottomSheet.State.PROCESSING) + + val transport = withContext(Dispatchers.IO) { + UsbTransport.create(usbManager, device) + } ?: throw Exception("Failed to initialize USB connection") + + currentTransport = transport + pinProtocol = PinProtocol(transport) + + setInstruction(getString(R.string.instruction_key_connected)) + showProgress(true) + + processRequest() + + } catch (e: Exception) { + Log.e(TAG, "USB error", e) + setInstruction(getString(R.string.error_retry_format, e.message ?: "Unknown error")) + setState(CredentialBottomSheet.State.ERROR) + showProgress(false) + } + } + } + + private fun processRequest() { + scope.launch { + try { + val json = JSONObject(requestJson!!) + val transport = currentTransport ?: throw Exception("No transport") + val protocol = pinProtocol ?: throw Exception("No PIN protocol") + + // Get device info to check PIN requirements and CTAP version + val infoResponse = withContext(Dispatchers.IO) { + transport.sendCtapCommand(CTAP.buildCommand(CTAP.CMD_GET_INFO)) + } + deviceInfo = CTAP.parseGetInfoStructured(infoResponse) + + // Check if clientPin is actually set on the device + val deviceHasPin = deviceInfo?.options?.get("clientPin") == true + val alwaysUv = deviceInfo?.options?.get("alwaysUv") == true + + when { + // We already have PIN from pre-prompt + deviceHasPin && pendingPin != null -> { + setInstruction(getString(R.string.instruction_authenticating)) + authenticateAndExecute(pendingPin!!, json) + } + // UV required but device has no PIN - fail + userVerification == UserVerification.REQUIRED && !deviceHasPin -> { + throw Exception(getString(R.string.error_uv_required_no_pin)) + } + // UV required/preferred and device has PIN - need to get PIN + userVerification != UserVerification.DISCOURAGED && deviceHasPin -> { + val retries = withContext(Dispatchers.IO) { protocol.getPinRetries() } ?: 8 + showPinDialog(retries, json) + } + // UV discouraged but device has alwaysUv - need PIN anyway + userVerification == UserVerification.DISCOURAGED && alwaysUv && deviceHasPin -> { + val retries = withContext(Dispatchers.IO) { protocol.getPinRetries() } ?: 8 + showPinDialog(retries, json) + } + // UV discouraged or preferred with no PIN - try without + else -> { + tryExecuteWithoutPin(json) + } + } + + } catch (e: Exception) { + Log.e(TAG, "Error processing request", e) + handleError(e) + } + } + } + + private fun tryExecuteWithoutPin(json: JSONObject) { + scope.launch { + try { + executeRequest(json, null) + } catch (e: Exception) { + // Check if authenticator requires PIN despite UV=discouraged + val errorMsg = e.message ?: "" + if (errorMsg.contains("PIN_REQUIRED") || + errorMsg.contains("PIN_AUTH_INVALID") || + errorMsg.contains("0x36") || // CTAP2_ERR_PUAT_REQUIRED + errorMsg.contains("0x44")) { // CTAP2_ERR_PIN_REQUIRED + // Authenticator requires PIN, ask user + Log.d(TAG, "Authenticator requires PIN despite UV=discouraged") + val protocol = pinProtocol ?: throw Exception("No PIN protocol") + val retries = withContext(Dispatchers.IO) { protocol.getPinRetries() } ?: 8 + showPinDialog(retries, json) + } else { + throw e + } + } + } + } + + private fun showPinDialog(retries: Int, requestJson: JSONObject) { + runOnUiThread { + showProgress(false) + bottomSheet?.hideAccounts() + setInstruction(getString(R.string.pin_retries_remaining, retries)) + setState(CredentialBottomSheet.State.PIN) + bottomSheet?.showPinInput(true) + } + } + + private fun authenticateAndExecute(pin: String, requestJson: JSONObject) { + scope.launch { + try { + val protocol = pinProtocol ?: throw Exception("PIN protocol not initialized") + + setInstruction(getString(R.string.instruction_initializing)) + val initialized = withContext(Dispatchers.IO) { protocol.initialize() } + if (!initialized) { + throw Exception("Failed to initialize PIN protocol") + } + + // Determine permissions and rpId based on operation type + 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") + } + + setInstruction(getString(R.string.instruction_verifying_pin)) + // Try CTAP2.1 style with permissions first + val authenticated = withContext(Dispatchers.IO) { + protocol.getPinToken(pin, permissions, rpId) + } + if (!authenticated) { + val retries = withContext(Dispatchers.IO) { protocol.getPinRetries() } ?: 0 + if (retries > 0) { + runOnUiThread { + showProgress(false) + setInstruction(getString(R.string.pin_invalid_retries, retries)) + setState(CredentialBottomSheet.State.PIN) + bottomSheet?.showPinInput(true) + } + } else { + throw Exception(getString(R.string.error_pin_blocked)) + } + return@launch + } + + executeRequest(requestJson, protocol) + + } catch (e: Exception) { + Log.e(TAG, "Authentication error", e) + handleError(e) + } + } + } + + private suspend fun executeRequest(requestJson: JSONObject, pinProtocol: PinProtocol?) { + try { + val transport = currentTransport ?: throw Exception("No transport") + + if (isCreateRequest) { + executeCreateCredential(transport, requestJson, pinProtocol) + } else { + executeGetAssertion(transport, requestJson, pinProtocol) + } + + } catch (e: Exception) { + Log.e(TAG, "Execute error", e) + handleError(e) + } + } + + private suspend fun executeCreateCredential( + transport: FidoTransport, + requestJson: JSONObject, + pinProtocol: PinProtocol? + ) { + setInstruction(getString(R.string.instruction_creating)) + + // Parse request + val rp = requestJson.getJSONObject("rp") + val rpId = rp.getString("id") + val rpName = rp.optString("name", rpId) + + val user = requestJson.getJSONObject("user") + val userId = Base64.decode(user.getString("id"), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) + val userName = user.optString("name", "") + val userDisplayName = user.optString("displayName", userName) + + val challenge = Base64.decode( + requestJson.getString("challenge"), + Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP + ) + + val pubKeyCredParams = mutableListOf>() + val paramsArray = requestJson.getJSONArray("pubKeyCredParams") + for (i in 0 until paramsArray.length()) { + val param = paramsArray.getJSONObject(i) + pubKeyCredParams.add(Pair(param.getString("type"), param.getInt("alg"))) + } + + // Parse excludeCredentials if present + val excludeList = mutableListOf() + if (requestJson.has("excludeCredentials")) { + val excludeArray = requestJson.getJSONArray("excludeCredentials") + for (i in 0 until excludeArray.length()) { + val cred = excludeArray.getJSONObject(i) + val id = Base64.decode(cred.getString("id"), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) + excludeList.add(id) + } + } + + // Parse authenticatorSelection for residentKey requirement + val authSelection = requestJson.optJSONObject("authenticatorSelection") + val residentKey = ResidentKeyRequirement.fromString( + authSelection?.optString("residentKey", "preferred") + ) + + // Build clientDataJSON with proper origin + val origin = computeOrigin() + + val clientDataJson = JSONObject().apply { + put("type", "webauthn.create") + put("challenge", requestJson.getString("challenge")) + put("origin", origin) + put("crossOrigin", false) + }.toString().replace("\\/", "/") // Android JSONObject escapes slashes + + val clientDataHash = FidoCommands.hashClientData(clientDataJson) + + // Compute pinUvAuthParam if needed + var pinUvAuthParam: ByteArray? = null + if (pinProtocol != null) { + pinUvAuthParam = pinProtocol.computeAuthParam(clientDataHash) + } + + // Build and send command + val command = FidoCommands.buildMakeCredential( + clientDataHash = clientDataHash, + rpId = rpId, + rpName = rpName, + userId = userId, + userName = userName, + userDisplayName = userDisplayName, + pubKeyCredParams = pubKeyCredParams, + excludeList = if (excludeList.isNotEmpty()) excludeList else null, + requireResidentKey = residentKey.requiresResidentKey(), + requireUserVerification = false, // UV is provided by pinUvAuthParam + pinUvAuthParam = pinUvAuthParam, + pinUvAuthProtocol = if (pinProtocol != null) 1 else null + ) + + runOnUiThread { + setInstruction(getString(R.string.instruction_touch_key)) + if (transport.transportType == TransportType.USB) { + setState(CredentialBottomSheet.State.TOUCH) + } + } + + val response = withContext(Dispatchers.IO) { + transport.sendCtapCommand(command) + } + + val result = FidoCommands.parseMakeCredentialResponse(response) + val makeCredResult = result.getOrElse { throw it } + + // Build attestation object (CBOR encoded) + val attestationObject = buildAttestationObject( + makeCredResult.fmt, + makeCredResult.authData, + makeCredResult.attStmt + ) + + // Extract credential ID from authData + val credentialId = extractCredentialIdFromAuthData(makeCredResult.authData) + + // 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 + + // Build response JSON + val responseJson = JSONObject().apply { + put("id", Base64.encodeToString(credentialId, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)) + put("rawId", Base64.encodeToString(credentialId, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)) + put("type", "public-key") + put("authenticatorAttachment", "cross-platform") + put("response", JSONObject().apply { + put("clientDataJSON", Base64.encodeToString( + clientDataJson.toByteArray(), + Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP + )) + put("attestationObject", Base64.encodeToString( + attestationObject, + Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP + )) + // Add transports array based on current transport + put("transports", org.json.JSONArray().apply { + put(transport.transportType.webauthnName) + }) + }) + // Add clientExtensionResults if credProps was requested + if (credPropsRequested) { + put("clientExtensionResults", JSONObject().apply { + put("credProps", JSONObject().apply { + put("rk", isDiscoverable) + }) + }) + } + } + + returnCreateResult(responseJson.toString()) + } + + private suspend fun executeGetAssertion( + transport: FidoTransport, + requestJson: JSONObject, + pinProtocol: PinProtocol? + ) { + setInstruction(getString(R.string.instruction_signing_in)) + + // Parse request + val rpId = requestJson.getString("rpId") + val challenge = Base64.decode( + requestJson.getString("challenge"), + Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP + ) + + // Parse allowCredentials if present + val allowList = mutableListOf() + if (requestJson.has("allowCredentials")) { + val allowArray = requestJson.getJSONArray("allowCredentials") + for (i in 0 until allowArray.length()) { + val cred = allowArray.getJSONObject(i) + val id = Base64.decode(cred.getString("id"), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) + allowList.add(id) + } + } + + // Build clientDataJSON with proper origin + val origin = computeOrigin() + val clientDataJson = JSONObject().apply { + put("type", "webauthn.get") + put("challenge", requestJson.getString("challenge")) + put("origin", origin) + put("crossOrigin", false) + }.toString().replace("\\/", "/") // Android JSONObject escapes slashes + + val clientDataHash = FidoCommands.hashClientData(clientDataJson) + + // Compute pinUvAuthParam if needed + var pinUvAuthParam: ByteArray? = null + if (pinProtocol != null) { + pinUvAuthParam = pinProtocol.computeAuthParam(clientDataHash) + } + + // Build and send command + val command = FidoCommands.buildGetAssertion( + rpId = rpId, + clientDataHash = clientDataHash, + allowList = if (allowList.isNotEmpty()) allowList else null, + requireUserVerification = false, // UV is provided by pinUvAuthParam + pinUvAuthParam = pinUvAuthParam, + pinUvAuthProtocol = if (pinProtocol != null) 1 else null + ) + + runOnUiThread { + setInstruction(getString(R.string.instruction_touch_key)) + if (transport.transportType == TransportType.USB) { + setState(CredentialBottomSheet.State.TOUCH) + } + } + + val response = withContext(Dispatchers.IO) { + transport.sendCtapCommand(command) + } + + val result = FidoCommands.parseGetAssertionResponse(response) + val firstAssertion = result.getOrElse { throw it } + + // Check if there are multiple credentials + val numCredentials = firstAssertion.numberOfCredentials ?: 1 + val selectedAssertion = if (numCredentials > 1) { + // Collect all assertions while key is still connected + val assertions = mutableListOf(firstAssertion) + repeat(numCredentials - 1) { + val nextResponse = withContext(Dispatchers.IO) { + transport.sendCtapCommand(FidoCommands.buildGetNextAssertion()) + } + val nextResult = FidoCommands.parseGetAssertionResponse(nextResponse) + nextResult.getOrNull()?.let { assertions.add(it) } + } + + // Show picker and wait for selection + showCredentialPicker(assertions) + } else { + firstAssertion + } + + // Get credential ID from response or allowList + val credentialId = selectedAssertion.credential?.id + ?: if (allowList.isNotEmpty()) allowList[0] else ByteArray(0) + + // Build response JSON + val responseJson = JSONObject().apply { + put("id", Base64.encodeToString(credentialId, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)) + put("rawId", Base64.encodeToString(credentialId, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)) + put("type", "public-key") + put("authenticatorAttachment", "cross-platform") + put("response", JSONObject().apply { + put("clientDataJSON", Base64.encodeToString( + clientDataJson.toByteArray(), + Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP + )) + put("authenticatorData", Base64.encodeToString( + selectedAssertion.authData, + Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP + )) + put("signature", Base64.encodeToString( + selectedAssertion.signature, + Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP + )) + selectedAssertion.user?.id?.let { userId -> + put("userHandle", Base64.encodeToString( + userId, + Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP + )) + } + }) + } + + returnGetResult(responseJson.toString()) + } + + private suspend fun showCredentialPicker( + assertions: List + ): FidoCommands.GetAssertionResponse { + return suspendCancellableCoroutine { continuation -> + runOnUiThread { + val accounts = assertions.map { assertion -> + val displayName = assertion.user?.displayName + ?: assertion.user?.name + ?: getString(R.string.unknown_account) + val subtitle = if (assertion.user?.displayName != null && assertion.user.name != null) { + assertion.user.name + } else null + CredentialBottomSheet.AccountInfo(displayName, subtitle) + } + + setStatus(getString(R.string.choose_account)) + setInstruction("") + showProgress(false) + + bottomSheet?.onAccountSelected = { index -> + bottomSheet?.hideAccounts() + continuation.resume(assertions[index]) {} + } + bottomSheet?.showAccounts(accounts) + } + } + } + + private fun buildAttestationObject( + fmt: String, + authData: ByteArray, + attStmt: Map<*, *> + ): ByteArray { + // Re-encode as CBOR + val output = mutableListOf() + output.add(0xA3.toByte()) // map of 3 items + + // "fmt" + output.add(0x63) // text string of 3 chars + output.addAll("fmt".toByteArray().toList()) + val fmtBytes = fmt.toByteArray() + if (fmtBytes.size < 24) { + output.add((0x60 + fmtBytes.size).toByte()) + } else { + output.add(0x78.toByte()) + output.add(fmtBytes.size.toByte()) + } + output.addAll(fmtBytes.toList()) + + // "authData" + output.add(0x68) // text string of 8 chars + output.addAll("authData".toByteArray().toList()) + if (authData.size < 24) { + output.add((0x40 + authData.size).toByte()) + } else if (authData.size < 256) { + output.add(0x58.toByte()) + output.add(authData.size.toByte()) + } else { + output.add(0x59.toByte()) + output.add((authData.size shr 8).toByte()) + output.add((authData.size and 0xFF).toByte()) + } + output.addAll(authData.toList()) + + // "attStmt" + output.add(0x67) // text string of 7 chars + output.addAll("attStmt".toByteArray().toList()) + if (attStmt.isEmpty()) { + output.add(0xA0.toByte()) // empty map + } else { + output.addAll(encodeAttStmt(attStmt)) + } + + return output.toByteArray() + } + + private fun encodeAttStmt(attStmt: Map<*, *>): List { + val output = mutableListOf() + + // Count items + val items = attStmt.size + if (items < 24) { + output.add((0xA0 + items).toByte()) + } else { + output.add(0xB8.toByte()) + output.add(items.toByte()) + } + + for ((key, value) in attStmt) { + // Encode key (should be string) + val keyStr = key.toString() + val keyBytes = keyStr.toByteArray() + if (keyBytes.size < 24) { + output.add((0x60 + keyBytes.size).toByte()) + } else { + output.add(0x78.toByte()) + output.add(keyBytes.size.toByte()) + } + output.addAll(keyBytes.toList()) + + // Encode value + when (value) { + is ByteArray -> { + if (value.size < 24) { + output.add((0x40 + value.size).toByte()) + } else if (value.size < 256) { + output.add(0x58.toByte()) + output.add(value.size.toByte()) + } else { + output.add(0x59.toByte()) + output.add((value.size shr 8).toByte()) + output.add((value.size and 0xFF).toByte()) + } + output.addAll(value.toList()) + } + is List<*> -> { + // Array of byte arrays (e.g., x5c) + output.add((0x80 + value.size).toByte()) + for (item in value) { + if (item is ByteArray) { + if (item.size < 24) { + output.add((0x40 + item.size).toByte()) + } else if (item.size < 256) { + output.add(0x58.toByte()) + output.add(item.size.toByte()) + } else { + output.add(0x59.toByte()) + output.add((item.size shr 8).toByte()) + output.add((item.size and 0xFF).toByte()) + } + output.addAll(item.toList()) + } + } + } + is Number -> { + val intVal = value.toInt() + if (intVal >= 0 && intVal < 24) { + output.add(intVal.toByte()) + } else if (intVal >= 0 && intVal < 256) { + output.add(0x18.toByte()) + output.add(intVal.toByte()) + } else if (intVal < 0) { + val encoded = -1 - intVal + if (encoded < 24) { + output.add((0x20 + encoded).toByte()) + } else { + output.add(0x38.toByte()) + output.add(encoded.toByte()) + } + } + } + else -> { + // Skip unknown types + output.add(0xF6.toByte()) // null + } + } + } + + return output + } + + private fun extractCredentialIdFromAuthData(authData: ByteArray): ByteArray { + // authData structure: + // rpIdHash (32 bytes) + flags (1 byte) + signCount (4 bytes) + attestedCredentialData + // attestedCredentialData: aaguid (16 bytes) + credentialIdLength (2 bytes) + credentialId + publicKey + + if (authData.size < 55) { + return ByteArray(0) + } + + val flags = authData[32].toInt() and 0xFF + val hasAttestedCredData = (flags and 0x40) != 0 + + if (!hasAttestedCredData) { + return ByteArray(0) + } + + // Skip rpIdHash (32) + flags (1) + signCount (4) + aaguid (16) + val credIdLengthOffset = 32 + 1 + 4 + 16 + val credIdLength = ((authData[credIdLengthOffset].toInt() and 0xFF) shl 8) or + (authData[credIdLengthOffset + 1].toInt() and 0xFF) + + val credIdOffset = credIdLengthOffset + 2 + return authData.sliceArray(credIdOffset until credIdOffset + credIdLength) + } + + private fun returnCreateResult(responseJson: String) { + val response = CreatePublicKeyCredentialResponse(responseJson) + val resultData = Intent() + PendingIntentHandler.setCreateCredentialResponse(resultData, response) + setResult(RESULT_OK, resultData) + finish() + } + + private fun returnGetResult(responseJson: String) { + val credential = PublicKeyCredential(responseJson) + val response = GetCredentialResponse(credential) + val resultData = Intent() + PendingIntentHandler.setGetCredentialResponse(resultData, response) + setResult(RESULT_OK, resultData) + finish() + } + + private fun handleError(e: Exception) { + runOnUiThread { + showProgress(false) + setInstruction(getString(R.string.error_format, e.message ?: "Unknown error")) + setState(CredentialBottomSheet.State.ERROR) + } + } + + private fun cancelOperation() { + if (isCreateRequest) { + val resultData = Intent() + PendingIntentHandler.setCreateCredentialException( + resultData, + CreateCredentialUnknownException("User cancelled") + ) + setResult(RESULT_CANCELED, resultData) + } else { + val resultData = Intent() + PendingIntentHandler.setGetCredentialException( + resultData, + GetCredentialUnknownException("User cancelled") + ) + setResult(RESULT_CANCELED, resultData) + } + finish() + } + + /** + * Compute the origin for clientDataJSON. + * For privileged apps (browsers), use their provided origin via the allowlist. + * For regular Android apps, compute from the signing certificate. + */ + private fun computeOrigin(): String { + val appInfo = callingAppInfo ?: return "android:apk-key-hash:unknown" + + // Try to get origin using the privileged apps allowlist (for browsers) + try { + val allowlist = loadPrivilegedAllowlist() + if (allowlist != null) { + val origin = appInfo.getOrigin(allowlist) + if (origin != null) { + return origin + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to get privileged origin", e) + } + + // For regular Android apps, compute origin from signing certificate + return try { + val signingInfo = appInfo.signingInfo + val cert = signingInfo.apkContentsSigners[0].toByteArray() + val md = MessageDigest.getInstance("SHA-256") + val certHash = md.digest(cert) + "android:apk-key-hash:${Base64.encodeToString(certHash, Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE)}" + } catch (e: Exception) { + Log.e(TAG, "Failed to compute origin", e) + "android:apk-key-hash:${appInfo.packageName}" + } + } + + private fun loadPrivilegedAllowlist(): String? { + return try { + resources.openRawResource(R.raw.privileged_apps).bufferedReader().use { it.readText() } + } catch (e: Exception) { + Log.e(TAG, "Failed to load privileged apps allowlist", e) + null + } + } + + companion object { + private const val TAG = "CredProviderActivity" + private const val ACTION_USB_PERMISSION = "pl.lebihan.authnkey.CRED_USB_PERMISSION" + } +} diff --git a/app/src/main/java/pl/lebihan/authnkey/FidoCommands.kt b/app/src/main/java/pl/lebihan/authnkey/FidoCommands.kt new file mode 100644 index 0000000..807b6ad --- /dev/null +++ b/app/src/main/java/pl/lebihan/authnkey/FidoCommands.kt @@ -0,0 +1,226 @@ +package pl.lebihan.authnkey + +import java.security.MessageDigest + +object FidoCommands { + + fun buildMakeCredential( + clientDataHash: ByteArray, + rpId: String, + rpName: String?, + userId: ByteArray, + userName: String?, + userDisplayName: String?, + pubKeyCredParams: List>, + excludeList: List? = null, + requireResidentKey: Boolean = true, + requireUserVerification: Boolean = true, + pinUvAuthParam: ByteArray? = null, + pinUvAuthProtocol: Int? = null + ): ByteArray { + val payload = cbor { + map { + 1 to bytes(clientDataHash) + + 2 to map { + "id" to rpId + if (rpName != null) "name" to rpName + } + + 3 to map { + "id" to bytes(userId) + if (userName != null) "name" to userName + if (userDisplayName != null) "displayName" to userDisplayName + } + + 4 to array { + for ((type, alg) in pubKeyCredParams) { + map { + "type" to type + "alg" to alg + } + } + } + + if (excludeList != null && excludeList.isNotEmpty()) { + 5 to array { + for (credId in excludeList) { + map { + "type" to "public-key" + "id" to bytes(credId) + } + } + } + } + + 7 to map { "rk" to requireResidentKey } + + if (pinUvAuthParam != null) { + 8 to bytes(pinUvAuthParam) + } + + if (pinUvAuthProtocol != null) { + 9 to pinUvAuthProtocol + } + } + } + + return byteArrayOf(CTAP.CMD_MAKE_CREDENTIAL.toByte()) + payload + } + + fun buildGetAssertion( + rpId: String, + clientDataHash: ByteArray, + allowList: List? = null, + requireUserVerification: Boolean = true, + pinUvAuthParam: ByteArray? = null, + pinUvAuthProtocol: Int? = null + ): ByteArray { + val payload = cbor { + map { + 1 to rpId + 2 to bytes(clientDataHash) + + if (allowList != null && allowList.isNotEmpty()) { + 3 to array { + for (credId in allowList) { + map { + "type" to "public-key" + "id" to bytes(credId) + } + } + } + } + + 5 to map { "up" to true } + + if (pinUvAuthParam != null) { + 6 to bytes(pinUvAuthParam) + } + + if (pinUvAuthProtocol != null) { + 7 to pinUvAuthProtocol + } + } + } + + return byteArrayOf(CTAP.CMD_GET_ASSERTION.toByte()) + payload + } + + fun buildGetNextAssertion(): ByteArray { + return byteArrayOf(CTAP.CMD_GET_NEXT_ASSERTION.toByte()) + } + + data class MakeCredentialResponse( + val fmt: String, + val authData: ByteArray, + val attStmt: Map<*, *>, + val rawResponse: ByteArray + ) + + fun parseMakeCredentialResponse(response: ByteArray): Result { + val error = CTAP.getResponseError(response) + if (error != null) { + return Result.failure(Exception(error.name)) + } + + return try { + val data = response.drop(1).toByteArray() + val parsed = CborMap.decode(data) + ?: return Result.failure(Exception("Invalid CBOR")) + + val fmt = parsed.string(1) + ?: return Result.failure(Exception("Missing fmt")) + val authData = parsed.bytes(2) + ?: return Result.failure(Exception("Missing authData")) + + val rawDecoded = CborDecoder.decode(data) as? Map<*, *> + ?: return Result.failure(Exception("Invalid CBOR")) + val attStmt = (rawDecoded[3L] ?: rawDecoded[3]) as? Map<*, *> + ?: return Result.failure(Exception("Missing attStmt")) + + Result.success(MakeCredentialResponse(fmt, authData, attStmt, data)) + } catch (e: Exception) { + Result.failure(e) + } + } + + data class GetAssertionResponse( + val credential: CredentialDescriptor?, + val authData: ByteArray, + val signature: ByteArray, + val user: UserEntity?, + val numberOfCredentials: Int?, + val rawResponse: ByteArray + ) + + data class CredentialDescriptor( + val type: String, + val id: ByteArray + ) + + data class UserEntity( + val id: ByteArray, + val name: String?, + val displayName: String? + ) + + fun parseGetAssertionResponse(response: ByteArray): Result { + val error = CTAP.getResponseError(response) + if (error != null) { + return Result.failure(Exception(error.name)) + } + + return try { + val data = response.drop(1).toByteArray() + val parsed = CborMap.decode(data) + ?: return Result.failure(Exception("Invalid CBOR")) + + val credentialMap = parsed.map(1) + val credential = credentialMap?.let { + CredentialDescriptor( + type = it.string("type") ?: "public-key", + id = it.bytes("id") ?: ByteArray(0) + ) + } + + val authData = parsed.bytes(2) + ?: return Result.failure(Exception("Missing authData")) + + val signature = parsed.bytes(3) + ?: return Result.failure(Exception("Missing signature")) + + val userMap = parsed.map(4) + val user = userMap?.let { + UserEntity( + id = it.bytes("id") ?: ByteArray(0), + name = it.string("name"), + displayName = it.string("displayName") + ) + } + + val numberOfCredentials = parsed.int(5) + + Result.success(GetAssertionResponse( + credential = credential, + authData = authData, + signature = signature, + user = user, + numberOfCredentials = numberOfCredentials, + rawResponse = data + )) + } catch (e: Exception) { + Result.failure(e) + } + } + + fun hashClientData(clientDataJson: String): ByteArray { + val digest = MessageDigest.getInstance("SHA-256") + return digest.digest(clientDataJson.toByteArray(Charsets.UTF_8)) + } + + fun hashRpId(rpId: String): ByteArray { + val digest = MessageDigest.getInstance("SHA-256") + return digest.digest(rpId.toByteArray(Charsets.UTF_8)) + } +} diff --git a/app/src/main/java/pl/lebihan/authnkey/FidoTransport.kt b/app/src/main/java/pl/lebihan/authnkey/FidoTransport.kt new file mode 100644 index 0000000..221be46 --- /dev/null +++ b/app/src/main/java/pl/lebihan/authnkey/FidoTransport.kt @@ -0,0 +1,22 @@ +package pl.lebihan.authnkey + +/** + * Transport type for FIDO authenticators + */ +enum class TransportType(val webauthnName: String) { + USB("usb"), + NFC("nfc") +} + +/** + * Common interface for FIDO transport (NFC or USB) + */ +interface FidoTransport { + val transportType: TransportType + val isConnected: Boolean + + @Throws(Exception::class) + suspend fun sendCtapCommand(command: ByteArray): ByteArray + + fun close() +} diff --git a/app/src/main/java/pl/lebihan/authnkey/MainActivity.kt b/app/src/main/java/pl/lebihan/authnkey/MainActivity.kt new file mode 100644 index 0000000..0e51bd4 --- /dev/null +++ b/app/src/main/java/pl/lebihan/authnkey/MainActivity.kt @@ -0,0 +1,817 @@ +package pl.lebihan.authnkey + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbManager +import android.nfc.NfcAdapter +import android.nfc.Tag +import android.nfc.tech.IsoDep +import android.os.Build +import android.os.Bundle +import android.provider.Settings +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import kotlinx.coroutines.* + +class MainActivity : AppCompatActivity() { + + private var nfcAdapter: NfcAdapter? = null + private lateinit var usbManager: UsbManager + + private lateinit var statusText: TextView + private lateinit var connectionType: TextView + private lateinit var resultText: TextView + private lateinit var btnScanUsb: Button + private lateinit var btnDeviceInfo: Button + private lateinit var btnListCredentials: Button + private lateinit var btnChangePin: Button + private lateinit var providerStatusContainer: LinearLayout + private lateinit var providerStatusText: TextView + private lateinit var btnEnableProvider: Button + + private var currentTransport: FidoTransport? = null + private var pinProtocol: PinProtocol? = null + private var credentialManagement: CredentialManagement? = null + private lateinit var outputFormatter: OutputFormatter + + // NFC reconnection state + private var pendingAction: (() -> Unit)? = null + private var awaitingNfcReconnect: Boolean = false + private var reconnectDialog: AlertDialog? = null + + private val scope = CoroutineScope(Dispatchers.Main + Job()) + + private val usbPermissionReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == ACTION_USB_PERMISSION) { + val device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(UsbManager.EXTRA_DEVICE) + } + val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false) + + if (granted && device != null) { + connectToUsbDevice(device) + } else { + statusText.text = getString(R.string.usb_permission_denied) + } + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + statusText = findViewById(R.id.statusText) + connectionType = findViewById(R.id.connectionType) + resultText = findViewById(R.id.resultText) + btnScanUsb = findViewById(R.id.btnScanUsb) + btnDeviceInfo = findViewById(R.id.btnDeviceInfo) + btnListCredentials = findViewById(R.id.btnListCredentials) + btnChangePin = findViewById(R.id.btnChangePin) + providerStatusContainer = findViewById(R.id.providerStatusContainer) + providerStatusText = findViewById(R.id.providerStatusText) + btnEnableProvider = findViewById(R.id.btnEnableProvider) + + nfcAdapter = NfcAdapter.getDefaultAdapter(this) + usbManager = getSystemService(Context.USB_SERVICE) as UsbManager + outputFormatter = OutputFormatter(this) + + // Register USB permission receiver + val filter = IntentFilter(ACTION_USB_PERMISSION) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(usbPermissionReceiver, filter, RECEIVER_NOT_EXPORTED) + } else { + registerReceiver(usbPermissionReceiver, filter) + } + + btnScanUsb.setOnClickListener { scanForUsbDevices() } + btnDeviceInfo.setOnClickListener { getDeviceInfo() } + btnListCredentials.setOnClickListener { listCredentials() } + btnChangePin.setOnClickListener { showChangePinDialog() } + btnEnableProvider.setOnClickListener { openProviderSettings() } + + updateConnectionStatus() + } + + override fun onDestroy() { + super.onDestroy() + unregisterReceiver(usbPermissionReceiver) + scope.cancel() + } + + override fun onResume() { + super.onResume() + + // Check credential provider status + checkProviderStatus() + + // Enable NFC foreground dispatch + nfcAdapter?.let { adapter -> + val intent = Intent(this, javaClass).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + val pendingIntent = PendingIntent.getActivity( + this, 0, intent, + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + val filters = arrayOf(IntentFilter(NfcAdapter.ACTION_TECH_DISCOVERED)) + val techLists = arrayOf(arrayOf(IsoDep::class.java.name)) + adapter.enableForegroundDispatch(this, pendingIntent, filters, techLists) + } + + // Check if started by USB device attachment + if (intent.action == UsbManager.ACTION_USB_DEVICE_ATTACHED) { + val device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(UsbManager.EXTRA_DEVICE) + } + device?.let { handleUsbDevice(it) } + } + } + + override fun onPause() { + super.onPause() + nfcAdapter?.disableForegroundDispatch(this) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + + when (intent.action) { + NfcAdapter.ACTION_TECH_DISCOVERED -> { + val tag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(NfcAdapter.EXTRA_TAG) + } + tag?.let { handleNfcTag(it) } + } + UsbManager.ACTION_USB_DEVICE_ATTACHED -> { + val device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(UsbManager.EXTRA_DEVICE) + } + device?.let { handleUsbDevice(it) } + } + } + } + + private fun handleNfcTag(tag: Tag) { + scope.launch { + try { + // Close old transport (NFC tags can't be reused after moving away) + currentTransport?.close() + currentTransport = null + + val isoDep = IsoDep.get(tag) ?: throw Exception("Not an ISO-DEP tag") + val transport = NfcTransport(isoDep) + + if (!transport.selectFidoApplet()) { + throw Exception("Failed to select FIDO applet") + } + + currentTransport = transport + pinProtocol = PinProtocol(transport) + credentialManagement = null + + updateConnectionStatus() + + // Check if we were waiting for reconnection + if (awaitingNfcReconnect) { + reconnectDialog?.dismiss() + reconnectDialog = null + awaitingNfcReconnect = false + + val action = pendingAction + if (action != null) { + action() + } else { + statusText.text = getString(R.string.nfc_connected) + resultText.text = "" + } + } else { + statusText.text = getString(R.string.nfc_connected) + } + + } catch (e: Exception) { + statusText.text = getString(R.string.nfc_error, e.message ?: "") + updateConnectionStatus() + } + } + } + + private fun handleUsbDevice(device: UsbDevice) { + if (!UsbTransport.isFidoDevice(device)) { + return + } + + if (usbManager.hasPermission(device)) { + connectToUsbDevice(device) + } else { + requestUsbPermission(device) + } + } + + private fun scanForUsbDevices() { + val devices = usbManager.deviceList.values + .filter { UsbTransport.isFidoDevice(it) } + + if (devices.isEmpty()) { + AlertDialog.Builder(this) + .setTitle(getString(R.string.no_devices_title)) + .setMessage(getString(R.string.no_devices_message)) + .setPositiveButton(getString(R.string.ok), null) + .show() + return + } + + if (devices.size == 1) { + handleUsbDevice(devices.first()) + return + } + + // Show device selection dialog + showDeviceSelectionDialog(devices) + } + + private fun showDeviceSelectionDialog(devices: Collection) { + val dialogView = layoutInflater.inflate(R.layout.dialog_usb_devices, null) + val recyclerView = dialogView.findViewById(R.id.deviceList) + + val dialog = AlertDialog.Builder(this) + .setTitle(getString(R.string.select_security_key)) + .setView(dialogView) + .setNegativeButton(getString(R.string.cancel), null) + .create() + + recyclerView.layoutManager = LinearLayoutManager(this) + recyclerView.adapter = UsbDeviceAdapter(devices.toList()) { device -> + dialog.dismiss() + handleUsbDevice(device) + } + + dialog.show() + } + + private fun requestUsbPermission(device: UsbDevice) { + val intent = Intent(ACTION_USB_PERMISSION).apply { + setPackage(packageName) + } + val permissionIntent = PendingIntent.getBroadcast( + this, 0, + intent, + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + usbManager.requestPermission(device, permissionIntent) + } + + private fun connectToUsbDevice(device: UsbDevice) { + scope.launch { + try { + currentTransport?.close() + currentTransport = null + pinProtocol = null + credentialManagement = null + + statusText.text = getString(R.string.connecting_usb) + + val transport = withContext(Dispatchers.IO) { + UsbTransport.create(usbManager, device) + } ?: throw Exception("Failed to initialize FIDO communication") + + currentTransport = transport + pinProtocol = PinProtocol(transport) + credentialManagement = null + + updateConnectionStatus() + statusText.text = getString(R.string.usb_connected) + + } catch (e: Exception) { + statusText.text = getString(R.string.usb_error, e.message ?: "") + updateConnectionStatus() + } + } + } + + private fun updateConnectionStatus() { + val transport = currentTransport + val connected = transport?.isConnected == true + + connectionType.text = if (connected) { + getString(R.string.connected_via, transport?.transportType?.name ?: "") + } else { + getString(R.string.not_connected) + } + + btnDeviceInfo.isEnabled = connected + btnListCredentials.isEnabled = connected + btnChangePin.isEnabled = connected + + // Update status text if not connected and not waiting for reconnect + if (!connected && !awaitingNfcReconnect) { + statusText.text = getString(R.string.waiting_for_key) + } + } + + private fun isNfcDisconnected(): Boolean { + return currentTransport is NfcTransport && currentTransport?.isConnected == false + } + + private fun showNfcReconnectDialog() { + awaitingNfcReconnect = true + statusText.text = getString(R.string.connection_lost) + resultText.text = getString(R.string.waiting_reconnection) + + reconnectDialog = AlertDialog.Builder(this) + .setTitle(getString(R.string.connection_lost_title)) + .setMessage(getString(R.string.connection_lost_message)) + .setCancelable(false) + .setNegativeButton(getString(R.string.cancel)) { _, _ -> + awaitingNfcReconnect = false + pendingAction = null + resultText.text = getString(R.string.operation_cancelled) + updateConnectionStatus() + } + .show() + } + + private fun getDeviceInfo() { + pendingAction = { getDeviceInfo() } + + scope.launch { + try { + val transport = currentTransport ?: throw Exception("No key connected") + + resultText.text = getString(R.string.reading_device_info) + + val response = withContext(Dispatchers.IO) { + transport.sendCtapCommand(CTAP.buildCommand(CTAP.CMD_GET_INFO)) + } + + val error = CTAP.getResponseError(response) + if (error != null) { + resultText.text = outputFormatter.formatDeviceInfoError(error.name) + pendingAction = null + return@launch + } + + val deviceInfo = CTAP.parseGetInfoStructured(response) + if (deviceInfo != null) { + resultText.text = outputFormatter.formatDeviceInfo(deviceInfo) + } else { + resultText.text = outputFormatter.formatDeviceInfoError("Failed to parse response") + } + pendingAction = null + + } catch (e: Exception) { + if (isNfcDisconnected()) { + showNfcReconnectDialog() + } else { + resultText.text = getString(R.string.error_generic, e.message ?: "") + pendingAction = null + handleDisconnect() + } + } + } + } + + private fun listCredentials() { + pendingAction = { listCredentials() } + + scope.launch { + try { + val transport = currentTransport ?: throw Exception("No key connected") + + resultText.text = getString(R.string.checking_cred_mgmt) + + val infoResponse = withContext(Dispatchers.IO) { + transport.sendCtapCommand(CTAP.buildCommand(CTAP.CMD_GET_INFO)) + } + + val deviceInfo = CTAP.parseGetInfoStructured(infoResponse) + if (deviceInfo == null) { + resultText.text = getString(R.string.error_parse_device_info) + pendingAction = null + return@launch + } + + if (!deviceInfo.supportsCredMgmt && !deviceInfo.supportsCredMgmtPreview) { + resultText.text = outputFormatter.status( + getString(R.string.credential_management_title), + "✗ " + getString(R.string.credential_management_not_supported) + ) + pendingAction = null + return@launch + } + + val protocol = pinProtocol ?: throw Exception("PIN protocol not initialized") + val retries = withContext(Dispatchers.IO) { protocol.getPinRetries() } + + if (retries == null) { + resultText.text = getString(R.string.error_could_not_get_pin_status) + pendingAction = null + return@launch + } + + if (retries == 0) { + resultText.text = getString(R.string.error_pin_blocked) + pendingAction = null + return@launch + } + + // Clear pendingAction before showing dialog (will be set again with PIN) + pendingAction = null + showPinDialogForCredentials(retries, deviceInfo.usePreviewCommand) + + } catch (e: Exception) { + if (isNfcDisconnected()) { + showNfcReconnectDialog() + } else { + resultText.text = getString(R.string.error_generic, e.message ?: "") + pendingAction = null + handleDisconnect() + } + } + } + } + + private fun showPinDialogForCredentials(retries: Int, usePreviewCommand: Boolean) { + val editText = EditText(this).apply { + hint = getString(R.string.pin_hint) + inputType = android.text.InputType.TYPE_CLASS_TEXT or + android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD + setPadding(48, 32, 48, 32) + } + + AlertDialog.Builder(this) + .setTitle(getString(R.string.pin_required_title)) + .setMessage(getString(R.string.pin_required_message, retries)) + .setView(editText) + .setPositiveButton(getString(R.string.ok)) { _, _ -> + val pin = editText.text.toString() + if (pin.length >= 4) { + authenticateAndListCredentials(pin, usePreviewCommand) + } else { + resultText.text = getString(R.string.error_pin_min_length) + } + } + .setNegativeButton(getString(R.string.cancel), null) + .show() + } + + private fun authenticateAndListCredentials(pin: String, usePreviewCommand: Boolean) { + // Save for potential reconnection + pendingAction = { authenticateAndListCredentials(pin, usePreviewCommand) } + + scope.launch { + try { + val transport = currentTransport ?: throw Exception("No key connected") + val protocol = pinProtocol ?: throw Exception("PIN protocol not initialized") + + resultText.text = getString(R.string.authenticating) + + val initialized = withContext(Dispatchers.IO) { protocol.initialize() } + if (!initialized) { + if (isNfcDisconnected()) { + showNfcReconnectDialog() + } else { + resultText.text = getString(R.string.error_init_pin_protocol) + pendingAction = null + } + return@launch + } + + resultText.text = getString(R.string.verifying_pin) + val authenticated = withContext(Dispatchers.IO) { protocol.getPinToken(pin) } + if (!authenticated) { + if (isNfcDisconnected()) { + showNfcReconnectDialog() + } else { + resultText.text = getString(R.string.error_invalid_pin) + pendingAction = null + } + return@launch + } + + val credMgmt = CredentialManagement(transport, protocol, usePreviewCommand) + credentialManagement = credMgmt + + resultText.text = getString(R.string.getting_metadata) + val metadataResult = withContext(Dispatchers.IO) { credMgmt.getCredentialsMetadata() } + + val metadata = metadataResult.getOrElse { + if (isNfcDisconnected()) { + showNfcReconnectDialog() + } else { + resultText.text = getString(R.string.error_metadata, it.message ?: "") + pendingAction = null + } + return@launch + } + + // Show metadata while loading + resultText.text = outputFormatter.formatMetadataSection(metadata) + + if (metadata.existingResidentCredentialsCount == 0) { + resultText.text = outputFormatter.formatNoCredentials(metadata) + pendingAction = null + return@launch + } + + resultText.text = getString(R.string.enumerating_rps) + val rpsResult = withContext(Dispatchers.IO) { credMgmt.enumerateRelyingParties() } + + val relyingParties = rpsResult.getOrElse { + if (isNfcDisconnected()) { + showNfcReconnectDialog() + } else { + resultText.text = outputFormatter.formatEnumerateRpsError(metadata, it.message ?: "") + pendingAction = null + } + return@launch + } + + if (relyingParties.isEmpty()) { + resultText.text = outputFormatter.formatNoRelyingParties(metadata) + pendingAction = null + return@launch + } + + // Collect credentials for each RP + val rpsWithCredentials = mutableListOf() + + for (rp in relyingParties) { + resultText.text = getString(R.string.loading_credentials_for, rp.rpId ?: "RP") + + val credsResult = withContext(Dispatchers.IO) { + credMgmt.enumerateCredentials(rp.rpIdHash) + } + + if (credsResult.isFailure) { + if (isNfcDisconnected()) { + showNfcReconnectDialog() + return@launch + } + rpsWithCredentials.add( + OutputFormatter.RelyingPartyWithCredentials( + relyingParty = rp, + credentials = null, + error = credsResult.exceptionOrNull()?.message + ) + ) + } else { + rpsWithCredentials.add( + OutputFormatter.RelyingPartyWithCredentials( + relyingParty = rp, + credentials = credsResult.getOrThrow(), + error = null + ) + ) + } + } + + // Format and display the complete report + val report = OutputFormatter.CredentialReport( + metadata = metadata, + relyingParties = rpsWithCredentials + ) + resultText.text = outputFormatter.formatCredentialReport(report) + + // Clear action after successful operation + pendingAction = null + + } catch (e: Exception) { + if (isNfcDisconnected()) { + showNfcReconnectDialog() + } else { + resultText.text = getString(R.string.error_generic_with_trace, e.message ?: "", e.stackTraceToString()) + pendingAction = null + handleDisconnect() + } + } + } + } + + private fun showChangePinDialog() { + pendingAction = { showChangePinDialog() } + + scope.launch { + try { + val protocol = pinProtocol ?: throw Exception("PIN protocol not initialized") + + resultText.text = getString(R.string.checking_pin_status) + + val retries = withContext(Dispatchers.IO) { protocol.getPinRetries() } + + val dialogView = layoutInflater.inflate(R.layout.dialog_pin, null) + val currentPinEdit = dialogView.findViewById(R.id.currentPin) + val newPinEdit = dialogView.findViewById(R.id.newPin) + val confirmPinEdit = dialogView.findViewById(R.id.confirmPin) + + val message = if (retries != null) { + "PIN retries remaining: $retries" + } else { + "Could not get PIN status" + } + + pendingAction = null + + AlertDialog.Builder(this@MainActivity) + .setTitle(getString(R.string.change_pin_title)) + .setMessage(message) + .setView(dialogView) + .setPositiveButton(getString(R.string.change)) { _, _ -> + val currentPin = currentPinEdit.text.toString() + val newPin = newPinEdit.text.toString() + val confirmPin = confirmPinEdit.text.toString() + + if (newPin != confirmPin) { + resultText.text = getString(R.string.error_pins_dont_match) + return@setPositiveButton + } + + if (newPin.length < 4) { + resultText.text = getString(R.string.error_pin_min_length) + return@setPositiveButton + } + + changePin(currentPin, newPin) + } + .setNegativeButton(getString(R.string.cancel), null) + .show() + + resultText.text = "" + + } catch (e: Exception) { + if (isNfcDisconnected()) { + showNfcReconnectDialog() + } else { + resultText.text = getString(R.string.error_generic, e.message ?: "") + pendingAction = null + handleDisconnect() + } + } + } + } + + private fun changePin(currentPin: String, newPin: String) { + pendingAction = { changePin(currentPin, newPin) } + + scope.launch { + try { + val protocol = pinProtocol ?: throw Exception("PIN protocol not initialized") + + resultText.text = getString(R.string.initializing_pin_protocol) + + val initialized = withContext(Dispatchers.IO) { protocol.initialize() } + if (!initialized) { + if (isNfcDisconnected()) { + showNfcReconnectDialog() + } else { + resultText.text = getString(R.string.error_init_pin_protocol) + pendingAction = null + } + return@launch + } + + resultText.text = getString(R.string.changing_pin) + + val result = withContext(Dispatchers.IO) { + protocol.changePin(currentPin, newPin) + } + + result.fold( + onSuccess = { + resultText.text = outputFormatter.formatPinChangeSuccess() + pendingAction = null + }, + onFailure = { error -> + if (isNfcDisconnected()) { + showNfcReconnectDialog() + return@launch + } + + resultText.text = outputFormatter.formatPinChangeError(error) + pendingAction = null + } + ) + + } catch (e: Exception) { + if (isNfcDisconnected()) { + showNfcReconnectDialog() + } else { + resultText.text = getString(R.string.error_generic, e.message ?: "") + pendingAction = null + } + } + } + } + + private fun handleDisconnect() { + currentTransport?.close() + currentTransport = null + pinProtocol = null + credentialManagement = null + updateConnectionStatus() + } + + private fun checkProviderStatus() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + // Check if credentials feature is supported + if (!packageManager.hasSystemFeature(PackageManager.FEATURE_CREDENTIALS)) { + providerStatusContainer.visibility = View.GONE + return + } + + providerStatusContainer.visibility = View.VISIBLE + + try { + val credentialManager = getSystemService(android.credentials.CredentialManager::class.java) + val componentName = ComponentName(this, AuthnkeyCredentialService::class.java) + val isEnabled = credentialManager?.isEnabledCredentialProviderService(componentName) ?: false + + if (isEnabled) { + providerStatusContainer.setBackgroundColor(getColor(R.color.provider_enabled_background)) + providerStatusText.setTextColor(getColor(R.color.provider_enabled_text)) + providerStatusText.text = getString(R.string.provider_enabled) + btnEnableProvider.visibility = View.GONE + } else { + providerStatusContainer.setBackgroundColor(getColor(R.color.provider_not_enabled_background)) + providerStatusText.setTextColor(getColor(R.color.provider_not_enabled_text)) + providerStatusText.text = getString(R.string.provider_not_enabled) + btnEnableProvider.visibility = View.VISIBLE + } + } catch (e: Exception) { + providerStatusContainer.visibility = View.GONE + } + } else { + providerStatusContainer.visibility = View.GONE + } + } + + private fun openProviderSettings() { + val intent = Intent(Settings.ACTION_CREDENTIAL_PROVIDER) + .setData(android.net.Uri.parse("package:$packageName")) + startActivity(intent) + } + + companion object { + private const val ACTION_USB_PERMISSION = "pl.lebihan.authnkey.USB_PERMISSION" + } +} + +/** + * RecyclerView adapter for USB device selection + */ +class UsbDeviceAdapter( + private val devices: List, + private val onSelect: (UsbDevice) -> Unit +) : RecyclerView.Adapter() { + + class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + val name: TextView = view.findViewById(R.id.deviceName) + val info: TextView = view.findViewById(R.id.deviceInfo) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_usb_device, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val device = devices[position] + val context = holder.itemView.context + holder.name.text = device.productName ?: context.getString(R.string.unknown_device) + holder.info.text = context.getString( + R.string.device_info_format, + String.format("%04X", device.vendorId), + String.format("%04X", device.productId) + ) + holder.itemView.setOnClickListener { onSelect(device) } + } + + override fun getItemCount() = devices.size +} diff --git a/app/src/main/java/pl/lebihan/authnkey/NfcTransport.kt b/app/src/main/java/pl/lebihan/authnkey/NfcTransport.kt new file mode 100644 index 0000000..973c876 --- /dev/null +++ b/app/src/main/java/pl/lebihan/authnkey/NfcTransport.kt @@ -0,0 +1,136 @@ +package pl.lebihan.authnkey + +import android.nfc.tech.IsoDep +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * FIDO transport over NFC using ISO 7816-4 APDUs + */ +class NfcTransport(private val isoDep: IsoDep) : FidoTransport { + + override val transportType = TransportType.NFC + + override val isConnected: Boolean + get() = try { + isoDep.isConnected + } catch (e: SecurityException) { + false + } + + init { + if (!isoDep.isConnected) { + isoDep.connect() + } + isoDep.timeout = 5000 + } + + /** + * Select the FIDO applet on the NFC device + */ + suspend fun selectFidoApplet(): Boolean = withContext(Dispatchers.IO) { + try { + val response = isoDep.transceive(SELECT_FIDO_APPLET) + isSuccess(response) + } catch (e: SecurityException) { + false + } + } + + override suspend fun sendCtapCommand(command: ByteArray): ByteArray = withContext(Dispatchers.IO) { + try { + // Wrap CTAP command in ISO 7816-4 APDU + val apdu = buildApdu(command) + + var response = isoDep.transceive(apdu) + + // Handle response chaining (if response is larger than single frame) + val fullResponse = mutableListOf() + + while (response.size >= 2) { + val sw1 = response[response.size - 2].toInt() and 0xFF + val sw2 = response[response.size - 1].toInt() and 0xFF + + // Add data (excluding status bytes) + if (response.size > 2) { + fullResponse.addAll(response.dropLast(2)) + } + + when { + sw1 == 0x90 && sw2 == 0x00 -> { + // Success - return complete response + return@withContext fullResponse.toByteArray() + } + sw1 == 0x61 -> { + // More data available - send GET RESPONSE + response = isoDep.transceive(byteArrayOf(0x00, 0xC0.toByte(), 0x00, 0x00, sw2.toByte())) + } + else -> { + // Error + throw Exception("APDU error: ${String.format("%02X%02X", sw1, sw2)}") + } + } + } + + fullResponse.toByteArray() + } catch (e: SecurityException) { + // Tag is out of date / disconnected + throw java.io.IOException("NFC connection lost") + } + } + + override fun close() { + try { + isoDep.close() + } catch (e: Exception) { + // Ignore + } + } + + private fun buildApdu(ctapData: ByteArray): ByteArray { + // NFCCTAP command APDU: CLA=80, INS=10, P1=00, P2=00 + val apdu = mutableListOf() + apdu.add(0x80.toByte()) // CLA + apdu.add(0x10.toByte()) // INS (NFCCTAP_MSG) + apdu.add(0x00) // P1 + apdu.add(0x00) // P2 + + // Lc (length of data) - extended length encoding if needed + if (ctapData.size <= 255) { + apdu.add(ctapData.size.toByte()) + } else { + apdu.add(0x00) + apdu.add((ctapData.size shr 8).toByte()) + apdu.add((ctapData.size and 0xFF).toByte()) + } + + // Data + apdu.addAll(ctapData.toList()) + + // Le (expected response length) - request maximum + if (ctapData.size <= 255) { + apdu.add(0x00) // Le = 256 + } else { + apdu.add(0x00) + apdu.add(0x00) // Le = 65536 + } + + return apdu.toByteArray() + } + + private fun isSuccess(response: ByteArray): Boolean { + return response.size >= 2 && + response[response.size - 2] == 0x90.toByte() && + response[response.size - 1] == 0x00.toByte() + } + + companion object { + // FIDO Alliance AID + val SELECT_FIDO_APPLET = byteArrayOf( + 0x00, 0xA4.toByte(), 0x04, 0x00, // SELECT command + 0x08, // Length of AID + 0xA0.toByte(), 0x00, 0x00, 0x06, 0x47, 0x2F, 0x00, 0x01, // FIDO AID + 0x00 // Le + ) + } +} diff --git a/app/src/main/java/pl/lebihan/authnkey/OutputFormatter.kt b/app/src/main/java/pl/lebihan/authnkey/OutputFormatter.kt new file mode 100644 index 0000000..ebcfdc4 --- /dev/null +++ b/app/src/main/java/pl/lebihan/authnkey/OutputFormatter.kt @@ -0,0 +1,298 @@ +package pl.lebihan.authnkey + +import android.content.Context + +/** + * Centralized formatting for all UI output. + * Keeps presentation logic separate from business logic. + */ +class OutputFormatter(private val context: Context) { + + // ========== Generic Status Formatting ========== + + /** + * Format a header line + */ + fun header(title: String): String = "=== $title ===" + + /** + * Format a status message with header and body + */ + fun status(title: String, message: String): String = """ + |${header(title)} + | + |$message + """.trimMargin() + + /** + * Format a list with header + */ + fun list(title: String, items: List): String = buildString { + appendLine(header(title)) + appendLine() + items.forEach { appendLine(it) } + } + + /** + * Format key-value pairs + */ + fun keyValueList(title: String, pairs: List>): String = buildString { + appendLine(header(title)) + appendLine() + pairs.forEach { (key, value) -> + appendLine("$key: $value") + } + } + + // ========== Device Info Formatting ========== + + /** + * Format device info response + */ + fun formatDeviceInfo(info: DeviceInfo): String = buildString { + appendLine(header(context.getString(R.string.device_info_title))) + appendLine() + + if (info.versions.isNotEmpty()) { + appendLine(context.getString(R.string.device_info_versions, info.versions.joinToString(", "))) + } + + if (info.extensions.isNotEmpty()) { + appendLine(context.getString(R.string.device_info_extensions, info.extensions.joinToString(", "))) + } + + info.aaguid?.let { + appendLine(context.getString(R.string.device_info_aaguid, it.toHex())) + } + + if (info.options.isNotEmpty()) { + appendLine() + appendLine(context.getString(R.string.device_info_options)) + info.options.forEach { (k, v) -> + appendLine(context.getString(R.string.device_info_option_item, k, v.toString())) + } + } + + info.maxMsgSize?.let { + appendLine() + appendLine(context.getString(R.string.device_info_max_msg_size, it)) + } + + if (info.pinUvAuthProtocols.isNotEmpty()) { + appendLine(context.getString(R.string.device_info_pin_protocols, info.pinUvAuthProtocols.joinToString(", "))) + } + + info.maxCredentialCountInList?.let { + appendLine(context.getString(R.string.device_info_max_creds_in_list, it)) + } + + info.maxCredentialIdLength?.let { + appendLine(context.getString(R.string.device_info_max_cred_id_length, it)) + } + + if (info.transports.isNotEmpty()) { + appendLine(context.getString(R.string.device_info_transports, info.transports.joinToString(", "))) + } + + if (info.algorithms.isNotEmpty()) { + appendLine() + appendLine(context.getString(R.string.device_info_algorithms)) + info.algorithms.forEach { alg -> + appendLine(context.getString(R.string.device_info_algorithm_item, alg.type ?: "?", alg.alg?.toString() ?: "?")) + } + } + + info.minPinLength?.let { + appendLine(context.getString(R.string.device_info_min_pin_length, it)) + } + + info.firmwareVersion?.let { + appendLine(context.getString(R.string.device_info_firmware, it)) + } + } + + /** + * Format device info error + */ + fun formatDeviceInfoError(errorMessage: String): String = + context.getString(R.string.device_info_error, errorMessage) + + // ========== Credential Management Formatting ========== + + /** + * Complete credential report data + */ + data class CredentialReport( + val metadata: CredentialManagement.CredentialMetadata, + val relyingParties: List + ) + + data class RelyingPartyWithCredentials( + val relyingParty: CredentialManagement.RelyingParty, + val credentials: List?, + val error: String? + ) + + /** + * Format a complete credential management report + */ + fun formatCredentialReport(report: CredentialReport): String = buildString { + appendLine(header(context.getString(R.string.credential_management_title))) + appendLine() + appendLine(context.getString(R.string.credential_stored_count, report.metadata.existingResidentCredentialsCount)) + appendLine(context.getString(R.string.credential_remaining_slots, report.metadata.maxPossibleRemainingCredentials)) + appendLine() + + if (report.metadata.existingResidentCredentialsCount == 0) { + appendLine(context.getString(R.string.credential_no_credentials)) + return@buildString + } + + if (report.relyingParties.isEmpty()) { + appendLine(context.getString(R.string.credential_no_rps)) + return@buildString + } + + appendLine(context.getString(R.string.credential_found_rps, report.relyingParties.size)) + appendLine() + + for ((index, rpWithCreds) in report.relyingParties.withIndex()) { + append(formatRelyingParty(index, rpWithCreds)) + } + + appendLine(SEPARATOR) + } + + /** + * Format metadata section only (for partial display during loading) + */ + fun formatMetadataSection(metadata: CredentialManagement.CredentialMetadata): String = buildString { + appendLine(header(context.getString(R.string.credential_management_title))) + appendLine() + appendLine(context.getString(R.string.credential_stored_count, metadata.existingResidentCredentialsCount)) + appendLine(context.getString(R.string.credential_remaining_slots, metadata.maxPossibleRemainingCredentials)) + appendLine() + } + + /** + * Format empty credentials message + */ + fun formatNoCredentials(metadata: CredentialManagement.CredentialMetadata): String = buildString { + append(formatMetadataSection(metadata)) + appendLine(context.getString(R.string.credential_no_credentials)) + } + + /** + * Format error when enumerating RPs + */ + fun formatEnumerateRpsError(metadata: CredentialManagement.CredentialMetadata, errorMessage: String): String = buildString { + append(formatMetadataSection(metadata)) + appendLine(context.getString(R.string.credential_error_enumerate_rps, errorMessage)) + } + + /** + * Format no relying parties found + */ + fun formatNoRelyingParties(metadata: CredentialManagement.CredentialMetadata): String = buildString { + append(formatMetadataSection(metadata)) + appendLine(context.getString(R.string.credential_no_rps)) + } + + /** + * Format a single relying party with its credentials + */ + private fun formatRelyingParty( + index: Int, + rpWithCreds: RelyingPartyWithCredentials + ): String = buildString { + val rp = rpWithCreds.relyingParty + + appendLine(SEPARATOR) + appendLine(context.getString(R.string.credential_rp_header, index + 1, rp.rpId ?: rp.rpIdHash.toHex())) + rp.rpName?.let { appendLine(context.getString(R.string.credential_rp_name, it)) } + appendLine() + + when { + rpWithCreds.error != null -> { + appendLine(" " + context.getString(R.string.credential_error_loading, rpWithCreds.error)) + } + rpWithCreds.credentials != null -> { + for ((credIndex, cred) in rpWithCreds.credentials.withIndex()) { + append(formatCredential(credIndex, cred)) + } + } + } + } + + /** + * Format a single credential + */ + private fun formatCredential(index: Int, cred: CredentialManagement.Credential): String = buildString { + appendLine(" " + context.getString(R.string.credential_number, index + 1)) + cred.userName?.let { + appendLine(" " + context.getString(R.string.credential_username, it)) + } + cred.userDisplayName?.let { + appendLine(" " + context.getString(R.string.credential_display_name, it)) + } + cred.userId?.let { + appendLine(" " + context.getString(R.string.credential_user_id, it.toHex())) + } + appendLine(" " + context.getString(R.string.credential_id, cred.credentialId.toHex().take(32))) + cred.credProtect?.let { + appendLine(" " + context.getString(R.string.credential_protection, formatCredProtect(it))) + } + appendLine() + } + + /** + * Format credential protection level + */ + private fun formatCredProtect(level: Int): String = when (level) { + 1 -> "userVerificationOptional" + 2 -> "userVerificationOptionalWithCredentialIDList" + 3 -> "userVerificationRequired" + else -> "unknown ($level)" + } + + // ========== PIN Change Formatting ========== + + /** + * Format PIN change error message + */ + fun formatPinChangeError(error: Throwable): String = when (error) { + is PinProtocol.PinChangeError.InvalidPin -> status( + context.getString(R.string.pin_invalid_title), + context.getString(R.string.pin_invalid_message) + ) + is PinProtocol.PinChangeError.PinBlocked -> status( + context.getString(R.string.pin_blocked_title), + context.getString(R.string.pin_blocked_message) + ) + is PinProtocol.PinChangeError.PinPolicyViolation -> status( + context.getString(R.string.pin_policy_violation_title), + context.getString(R.string.pin_policy_violation_message) + ) + is PinProtocol.PinChangeError.PinNotSet -> status( + context.getString(R.string.pin_not_set_title), + context.getString(R.string.pin_not_set_message) + ) + is PinProtocol.PinChangeError.Other -> status( + context.getString(R.string.pin_change_failed_title), + context.getString(R.string.pin_change_failed_message, error.errorName) + ) + else -> context.getString(R.string.error_generic, error.message ?: "Unknown error") + } + + /** + * Format PIN change success message + */ + fun formatPinChangeSuccess(): String = status( + context.getString(R.string.pin_change_success_title), + context.getString(R.string.pin_change_success_message) + ) + + companion object { + private const val SEPARATOR = "────────────────────────────────────────" + } +} diff --git a/app/src/main/java/pl/lebihan/authnkey/PinProtocol.kt b/app/src/main/java/pl/lebihan/authnkey/PinProtocol.kt new file mode 100644 index 0000000..eb94cf9 --- /dev/null +++ b/app/src/main/java/pl/lebihan/authnkey/PinProtocol.kt @@ -0,0 +1,350 @@ +package pl.lebihan.authnkey + +import java.math.BigInteger +import java.security.* +import java.security.interfaces.ECPublicKey +import java.security.spec.* +import javax.crypto.Cipher +import javax.crypto.KeyAgreement +import javax.crypto.Mac +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +class PinProtocol(private val transport: FidoTransport) { + + companion object { + const val PERMISSION_MC = 0x01 + const val PERMISSION_GA = 0x02 + const val PERMISSION_CM = 0x04 + const val PERMISSION_BE = 0x08 + const val PERMISSION_LBW = 0x10 + const val PERMISSION_ACFG = 0x20 + } + + private var sharedSecret: ByteArray? = null + private var pinToken: ByteArray? = null + private var platformPublicKey: ECPublicKey? = null + + suspend fun initialize(): Boolean { + try { + val keyAgreementResponse = transport.sendCtapCommand(buildGetKeyAgreementCommand()) + + if (!CTAP.isSuccess(keyAgreementResponse)) { + return false + } + + val authenticatorPublicKey = parseKeyAgreementResponse(keyAgreementResponse) + ?: return false + + val keyPairGenerator = KeyPairGenerator.getInstance("EC") + keyPairGenerator.initialize(ECGenParameterSpec("secp256r1")) + val ephemeralKeyPair = keyPairGenerator.generateKeyPair() + + val keyAgreement = KeyAgreement.getInstance("ECDH") + keyAgreement.init(ephemeralKeyPair.private) + keyAgreement.doPhase(authenticatorPublicKey, true) + val rawSharedSecret = keyAgreement.generateSecret() + + val sha256 = MessageDigest.getInstance("SHA-256") + sharedSecret = sha256.digest(rawSharedSecret) + + platformPublicKey = ephemeralKeyPair.public as ECPublicKey + + return true + } catch (e: Exception) { + return false + } + } + + suspend fun getPinToken(pin: String): Boolean { + val secret = sharedSecret ?: return false + val pubKey = platformPublicKey ?: return false + + try { + val sha256 = MessageDigest.getInstance("SHA-256") + val pinHash = sha256.digest(pin.toByteArray(Charsets.UTF_8)) + val pinHashLeft16 = pinHash.copyOf(16) + + val encryptedPinHash = aesEncrypt(secret, pinHashLeft16) + + val command = buildGetPinTokenCommand(pubKey, encryptedPinHash) + val response = transport.sendCtapCommand(command) + + if (!CTAP.isSuccess(response)) { + return false + } + + val encryptedToken = parsePinTokenResponse(response) ?: return false + pinToken = aesDecrypt(secret, encryptedToken) + + return pinToken != null + } catch (e: Exception) { + return false + } + } + + suspend fun getPinToken(pin: String, permissions: Int, rpId: String? = null): Boolean { + val secret = sharedSecret ?: return false + val pubKey = platformPublicKey ?: return false + + try { + val sha256 = MessageDigest.getInstance("SHA-256") + val pinHash = sha256.digest(pin.toByteArray(Charsets.UTF_8)) + val pinHashLeft16 = pinHash.copyOf(16) + + val encryptedPinHash = aesEncrypt(secret, pinHashLeft16) + + val command = buildGetPinTokenWithPermissionsCommand(pubKey, encryptedPinHash, permissions, rpId) + val response = transport.sendCtapCommand(command) + + if (response.isEmpty()) { + return false + } + + if (CTAP.isSuccess(response)) { + val encryptedToken = parsePinTokenResponse(response) ?: return false + pinToken = aesDecrypt(secret, encryptedToken) + return pinToken != null + } + + val error = CTAP.getResponseError(response) + val fallbackErrors = listOf( + CTAP.Error.INVALID_COMMAND, + CTAP.Error.INVALID_PARAMETER, + CTAP.Error.CBOR_UNEXPECTED_TYPE, + CTAP.Error.MISSING_PARAMETER + ) + if (error in fallbackErrors) { + return getPinToken(pin) + } + + return false + + } catch (e: Exception) { + return getPinToken(pin) + } + } + + suspend fun getPinRetries(): Int? { + try { + val response = transport.sendCtapCommand(CTAP.buildGetPinRetriesCommand()) + if (!CTAP.isSuccess(response)) { + return null + } + + val data = response.drop(1).toByteArray() + val parsed = CborMap.decode(data) + return parsed?.int(3) + } catch (e: Exception) { + return null + } + } + + sealed class PinChangeError(message: String) : Exception(message) { + object InvalidPin : PinChangeError("Current PIN is incorrect") + object PinBlocked : PinChangeError("PIN is blocked due to too many incorrect attempts") + object PinPolicyViolation : PinChangeError("New PIN does not meet authenticator requirements") + object PinNotSet : PinChangeError("No PIN is set on this authenticator") + data class Other(val errorName: String) : PinChangeError(errorName) + } + + suspend fun changePin(currentPin: String, newPin: String): Result { + val secret = sharedSecret ?: return Result.failure(Exception("Shared secret not available")) + val pubKey = platformPublicKey ?: return Result.failure(Exception("Platform key not available")) + + try { + val sha256 = MessageDigest.getInstance("SHA-256") + + val currentPinHash = sha256.digest(currentPin.toByteArray(Charsets.UTF_8)) + val currentPinHashLeft16 = currentPinHash.copyOf(16) + + val newPinBytes = newPin.toByteArray(Charsets.UTF_8) + val newPinPadded = ByteArray(64) + newPinBytes.copyInto(newPinPadded, 0, 0, newPinBytes.size) + + val encryptedCurrentPinHash = aesEncrypt(secret, currentPinHashLeft16) + val encryptedNewPin = aesEncrypt(secret, newPinPadded) + + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(secret, "HmacSHA256")) + mac.update(encryptedNewPin) + mac.update(encryptedCurrentPinHash) + val hmacResult = mac.doFinal() + val pinUvAuthParam = hmacResult.copyOf(16) + + val command = buildChangePinCommand(pubKey, encryptedNewPin, encryptedCurrentPinHash, pinUvAuthParam) + val response = transport.sendCtapCommand(command) + + if (response.isEmpty()) { + return Result.failure(PinChangeError.Other("Empty response")) + } + + if (CTAP.isSuccess(response)) { + return Result.success(Unit) + } + + return when (CTAP.getResponseError(response)) { + CTAP.Error.PIN_INVALID -> Result.failure(PinChangeError.InvalidPin) + CTAP.Error.PIN_BLOCKED -> Result.failure(PinChangeError.PinBlocked) + CTAP.Error.PIN_POLICY_VIOLATION -> Result.failure(PinChangeError.PinPolicyViolation) + CTAP.Error.PIN_NOT_SET -> Result.failure(PinChangeError.PinNotSet) + else -> Result.failure(PinChangeError.Other(CTAP.getErrorName(response[0]))) + } + + } catch (e: Exception) { + return Result.failure(e) + } + } + + fun hasPinToken(): Boolean = pinToken != null + + fun computeAuthParam(message: ByteArray): ByteArray? { + val token = pinToken ?: return null + + try { + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(token, "HmacSHA256")) + val hmacResult = mac.doFinal(message) + return hmacResult.copyOf(16) + } catch (e: Exception) { + return null + } + } + + private fun buildGetKeyAgreementCommand(): ByteArray { + return byteArrayOf(CTAP.CMD_CLIENT_PIN.toByte()) + cbor { + map { + 1 to 1 + 2 to 2 + } + } + } + + private fun buildGetPinTokenCommand(platformKey: ECPublicKey, encryptedPinHash: ByteArray): ByteArray { + return byteArrayOf(CTAP.CMD_CLIENT_PIN.toByte()) + cbor { + map { + 1 to 1 + 2 to 5 + 3 to encodeCoseKey(platformKey) + 6 to bytes(encryptedPinHash) + } + } + } + + private fun buildGetPinTokenWithPermissionsCommand( + platformKey: ECPublicKey, + encryptedPinHash: ByteArray, + permissions: Int, + rpId: String? = null + ): ByteArray { + return byteArrayOf(CTAP.CMD_CLIENT_PIN.toByte()) + cbor { + map { + 1 to 1 + 2 to 9 + 3 to encodeCoseKey(platformKey) + 6 to bytes(encryptedPinHash) + 9 to permissions + if (rpId != null) { + 0x0A to rpId + } + } + } + } + + private fun buildChangePinCommand( + platformKey: ECPublicKey, + encryptedNewPin: ByteArray, + encryptedCurrentPinHash: ByteArray, + pinUvAuthParam: ByteArray + ): ByteArray { + return byteArrayOf(CTAP.CMD_CLIENT_PIN.toByte()) + cbor { + map { + 1 to 1 + 2 to 4 + 3 to encodeCoseKey(platformKey) + 4 to bytes(pinUvAuthParam) + 5 to bytes(encryptedNewPin) + 6 to bytes(encryptedCurrentPinHash) + } + } + } + + private fun CborMapEncoder.encodeCoseKey(publicKey: ECPublicKey): CborRaw { + val point = publicKey.w + val x = bigIntegerToBytes(point.affineX, 32) + val y = bigIntegerToBytes(point.affineY, 32) + + return map { + 1 to 2 + 3 to -25 + -1 to 1 + -2 to bytes(x) + -3 to bytes(y) + } + } + + private fun parseKeyAgreementResponse(response: ByteArray): ECPublicKey? { + try { + val data = response.drop(1).toByteArray() + val parsed = CborMap.decode(data) ?: return null + + val coseKey = parsed.map(1) ?: return null + + val x = coseKey.bytes(-2) ?: return null + val y = coseKey.bytes(-3) ?: return null + + return createECPublicKey(x, y) + } catch (e: Exception) { + return null + } + } + + private fun parsePinTokenResponse(response: ByteArray): ByteArray? { + try { + val data = response.drop(1).toByteArray() + val parsed = CborMap.decode(data) ?: return null + + return parsed.bytes(2) + } catch (e: Exception) { + return null + } + } + + private fun aesEncrypt(key: ByteArray, data: ByteArray): ByteArray { + val cipher = Cipher.getInstance("AES/CBC/NoPadding") + val secretKey = SecretKeySpec(key, "AES") + val iv = IvParameterSpec(ByteArray(16)) + cipher.init(Cipher.ENCRYPT_MODE, secretKey, iv) + return cipher.doFinal(data) + } + + private fun aesDecrypt(key: ByteArray, data: ByteArray): ByteArray { + val cipher = Cipher.getInstance("AES/CBC/NoPadding") + val secretKey = SecretKeySpec(key, "AES") + val iv = IvParameterSpec(ByteArray(16)) + cipher.init(Cipher.DECRYPT_MODE, secretKey, iv) + return cipher.doFinal(data) + } + + private fun createECPublicKey(x: ByteArray, y: ByteArray): ECPublicKey { + val ecPoint = ECPoint(BigInteger(1, x), BigInteger(1, y)) + + val paramSpec = ECGenParameterSpec("secp256r1") + val keyPairGenerator = KeyPairGenerator.getInstance("EC") + keyPairGenerator.initialize(paramSpec) + val params = (keyPairGenerator.generateKeyPair().public as ECPublicKey).params + + val pubKeySpec = ECPublicKeySpec(ecPoint, params) + val keyFactory = KeyFactory.getInstance("EC") + return keyFactory.generatePublic(pubKeySpec) as ECPublicKey + } + + private fun bigIntegerToBytes(value: BigInteger, length: Int): ByteArray { + val bytes = value.toByteArray() + return when { + bytes.size == length -> bytes + bytes.size > length -> bytes.copyOfRange(bytes.size - length, bytes.size) + else -> ByteArray(length - bytes.size) + bytes + } + } +} diff --git a/app/src/main/java/pl/lebihan/authnkey/UsbTransport.kt b/app/src/main/java/pl/lebihan/authnkey/UsbTransport.kt new file mode 100644 index 0000000..b9cdf84 --- /dev/null +++ b/app/src/main/java/pl/lebihan/authnkey/UsbTransport.kt @@ -0,0 +1,292 @@ +package pl.lebihan.authnkey + +import android.hardware.usb.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.random.Random + +/** + * FIDO transport over USB HID using CTAPHID protocol + */ +class UsbTransport( + private val usbManager: UsbManager, + private val device: UsbDevice, + private val connection: UsbDeviceConnection, + private val hidInterface: UsbInterface, + private val inEndpoint: UsbEndpoint, + private val outEndpoint: UsbEndpoint +) : FidoTransport { + + override val transportType = TransportType.USB + + private var channelId: Int = CID_BROADCAST + private var _isConnected = true + + override val isConnected: Boolean + get() = _isConnected + + private val packetSize = outEndpoint.maxPacketSize.coerceAtLeast(64) + + /** + * Initialize CTAPHID channel + */ + suspend fun init(): Boolean = withContext(Dispatchers.IO) { + try { + // Send INIT command to get a channel + val nonce = ByteArray(8).also { Random.nextBytes(it) } + val response = sendRaw(CID_BROADCAST, CMD_INIT, nonce) + + if (response.size >= 17) { + // Verify nonce + val receivedNonce = response.sliceArray(0..7) + if (!receivedNonce.contentEquals(nonce)) { + throw Exception("Nonce mismatch") + } + + // Extract channel ID (bytes 8-11, big endian) + channelId = ByteBuffer.wrap(response, 8, 4).order(ByteOrder.BIG_ENDIAN).int + true + } else { + false + } + } catch (e: Exception) { + false + } + } + + override suspend fun sendCtapCommand(command: ByteArray): ByteArray = withContext(Dispatchers.IO) { + // CTAPHID_CBOR command + sendRaw(channelId, CMD_CBOR, command) + } + + private fun sendRaw(cid: Int, cmd: Int, data: ByteArray): ByteArray { + // Build and send initialization packet + val initPacket = ByteArray(packetSize) + var offset = 0 + + // Channel ID (4 bytes, big endian) + initPacket[0] = (cid shr 24).toByte() + initPacket[1] = (cid shr 16).toByte() + initPacket[2] = (cid shr 8).toByte() + initPacket[3] = cid.toByte() + + // Command (1 byte, with bit 7 set for init packet) + initPacket[4] = (cmd or 0x80).toByte() + + // Length (2 bytes, big endian) + initPacket[5] = (data.size shr 8).toByte() + initPacket[6] = (data.size and 0xFF).toByte() + + // Data (up to packetSize - 7 bytes in init packet) + val initDataLen = minOf(data.size, packetSize - 7) + System.arraycopy(data, 0, initPacket, 7, initDataLen) + offset = initDataLen + + // Send init packet + val sent = connection.bulkTransfer(outEndpoint, initPacket, packetSize, TIMEOUT_MS) + if (sent < 0) throw Exception("Failed to send init packet") + + // Send continuation packets if needed + var seq = 0 + while (offset < data.size) { + val contPacket = ByteArray(packetSize) + + // Channel ID + contPacket[0] = (cid shr 24).toByte() + contPacket[1] = (cid shr 16).toByte() + contPacket[2] = (cid shr 8).toByte() + contPacket[3] = cid.toByte() + + // Sequence number (without bit 7) + contPacket[4] = (seq and 0x7F).toByte() + seq++ + + // Data + val contDataLen = minOf(data.size - offset, packetSize - 5) + System.arraycopy(data, offset, contPacket, 5, contDataLen) + offset += contDataLen + + val contSent = connection.bulkTransfer(outEndpoint, contPacket, packetSize, TIMEOUT_MS) + if (contSent < 0) throw Exception("Failed to send continuation packet") + } + + // Receive response + return receiveResponse(cid) + } + + private fun receiveResponse(expectedCid: Int): ByteArray { + val responseData = mutableListOf() + var expectedLen = 0 + var receivedLen = 0 + var expectedSeq = 0 + var isFirst = true + + // Use longer timeout for operations that need user presence + val startTime = System.currentTimeMillis() + val maxWaitTime = 30000L // 30 seconds for user to touch the key + + while (true) { + // Check if we've exceeded max wait time + if (System.currentTimeMillis() - startTime > maxWaitTime) { + throw Exception("Timeout waiting for response") + } + + val packet = ByteArray(packetSize) + val received = connection.bulkTransfer(inEndpoint, packet, packetSize, TIMEOUT_MS) + + if (received < 0) { + // Timeout on this read, but keep trying if within max wait time + continue + } + if (received < 5) continue + + // Parse channel ID + val recvCid = ByteBuffer.wrap(packet, 0, 4).order(ByteOrder.BIG_ENDIAN).int + if (recvCid != expectedCid) continue + + val cmdOrSeq = packet[4].toInt() and 0xFF + + // Handle KEEPALIVE messages (0x3B | 0x80 = 0xBB) + if (cmdOrSeq == (CMD_KEEPALIVE or 0x80)) { + // Keepalive status is in the data + // 0x01 = processing, 0x02 = user presence needed + // Just continue waiting + continue + } + + if (isFirst) { + // Init packet + if ((cmdOrSeq and 0x80) == 0) continue + + // Check for error + if (cmdOrSeq == (CMD_ERROR or 0x80)) { + val errorCode = if (received > 7) packet[7] else 0 + throw Exception("CTAPHID error: 0x${String.format("%02X", errorCode)}") + } + + expectedLen = ((packet[5].toInt() and 0xFF) shl 8) or (packet[6].toInt() and 0xFF) + val dataLen = minOf(expectedLen, received - 7) + + for (i in 0 until dataLen) { + responseData.add(packet[7 + i]) + } + receivedLen = dataLen + isFirst = false + } else { + // Continuation packet + if ((cmdOrSeq and 0x80) != 0) continue + if (cmdOrSeq != expectedSeq) continue + + expectedSeq++ + val dataLen = minOf(expectedLen - receivedLen, received - 5) + + for (i in 0 until dataLen) { + responseData.add(packet[5 + i]) + } + receivedLen += dataLen + } + + if (receivedLen >= expectedLen) { + break + } + } + + return responseData.toByteArray() + } + + override fun close() { + _isConnected = false + try { + connection.releaseInterface(hidInterface) + connection.close() + } catch (e: Exception) { + // Ignore + } + } + + companion object { + private const val CID_BROADCAST = 0xFFFFFFFF.toInt() + private const val CMD_INIT = 0x06 + private const val CMD_CBOR = 0x10 + private const val CMD_KEEPALIVE = 0x3B + private const val CMD_ERROR = 0x3F + private const val TIMEOUT_MS = 5000 + + /** + * Find FIDO HID interface on a USB device + */ + fun findFidoInterface(device: UsbDevice): Pair>? { + for (i in 0 until device.interfaceCount) { + val intf = device.getInterface(i) + + // HID class = 3 + if (intf.interfaceClass != UsbConstants.USB_CLASS_HID) continue + + var inEp: UsbEndpoint? = null + var outEp: UsbEndpoint? = null + + for (j in 0 until intf.endpointCount) { + val ep = intf.getEndpoint(j) + if (ep.type == UsbConstants.USB_ENDPOINT_XFER_INT) { + if (ep.direction == UsbConstants.USB_DIR_IN) { + inEp = ep + } else { + outEp = ep + } + } + } + + if (inEp != null && outEp != null) { + return Pair(intf, Pair(inEp, outEp)) + } + } + return null + } + + /** + * Check if a device might be a FIDO device + * Note: Can't check HID usage page on Android, so we check known vendors + * or just try any HID device + */ + fun isFidoDevice(device: UsbDevice): Boolean { + // Known FIDO device vendors (partial list) + val fidoVendors = setOf( + 0x1050, // Yubico + 0x096E, // Feitian + 0x20A0, // Ledger + 0x2581, // Ledger + 0x0483, // STMicroelectronics (some FIDO keys) + 0x10C4, // Silicon Labs (SoloKey) + 0x1209, // Generic (SoloKey, etc) + 0x2C97, // Ledger + 0x18D1, // Google (Titan) + ) + + return fidoVendors.contains(device.vendorId) || findFidoInterface(device) != null + } + + /** + * Create a UsbTransport from a USB device + */ + suspend fun create(usbManager: UsbManager, device: UsbDevice): UsbTransport? { + val (hidInterface, endpoints) = findFidoInterface(device) ?: return null + val (inEp, outEp) = endpoints + + val connection = usbManager.openDevice(device) ?: return null + + if (!connection.claimInterface(hidInterface, true)) { + connection.close() + return null + } + + val transport = UsbTransport(usbManager, device, connection, hidInterface, inEp, outEp) + + return if (transport.init()) transport else { + transport.close() + null + } + } + } +} diff --git a/app/src/main/res/anim/pulse.xml b/app/src/main/res/anim/pulse.xml new file mode 100644 index 0000000..0a54237 --- /dev/null +++ b/app/src/main/res/anim/pulse.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/account_circle_24.xml b/app/src/main/res/drawable/account_circle_24.xml new file mode 100644 index 0000000..fec33e7 --- /dev/null +++ b/app/src/main/res/drawable/account_circle_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/bg_icon_circle.xml b/app/src/main/res/drawable/bg_icon_circle.xml new file mode 100644 index 0000000..1b75b6d --- /dev/null +++ b/app/src/main/res/drawable/bg_icon_circle.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/check_circle_24.xml b/app/src/main/res/drawable/check_circle_24.xml new file mode 100644 index 0000000..728be3b --- /dev/null +++ b/app/src/main/res/drawable/check_circle_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/error_24.xml b/app/src/main/res/drawable/error_24.xml new file mode 100644 index 0000000..8355c11 --- /dev/null +++ b/app/src/main/res/drawable/error_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/fingerprint_24.xml b/app/src/main/res/drawable/fingerprint_24.xml new file mode 100644 index 0000000..56ebe6b --- /dev/null +++ b/app/src/main/res/drawable/fingerprint_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..0b3fdbd --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/drawable/key_24.xml b/app/src/main/res/drawable/key_24.xml new file mode 100644 index 0000000..e0df275 --- /dev/null +++ b/app/src/main/res/drawable/key_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/lock_24.xml b/app/src/main/res/drawable/lock_24.xml new file mode 100644 index 0000000..11d93f2 --- /dev/null +++ b/app/src/main/res/drawable/lock_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/sensors_24.xml b/app/src/main/res/drawable/sensors_24.xml new file mode 100644 index 0000000..81b21fa --- /dev/null +++ b/app/src/main/res/drawable/sensors_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..641d8ce --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,113 @@ + + + + + + + + +