mirror of
https://github.com/token2/FIDO2_Bridge.git
synced 2026-06-22 14:42:54 -07:00
Initial commit
This commit is contained in:
+10
@@ -0,0 +1,10 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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)
|
||||
}
|
||||
Vendored
+21
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.NFC" />
|
||||
|
||||
<uses-feature android:name="android.hardware.nfc" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.usb.host" android:required="false" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Authnkey"
|
||||
tools:targetApi="34">
|
||||
|
||||
<!-- Main Activity -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Credential Provider Activity -->
|
||||
<activity
|
||||
android:name=".CredentialProviderActivity"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/Theme.Authnkey.Transparent" />
|
||||
|
||||
<!-- Credential Provider Service (Android 14+) -->
|
||||
<service
|
||||
android:name=".AuthnkeyCredentialService"
|
||||
android:enabled="true"
|
||||
android:exported="true"
|
||||
android:label="Security Key"
|
||||
android:permission="android.permission.BIND_CREDENTIAL_PROVIDER_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.credentials.CredentialProviderService" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.credentials.provider"
|
||||
android:resource="@xml/credential_provider_config" />
|
||||
</service>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -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<BeginCreateCredentialResponse, CreateCredentialException>
|
||||
) {
|
||||
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<BeginGetCredentialResponse, GetCredentialException>
|
||||
) {
|
||||
try {
|
||||
val credentialEntries = mutableListOf<CredentialEntry>()
|
||||
|
||||
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<Void?, ClearCredentialException>
|
||||
) {
|
||||
// Nothing to clear - credentials are on the physical key
|
||||
callback.onResult(null)
|
||||
}
|
||||
|
||||
private fun handleBeginCreatePasskey(
|
||||
request: BeginCreatePublicKeyCredentialRequest,
|
||||
callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>
|
||||
) {
|
||||
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<CredentialEntry> {
|
||||
val entries = mutableListOf<CredentialEntry>()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package pl.lebihan.authnkey
|
||||
|
||||
data class AlgorithmInfo(
|
||||
val type: String?,
|
||||
val alg: Int?
|
||||
)
|
||||
|
||||
data class DeviceInfo(
|
||||
val versions: List<String> = emptyList(),
|
||||
val extensions: List<String> = emptyList(),
|
||||
val aaguid: ByteArray? = null,
|
||||
val options: Map<String, Boolean> = emptyMap(),
|
||||
val maxMsgSize: Int? = null,
|
||||
val pinUvAuthProtocols: List<Int> = emptyList(),
|
||||
val maxCredentialCountInList: Int? = null,
|
||||
val maxCredentialIdLength: Int? = null,
|
||||
val transports: List<String> = emptyList(),
|
||||
val algorithms: List<AlgorithmInfo> = 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<String>(1) ?: emptyList()
|
||||
val extensions = parsed.list<String>(2) ?: emptyList()
|
||||
val aaguid = parsed.bytes(3)
|
||||
|
||||
val options = mutableMapOf<String, Boolean>()
|
||||
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<Long>(6)?.map { it.toInt() } ?: emptyList()
|
||||
val maxCredentialCountInList = parsed.int(7)
|
||||
val maxCredentialIdLength = parsed.int(8)
|
||||
val transports = parsed.list<String>(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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Byte>()
|
||||
|
||||
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<List<Byte>>()
|
||||
|
||||
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<Byte>()
|
||||
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<Byte>()
|
||||
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<List<Byte>>()
|
||||
|
||||
fun add(value: Any?) {
|
||||
items.add(encodeValue(value))
|
||||
}
|
||||
|
||||
fun map(block: CborMapEncoder.() -> Unit) {
|
||||
val nested = CborMapEncoder()
|
||||
nested.block()
|
||||
val bytes = mutableListOf<Byte>()
|
||||
bytes.addAll(encodeHeader(5, nested.entries.size))
|
||||
nested.entries.forEach { bytes.addAll(it) }
|
||||
items.add(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmInline
|
||||
value class CborRaw(val bytes: List<Byte>)
|
||||
|
||||
private fun encodeValue(value: Any?): List<Byte> = 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<Byte> = 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<Byte> = 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<Byte> =
|
||||
if (value >= 0) encodeHeader(0, value)
|
||||
else encodeHeader(1, -1 - value)
|
||||
|
||||
private fun encodeLong(value: Long): List<Byte> =
|
||||
if (value >= 0) encodeHeaderLong(0, value)
|
||||
else encodeHeaderLong(1, -1 - value)
|
||||
|
||||
private fun encodeText(s: String): List<Byte> {
|
||||
val bytes = s.toByteArray(Charsets.UTF_8)
|
||||
return encodeHeader(3, bytes.size) + bytes.toList()
|
||||
}
|
||||
|
||||
private fun encodeBytes(b: ByteArray): List<Byte> =
|
||||
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<Any?> {
|
||||
val len = readUnsigned(info).toInt()
|
||||
return (0 until len).map { readValue() }
|
||||
}
|
||||
|
||||
private fun readMap(info: Int): Map<Any?, Any?> {
|
||||
val len = readUnsigned(info).toInt()
|
||||
val result = linkedMapOf<Any?, Any?>()
|
||||
repeat(len) {
|
||||
val key = readValue()
|
||||
val value = readValue()
|
||||
result[key] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
class CborMap(private val raw: Map<Any?, Any?>) {
|
||||
|
||||
companion object {
|
||||
fun decode(data: ByteArray): CborMap? {
|
||||
val decoded = CborDecoder.decode(data) as? Map<*, *> ?: return null
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return CborMap(decoded as Map<Any?, Any?>)
|
||||
}
|
||||
}
|
||||
|
||||
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<Any?, Any?>)
|
||||
}
|
||||
fun map(key: String): CborMap? = (this[key] as? Map<*, *>)?.let {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
CborMap(it as Map<Any?, Any?>)
|
||||
}
|
||||
|
||||
fun <T> list(key: Int): List<T>? {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return this[key] as? List<T>
|
||||
}
|
||||
fun <T> list(key: String): List<T>? {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return this[key] as? List<T>
|
||||
}
|
||||
|
||||
fun mapList(key: Int): List<CborMap>? = list<Map<Any?, Any?>>(key)?.map { CborMap(it) }
|
||||
fun mapList(key: String): List<CborMap>? = list<Map<Any?, Any?>>(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
|
||||
}
|
||||
@@ -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<AccountInfo>) {
|
||||
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<AccountInfo>,
|
||||
private val onItemClick: (Int) -> Unit
|
||||
) : RecyclerView.Adapter<AccountAdapter.ViewHolder>() {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<CredentialMetadata> {
|
||||
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<List<RelyingParty>> {
|
||||
if (!pinProtocol.hasPinToken()) {
|
||||
return Result.failure(Exception("PIN token not available"))
|
||||
}
|
||||
|
||||
val relyingParties = mutableListOf<RelyingParty>()
|
||||
|
||||
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<List<Credential>> {
|
||||
if (!pinProtocol.hasPinToken()) {
|
||||
return Result.failure(Exception("PIN token not available"))
|
||||
}
|
||||
|
||||
val credentials = mutableListOf<Credential>()
|
||||
|
||||
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<Unit> {
|
||||
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<Byte>()
|
||||
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<RelyingParty, Int>? {
|
||||
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<Credential, Int>? {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<Pair<String, Int>>,
|
||||
excludeList: List<ByteArray>? = 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<ByteArray>? = 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<MakeCredentialResponse> {
|
||||
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<GetAssertionResponse> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<Byte>()
|
||||
|
||||
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<Byte>()
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>): String = buildString {
|
||||
appendLine(header(title))
|
||||
appendLine()
|
||||
items.forEach { appendLine(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Format key-value pairs
|
||||
*/
|
||||
fun keyValueList(title: String, pairs: List<Pair<String, String>>): 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<RelyingPartyWithCredentials>
|
||||
)
|
||||
|
||||
data class RelyingPartyWithCredentials(
|
||||
val relyingParty: CredentialManagement.RelyingParty,
|
||||
val credentials: List<CredentialManagement.Credential>?,
|
||||
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 = "────────────────────────────────────────"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user