Initial commit

This commit is contained in:
mimi89999
2025-12-13 19:40:04 +01:00
commit 5e3cc32e57
72 changed files with 6967 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
+21
View File
@@ -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.
+40
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
/build
+49
View File
@@ -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)
}
+21
View File
@@ -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)
}
}
+56
View File
@@ -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