From fc3f59e60ee8b4e77f86bd9388193f870fa424a2 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Tue, 18 Mar 2025 17:27:33 +0100 Subject: [PATCH 01/28] initial implementation, read certificates --- android/app/build.gradle | 1 + .../com/yubico/authenticator/MainActivity.kt | 15 + .../com/yubico/authenticator/MainViewModel.kt | 5 +- .../authenticator/piv/PivConnectionHelper.kt | 106 ++++ .../yubico/authenticator/piv/PivManager.kt | 380 ++++++++++++ .../yubico/authenticator/piv/PivViewModel.kt | 49 ++ .../yubico/authenticator/piv/data/CertInfo.kt | 30 + .../yubico/authenticator/piv/data/DataExt.kt | 30 + .../piv/data/ManagementKeyMetadata.kt | 45 ++ .../authenticator/piv/data/PinMetadata.kt | 44 ++ .../yubico/authenticator/piv/data/PivSlot.kt | 15 + .../yubico/authenticator/piv/data/PivState.kt | 101 +++ .../piv/data/PivStateMetadata.kt | 30 + .../yubico/authenticator/piv/data/Session.kt | 24 + .../authenticator/piv/data/SlotMetadata.kt | 43 ++ android/build.gradle | 2 +- lib/android/init.dart | 10 +- lib/android/piv/state.dart | 576 ++++++++++++++++++ lib/piv/views/piv_screen.dart | 11 +- 19 files changed, 1512 insertions(+), 5 deletions(-) create mode 100644 android/app/src/main/kotlin/com/yubico/authenticator/piv/PivConnectionHelper.kt create mode 100644 android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt create mode 100644 android/app/src/main/kotlin/com/yubico/authenticator/piv/PivViewModel.kt create mode 100644 android/app/src/main/kotlin/com/yubico/authenticator/piv/data/CertInfo.kt create mode 100644 android/app/src/main/kotlin/com/yubico/authenticator/piv/data/DataExt.kt create mode 100644 android/app/src/main/kotlin/com/yubico/authenticator/piv/data/ManagementKeyMetadata.kt create mode 100644 android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PinMetadata.kt create mode 100644 android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivSlot.kt create mode 100644 android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivState.kt create mode 100644 android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivStateMetadata.kt create mode 100644 android/app/src/main/kotlin/com/yubico/authenticator/piv/data/Session.kt create mode 100644 android/app/src/main/kotlin/com/yubico/authenticator/piv/data/SlotMetadata.kt create mode 100644 lib/android/piv/state.dart diff --git a/android/app/build.gradle b/android/app/build.gradle index 49ab6285b..05bbb94d3 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -92,6 +92,7 @@ dependencies { api "com.yubico.yubikit:management:$project.yubiKitVersion" api "com.yubico.yubikit:oath:$project.yubiKitVersion" api "com.yubico.yubikit:fido:$project.yubiKitVersion" + api "com.yubico.yubikit:piv:$project.yubiKitVersion" api "com.yubico.yubikit:support:$project.yubiKitVersion" implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1' diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt b/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt index fe3813c7b..dda5ff905 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt @@ -53,6 +53,8 @@ import com.yubico.authenticator.management.ManagementManager import com.yubico.authenticator.oath.AppLinkMethodChannel import com.yubico.authenticator.oath.OathManager import com.yubico.authenticator.oath.OathViewModel +import com.yubico.authenticator.piv.PivManager +import com.yubico.authenticator.piv.PivViewModel import com.yubico.authenticator.yubikit.DeviceInfoHelper.Companion.getDeviceInfo import com.yubico.authenticator.yubikit.NfcState import com.yubico.authenticator.yubikit.NfcStateDispatcher @@ -91,6 +93,7 @@ class MainActivity : FlutterFragmentActivity() { } private val oathViewModel: OathViewModel by viewModels() private val fidoViewModel: FidoViewModel by viewModels() + private val pivViewModel: PivViewModel by viewModels() private val nfcConfiguration = NfcConfiguration().timeout(5000) @@ -497,6 +500,8 @@ class MainActivity : FlutterFragmentActivity() { fidoViewModel.fingerprints.streamTo(this, messenger, "android.fido.fingerprints"), fidoViewModel.resetState.streamTo(this, messenger, "android.fido.reset"), fidoViewModel.registerFingerprint.streamTo(this, messenger, "android.fido.registerFp"), + pivViewModel.state.streamTo(this, messenger, "android.piv.state"), + pivViewModel.slots.streamTo(this, messenger, "android.piv.slots") ) viewModel.appContext.observe(this) { @@ -528,6 +533,15 @@ class MainActivity : FlutterFragmentActivity() { fidoViewModel, viewModel ) + val pivContextManager = PivManager( + messenger, + deviceManager, + this, + appMethodChannel, + nfcOverlayManager, + pivViewModel, + viewModel + ) val managementContextManager = ManagementManager(messenger, deviceManager) contextManagers = mapOf( @@ -536,6 +550,7 @@ class MainActivity : FlutterFragmentActivity() { OperationContext.FidoPasskeys to fidoContextManager, OperationContext.FidoFingerprints to fidoContextManager, OperationContext.Management to managementContextManager, + OperationContext.Piv to pivContextManager, // currently not supported OperationContext.FidoU2f to homeContextManager, OperationContext.HsmAuth to homeContextManager, diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/MainViewModel.kt b/android/app/src/main/kotlin/com/yubico/authenticator/MainViewModel.kt index e1f003fe0..07d7b1c63 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/MainViewModel.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/MainViewModel.kt @@ -34,8 +34,8 @@ enum class OperationContext(val value: Int) { FidoU2f(2), FidoFingerprints(3), FidoPasskeys(4), - YubiOtp(5), - Piv(6), + Piv(5), + YubiOtp(6), OpenPgp(7), HsmAuth(8), Management(9); @@ -47,6 +47,7 @@ enum class OperationContext(val value: Int) { val capabilitiesToContext = mapOf( Capability.OATH to Oath, + Capability.PIV to Piv, Capability.FIDO2 to FidoPasskeys ) diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivConnectionHelper.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivConnectionHelper.kt new file mode 100644 index 000000000..59cad0368 --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivConnectionHelper.kt @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2025 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.yubico.authenticator.piv + +import com.yubico.authenticator.device.DeviceManager +import com.yubico.authenticator.yubikit.DeviceInfoHelper.Companion.getDeviceInfo +import com.yubico.authenticator.yubikit.withConnection +import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice +import com.yubico.yubikit.core.smartcard.SmartCardConnection +import com.yubico.yubikit.core.util.Result +import org.slf4j.LoggerFactory +import kotlin.coroutines.cancellation.CancellationException +import kotlin.coroutines.suspendCoroutine + +typealias YubiKitPivSession = com.yubico.yubikit.piv.PivSession + +class PivConnectionHelper(private val deviceManager: DeviceManager) { + private var pendingAction: PivAction? = null + + fun hasPending(): Boolean { + return pendingAction != null + } + + fun invokePending(piv: YubiKitPivSession): Boolean { + var requestHandled = true + pendingAction?.let { action -> + pendingAction = null + // it is the pending action who handles this request + requestHandled = false + action.invoke(Result.success(piv)) + } + return requestHandled + } + + fun cancelPending() { + pendingAction?.let { action -> + action.invoke(Result.failure(CancellationException())) + pendingAction = null + } + } + + suspend fun useSession( + updateDeviceInfo: Boolean = false, + block: (YubiKitPivSession) -> T + ): T { + PivManager.updateDeviceInfo.set(updateDeviceInfo) + return deviceManager.withKey( + onUsb = { useSessionUsb(it, updateDeviceInfo, block) }, + onNfc = { useSessionNfc(block) }, + onCancelled = { + pendingAction?.invoke(Result.failure(CancellationException())) + pendingAction = null + } + ) + } + + suspend fun useSessionUsb( + device: UsbYubiKeyDevice, + updateDeviceInfo: Boolean = false, + block: (YubiKitPivSession) -> T + ): T = device.withConnection { + block(YubiKitPivSession(it)) + }.also { + if (updateDeviceInfo) { + deviceManager.setDeviceInfo(runCatching { getDeviceInfo(device) }.getOrNull()) + } + } + + suspend fun useSessionNfc( + block: (YubiKitPivSession) -> T + ): Result { + try { + val result = suspendCoroutine { outer -> + pendingAction = { + outer.resumeWith(runCatching { + block.invoke(it.value) + }) + } + } + return Result.success(result!!) + } catch (cancelled: CancellationException) { + return Result.failure(cancelled) + } catch (error: Throwable) { + logger.error("Exception during action: ", error) + return Result.failure(error) + } + } + + companion object { + private val logger = LoggerFactory.getLogger(PivConnectionHelper::class.java) + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt new file mode 100644 index 000000000..daef3b0dd --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt @@ -0,0 +1,380 @@ +/* + * Copyright (C) 2025 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.yubico.authenticator.piv + +import androidx.lifecycle.LifecycleOwner +import com.yubico.authenticator.AppContextManager +import com.yubico.authenticator.MainActivity +import com.yubico.authenticator.MainViewModel +import com.yubico.authenticator.NfcOverlayManager +import com.yubico.authenticator.OperationContext +import com.yubico.authenticator.device.DeviceManager +import com.yubico.authenticator.piv.data.CertInfo +import com.yubico.authenticator.piv.data.PivSlot +import com.yubico.authenticator.piv.data.PivState +import com.yubico.authenticator.piv.data.SlotMetadata +import com.yubico.authenticator.piv.data.hexStringToByteArray +import com.yubico.authenticator.setHandler +import com.yubico.authenticator.yubikit.DeviceInfoHelper.Companion.getDeviceInfo +import com.yubico.authenticator.yubikit.withConnection +import com.yubico.yubikit.android.transport.nfc.NfcYubiKeyDevice +import com.yubico.yubikit.core.YubiKeyConnection +import com.yubico.yubikit.core.YubiKeyDevice +import com.yubico.yubikit.core.application.BadResponseException +import com.yubico.yubikit.core.smartcard.ApduException +import com.yubico.yubikit.core.smartcard.SmartCardConnection +import com.yubico.yubikit.core.util.Result +import com.yubico.yubikit.fido.ctap.Ctap2Session.InfoData +import com.yubico.yubikit.fido.ctap.PinUvAuthDummyProtocol +import com.yubico.yubikit.fido.ctap.PinUvAuthProtocol +import com.yubico.yubikit.fido.ctap.PinUvAuthProtocolV1 +import com.yubico.yubikit.fido.ctap.PinUvAuthProtocolV2 +import com.yubico.yubikit.piv.ManagementKeyType +import com.yubico.yubikit.piv.Slot +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodChannel +import org.slf4j.LoggerFactory +import java.io.IOException +import java.util.Arrays +import java.util.concurrent.atomic.AtomicBoolean + +typealias PivAction = (Result) -> Unit + +class PivManager( + messenger: BinaryMessenger, + deviceManager: DeviceManager, + lifecycleOwner: LifecycleOwner, + appMethodChannel: MainActivity.AppMethodChannel, + nfcOverlayManager: NfcOverlayManager, + private val pivViewModel: PivViewModel, + mainViewModel: MainViewModel +) : AppContextManager(deviceManager) { + + companion object { + val updateDeviceInfo = AtomicBoolean(false) + fun getPreferredPinUvAuthProtocol(infoData: InfoData): PinUvAuthProtocol { + val pinUvAuthProtocols = infoData.pinUvAuthProtocols + val pinSupported = infoData.options["clientPin"] != null + if (pinSupported) { + for (protocol in pinUvAuthProtocols) { + if (protocol == PinUvAuthProtocolV1.VERSION) { + return PinUvAuthProtocolV1() + } + if (protocol == PinUvAuthProtocolV2.VERSION) { + return PinUvAuthProtocolV2() + } + } + } + return PinUvAuthDummyProtocol() + } + } + + private val connectionHelper = PivConnectionHelper(deviceManager) + + private val pivChannel = MethodChannel(messenger, "android.piv.methods") + + private val logger = LoggerFactory.getLogger(PivManager::class.java) + + private var pinRetries: Int? = null + + init { + logger.debug("PivManager initialized") + pinRetries = null + + pivChannel.setHandler(coroutineScope) { method, args -> + when (method) { + + "reset" -> reset() + + "authenticate" -> authenticate( + (args["managementKey"] as String).hexStringToByteArray() + ) + + "verifyPin" -> verifyPin( + (args["pin"] as String).toCharArray() + ) + + "changePin" -> changePin( + (args["pin"] as String).toCharArray(), + (args["newPin"] as String).toCharArray(), + ) + + "changePuk" -> changePuk( + (args["puk"] as String).toCharArray(), + (args["newPuk"] as String).toCharArray(), + ) + + "setManagementKey" -> setManagementKey( + (args["managementKey"] as String).hexStringToByteArray(), + args["managementKeyType"] as ManagementKeyType, + args["storeKey"] as Boolean + ) + + "unblockPin" -> unblockPin( + (args["puk"] as String).toCharArray(), + (args["newPin"] as String).toCharArray(), + ) + + else -> throw NotImplementedError() + } + } + } + + override fun supports(appContext: OperationContext): Boolean = when (appContext) { + OperationContext.FidoPasskeys, OperationContext.FidoFingerprints -> true + else -> false + } + + override fun activate() { + super.activate() + logger.debug("PivManager activated") + } + + override fun deactivate() { + pivViewModel.clearState() + pivViewModel.updateSlots(null) + connectionHelper.cancelPending() + logger.debug("PivManager deactivated") + super.deactivate() + } + + override fun onError(e: Exception) { + super.onError(e) + if (connectionHelper.hasPending()) { + logger.error("Cancelling pending action. Cause: ", e) + connectionHelper.cancelPending() + } + } + + override fun hasPending(): Boolean { + return connectionHelper.hasPending() + } + + override fun dispose() { + super.dispose() + pivChannel.setMethodCallHandler(null) + logger.debug("PivManager disposed") + } + + override suspend fun processYubiKey(device: YubiKeyDevice): Boolean { + var requestHandled = true + try { + device.withConnection { connection -> + requestHandled = processYubiKey(connection, device) + } + + if (updateDeviceInfo.getAndSet(false)) { + deviceManager.setDeviceInfo(runCatching { getDeviceInfo(device) }.getOrNull()) + } + } catch (e: Exception) { + + logger.error("Cancelling pending action. Cause: ", e) + connectionHelper.cancelPending() + + if (e !is IOException) { + // we don't clear the session on IOExceptions so that the session is ready for + // a possible re-run of a failed action. + pivViewModel.clearState() + } + throw e + } + + return requestHandled + } + + private fun processYubiKey(connection: YubiKeyConnection, device: YubiKeyDevice): Boolean { + var requestHandled = true + val piv = YubiKitPivSession(connection as SmartCardConnection) + + val previousSerial = pivViewModel.currentSerial + val currentSerial = piv.serialNumber + logger.debug( + "Previous serial: {}, current serial: {}", + previousSerial, + currentSerial + ) + + val sameDevice = previousSerial.value == currentSerial + + if (device is NfcYubiKeyDevice && (sameDevice)) { + requestHandled = connectionHelper.invokePending(piv) + } else { + +// if (!sameDevice) { +// // different key +// logger.debug("This is a different key than previous, invalidating the PIN token") +// connectionHelper.cancelPending() +// } +// +// val infoData = piv.cachedInfo +// val clientPin = +// ClientPin(piv, getPreferredPinUvAuthProtocol(infoData)) +// +// pinRetries = +// if (infoData.options["clientPin"] == true) clientPin.pinRetries.count else null +// +// pivViewModel.setSessionState( +// Session(infoData, false, pinRetries) +// ) + + pivViewModel.setState(PivState(piv, false, false, false, false)) + } + + pivViewModel.updateSlots(getSlots(piv)); + + return requestHandled + } + + private suspend fun reset(): String = + connectionHelper.useSession { piv -> + piv.reset() + "" + } + + private suspend fun authenticate(managementKey: ByteArray): String = + connectionHelper.useSession { piv -> + piv.authenticate(managementKey) + "" + } + + private suspend fun verifyPin(pin: CharArray): String = + connectionHelper.useSession { piv -> + piv.verifyPin(pin) + "" + } + + private suspend fun changePin(pin: CharArray, newPin: CharArray): String = + connectionHelper.useSession(updateDeviceInfo = true) { piv -> + try { + piv.changePin(pin, newPin) + "" + } finally { + Arrays.fill(newPin, 0.toChar()) + Arrays.fill(pin, 0.toChar()) + } + } + + private suspend fun changePuk(puk: CharArray, newPuk: CharArray): String = + connectionHelper.useSession(updateDeviceInfo = true) { piv -> + try { + piv.changePuk(puk, newPuk) + "" + } finally { + Arrays.fill(newPuk, 0.toChar()) + Arrays.fill(puk, 0.toChar()) + } + } + + private suspend fun setManagementKey( + managementKey: ByteArray, + keyType: ManagementKeyType, + storeKey: Boolean + ): String = + connectionHelper.useSession(updateDeviceInfo = true) { piv -> + piv.setManagementKey(keyType, managementKey, false) // review require touch + "" + } + + private suspend fun unblockPin(puk: CharArray, newPin: CharArray): String = + connectionHelper.useSession(updateDeviceInfo = true) { piv -> + try { + piv.unblockPin(puk, newPin) + "" + } finally { + Arrays.fill(newPin, 0.toChar()) + Arrays.fill(puk, 0.toChar()) + } + } + + + /* + for slot in set(SLOT) - {SLOT.ATTESTATION}: + metadata = None + if self._has_metadata: + try: + metadata = self.session.get_slot_metadata(slot) + except (ApduError, BadResponseError): + pass + try: + certificate = self.session.get_certificate(slot) + except (ApduError, BadResponseError): + # TODO: Differentiate between none and malformed + certificate = None + self._slots[slot] = (metadata, certificate) + if self._child and _slot_for(self._child_name) not in self._slots: + self._close_child() + */ + private fun getSlots( + piv: YubiKitPivSession + ): List = + try { + val supportsMetadata = piv.supports(YubiKitPivSession.FEATURE_METADATA) + pivViewModel.updateSlots(null) + + val slotList = Slot.entries.minus(Slot.ATTESTATION).map { + val metadata: com.yubico.yubikit.piv.SlotMetadata? = if (supportsMetadata) + try { + piv.getSlotMetadata(it) + } catch (e: Exception) { + when (e) { + is ApduException, is BadResponseException -> { + null + } + + else -> throw e + } + } + else { + null + } + + val certificate = try { + piv.getCertificate(it) + } catch (e: Exception) { + when (e) { + is ApduException, is BadResponseException -> { + null + } + + else -> throw e + } + } + + PivSlot( + it.value, + if (metadata != null) { + SlotMetadata(metadata) + } else null, + if (certificate != null) { + CertInfo(certificate) + } else null, + null + ) + } + + slotList + } finally { + + } + + override fun onDisconnected() { + } + + override fun onTimeout() { + pivViewModel.clearState() + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivViewModel.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivViewModel.kt new file mode 100644 index 000000000..314a9e9e6 --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivViewModel.kt @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2025 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.yubico.authenticator.piv + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.yubico.authenticator.ViewModelData +import com.yubico.authenticator.piv.data.PivSlot +import com.yubico.authenticator.piv.data.PivState + +class PivViewModel : ViewModel() { + private val _state = MutableLiveData() + val state: LiveData = _state + + private val _currentSerial = MutableLiveData() + val currentSerial: LiveData = _currentSerial + + fun state(): PivState? = (_state.value as? ViewModelData.Value<*>)?.data as? PivState? + + fun setState(state: PivState) { + _state.postValue(ViewModelData.Value(state)) + } + + fun clearState() { + _state.postValue(ViewModelData.Empty) + } + + private val _slots = MutableLiveData?>() + val slots: LiveData?> = _slots + + fun updateSlots(slots: List?) { + _slots.postValue(slots) + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/CertInfo.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/CertInfo.kt new file mode 100644 index 000000000..debb0a662 --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/CertInfo.kt @@ -0,0 +1,30 @@ +package com.yubico.authenticator.piv.data + +import com.yubico.yubikit.piv.KeyType +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.security.cert.X509Certificate + +@Serializable +data class CertInfo( + @SerialName("key_type") + val keyType: UByte, + val subject: String, + val issuer: String, + val serial: String, + @SerialName("not_valid_before") + val notValidBefore: String, + @SerialName("not_valid_after") + val notValidAfter: String, + val fingerprint: String, +) { + constructor(certificate: X509Certificate) : this( + KeyType.fromKey(certificate.publicKey).value.toUByte(), + certificate.subjectDN.toString(), + certificate.issuerDN.toString(), + certificate.serialNumber.toByteArray().byteArrayToHexString(), + certificate.notBefore.isoFormat(), + certificate.notAfter.isoFormat(), + certificate.fingerprint() + ) +} diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/DataExt.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/DataExt.kt new file mode 100644 index 000000000..e09cc1726 --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/DataExt.kt @@ -0,0 +1,30 @@ +@file:OptIn(ExperimentalStdlibApi::class) + +package com.yubico.authenticator.piv.data + +import android.os.Build +import java.security.MessageDigest +import java.security.cert.X509Certificate +import java.text.SimpleDateFormat +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +fun ByteArray.byteArrayToHexString(): String = toHexString() + +fun String.hexStringToByteArray(): ByteArray = hexToByteArray() + +fun Date.isoFormat(): String = if (Build.VERSION.SDK_INT >= 26) { + toInstant().atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) +} else { + @Suppress("SpellCheckingInspection") + val isoFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'" + val sdf = SimpleDateFormat(isoFormat, Locale.US) + sdf.timeZone = TimeZone.getTimeZone("UTC") + sdf.format(this) +} + +fun X509Certificate.fingerprint(): String = + MessageDigest.getInstance("SHA-256").digest(encoded).byteArrayToHexString() \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/ManagementKeyMetadata.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/ManagementKeyMetadata.kt new file mode 100644 index 000000000..ea38d4992 --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/ManagementKeyMetadata.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2025 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.yubico.authenticator.piv.data + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +typealias YubiKitManagementKeyMetadata = com.yubico.yubikit.piv.ManagementKeyMetadata + + +@Serializable +data class ManagementKeyMetadata( + @SerialName("key_type") + val keyType: Byte, + @SerialName("default_value") + val defaultValue: Boolean, + @SerialName("touch_policy") + val touchPolicy: Int +) { + + companion object { + fun from(managementKeyMetadata: YubiKitManagementKeyMetadata?): ManagementKeyMetadata? = + managementKeyMetadata?.let { + ManagementKeyMetadata( + it.keyType.value, + it.isDefaultValue, + it.touchPolicy.value + ) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PinMetadata.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PinMetadata.kt new file mode 100644 index 000000000..54a1d442b --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PinMetadata.kt @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2025 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.yubico.authenticator.piv.data + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +typealias YubiKitPinMetadata = com.yubico.yubikit.piv.PinMetadata + +@Serializable +data class PinMetadata( + @SerialName("default_value") + val defaultValue: Boolean, + @SerialName("total_attempts") + val totalAttempts: Int, + @SerialName("attempts_remaining") + val attemptsRemaining: Int +) { + + companion object { + fun from(pinMetadata: YubiKitPinMetadata?): PinMetadata? = + pinMetadata?.let { + PinMetadata( + it.isDefaultValue, + it.totalAttempts, + it.attemptsRemaining + ) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivSlot.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivSlot.kt new file mode 100644 index 000000000..4bd1523d8 --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivSlot.kt @@ -0,0 +1,15 @@ +package com.yubico.authenticator.piv.data + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PivSlot( + @SerialName("slot") + val slotId: Int, + val metadata: SlotMetadata?, + @SerialName("cert_info") + val certInfo: CertInfo?, + @SerialName("public_key_match") + val publicKeyMatch: Boolean? +) diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivState.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivState.kt new file mode 100644 index 000000000..c6010ead0 --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivState.kt @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2025 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.yubico.authenticator.piv.data + +import com.yubico.authenticator.JsonSerializable +import com.yubico.authenticator.device.Version +import com.yubico.authenticator.jsonSerializer +import com.yubico.authenticator.piv.YubiKitPivSession +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PivState( + val version: Version, + val authenticated: Boolean, + @SerialName("derived_key") + val derivedKey: Boolean, + @SerialName("stored_key") + val storedKey: Boolean, + @SerialName("pin_attempts") + val pinAttempts: Int, + @SerialName("supports_bio") + val supportsBio: Boolean, + val chuid: String?, + val ccc: String?, + val metadata: PivStateMetadata? +) : JsonSerializable { + + + /* + { + "version" : [ 5, 8, 0 ], + "authenticated" : false, + "derived_key" : false, + "stored_key" : false, + "pin_attempts" : 3, + "supports_bio" : false, + "chuid" : null, + "ccc" : null, + "metadata" : { + "management_key_metadata" : { + "key_type" : "AES192", + "default_value" : false, + "touch_policy" : "NEVER" + }, + "pin_metadata" : { + "default_value" : false, + "total_attempts" : 3, + "attempts_remaining" : 3 + }, + "puk_metadata" : { + "default_value" : false, + "total_attempts" : 3, + "attempts_remaining" : 3 + } + } +} + */ + + + constructor( + piv: YubiKitPivSession, + authenticated: Boolean, + derivedKey: Boolean, + storedKey: Boolean, + supportsBio: Boolean + ) + : this( + Version( + piv.version.major, + piv.version.minor, + piv.version.micro + ), + authenticated, derivedKey, storedKey, piv.pinAttempts, supportsBio, + null, null, PivStateMetadata( + ManagementKeyMetadata.from(piv.managementKeyMetadata)!!, + PinMetadata.from(piv.pinMetadata)!!, + PinMetadata.from(piv.pukMetadata)!! + ) + + ) + + + override fun toJson(): String { + return jsonSerializer.encodeToString(this) + } +} diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivStateMetadata.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivStateMetadata.kt new file mode 100644 index 000000000..d548234e9 --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivStateMetadata.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2025 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.yubico.authenticator.piv.data + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PivStateMetadata( + @SerialName("management_key_metadata") + val managementKeyMetadata: ManagementKeyMetadata, + @SerialName("pin_metadata") + val pinMetadata: PinMetadata, + @SerialName("puk_metadata") + val pukMetadata: PinMetadata +) diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/Session.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/Session.kt new file mode 100644 index 000000000..e35951d59 --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/Session.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2024 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.yubico.authenticator.piv.data + +import com.yubico.authenticator.JsonSerializable +import com.yubico.authenticator.jsonSerializer +import com.yubico.yubikit.fido.ctap.Ctap2Session.InfoData +import kotlinx.serialization.* + + diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/SlotMetadata.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/SlotMetadata.kt new file mode 100644 index 000000000..b806e2154 --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/SlotMetadata.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2025 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.yubico.authenticator.piv.data + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +typealias YubikitPivSlotMetadata = com.yubico.yubikit.piv.SlotMetadata + +@Serializable +data class SlotMetadata( + @SerialName("key_type") + val keyType: UByte, + @SerialName("pin_policy") + val pinPolicy: Int, + @SerialName("touch_policy") + val touchPolicy: Int, + val generated: Boolean, + @SerialName("public_key") + val publicKey: String +) { + constructor(slotMetadata: YubikitPivSlotMetadata) : this( + slotMetadata.keyType.value.toUByte(), + slotMetadata.pinPolicy.value, + slotMetadata.touchPolicy.value, + slotMetadata.isGenerated, + slotMetadata.publicKeyValues.toString() + ) +} diff --git a/android/build.gradle b/android/build.gradle index fc0719d19..64bf60eba 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -9,7 +9,7 @@ allprojects { targetSdkVersion = 35 compileSdkVersion = 35 - yubiKitVersion = "2.8.1" + yubiKitVersion = "2.8.2" junitVersion = "4.13.2" mockitoVersion = "5.18.0" } diff --git a/lib/android/init.dart b/lib/android/init.dart index 2eeb13581..5cb20cb24 100644 --- a/lib/android/init.dart +++ b/lib/android/init.dart @@ -34,6 +34,7 @@ import '../core/state.dart'; import '../fido/state.dart'; import '../management/state.dart'; import '../oath/state.dart'; +import '../piv/state.dart'; import 'app_methods.dart'; import 'fido/state.dart'; import 'logger.dart'; @@ -42,6 +43,7 @@ import 'oath/otp_auth_link_handler.dart'; import 'oath/state.dart'; import 'overlay/nfc/nfc_event_notifier.dart'; import 'overlay/nfc/nfc_overlay.dart'; +import 'piv/state.dart'; import 'qr_scanner/qr_scanner_provider.dart'; import 'state.dart'; import 'window_state_provider.dart'; @@ -91,6 +93,7 @@ Future initialize({Level? level}) async { Section.accounts, Section.fingerprints, Section.passkeys, + Section.certificates, ]), // this specifies the priority of sections to show when // the connected YubiKey does not support current section @@ -98,6 +101,7 @@ Future initialize({Level? level}) async { Section.accounts, Section.fingerprints, Section.passkeys, + Section.certificates, Section.home, ]), supportedThemesProvider.overrideWith( @@ -105,6 +109,10 @@ Future initialize({Level? level}) async { ), defaultColorProvider.overrideWithValue(await getPrimaryColor()), + // PIV + pivStateProvider.overrideWithProvider(androidPivState.call), + pivSlotsProvider.overrideWithProvider(androidPivSlots.call), + // FIDO fidoStateProvider.overrideWithProvider(androidFidoStateProvider.call), fingerprintProvider.overrideWithProvider(androidFingerprintProvider.call), @@ -121,7 +129,7 @@ Future initialize({Level? level}) async { // TODO: Load feature flags from file/config? //..loadConfig(config) // Disable unimplemented feature - ..setFeature(features.piv, false) + ..setFeature(features.piv, true) ..setFeature(features.otp, false) ..setFeature(features.management, true); }); diff --git a/lib/android/piv/state.dart b/lib/android/piv/state.dart new file mode 100644 index 000000000..6f3ae411c --- /dev/null +++ b/lib/android/piv/state.dart @@ -0,0 +1,576 @@ +/* + * Copyright (C) 2025 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logging/logging.dart'; + +import '../../app/models.dart'; +import '../../app/state.dart'; +import '../../app/views/user_interaction.dart'; +import '../../exception/no_data_exception.dart'; +import '../../piv/models.dart'; +import '../../piv/state.dart'; +import '../overlay/nfc/method_channel_notifier.dart' show MethodChannelNotifier; + +final _log = Logger('android.piv.state'); + +final _managementKeyProvider = StateProvider.autoDispose + .family((ref, _) => null); + +final _pinProvider = StateProvider.autoDispose.family( + (ref, _) => null, +); + +final androidPivState = AsyncNotifierProvider.autoDispose + .family( + _AndroidPivStateNotifier.new, + ); + +class _AndroidPivStateNotifier extends PivStateNotifier { + late DevicePath _devicePath; + + final _events = const EventChannel('android.piv.state'); + late StreamSubscription _sub; + late _PivMethodChannelNotifier piv = ref.watch(_pivMethodsProvider.notifier); + + @override + FutureOr build(DevicePath devicePath) async { + _sub = _events.receiveBroadcastStream().listen( + (event) { + final json = jsonDecode(event); + if (json == null) { + state = AsyncValue.error(const NoDataException(), StackTrace.current); + } else if (json == 'loading') { + state = const AsyncValue.loading(); + } else { + final pivState = PivState.fromJson(json); + state = AsyncValue.data(pivState); + } + }, + onError: (err, stackTrace) { + state = AsyncValue.error(err, stackTrace); + }, + ); + + ref.onDispose(_sub.cancel); + + return Completer().future; + + // _session = ref.watch(_sessionProvider(devicePath)); + // _session + // ..setErrorHandler('state-reset', (_) async { + // ref.invalidate(_sessionProvider(devicePath)); + // }) + // ..setErrorHandler('auth-required', (e) async { + // try { + // if (state.valueOrNull?.protectedKey == true) { + // final String? pin; + // if (state.valueOrNull?.metadata?.pinMetadata.defaultValue == true) { + // pin = defaultPin; + // } else { + // pin = ref.read(_pinProvider(devicePath)); + // } + // if (pin != null) { + // if (await verifyPin(pin) is PinSuccess) { + // return; + // } else { + // ref.read(_pinProvider(devicePath).notifier).state = null; + // } + // } + // } else { + // final String? mgmtKey; + // if (state + // .valueOrNull + // ?.metadata + // ?.managementKeyMetadata + // .defaultValue == + // true) { + // mgmtKey = defaultManagementKey; + // } else { + // mgmtKey = ref.read(_managementKeyProvider(devicePath)); + // } + // if (mgmtKey != null) { + // if (await authenticate(mgmtKey)) { + // return; + // } else { + // ref.read(_managementKeyProvider(devicePath).notifier).state = + // null; + // } + // } + // } + // throw e; + // } finally { + // ref.invalidateSelf(); + // } + // }); + // ref.onDispose(() { + // _session + // ..unsetErrorHandler('state-reset') + // ..unsetErrorHandler('auth-required'); + // }); + // _devicePath = devicePath; + // + // final result = await _session.command('get'); + // _log.debug('application status', jsonEncode(result)); + //final pivState = PivState.fromJson({}); + + //return pivState; + } + + @override + Future reset() async { + // await _session.command('reset'); + // ref.read(_managementKeyProvider(_devicePath).notifier).state = null; + // ref.invalidate(_sessionProvider(_session.devicePath)); + } + + @override + Future authenticate(String managementKey) async { + final withContext = ref.watch(withContextProvider); + + // final signaler = Signaler(); + UserInteractionController? controller; + try { + // signaler.signals.listen((signal) async { + // if (signal.status == 'touch') { + // controller = await withContext((context) async { + // final l10n = AppLocalizations.of(context); + // return promptUserInteraction( + // context, + // icon: const Icon(Symbols.touch_app), + // title: l10n.s_touch_required, + // description: l10n.l_touch_button_now, + // ); + // }); + // } + //}); + + // final result = await _session.command( + // 'authenticate', + // params: {'key': managementKey}, + // signal: signaler, + // ); + + // if (result['status']) { + // ref.read(_managementKeyProvider(_devicePath).notifier).state = + // managementKey; + // final oldState = state.valueOrNull; + // if (oldState != null) { + // state = AsyncData(oldState.copyWith(authenticated: true)); + // } + // return true; + // } else { + // return false; + // } + return true; + } finally { + controller?.close(); + } + } + + @override + Future verifyPin(String pin) async { + final pivState = state.valueOrNull; + + // final signaler = Signaler(); + UserInteractionController? controller; + try { + // if (pivState?.protectedKey == true) { + // // Might require touch as this will also authenticate + // final withContext = ref.watch(withContextProvider); + // signaler.signals.listen((signal) async { + // if (signal.status == 'touch') { + // controller = await withContext((context) async { + // final l10n = AppLocalizations.of(context); + // return promptUserInteraction( + // context, + // icon: const Icon(Symbols.touch_app), + // title: l10n.s_touch_required, + // description: l10n.l_touch_button_now, + // ); + // }); + // } + // }); + // } + // await _session.command( + // 'verify_pin', + // params: {'pin': pin}, + // signal: signaler, + // ); + // + // ref.read(_pinProvider(_devicePath).notifier).state = pin; + + return const PinVerificationStatus.success(); + } on PlatformException catch (e) { + // TODO + if (e.message == 'invalid-pin') { + // TODO + return PinVerificationStatus.failure( + PivPinFailureReason.invalidPin( + // TODO e.body['attempts_remaining']), + 3, + ), + ); + } + rethrow; + } finally { + controller?.close(); + ref.invalidateSelf(); + } + } + + @override + Future changePin(String pin, String newPin) async { + try { + // await _session.command( + // 'change_pin', + // params: {'pin': pin, 'new_pin': newPin}, + // ); + // ref.read(_pinProvider(_devicePath).notifier).state = null; + return const PinVerificationStatus.success(); + } on PlatformException catch (e) { + // TODO + if (e.message == 'invalid-pin') { + // TODO + return PinVerificationStatus.failure( + PivPinFailureReason.invalidPin( + // TODO e.body['attempts_remaining']), + 3, + ), + ); + } + if (e.message == 'pin-complexity') { + return PinVerificationStatus.failure( + const PivPinFailureReason.weakPin(), + ); + } + rethrow; + } finally { + ref.invalidateSelf(); + } + } + + @override + Future changePuk(String puk, String newPuk) async { + try { + // await _session.command( + // 'change_puk', + // params: {'puk': puk, 'new_puk': newPuk}, + // ); + return const PinVerificationStatus.success(); + } on PlatformException catch (e) { + // TODO + if (e.message == 'invalid-pin') { + return PinVerificationStatus.failure( + PivPinFailureReason.invalidPin( + // TODO e.body['attempts_remaining']), + 3, + ), + ); + } + if (e.message == 'pin-complexity') { + // TODO + return PinVerificationStatus.failure( + const PivPinFailureReason.weakPin(), + ); + } + rethrow; + } finally { + ref.invalidateSelf(); + } + } + + @override + Future setManagementKey( + String managementKey, { + ManagementKeyType managementKeyType = defaultManagementKeyType, + bool storeKey = false, + }) async { + // await _session.command( + // 'set_key', + // params: { + // 'key': managementKey, + // 'key_type': managementKeyType.value, + // 'store_key': storeKey, + // }, + // ); + ref.read(_managementKeyProvider(_devicePath).notifier).state = + managementKey; + ref.invalidateSelf(); + } + + @override + Future unblockPin(String puk, String newPin) async { + try { + // await _session.command( + // 'unblock_pin', + // params: {'puk': puk, 'new_pin': newPin}, + // ); + return const PinVerificationStatus.success(); + } on PlatformException catch (e) { + // TODO + if (e.message == 'invalid-pin') { + // TODO + return PinVerificationStatus.failure( + PivPinFailureReason.invalidPin( + // TODO e.body['attempts_remaining']), + 3, + ), + ); + } + if (e.message == 'pin-complexity') { + // TODO + return PinVerificationStatus.failure( + const PivPinFailureReason.weakPin(), + ); + } + rethrow; + } finally { + ref.invalidateSelf(); + } + } +} + +final _shownSlots = SlotId.values.map((slot) => slot.id).toList(); + +final androidPivSlots = AsyncNotifierProvider.autoDispose + .family, DevicePath>( + _AndroidPivSlotsNotifier.new, + ); + +class _AndroidPivSlotsNotifier extends PivSlotsNotifier { + final _events = const EventChannel('android.piv.slots'); + late StreamSubscription _sub; + late _PivMethodChannelNotifier piv = ref.watch(_pivMethodsProvider.notifier); + + @override + FutureOr> build(DevicePath devicePath) async { + _sub = _events.receiveBroadcastStream().listen( + (event) { + final json = jsonDecode(event); + if (json == null) { + state = AsyncValue.error(const NoDataException(), StackTrace.current); + } else if (json == 'loading') { + state = const AsyncValue.loading(); + } else { + final json = jsonDecode(event); + List? slots = + json != null + ? List.from( + (json as List).map((e) => PivSlot.fromJson(e)).toList(), + ) + : []; + + state = AsyncValue.data(slots); + } + }, + onError: (err, stackTrace) { + state = AsyncValue.error(err, stackTrace); + }, + ); + + ref.onDispose(_sub.cancel); + + return Completer>().future; + + // _session = ref.watch(_sessionProvider(devicePath)); + // + // final result = await _session.command('get', target: ['slots']); + // return (result['children'] as Map).values + // .where((e) => _shownSlots.contains(e['slot'])) + // .map((e) => PivSlot.fromJson(e)) + // .toList(); + return []; + } + + @override + Future delete(SlotId slot, bool deleteCert, bool deleteKey) async { + // await _session.command( + // 'delete', + // target: ['slots', slot.hexId], + // params: {'delete_cert': deleteCert, 'delete_key': deleteKey}, + // ); + // ref.invalidateSelf(); + } + + @override + Future moveKey( + SlotId source, + SlotId destination, + bool overwriteKey, + bool includeCertificate, + ) async { + // await _session.command( + // 'move_key', + // target: ['slots', source.hexId], + // params: { + // 'destination': destination.hexId, + // 'overwrite_key': overwriteKey, + // 'include_certificate': includeCertificate, + // }, + // ); + // ref.invalidateSelf(); + } + + @override + Future generate( + SlotId slot, + KeyType keyType, { + required PivGenerateParameters parameters, + PinPolicy pinPolicy = PinPolicy.dfault, + TouchPolicy touchPolicy = TouchPolicy.dfault, + String? pin, + }) async { + // final withContext = ref.watch(withContextProvider); + // + // final signaler = Signaler(); + // UserInteractionController? controller; + // try { + // signaler.signals.listen((signal) async { + // if (signal.status == 'touch') { + // controller = await withContext((context) async { + // final l10n = AppLocalizations.of(context); + // return promptUserInteraction( + // context, + // icon: const Icon(Symbols.touch_app), + // title: l10n.s_touch_required, + // description: l10n.l_touch_button_now, + // ); + // }); + // } + // }); + // + // final (type, subject, validFrom, validTo) = parameters.when( + // publicKey: () => (GenerateType.publicKey, null, null, null), + // certificate: + // (subject, validFrom, validTo) => ( + // GenerateType.certificate, + // subject, + // dateFormatter.format(validFrom), + // dateFormatter.format(validTo), + // ), + // csr: (subject) => (GenerateType.csr, subject, null, null), + // ); + // + // final pin = ref.read(_pinProvider(_session.devicePath)); + // + // final result = await _session.command( + // 'generate', + // target: ['slots', slot.hexId], + // params: { + // 'key_type': keyType.value, + // 'pin_policy': pinPolicy.value, + // 'touch_policy': touchPolicy.value, + // 'subject': subject, + // 'generate_type': type.name, + // 'valid_from': validFrom, + // 'valid_to': validTo, + // 'pin': pin, + // }, + // signal: signaler, + // ); + // + // ref.invalidateSelf(); + // + // return PivGenerateResult.fromJson({ + // 'generate_type': type.name, + // ...result, + // }); + // } finally { + // controller?.close(); + // } + return PivGenerateResult.fromJson({}); + } + + @override + Future examine( + SlotId slot, + String data, { + String? password, + }) async { + // final result = await _session.command( + // 'examine_file', + // target: ['slots', slot.hexId], + // params: {'data': data, 'password': password}, + // ); + // + // if (result['status']) { + // return PivExamineResult.fromJson({'runtimeType': 'result', ...result}); + // } else { + // return PivExamineResult.invalidPassword(); + // } + return PivExamineResult.invalidPassword(); + } + + @override + Future validateRfc4514(String value) async { + // final result = await _session.command( + // 'validate_rfc4514', + // params: {'data': value}, + // ); + // return result['status']; + return true; + } + + @override + Future import( + SlotId slot, + String data, { + String? password, + PinPolicy pinPolicy = PinPolicy.dfault, + TouchPolicy touchPolicy = TouchPolicy.dfault, + }) async { + // final result = await _session.command( + // 'import_file', + // target: ['slots', slot.hexId], + // params: { + // 'data': data, + // 'password': password, + // 'pin_policy': pinPolicy.value, + // 'touch_policy': touchPolicy.value, + // }, + // ); + // + // ref.invalidateSelf(); + // return PivImportResult.fromJson(result); + return PivImportResult.fromJson({}); + } + + @override + Future<(SlotMetadata?, String?)> read(SlotId slot) async { + // final result = await _session.command('get', target: ['slots', slot.hexId]); + // final data = result['data']; + // final metadata = data['metadata']; + // return ( + // metadata != null ? SlotMetadata.fromJson(metadata) : null, + // data['certificate'] as String?, + // ); + return (SlotMetadata.fromJson({}), ''); + } +} + +final _pivMethodsProvider = NotifierProvider<_PivMethodChannelNotifier, void>( + () => _PivMethodChannelNotifier(), +); + +class _PivMethodChannelNotifier extends MethodChannelNotifier { + _PivMethodChannelNotifier() + : super(const MethodChannel('android.piv.methods')); +} diff --git a/lib/piv/views/piv_screen.dart b/lib/piv/views/piv_screen.dart index a7716354c..60dbc3101 100644 --- a/lib/piv/views/piv_screen.dart +++ b/lib/piv/views/piv_screen.dart @@ -29,8 +29,10 @@ import '../../app/views/app_failure_page.dart'; import '../../app/views/app_list_item.dart'; import '../../app/views/app_page.dart'; import '../../app/views/message_page.dart'; +import '../../app/views/message_page_not_initialized.dart'; import '../../app/views/reset_dialog.dart'; import '../../core/state.dart'; +import '../../exception/no_data_exception.dart'; import '../../generated/l10n/app_localizations.dart'; import '../../management/models.dart'; import '../../widgets/list_title.dart'; @@ -80,7 +82,14 @@ class _PivScreenState extends ConsumerState { graphic: const CircularProgressIndicator(), delayedContent: true, ), - error: (error, _) => AppFailurePage(cause: error), + error: + (error, _) => + error is NoDataException + ? MessagePageNotInitialized( + title: l10n.s_certificates, + capabilities: const [Capability.piv], + ) + : AppFailurePage(cause: error), data: (pivState) { final pivSlots = ref.watch(pivSlotsProvider(widget.data.node.path)).asData; From d3b2432388b0aec133a1d3bbd04ee59f09e26fd8 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Fri, 25 Jul 2025 07:34:57 +0200 Subject: [PATCH 02/28] code style fixes --- .../yubico/authenticator/piv/data/CertInfo.kt | 16 ++++++ .../yubico/authenticator/piv/data/DataExt.kt | 23 +++++++- .../piv/data/ManagementKeyMetadata.kt | 16 ++---- .../authenticator/piv/data/PinMetadata.kt | 16 ++---- .../yubico/authenticator/piv/data/PivSlot.kt | 16 ++++++ .../yubico/authenticator/piv/data/PivState.kt | 52 +++++-------------- .../yubico/authenticator/piv/data/Session.kt | 24 --------- 7 files changed, 75 insertions(+), 88 deletions(-) delete mode 100644 android/app/src/main/kotlin/com/yubico/authenticator/piv/data/Session.kt diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/CertInfo.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/CertInfo.kt index debb0a662..364610171 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/CertInfo.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/CertInfo.kt @@ -1,3 +1,19 @@ +/* + * Copyright (C) 2025 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.yubico.authenticator.piv.data import com.yubico.yubikit.piv.KeyType diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/DataExt.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/DataExt.kt index e09cc1726..d6ef70f27 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/DataExt.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/DataExt.kt @@ -1,4 +1,18 @@ -@file:OptIn(ExperimentalStdlibApi::class) +/* + * Copyright (C) 2025 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package com.yubico.authenticator.piv.data @@ -12,8 +26,10 @@ import java.util.Date import java.util.Locale import java.util.TimeZone +@OptIn(ExperimentalStdlibApi::class) fun ByteArray.byteArrayToHexString(): String = toHexString() +@OptIn(ExperimentalStdlibApi::class) fun String.hexStringToByteArray(): ByteArray = hexToByteArray() fun Date.isoFormat(): String = if (Build.VERSION.SDK_INT >= 26) { @@ -27,4 +43,7 @@ fun Date.isoFormat(): String = if (Build.VERSION.SDK_INT >= 26) { } fun X509Certificate.fingerprint(): String = - MessageDigest.getInstance("SHA-256").digest(encoded).byteArrayToHexString() \ No newline at end of file + MessageDigest + .getInstance("SHA-256") + .digest(encoded) + .byteArrayToHexString() \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/ManagementKeyMetadata.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/ManagementKeyMetadata.kt index ea38d4992..a84e724fc 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/ManagementKeyMetadata.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/ManagementKeyMetadata.kt @@ -31,15 +31,9 @@ data class ManagementKeyMetadata( @SerialName("touch_policy") val touchPolicy: Int ) { - - companion object { - fun from(managementKeyMetadata: YubiKitManagementKeyMetadata?): ManagementKeyMetadata? = - managementKeyMetadata?.let { - ManagementKeyMetadata( - it.keyType.value, - it.isDefaultValue, - it.touchPolicy.value - ) - } - } + constructor(managementKeyMetadata: YubiKitManagementKeyMetadata) : this( + managementKeyMetadata.keyType.value, + managementKeyMetadata.isDefaultValue, + managementKeyMetadata.touchPolicy.value + ) } \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PinMetadata.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PinMetadata.kt index 54a1d442b..a9ad1bf21 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PinMetadata.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PinMetadata.kt @@ -30,15 +30,9 @@ data class PinMetadata( @SerialName("attempts_remaining") val attemptsRemaining: Int ) { - - companion object { - fun from(pinMetadata: YubiKitPinMetadata?): PinMetadata? = - pinMetadata?.let { - PinMetadata( - it.isDefaultValue, - it.totalAttempts, - it.attemptsRemaining - ) - } - } + constructor(pinMetadata: YubiKitPinMetadata) : this( + pinMetadata.isDefaultValue, + pinMetadata.totalAttempts, + pinMetadata.attemptsRemaining + ) } \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivSlot.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivSlot.kt index 4bd1523d8..7b70add1c 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivSlot.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivSlot.kt @@ -1,3 +1,19 @@ +/* + * Copyright (C) 2025 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.yubico.authenticator.piv.data import kotlinx.serialization.SerialName diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivState.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivState.kt index c6010ead0..893f33bbb 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivState.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivState.kt @@ -40,61 +40,33 @@ data class PivState( val metadata: PivStateMetadata? ) : JsonSerializable { - - /* - { - "version" : [ 5, 8, 0 ], - "authenticated" : false, - "derived_key" : false, - "stored_key" : false, - "pin_attempts" : 3, - "supports_bio" : false, - "chuid" : null, - "ccc" : null, - "metadata" : { - "management_key_metadata" : { - "key_type" : "AES192", - "default_value" : false, - "touch_policy" : "NEVER" - }, - "pin_metadata" : { - "default_value" : false, - "total_attempts" : 3, - "attempts_remaining" : 3 - }, - "puk_metadata" : { - "default_value" : false, - "total_attempts" : 3, - "attempts_remaining" : 3 - } - } -} - */ - - constructor( piv: YubiKitPivSession, authenticated: Boolean, derivedKey: Boolean, storedKey: Boolean, supportsBio: Boolean - ) - : this( + ) : this( Version( piv.version.major, piv.version.minor, piv.version.micro ), - authenticated, derivedKey, storedKey, piv.pinAttempts, supportsBio, - null, null, PivStateMetadata( - ManagementKeyMetadata.from(piv.managementKeyMetadata)!!, - PinMetadata.from(piv.pinMetadata)!!, - PinMetadata.from(piv.pukMetadata)!! + authenticated, + derivedKey, + storedKey, + piv.pinAttempts, + supportsBio, + null, + null, + PivStateMetadata( + ManagementKeyMetadata(piv.managementKeyMetadata), + PinMetadata(piv.pinMetadata), + PinMetadata(piv.pukMetadata) ) ) - override fun toJson(): String { return jsonSerializer.encodeToString(this) } diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/Session.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/Session.kt deleted file mode 100644 index e35951d59..000000000 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/Session.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (C) 2024 Yubico. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.yubico.authenticator.piv.data - -import com.yubico.authenticator.JsonSerializable -import com.yubico.authenticator.jsonSerializer -import com.yubico.yubikit.fido.ctap.Ctap2Session.InfoData -import kotlinx.serialization.* - - From 8bb013c6c3a3131fa85e906a521dedf0c16215d1 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Fri, 25 Jul 2025 07:35:59 +0200 Subject: [PATCH 03/28] add missing cases for certificates --- .../src/main/kotlin/com/yubico/authenticator/AppPreferences.kt | 1 + .../app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt | 1 + 2 files changed, 2 insertions(+) diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/AppPreferences.kt b/android/app/src/main/kotlin/com/yubico/authenticator/AppPreferences.kt index 5ec151efb..a73b850f1 100755 --- a/android/app/src/main/kotlin/com/yubico/authenticator/AppPreferences.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/AppPreferences.kt @@ -76,6 +76,7 @@ class AppPreferences(context: Context) { "passkeys" -> OperationContext.FidoPasskeys "fingerprints" -> OperationContext.FidoFingerprints "accounts" -> OperationContext.Oath + "certificates" -> OperationContext.Piv else -> OperationContext.Default } diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt b/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt index dda5ff905..2ec7deef6 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt @@ -420,6 +420,7 @@ class MainActivity : FlutterFragmentActivity() { OperationContext.Oath, OperationContext.FidoPasskeys, OperationContext.FidoFingerprints, + OperationContext.Piv, OperationContext.Home ) From 41897d3a7f6e8a04a961e5c5379f11409c22fdc5 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Fri, 25 Jul 2025 10:29:20 +0200 Subject: [PATCH 04/28] add method channel stubs --- .../yubico/authenticator/piv/PivManager.kt | 240 +++++++++++------- lib/android/piv/state.dart | 192 ++++++-------- 2 files changed, 224 insertions(+), 208 deletions(-) diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt index daef3b0dd..0dbc8fd4f 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt @@ -38,11 +38,6 @@ import com.yubico.yubikit.core.application.BadResponseException import com.yubico.yubikit.core.smartcard.ApduException import com.yubico.yubikit.core.smartcard.SmartCardConnection import com.yubico.yubikit.core.util.Result -import com.yubico.yubikit.fido.ctap.Ctap2Session.InfoData -import com.yubico.yubikit.fido.ctap.PinUvAuthDummyProtocol -import com.yubico.yubikit.fido.ctap.PinUvAuthProtocol -import com.yubico.yubikit.fido.ctap.PinUvAuthProtocolV1 -import com.yubico.yubikit.fido.ctap.PinUvAuthProtocolV2 import com.yubico.yubikit.piv.ManagementKeyType import com.yubico.yubikit.piv.Slot import io.flutter.plugin.common.BinaryMessenger @@ -66,21 +61,6 @@ class PivManager( companion object { val updateDeviceInfo = AtomicBoolean(false) - fun getPreferredPinUvAuthProtocol(infoData: InfoData): PinUvAuthProtocol { - val pinUvAuthProtocols = infoData.pinUvAuthProtocols - val pinSupported = infoData.options["clientPin"] != null - if (pinSupported) { - for (protocol in pinUvAuthProtocols) { - if (protocol == PinUvAuthProtocolV1.VERSION) { - return PinUvAuthProtocolV1() - } - if (protocol == PinUvAuthProtocolV2.VERSION) { - return PinUvAuthProtocolV2() - } - } - } - return PinUvAuthDummyProtocol() - } } private val connectionHelper = PivConnectionHelper(deviceManager) @@ -119,8 +99,8 @@ class PivManager( ) "setManagementKey" -> setManagementKey( - (args["managementKey"] as String).hexStringToByteArray(), - args["managementKeyType"] as ManagementKeyType, + (args["key"] as String).hexStringToByteArray(), + args["keyType"] as ManagementKeyType, args["storeKey"] as Boolean ) @@ -129,13 +109,48 @@ class PivManager( (args["newPin"] as String).toCharArray(), ) + "delete" -> delete( + (args["slot"] as String), + (args["deleteCert"] as Boolean), + (args["deleteKey"] as Boolean), + ) + + "moveKey" -> moveKey( + (args["slot"] as String), + (args["destination"] as String), + (args["overwriteKey"] as Boolean), + (args["includeCertificate"] as Boolean), + ) + + "examineFile" -> examineFile( + (args["slot"] as String), + (args["data"] as String), + (args["password"] as String), + ) + + "validateRfc4514" -> validateRfc4514( + (args["data"] as String), + ) + + "importFile" -> importFile( + (args["slot"] as String), + (args["data"] as String), + (args["password"] as String), + (args["pinPolicy"] as Int), + (args["touchPolicy"] as Int), + ) + + "getSlot" -> getSlot( + (args["slot"] as String), + ) + else -> throw NotImplementedError() } } } override fun supports(appContext: OperationContext): Boolean = when (appContext) { - OperationContext.FidoPasskeys, OperationContext.FidoFingerprints -> true + OperationContext.Piv -> true else -> false } @@ -210,28 +225,24 @@ class PivManager( val sameDevice = previousSerial.value == currentSerial - if (device is NfcYubiKeyDevice && (sameDevice)) { + if (device is NfcYubiKeyDevice && sameDevice) { requestHandled = connectionHelper.invokePending(piv) } else { -// if (!sameDevice) { -// // different key -// logger.debug("This is a different key than previous, invalidating the PIN token") -// connectionHelper.cancelPending() -// } -// -// val infoData = piv.cachedInfo -// val clientPin = -// ClientPin(piv, getPreferredPinUvAuthProtocol(infoData)) -// -// pinRetries = -// if (infoData.options["clientPin"] == true) clientPin.pinRetries.count else null -// -// pivViewModel.setSessionState( -// Session(infoData, false, pinRetries) -// ) - - pivViewModel.setState(PivState(piv, false, false, false, false)) + if (!sameDevice) { + // different key + logger.debug("This is a different key than previous, invalidating the PIN token") + connectionHelper.cancelPending() + } + pivViewModel.setState( + PivState( + piv, + authenticated = false, + derivedKey = false, + storedKey = false, + supportsBio = false + ) + ) } pivViewModel.updateSlots(getSlots(piv)); @@ -300,68 +311,21 @@ class PivManager( } } - - /* - for slot in set(SLOT) - {SLOT.ATTESTATION}: - metadata = None - if self._has_metadata: - try: - metadata = self.session.get_slot_metadata(slot) - except (ApduError, BadResponseError): - pass - try: - certificate = self.session.get_certificate(slot) - except (ApduError, BadResponseError): - # TODO: Differentiate between none and malformed - certificate = None - self._slots[slot] = (metadata, certificate) - if self._child and _slot_for(self._child_name) not in self._slots: - self._close_child() - */ - private fun getSlots( - piv: YubiKitPivSession - ): List = + private fun getSlots(piv: YubiKitPivSession): List = try { val supportsMetadata = piv.supports(YubiKitPivSession.FEATURE_METADATA) pivViewModel.updateSlots(null) val slotList = Slot.entries.minus(Slot.ATTESTATION).map { - val metadata: com.yubico.yubikit.piv.SlotMetadata? = if (supportsMetadata) - try { - piv.getSlotMetadata(it) - } catch (e: Exception) { - when (e) { - is ApduException, is BadResponseException -> { - null - } - - else -> throw e - } - } - else { - null - } - - val certificate = try { - piv.getCertificate(it) - } catch (e: Exception) { - when (e) { - is ApduException, is BadResponseException -> { - null - } - - else -> throw e - } - } + val metadata = if (supportsMetadata) { + runPivOperation { piv.getSlotMetadata(it) } + } else null + val certificate = runPivOperation { piv.getCertificate(it) } PivSlot( it.value, - if (metadata != null) { - SlotMetadata(metadata) - } else null, - if (certificate != null) { - CertInfo(certificate) - } else null, + metadata?.let(::SlotMetadata), + certificate?.let(::CertInfo), null ) } @@ -371,10 +335,94 @@ class PivManager( } + + private suspend fun delete(slot: String, deleteCert: Boolean, deleteKey: Boolean): String = + connectionHelper.useSession(updateDeviceInfo = true) { piv -> + try { + "" + } finally { + } + } + + private suspend fun moveKey( + slot: String, + destination: String, + overwriteKey: Boolean, + includeCertificate: Boolean + ): String = + connectionHelper.useSession(updateDeviceInfo = true) { piv -> + try { + "" + } finally { + } + } + + private suspend fun examineFile( + slot: String, + data: String, + password: String + ): String = + connectionHelper.useSession(updateDeviceInfo = true) { piv -> + try { + "" + } finally { + } + } + + private suspend fun validateRfc4514( + data: String + ): String = + connectionHelper.useSession(updateDeviceInfo = true) { piv -> + try { + "" + } finally { + } + } + + + private suspend fun importFile( + slot: String, + data: String, + password: String, + pinPolicy: Int, + touchPolicy: Int + ): String = + connectionHelper.useSession(updateDeviceInfo = true) { piv -> + try { + "" + } finally { + } + } + + private suspend fun getSlot( + slot: String + ): String = + connectionHelper.useSession(updateDeviceInfo = true) { piv -> + try { + "" + } finally { + } + } + override fun onDisconnected() { } override fun onTimeout() { pivViewModel.clearState() } + + /** + * Executes a PIV operation and returns null if it fails with a known, + * recoverable exception. Other exceptions are re-thrown. + */ + private fun runPivOperation(operation: () -> T): T? { + return try { + operation() + } catch (e: Exception) { + when (e) { + is ApduException, is BadResponseException -> null + else -> throw e + } + } + } } \ No newline at end of file diff --git a/lib/android/piv/state.dart b/lib/android/piv/state.dart index 6f3ae411c..c818229d8 100644 --- a/lib/android/piv/state.dart +++ b/lib/android/piv/state.dart @@ -136,9 +136,9 @@ class _AndroidPivStateNotifier extends PivStateNotifier { @override Future reset() async { - // await _session.command('reset'); - // ref.read(_managementKeyProvider(_devicePath).notifier).state = null; - // ref.invalidate(_sessionProvider(_session.devicePath)); + await piv.invoke('reset'); + ref.read(_managementKeyProvider(_devicePath).notifier).state = null; + //ref.invalidate(_sessionProvider(_session.devicePath)); } @override @@ -162,24 +162,23 @@ class _AndroidPivStateNotifier extends PivStateNotifier { // } //}); - // final result = await _session.command( - // 'authenticate', - // params: {'key': managementKey}, - // signal: signaler, - // ); - - // if (result['status']) { - // ref.read(_managementKeyProvider(_devicePath).notifier).state = - // managementKey; - // final oldState = state.valueOrNull; - // if (oldState != null) { - // state = AsyncData(oldState.copyWith(authenticated: true)); - // } - // return true; - // } else { - // return false; - // } - return true; + final result = await piv.invoke( + 'authenticate', + {'key': managementKey}, + //signal: signaler, + ); + + if (result['status']) { + ref.read(_managementKeyProvider(_devicePath).notifier).state = + managementKey; + final oldState = state.valueOrNull; + if (oldState != null) { + state = AsyncData(oldState.copyWith(authenticated: true)); + } + return true; + } else { + return false; + } } finally { controller?.close(); } @@ -209,11 +208,11 @@ class _AndroidPivStateNotifier extends PivStateNotifier { // } // }); // } - // await _session.command( - // 'verify_pin', - // params: {'pin': pin}, - // signal: signaler, - // ); + await piv.invoke( + 'verifyPin', + {'pin': pin}, + // signal: _signaler + ); // // ref.read(_pinProvider(_devicePath).notifier).state = pin; @@ -239,10 +238,7 @@ class _AndroidPivStateNotifier extends PivStateNotifier { @override Future changePin(String pin, String newPin) async { try { - // await _session.command( - // 'change_pin', - // params: {'pin': pin, 'new_pin': newPin}, - // ); + await piv.invoke('changePin', {'pin': pin, 'newPin': newPin}); // ref.read(_pinProvider(_devicePath).notifier).state = null; return const PinVerificationStatus.success(); } on PlatformException catch (e) { @@ -270,10 +266,7 @@ class _AndroidPivStateNotifier extends PivStateNotifier { @override Future changePuk(String puk, String newPuk) async { try { - // await _session.command( - // 'change_puk', - // params: {'puk': puk, 'new_puk': newPuk}, - // ); + await piv.invoke('changePuk', {'puk': puk, 'newPuk': newPuk}); return const PinVerificationStatus.success(); } on PlatformException catch (e) { // TODO @@ -303,14 +296,11 @@ class _AndroidPivStateNotifier extends PivStateNotifier { ManagementKeyType managementKeyType = defaultManagementKeyType, bool storeKey = false, }) async { - // await _session.command( - // 'set_key', - // params: { - // 'key': managementKey, - // 'key_type': managementKeyType.value, - // 'store_key': storeKey, - // }, - // ); + await piv.invoke('setManagementKey', { + 'key': managementKey, + 'keyType': managementKeyType.value, + 'storeKey': storeKey, + }); ref.read(_managementKeyProvider(_devicePath).notifier).state = managementKey; ref.invalidateSelf(); @@ -319,10 +309,7 @@ class _AndroidPivStateNotifier extends PivStateNotifier { @override Future unblockPin(String puk, String newPin) async { try { - // await _session.command( - // 'unblock_pin', - // params: {'puk': puk, 'new_pin': newPin}, - // ); + await piv.invoke('unblockPin', {'puk': puk, 'newPin': newPin}); return const PinVerificationStatus.success(); } on PlatformException catch (e) { // TODO @@ -374,7 +361,10 @@ class _AndroidPivSlotsNotifier extends PivSlotsNotifier { List? slots = json != null ? List.from( - (json as List).map((e) => PivSlot.fromJson(e)).toList(), + (json as List) + .where((e) => _shownSlots.contains(e['slot'])) + .map((e) => PivSlot.fromJson(e)) + .toList(growable: false), ) : []; @@ -389,25 +379,16 @@ class _AndroidPivSlotsNotifier extends PivSlotsNotifier { ref.onDispose(_sub.cancel); return Completer>().future; - - // _session = ref.watch(_sessionProvider(devicePath)); - // - // final result = await _session.command('get', target: ['slots']); - // return (result['children'] as Map).values - // .where((e) => _shownSlots.contains(e['slot'])) - // .map((e) => PivSlot.fromJson(e)) - // .toList(); - return []; } @override Future delete(SlotId slot, bool deleteCert, bool deleteKey) async { - // await _session.command( - // 'delete', - // target: ['slots', slot.hexId], - // params: {'delete_cert': deleteCert, 'delete_key': deleteKey}, - // ); - // ref.invalidateSelf(); + await piv.invoke('delete', { + 'slot': slot.hexId, + 'deleteCert': deleteCert, + 'deleteKey': deleteKey, + }); + ref.invalidateSelf(); } @override @@ -417,16 +398,13 @@ class _AndroidPivSlotsNotifier extends PivSlotsNotifier { bool overwriteKey, bool includeCertificate, ) async { - // await _session.command( - // 'move_key', - // target: ['slots', source.hexId], - // params: { - // 'destination': destination.hexId, - // 'overwrite_key': overwriteKey, - // 'include_certificate': includeCertificate, - // }, - // ); - // ref.invalidateSelf(); + await piv.invoke('moveKey', { + 'slot': source.hexId, + 'destination': destination.hexId, + 'overwriteKey': overwriteKey, + 'includeCertificate': includeCertificate, + }); + ref.invalidateSelf(); } @override @@ -505,28 +483,23 @@ class _AndroidPivSlotsNotifier extends PivSlotsNotifier { String data, { String? password, }) async { - // final result = await _session.command( - // 'examine_file', - // target: ['slots', slot.hexId], - // params: {'data': data, 'password': password}, - // ); - // - // if (result['status']) { - // return PivExamineResult.fromJson({'runtimeType': 'result', ...result}); - // } else { - // return PivExamineResult.invalidPassword(); - // } - return PivExamineResult.invalidPassword(); + final result = await piv.invoke('examineFile', { + 'slot': slot.hexId, + 'data': data, + 'password': password, + }); + + if (result['status']) { + return PivExamineResult.fromJson({'runtimeType': 'result', ...result}); + } else { + return PivExamineResult.invalidPassword(); + } } @override Future validateRfc4514(String value) async { - // final result = await _session.command( - // 'validate_rfc4514', - // params: {'data': value}, - // ); - // return result['status']; - return true; + final result = await piv.invoke('validateRfc4514', {'data': value}); + return result['status']; } @override @@ -537,32 +510,27 @@ class _AndroidPivSlotsNotifier extends PivSlotsNotifier { PinPolicy pinPolicy = PinPolicy.dfault, TouchPolicy touchPolicy = TouchPolicy.dfault, }) async { - // final result = await _session.command( - // 'import_file', - // target: ['slots', slot.hexId], - // params: { - // 'data': data, - // 'password': password, - // 'pin_policy': pinPolicy.value, - // 'touch_policy': touchPolicy.value, - // }, - // ); - // - // ref.invalidateSelf(); - // return PivImportResult.fromJson(result); - return PivImportResult.fromJson({}); + final result = await piv.invoke('importFile', { + 'slot': slot.hexId, + 'data': data, + 'password': password, + 'pinPolicy': pinPolicy.value, + 'touchPolicy': touchPolicy.value, + }); + + ref.invalidateSelf(); + return PivImportResult.fromJson(result); } @override Future<(SlotMetadata?, String?)> read(SlotId slot) async { - // final result = await _session.command('get', target: ['slots', slot.hexId]); - // final data = result['data']; - // final metadata = data['metadata']; - // return ( - // metadata != null ? SlotMetadata.fromJson(metadata) : null, - // data['certificate'] as String?, - // ); - return (SlotMetadata.fromJson({}), ''); + final result = await piv.invoke('getSlot', {'slot': slot.hexId}); + final data = result['data']; + final metadata = data['metadata']; + return ( + metadata != null ? SlotMetadata.fromJson(metadata) : null, + data['certificate'] as String?, + ); } } From a3d86c470a66f35d6e22d07ba6e2c744e408f277 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Fri, 25 Jul 2025 10:40:42 +0200 Subject: [PATCH 05/28] workaround precommit checks --- lib/android/piv/state.dart | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/lib/android/piv/state.dart b/lib/android/piv/state.dart index c818229d8..3826d81d5 100644 --- a/lib/android/piv/state.dart +++ b/lib/android/piv/state.dart @@ -19,24 +19,25 @@ import 'dart:convert'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:logging/logging.dart'; +// TODO import 'package:logging/logging.dart'; import '../../app/models.dart'; -import '../../app/state.dart'; -import '../../app/views/user_interaction.dart'; +// TODO import '../../app/state.dart'; +// TODO import '../../app/views/user_interaction.dart'; import '../../exception/no_data_exception.dart'; import '../../piv/models.dart'; import '../../piv/state.dart'; import '../overlay/nfc/method_channel_notifier.dart' show MethodChannelNotifier; -final _log = Logger('android.piv.state'); +// TODO final _log = Logger('android.piv.state'); final _managementKeyProvider = StateProvider.autoDispose .family((ref, _) => null); -final _pinProvider = StateProvider.autoDispose.family( - (ref, _) => null, -); +// TODO +// final _pinProvider = StateProvider.autoDispose.family( +// (ref, _) => null, +// ); final androidPivState = AsyncNotifierProvider.autoDispose .family( @@ -143,10 +144,10 @@ class _AndroidPivStateNotifier extends PivStateNotifier { @override Future authenticate(String managementKey) async { - final withContext = ref.watch(withContextProvider); + // TODO final withContext = ref.watch(withContextProvider); // final signaler = Signaler(); - UserInteractionController? controller; + // TODO UserInteractionController? controller; try { // signaler.signals.listen((signal) async { // if (signal.status == 'touch') { @@ -180,16 +181,16 @@ class _AndroidPivStateNotifier extends PivStateNotifier { return false; } } finally { - controller?.close(); + // TODO controller?.close(); } } @override Future verifyPin(String pin) async { - final pivState = state.valueOrNull; + // TODO final pivState = state.valueOrNull; // final signaler = Signaler(); - UserInteractionController? controller; + // TODO UserInteractionController? controller; try { // if (pivState?.protectedKey == true) { // // Might require touch as this will also authenticate @@ -230,7 +231,7 @@ class _AndroidPivStateNotifier extends PivStateNotifier { } rethrow; } finally { - controller?.close(); + // TODO controller?.close(); ref.invalidateSelf(); } } From d607385582281963e38462d5dff52df145f07209 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Fri, 25 Jul 2025 16:10:06 +0200 Subject: [PATCH 06/28] implement move key, delete ops (WIP) --- .../yubico/authenticator/piv/PivManager.kt | 50 ++++++++++++++++--- .../yubico/authenticator/piv/PivViewModel.kt | 8 ++- 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt index 0dbc8fd4f..6fdaa0f15 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt @@ -39,6 +39,7 @@ import com.yubico.yubikit.core.smartcard.ApduException import com.yubico.yubikit.core.smartcard.SmartCardConnection import com.yubico.yubikit.core.util.Result import com.yubico.yubikit.piv.ManagementKeyType +import com.yubico.yubikit.piv.ObjectId import com.yubico.yubikit.piv.Slot import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.MethodChannel @@ -110,14 +111,14 @@ class PivManager( ) "delete" -> delete( - (args["slot"] as String), + Slot.fromStringAlias(args["slot"] as String), (args["deleteCert"] as Boolean), (args["deleteKey"] as Boolean), ) "moveKey" -> moveKey( - (args["slot"] as String), - (args["destination"] as String), + Slot.fromStringAlias(args["slot"] as String), + Slot.fromStringAlias(args["destination"] as String), (args["overwriteKey"] as Boolean), (args["includeCertificate"] as Boolean), ) @@ -217,9 +218,10 @@ class PivManager( val previousSerial = pivViewModel.currentSerial val currentSerial = piv.serialNumber + pivViewModel.setSerial(currentSerial) logger.debug( "Previous serial: {}, current serial: {}", - previousSerial, + previousSerial.value, currentSerial ) @@ -335,28 +337,60 @@ class PivManager( } - - private suspend fun delete(slot: String, deleteCert: Boolean, deleteKey: Boolean): String = + private suspend fun delete(slot: Slot, deleteCert: Boolean, deleteKey: Boolean): String = connectionHelper.useSession(updateDeviceInfo = true) { piv -> try { + if (!deleteCert && !deleteKey) { + throw IllegalArgumentException("Missing delete option") + } + + if (deleteCert) { + piv.deleteCertificate(slot) + piv.putObject(ObjectId.CHUID, generateChuid()) + } + + if (deleteKey) { + piv.deleteKey(slot) + } "" } finally { } } private suspend fun moveKey( - slot: String, - destination: String, + src: Slot, + dst: Slot, overwriteKey: Boolean, includeCertificate: Boolean ): String = connectionHelper.useSession(updateDeviceInfo = true) { piv -> try { + + val sourceObject = if (includeCertificate) { + piv.getObject(src.objectId) + } else null + + if (overwriteKey) { + piv.deleteKey(dst) + } + + piv.moveKey(src, dst) + + sourceObject?.let { + piv.putObject(dst.objectId, it) + piv.deleteCertificate(src) + piv.putObject(ObjectId.CHUID, generateChuid()) + } "" } finally { } } + private fun generateChuid(): ByteArray { + // TODO + return ByteArray(10) + } + private suspend fun examineFile( slot: String, data: String, diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivViewModel.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivViewModel.kt index 314a9e9e6..bd2307fea 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivViewModel.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivViewModel.kt @@ -27,8 +27,12 @@ class PivViewModel : ViewModel() { private val _state = MutableLiveData() val state: LiveData = _state - private val _currentSerial = MutableLiveData() - val currentSerial: LiveData = _currentSerial + private val _currentSerial = MutableLiveData() + val currentSerial: LiveData = _currentSerial + + fun setSerial(serial: Int?) { + _currentSerial.postValue(serial) + } fun state(): PivState? = (_state.value as? ViewModelData.Value<*>)?.data as? PivState? From 9a900e050db9d362cb65757a1c10261645f4901e Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Tue, 5 Aug 2025 16:30:12 +0200 Subject: [PATCH 07/28] implement PIN ops, certificate ops, tests (WIP) --- android/app/build.gradle | 13 +- .../yubico/authenticator/piv/PivManager.kt | 331 ++++++++++++++++-- .../yubico/authenticator/piv/PivViewModel.kt | 8 + .../authenticator/piv/data/SlotMetadata.kt | 29 +- .../piv/utils/KeyMaterialUtils.kt | 222 ++++++++++++ .../piv/utils/KeyMaterialTestData.kt | 201 +++++++++++ .../piv/utils/KeyMaterialUtilsTest.kt | 87 +++++ lib/android/piv/state.dart | 310 ++++++++-------- lib/piv/views/authentication_dialog.dart | 1 + lib/piv/views/pin_dialog.dart | 1 + 10 files changed, 1019 insertions(+), 184 deletions(-) create mode 100644 android/app/src/main/kotlin/com/yubico/authenticator/piv/utils/KeyMaterialUtils.kt create mode 100644 android/app/src/test/java/com/yubico/authenticator/piv/utils/KeyMaterialTestData.kt create mode 100644 android/app/src/test/java/com/yubico/authenticator/piv/utils/KeyMaterialUtilsTest.kt diff --git a/android/app/build.gradle b/android/app/build.gradle index 05bbb94d3..7af57df1d 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -78,6 +78,12 @@ android { } } + packagingOptions { + exclude 'META-INF/versions/9/OSGI-INF/MANIFEST.MF' + exclude 'META-INF/versions/9/OSGI-INF/bcprov.provider.bc.Licenses' + exclude 'META-INF/versions/9/OSGI-INF/bcpkix.provider.bc.Licenses' + } + } apply from: "signing.gradle" @@ -98,16 +104,19 @@ dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1' // Lifecycle - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.0' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.2' implementation "androidx.core:core-ktx:1.16.0" - implementation 'androidx.fragment:fragment-ktx:1.8.7' + implementation 'androidx.fragment:fragment-ktx:1.8.8' implementation 'androidx.preference:preference-ktx:1.2.1' implementation 'com.google.android.material:material:1.12.0' implementation 'com.github.tony19:logback-android:3.0.0' + implementation 'org.bouncycastle:bcprov-jdk18on:1.80' + implementation 'org.bouncycastle:bcpkix-jdk18on:1.79' + // testing dependencies testImplementation "junit:junit:$project.junitVersion" testImplementation "org.mockito:mockito-core:$project.mockitoVersion" diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt index 6fdaa0f15..e0ff65378 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt @@ -23,11 +23,20 @@ import com.yubico.authenticator.MainViewModel import com.yubico.authenticator.NfcOverlayManager import com.yubico.authenticator.OperationContext import com.yubico.authenticator.device.DeviceManager +import com.yubico.authenticator.jsonSerializer import com.yubico.authenticator.piv.data.CertInfo import com.yubico.authenticator.piv.data.PivSlot import com.yubico.authenticator.piv.data.PivState import com.yubico.authenticator.piv.data.SlotMetadata +import com.yubico.authenticator.piv.data.byteArrayToHexString +import com.yubico.authenticator.piv.data.fingerprint import com.yubico.authenticator.piv.data.hexStringToByteArray +import com.yubico.authenticator.piv.data.isoFormat +import com.yubico.authenticator.piv.utils.InvalidPasswordException +import com.yubico.authenticator.piv.utils.KeyMaterial +import com.yubico.authenticator.piv.utils.KeyMaterialUtils.getLeafCertificates +import com.yubico.authenticator.piv.utils.KeyMaterialUtils.parse +import com.yubico.authenticator.piv.utils.KeyMaterialUtils.toPem import com.yubico.authenticator.setHandler import com.yubico.authenticator.yubikit.DeviceInfoHelper.Companion.getDeviceInfo import com.yubico.authenticator.yubikit.withConnection @@ -35,16 +44,30 @@ import com.yubico.yubikit.android.transport.nfc.NfcYubiKeyDevice import com.yubico.yubikit.core.YubiKeyConnection import com.yubico.yubikit.core.YubiKeyDevice import com.yubico.yubikit.core.application.BadResponseException +import com.yubico.yubikit.core.application.InvalidPinException +import com.yubico.yubikit.core.keys.PrivateKeyValues +import com.yubico.yubikit.core.keys.PublicKeyValues import com.yubico.yubikit.core.smartcard.ApduException +import com.yubico.yubikit.core.smartcard.SW import com.yubico.yubikit.core.smartcard.SmartCardConnection import com.yubico.yubikit.core.util.Result +import com.yubico.yubikit.piv.KeyType import com.yubico.yubikit.piv.ManagementKeyType import com.yubico.yubikit.piv.ObjectId +import com.yubico.yubikit.piv.PinPolicy +import com.yubico.yubikit.piv.PivSession import com.yubico.yubikit.piv.Slot +import com.yubico.yubikit.piv.TouchPolicy import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.MethodChannel +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import org.bouncycastle.asn1.x500.X500Name import org.slf4j.LoggerFactory import java.io.IOException +import java.security.cert.X509Certificate import java.util.Arrays import java.util.concurrent.atomic.AtomicBoolean @@ -64,6 +87,9 @@ class PivManager( val updateDeviceInfo = AtomicBoolean(false) } + private val managementKeyStorage: MutableMap = mutableMapOf() + private val pinStorage: MutableMap = mutableMapOf() + private val connectionHelper = PivConnectionHelper(deviceManager) private val pivChannel = MethodChannel(messenger, "android.piv.methods") @@ -82,7 +108,7 @@ class PivManager( "reset" -> reset() "authenticate" -> authenticate( - (args["managementKey"] as String).hexStringToByteArray() + (args["key"] as String).hexStringToByteArray() ) "verifyPin" -> verifyPin( @@ -126,17 +152,28 @@ class PivManager( "examineFile" -> examineFile( (args["slot"] as String), (args["data"] as String), - (args["password"] as String), + (args["password"] as String?), ) "validateRfc4514" -> validateRfc4514( (args["data"] as String), ) - "importFile" -> importFile( + "generate" -> generate( (args["slot"] as String), + (args["keyType"] as Int), + (args["pinPolicy"] as Int), + (args["touchPolicy"] as Int), + (args["subject"] as String?), + (args["generateType"] as String), + (args["validFrom"] as String?), + (args["validTo"] as String?) + ) + + "importFile" -> importFile( + Slot.fromStringAlias(args["slot"] as String), (args["data"] as String), - (args["password"] as String), + (args["password"] as String?), (args["pinPolicy"] as Int), (args["touchPolicy"] as Int), ) @@ -247,7 +284,7 @@ class PivManager( ) } - pivViewModel.updateSlots(getSlots(piv)); + pivViewModel.updateSlots(getSlots(piv)) return requestHandled } @@ -258,23 +295,52 @@ class PivManager( "" } + + private fun doAuth(piv: PivSession, serial: String) = + try { + val managementKey = managementKeyStorage[serial] + ?: "010203040506070801020304050607080102030405060708".hexStringToByteArray() + piv.authenticate(managementKey) + } catch (e: Exception) { + managementKeyStorage.remove(serial) + throw e + } + private suspend fun authenticate(managementKey: ByteArray): String = connectionHelper.useSession { piv -> - piv.authenticate(managementKey) - "" + try { + val serial = pivViewModel.currentSerial.value.toString() + managementKeyStorage[serial] = managementKey + doAuth(piv, serial) + jsonSerializer.encodeToString(mapOf("status" to true)) + } catch (e: Exception) { + jsonSerializer.encodeToString(mapOf("status" to false)) + } + } + + private fun doVerifyPin(piv: PivSession, serial: String) = + try { + pinStorage[serial]?.let { piv.verifyPin(it) } + } catch (e: Exception) { + pinStorage.remove(serial) + throw e } private suspend fun verifyPin(pin: CharArray): String = connectionHelper.useSession { piv -> - piv.verifyPin(pin) - "" + try { + val serial = pivViewModel.currentSerial.value.toString() + pinStorage[serial] = pin.clone() + handlePinPukErrors { doVerifyPin(piv, serial) } + } finally { + Arrays.fill(pin, 0.toChar()) + } } private suspend fun changePin(pin: CharArray, newPin: CharArray): String = connectionHelper.useSession(updateDeviceInfo = true) { piv -> try { - piv.changePin(pin, newPin) - "" + handlePinPukErrors { piv.changePin(pin, newPin) } } finally { Arrays.fill(newPin, 0.toChar()) Arrays.fill(pin, 0.toChar()) @@ -284,8 +350,7 @@ class PivManager( private suspend fun changePuk(puk: CharArray, newPuk: CharArray): String = connectionHelper.useSession(updateDeviceInfo = true) { piv -> try { - piv.changePuk(puk, newPuk) - "" + handlePinPukErrors { piv.changePuk(puk, newPuk) } } finally { Arrays.fill(newPuk, 0.toChar()) Arrays.fill(puk, 0.toChar()) @@ -305,14 +370,28 @@ class PivManager( private suspend fun unblockPin(puk: CharArray, newPin: CharArray): String = connectionHelper.useSession(updateDeviceInfo = true) { piv -> try { - piv.unblockPin(puk, newPin) - "" + handlePinPukErrors { piv.unblockPin(puk, newPin) } } finally { Arrays.fill(newPin, 0.toChar()) Arrays.fill(puk, 0.toChar()) } } + private fun handlePinPukErrors(block: () -> Unit) : String { + try { + block() + return jsonSerializer.encodeToString(mapOf("status" to "success")) + } catch (invalidPin: InvalidPinException) { + return jsonSerializer.encodeToString(mapOf("status" to "invalid-pin", + "attemptsRemaining" to invalidPin.attemptsRemaining)) + } catch (apduException: ApduException) { + if (apduException.sw == SW.CONDITIONS_NOT_SATISFIED) { + return jsonSerializer.encodeToString(mapOf("status" to "pin-complexity")) + } + } + return jsonSerializer.encodeToString(mapOf("status" to "other-error")) + } + private fun getSlots(piv: YubiKitPivSession): List = try { val supportsMetadata = piv.supports(YubiKitPivSession.FEATURE_METADATA) @@ -340,6 +419,8 @@ class PivManager( private suspend fun delete(slot: Slot, deleteCert: Boolean, deleteKey: Boolean): String = connectionHelper.useSession(updateDeviceInfo = true) { piv -> try { + doAuth(piv, pivViewModel.currentSerial.value.toString()) + if (!deleteCert && !deleteKey) { throw IllegalArgumentException("Missing delete option") } @@ -366,6 +447,8 @@ class PivManager( connectionHelper.useSession(updateDeviceInfo = true) { piv -> try { + doAuth(piv, pivViewModel.currentSerial.value.toString()) + val sourceObject = if (includeCertificate) { piv.getObject(src.objectId) } else null @@ -391,39 +474,233 @@ class PivManager( return ByteArray(10) } - private suspend fun examineFile( + private fun chooseCertificate(certificates: List?): X509Certificate? { + return certificates?.let { + when { + it.size > 1 -> getLeafCertificates(it).firstOrNull() + else -> it.firstOrNull() + } + } + } + + private fun getCertificateInfo(certificate: X509Certificate?) = + certificate?.let { + buildJsonObject { + val keyType = KeyType.fromKey(certificate.publicKey) + put("key_type", JsonPrimitive(keyType.value)) + put("subject", JsonPrimitive(certificate.subjectDN.name)) + put("issuer", JsonPrimitive(certificate.issuerDN.name)) + put("serial", JsonPrimitive(certificate.serialNumber.toString())) + put("not_valid_before", JsonPrimitive(certificate.notBefore.isoFormat())) + put("not_valid_after", JsonPrimitive(certificate.notAfter.isoFormat())) + put("fingerprint", JsonPrimitive(certificate.fingerprint())) + } + } + + private fun publicKeyMatch(certificate: X509Certificate?, metadata: SlotMetadata?) : Boolean? { + if (certificate == null || metadata == null) { + return null + } + + val slotPublicKey = metadata.publicKey + val certPublicKey = PublicKeyValues.fromPublicKey(certificate.publicKey) + + return slotPublicKey?.encoded.contentEquals(certPublicKey.encoded) + } + + private fun examineFile( slot: String, data: String, - password: String - ): String = - connectionHelper.useSession(updateDeviceInfo = true) { piv -> - try { - "" - } finally { + password: String? + ): String = try { + val (certificates, privateKey) = parseFile(data, password) + val certificate = chooseCertificate(certificates) + + val result = buildJsonObject { + put("status", JsonPrimitive(true)) + put("password", JsonPrimitive(password != null)) + put("key_type", privateKey?.let { + JsonPrimitive(KeyType.fromKeyParams(PrivateKeyValues.fromPrivateKey(it)).value) + } ?: JsonNull) + put("cert_info", getCertificateInfo(certificate) ?: JsonNull) + pivViewModel.getMetadata(slot)?.let { + if (certificate != null && privateKey == null) { + put("public_key_match", JsonPrimitive(publicKeyMatch(certificate, it))) + } } } - private suspend fun validateRfc4514( + jsonSerializer.encodeToString(JsonObject.serializer(), result) + } catch (e: InvalidPasswordException) { + val result = buildJsonObject { + put("status", JsonPrimitive(false)) + } + jsonSerializer.encodeToString(JsonObject.serializer(), result) + } finally { + } + + + private fun getX500Name(data: String) = X500Name(data) + + + private fun validateRfc4514( data: String + ): String = try { + getX500Name(data) + jsonSerializer.encodeToString(mapOf("status" to true)) + } catch (e: IllegalArgumentException) { + jsonSerializer.encodeToString(mapOf("status" to false)) + } + + private suspend fun generate( + slot: String, + keyType: Int, + pinPolicy: Int, + touchPolicy: Int, + subject: String?, + generateType: String, + validFrom: String?, + validTo: String? ): String = connectionHelper.useSession(updateDeviceInfo = true) { piv -> try { - "" + + val serial = pivViewModel.currentSerial.value.toString() + doAuth(piv, serial) + doVerifyPin(piv, serial) + + val keyValues = piv.generateKeyValues( + Slot.fromStringAlias(slot), + KeyType.fromValue(keyType), + PinPolicy.fromValue(pinPolicy), + TouchPolicy.fromValue(touchPolicy) + ) + + val publicKey = keyValues.toPublicKey() + val publicKeyPem = publicKey.encoded + + val result = when (generateType) { + "publicKey" -> publicKeyPem.byteArrayToHexString() + "csr" -> { + if (subject == null) { + throw IllegalArgumentException("Subject missing for csr") + } + // TODO implement + //val csrBuilder = JcaPKCS10CertificationRequestBuilder(getX500Name(subject), publicKey) + //val csBuilder = JcaContentSignerBuilder("SHA256withRSA") + // + //val signer = csBuilder.build(keyPair.getPrivate()); + //csrBuilder.build(signer) + "" + } + + "certificate" -> "" // TODO implement + else -> throw IllegalArgumentException("Invalid generate type: $generateType") + } + + jsonSerializer.encodeToString( + mapOf( + "public_key" to publicKeyPem.byteArrayToHexString(), + "result" to result + ) + ) + } catch (e: Exception) { + throw e } finally { + + } + } + + private fun parseFile( + data: String, + password: String? + ): KeyMaterial = try { + parse(data.hexStringToByteArray(), password) + } catch (e: Exception) { + when (e) { + is IllegalArgumentException, is IOException -> KeyMaterial( + emptyList(), + null + ) + + else -> throw e } } private suspend fun importFile( - slot: String, + slot: Slot, data: String, - password: String, + password: String?, pinPolicy: Int, touchPolicy: Int ): String = connectionHelper.useSession(updateDeviceInfo = true) { piv -> try { - "" + + val serial = pivViewModel.currentSerial.value.toString() + doAuth(piv, serial) + + val (certificates, privateKey) = parseFile(data, password) + // TODO catch invalid password exception + + if (privateKey == null && certificates.isEmpty()) { + throw IllegalArgumentException("Failed to parse") + } + + var metadata : SlotMetadata? = null + privateKey?.let { + piv.putKey( + slot, + PrivateKeyValues.fromPrivateKey(privateKey), + PinPolicy.fromValue(pinPolicy), + TouchPolicy.fromValue(touchPolicy) + ) + + metadata = try { + SlotMetadata(piv.getSlotMetadata(slot)) + } catch (e: Exception) { + when (e) { + // TODO NotSupported + is ApduException, is BadResponseException -> null + else -> throw e + } + } + } + + val certificate = chooseCertificate(certificates) + certificate?.let { + piv.putCertificate(slot, certificate) + piv.putObject(ObjectId.CHUID, generateChuid()) + // TODO self.certificate = certificate + } + + val result = buildJsonObject { + + // TODO get public key from the private key + val publicKey2 = metadata?.let { + it.publicKey?.toPublicKey() + } + put("metadata", metadata?.let {buildJsonObject { + put("key_type", JsonPrimitive(it.keyType.toInt())) + put("pin_policy", JsonPrimitive(it.pinPolicy)) + put("touch_policy", JsonPrimitive(it.touchPolicy)) + put("generated", JsonPrimitive(it.generated)) + put( + "public_key", + it.publicKey?.let { JsonPrimitive(it.toPublicKey().toPem()) } + ?: JsonNull) + }} ?: JsonNull) + put("public_key", privateKey?.let { + JsonPrimitive(publicKey2?.toPem())} ?: JsonNull) + put("certificate", + certificate?.let { + JsonPrimitive(it.encoded.byteArrayToHexString()) + } ?: JsonNull + ) + } + + jsonSerializer.encodeToString(JsonObject.serializer(), result) } finally { } } diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivViewModel.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivViewModel.kt index bd2307fea..d36c0fea1 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivViewModel.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivViewModel.kt @@ -22,6 +22,8 @@ import androidx.lifecycle.ViewModel import com.yubico.authenticator.ViewModelData import com.yubico.authenticator.piv.data.PivSlot import com.yubico.authenticator.piv.data.PivState +import com.yubico.authenticator.piv.data.SlotMetadata +import com.yubico.yubikit.piv.Slot class PivViewModel : ViewModel() { private val _state = MutableLiveData() @@ -50,4 +52,10 @@ class PivViewModel : ViewModel() { fun updateSlots(slots: List?) { _slots.postValue(slots) } + + fun getMetadata(slotAlias: String): SlotMetadata? = + _slots.value?.first { slot -> + slot.slotId == Slot.fromStringAlias(slotAlias).value + }?.metadata + } \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/SlotMetadata.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/SlotMetadata.kt index b806e2154..5a914d4a3 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/SlotMetadata.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/SlotMetadata.kt @@ -16,8 +16,16 @@ package com.yubico.authenticator.piv.data +import android.util.Base64 +import com.yubico.yubikit.core.keys.PublicKeyValues +import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder typealias YubikitPivSlotMetadata = com.yubico.yubikit.piv.SlotMetadata @@ -30,14 +38,31 @@ data class SlotMetadata( @SerialName("touch_policy") val touchPolicy: Int, val generated: Boolean, + @Serializable(with = PublicKeyValuesAsStringSerializer::class) @SerialName("public_key") - val publicKey: String + val publicKey: PublicKeyValues? = null ) { constructor(slotMetadata: YubikitPivSlotMetadata) : this( slotMetadata.keyType.value.toUByte(), slotMetadata.pinPolicy.value, slotMetadata.touchPolicy.value, slotMetadata.isGenerated, - slotMetadata.publicKeyValues.toString() + slotMetadata.publicKeyValues ) } + +object PublicKeyValuesAsStringSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor( + "PublicKeyValuesAsString", + PrimitiveKind.STRING + ) + + override fun serialize(encoder: Encoder, value: PublicKeyValues?) { + encoder.encodeString(value?.let { Base64.encodeToString(it.encoded, 0) } ?: "") + } + + override fun deserialize(decoder: Decoder): PublicKeyValues? { + // TODO + return null + } +} diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/utils/KeyMaterialUtils.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/utils/KeyMaterialUtils.kt new file mode 100644 index 000000000..cc0b123e8 --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/utils/KeyMaterialUtils.kt @@ -0,0 +1,222 @@ +/* + * Copyright (C) 2025 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.yubico.authenticator.piv.utils + +import android.util.Base64 +import com.yubico.authenticator.piv.data.hexStringToByteArray +import java.io.ByteArrayInputStream +import java.io.IOException +import java.security.KeyFactory +import java.security.KeyStore +import java.security.PrivateKey +import java.security.PublicKey +import java.security.UnrecoverableKeyException +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.security.spec.PKCS8EncodedKeySpec +import javax.security.auth.x500.X500Principal + +typealias KeyMaterial = Pair, PrivateKey?> + +class InvalidPasswordException(cause: Throwable) : Exception(cause) + +object KeyMaterialUtils { + + private class InvalidDerFormat : Exception() + + fun PublicKey.toPem(): String { + val base64 = Base64.encodeToString(encoded, Base64.NO_WRAP) + val wrapped = base64.chunked(64).joinToString("\n") + return "-----BEGIN PUBLIC KEY-----\n$wrapped\n-----END PUBLIC KEY-----\n" + } + + fun getLeafCertificates(certs: List): List { + val issuers: Set = certs.map { it.issuerX500Principal }.toSet() + return certs.filter { cert -> cert.subjectX500Principal !in issuers } + } + + fun parse(bytes: ByteArray, password: String? = null): KeyMaterial { + return if (isPem(bytes)) { + parsePem(bytes) + } else { + parseBinary(bytes, password) + } + } + + private fun parsePem(bytes: ByteArray): KeyMaterial { + val pem = String(bytes, Charsets.UTF_8) + val certs = mutableListOf() + val regex = Regex( + "-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----", + RegexOption.DOT_MATCHES_ALL + ) + for (match in regex.findAll(pem)) { + val base64 = match.groupValues[1].replace("\n", "").replace("\r", "") + val decoded = Base64.decode(base64, Base64.DEFAULT) + val cert = CertificateFactory.getInstance("X.509") + .generateCertificate(ByteArrayInputStream(decoded)) as X509Certificate + certs.add(cert) + } + val key = parsePemPrivateKey(pem) + return KeyMaterial(certs, key) + } + + private fun parsePemPrivateKey(pem: String): PrivateKey? { + val pkcs8Regex = Regex( + "-----BEGIN PRIVATE KEY-----(.*?)-----END PRIVATE KEY-----", + RegexOption.DOT_MATCHES_ALL + ) + val match = pkcs8Regex.find(pem) + if (match != null) { + val base64 = match.groupValues[1].replace("\n", "").replace("\r", "") + val decoded = Base64.decode(base64, Base64.DEFAULT) + return generatePkcs8PrivateKey(decoded) + } + + // TODO EC PRIVATE KEY - SEC1 (RFC 5915) + // TODO ED25519 PRIVATE KEY + // TODO ENCRYPTED PRIVATE KEY + val pkcs1Regex = Regex( + "-----BEGIN RSA PRIVATE KEY-----(.*?)-----END RSA PRIVATE KEY-----", + RegexOption.DOT_MATCHES_ALL + ) + val matchRsa = pkcs1Regex.find(pem) + if (matchRsa != null) { + val base64 = matchRsa.groupValues[1] + .replace("\n", "") + .replace("\r", "") + val decoded = Base64.decode(base64, Base64.DEFAULT) + return pkcs1ToPkcs8(decoded) + } + return null + } + + private fun generatePkcs8PrivateKey(encoded: ByteArray): PrivateKey? { + try { + val keySpec = PKCS8EncodedKeySpec(encoded) + try { + return KeyFactory.getInstance("RSA").generatePrivate(keySpec) + } catch (_: Exception) { + } + try { + return KeyFactory.getInstance("EC").generatePrivate(keySpec) + } catch (_: Exception) { + } + try { + return KeyFactory.getInstance("DSA").generatePrivate(keySpec) + } catch (_: Exception) { + } + } catch (_: Exception) { + } + return null + } + + private fun pkcs1ToPkcs8(pkcs1Bytes: ByteArray): PrivateKey? { + // TODO + return null + } + + private fun parseBinary(bytes: ByteArray, password: String?): KeyMaterial { + try { + return parseDer(bytes) + } catch (e: Exception) { + when (e) { + !is InvalidDerFormat -> throw e + } + } + + try { + return parsePkcs12(bytes, password) + + } catch (e: Exception) { + when (e) { + is UnrecoverableKeyException, is NullPointerException, is IOException -> throw InvalidPasswordException( + e + ) + } + } + + return KeyMaterial(emptyList(), null) + } + + private fun parseDer(der: ByteArray): KeyMaterial { + val derCert = parseDerCert(der) + val derKey = parseDerPrivateKey(der) + + if (derCert == null && derKey == null) { + throw InvalidDerFormat() + } + + return KeyMaterial(derCert ?: emptyList(), derKey) + } + + private fun parseDerCert(der: ByteArray): List? = + try { + listOf( + CertificateFactory.getInstance("X.509") + .generateCertificate(ByteArrayInputStream(der)) as X509Certificate + ) + } catch (_: Exception) { + null // not a cert + } + + private fun parseDerPrivateKey(der: ByteArray): PrivateKey? { + val keySpec = PKCS8EncodedKeySpec(der) + try { + return KeyFactory.getInstance("RSA").generatePrivate(keySpec) + } catch (_: Exception) { + } + + try { + return KeyFactory.getInstance("EC").generatePrivate(keySpec) + } catch (_: Exception) { + } + + try { + return KeyFactory.getInstance("DSA").generatePrivate(keySpec) + } catch (_: Exception) { + } + return null + } + + private fun parsePkcs12( + bytes: ByteArray, + password: String? + ): KeyMaterial { + val keyStore = KeyStore.getInstance("PKCS12") + keyStore.load(ByteArrayInputStream(bytes), password?.toCharArray()) + val certs = mutableListOf() + val aliases = keyStore.aliases() + while (aliases.hasMoreElements()) { + val alias = aliases.nextElement() + val cert = keyStore.getCertificate(alias) + if (cert is X509Certificate) { + certs.add(cert) + } + } + + val chosenAlias = keyStore.aliases().toList().firstOrNull() + val key = if (chosenAlias != null) { + keyStore.getKey(chosenAlias, password?.toCharArray()) as? PrivateKey + } else null + + return KeyMaterial(certs, key) + } + + private fun isPem(bytes: ByteArray): Boolean = + String(bytes.take(10).toByteArray(), Charsets.UTF_8).startsWith("-----BEGIN") +} \ No newline at end of file diff --git a/android/app/src/test/java/com/yubico/authenticator/piv/utils/KeyMaterialTestData.kt b/android/app/src/test/java/com/yubico/authenticator/piv/utils/KeyMaterialTestData.kt new file mode 100644 index 000000000..a89c43acb --- /dev/null +++ b/android/app/src/test/java/com/yubico/authenticator/piv/utils/KeyMaterialTestData.kt @@ -0,0 +1,201 @@ +/* + * Copyright (C) 2025 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("SpellCheckingInspection") + +package com.yubico.authenticator.piv.utils + +import com.yubico.authenticator.piv.data.hexStringToByteArray + +object KeyMaterialTestData { + object Rsa2048 { + val PKCS12 = PKCS12_HEX.hexStringToByteArray() + private const val PKCS12_HEX = "30820a1f020103308209d506092a864886f70d010701a08209c60482" + + "09c2308209be3082042a06092a864886f70d010706a082041b308204170201003082041006092a8" + + "64886f70d010701305f06092a864886f70d01050d3052303106092a864886f70d01050c30240410" + + "73f69e2f73ce9cb317c38d10f0a0ef9602020800300c06082a864886f70d02090500301d0609608" + + "64801650304012a041071b70f02b82756927b3b24e76f172835808203a0d8fcb1767999456491e9" + + "779326c480afa903ca884394d3a665e718c5ddc48ec86941809ea3eea9bad66c2f67fbcf10fdeb1" + + "c2adeb78b8322246f513095897c80fbac7a96125e37c69050b0af08f63599b0fdb549f08fa408c6" + + "bf9b9843d640c8adbea7b0bd8678a75d02eaf3835ab232d50591f4a24087233c25db269fb250583" + + "a7dec9044bb202e72d48711c02d8da107e1320e84e7ae802985bbda92801ee8324fa319507210f2" + + "d67472a68c783b382ab9b7f4d0883c4be3bafc86dd58dcce6423fe8277e278ddaa6b47721118dc4" + + "7b701bb3a50df9b277e8266d3953eb94f82b69bb34c22c001a817ae83066649f84f2868339f889f" + + "7c71395a907eb5b3faa679cc5792760c60e6a1fdb59773685b4118db49708ecc9d615bcea97527c" + + "b988921eadc1ea3fa0d371d63e0f132cf79f27382e0383bfecd6ede4b536850ef14bba5366ceb18" + + "9a71fbeaffa1b18f26630ecb00410401e59756e7a90d4eba6b120a28da7c67789495ab964251fdd" + + "1ce2e5f2c9207c467abdc1cca095b73e2a9065512d235176c52a48294f37456f11849ba16f4b99d" + + "8e408b34f076f73017ee718001bebc8203408e945ba5055a36eb7c407678e4ee60997a189443637" + + "08044807b1eee34bb03e4bda0a6887db73327f02e2c2ccc8f413573ee026debe659f45a494d8124" + + "ab6ac7739b4174c2ae5095a478888a6e343f0738ab8889c4d682b5edad5a00dcf300882b973c5d4" + + "08c0421a7b4fbe2d0487f7603a83ff123b5546abed33837bcbca755441549ddfd8698542cd69460" + + "7e147ed03100c938a9de79e43120d4999d2b4ddf7480e64c5d1f979d8242186143ba0ad0cb9cc83" + + "e920da8891d44c2f02c0e3558be51b95ba911a62cf35aac015178beb2e73969227970dae6ab30d5" + + "d2dbfa22b751aff3679c836bfb69b2a9a2c31690f586326137edbb081b63b17dc2c9793d10c5248" + + "d7aa2aaac1c075f3d22c53c178dd010e710d657be22932268ce48b803b629b51c02dd96f0111a2d" + + "a5380e765f625be8ed42d3ef93cda0bda00bce579d8d7035e9d3283ac964c0495e972baa74f362c" + + "69532ada2d9bd579f730dfe7a2b75c2cf3f2e47c55416b9533f5ac4870e1e63d2cd81c24bfddf64" + + "f29b1f8f2224895a8bf2262a17681ec8a57f9034436d2124d3f69b790b0cbf9c822e1a97a87d5ce" + + "70cdaa8b948b40da4560f451d99f4c4e21f13680dd0102539b8f9695fcfaca691bc6d6abcdb1d96" + + "0de81b8a2055eaf9d15e3e355d2705b4eb770370bb9c4d019b37c32e72a46ddba979faa464fe0f0" + + "26137dabd9778aff3223082058c06092a864886f70d010701a082057d0482057930820575308205" + + "71060b2a864886f70d010c0a0102a082053930820535305f06092a864886f70d01050d305230310" + + "6092a864886f70d01050c30240410f16373ea59850909c6ad2eaa94d1ad7e02020800300c06082a" + + "864886f70d02090500301d060960864801650304012a0410b3f9462876bfee8df58eb03cae1eaab" + + "c048204d024bea64756cf9aad643d3b3a41cbdf2096bfd1c5e74caa177fc02e0397956e55c866eb" + + "a13b9a626ce6dc2a889075fb708a322abcb9c824dc4802e1d63d93cc51463770ac7e0d1078029ba" + + "19be6f7f4bcacba22264da8cf1db41d5db7d6bcb80543487e894c03f2b412a009310dcd99ff9e20" + + "18aa8b7441ee5df280492226b3e831b162de09e650009b58053a4a5d36c67fbd5b64c1481310f97" + + "b17717c1bd75c81c927b99e0ca98c6c355c8f7d09565ced64fdb843c52d57c6e0cb5a84413c088d" + + "1968d2696a6a0cb8375c445b700178c3b05fb1e1d11522ccb546714375bbaff717257f77245f434" + + "24cc400227c576de6adb98a4865232ab4e12aac7e200ac1a16383cd24899794e4c2a37f73d6a4bc" + + "7115d6ddffe7569f43ec15f3be0f07a2b69f54d9fd277cf1c055960d6e593276324d4ebcb9ef5f4" + + "a0d92ba8e9eb4d6fb3f7a5979e096804fb6d9cbc420c93d213f66cb69591194e9f1f39b90382ee6" + + "88cd080c8f5a327b8d40ab2541a69cd1cdb009165510be6757c11f14cf5f950e9e8098a3e600027" + + "2f2b942afd7aa12723153971fe3a6325e495bfcb967c357c20fab3467f381d1038d2856d07e9bb5" + + "90cc8b7de885f00edddd99a1fb152f9f953798d5385fd9d67c27d10773b5d2ac6cfbf13f0053092" + + "1f27b54d969dfd76866ec26907512da97a35773db0049dae054c3fe9111638e74bdf0837b476b6c" + + "2249849c854f72c3368d4b7d14d8c30c99e9370a1db5d3a78949a32af50d961b20b695d02aa6129" + + "2a3c52aceb06c7f6b507fb06c397f45ffcf52f8a61b3ce5a4e79afcc5328d51c5300304f39ab9fc" + + "f74a4a94f519c06bdf775678b12a760f86aa22cbb7a4e3f64d9d104354df79dadc7b42956cdfc7a" + + "d27655a28ed980af67bca992f627ea91fbb4bd111e55ff04dd53c713f5cb21fbc4cdacb22e04812" + + "f99ffc8273dbdec9efbdf61048e1dfaba5e9bbff63c2c8ec290fb5eb3aa3a3c2859b15eedcaa865" + + "ed6f4040e64bdb23672fccee9dda445de2bad21f3c1b6d7b704aa376ca6d5813bca1b881f4cbf05" + + "c99f6f7cceff2f76c17ca4a9e4524929cf41d9cf00ca74d3cce95dbe1e59fc95363a66dd8b7823f" + + "99df5e9b496ef92b2071d25a73bdee4763921f28888b0244712e0d361c6268eb5c0c554099d7a13" + + "a3ee03fc5713faca4154ab3f3ca7373a532c4c9418028e7938ec7871c732aa2d82f5a9c9998f1d5" + + "8ae1dd795dd044c9b6fb1e3916e8bbd691f861a3ff4b2d107369769b3ddc95acfcca6e49c27f259" + + "fbc647630b9fcd76b220795b73e7d78dd3384f277e6d7628d406c0ead207c499d51b693d3798330" + + "0eef095cc3c8f8a9224dab140444f0f0d6faff4430292d5df055fe035b12eadd2334fc09ec70d25" + + "1f6f745456eceb79cac132e6c3ede2537ba096a9fb7c564c81ab853de4659faf356f6c87e12bf50" + + "e744b91661e3b53597bb3ea1726ae30371b5a235c4500b4093824eadd4f3fc086a1b2c6629538d7" + + "327139882fa1cc96b0a0f396f28eb5a35948c214a88ce4a0f36affaeff22227e1e8bedb60d4f2b6" + + "83b5937976cc9fa7f4001459bd20f28535d273d2aef3aead49ce63e6b3d9ca7cd8d999d0fb1b4d0" + + "1098600b3181920ceea85992e3611b5b678373d136877fd41770f78f5e2be87ca57f95c721a2627" + + "beb6f66107c1987cfeca35e03f25d75a50daccb08bafeeaf36a5493ca56604302db7ff6779b5c9d" + + "7ff12815f2b361349a66214d3125302306092a864886f70d01091531160414565cc72e1bc0f781d" + + "06b5ba3995fb9c5ffbc938030413031300d0609608648016503040201050004202373b26c9670ca" + + "ecbf3336cba8dbbe443a88df401116515174d0d9949397667504089387718df8bb7e4a02020800" + + val PEM = PEM_STRING.toByteArray(Charsets.UTF_8) + private const val PEM_STRING = "-----BEGIN CERTIFICATE-----\n" + + "MIIDOTCCAiGgAwIBAgIUND9T2D3cevsQ947Le2H3WLiMl/kwDQYJKoZIhvcNAQEL" + + "BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM" + + "GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNTA4MDEwNTUyMjJaFw0yNjA4" + + "MDEwNTUyMjJaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw" + + "HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB" + + "AQUAA4IBDwAwggEKAoIBAQDMZo6f9skXTORKN2RVHKXmbQIBFHXLvAXrGTcNkSSt" + + "FUaRv+qDk850cSEdGh5/+TLNrJPTRVKQxo9u28OKo0tYFGHwcCUi9SdhpBic56Pz" + + "kZW9QIfQ3BlJ2ThRnnYNEfyubo/3WBEWZOlaOJXXjZpwqLNBDAZCcUstlnOhowZQ" + + "dGKdh9AJmzT5ubHtdrkmY5u1fsaklSEi69QXOOatDI5YkJIiGn9lwJ9Mlphaomsp" + + "8YoKVpC/xupbHcH7B06Exk0CSUjpLv/pNV/AbLOWnKNW3Vqqq5coJWTWVbW1c9sL" + + "PjQjbetq6BMhZylOo4/dKpT2IFrxpieZNZ8inh1KDe4nAgMBAAGjITAfMB0GA1Ud" + + "DgQWBBTp7OcBdGgktyt+Oww556RxY6WWHTANBgkqhkiG9w0BAQsFAAOCAQEAs+6Z" + + "v859tzA5eJQ0nRojFkXizk4tjbopYTA4t1p+812oPJMmvTUJ+zZ4LnOdako8a9XR" + + "pY6xeGEnzt2wMhL7iF5ZVIC9eXAz5F2FrkmhIUHjdoqabv4vqav6+tPddlatkWUy" + + "BQtNJh7R80T57/xVQjOfDLqLos8lrnuxQh+yJHZpC8ydCk+TE7gjOVkRPG99ZbNz" + + "P99KdC9t9Qy1HTHwYUKljB5svB+AvMnTX6ww/T8xnepEUU0bU4CjAmAcPdm/9A3C" + + "gA8ySq22RZBe+IuwTs8ppA2vK4StWbvi/yyIJOR0v9QkrzYIsT+1sJQjVSvw4iFJ" + + "4uNDLtfSA6hINdEpGw==" + + "-----END CERTIFICATE-----" + + "-----BEGIN PRIVATE KEY-----" + + "MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDMZo6f9skXTORK" + + "N2RVHKXmbQIBFHXLvAXrGTcNkSStFUaRv+qDk850cSEdGh5/+TLNrJPTRVKQxo9u" + + "28OKo0tYFGHwcCUi9SdhpBic56PzkZW9QIfQ3BlJ2ThRnnYNEfyubo/3WBEWZOla" + + "OJXXjZpwqLNBDAZCcUstlnOhowZQdGKdh9AJmzT5ubHtdrkmY5u1fsaklSEi69QX" + + "OOatDI5YkJIiGn9lwJ9Mlphaomsp8YoKVpC/xupbHcH7B06Exk0CSUjpLv/pNV/A" + + "bLOWnKNW3Vqqq5coJWTWVbW1c9sLPjQjbetq6BMhZylOo4/dKpT2IFrxpieZNZ8i" + + "nh1KDe4nAgMBAAECggEAXOUrYuYNBGrswhIkpk3r1Cqso4MB+kMMyYlfLOpPKd6m" + + "gO0hDwWo6eDUdN5/CBhgj3skf/tch/HGFFMKrsKCJpi03kqJhja23Dhw+zaHm9YJ" + + "oMZoM3MkhxyS7P1Al7YaCciz420B7xSTvW5EI3/2tcbmGOT3H1FQInrjOI3X+83p" + + "FoS93sBnGfDHGMQxUPsHoSw5HfhYGCUFw9yFshOSv55SxKmQEoDFoqzGJYsLe/FI" + + "RdUmM82miiAMr00X6u+zRffvtfjUIhQ5xLofUB6XetFjmRvLudK9OzY4LvzMImpX" + + "Ga2jLE+d9JubX2pVXV34Ry4qM7KFaTFvEbpj0ygd4QKBgQDx+3OAUPWOKOhvndnx" + + "zGGueya11qqinyvy+qqC1ppattfSFxdJT+9UF+zyRDDmhNd4Qa80tNaSL61fmiJ9" + + "/f4SRBt0bH8RztQ4/UwhTYjaA4nsdsvAUsUc6dRygrsOWuaXNZ0+JZ30IbDcG7LV" + + "iN7BFhEpZtEF6bIGOtp/71rWUQKBgQDYPcco9DLx6BokypPddM21DLkvlMXe4qmP" + + "ICEVXpH+7z9RUsBnuGrz5zXTNVT7lOk2yubo//+u9GInGKNcvnI6DuI0u99dVuSw" + + "haymuN0gSRT1fbxejPxQFuZce44zuRitLou6xhPpG1KiibEGLGvjyHz9j7VP7cS6" + + "N8dq3x9G9wKBgESkvwQUc0QLiLw4/B1ijAcx+i41IhyVqKL5xqrs88Zt/dUkJb/v" + + "RAYH73heLb0GzBTaFTiPYBsCGV14XPZ+ubc2yM8DBBzqHju4ZwM/emXWAScqH+yD" + + "zlTAZDrDqQqOcMFOPTfm9eLON9yIovd+JyqA9wdWmk7iF1U7FsaaAJuxAoGBAMKW" + + "/US2U63qnrQi8+LiPEbDV1Yg+9qxf8IDOKJBQwH1i7YD0I7FnsEze/U/VeU7QI6F" + + "Ejv0OsLWugjSnBdWbfYe9KJduggFrK/I6u/xBVQLT+gGKN+w4VC0+sGYkgOrejBF" + + "5YnCu6IWa0tGut2CVehZv1hx3Mg7f7/PeA2NEVlLAoGBAIA56s+GyLlSU9rU1IxZ" + + "xD9UBWlak0qhauMXSsqSD3Pj5RxslsDUP7CY0Y+GtsFHDvu3/54OKomaA2JaoxQG" + + "cKc05nARq3GzNS4F/hOt5izd3laddb8YOeO0+mDJrLFLzjtgN4kuvI7fTSfxHJrw" + + "RZu52GdpFyD2dU1YCR6BsfJ/" + + "-----END PRIVATE KEY-----" + + val DER_CERT = DER_CERT_HEX.hexStringToByteArray() + private const val DER_CERT_HEX = "3082033930820221a0030201020214343f53d83ddc7afb10f78ecb" + + "7b61f758b88c97f9300d06092a864886f70d01010b05003045310b3009060355040613024155311" + + "3301106035504080c0a536f6d652d53746174653121301f060355040a0c18496e7465726e657420" + + "5769646769747320507479204c7464301e170d3235303830313035353232325a170d32363038303" + + "13035353232325a3045310b30090603550406130241553113301106035504080c0a536f6d652d53" + + "746174653121301f060355040a0c18496e7465726e6574205769646769747320507479204c74643" + + "0820122300d06092a864886f70d01010105000382010f003082010a0282010100cc668e9ff6c917" + + "4ce44a3764551ca5e66d02011475cbbc05eb19370d9124ad154691bfea8393ce7471211d1a1e7ff" + + "932cdac93d3455290c68f6edbc38aa34b581461f0702522f52761a4189ce7a3f39195bd4087d0dc" + + "1949d938519e760d11fcae6e8ff758111664e95a3895d78d9a70a8b3410c0642714b2d9673a1a30" + + "65074629d87d0099b34f9b9b1ed76b926639bb57ec6a4952122ebd41738e6ad0c8e589092221a7f" + + "65c09f4c96985aa26b29f18a0a5690bfc6ea5b1dc1fb074e84c64d024948e92effe9355fc06cb39" + + "69ca356dd5aaaab97282564d655b5b573db0b3e34236deb6ae8132167294ea38fdd2a94f6205af1" + + "a62799359f229e1d4a0dee270203010001a321301f301d0603551d0e04160414e9ece701746824b" + + "72b7e3b0c39e7a47163a5961d300d06092a864886f70d01010b05000382010100b3ee99bfce7db7" + + "30397894349d1a231645e2ce4e2d8dba29613038b75a7ef35da83c9326bd3509fb36782e739d6a4" + + "a3c6bd5d1a58eb1786127ceddb03212fb885e595480bd797033e45d85ae49a12141e3768a9a6efe" + + "2fa9abfafad3dd7656ad916532050b4d261ed1f344f9effc5542339f0cba8ba2cf25ae7bb1421fb" + + "22476690bcc9d0a4f9313b8233959113c6f7d65b3733fdf4a742f6df50cb51d31f06142a58c1e6c" + + "bc1f80bcc9d35fac30fd3f319dea44514d1b5380a302601c3dd9bff40dc2800f324aadb645905ef" + + "88bb04ecf29a40daf2b84ad59bbe2ff2c8824e474bfd424af3608b13fb5b09423552bf0e22149e2" + + "e3432ed7d203a84835d1291b" + + val DER_KEY = DER_KEY_HEX.hexStringToByteArray() + private const val DER_KEY_HEX = "308204be020100300d06092a864886f70d0101010500048204a8308" + + "204a40201000282010100cc668e9ff6c9174ce44a3764551ca5e66d02011475cbbc05eb19370d91" + + "24ad154691bfea8393ce7471211d1a1e7ff932cdac93d3455290c68f6edbc38aa34b581461f0702" + + "522f52761a4189ce7a3f39195bd4087d0dc1949d938519e760d11fcae6e8ff758111664e95a3895" + + "d78d9a70a8b3410c0642714b2d9673a1a3065074629d87d0099b34f9b9b1ed76b926639bb57ec6a" + + "4952122ebd41738e6ad0c8e589092221a7f65c09f4c96985aa26b29f18a0a5690bfc6ea5b1dc1fb" + + "074e84c64d024948e92effe9355fc06cb3969ca356dd5aaaab97282564d655b5b573db0b3e34236" + + "deb6ae8132167294ea38fdd2a94f6205af1a62799359f229e1d4a0dee270203010001028201005c" + + "e52b62e60d046aecc21224a64debd42aaca38301fa430cc9895f2cea4f29dea680ed210f05a8e9e" + + "0d474de7f0818608f7b247ffb5c87f1c614530aaec2822698b4de4a898636b6dc3870fb36879bd6" + + "09a0c668337324871c92ecfd4097b61a09c8b3e36d01ef1493bd6e44237ff6b5c6e618e4f71f515" + + "0227ae3388dd7fbcde91684bddec06719f0c718c43150fb07a12c391df858182505c3dc85b21392" + + "bf9e52c4a9901280c5a2acc6258b0b7bf14845d52633cda68a200caf4d17eaefb345f7efb5f8d42" + + "21439c4ba1f501e977ad163991bcbb9d2bd3b36382efccc226a5719ada32c4f9df49b9b5f6a555d" + + "5df8472e2a33b28569316f11ba63d3281de102818100f1fb738050f58e28e86f9dd9f1cc61ae7b2" + + "6b5d6aaa29f2bf2faaa82d69a5ab6d7d21717494fef5417ecf24430e684d77841af34b4d6922fad" + + "5f9a227dfdfe12441b746c7f11ced438fd4c214d88da0389ec76cbc052c51ce9d47282bb0e5ae69" + + "7359d3e259df421b0dc1bb2d588dec116112966d105e9b2063ada7fef5ad65102818100d83dc728" + + "f432f1e81a24ca93dd74cdb50cb92f94c5dee2a98f2021155e91feef3f5152c067b86af3e735d33" + + "554fb94e936cae6e8ffffaef4622718a35cbe723a0ee234bbdf5d56e4b085aca6b8dd204914f57d" + + "bc5e8cfc5016e65c7b8e33b918ad2e8bbac613e91b52a289b1062c6be3c87cfd8fb54fedc4ba37c" + + "76adf1f46f702818044a4bf041473440b88bc38fc1d628c0731fa2e35221c95a8a2f9c6aaecf3c6" + + "6dfdd52425bfef440607ef785e2dbd06cc14da15388f601b02195d785cf67eb9b736c8cf03041ce" + + "a1e3bb867033f7a65d601272a1fec83ce54c0643ac3a90a8e70c14e3d37e6f5e2ce37dc88a2f77e" + + "272a80f707569a4ee217553b16c69a009bb102818100c296fd44b653adea9eb422f3e2e23c46c35" + + "75620fbdab17fc20338a2414301f58bb603d08ec59ec1337bf53f55e53b408e85123bf43ac2d6ba" + + "08d29c17566df61ef4a25dba0805acafc8eaeff105540b4fe80628dfb0e150b4fac1989203ab7a3" + + "045e589c2bba2166b4b46badd8255e859bf5871dcc83b7fbfcf780d8d11594b028181008039eacf" + + "86c8b95253dad4d48c59c43f5405695a934aa16ae3174aca920f73e3e51c6c96c0d43fb098d18f8" + + "6b6c1470efbb7ff9e0e2a899a03625aa3140670a734e67011ab71b3352e05fe13ade62cddde569d" + + "75bf1839e3b4fa60c9acb14bce3b6037892ebc8edf4d27f11c9af0459bb9d867691720f6754d580" + + "91e81b1f27f" + } +} diff --git a/android/app/src/test/java/com/yubico/authenticator/piv/utils/KeyMaterialUtilsTest.kt b/android/app/src/test/java/com/yubico/authenticator/piv/utils/KeyMaterialUtilsTest.kt new file mode 100644 index 000000000..fcb16e15f --- /dev/null +++ b/android/app/src/test/java/com/yubico/authenticator/piv/utils/KeyMaterialUtilsTest.kt @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2025 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.yubico.authenticator.piv.utils + +import com.yubico.authenticator.piv.utils.KeyMaterialTestData.Rsa2048 +import org.junit.Assert +import org.junit.Test +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mockito.mockStatic +import java.security.interfaces.RSAPrivateKey +import java.util.Base64 + +class KeyMaterialUtilsTest { + @Test + fun `parse PKCS12 RSA2048`() { + val (certs, key) = KeyMaterialUtils.parse(Rsa2048.PKCS12, "11234567") + Assert.assertTrue(certs.size == 1) + Assert.assertTrue(key is RSAPrivateKey) + } + + @Test(expected = InvalidPasswordException::class) + fun `parse PKCS12 RSA2048 without password`() { + val (certs, key) = KeyMaterialUtils.parse(Rsa2048.PKCS12) + Assert.assertTrue(certs.size == 1) + Assert.assertTrue(key is RSAPrivateKey) + } + + @Test(expected = InvalidPasswordException::class) + fun `parse PKCS12 RSA2048 with wrong password`() { + val (certs, key) = KeyMaterialUtils.parse(Rsa2048.PKCS12, "invalid") + Assert.assertTrue(certs.size == 1) + Assert.assertTrue(key is RSAPrivateKey) + } + + + @Test + fun `parse PEM RSA2048`() { + mockBase64 { + val (certs, key) = KeyMaterialUtils.parse(Rsa2048.PEM) + Assert.assertTrue(certs.size == 1) + Assert.assertTrue(key is RSAPrivateKey) + } + } + + @Test + fun `parse DER cert RSA2048`() { + val (certs, key) = KeyMaterialUtils.parse(Rsa2048.DER_CERT) + Assert.assertTrue(certs.size == 1) + Assert.assertNull(key) + } + + @Test + fun `parse DER key RSA2048`() { + val (certs, key) = KeyMaterialUtils.parse(Rsa2048.DER_KEY) + Assert.assertTrue(certs.isEmpty()) + Assert.assertTrue(key is RSAPrivateKey) + } + + companion object { + fun mockBase64(block: () -> Unit) { + mockStatic(android.util.Base64::class.java).use { mock -> + + mock.`when` { android.util.Base64.decode(anyString(), anyInt()) } + .thenAnswer { invocation -> + Base64.getDecoder().decode(invocation.getArgument(0)) + } + + block() + } + } + } +} \ No newline at end of file diff --git a/lib/android/piv/state.dart b/lib/android/piv/state.dart index 3826d81d5..8b5bc2703 100644 --- a/lib/android/piv/state.dart +++ b/lib/android/piv/state.dart @@ -19,11 +19,13 @@ import 'dart:convert'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; + // TODO import 'package:logging/logging.dart'; import '../../app/models.dart'; // TODO import '../../app/state.dart'; // TODO import '../../app/views/user_interaction.dart'; +import '../../core/models.dart'; import '../../exception/no_data_exception.dart'; import '../../piv/models.dart'; import '../../piv/state.dart'; @@ -31,8 +33,8 @@ import '../overlay/nfc/method_channel_notifier.dart' show MethodChannelNotifier; // TODO final _log = Logger('android.piv.state'); -final _managementKeyProvider = StateProvider.autoDispose - .family((ref, _) => null); +// final _managementKeyProvider = StateProvider.autoDispose +// .family((ref, _) => null); // TODO // final _pinProvider = StateProvider.autoDispose.family( @@ -45,7 +47,7 @@ final androidPivState = AsyncNotifierProvider.autoDispose ); class _AndroidPivStateNotifier extends PivStateNotifier { - late DevicePath _devicePath; + //late DevicePath _devicePath; final _events = const EventChannel('android.piv.state'); late StreamSubscription _sub; @@ -138,7 +140,7 @@ class _AndroidPivStateNotifier extends PivStateNotifier { @override Future reset() async { await piv.invoke('reset'); - ref.read(_managementKeyProvider(_devicePath).notifier).state = null; + //ref.read(_managementKeyProvider(_devicePath).notifier).state = null; //ref.invalidate(_sessionProvider(_session.devicePath)); } @@ -163,15 +165,17 @@ class _AndroidPivStateNotifier extends PivStateNotifier { // } //}); - final result = await piv.invoke( - 'authenticate', - {'key': managementKey}, - //signal: signaler, + final result = jsonDecode( + await piv.invoke( + 'authenticate', + {'key': managementKey}, + //signal: signaler, + ), ); if (result['status']) { - ref.read(_managementKeyProvider(_devicePath).notifier).state = - managementKey; + // ref.read(_managementKeyProvider(_devicePath).notifier).state = + // managementKey; final oldState = state.valueOrNull; if (oldState != null) { state = AsyncData(oldState.copyWith(authenticated: true)); @@ -209,26 +213,16 @@ class _AndroidPivStateNotifier extends PivStateNotifier { // } // }); // } - await piv.invoke( - 'verifyPin', - {'pin': pin}, - // signal: _signaler - ); - // - // ref.read(_pinProvider(_devicePath).notifier).state = pin; - - return const PinVerificationStatus.success(); - } on PlatformException catch (e) { - // TODO - if (e.message == 'invalid-pin') { - // TODO - return PinVerificationStatus.failure( - PivPinFailureReason.invalidPin( - // TODO e.body['attempts_remaining']), - 3, - ), - ); - } + var result = jsonDecode(await piv.invoke('verifyPin', {'pin': pin})); + + return switch (result['status']) { + 'success' => const PinVerificationStatus.success(), + 'invalid-pin' => PinVerificationStatus.failure( + PivPinFailureReason.invalidPin(result['attemptsRemaining']), + ), + _ => throw 'Invalid response', + }; + } on PlatformException catch (_) { rethrow; } finally { // TODO controller?.close(); @@ -239,25 +233,21 @@ class _AndroidPivStateNotifier extends PivStateNotifier { @override Future changePin(String pin, String newPin) async { try { - await piv.invoke('changePin', {'pin': pin, 'newPin': newPin}); - // ref.read(_pinProvider(_devicePath).notifier).state = null; - return const PinVerificationStatus.success(); - } on PlatformException catch (e) { - // TODO - if (e.message == 'invalid-pin') { - // TODO - return PinVerificationStatus.failure( - PivPinFailureReason.invalidPin( - // TODO e.body['attempts_remaining']), - 3, - ), - ); - } - if (e.message == 'pin-complexity') { - return PinVerificationStatus.failure( + final result = jsonDecode( + await piv.invoke('changePin', {'pin': pin, 'newPin': newPin}), + ); + + return switch (result['status']) { + 'success' => const PinVerificationStatus.success(), + 'invalid-pin' => PinVerificationStatus.failure( + PivPinFailureReason.invalidPin(result['attemptsRemaining']), + ), + 'pin-complexity' => PinVerificationStatus.failure( const PivPinFailureReason.weakPin(), - ); - } + ), + _ => throw 'Invalid response', + }; + } on PlatformException catch (_) { rethrow; } finally { ref.invalidateSelf(); @@ -267,24 +257,20 @@ class _AndroidPivStateNotifier extends PivStateNotifier { @override Future changePuk(String puk, String newPuk) async { try { - await piv.invoke('changePuk', {'puk': puk, 'newPuk': newPuk}); - return const PinVerificationStatus.success(); - } on PlatformException catch (e) { - // TODO - if (e.message == 'invalid-pin') { - return PinVerificationStatus.failure( - PivPinFailureReason.invalidPin( - // TODO e.body['attempts_remaining']), - 3, - ), - ); - } - if (e.message == 'pin-complexity') { - // TODO - return PinVerificationStatus.failure( + final result = jsonDecode( + await piv.invoke('changePuk', {'puk': puk, 'newPuk': newPuk}), + ); + return switch (result['status']) { + 'success' => const PinVerificationStatus.success(), + 'invalid-pin' => PinVerificationStatus.failure( + PivPinFailureReason.invalidPin(result['attemptsRemaining']), + ), + 'pin-complexity' => PinVerificationStatus.failure( const PivPinFailureReason.weakPin(), - ); - } + ), + _ => throw 'Invalid response', + }; + } on PlatformException catch (_) { rethrow; } finally { ref.invalidateSelf(); @@ -302,33 +288,28 @@ class _AndroidPivStateNotifier extends PivStateNotifier { 'keyType': managementKeyType.value, 'storeKey': storeKey, }); - ref.read(_managementKeyProvider(_devicePath).notifier).state = - managementKey; + // ref.read(_managementKeyProvider(_devicePath).notifier).state = + // managementKey; ref.invalidateSelf(); } @override Future unblockPin(String puk, String newPin) async { try { - await piv.invoke('unblockPin', {'puk': puk, 'newPin': newPin}); - return const PinVerificationStatus.success(); - } on PlatformException catch (e) { - // TODO - if (e.message == 'invalid-pin') { - // TODO - return PinVerificationStatus.failure( - PivPinFailureReason.invalidPin( - // TODO e.body['attempts_remaining']), - 3, - ), - ); - } - if (e.message == 'pin-complexity') { - // TODO - return PinVerificationStatus.failure( + final result = jsonDecode( + await piv.invoke('unblockPin', {'puk': puk, 'newPin': newPin}), + ); + return switch (result['status']) { + 'success' => const PinVerificationStatus.success(), + 'invalid-pin' => PinVerificationStatus.failure( + PivPinFailureReason.invalidPin(result['attemptsRemaining']), + ), + 'pin-complexity' => PinVerificationStatus.failure( const PivPinFailureReason.weakPin(), - ); - } + ), + _ => throw 'Invalid response', + }; + } on PlatformException catch (_) { rethrow; } finally { ref.invalidateSelf(); @@ -421,61 +402,78 @@ class _AndroidPivSlotsNotifier extends PivSlotsNotifier { // // final signaler = Signaler(); // UserInteractionController? controller; - // try { - // signaler.signals.listen((signal) async { - // if (signal.status == 'touch') { - // controller = await withContext((context) async { - // final l10n = AppLocalizations.of(context); - // return promptUserInteraction( - // context, - // icon: const Icon(Symbols.touch_app), - // title: l10n.s_touch_required, - // description: l10n.l_touch_button_now, - // ); - // }); - // } - // }); - // - // final (type, subject, validFrom, validTo) = parameters.when( - // publicKey: () => (GenerateType.publicKey, null, null, null), - // certificate: - // (subject, validFrom, validTo) => ( - // GenerateType.certificate, - // subject, - // dateFormatter.format(validFrom), - // dateFormatter.format(validTo), - // ), - // csr: (subject) => (GenerateType.csr, subject, null, null), - // ); - // - // final pin = ref.read(_pinProvider(_session.devicePath)); - // - // final result = await _session.command( - // 'generate', - // target: ['slots', slot.hexId], - // params: { - // 'key_type': keyType.value, - // 'pin_policy': pinPolicy.value, - // 'touch_policy': touchPolicy.value, - // 'subject': subject, - // 'generate_type': type.name, - // 'valid_from': validFrom, - // 'valid_to': validTo, - // 'pin': pin, - // }, - // signal: signaler, - // ); - // - // ref.invalidateSelf(); - // - // return PivGenerateResult.fromJson({ - // 'generate_type': type.name, - // ...result, - // }); - // } finally { - // controller?.close(); - // } - return PivGenerateResult.fromJson({}); + try { + // signaler.signals.listen((signal) async { + // if (signal.status == 'touch') { + // controller = await withContext((context) async { + // final l10n = AppLocalizations.of(context); + // return promptUserInteraction( + // context, + // icon: const Icon(Symbols.touch_app), + // title: l10n.s_touch_required, + // description: l10n.l_touch_button_now, + // ); + // }); + // } + // }); + // + final (type, subject, validFrom, validTo) = switch (parameters) { + PivGeneratePublicKeyParameters() => ( + GenerateType.publicKey, + null, + null, + null, + ), + + PivGenerateCertificateParameters( + :final subject, + :final validFrom, + :final validTo, + ) => + ( + GenerateType.certificate, + subject, + dateFormatter.format(validFrom), + dateFormatter.format(validTo), + ), + + PivGenerateCsrParameters(:final subject) => ( + GenerateType.csr, + subject, + null, + null, + ), + }; + + //final pin = ref.read(_pinProvider(_session.devicePath)); + + final result = jsonDecode( + await piv.invoke( + 'generate', + { + 'slot': slot.hexId, + 'keyType': keyType.value, + 'pinPolicy': pinPolicy.value, + 'touchPolicy': touchPolicy.value, + 'subject': subject, + 'generateType': type.name, + 'validFrom': validFrom, + 'validTo': validTo, + }, + //signal: signaler, + ), + ); + + ref.invalidateSelf(); + + return PivGenerateResult.fromJson({ + 'generate_type': type.name, + ...result, + }); + } finally { + //controller?.close(); + } + //return PivGenerateResult.fromJson({}); } @override @@ -484,11 +482,13 @@ class _AndroidPivSlotsNotifier extends PivSlotsNotifier { String data, { String? password, }) async { - final result = await piv.invoke('examineFile', { - 'slot': slot.hexId, - 'data': data, - 'password': password, - }); + final result = jsonDecode( + await piv.invoke('examineFile', { + 'slot': slot.hexId, + 'data': data, + 'password': password, + }), + ); if (result['status']) { return PivExamineResult.fromJson({'runtimeType': 'result', ...result}); @@ -499,7 +499,9 @@ class _AndroidPivSlotsNotifier extends PivSlotsNotifier { @override Future validateRfc4514(String value) async { - final result = await piv.invoke('validateRfc4514', {'data': value}); + final result = jsonDecode( + await piv.invoke('validateRfc4514', {'data': value}), + ); return result['status']; } @@ -511,13 +513,15 @@ class _AndroidPivSlotsNotifier extends PivSlotsNotifier { PinPolicy pinPolicy = PinPolicy.dfault, TouchPolicy touchPolicy = TouchPolicy.dfault, }) async { - final result = await piv.invoke('importFile', { - 'slot': slot.hexId, - 'data': data, - 'password': password, - 'pinPolicy': pinPolicy.value, - 'touchPolicy': touchPolicy.value, - }); + final result = jsonDecode( + await piv.invoke('importFile', { + 'slot': slot.hexId, + 'data': data, + 'password': password, + 'pinPolicy': pinPolicy.value, + 'touchPolicy': touchPolicy.value, + }), + ); ref.invalidateSelf(); return PivImportResult.fromJson(result); diff --git a/lib/piv/views/authentication_dialog.dart b/lib/piv/views/authentication_dialog.dart index 404722fc9..3391d2171 100644 --- a/lib/piv/views/authentication_dialog.dart +++ b/lib/piv/views/authentication_dialog.dart @@ -66,6 +66,7 @@ class _AuthenticationDialogState extends ConsumerState { final keyFormatInvalid = !Format.hex.isValid(_keyController.text); void submit() async { + _keyFocus.unfocus(); if (keyFormatInvalid) { _keyController.selection = TextSelection( baseOffset: 0, diff --git a/lib/piv/views/pin_dialog.dart b/lib/piv/views/pin_dialog.dart index a28f454dd..25e387b14 100644 --- a/lib/piv/views/pin_dialog.dart +++ b/lib/piv/views/pin_dialog.dart @@ -61,6 +61,7 @@ class _PinDialogState extends ConsumerState { } Future _submit() async { + _pinFocus.unfocus(); final navigator = Navigator.of(context); try { final status = await ref From a67a6933e034185d21bdad5e69412fe138261cf4 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Thu, 7 Aug 2025 21:09:16 +0200 Subject: [PATCH 08/28] improve file parser, add tests --- .../com/yubico/authenticator/MainActivity.kt | 5 + .../authenticator/piv/KeyMaterialParser.kt | 242 ++++++++ .../yubico/authenticator/piv/PivManager.kt | 23 +- .../piv/utils/KeyMaterialUtils.kt | 222 ------- .../piv/KeyMaterialParserTest.kt | 223 +++++++ .../com/yubico/authenticator/piv/TestData.kt | 560 ++++++++++++++++++ .../piv/utils/KeyMaterialTestData.kt | 201 ------- .../piv/utils/KeyMaterialUtilsTest.kt | 87 --- 8 files changed, 1042 insertions(+), 521 deletions(-) create mode 100644 android/app/src/main/kotlin/com/yubico/authenticator/piv/KeyMaterialParser.kt delete mode 100644 android/app/src/main/kotlin/com/yubico/authenticator/piv/utils/KeyMaterialUtils.kt create mode 100644 android/app/src/test/java/com/yubico/authenticator/piv/KeyMaterialParserTest.kt create mode 100644 android/app/src/test/java/com/yubico/authenticator/piv/TestData.kt delete mode 100644 android/app/src/test/java/com/yubico/authenticator/piv/utils/KeyMaterialTestData.kt delete mode 100644 android/app/src/test/java/com/yubico/authenticator/piv/utils/KeyMaterialUtilsTest.kt diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt b/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt index 2ec7deef6..e98afea50 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt @@ -79,11 +79,13 @@ import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.MethodChannel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import org.bouncycastle.jce.provider.BouncyCastleProvider import org.json.JSONObject import org.slf4j.LoggerFactory import java.io.Closeable import java.io.IOException import java.security.NoSuchAlgorithmException +import java.security.Security import java.util.concurrent.Executors import javax.crypto.Mac @@ -127,6 +129,9 @@ class MainActivity : FlutterFragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + Security.removeProvider("BC") + Security.insertProviderAt(BouncyCastleProvider(), 1) + if (isPortraitOnly()) { forcePortraitOrientation() } diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/KeyMaterialParser.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/KeyMaterialParser.kt new file mode 100644 index 000000000..42c594317 --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/KeyMaterialParser.kt @@ -0,0 +1,242 @@ +/* + * Copyright (C) 2025 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.yubico.authenticator.piv + +import android.util.Base64 +import org.bouncycastle.asn1.ASN1Primitive +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo +import org.bouncycastle.asn1.sec.ECPrivateKey +import org.bouncycastle.cert.X509CertificateHolder +import org.bouncycastle.jce.ECNamedCurveTable +import org.bouncycastle.jce.spec.ECPrivateKeySpec +import org.bouncycastle.openssl.PEMEncryptedKeyPair +import org.bouncycastle.openssl.PEMKeyPair +import org.bouncycastle.openssl.PEMParser +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter +import org.bouncycastle.openssl.jcajce.JceOpenSSLPKCS8DecryptorProviderBuilder +import org.bouncycastle.openssl.jcajce.JcePEMDecryptorProviderBuilder +import org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfo +import java.io.ByteArrayInputStream +import java.io.IOException +import java.io.StringReader +import java.security.KeyFactory +import java.security.KeyStore +import java.security.PrivateKey +import java.security.PublicKey +import java.security.UnrecoverableKeyException +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.security.spec.PKCS8EncodedKeySpec +import java.util.Arrays +import javax.crypto.EncryptedPrivateKeyInfo +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.PBEKeySpec +import javax.security.auth.x500.X500Principal + +typealias KeyMaterial = Pair, PrivateKey?> + +class InvalidPasswordException(cause: Throwable) : Exception(cause) + +object KeyMaterialParser { + + private class InvalidDerFormat : Exception() + + fun PublicKey.toPem(): String { + val base64 = Base64.encodeToString(encoded, Base64.NO_WRAP) + val wrapped = base64.chunked(64).joinToString("\n") + return "-----BEGIN PUBLIC KEY-----\n$wrapped\n-----END PUBLIC KEY-----\n" + } + + fun getLeafCertificates(certs: List): List { + val issuers: Set = certs.map { it.issuerX500Principal }.toSet() + return certs.filter { cert -> cert.subjectX500Principal !in issuers } + } + + fun parse(bytes: ByteArray, password: CharArray? = null): KeyMaterial = try { + if (isPem(bytes)) { + parsePem(bytes, password) + } else { + parseBinary(bytes, password) + } + } finally { + password?.let { Arrays.fill(it, 0.toChar()) } + } + + private fun parsePem(bytes: ByteArray, password: CharArray?): KeyMaterial { + val pem = String(bytes, Charsets.UTF_8) + val certs = mutableListOf() + val regex = Regex( + "-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----", + RegexOption.DOT_MATCHES_ALL + ) + for (match in regex.findAll(pem)) { + val base64 = match.groupValues[1].replace("\n", "").replace("\r", "") + val decoded = Base64.decode(base64, Base64.DEFAULT) + val cert = CertificateFactory.getInstance("X.509") + .generateCertificate(ByteArrayInputStream(decoded)) as X509Certificate + certs.add(cert) + } + val key = parsePemPrivateKey(pem, password) + return KeyMaterial(certs, key) + } + + private fun parsePemPrivateKey(pem: String, password: CharArray?): PrivateKey? { + try { + val pemParser = PEMParser(StringReader(pem)) + var info: Any? + do { + info = pemParser.readObject() + val objectInfo = if (info is X509CertificateHolder) { + continue // not a key + } else if (info is PKCS8EncryptedPrivateKeyInfo) { + if (password == null) { + throw InvalidPasswordException( + Exception("Encrypted private key needs a password") + ) + } + val decryptor = JceOpenSSLPKCS8DecryptorProviderBuilder().build(password) + info.decryptPrivateKeyInfo(decryptor) + } else if (info is PEMEncryptedKeyPair) { + if (password == null) { + throw InvalidPasswordException( + Exception("Encrypted private key needs a password") + ) + } + val decryptor = JcePEMDecryptorProviderBuilder().build(password) + info.decryptKeyPair(decryptor).privateKeyInfo + } else if (info is PEMKeyPair) { + info.privateKeyInfo + } else info as? PrivateKeyInfo ?: continue + return JcaPEMKeyConverter().getPrivateKey(objectInfo) + } while (info != null) + } catch (_: ClassCastException) { + return null // not a key + } + + return null + } + + private fun parseBinary(bytes: ByteArray, password: CharArray?): KeyMaterial { + try { + return parseDer(bytes) + } catch (e: Exception) { + when (e) { + !is InvalidDerFormat -> throw e + } + } + + try { + return parsePkcs12(bytes, password) + } catch (e: Exception) { + when (e) { + is UnrecoverableKeyException, is NullPointerException, is IOException + -> throw InvalidPasswordException(e) + } + } + + return KeyMaterial(emptyList(), null) + } + + private fun parseDer(der: ByteArray): KeyMaterial { + val derCert = parseDerCert(der) + if (derCert == null) { + val derKey = parsePrivateKey(der) + if (derKey != null) { + return KeyMaterial(emptyList(), derKey) + } + } else { + return KeyMaterial(derCert, null) + } + throw InvalidDerFormat() + } + + private fun parseDerCert(der: ByteArray): List? = + try { + listOf( + CertificateFactory.getInstance("X.509") + .generateCertificate(ByteArrayInputStream(der)) as X509Certificate + ) + } catch (_: Exception) { + null // not a cert + } + + private fun parsePrivateKey(bytes: ByteArray, password: CharArray? = null): PrivateKey? { + val keySpec = if (password != null) { + val encryptedPrivateKeyInfo = EncryptedPrivateKeyInfo(bytes) + val keyFactory = SecretKeyFactory.getInstance(encryptedPrivateKeyInfo.algName) + val pbeKeySpec = PBEKeySpec(password) + val secretKey = keyFactory.generateSecret(pbeKeySpec) + encryptedPrivateKeyInfo.getKeySpec(secretKey) + } else { + PKCS8EncodedKeySpec(bytes) + } + + for (alg in listOf("RSA", "EC", "DSA", "Ed25519", "X25519")) { + try { + return KeyFactory.getInstance(alg).generatePrivate(keySpec) + } catch (_: Exception) { + // Ignore and try next + } + } + + try { + // try SEC1 + val asn1 = ASN1Primitive.fromByteArray(bytes) + + val ecPrivateKey = ECPrivateKey.getInstance(asn1) + val d = ecPrivateKey.key + + // TODO P-256 + val ecSpec = ECNamedCurveTable.getParameterSpec("secp384r1") + val privateKeySpec = ECPrivateKeySpec(d, ecSpec) + val kf = KeyFactory.getInstance("EC", "BC") + return kf.generatePrivate(privateKeySpec) + + } catch (_: Exception) { + // was not SEC1 + } + + return null + } + + private fun parsePkcs12( + bytes: ByteArray, + password: CharArray? + ): KeyMaterial { + val keyStore = KeyStore.getInstance("PKCS12") + keyStore.load(ByteArrayInputStream(bytes), password) + val certs = mutableListOf() + val aliases = keyStore.aliases() + while (aliases.hasMoreElements()) { + val alias = aliases.nextElement() + val cert = keyStore.getCertificate(alias) + if (cert is X509Certificate) { + certs.add(cert) + } + } + + val chosenAlias = keyStore.aliases().toList().firstOrNull() + val key = if (chosenAlias != null) { + keyStore.getKey(chosenAlias, password) as? PrivateKey + } else null + + return KeyMaterial(certs, key) + } + + private fun isPem(bytes: ByteArray): Boolean = + String(bytes.take(10).toByteArray(), Charsets.UTF_8).startsWith("-----BEGIN") +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt index e0ff65378..c8629a32d 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt @@ -32,11 +32,9 @@ import com.yubico.authenticator.piv.data.byteArrayToHexString import com.yubico.authenticator.piv.data.fingerprint import com.yubico.authenticator.piv.data.hexStringToByteArray import com.yubico.authenticator.piv.data.isoFormat -import com.yubico.authenticator.piv.utils.InvalidPasswordException -import com.yubico.authenticator.piv.utils.KeyMaterial -import com.yubico.authenticator.piv.utils.KeyMaterialUtils.getLeafCertificates -import com.yubico.authenticator.piv.utils.KeyMaterialUtils.parse -import com.yubico.authenticator.piv.utils.KeyMaterialUtils.toPem +import com.yubico.authenticator.piv.KeyMaterialParser.getLeafCertificates +import com.yubico.authenticator.piv.KeyMaterialParser.parse +import com.yubico.authenticator.piv.KeyMaterialParser.toPem import com.yubico.authenticator.setHandler import com.yubico.authenticator.yubikit.DeviceInfoHelper.Companion.getDeviceInfo import com.yubico.authenticator.yubikit.withConnection @@ -313,7 +311,7 @@ class PivManager( managementKeyStorage[serial] = managementKey doAuth(piv, serial) jsonSerializer.encodeToString(mapOf("status" to true)) - } catch (e: Exception) { + } catch (_: Exception) { jsonSerializer.encodeToString(mapOf("status" to false)) } } @@ -487,7 +485,7 @@ class PivManager( certificate?.let { buildJsonObject { val keyType = KeyType.fromKey(certificate.publicKey) - put("key_type", JsonPrimitive(keyType.value)) + put("key_type", JsonPrimitive(keyType.value.toInt() and 0xff)) put("subject", JsonPrimitive(certificate.subjectDN.name)) put("issuer", JsonPrimitive(certificate.issuerDN.name)) put("serial", JsonPrimitive(certificate.serialNumber.toString())) @@ -520,7 +518,10 @@ class PivManager( put("status", JsonPrimitive(true)) put("password", JsonPrimitive(password != null)) put("key_type", privateKey?.let { - JsonPrimitive(KeyType.fromKeyParams(PrivateKeyValues.fromPrivateKey(it)).value) + JsonPrimitive( + KeyType.fromKeyParams( + PrivateKeyValues.fromPrivateKey(it) + ).value.toUByte()) } ?: JsonNull) put("cert_info", getCertificateInfo(certificate) ?: JsonNull) pivViewModel.getMetadata(slot)?.let { @@ -531,7 +532,7 @@ class PivManager( } jsonSerializer.encodeToString(JsonObject.serializer(), result) - } catch (e: InvalidPasswordException) { + } catch (_: InvalidPasswordException) { val result = buildJsonObject { put("status", JsonPrimitive(false)) } @@ -548,7 +549,7 @@ class PivManager( ): String = try { getX500Name(data) jsonSerializer.encodeToString(mapOf("status" to true)) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { jsonSerializer.encodeToString(mapOf("status" to false)) } @@ -615,7 +616,7 @@ class PivManager( data: String, password: String? ): KeyMaterial = try { - parse(data.hexStringToByteArray(), password) + parse(data.hexStringToByteArray(), password?.toCharArray()) } catch (e: Exception) { when (e) { is IllegalArgumentException, is IOException -> KeyMaterial( diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/utils/KeyMaterialUtils.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/utils/KeyMaterialUtils.kt deleted file mode 100644 index cc0b123e8..000000000 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/utils/KeyMaterialUtils.kt +++ /dev/null @@ -1,222 +0,0 @@ -/* - * Copyright (C) 2025 Yubico. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.yubico.authenticator.piv.utils - -import android.util.Base64 -import com.yubico.authenticator.piv.data.hexStringToByteArray -import java.io.ByteArrayInputStream -import java.io.IOException -import java.security.KeyFactory -import java.security.KeyStore -import java.security.PrivateKey -import java.security.PublicKey -import java.security.UnrecoverableKeyException -import java.security.cert.CertificateFactory -import java.security.cert.X509Certificate -import java.security.spec.PKCS8EncodedKeySpec -import javax.security.auth.x500.X500Principal - -typealias KeyMaterial = Pair, PrivateKey?> - -class InvalidPasswordException(cause: Throwable) : Exception(cause) - -object KeyMaterialUtils { - - private class InvalidDerFormat : Exception() - - fun PublicKey.toPem(): String { - val base64 = Base64.encodeToString(encoded, Base64.NO_WRAP) - val wrapped = base64.chunked(64).joinToString("\n") - return "-----BEGIN PUBLIC KEY-----\n$wrapped\n-----END PUBLIC KEY-----\n" - } - - fun getLeafCertificates(certs: List): List { - val issuers: Set = certs.map { it.issuerX500Principal }.toSet() - return certs.filter { cert -> cert.subjectX500Principal !in issuers } - } - - fun parse(bytes: ByteArray, password: String? = null): KeyMaterial { - return if (isPem(bytes)) { - parsePem(bytes) - } else { - parseBinary(bytes, password) - } - } - - private fun parsePem(bytes: ByteArray): KeyMaterial { - val pem = String(bytes, Charsets.UTF_8) - val certs = mutableListOf() - val regex = Regex( - "-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----", - RegexOption.DOT_MATCHES_ALL - ) - for (match in regex.findAll(pem)) { - val base64 = match.groupValues[1].replace("\n", "").replace("\r", "") - val decoded = Base64.decode(base64, Base64.DEFAULT) - val cert = CertificateFactory.getInstance("X.509") - .generateCertificate(ByteArrayInputStream(decoded)) as X509Certificate - certs.add(cert) - } - val key = parsePemPrivateKey(pem) - return KeyMaterial(certs, key) - } - - private fun parsePemPrivateKey(pem: String): PrivateKey? { - val pkcs8Regex = Regex( - "-----BEGIN PRIVATE KEY-----(.*?)-----END PRIVATE KEY-----", - RegexOption.DOT_MATCHES_ALL - ) - val match = pkcs8Regex.find(pem) - if (match != null) { - val base64 = match.groupValues[1].replace("\n", "").replace("\r", "") - val decoded = Base64.decode(base64, Base64.DEFAULT) - return generatePkcs8PrivateKey(decoded) - } - - // TODO EC PRIVATE KEY - SEC1 (RFC 5915) - // TODO ED25519 PRIVATE KEY - // TODO ENCRYPTED PRIVATE KEY - val pkcs1Regex = Regex( - "-----BEGIN RSA PRIVATE KEY-----(.*?)-----END RSA PRIVATE KEY-----", - RegexOption.DOT_MATCHES_ALL - ) - val matchRsa = pkcs1Regex.find(pem) - if (matchRsa != null) { - val base64 = matchRsa.groupValues[1] - .replace("\n", "") - .replace("\r", "") - val decoded = Base64.decode(base64, Base64.DEFAULT) - return pkcs1ToPkcs8(decoded) - } - return null - } - - private fun generatePkcs8PrivateKey(encoded: ByteArray): PrivateKey? { - try { - val keySpec = PKCS8EncodedKeySpec(encoded) - try { - return KeyFactory.getInstance("RSA").generatePrivate(keySpec) - } catch (_: Exception) { - } - try { - return KeyFactory.getInstance("EC").generatePrivate(keySpec) - } catch (_: Exception) { - } - try { - return KeyFactory.getInstance("DSA").generatePrivate(keySpec) - } catch (_: Exception) { - } - } catch (_: Exception) { - } - return null - } - - private fun pkcs1ToPkcs8(pkcs1Bytes: ByteArray): PrivateKey? { - // TODO - return null - } - - private fun parseBinary(bytes: ByteArray, password: String?): KeyMaterial { - try { - return parseDer(bytes) - } catch (e: Exception) { - when (e) { - !is InvalidDerFormat -> throw e - } - } - - try { - return parsePkcs12(bytes, password) - - } catch (e: Exception) { - when (e) { - is UnrecoverableKeyException, is NullPointerException, is IOException -> throw InvalidPasswordException( - e - ) - } - } - - return KeyMaterial(emptyList(), null) - } - - private fun parseDer(der: ByteArray): KeyMaterial { - val derCert = parseDerCert(der) - val derKey = parseDerPrivateKey(der) - - if (derCert == null && derKey == null) { - throw InvalidDerFormat() - } - - return KeyMaterial(derCert ?: emptyList(), derKey) - } - - private fun parseDerCert(der: ByteArray): List? = - try { - listOf( - CertificateFactory.getInstance("X.509") - .generateCertificate(ByteArrayInputStream(der)) as X509Certificate - ) - } catch (_: Exception) { - null // not a cert - } - - private fun parseDerPrivateKey(der: ByteArray): PrivateKey? { - val keySpec = PKCS8EncodedKeySpec(der) - try { - return KeyFactory.getInstance("RSA").generatePrivate(keySpec) - } catch (_: Exception) { - } - - try { - return KeyFactory.getInstance("EC").generatePrivate(keySpec) - } catch (_: Exception) { - } - - try { - return KeyFactory.getInstance("DSA").generatePrivate(keySpec) - } catch (_: Exception) { - } - return null - } - - private fun parsePkcs12( - bytes: ByteArray, - password: String? - ): KeyMaterial { - val keyStore = KeyStore.getInstance("PKCS12") - keyStore.load(ByteArrayInputStream(bytes), password?.toCharArray()) - val certs = mutableListOf() - val aliases = keyStore.aliases() - while (aliases.hasMoreElements()) { - val alias = aliases.nextElement() - val cert = keyStore.getCertificate(alias) - if (cert is X509Certificate) { - certs.add(cert) - } - } - - val chosenAlias = keyStore.aliases().toList().firstOrNull() - val key = if (chosenAlias != null) { - keyStore.getKey(chosenAlias, password?.toCharArray()) as? PrivateKey - } else null - - return KeyMaterial(certs, key) - } - - private fun isPem(bytes: ByteArray): Boolean = - String(bytes.take(10).toByteArray(), Charsets.UTF_8).startsWith("-----BEGIN") -} \ No newline at end of file diff --git a/android/app/src/test/java/com/yubico/authenticator/piv/KeyMaterialParserTest.kt b/android/app/src/test/java/com/yubico/authenticator/piv/KeyMaterialParserTest.kt new file mode 100644 index 000000000..a764cc203 --- /dev/null +++ b/android/app/src/test/java/com/yubico/authenticator/piv/KeyMaterialParserTest.kt @@ -0,0 +1,223 @@ +/* + * Copyright (C) 2025 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.yubico.authenticator.piv + +import android.util.Base64 +import com.yubico.authenticator.piv.TestData.ECCP384 +import com.yubico.authenticator.piv.TestData.ED25519 +import com.yubico.authenticator.piv.TestData.RSA2048 +import com.yubico.authenticator.piv.TestData.X25519 +import org.bouncycastle.jcajce.interfaces.EdDSAPrivateKey +import org.bouncycastle.jce.interfaces.ECPrivateKey +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.mockito.ArgumentMatchers +import org.mockito.Mockito +import java.security.Security +import java.security.interfaces.RSAPrivateKey +import java.security.interfaces.XECPrivateKey + +class KeyMaterialParserTest { + + @Before + fun `setup BC`() { + Security.removeProvider("BC") + Security.insertProviderAt(BouncyCastleProvider(), 1) + } + + @Test + fun `rsa2048 p12`() { + val (certs, key) = KeyMaterialParser.parse(RSA2048.PKCS12, password()) + Assert.assertTrue(certs.size == 1) + Assert.assertTrue(key is RSAPrivateKey) + } + + @Test(expected = InvalidPasswordException::class) + fun `rsa2048 p12 no password`() { + val (certs, key) = KeyMaterialParser.parse(RSA2048.PKCS12) + Assert.assertTrue(certs.size == 1) + Assert.assertTrue(key is RSAPrivateKey) + } + + @Test(expected = InvalidPasswordException::class) + fun `rsa2048 p12 wrong password`() { + val (certs, key) = KeyMaterialParser.parse(RSA2048.PKCS12, invalidPassword()) + Assert.assertTrue(certs.size == 1) + Assert.assertTrue(key is RSAPrivateKey) + } + + @Test + fun `rsa2048 pem`() { + mockBase64 { + val (certs, key) = KeyMaterialParser.parse(RSA2048.PEM, password()) + Assert.assertTrue(certs.size == 1) + Assert.assertTrue(key is RSAPrivateKey) + } + } + + @Test + fun `rsa2048 enc pem`() { + mockBase64 { + val (certs, key) = KeyMaterialParser.parse(RSA2048.PEM_ENC, password()) + Assert.assertTrue(certs.size == 1) + Assert.assertTrue(key is RSAPrivateKey) + } + } + + @Test + fun `rsa2048 crt der`() { + val (certs, key) = KeyMaterialParser.parse(RSA2048.DER_CERT) + Assert.assertTrue(certs.size == 1) + Assert.assertNull(key) + } + + @Test + fun `rsa2048 key der`() { + val (certs, key) = KeyMaterialParser.parse(RSA2048.DER_KEY) + Assert.assertTrue(certs.isEmpty()) + Assert.assertTrue(key is RSAPrivateKey) + } + + // X25519 + @Test + fun `X25519 key pem`() { + mockBase64 { + val (certs, key) = KeyMaterialParser.parse(X25519.PEM) + Assert.assertTrue(certs.isEmpty()) + Assert.assertTrue(key is XECPrivateKey) + } + } + + @Test + fun `X25519 key enc pem`() { + mockBase64 { + val (certs, key) = KeyMaterialParser.parse(X25519.PEM_ENC, password()) + Assert.assertTrue(certs.isEmpty()) + Assert.assertTrue(key is XECPrivateKey) + } + } + + @Test + fun `X25519 key der`() { + val (certs, key) = KeyMaterialParser.parse(X25519.DER) + Assert.assertTrue(certs.isEmpty()) + Assert.assertTrue(key is XECPrivateKey) + } + + // Ed25519 + @Test + fun `Ed25519 key pem`() { + mockBase64 { + val (certs, key) = KeyMaterialParser.parse(ED25519.PEM) + Assert.assertEquals(1, certs.size) + Assert.assertTrue(key is EdDSAPrivateKey) + } + } + + @Test + fun `Ed25519 key enc pem`() { + mockBase64 { + val (certs, key) = KeyMaterialParser.parse(ED25519.PEM_ENC, password()) + Assert.assertEquals(1, certs.size) + Assert.assertTrue(key is EdDSAPrivateKey) + } + } + + @Test + fun `Ed25519 key der`() { + val (certs, key) = KeyMaterialParser.parse(ED25519.DER_KEY) + Assert.assertTrue(certs.isEmpty()) + Assert.assertTrue(key is EdDSAPrivateKey) + } + + @Test + fun `Ed25519 crt der`() { + val (certs, key) = KeyMaterialParser.parse(ED25519.DER_CERT) + Assert.assertEquals(1, certs.size) + Assert.assertNull(key) + } + + @Test + fun `Ed25519 p12`() { + val (certs, key) = KeyMaterialParser.parse(ED25519.PKCS12, password()) + Assert.assertEquals(1, certs.size) + Assert.assertTrue(key is EdDSAPrivateKey) + } + + // ECCP384 + @Test + fun `ecsecp384r1 pem`() { + mockBase64 { + val (certs, key) = KeyMaterialParser.parse(ECCP384.PEM) + Assert.assertEquals(1, certs.size) + Assert.assertTrue(key is ECPrivateKey) + } + } + + @Test + fun `ecsecp384r1 enc pem`() { + mockBase64 { + val (certs, key) = KeyMaterialParser.parse(ECCP384.PEM_ENC, password()) + Assert.assertEquals(1, certs.size) + Assert.assertTrue(key is ECPrivateKey) + } + } + + @Test + fun `ecsecp384r1 key`() { + val (certs, key) = KeyMaterialParser.parse(ECCP384.DER_KEY) + Assert.assertTrue(certs.isEmpty()) + Assert.assertTrue(key is ECPrivateKey) + } + + @Test + fun `ecsecp384r1 crt der`() { + val (certs, key) = KeyMaterialParser.parse(ECCP384.DER_CERT) + Assert.assertEquals(1, certs.size) + Assert.assertNull(key) + } + + @Test + fun `ecsecp384r1 p12`() { + val (certs, key) = KeyMaterialParser.parse(ECCP384.PKCS12, password()) + Assert.assertEquals(1, certs.size) + Assert.assertTrue(key is ECPrivateKey) + } + + companion object { + fun password() = "11234567".toCharArray() + fun invalidPassword() = "1123456".toCharArray() + fun mockBase64(block: () -> Unit) { + Mockito.mockStatic(Base64::class.java).use { mock -> + + mock.`when` { + Base64.decode( + ArgumentMatchers.anyString(), + ArgumentMatchers.anyInt() + ) + } + .thenAnswer { invocation -> + java.util.Base64.getDecoder().decode(invocation.getArgument(0)) + } + + block() + } + } + } +} \ No newline at end of file diff --git a/android/app/src/test/java/com/yubico/authenticator/piv/TestData.kt b/android/app/src/test/java/com/yubico/authenticator/piv/TestData.kt new file mode 100644 index 000000000..a66a078ce --- /dev/null +++ b/android/app/src/test/java/com/yubico/authenticator/piv/TestData.kt @@ -0,0 +1,560 @@ +/* + * Copyright (C) 2025 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.yubico.authenticator.piv + +import com.yubico.authenticator.piv.data.hexStringToByteArray + +object TestData { + object RSA2048 { + val PKCS12 = PKCS12_HEX.hexStringToByteArray() + private const val PKCS12_HEX = "30820a5f02010330820a1506092a864886f70d010701a0820a060482" + + "0a02308209fe3082046a06092a864886f70d010706a082045b308204570201003082045006092a8" + + "64886f70d010701305f06092a864886f70d01050d3052303106092a864886f70d01050c30240410" + + "94ecdd1cbea1477b713901994804bb4702020800300c06082a864886f70d02090500301d0609608" + + "64801650304012a0410caec11f260bdc96ba0a59bad277418a4808203e043fc1d246460b3edb92b" + + "9449f13f82c196a3837d15647b3d8172746063cbf8da5ff7d0baff37e835219003c2f9213ebe084" + + "9f6fc8a495d5d5acc4a2f519f6b893ced799945662060d079b002967ff8d331f60672d73c08c43f" + + "b5f57ff0e59c0e6a30bb3f53a159cc7f01ebd01a8aa3fd13a2639c85e3c3462e2c30411d2774092" + + "548ab8fe0b43a436994d82d0218e8dd36e93767997f336f17bac8eba394adb31c004d1fd37f1b16" + + "25dc9a48b48df844698da311f5c5740c6a1755277104e7fcbfd4d7318acaecab59028ec617e46e2" + + "1199821c8a415662ac496746e40c42ff78a21d9f896f5b5c26b970e9e06919a28f6c2578a09b2cf" + + "f2e9e2037bb883c61bdef99271067da22de5a15d5e03d40c4f7645f8913ed95c1b45711f75174e7" + + "424739f20755adf66f43b41ffba609ed74896b5edc5695a5959f30db454ed358087ec84dabef5eb" + + "80d9861e5286100d91c60cf833d767afe0356b5400523a1741607d3b4bd6e84608012c7763be94a" + + "978450998ef31b3f78d83741911664b1922993af3a551d03de24249880c1f09e10017c30c020a5a" + + "b55dd3290df29397446c087444fb7532106e5a9ee3762a45458a44f4295c9d8aee231a4332c396f" + + "1c781f8c5b8b3a9cad39acec78863f5fb181513a77f55ad305d9a2865f1370543ed058997c3954c" + + "f37255392d27c4c8d277b5446a017311e3a04aece860394e3272ccf0a25b39c7b558d948d1e87cd" + + "c1174c9df15b10a02cb9644b58fc02eda753940f1467f47cc3e2ca72df69c60d675ac644fe739bb" + + "8fb05b2b39de75302712d0cf0830d2290827172080c23a811c81ca11325759f491001d9efa15dd4" + + "2e97f4082f0f9c4fd311c4d083fab58c7a6282c00d32dcdc7995274817dfcbcad345f7d2a464501" + + "ba4473f288a6b14cde3c4851740166d62fac9b58f93d898a373f061731e917ec261595768a6ccbb" + + "dc77af9a18b1e88a43259729070983ea1757d52c1f071feb0cbefe9158afdd4f42e6893aa9dec80" + + "b7f32fa11e8e899cdd2697edcbc5494bf48f96153624f38b5868e3847db39ccf6fe98f1108f4dad" + + "c9548a8304c5b5a55e2d6196801d7ae4cb5f4db88927af67ecf7cfdd2d75e21cda5b1cdba78fd14" + + "9214dcaa06361f4472cc62ab69397251bee9841134c9c01c462fa8ddd5d443e0e055f5cb4a53e41" + + "d77edc1111298ab757b5f88a2a3bc1590ebcd553b6ad5cb1c869081a5be73af53d5bd58bdc6c01e" + + "7bd0d2a3dce6b29f1e231b918671c5f03ba49c3af26eb4f38fdc50a3e5b4d28fc8cea17a7ef45d7" + + "ef631f9a8008d56b2b24bfd71949ea21a6dddf8de5d5153a991be8de9843c101f58c5d221b1fd6d" + + "0440de3b5cbf925f0ae92b6dd9ee8b2d7c6cb1fed2439f6838277e6239dbf8c062fe3082058c060" + + "92a864886f70d010701a082057d048205793082057530820571060b2a864886f70d010c0a0102a0" + + "82053930820535305f06092a864886f70d01050d3052303106092a864886f70d01050c302404104" + + "93a0c381e8350da4fab27a465b387d802020800300c06082a864886f70d02090500301d06096086" + + "4801650304012a0410011db7eccf0d6574a9e0844d7e989530048204d098a23193e1876327bc8e3" + + "b86261268e046699c86759b3b665e5215519e30ef366dc5c629b9c07716d626d720d09525f2f60c" + + "86534fb9f8aab74959a4dd9392cb84e2c3985372e0fc2f39fd9271713a9a31db94a8c160ff429f1" + + "901b5eff7d18bbc30e1fb66ea228d363c41c73e1563fb4a2627446d392de29c07e06f82e0a47316" + + "ee93ffd58f807a344fe4d46f99ae6ba20dbce8f38f7db67de3b58898ec5aef5bef69f91dc2a127c" + + "172494e1c03b0488ee8a4d825d83f91c9216addfb0be8eada6f04d34460882180ab772ce3c65354" + + "41ec8734cfff1f38975b70249f2418965e8f7de9e56fab0084fc1aaeac530a9d110468fa1db9544" + + "cc35297668460c1b42f129b014bff7aef5b888d86d72de4286883184bb35c945c92c074f22aafdc" + + "9d7dd50879107fd848baf6838f882cf432403ed191b64114ba816267dec077de4be6e76f93a263b" + + "135a85227b3ee7cdef078f21bbe9f80a2b709e8a17211cb5cd018160723165662ddc628138bb529" + + "05c202f35085146942d68e094867a1dc847f21edcf341e8bc700947f3682b59fbdb57e4b9a3b939" + + "3b1148ff46ef48e9a04e30e122a44c5fbbe6a7bffd622ba5623f049c1036e484b666b959eb58a43" + + "27afc224ffb57a970d3d7e5b88cf2a65eaca1ab8e9f04d9030574df9c134b4e446177a6f4463a77" + + "214e1a7ee762f93893bcc2a76e89a874d380d8efc7e3fc8466e42061b731dd455486f5d07bf61ef" + + "29a0db635cb8ec9139024b0f768b95b57bd88a767fc77eb79b6fee557bb084a188eb8b0b2ddf9e2" + + "b7c249768ac233b0f50da0424d0d41c85587785699ba32b0869da21aa156b2bcc5a16e86ef61339" + + "a584f7da7db0b538077097b89b0059d1b198cd4a659a5082c61f8ba1ca3b8b030188feed100cfaa" + + "81d5dce43a40543d9e76943ace557f3573c40b1e5e577bb371763639d530df9da7fe8e747b290f4" + + "7f0b4b15439aee379bd2a481d779b451c3569a0ce48b648dac6e8b63bdbc2e1bbe22509d227a817" + + "4448a3b60ea675f7d8f804f8a9aea1618922bae70021bca3fe83b957808468f8a9a2a0e1abb7d95" + + "882b4c629373b3aa3a3d9fa1bd99abfa0094099051b82e1dac5a3fed03713a9647c00dc1a7e006f" + + "aaca7832ee79e334d41babbb8f0ec6c2c1e27feb675d5bc5d7bba6ab1419d6a547880a61f1a3bf1" + + "613cd2c46a77ba9ed6cf5da67ea4c039ef4a1912c1e1d50c388601deb4748656ca39d704988b60c" + + "2458aee81651273c10ae62e157fdc563017816e462239c86bca04543343f493b4275727afd77288" + + "5ab06b01ff92798f9134a5d7fd3dca5d2f08e1b1f5b29b55ac1e30f66b5846ba63aac1a52ce9b49" + + "ce06f36875988be13a10b726fb2995e388dd721ae01e71ff076a7b252a1710a6b01c41ca416ba68" + + "7ececf515ea5fc92aad3f95b4e6ee704706a84d3cf10645669622a4ec9c6cb90ec97baf41950692" + + "cfbd6f1a8ed3769b43a1a97938f466786b0046b4c824d34fbd4b40ace1b1eff96dd57eecb7e63d4" + + "c7a681f05c23e51223049d75d220db0b62a912869d71f6d708018e6a2f15de3e579a0230b15bd07" + + "2a9db48cb61983e9359876ccbaaf918fbd2a675037735fd1e86c75652ea7fdc41519182432f1f37" + + "638b3b27604b22611a712182d3e4fa4664c86c0c665326487fd8fa780422a952cc8aaae62116eda" + + "985625ed2b4e9047e9d82c5b317e7580350d89a4e133150fa0b0061cb8138456f92cc3c0f312530" + + "2306092a864886f70d01091531160414d19884b1536e383e7ff1181b0694183339cfc3ad3041303" + + "1300d060960864801650304020105000420cba0cb53e5da0c7066745ad7344832e6c1de59aa43ab" + + "4924f00f5f667bf8349e04089594cb0c096e9e5202020800" + + val PEM = PEM_HEX.hexStringToByteArray() + private const val PEM_HEX = "2d2d2d2d2d424547494e2050524956415445204b45592d2d2d2d2d0a4d4" + + "94945764149424144414e42676b71686b6947397730424151454641415343424b59776767536941" + + "674541416f494241514332767a744d46633054525836410a38652f4539574c52705475565169346" + + "37237424d7971396a586871324356397a583754312b586567677655506c6375396e795758566173" + + "594c354f656e6c52790a4d6d457049672f46563162674a624b4a32706e4966622b726f712f6b343" + + "1514e55435433626b7a4545466a576c6f3134687a3056726c4b346c2b76457232354e0a53713254" + + "73444f552b3054487561705a2f6e4c643837695044354c4d4b4d787553617a346433516b6561587" + + "3312b637a4f4a564169683371706e4d2f4f43356d0a672b59565044457978593835727245366a67" + + "4a5945644a556f67566d32317561736e55663874496e45456f564f6b77384a576445395a4f424f4" + + "95964374348630a454752364a7159576b4a494b7a6e79615774537a6e6c76627633453933523633" + + "476f627933455679345452594b4f556c497971462f62436c43304f6c726350440a31613845626c4" + + "65a41674d4241414543676639626c6855494a6b745545475a766e35326b5a50413749556475624e" + + "5956765a4333556842684355694d387238770a59424f642f37563062324356496a44674a474a6c7" + + "7714d57596c744d63454237783850703877364e3865726c424d786346786a494658437446436835" + + "644547530a42426943746173707977654874314b56794753482b534a372b6e52494f416e3247506" + + "3594a756d7a445733724554746f65476f5a42597866762b35534c30616b0a7642782f7639746d56" + + "566634477372626e576c7974682f70572f336e545a6c48387939657a564b6e684d4847323669667" + + "36b394c416a6a4b2b55785a6a4b31570a73414b42396b566c497635567455676f6f55316c6b6270" + + "382f544a4e72672f6932384c4172375731414c39737855744c314b4a594b376d6c62736a6839502" + + "b380a3377534646534d38394b6677716e3930644466367773356a59386b417a4f58656566397778" + + "566b436759454137414c784f642b4650636f772f4938527039724d0a42336f337756506f3676563" + + "4746c6f414b6c643461746f6a5376776776614e30566d73696a436a456557724271636c39362b42" + + "785168545a353277586e5479540a51736d616473756a6473696a3967384865423879464e3667374" + + "a3952594856386a4c6c3974622b4849726434634667412f6d456e436d344a747941576b494a750a" + + "5a77576f4e61416759576e43506f7142737562684766304367594541786a6c773466684e466c525" + + "24a36704d614f784454356775356a686b7539647444726f310a794b756945554942425a69312f4a" + + "72374534447a62305764485a696a55586438654838326c4c3739384d6a626e6565615a71784d532" + + "b492b4743436e42492b4a0a756b416676626f306b7a543678774a2f313332634255445266474c2b" + + "4b4837696a53306b7034587a315a374a624f2f53574f4a59545731362f38647342474d550a7a4a6" + + "5365659304367594541704656554a436a34504e4d4a6b7749486d436f6f514e6966382f52365451" + + "674a4f6d656d6a3955434774797a45475a4959654e720a6754547542457671716d526b587458573" + + "45351526d4b4b754b34362f54704f384d686677667546436539597648496a51655a713349344c30" + + "364733796d7369540a7065554f6e447531573734476c61736858663746305752585458534259625" + + "1414c616e7075785535467273754c714141755649373050304367594275416954650a6b36476365" + + "64444d4e5a76515177616348725977654332586664663961325137364e793576537136413256454" + + "f6e4b4c41573659573062317a4f6365614453410a337744596a694a3652633530464c7641725842" + + "75554c4872532f76453854416257456b46543169734e61505458465338722b34746b437079594c4" + + "8796f58427a0a7278442f6b377964326c437044334e71647274517853695532515465626d70316e" + + "4f4d425a514b42675144593467514c2b756f384c575a5367664156532b6f460a416c74634468666" + + "f327663485758302b615558416c743551573535734871335a6e63333958634e43382b6b504b426a" + + "6b7a7872324b43304d473856672f706b4c0a526e2b2b71472b67664b584e4d7438487a676e62485" + + "06465336c4552763662486a5a39394a784f4f494d365150464a747a78625773416e5936676c6a65" + + "5758750a794d6567584367397145416d704348346b78794c71413d3d0a2d2d2d2d2d454e4420505" + + "24956415445204b45592d2d2d2d2d0a2d2d2d2d2d424547494e2043455254494649434154452d2d" + + "2d2d2d0a4d49494465444343416d4367417749424167495241505a4951445741395865657643474" + + "74450586d673873774451594a4b6f5a496876634e4151454c425141770a5454454c4d416b474131" + + "554542684d4356564d784454414c42674e564241674d4246526c633351784454414c42674e56424" + + "1634d4246526c633351784454414c0a42674e5642416f4d4246526c633351784554415042674e56" + + "42414d4d4346526c633352445a584a304d423458445449314d4467774e7a41314d5441304e6c6f5" + + "80a445449324d4467774e7a41314d5441304e6c6f775454454c4d416b474131554542684d435656" + + "4d784454414c42674e564241674d4246526c633351784454414c0a42674e564241634d4246526c6" + + "33351784454414c42674e5642416f4d4246526c633351784554415042674e5642414d4d4346526c" + + "633352445a584a304d4949420a496a414e42676b71686b6947397730424151454641414f4341513" + + "8414d49494243674b4341514541747238375442584e4530562b6750487678505669306155370a6c" + + "554975484b2b77544d71765931346174676c6663312b3039666c336f494c314435584c765a386c6" + + "c31577247432b546e703555636a4a684b534950785664570a344357796964715a7948322f71364b" + + "76354f4e554456416b3932354d784242593170614e6549633946613553754a6672784b397554557" + + "1746b37417a6c5074450a78376d715766357933664f346a772b537a436a4d626b6d732b4864304a" + + "486d6c374e666e4d7a695651496f6436715a7a507a67755a6f506d465477784d7357500a4f61367" + + "84f6f344357424853564b49465a7474626d724a31482f4c534a78424b4654704d5043566e525057" + + "5467546947486577683342426b6569616d467043530a437335386d6c72557335356232373978506" + + "4306574787147387478466375453057436a6c4a534d716866327770517444706133447739577642" + + "473552575149440a415141426f314d775554416442674e564851344546675155363458334b30487" + + "535344e574a455731586d3678616d4652304a3077487759445652306a424267770a466f41553634" + + "58334b30487535344e574a455731586d3678616d4652304a307744775944565230544151482f424" + + "15577417745422f7a414e42676b71686b69470a397730424151734641414f43415145416559786d" + + "7a4530716e2b7a5578736f3056675173322b656b4b437a4868346937312b53346c4679326956454" + + "e6459792f0a36743866317967644c55364a777755505a38386d75457a6c4a7a42584b4342506636" + + "45666b7241732b3168542b3669556631704c57547337486779506b6a38310a446c714f624571454" + + "e43556a4453776152475578416474425137745272355678777453626d5334474e65444e31766c68" + + "6d38596830364f6c4233727047424b6f0a484a572f556d3233504a644e7a414f5a4259786d796f4" + + "f77777042683035694b736f30712b76536d365844497a7052723853443770704f46496c6e6b6371" + + "726f0a6755385a394b6e734a44464f716f6c682b694b3353706c654c3132696965744d6d32316c3" + + "84b6c6d4830687651326d48454d786358664d757377335349597a470a666c775078303975672f63" + + "4a35596b70432b4568436d666472675357324272594f5a414336673d3d0a2d2d2d2d2d454e44204" + + "3455254494649434154452d2d2d2d2d0a" + + val PEM_ENC = PEM_ENC_HEX.hexStringToByteArray() + private const val PEM_ENC_HEX = "2d2d2d2d2d424547494e20454e43525950544544205052495641544" + + "5204b45592d2d2d2d2d0a4d4949464e54426642676b71686b69473977304242513077556a417842" + + "676b71686b694739773042425177774a4151514b51577933794e7855373679612f494d0a2f567a7" + + "77277494343414177444159494b6f5a496876634e41676b464144416442676c67686b67425a514d" + + "4541536f4545424a36784d37666a4d76744544584d0a6e657a4c6e5049456767545177486f7a2b7" + + "7794644356f444142702f2f393164644f30396f755532362b32444543487050677a4e49636e5764" + + "6467324e425a6c0a52776c4364394a5251475338503149784d546562492f6877486c37554c44674" + + "9356e6f61777a444d554b474149704277777330504737596e56716b6f6973456b0a71306f733038" + + "623355595a764a55766a6f44374f386542736153762b784b5856364e72494b30644d454a4f61523" + + "149642f646a6270433374554e5643555355340a39487a714f6b4e4f773531564f7a7a7235445a71" + + "797046356e426d4f73714f34547979394944774a576835316e527a6b4d5575753279767458566c4" + + "86f6b50370a4334727764706861716768766664527a4b79384f6b566c55514c4830376851775775" + + "4d51354c7071574a742f77377a452b614132415776335638724c397045650a79704a6456542b6f7" + + "9414b746e2b3136342b71393349772f695479454c396f7269587a592f326c735633667a706f6547" + + "2f745557624a6843662f555851716b6e0a566257684a734c31504a69706f4f324239557249306d5" + + "867664e697979727679436d575a73345637524f4b7a2f394e2f69634e38756e714d695831583458" + + "4d4b0a75767166757a486568756c78793748594f4751774743334e7871394b304f6768537946514" + + "e355567505a374475526449305948314a37775575466251312b61460a32766b5343737851756e78" + + "616342524b79585943734f734c524239612f476c764b7841654c5435766e52444e424f575265666" + + "433685a47675a6a6e58634b706d0a71325572635252624f2f395676472b3047616873324b6a4873" + + "643146346a626d4d7634364b4e525a704f6456456a326e5878547871347a584734672f72584e570" + + "a41423141454f786d756a72346f784e7266534d386c5a414834646d54684b71626e656b4e585655" + + "76507361637a3879716239783738624f6b7950486938796e2b0a4a3564314d586a6f4f34474d747" + + "15068424759704f764e684c52695866584d6a324978544d497564652f715167485634692f556872" + + "55627a3846362b325a72440a4f2f6768456d503932553765706542456d6b4e74564a2f563458424" + + "c4a5337766647342f45614d4936392f485a30796a5a3146517761726b484c675832504b540a744b" + + "4159746c61464e4e717175487a6368424b6535364e7a66366f2f37726347764c326737556277764" + + "f67496841552f4c56764f4355424e2f7153464466566f0a55784a694f717271544f2b5338774a6c" + + "31702f312f5157644c50697a536830444341653243644a70774c7344335578376d77617173704f4" + + "331376d774b6b31320a5166724239324935525a447761566f4b32466a7048626872627857613936" + + "787a616a7363686e6c7058594d64494c617557687636794c4162422b4669716b72370a412f316f5" + + "475662b776f6751756957395a4d7339374645596b3375376756774932515831504f72766259344e" + + "6c496757364e62687355537774794438723830740a5a7262795265724730704d2b43437441586e4" + + "c396e3557666a78706c2b756f3370374779624f6d737175393637675a5551333233437456316c32" + + "51376e424a430a614c3452317444713466495832642b666a3572646b73456161645042754854627" + + "26b304b525056427736617a6648653542326d6a4258455562446745623148580a415a6851354671" + + "55574b6e6a6c48434377472f43593872646f77345476524276322b4661397669337a645a3342554" + + "931646954524c74423369664561395a6d550a526e624a7a67696c6534644755514678663577707a" + + "48367042517576322b4f507332716e685671313053504e7334446a516d2b747a5a3474784142783" + + "24a74680a394f5571314b6b33683257345251584237446132383252454e466e30697a4f7471362b" + + "4871664a76307257504631456f4d4a49684a677650754c5977447a42740a2f3642344e59782b417" + + "42f31736f563178493132545049654745323932442b6741357536617a7566433747744233733277" + + "4e2b4f78735679797169596b547a630a4b7767363967374159343339646b596c6857744d5839467" + + "6753031625a5644304c6a6259753564566374342f70536946636758524b6d4571756b5139416d48" + + "450a51726757624443656e55637a7331317a66486244534f596330507679536e5862547a594e624" + + "362745753647a31456e5770654a662f5a31326567636f54622b6c0a357639635643534a48344449" + + "543362694c754f676237427346684254767266375849734151706b3733376444484e44346c567a5" + + "12b556b3d0a2d2d2d2d2d454e4420454e435259505445442050524956415445204b45592d2d2d2d" + + "2d0a2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d49494465444343416" + + "d4367417749424167495241505a495144574139586565764347474450586d673873774451594a4b" + + "6f5a496876634e4151454c425141770a5454454c4d416b474131554542684d4356564d784454414" + + "c42674e564241674d4246526c633351784454414c42674e564241634d4246526c63335178445441" + + "4c0a42674e5642416f4d4246526c633351784554415042674e5642414d4d4346526c633352445a5" + + "84a304d423458445449314d4467774e7a41314d5441304e6c6f580a445449324d4467774e7a4131" + + "4d5441304e6c6f775454454c4d416b474131554542684d4356564d784454414c42674e564241674" + + "d4246526c633351784454414c0a42674e564241634d4246526c633351784454414c42674e564241" + + "6f4d4246526c633351784554415042674e5642414d4d4346526c633352445a584a304d4949420a4" + + "96a414e42676b71686b6947397730424151454641414f43415138414d49494243674b4341514541" + + "747238375442584e4530562b6750487678505669306155370a6c554975484b2b77544d717659313" + + "46174676c6663312b3039666c336f494c314435584c765a386c6c31577247432b546e703555636a" + + "4a684b534950785664570a344357796964715a7948322f71364b76354f4e554456416b3932354d7" + + "84242593170614e6549633946613553754a6672784b3975545571746b37417a6c5074450a78376d" + + "715766357933664f346a772b537a436a4d626b6d732b4864304a486d6c374e666e4d7a695651496" + + "f6436715a7a507a67755a6f506d465477784d7357500a4f6136784f6f344357424853564b49465a" + + "7474626d724a31482f4c534a78424b4654704d5043566e5250575467546947486577683342426b6" + + "569616d467043530a437335386d6c72557335356232373978506430657478714738747846637545" + + "3057436a6c4a534d716866327770517444706133447739577642473552575149440a415141426f3" + + "14d775554416442674e564851344546675155363458334b30487535344e574a455731586d367861" + + "6d4652304a3077487759445652306a424267770a466f4155363458334b30487535344e574a45573" + + "1586d3678616d4652304a307744775944565230544151482f42415577417745422f7a414e42676b" + + "71686b69470a397730424151734641414f43415145416559786d7a4530716e2b7a5578736f30566" + + "75173322b656b4b437a4868346937312b53346c4679326956454e6459792f0a3674386631796764" + + "4c55364a777755505a38386d75457a6c4a7a42584b434250663645666b7241732b3168542b36695" + + "56631704c57547337486779506b6a38310a446c714f624571454e43556a44537761524755784164" + + "74425137745272355678777453626d5334474e65444e31766c686d38596830364f6c42337270474" + + "24b6f0a484a572f556d3233504a644e7a414f5a4259786d796f4f77777042683035694b736f3071" + + "2b76536d365844497a7052723853443770704f46496c6e6b6371726f0a6755385a394b6e734a444" + + "64f716f6c682b694b3353706c654c3132696965744d6d32316c384b6c6d4830687651326d48454d" + + "786358664d757377335349597a470a666c775078303975672f634a35596b70432b4568436d66647" + + "2675357324272594f5a414336673d3d0a2d2d2d2d2d454e442043455254494649434154452d2d2d" + + "2d2d0a" + + val DER_CERT = DER_CERT_HEX.hexStringToByteArray() + private const val DER_CERT_HEX = "3082037830820260a003020102021100f648403580f5779ebc2186" + + "0cf5e683cb300d06092a864886f70d01010b0500304d310b3009060355040613025553310d300b0" + + "6035504080c0454657374310d300b06035504070c0454657374310d300b060355040a0c04546573" + + "743111300f06035504030c085465737443657274301e170d3235303830373035313034365a170d3" + + "236303830373035313034365a304d310b3009060355040613025553310d300b06035504080c0454" + + "657374310d300b06035504070c0454657374310d300b060355040a0c04546573743111300f06035" + + "504030c08546573744365727430820122300d06092a864886f70d01010105000382010f00308201" + + "0a0282010100b6bf3b4c15cd13457e80f1efc4f562d1a53b95422e1cafb04ccaaf635e1ab6095f7" + + "35fb4f5f977a082f50f95cbbd9f259755ab182f939e9e5472326129220fc55756e025b289da99c8" + + "7dbfaba2afe4e3540d5024f76e4cc41058d6968d78873d15ae52b897ebc4af6e4d4aad93b03394f" + + "b44c7b9aa59fe72ddf3b88f0f92cc28cc6e49acf877742479a5ecd7e7333895408a1deaa6733f38" + + "2e6683e6153c3132c58f39aeb13a8e025811d254a20566db5b9ab2751ff2d227104a153a4c3c256" + + "744f5938138861dec21dc10647a26a61690920ace7c9a5ad4b39e5bdbbf713ddd1eb71a86f2dc45" + + "72e1345828e525232a85fdb0a50b43a5adc3c3d5af046e51590203010001a3533051301d0603551" + + "d0e04160414eb85f72b41eee783562445b55e6eb16a6151d09d301f0603551d23041830168014eb" + + "85f72b41eee783562445b55e6eb16a6151d09d300f0603551d130101ff040530030101ff300d060" + + "92a864886f70d01010b05000382010100798c66cc4d2a9fecd4c6ca3456042cdbe7a4282cc78788" + + "bbd7e4b8945cb689510d758cbfeadf1fd7281d2d4e89c3050f67cf26b84ce527305728204f7fa11" + + "f92b02cfb5853fba8947f5a4b593b3b1e0c8f923f350e5a8e6c4a843425230d2c1a44653101db41" + + "43bb51af9571c2d49b992e0635e0cdd6f9619bc621d3a3a5077ae91812a81c95bf526db73c974dc" + + "c0399058c66ca83b0c29061d3988ab28d2afaf4a6e970c8ce946bf120fba693852259e472aae881" + + "4f19f4a9ec24314eaa8961fa22b74a995e2f5da289eb4c9b6d65f0a9661f486f43698710cc5c5df" + + "32eb30dd2218cc67e5c0fc74f6e83f709e589290be1210a67ddae0496d81ad8399002ea" + + val DER_KEY = DER_KEY_HEX.hexStringToByteArray() + private const val DER_KEY_HEX = "308204a20201000282010100b6bf3b4c15cd13457e80f1efc4f562d" + + "1a53b95422e1cafb04ccaaf635e1ab6095f735fb4f5f977a082f50f95cbbd9f259755ab182f939e" + + "9e5472326129220fc55756e025b289da99c87dbfaba2afe4e3540d5024f76e4cc41058d6968d788" + + "73d15ae52b897ebc4af6e4d4aad93b03394fb44c7b9aa59fe72ddf3b88f0f92cc28cc6e49acf877" + + "742479a5ecd7e7333895408a1deaa6733f382e6683e6153c3132c58f39aeb13a8e025811d254a20" + + "566db5b9ab2751ff2d227104a153a4c3c256744f5938138861dec21dc10647a26a61690920ace7c" + + "9a5ad4b39e5bdbbf713ddd1eb71a86f2dc4572e1345828e525232a85fdb0a50b43a5adc3c3d5af0" + + "46e515902030100010281ff5b961508264b5410666f9f9da464f03b21476e6cd615bd90b7521061" + + "09488cf2bf3060139dffb5746f60952230e0246265c2a316625b4c70407bc7c3e9f30e8df1eae50" + + "4cc5c1718c81570ad142879744192041882b5ab29cb0787b75295c86487f9227bfa74483809f618" + + "f71826e9b30d6deb113b68786a19058c5fbfee522f46a4bc1c7fbfdb665557f81acadb9d6972b61" + + "fe95bfde74d9947f32f5ecd52a784c1c6dba89fb24f4b0238caf94c598cad56b00281f6456522fe" + + "55b54828a14d6591ba7cfd324dae0fe2dbc2c0afb5b500bf6cc54b4bd4a2582bb9a56ec8e1f4ffb" + + "cdf048515233cf4a7f0aa7f747437fac2ce6363c900cce5de79ff70c55902818100ec02f139df85" + + "3dca30fc8f11a7dacc077a37c153e8eaf578b65a002a57786ada234afc20bda374566b228c28c47" + + "96ac1a9c97debe0714214d9e76c179d3c9342c99a76cba376c8a3f60f07781f3214dea0ec9f5160" + + "757c8cb97db5bf8722b778705800fe61270a6e09b7201690826e6705a835a0206169c23e8a81b2e" + + "6e119fd02818100c63970e1f84d16545127aa4c68ec434f982ee63864bbd76d0eba35c8aba21142" + + "010598b5fc9afb1380f36f459d1d98a351777c787f3694befdf0c8db9de79a66ac4c4be23e1820a" + + "7048f89ba401fbdba349334fac7027fd77d9c0540d17c62fe287ee28d2d24a785f3d59ec96cefd2" + + "58e2584d6d7affc76c046314cc97ba558d02818100a455542428f83cd309930207982a2840d89ff" + + "3f47a4d08093a67a68fd5021adcb310664861e36b8134ee044beaaa64645ed5d6e1241198a2ae2b" + + "8ebf4e93bc3217f07ee1427bd62f1c88d0799ab72382f4e86df29ac893a5e50e9c3bb55bbe0695a" + + "b215dfec5d164574d748161b4002da9e9bb153916bb2e2ea000b9523bd0fd0281806e0224de93a1" + + "9c79d0cc359bd043069c1eb630782d977dd7fd6b643be8dcb9bd2aba0365443a728b016e985b46f" + + "5cce71e683480df00d88e227a45ce7414bbc0ad706e50b1eb4bfbc4f1301b5849054f58ac35a3d3" + + "5c54bcafee2d902a7260b1f2a17073af10ff93bc9dda50a90f736a76bb50c52894d904de6e6a759" + + "ce3016502818100d8e2040bfaea3c2d665281f0154bea05025b5c0e17e8daf707597d3e6945c096" + + "de505b9e6c1eadd99dcdfd5dc342f3e90f2818e4cf1af6282d0c1bc560fe990b467fbea86fa07ca" + + "5cd32df07ce09db1cf75ede5111bfa6c78d9f7d27138e20ce903c526dcf16d6b009d8ea09637965" + + "eec8c7a05c283da84026a421f8931c8ba8" + } + + object X25519 { + val PEM = PEM_HEX.hexStringToByteArray() + private const val PEM_HEX = "2d2d2d2d2d424547494e2050524956415445204b45592d2d2d2d2d0a4d4" + + "3344341514177425159444b32567542434945494a6952677a734949666d4a4b6b57656b34586e35" + + "414a715867567368636b4a65356c6c4a59704d5a694a340a2d2d2d2d2d454e44205052495641544" + + "5204b45592d2d2d2d2d0a" + + val PEM_ENC = PEM_ENC_HEX.hexStringToByteArray() + private const val PEM_ENC_HEX = "2d2d2d2d2d424547494e20454e43525950544544205052495641544" + + "5204b45592d2d2d2d2d0a4d49476a4d463847435371475349623344514546445442534d44454743" + + "53714753496233445145464444416b4242416c6b35526b673748496a762f79573179360a47576a7" + + "7416749494144414d42676771686b694739773043435155414d423047435743475341466c417751" + + "424b675151305a2f534235545a377157487374386c0a484544712f77524177506a7273786f6c747" + + "14b346955584a6a7343545354397959477573397350454b586e55512b6d6957386d6f6e6a73714b" + + "66764a724a784a0a34526f56334f52756972425858546234727772465072734e364a324c6d773d3" + + "d0a2d2d2d2d2d454e4420454e435259505445442050524956415445204b45592d2d2d2d2d0a" + + val DER = DER_HEX.hexStringToByteArray() + private const val DER_HEX = "302e020100300506032b656e042204209891833b0821f9892a459e9385e" + + "7e4026a5e056c85c9097b9965258a4c662278" + } + + object ED25519 { + val PKCS12 = PKCS12_HEX.hexStringToByteArray() + private const val PKCS12_HEX = "308203f6020103308203ac06092a864886f70d010701a082039d0482" + + "0399308203953082029a06092a864886f70d010706a082028b308202870201003082028006092a8" + + "64886f70d010701305f06092a864886f70d01050d3052303106092a864886f70d01050c30240410" + + "861e6cc05174c447b5f3ac29ce75669e02020800300c06082a864886f70d02090500301d0609608" + + "64801650304012a0410b7a67bc377cfd82e127310f2f3db12e680820210cd641daead0ff576ac3d" + + "e6bce8d8c66a85a79a5d032f29e143bf75ed431f1dd95257f250c9815c40b291c932bf1099c2d25" + + "8c201bc74031e17c348b148361083aa8802d3f2ca894b95421d9c9c427a09dab8836e3619b3b32b" + + "833965de4cce329da85520d46b0fd006dfadab9a0fc30583e14f32871ce39d814d641f0bab8af3d" + + "e9b4c906408b1304058414e4b3e094c82bf089b23e6cda6177d01914c408d5d358e68fd78bf6187" + + "e84c218ae16d5ccffeadf8dbfc9b568ad1c604a7c57706b493055b9c4b3f81d310a2138f5323903" + + "0816ef36fc1d24c4a28cb348ddf4d1eff757fca0e09bf74e01a56321a6c744516589a02eeba7819" + + "6493d3a9df04e1ef380a605ffa924ba7beadec29bcfe6f051dfe734d227c6cbaa9a2c4c241de09b" + + "90c6f4e99592d739538fb27f3d507c407cefb94712381a0abe1259c6c27de47cd954fdd4d129321" + + "50fde3970e917f62d8cf12fe668e7b3b423a4507ea02b20cc80d10d6b4a0a97deafe9f20296858e" + + "e39601fbc5dcaf149d1f60ef2a1cadc30d63aeb51a8f72a7fd82eaad3b2e6beae00b7c5f65660d4" + + "9a9d77ad0d20ba9fb3bba6a53ce1d0ac1756f740370bc76745bf70e150862f84ba2cccbaddd413b" + + "175373843a770d9a8fc01ec0bb6df4187b52e78cc1198e50c5f6fde5b67449cd251d571f79b30f1" + + "616acead46d7073cb4b37f22d87f45d75b89e0b9edb600e09d19794d8d9ff3183500ba6f47c01b5" + + "2c542dd583081f406092a864886f70d010701a081e60481e33081e03081dd060b2a864886f70d01" + + "0c0a0102a081a63081a3305f06092a864886f70d01050d3052303106092a864886f70d01050c302" + + "404108a916e880e3475b0cfad0c747ee3023502020800300c06082a864886f70d02090500301d06" + + "0960864801650304012a0410dc78b40243750cb9e25cec90d59fb48f0440bc47404f9f0f3169947" + + "03dfd9980d6f79a70bea698e1c08c4b03229d9b98fdf9dd0859369154a4537d6cec2c1f36c5077b" + + "e8bc3262d7dfa4f1a238505b792b743125302306092a864886f70d010915311604141c458990fa7" + + "fee1e592d05731049891d2507aa2130413031300d06096086480165030402010500042051cc48e0" + + "5aca3856d0f096629ac02a6cfe397ec7ba8fde7ad6bd56220a6e1ac004088550aae32410466e020" + + "20800" + + val PEM = PEM_HEX.hexStringToByteArray() + private const val PEM_HEX = "2d2d2d2d2d424547494e2050524956415445204b45592d2d2d2d2d0a4d4" + + "3344341514177425159444b325677424349454948664d58546f6c455152756d3639522b35747138" + + "4e5279444a36434f4332442f344b6558476151756e6a540a2d2d2d2d2d454e44205052495641544" + + "5204b45592d2d2d2d2d0a2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d" + + "494942717a4343415632674177494241674951496243646570516d4f70364163576e316a4f2b4b6" + + "444414642674d725a5841775454454c4d416b47413155450a42684d4356564d784454414c42674e" + + "564241674d4246526c633351784454414c42674e564241634d4246526c633351784454414c42674" + + "e5642416f4d4246526c0a633351784554415042674e5642414d4d4346526c633352445a584a304d" + + "423458445449314d4467774e7a41314d5441304e316f58445449324d4467774e7a41310a4d54413" + + "04e316f775454454c4d416b474131554542684d4356564d784454414c42674e564241674d424652" + + "6c633351784454414c42674e564241634d4246526c0a633351784454414c42674e5642416f4d424" + + "6526c633351784554415042674e5642414d4d4346526c633352445a584a304d436f77425159444b" + + "325677417945410a4f4c6473624d557062694a614a49485455434373506132492f4531794f30662" + + "f6e2f7476773071394a5a326a557a42524d4230474131556444675157424251710a2b56314d4435" + + "492f7356376a537666434a554a656b6a7250376a416642674e5648534d4547444157674251712b5" + + "6314d4435492f7356376a537666434a554a650a6b6a7250376a415042674e5648524d4241663845" + + "425441444151482f4d4155474179746c63414e424145334e766464386e4636724f3977504a30646" + + "f4764662b0a754c504f647733684a42397852436a366c6b356473624663476279314764424c555a" + + "3376324871754532314f7a43654b323562534d376451625251752f41673d0a2d2d2d2d2d454e442" + + "043455254494649434154452d2d2d2d2d0a" + + val PEM_ENC = PEM_ENC_HEX.hexStringToByteArray() + private const val PEM_ENC_HEX = "2d2d2d2d2d424547494e20454e43525950544544205052495641544" + + "5204b45592d2d2d2d2d0a4d49476a4d463847435371475349623344514546445442534d44454743" + + "53714753496233445145464444416b4242425232576d675269586c516d4c4e714f474c0a6154384" + + "b416749494144414d42676771686b694739773043435155414d423047435743475341466c417751" + + "424b6751517941666a42446450675136414d7070370a323659674e415241376379474b743955386" + + "d536164386a7556666157625a3339786a54704c563151353737785a7434445067702f6164746668" + + "4e75456a426a610a664f6d4b324675436c785459654934416f4c57596c6c3766366b457475673d3" + + "d0a2d2d2d2d2d454e4420454e435259505445442050524956415445204b45592d2d2d2d2d0a2d2d" + + "2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d494942717a434341563267417" + + "7494241674951496243646570516d4f70364163576e316a4f2b4b6444414642674d725a58417754" + + "54454c4d416b47413155450a42684d4356564d784454414c42674e564241674d4246526c6333517" + + "84454414c42674e564241634d4246526c633351784454414c42674e5642416f4d4246526c0a6333" + + "51784554415042674e5642414d4d4346526c633352445a584a304d423458445449314d4467774e7" + + "a41314d5441304e316f58445449324d4467774e7a41310a4d5441304e316f775454454c4d416b47" + + "4131554542684d4356564d784454414c42674e564241674d4246526c633351784454414c42674e5" + + "64241634d4246526c0a633351784454414c42674e5642416f4d4246526c63335178455441504267" + + "4e5642414d4d4346526c633352445a584a304d436f77425159444b325677417945410a4f4c64736" + + "24d557062694a614a49485455434373506132492f4531794f30662f6e2f7476773071394a5a326a" + + "557a42524d4230474131556444675157424251710a2b56314d4435492f7356376a537666434a554" + + "a656b6a7250376a416642674e5648534d4547444157674251712b56314d4435492f7356376a5376" + + "66434a554a650a6b6a7250376a415042674e5648524d4241663845425441444151482f4d4155474" + + "179746c63414e424145334e766464386e4636724f3977504a30646f4764662b0a754c504f647733" + + "684a42397852436a366c6b356473624663476279314764424c555a3376324871754532314f7a436" + + "54b323562534d376451625251752f41673d0a2d2d2d2d2d454e442043455254494649434154452d" + + "2d2d2d2d0a" + + val DER_CERT = DER_CERT_HEX.hexStringToByteArray() + private const val DER_CERT_HEX = "308201ab3082015da003020102021021b09d7a94263a9e807169f5" + + "8cef8a74300506032b6570304d310b3009060355040613025553310d300b06035504080c0454657" + + "374310d300b06035504070c0454657374310d300b060355040a0c04546573743111300f06035504" + + "030c085465737443657274301e170d3235303830373035313034375a170d3236303830373035313" + + "034375a304d310b3009060355040613025553310d300b06035504080c0454657374310d300b0603" + + "5504070c0454657374310d300b060355040a0c04546573743111300f06035504030c08546573744" + + "3657274302a300506032b657003210038b76c6cc5296e225a2481d35020ac3dad88fc4d723b47ff" + + "9ffb6fc34abd259da3533051301d0603551d0e041604142af95d4c0f923fb15ee34af7c225425e9" + + "23acfee301f0603551d230418301680142af95d4c0f923fb15ee34af7c225425e923acfee300f06" + + "03551d130101ff040530030101ff300506032b65700341004dcdbdd77c9c5eab3bdc0f27476819d" + + "7feb8b3ce770de1241f714428fa964e5db1b15c19bcb519d04b519defd87aae136d4ecc278adb96" + + "d233b7506d142efc08" + + val DER_KEY = DER_KEY_HEX.hexStringToByteArray() + private const val DER_KEY_HEX = "302e020100300506032b65700422042077cc5d3a2511046e9baf51f" + + "b9b6af0d4720c9e82382d83ff829e5c6690ba78d3" + + } + + object ECCP384 { + val PKCS12 = PKCS12_HEX.hexStringToByteArray() + private const val PKCS12_HEX = "308204fe020103308204b406092a864886f70d010701a08204a50482" + + "04a13082049d3082031a06092a864886f70d010706a082030b308203070201003082030006092a8" + + "64886f70d010701305f06092a864886f70d01050d3052303106092a864886f70d01050c30240410" + + "70f6cc65af8629eb8e685594bd62984202020800300c06082a864886f70d02090500301d0609608" + + "64801650304012a041033590b727eb0e2e47da43f13c18c1ff880820290f076742de4eb6b811e0a" + + "80a1defeb7f12db4de3f5bea5ab52b59489b12cd226244af13af2b2adcaa32dcfa2d88ccc4b6a0d" + + "8982975242613df5789b8144700b01bff8cdb98bc29d53d6bc7f001551f932c88fb59dee3b057e5" + + "e9bc0ae21a250514faa34dd9f1b4c6413066ddd43f481a5e8f0b7b9b514573c261978fddb622a90" + + "ed4d560ebcfdd73fab54ef678e8faf4892d341ea58e27b881843e84bc9e058818aae0e441862ada" + + "b276fc7a1a42f42951785635f156171acd022555a281015f45d2c0a271745128401f12e4e124c49" + + "5a621622a8ee6a13396456e9a61195d96e4e8829e1855fa409639a5c0ec813571539b4a1970fc57" + + "f00bb5b5ceb1336f1aab73db34056390440d66a97671bc3ac8a4b46295f6450114d17a8f6b59d1f" + + "ac6c3216704ab9760b9015fbdcb5d63a65a55cf07bfaa50ba71b14881bbdc2b607ecc50bf7c95e9" + + "76e68a0bee8613b3c10ddf1abf653448b734dca650641fb5a9a6e9feecaa6d9061bc1e6aa9faa87" + + "36b4f8ec52ec146f1060a7ccbfcf4cca23d5ed8bafda2dcafbb8592b14c77bba5a28619fedbf2ee" + + "d2b391f84ba0be9bd63c287c204097babfdc599b649edcdb49dda62a13a84d88df6eeb6d0ab0260" + + "b6585cdf5914db8ec3e9a70166c0442d6e18d0a6d32ac3160b17158c8ea3fc7b7dd4c6b32448d07" + + "e9c6bd4bc12531772f8d7f9e3996699c1eddb2192612caa8f8a9b937275f5c3e857bc72f3d21d35" + + "182ca216aaa5772d0bb5193894560177bbce86ee70299009596a09b24e64f98c55aead699171eed" + + "e3aca339c3937c8e90a90b5133ac2e4781f038a239a8bbb571c8b17461ffcb2a5323828bf6006dc" + + "898cfaf54a9e3f7f6b72f189d06cf244c3796ee7073a469c5fea048455c66ad7542a80dba8ba81e" + + "2d5f8d9c9ec41e0e9ca5f41f49eb3082017b06092a864886f70d010701a082016c0482016830820" + + "16430820160060b2a864886f70d010c0a0102a082012830820124305f06092a864886f70d01050d" + + "3052303106092a864886f70d01050c302404105176b836b18b21bd2e30a5699e48f0c0020208003" + + "00c06082a864886f70d02090500301d060960864801650304012a0410cbf2e9303c033280aecfe5" + + "f9fe89dfa50481c044bf20e2d1a8e87d00151d8e1e277e99b1ac9262920a5b6c3b4f02f2cb7329b" + + "61b4755c4c13b9fdbc0d060cc4394e71234643a19ff22ae636d5f6c59ff04dbeb23c8932cc67387" + + "272276c331055126ca129e9d2cbcb7fcbb0f3b70bb183e722b4d261865b1278103ee0b4b09b5892" + + "3109f3a436cbe5059299850bc47cd4869b3c57ef8aae94394761c7caae9e0982db951501ff11938" + + "6a935465ae5a1bd10b7bd434f67b82781494cc88f846cc72f31b35e56f69fa3f694b34a04056665" + + "7bba53125302306092a864886f70d01091531160414436855b1c9581d517c38c9a130670cf216e8" + + "12ae30413031300d06096086480165030402010500042036b2c64f4b8f95ed92087fb6f7d8fab9a" + + "dcf0dffe1a4a443884a8e094b5ca62604088fa6e47c144a951402020800" + + val PEM = PEM_HEX.hexStringToByteArray() + private const val PEM_HEX = "2d2d2d2d2d424547494e2045432050524956415445204b45592d2d2d2d2" + + "d0a4d49476b416745424244415a4378444b707159362b2b3746645149627434586e354d70327156" + + "39556a592f39445978684a79686d3831736163392b4d32534c730a667955786b2b623169764f674" + + "27759464b34454541434b685a414e69414151425a494f4c73636c3478586459442b473030567767" + + "6f4635714a5955474b5663540a6c412f486e524763367a6c54444b315158314754764c746244526" + + "239465a5046716d5a71443373534b713575626445553337494f4b35707a396a3856394566780a63" + + "645a4c3879686d68744353634d505735316c7270727a516755414271644d3d0a2d2d2d2d2d454e4" + + "42045432050524956415445204b45592d2d2d2d2d0a2d2d2d2d2d424547494e2043455254494649" + + "434154452d2d2d2d2d0a4d4949434b44434341612b674177494241674952414f5a72494a6a57615" + + "877627571785a74525a444a324177436759494b6f5a497a6a3045417749775454454c0a4d416b47" + + "4131554542684d4356564d784454414c42674e564241674d4246526c633351784454414c42674e5" + + "64241634d4246526c633351784454414c42674e560a42416f4d4246526c63335178455441504267" + + "4e5642414d4d4346526c633352445a584a304d423458445449314d4467774e7a41314d5441304e3" + + "16f58445449320a4d4467774e7a41314d5441304e316f775454454c4d416b474131554542684d43" + + "56564d784454414c42674e564241674d4246526c633351784454414c42674e560a4241634d42465" + + "26c633351784454414c42674e5642416f4d4246526c633351784554415042674e5642414d4d4346" + + "526c633352445a584a304d485977454159480a4b6f5a497a6a3043415159464b344545414349445" + + "9674145415753446937484a654d563357412f68744e4663494b42656169574642696c5845355150" + + "783530520a6e4f733555777974554639526b377937577730572f5257547861706d6167393745697" + + "175626d3352464e2b7944697561632f592f4666524838584857532f4d6f0a5a6f62516b6e444431" + + "75645a613661383049464141616e546f314d775554416442674e5648513445466751555035794b7" + + "6392f41726a684d52396d446b3248540a5a35734b35703077487759445652306a42426777466f41" + + "555035794b76392f41726a684d52396d446b3248545a35734b35703077447759445652305441514" + + "82f0a42415577417745422f7a414b42676771686b6a4f5051514441674e6e4144426b416a414634" + + "735a547433367358364157307476436877334e49613444752b56670a6433727841774f3237796f5" + + "74d423450586953764c3967766264584a756847526b436f434d41544d314a714e39535a65577537" + + "5a2b31554a577241566e414e670a4e747735624562416c44546a6876315771756c33765835464e7" + + "63463354442742f74635438413d3d0a2d2d2d2d2d454e442043455254494649434154452d2d2d2d" + + "2d0a" + + val PEM_ENC = PEM_ENC_HEX.hexStringToByteArray() + private const val PEM_ENC_HEX = "2d2d2d2d2d424547494e2045432050524956415445204b45592d2d2" + + "d2d2d0a50726f632d547970653a20342c454e435259505445440a44454b2d496e666f3a20414553" + + "2d3235362d4342432c4341453545303036354330384536453642333046363239433834463130453" + + "9340a0a354847727674385844534a38676c49335a3951767a7145324874666d634d6a61376b5058" + + "52716e724a4d4558617a6b5a4f4735784b6c313357414a66596356320a31774a776834466f56565" + + "172364e3135506c5364434b744963322f2f70503950433732584c49376157714234644f51495441" + + "493555452f6961536d4939526e2f0a41644b4f464456373472466558497251692f4e2f7153624b6" + + "14c477575374a596d594a394746552b5741516758703131757345794d7758486259386351715266" + + "0a4c6f377a752b624739665142414b72716449676335564135464f7972303546424478494a4d623" + + "5464455553d0a2d2d2d2d2d454e442045432050524956415445204b45592d2d2d2d2d0a2d2d2d2d" + + "2d424547494e2043455254494649434154452d2d2d2d2d0a4d4949434b44434341612b674177494" + + "241674952414f5a72494a6a57615877627571785a74525a444a324177436759494b6f5a497a6a30" + + "45417749775454454c0a4d416b474131554542684d4356564d784454414c42674e564241674d424" + + "6526c633351784454414c42674e564241634d4246526c633351784454414c42674e560a42416f4d" + + "4246526c633351784554415042674e5642414d4d4346526c633352445a584a304d4234584454493" + + "14d4467774e7a41314d5441304e316f58445449320a4d4467774e7a41314d5441304e316f775454" + + "454c4d416b474131554542684d4356564d784454414c42674e564241674d4246526c63335178445" + + "4414c42674e560a4241634d4246526c633351784454414c42674e5642416f4d4246526c63335178" + + "4554415042674e5642414d4d4346526c633352445a584a304d485977454159480a4b6f5a497a6a3" + + "043415159464b3445454143494459674145415753446937484a654d563357412f68744e4663494b" + + "42656169574642696c5845355150783530520a6e4f733555777974554639526b377937577730572" + + "f5257547861706d6167393745697175626d3352464e2b7944697561632f592f4666524838584857" + + "532f4d6f0a5a6f62516b6e44443175645a613661383049464141616e546f314d775554416442674" + + "e5648513445466751555035794b76392f41726a684d52396d446b3248540a5a35734b3570307748" + + "7759445652306a42426777466f41555035794b76392f41726a684d52396d446b3248545a35734b3" + + "570307744775944565230544151482f0a42415577417745422f7a414b42676771686b6a4f505151" + + "4441674e6e4144426b416a414634735a547433367358364157307476436877334e49613444752b5" + + "6670a6433727841774f3237796f574d423450586953764c3967766264584a756847526b436f434d" + + "41544d314a714e39535a655775375a2b31554a577241566e414e670a4e747735624562416c44546" + + "a6876315771756c33765835464e763463354442742f74635438413d3d0a2d2d2d2d2d454e442043" + + "455254494649434154452d2d2d2d2d0a" + + val DER_CERT = DER_CERT_HEX.hexStringToByteArray() + private const val DER_CERT_HEX = "30820228308201afa003020102021100e66b2098d6697c1bbaac59" + + "b516432760300a06082a8648ce3d040302304d310b3009060355040613025553310d300b0603550" + + "4080c0454657374310d300b06035504070c0454657374310d300b060355040a0c04546573743111" + + "300f06035504030c085465737443657274301e170d3235303830373035313034375a170d3236303" + + "830373035313034375a304d310b3009060355040613025553310d300b06035504080c0454657374" + + "310d300b06035504070c0454657374310d300b060355040a0c04546573743111300f06035504030" + + "c0854657374436572743076301006072a8648ce3d020106052b81040022036200040164838bb1c9" + + "78c577580fe1b4d15c20a05e6a258506295713940fc79d119ceb39530cad505f5193bcbb5b0d16f" + + "d1593c5aa666a0f7b122aae6e6dd114dfb20e2b9a73f63f15f447f171d64bf3286686d09270c3d6" + + "e7596ba6bcd0814001a9d3a3533051301d0603551d0e041604143f9c8abfdfc0ae384c47d983936" + + "1d3679b0ae69d301f0603551d230418301680143f9c8abfdfc0ae384c47d9839361d3679b0ae69d" + + "300f0603551d130101ff040530030101ff300a06082a8648ce3d0403020367003064023005e2c65" + + "3b77eac5fa016d2dbc2870dcd21ae03bbe560777af10303b6ef2a16301e0f5e24af2fd82f6dd5c9" + + "ba1191902a023004ccd49a8df5265e5aeed9fb55095ab0159c036036dc396c46c09434e386fd56a" + + "ae977bd7e4536fe1ce4306dfed713f0" + + val DER_KEY = DER_KEY_HEX.hexStringToByteArray() + private const val DER_KEY_HEX = "3081a40201010430190b10caa6a63afbeec575021bb785e7e4ca76a" + + "95f548d8ffd0d8c61272866f35b1a73df8cd922ec7f253193e6f58af3a00706052b81040022a164" + + "036200040164838bb1c978c577580fe1b4d15c20a05e6a258506295713940fc79d119ceb39530ca" + + "d505f5193bcbb5b0d16fd1593c5aa666a0f7b122aae6e6dd114dfb20e2b9a73f63f15f447f171d6" + + "4bf3286686d09270c3d6e7596ba6bcd0814001a9d3" + + } + +} \ No newline at end of file diff --git a/android/app/src/test/java/com/yubico/authenticator/piv/utils/KeyMaterialTestData.kt b/android/app/src/test/java/com/yubico/authenticator/piv/utils/KeyMaterialTestData.kt deleted file mode 100644 index a89c43acb..000000000 --- a/android/app/src/test/java/com/yubico/authenticator/piv/utils/KeyMaterialTestData.kt +++ /dev/null @@ -1,201 +0,0 @@ -/* - * Copyright (C) 2025 Yubico. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@file:Suppress("SpellCheckingInspection") - -package com.yubico.authenticator.piv.utils - -import com.yubico.authenticator.piv.data.hexStringToByteArray - -object KeyMaterialTestData { - object Rsa2048 { - val PKCS12 = PKCS12_HEX.hexStringToByteArray() - private const val PKCS12_HEX = "30820a1f020103308209d506092a864886f70d010701a08209c60482" + - "09c2308209be3082042a06092a864886f70d010706a082041b308204170201003082041006092a8" + - "64886f70d010701305f06092a864886f70d01050d3052303106092a864886f70d01050c30240410" + - "73f69e2f73ce9cb317c38d10f0a0ef9602020800300c06082a864886f70d02090500301d0609608" + - "64801650304012a041071b70f02b82756927b3b24e76f172835808203a0d8fcb1767999456491e9" + - "779326c480afa903ca884394d3a665e718c5ddc48ec86941809ea3eea9bad66c2f67fbcf10fdeb1" + - "c2adeb78b8322246f513095897c80fbac7a96125e37c69050b0af08f63599b0fdb549f08fa408c6" + - "bf9b9843d640c8adbea7b0bd8678a75d02eaf3835ab232d50591f4a24087233c25db269fb250583" + - "a7dec9044bb202e72d48711c02d8da107e1320e84e7ae802985bbda92801ee8324fa319507210f2" + - "d67472a68c783b382ab9b7f4d0883c4be3bafc86dd58dcce6423fe8277e278ddaa6b47721118dc4" + - "7b701bb3a50df9b277e8266d3953eb94f82b69bb34c22c001a817ae83066649f84f2868339f889f" + - "7c71395a907eb5b3faa679cc5792760c60e6a1fdb59773685b4118db49708ecc9d615bcea97527c" + - "b988921eadc1ea3fa0d371d63e0f132cf79f27382e0383bfecd6ede4b536850ef14bba5366ceb18" + - "9a71fbeaffa1b18f26630ecb00410401e59756e7a90d4eba6b120a28da7c67789495ab964251fdd" + - "1ce2e5f2c9207c467abdc1cca095b73e2a9065512d235176c52a48294f37456f11849ba16f4b99d" + - "8e408b34f076f73017ee718001bebc8203408e945ba5055a36eb7c407678e4ee60997a189443637" + - "08044807b1eee34bb03e4bda0a6887db73327f02e2c2ccc8f413573ee026debe659f45a494d8124" + - "ab6ac7739b4174c2ae5095a478888a6e343f0738ab8889c4d682b5edad5a00dcf300882b973c5d4" + - "08c0421a7b4fbe2d0487f7603a83ff123b5546abed33837bcbca755441549ddfd8698542cd69460" + - "7e147ed03100c938a9de79e43120d4999d2b4ddf7480e64c5d1f979d8242186143ba0ad0cb9cc83" + - "e920da8891d44c2f02c0e3558be51b95ba911a62cf35aac015178beb2e73969227970dae6ab30d5" + - "d2dbfa22b751aff3679c836bfb69b2a9a2c31690f586326137edbb081b63b17dc2c9793d10c5248" + - "d7aa2aaac1c075f3d22c53c178dd010e710d657be22932268ce48b803b629b51c02dd96f0111a2d" + - "a5380e765f625be8ed42d3ef93cda0bda00bce579d8d7035e9d3283ac964c0495e972baa74f362c" + - "69532ada2d9bd579f730dfe7a2b75c2cf3f2e47c55416b9533f5ac4870e1e63d2cd81c24bfddf64" + - "f29b1f8f2224895a8bf2262a17681ec8a57f9034436d2124d3f69b790b0cbf9c822e1a97a87d5ce" + - "70cdaa8b948b40da4560f451d99f4c4e21f13680dd0102539b8f9695fcfaca691bc6d6abcdb1d96" + - "0de81b8a2055eaf9d15e3e355d2705b4eb770370bb9c4d019b37c32e72a46ddba979faa464fe0f0" + - "26137dabd9778aff3223082058c06092a864886f70d010701a082057d0482057930820575308205" + - "71060b2a864886f70d010c0a0102a082053930820535305f06092a864886f70d01050d305230310" + - "6092a864886f70d01050c30240410f16373ea59850909c6ad2eaa94d1ad7e02020800300c06082a" + - "864886f70d02090500301d060960864801650304012a0410b3f9462876bfee8df58eb03cae1eaab" + - "c048204d024bea64756cf9aad643d3b3a41cbdf2096bfd1c5e74caa177fc02e0397956e55c866eb" + - "a13b9a626ce6dc2a889075fb708a322abcb9c824dc4802e1d63d93cc51463770ac7e0d1078029ba" + - "19be6f7f4bcacba22264da8cf1db41d5db7d6bcb80543487e894c03f2b412a009310dcd99ff9e20" + - "18aa8b7441ee5df280492226b3e831b162de09e650009b58053a4a5d36c67fbd5b64c1481310f97" + - "b17717c1bd75c81c927b99e0ca98c6c355c8f7d09565ced64fdb843c52d57c6e0cb5a84413c088d" + - "1968d2696a6a0cb8375c445b700178c3b05fb1e1d11522ccb546714375bbaff717257f77245f434" + - "24cc400227c576de6adb98a4865232ab4e12aac7e200ac1a16383cd24899794e4c2a37f73d6a4bc" + - "7115d6ddffe7569f43ec15f3be0f07a2b69f54d9fd277cf1c055960d6e593276324d4ebcb9ef5f4" + - "a0d92ba8e9eb4d6fb3f7a5979e096804fb6d9cbc420c93d213f66cb69591194e9f1f39b90382ee6" + - "88cd080c8f5a327b8d40ab2541a69cd1cdb009165510be6757c11f14cf5f950e9e8098a3e600027" + - "2f2b942afd7aa12723153971fe3a6325e495bfcb967c357c20fab3467f381d1038d2856d07e9bb5" + - "90cc8b7de885f00edddd99a1fb152f9f953798d5385fd9d67c27d10773b5d2ac6cfbf13f0053092" + - "1f27b54d969dfd76866ec26907512da97a35773db0049dae054c3fe9111638e74bdf0837b476b6c" + - "2249849c854f72c3368d4b7d14d8c30c99e9370a1db5d3a78949a32af50d961b20b695d02aa6129" + - "2a3c52aceb06c7f6b507fb06c397f45ffcf52f8a61b3ce5a4e79afcc5328d51c5300304f39ab9fc" + - "f74a4a94f519c06bdf775678b12a760f86aa22cbb7a4e3f64d9d104354df79dadc7b42956cdfc7a" + - "d27655a28ed980af67bca992f627ea91fbb4bd111e55ff04dd53c713f5cb21fbc4cdacb22e04812" + - "f99ffc8273dbdec9efbdf61048e1dfaba5e9bbff63c2c8ec290fb5eb3aa3a3c2859b15eedcaa865" + - "ed6f4040e64bdb23672fccee9dda445de2bad21f3c1b6d7b704aa376ca6d5813bca1b881f4cbf05" + - "c99f6f7cceff2f76c17ca4a9e4524929cf41d9cf00ca74d3cce95dbe1e59fc95363a66dd8b7823f" + - "99df5e9b496ef92b2071d25a73bdee4763921f28888b0244712e0d361c6268eb5c0c554099d7a13" + - "a3ee03fc5713faca4154ab3f3ca7373a532c4c9418028e7938ec7871c732aa2d82f5a9c9998f1d5" + - "8ae1dd795dd044c9b6fb1e3916e8bbd691f861a3ff4b2d107369769b3ddc95acfcca6e49c27f259" + - "fbc647630b9fcd76b220795b73e7d78dd3384f277e6d7628d406c0ead207c499d51b693d3798330" + - "0eef095cc3c8f8a9224dab140444f0f0d6faff4430292d5df055fe035b12eadd2334fc09ec70d25" + - "1f6f745456eceb79cac132e6c3ede2537ba096a9fb7c564c81ab853de4659faf356f6c87e12bf50" + - "e744b91661e3b53597bb3ea1726ae30371b5a235c4500b4093824eadd4f3fc086a1b2c6629538d7" + - "327139882fa1cc96b0a0f396f28eb5a35948c214a88ce4a0f36affaeff22227e1e8bedb60d4f2b6" + - "83b5937976cc9fa7f4001459bd20f28535d273d2aef3aead49ce63e6b3d9ca7cd8d999d0fb1b4d0" + - "1098600b3181920ceea85992e3611b5b678373d136877fd41770f78f5e2be87ca57f95c721a2627" + - "beb6f66107c1987cfeca35e03f25d75a50daccb08bafeeaf36a5493ca56604302db7ff6779b5c9d" + - "7ff12815f2b361349a66214d3125302306092a864886f70d01091531160414565cc72e1bc0f781d" + - "06b5ba3995fb9c5ffbc938030413031300d0609608648016503040201050004202373b26c9670ca" + - "ecbf3336cba8dbbe443a88df401116515174d0d9949397667504089387718df8bb7e4a02020800" - - val PEM = PEM_STRING.toByteArray(Charsets.UTF_8) - private const val PEM_STRING = "-----BEGIN CERTIFICATE-----\n" + - "MIIDOTCCAiGgAwIBAgIUND9T2D3cevsQ947Le2H3WLiMl/kwDQYJKoZIhvcNAQEL" + - "BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM" + - "GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNTA4MDEwNTUyMjJaFw0yNjA4" + - "MDEwNTUyMjJaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw" + - "HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB" + - "AQUAA4IBDwAwggEKAoIBAQDMZo6f9skXTORKN2RVHKXmbQIBFHXLvAXrGTcNkSSt" + - "FUaRv+qDk850cSEdGh5/+TLNrJPTRVKQxo9u28OKo0tYFGHwcCUi9SdhpBic56Pz" + - "kZW9QIfQ3BlJ2ThRnnYNEfyubo/3WBEWZOlaOJXXjZpwqLNBDAZCcUstlnOhowZQ" + - "dGKdh9AJmzT5ubHtdrkmY5u1fsaklSEi69QXOOatDI5YkJIiGn9lwJ9Mlphaomsp" + - "8YoKVpC/xupbHcH7B06Exk0CSUjpLv/pNV/AbLOWnKNW3Vqqq5coJWTWVbW1c9sL" + - "PjQjbetq6BMhZylOo4/dKpT2IFrxpieZNZ8inh1KDe4nAgMBAAGjITAfMB0GA1Ud" + - "DgQWBBTp7OcBdGgktyt+Oww556RxY6WWHTANBgkqhkiG9w0BAQsFAAOCAQEAs+6Z" + - "v859tzA5eJQ0nRojFkXizk4tjbopYTA4t1p+812oPJMmvTUJ+zZ4LnOdako8a9XR" + - "pY6xeGEnzt2wMhL7iF5ZVIC9eXAz5F2FrkmhIUHjdoqabv4vqav6+tPddlatkWUy" + - "BQtNJh7R80T57/xVQjOfDLqLos8lrnuxQh+yJHZpC8ydCk+TE7gjOVkRPG99ZbNz" + - "P99KdC9t9Qy1HTHwYUKljB5svB+AvMnTX6ww/T8xnepEUU0bU4CjAmAcPdm/9A3C" + - "gA8ySq22RZBe+IuwTs8ppA2vK4StWbvi/yyIJOR0v9QkrzYIsT+1sJQjVSvw4iFJ" + - "4uNDLtfSA6hINdEpGw==" + - "-----END CERTIFICATE-----" + - "-----BEGIN PRIVATE KEY-----" + - "MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDMZo6f9skXTORK" + - "N2RVHKXmbQIBFHXLvAXrGTcNkSStFUaRv+qDk850cSEdGh5/+TLNrJPTRVKQxo9u" + - "28OKo0tYFGHwcCUi9SdhpBic56PzkZW9QIfQ3BlJ2ThRnnYNEfyubo/3WBEWZOla" + - "OJXXjZpwqLNBDAZCcUstlnOhowZQdGKdh9AJmzT5ubHtdrkmY5u1fsaklSEi69QX" + - "OOatDI5YkJIiGn9lwJ9Mlphaomsp8YoKVpC/xupbHcH7B06Exk0CSUjpLv/pNV/A" + - "bLOWnKNW3Vqqq5coJWTWVbW1c9sLPjQjbetq6BMhZylOo4/dKpT2IFrxpieZNZ8i" + - "nh1KDe4nAgMBAAECggEAXOUrYuYNBGrswhIkpk3r1Cqso4MB+kMMyYlfLOpPKd6m" + - "gO0hDwWo6eDUdN5/CBhgj3skf/tch/HGFFMKrsKCJpi03kqJhja23Dhw+zaHm9YJ" + - "oMZoM3MkhxyS7P1Al7YaCciz420B7xSTvW5EI3/2tcbmGOT3H1FQInrjOI3X+83p" + - "FoS93sBnGfDHGMQxUPsHoSw5HfhYGCUFw9yFshOSv55SxKmQEoDFoqzGJYsLe/FI" + - "RdUmM82miiAMr00X6u+zRffvtfjUIhQ5xLofUB6XetFjmRvLudK9OzY4LvzMImpX" + - "Ga2jLE+d9JubX2pVXV34Ry4qM7KFaTFvEbpj0ygd4QKBgQDx+3OAUPWOKOhvndnx" + - "zGGueya11qqinyvy+qqC1ppattfSFxdJT+9UF+zyRDDmhNd4Qa80tNaSL61fmiJ9" + - "/f4SRBt0bH8RztQ4/UwhTYjaA4nsdsvAUsUc6dRygrsOWuaXNZ0+JZ30IbDcG7LV" + - "iN7BFhEpZtEF6bIGOtp/71rWUQKBgQDYPcco9DLx6BokypPddM21DLkvlMXe4qmP" + - "ICEVXpH+7z9RUsBnuGrz5zXTNVT7lOk2yubo//+u9GInGKNcvnI6DuI0u99dVuSw" + - "haymuN0gSRT1fbxejPxQFuZce44zuRitLou6xhPpG1KiibEGLGvjyHz9j7VP7cS6" + - "N8dq3x9G9wKBgESkvwQUc0QLiLw4/B1ijAcx+i41IhyVqKL5xqrs88Zt/dUkJb/v" + - "RAYH73heLb0GzBTaFTiPYBsCGV14XPZ+ubc2yM8DBBzqHju4ZwM/emXWAScqH+yD" + - "zlTAZDrDqQqOcMFOPTfm9eLON9yIovd+JyqA9wdWmk7iF1U7FsaaAJuxAoGBAMKW" + - "/US2U63qnrQi8+LiPEbDV1Yg+9qxf8IDOKJBQwH1i7YD0I7FnsEze/U/VeU7QI6F" + - "Ejv0OsLWugjSnBdWbfYe9KJduggFrK/I6u/xBVQLT+gGKN+w4VC0+sGYkgOrejBF" + - "5YnCu6IWa0tGut2CVehZv1hx3Mg7f7/PeA2NEVlLAoGBAIA56s+GyLlSU9rU1IxZ" + - "xD9UBWlak0qhauMXSsqSD3Pj5RxslsDUP7CY0Y+GtsFHDvu3/54OKomaA2JaoxQG" + - "cKc05nARq3GzNS4F/hOt5izd3laddb8YOeO0+mDJrLFLzjtgN4kuvI7fTSfxHJrw" + - "RZu52GdpFyD2dU1YCR6BsfJ/" + - "-----END PRIVATE KEY-----" - - val DER_CERT = DER_CERT_HEX.hexStringToByteArray() - private const val DER_CERT_HEX = "3082033930820221a0030201020214343f53d83ddc7afb10f78ecb" + - "7b61f758b88c97f9300d06092a864886f70d01010b05003045310b3009060355040613024155311" + - "3301106035504080c0a536f6d652d53746174653121301f060355040a0c18496e7465726e657420" + - "5769646769747320507479204c7464301e170d3235303830313035353232325a170d32363038303" + - "13035353232325a3045310b30090603550406130241553113301106035504080c0a536f6d652d53" + - "746174653121301f060355040a0c18496e7465726e6574205769646769747320507479204c74643" + - "0820122300d06092a864886f70d01010105000382010f003082010a0282010100cc668e9ff6c917" + - "4ce44a3764551ca5e66d02011475cbbc05eb19370d9124ad154691bfea8393ce7471211d1a1e7ff" + - "932cdac93d3455290c68f6edbc38aa34b581461f0702522f52761a4189ce7a3f39195bd4087d0dc" + - "1949d938519e760d11fcae6e8ff758111664e95a3895d78d9a70a8b3410c0642714b2d9673a1a30" + - "65074629d87d0099b34f9b9b1ed76b926639bb57ec6a4952122ebd41738e6ad0c8e589092221a7f" + - "65c09f4c96985aa26b29f18a0a5690bfc6ea5b1dc1fb074e84c64d024948e92effe9355fc06cb39" + - "69ca356dd5aaaab97282564d655b5b573db0b3e34236deb6ae8132167294ea38fdd2a94f6205af1" + - "a62799359f229e1d4a0dee270203010001a321301f301d0603551d0e04160414e9ece701746824b" + - "72b7e3b0c39e7a47163a5961d300d06092a864886f70d01010b05000382010100b3ee99bfce7db7" + - "30397894349d1a231645e2ce4e2d8dba29613038b75a7ef35da83c9326bd3509fb36782e739d6a4" + - "a3c6bd5d1a58eb1786127ceddb03212fb885e595480bd797033e45d85ae49a12141e3768a9a6efe" + - "2fa9abfafad3dd7656ad916532050b4d261ed1f344f9effc5542339f0cba8ba2cf25ae7bb1421fb" + - "22476690bcc9d0a4f9313b8233959113c6f7d65b3733fdf4a742f6df50cb51d31f06142a58c1e6c" + - "bc1f80bcc9d35fac30fd3f319dea44514d1b5380a302601c3dd9bff40dc2800f324aadb645905ef" + - "88bb04ecf29a40daf2b84ad59bbe2ff2c8824e474bfd424af3608b13fb5b09423552bf0e22149e2" + - "e3432ed7d203a84835d1291b" - - val DER_KEY = DER_KEY_HEX.hexStringToByteArray() - private const val DER_KEY_HEX = "308204be020100300d06092a864886f70d0101010500048204a8308" + - "204a40201000282010100cc668e9ff6c9174ce44a3764551ca5e66d02011475cbbc05eb19370d91" + - "24ad154691bfea8393ce7471211d1a1e7ff932cdac93d3455290c68f6edbc38aa34b581461f0702" + - "522f52761a4189ce7a3f39195bd4087d0dc1949d938519e760d11fcae6e8ff758111664e95a3895" + - "d78d9a70a8b3410c0642714b2d9673a1a3065074629d87d0099b34f9b9b1ed76b926639bb57ec6a" + - "4952122ebd41738e6ad0c8e589092221a7f65c09f4c96985aa26b29f18a0a5690bfc6ea5b1dc1fb" + - "074e84c64d024948e92effe9355fc06cb3969ca356dd5aaaab97282564d655b5b573db0b3e34236" + - "deb6ae8132167294ea38fdd2a94f6205af1a62799359f229e1d4a0dee270203010001028201005c" + - "e52b62e60d046aecc21224a64debd42aaca38301fa430cc9895f2cea4f29dea680ed210f05a8e9e" + - "0d474de7f0818608f7b247ffb5c87f1c614530aaec2822698b4de4a898636b6dc3870fb36879bd6" + - "09a0c668337324871c92ecfd4097b61a09c8b3e36d01ef1493bd6e44237ff6b5c6e618e4f71f515" + - "0227ae3388dd7fbcde91684bddec06719f0c718c43150fb07a12c391df858182505c3dc85b21392" + - "bf9e52c4a9901280c5a2acc6258b0b7bf14845d52633cda68a200caf4d17eaefb345f7efb5f8d42" + - "21439c4ba1f501e977ad163991bcbb9d2bd3b36382efccc226a5719ada32c4f9df49b9b5f6a555d" + - "5df8472e2a33b28569316f11ba63d3281de102818100f1fb738050f58e28e86f9dd9f1cc61ae7b2" + - "6b5d6aaa29f2bf2faaa82d69a5ab6d7d21717494fef5417ecf24430e684d77841af34b4d6922fad" + - "5f9a227dfdfe12441b746c7f11ced438fd4c214d88da0389ec76cbc052c51ce9d47282bb0e5ae69" + - "7359d3e259df421b0dc1bb2d588dec116112966d105e9b2063ada7fef5ad65102818100d83dc728" + - "f432f1e81a24ca93dd74cdb50cb92f94c5dee2a98f2021155e91feef3f5152c067b86af3e735d33" + - "554fb94e936cae6e8ffffaef4622718a35cbe723a0ee234bbdf5d56e4b085aca6b8dd204914f57d" + - "bc5e8cfc5016e65c7b8e33b918ad2e8bbac613e91b52a289b1062c6be3c87cfd8fb54fedc4ba37c" + - "76adf1f46f702818044a4bf041473440b88bc38fc1d628c0731fa2e35221c95a8a2f9c6aaecf3c6" + - "6dfdd52425bfef440607ef785e2dbd06cc14da15388f601b02195d785cf67eb9b736c8cf03041ce" + - "a1e3bb867033f7a65d601272a1fec83ce54c0643ac3a90a8e70c14e3d37e6f5e2ce37dc88a2f77e" + - "272a80f707569a4ee217553b16c69a009bb102818100c296fd44b653adea9eb422f3e2e23c46c35" + - "75620fbdab17fc20338a2414301f58bb603d08ec59ec1337bf53f55e53b408e85123bf43ac2d6ba" + - "08d29c17566df61ef4a25dba0805acafc8eaeff105540b4fe80628dfb0e150b4fac1989203ab7a3" + - "045e589c2bba2166b4b46badd8255e859bf5871dcc83b7fbfcf780d8d11594b028181008039eacf" + - "86c8b95253dad4d48c59c43f5405695a934aa16ae3174aca920f73e3e51c6c96c0d43fb098d18f8" + - "6b6c1470efbb7ff9e0e2a899a03625aa3140670a734e67011ab71b3352e05fe13ade62cddde569d" + - "75bf1839e3b4fa60c9acb14bce3b6037892ebc8edf4d27f11c9af0459bb9d867691720f6754d580" + - "91e81b1f27f" - } -} diff --git a/android/app/src/test/java/com/yubico/authenticator/piv/utils/KeyMaterialUtilsTest.kt b/android/app/src/test/java/com/yubico/authenticator/piv/utils/KeyMaterialUtilsTest.kt deleted file mode 100644 index fcb16e15f..000000000 --- a/android/app/src/test/java/com/yubico/authenticator/piv/utils/KeyMaterialUtilsTest.kt +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright (C) 2025 Yubico. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.yubico.authenticator.piv.utils - -import com.yubico.authenticator.piv.utils.KeyMaterialTestData.Rsa2048 -import org.junit.Assert -import org.junit.Test -import org.mockito.ArgumentMatchers.anyInt -import org.mockito.ArgumentMatchers.anyString -import org.mockito.Mockito.mockStatic -import java.security.interfaces.RSAPrivateKey -import java.util.Base64 - -class KeyMaterialUtilsTest { - @Test - fun `parse PKCS12 RSA2048`() { - val (certs, key) = KeyMaterialUtils.parse(Rsa2048.PKCS12, "11234567") - Assert.assertTrue(certs.size == 1) - Assert.assertTrue(key is RSAPrivateKey) - } - - @Test(expected = InvalidPasswordException::class) - fun `parse PKCS12 RSA2048 without password`() { - val (certs, key) = KeyMaterialUtils.parse(Rsa2048.PKCS12) - Assert.assertTrue(certs.size == 1) - Assert.assertTrue(key is RSAPrivateKey) - } - - @Test(expected = InvalidPasswordException::class) - fun `parse PKCS12 RSA2048 with wrong password`() { - val (certs, key) = KeyMaterialUtils.parse(Rsa2048.PKCS12, "invalid") - Assert.assertTrue(certs.size == 1) - Assert.assertTrue(key is RSAPrivateKey) - } - - - @Test - fun `parse PEM RSA2048`() { - mockBase64 { - val (certs, key) = KeyMaterialUtils.parse(Rsa2048.PEM) - Assert.assertTrue(certs.size == 1) - Assert.assertTrue(key is RSAPrivateKey) - } - } - - @Test - fun `parse DER cert RSA2048`() { - val (certs, key) = KeyMaterialUtils.parse(Rsa2048.DER_CERT) - Assert.assertTrue(certs.size == 1) - Assert.assertNull(key) - } - - @Test - fun `parse DER key RSA2048`() { - val (certs, key) = KeyMaterialUtils.parse(Rsa2048.DER_KEY) - Assert.assertTrue(certs.isEmpty()) - Assert.assertTrue(key is RSAPrivateKey) - } - - companion object { - fun mockBase64(block: () -> Unit) { - mockStatic(android.util.Base64::class.java).use { mock -> - - mock.`when` { android.util.Base64.decode(anyString(), anyInt()) } - .thenAnswer { invocation -> - Base64.getDecoder().decode(invocation.getArgument(0)) - } - - block() - } - } - } -} \ No newline at end of file From f2360bf46dc03739381f79b4100a3880af6ce319 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Fri, 8 Aug 2025 11:17:54 +0200 Subject: [PATCH 09/28] bump python version --- .github/workflows/env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/env b/.github/workflows/env index 443db832c..a30654487 100644 --- a/.github/workflows/env +++ b/.github/workflows/env @@ -1,2 +1,2 @@ FLUTTER=3.32.1 -PYVER=3.13.5 +PYVER=3.13.6 From 57298ac69f04ee23f24b2f63697e3a490ea8924e Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Fri, 8 Aug 2025 11:18:44 +0200 Subject: [PATCH 10/28] use JSONObject.toString() --- .../yubico/authenticator/piv/PivManager.kt | 127 ++++++++---------- 1 file changed, 59 insertions(+), 68 deletions(-) diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt index c8629a32d..e194d66b2 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt @@ -23,7 +23,6 @@ import com.yubico.authenticator.MainViewModel import com.yubico.authenticator.NfcOverlayManager import com.yubico.authenticator.OperationContext import com.yubico.authenticator.device.DeviceManager -import com.yubico.authenticator.jsonSerializer import com.yubico.authenticator.piv.data.CertInfo import com.yubico.authenticator.piv.data.PivSlot import com.yubico.authenticator.piv.data.PivState @@ -58,11 +57,8 @@ import com.yubico.yubikit.piv.Slot import com.yubico.yubikit.piv.TouchPolicy import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.MethodChannel -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonObject import org.bouncycastle.asn1.x500.X500Name +import org.json.JSONObject import org.slf4j.LoggerFactory import java.io.IOException import java.security.cert.X509Certificate @@ -310,9 +306,9 @@ class PivManager( val serial = pivViewModel.currentSerial.value.toString() managementKeyStorage[serial] = managementKey doAuth(piv, serial) - jsonSerializer.encodeToString(mapOf("status" to true)) + JSONObject(mapOf("status" to true)).toString() } catch (_: Exception) { - jsonSerializer.encodeToString(mapOf("status" to false)) + JSONObject(mapOf("status" to false)).toString() } } @@ -378,16 +374,20 @@ class PivManager( private fun handlePinPukErrors(block: () -> Unit) : String { try { block() - return jsonSerializer.encodeToString(mapOf("status" to "success")) + return JSONObject(mapOf("status" to "success")).toString() } catch (invalidPin: InvalidPinException) { - return jsonSerializer.encodeToString(mapOf("status" to "invalid-pin", - "attemptsRemaining" to invalidPin.attemptsRemaining)) + return JSONObject( + mapOf( + "status" to "invalid-pin", + "attemptsRemaining" to invalidPin.attemptsRemaining + ) + ).toString() } catch (apduException: ApduException) { if (apduException.sw == SW.CONDITIONS_NOT_SATISFIED) { - return jsonSerializer.encodeToString(mapOf("status" to "pin-complexity")) + return JSONObject(mapOf("status" to "pin-complexity")).toString() } } - return jsonSerializer.encodeToString(mapOf("status" to "other-error")) + return JSONObject(mapOf("status" to "other-error")).toString() } private fun getSlots(piv: YubiKitPivSession): List = @@ -483,16 +483,17 @@ class PivManager( private fun getCertificateInfo(certificate: X509Certificate?) = certificate?.let { - buildJsonObject { - val keyType = KeyType.fromKey(certificate.publicKey) - put("key_type", JsonPrimitive(keyType.value.toInt() and 0xff)) - put("subject", JsonPrimitive(certificate.subjectDN.name)) - put("issuer", JsonPrimitive(certificate.issuerDN.name)) - put("serial", JsonPrimitive(certificate.serialNumber.toString())) - put("not_valid_before", JsonPrimitive(certificate.notBefore.isoFormat())) - put("not_valid_after", JsonPrimitive(certificate.notAfter.isoFormat())) - put("fingerprint", JsonPrimitive(certificate.fingerprint())) - } + JSONObject( + mapOf( + "key_type" to (KeyType.fromKey(certificate.publicKey).value.toInt() and 0xff), + "subject" to certificate.subjectDN.name, + "issuer" to certificate.issuerDN.name, + "serial" to certificate.serialNumber.toString(), + "not_valid_before" to certificate.notBefore.isoFormat(), + "not_valid_after" to certificate.notAfter.isoFormat(), + "fingerprint" to certificate.fingerprint(), + ) + ) } private fun publicKeyMatch(certificate: X509Certificate?, metadata: SlotMetadata?) : Boolean? { @@ -514,43 +515,39 @@ class PivManager( val (certificates, privateKey) = parseFile(data, password) val certificate = chooseCertificate(certificates) - val result = buildJsonObject { - put("status", JsonPrimitive(true)) - put("password", JsonPrimitive(password != null)) - put("key_type", privateKey?.let { - JsonPrimitive( + JSONObject( + mapOf( + "status" to true, + "password" to (password != null), + "key_type" to privateKey?.let { KeyType.fromKeyParams( PrivateKeyValues.fromPrivateKey(it) - ).value.toUByte()) - } ?: JsonNull) - put("cert_info", getCertificateInfo(certificate) ?: JsonNull) + ).value.toUByte() + }, + "cert_info" to getCertificateInfo(certificate) + ) + ).apply { pivViewModel.getMetadata(slot)?.let { if (certificate != null && privateKey == null) { - put("public_key_match", JsonPrimitive(publicKeyMatch(certificate, it))) + put("public_key_match", publicKeyMatch(certificate, it)) } } - } - - jsonSerializer.encodeToString(JsonObject.serializer(), result) + }.toString() } catch (_: InvalidPasswordException) { - val result = buildJsonObject { - put("status", JsonPrimitive(false)) - } - jsonSerializer.encodeToString(JsonObject.serializer(), result) + JSONObject(mapOf("status" to false)).toString() } finally { } private fun getX500Name(data: String) = X500Name(data) - private fun validateRfc4514( data: String ): String = try { getX500Name(data) - jsonSerializer.encodeToString(mapOf("status" to true)) + JSONObject(mapOf("status" to true)).toString() } catch (_: IllegalArgumentException) { - jsonSerializer.encodeToString(mapOf("status" to false)) + JSONObject(mapOf("status" to false)).toString() } private suspend fun generate( @@ -599,12 +596,12 @@ class PivManager( else -> throw IllegalArgumentException("Invalid generate type: $generateType") } - jsonSerializer.encodeToString( + JSONObject( mapOf( "public_key" to publicKeyPem.byteArrayToHexString(), "result" to result ) - ) + ).toString() } catch (e: Exception) { throw e } finally { @@ -676,32 +673,26 @@ class PivManager( // TODO self.certificate = certificate } - val result = buildJsonObject { - - // TODO get public key from the private key - val publicKey2 = metadata?.let { - it.publicKey?.toPublicKey() - } - put("metadata", metadata?.let {buildJsonObject { - put("key_type", JsonPrimitive(it.keyType.toInt())) - put("pin_policy", JsonPrimitive(it.pinPolicy)) - put("touch_policy", JsonPrimitive(it.touchPolicy)) - put("generated", JsonPrimitive(it.generated)) - put( - "public_key", - it.publicKey?.let { JsonPrimitive(it.toPublicKey().toPem()) } - ?: JsonNull) - }} ?: JsonNull) - put("public_key", privateKey?.let { - JsonPrimitive(publicKey2?.toPem())} ?: JsonNull) - put("certificate", - certificate?.let { - JsonPrimitive(it.encoded.byteArrayToHexString()) - } ?: JsonNull + JSONObject( + mapOf( + "metadata" to metadata?.let { + JSONObject( + mapOf( + "key_type" to it.keyType.toInt(), + "pin_policy" to it.pinPolicy, + "touch_policy" to it.touchPolicy, + "generated" to it.generated, + "public_key" to it.publicKey?.toPublicKey()?.toPem() + ) + ) + }, + "public_key" to privateKey?.let { + metadata?.publicKey?.toPublicKey()?.toPem() + }, + "certificate" to + certificate?.encoded?.byteArrayToHexString() ) - } - - jsonSerializer.encodeToString(JsonObject.serializer(), result) + ).toString() } finally { } } From 8cfe2d2817bcf16d437a2bf06ad6d9e8f759a196 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Fri, 8 Aug 2025 17:50:17 +0200 Subject: [PATCH 11/28] bump file_picker --- pubspec.lock | 4 ++-- pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 6d77c8048..46a6e27a8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -309,10 +309,10 @@ packages: dependency: "direct main" description: name: file_picker - sha256: "77f8e81d22d2a07d0dee2c62e1dda71dc1da73bf43bb2d45af09727406167964" + sha256: "8f9f429998f9232d65bc4757af74475ce44fc80f10704ff5dfa8b1d14fc429b9" url: "https://pub.dev" source: hosted - version: "10.1.9" + version: "10.2.3" fixnum: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4dde84e4a..54515ce75 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -63,7 +63,7 @@ dependencies: vector_graphics: ^1.1.15 vector_graphics_compiler: ^1.1.17 path: ^1.9.1 - file_picker: ^10.1.9 + file_picker: ^10.2.3 archive: ^4.0.7 crypto: ^3.0.2 tray_manager: ^0.5.0 From a9f41953e0df3127d4accb7d10fc25fcbdf624ef Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Fri, 8 Aug 2025 17:51:01 +0200 Subject: [PATCH 12/28] handle focus when submitting in PIV dialogs --- lib/piv/views/manage_key_dialog.dart | 2 ++ lib/piv/views/manage_pin_puk_dialog.dart | 3 +++ 2 files changed, 5 insertions(+) diff --git a/lib/piv/views/manage_key_dialog.dart b/lib/piv/views/manage_key_dialog.dart index 441790696..5337e4ebb 100644 --- a/lib/piv/views/manage_key_dialog.dart +++ b/lib/piv/views/manage_key_dialog.dart @@ -96,6 +96,8 @@ class _ManageKeyDialogState extends ConsumerState { } Future _submit() async { + _currentFocus.unfocus(); + _newFocus.unfocus(); final currentValidFormat = _usesStoredKey || Format.hex.isValid(_currentController.text); final newValidFormat = Format.hex.isValid(_keyController.text); diff --git a/lib/piv/views/manage_pin_puk_dialog.dart b/lib/piv/views/manage_pin_puk_dialog.dart index 3e286f23f..430568cf0 100644 --- a/lib/piv/views/manage_pin_puk_dialog.dart +++ b/lib/piv/views/manage_pin_puk_dialog.dart @@ -95,6 +95,9 @@ class _ManagePinPukDialogState extends ConsumerState { } Future _submit() async { + _currentPinFocus.unfocus(); + _newPinFocus.unfocus(); + _confirmPinFocus.unfocus(); final notifier = ref.read(pivStateProvider(widget.path).notifier); final l10n = AppLocalizations.of(context); From 64056db27608b32fdb269cebf735c1eacbfd7463 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Fri, 8 Aug 2025 17:52:07 +0200 Subject: [PATCH 13/28] fix file save for Android --- lib/piv/views/actions.dart | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/piv/views/actions.dart b/lib/piv/views/actions.dart index 20ef62321..c8a12b12c 100644 --- a/lib/piv/views/actions.dart +++ b/lib/piv/views/actions.dart @@ -14,7 +14,9 @@ * limitations under the License. */ +import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; @@ -182,9 +184,13 @@ class PivActions extends ConsumerWidget { fileName: fileName, allowedExtensions: [fileExt], type: FileType.custom, + bytes: + isAndroid + ? Uint8List.fromList(utf8.encode(data!)) + : null, lockParentWindow: true, ); - if (filePath != null) { + if (!isAndroid && filePath != null) { // Windows only: Append extension if missing if (Platform.isWindows && !filePath.toLowerCase().endsWith('.$fileExt')) { From 6c5ee773168ea1500afa2591330b231fd2b04a49 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Fri, 8 Aug 2025 17:54:29 +0200 Subject: [PATCH 14/28] temporarily remove update of device Info --- .../com/yubico/authenticator/piv/PivManager.kt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt index e194d66b2..883f0e2a5 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt @@ -332,7 +332,7 @@ class PivManager( } private suspend fun changePin(pin: CharArray, newPin: CharArray): String = - connectionHelper.useSession(updateDeviceInfo = true) { piv -> + connectionHelper.useSession { piv -> try { handlePinPukErrors { piv.changePin(pin, newPin) } } finally { @@ -342,7 +342,7 @@ class PivManager( } private suspend fun changePuk(puk: CharArray, newPuk: CharArray): String = - connectionHelper.useSession(updateDeviceInfo = true) { piv -> + connectionHelper.useSession { piv -> try { handlePinPukErrors { piv.changePuk(puk, newPuk) } } finally { @@ -356,13 +356,13 @@ class PivManager( keyType: ManagementKeyType, storeKey: Boolean ): String = - connectionHelper.useSession(updateDeviceInfo = true) { piv -> + connectionHelper.useSession { piv -> piv.setManagementKey(keyType, managementKey, false) // review require touch "" } private suspend fun unblockPin(puk: CharArray, newPin: CharArray): String = - connectionHelper.useSession(updateDeviceInfo = true) { piv -> + connectionHelper.useSession { piv -> try { handlePinPukErrors { piv.unblockPin(puk, newPin) } } finally { @@ -415,7 +415,7 @@ class PivManager( } private suspend fun delete(slot: Slot, deleteCert: Boolean, deleteKey: Boolean): String = - connectionHelper.useSession(updateDeviceInfo = true) { piv -> + connectionHelper.useSession { piv -> try { doAuth(piv, pivViewModel.currentSerial.value.toString()) @@ -442,7 +442,7 @@ class PivManager( overwriteKey: Boolean, includeCertificate: Boolean ): String = - connectionHelper.useSession(updateDeviceInfo = true) { piv -> + connectionHelper.useSession { piv -> try { doAuth(piv, pivViewModel.currentSerial.value.toString()) @@ -560,7 +560,7 @@ class PivManager( validFrom: String?, validTo: String? ): String = - connectionHelper.useSession(updateDeviceInfo = true) { piv -> + connectionHelper.useSession { piv -> try { val serial = pivViewModel.currentSerial.value.toString() @@ -633,7 +633,7 @@ class PivManager( pinPolicy: Int, touchPolicy: Int ): String = - connectionHelper.useSession(updateDeviceInfo = true) { piv -> + connectionHelper.useSession { piv -> try { val serial = pivViewModel.currentSerial.value.toString() @@ -700,7 +700,7 @@ class PivManager( private suspend fun getSlot( slot: String ): String = - connectionHelper.useSession(updateDeviceInfo = true) { piv -> + connectionHelper.useSession { piv -> try { "" } finally { From 2745a3d37bdbb248e08ef84da67ef6c0738d91bb Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Fri, 8 Aug 2025 18:00:28 +0200 Subject: [PATCH 15/28] implement generate key outputs --- .../authenticator/piv/CertificateUtils.kt | 231 ++++++++++++++++++ .../authenticator/piv/KeyMaterialParser.kt | 21 +- .../yubico/authenticator/piv/PivManager.kt | 56 +++-- 3 files changed, 290 insertions(+), 18 deletions(-) create mode 100644 android/app/src/main/kotlin/com/yubico/authenticator/piv/CertificateUtils.kt diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/CertificateUtils.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/CertificateUtils.kt new file mode 100644 index 000000000..85ede2d9f --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/CertificateUtils.kt @@ -0,0 +1,231 @@ +/* + * Copyright (C) 2025 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.yubico.authenticator.piv + +import com.yubico.yubikit.piv.KeyType +import com.yubico.yubikit.piv.PivSession +import com.yubico.yubikit.piv.Slot +import org.bouncycastle.asn1.ASN1ObjectIdentifier +import org.bouncycastle.asn1.edec.EdECObjectIdentifiers +import org.bouncycastle.cert.X509CertificateHolder +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder +import org.bouncycastle.operator.ContentSigner +import org.bouncycastle.operator.DefaultSignatureAlgorithmIdentifierFinder +import org.bouncycastle.operator.OperatorCreationException +import org.bouncycastle.pkcs.PKCS10CertificationRequest +import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequestBuilder +import java.io.ByteArrayOutputStream +import java.math.BigInteger +import java.security.GeneralSecurityException +import java.security.KeyFactory +import java.security.PublicKey +import java.security.interfaces.ECPublicKey +import java.security.interfaces.RSAPublicKey +import java.security.spec.X509EncodedKeySpec +import java.util.Date +import javax.security.auth.x500.X500Principal +import java.security.cert.X509Certificate +import org.bouncycastle.asn1.x509.AlgorithmIdentifier +import java.security.SecureRandom +import java.security.Signature + +// TODO review file + +/** + * Hash algorithms supported for RSA/ECDSA. Ignored for Ed25519. + */ +enum class HashAlgorithm { + SHA256, SHA384, SHA512 +} + +/** + * Abstracts the key algorithm class (RSA, EC, Ed25519). + */ +enum class KeyAlgorithm { + RSA, EC, ED25519; + + companion object { + fun fromPublicKey(publicKey: PublicKey): KeyAlgorithm = when (publicKey.algorithm.uppercase()) { + "RSA" -> RSA + "EC" -> EC + // Android/JCA may report Ed25519 as "Ed25519" or "EdDSA" + "ED25519", "EDDSA" -> ED25519 + // X25519 ("XDH", "X25519") is key agreement only and cannot sign + "X25519", "XDH" -> throw UnsupportedOperationException("X25519/XDH cannot be used for signing (CSR/cert).") + else -> { + // Try to detect by encoded OID if the algorithm string is unexpected. + val alg = tryDetectFromEncoded(publicKey) + alg ?: throw UnsupportedOperationException("Unsupported public key algorithm: ${publicKey.algorithm}") + } + } + + private fun tryDetectFromEncoded(publicKey: PublicKey): KeyAlgorithm? = try { + val spec = X509EncodedKeySpec(publicKey.encoded) + val kfRSA = try { KeyFactory.getInstance("RSA") } catch (_: Exception) { null } + val kfEC = try { KeyFactory.getInstance("EC") } catch (_: Exception) { null } + when { + kfRSA != null && runCatching { kfRSA.generatePublic(spec) as RSAPublicKey }.isSuccess -> RSA + kfEC != null && runCatching { kfEC.generatePublic(spec) as ECPublicKey }.isSuccess -> EC + else -> null + } + } catch (_: Exception) { null } + } +} + +/** + * Signature algorithm abstraction; holds the JCA name and (optionally) a fixed AlgorithmIdentifier OID. + */ +sealed class SignatureAlgorithm(val jcaName: String, val fixedAlgId: ASN1ObjectIdentifier? = null) { + class Rsa(val hash: HashAlgorithm) : SignatureAlgorithm( + when (hash) { + HashAlgorithm.SHA256 -> "SHA256withRSA" + HashAlgorithm.SHA384 -> "SHA384withRSA" + HashAlgorithm.SHA512 -> "SHA512withRSA" + } + ) + + class EcDsa(val hash: HashAlgorithm) : SignatureAlgorithm( + when (hash) { + HashAlgorithm.SHA256 -> "SHA256withECDSA" + HashAlgorithm.SHA384 -> "SHA384withECDSA" + HashAlgorithm.SHA512 -> "SHA512withECDSA" + } + ) + + object Ed25519 : SignatureAlgorithm("Ed25519", EdECObjectIdentifiers.id_Ed25519) +} + +/** + * Maps a public key + requested hash into a SignatureAlgorithm. + * - RSA -> SHAxxxwithRSA (PKCS#1 v1.5) + * - EC -> SHAxxxwithECDSA + * - Ed25519 -> Ed25519 (hash ignored) + */ +fun resolveSignatureAlgorithm(publicKey: PublicKey, hash: HashAlgorithm): SignatureAlgorithm { + return when (KeyAlgorithm.fromPublicKey(publicKey)) { + KeyAlgorithm.RSA -> SignatureAlgorithm.Rsa(hash) + KeyAlgorithm.EC -> SignatureAlgorithm.EcDsa(hash) + KeyAlgorithm.ED25519 -> SignatureAlgorithm.Ed25519 + } +} + +class PivContentSigner( + private val session: PivSession, + private val slot: Slot, + private val publicKey: PublicKey, + private val signatureAlgorithm: SignatureAlgorithm +) : ContentSigner { + + private val buffer = ByteArrayOutputStream() + private val keyAlg = KeyType.fromKey(publicKey) + private val algId: AlgorithmIdentifier = run { + // For Ed25519 we must emit the Ed25519 OID; for others, use the default finder. + val maybeOid = signatureAlgorithm.fixedAlgId + if (maybeOid != null) { + AlgorithmIdentifier(maybeOid) + } else { + DefaultSignatureAlgorithmIdentifierFinder().find(signatureAlgorithm.jcaName) + } + } + + override fun getAlgorithmIdentifier(): AlgorithmIdentifier = algId + + override fun getOutputStream() = buffer + + @Throws(OperatorCreationException::class) + override fun getSignature(): ByteArray { + try { + val toBeSigned = buffer.toByteArray() + val signature = Signature.getInstance(signatureAlgorithm.jcaName) + // TODO use JCA + return session.sign(slot, keyAlg, toBeSigned, signature) + } catch (e: GeneralSecurityException) { + throw OperatorCreationException("PIV signing failed", e) + } + } +} + +@Throws(GeneralSecurityException::class) +fun signCsrBuilder( + session: PivSession, + slot: Slot, + publicKey: PublicKey, + builder: JcaPKCS10CertificationRequestBuilder, + hashAlgorithm: HashAlgorithm = HashAlgorithm.SHA256 +): PKCS10CertificationRequest { + val sigAlg = resolveSignatureAlgorithm(publicKey, hashAlgorithm) + val signer = PivContentSigner(session, slot, publicKey, sigAlg) + return builder.build(signer) +} + +@Throws(GeneralSecurityException::class) +fun generateCsr( + session: PivSession, + slot: Slot, + publicKey: PublicKey, + subjectRfc4514: String, + hashAlgorithm: HashAlgorithm = HashAlgorithm.SHA256 +): PKCS10CertificationRequest { + val subject = X500Principal(subjectRfc4514) + val builder = JcaPKCS10CertificationRequestBuilder(subject, publicKey) + return signCsrBuilder(session, slot, publicKey, builder, hashAlgorithm) +} + + +@Throws(GeneralSecurityException::class) +fun generateSelfSignedCertificate( + session: PivSession, + slot: Slot, + publicKey: PublicKey, + subjectRfc4514: String, + notBefore: Date, + notAfter: Date, + hashAlgorithm: HashAlgorithm = HashAlgorithm.SHA256 +): X509Certificate { + val subject = X500Principal(subjectRfc4514) + val serial = randomSerialNumber() + + val certBuilder = JcaX509v3CertificateBuilder( + subject, + serial, + notBefore, + notAfter, + subject, + publicKey + ) + + val sigAlg = resolveSignatureAlgorithm(publicKey, hashAlgorithm) + val signer = PivContentSigner(session, slot, publicKey, sigAlg) + val holder: X509CertificateHolder = certBuilder.build(signer) + + return JcaX509CertificateConverter() + .setProvider("BC") + .getCertificate(holder) +} + +/** + * Utility to generate a positive random 128-bit serial number. + * Mirrors the intent of cryptography.x509.random_serial_number(). + */ +fun randomSerialNumber(): BigInteger { + val bits = 128 + val rnd = SecureRandom() + var serial = BigInteger(bits, rnd) + if (serial.signum() <= 0) serial = serial.negate() + return serial +} diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/KeyMaterialParser.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/KeyMaterialParser.kt index 42c594317..d21b28d09 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/KeyMaterialParser.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/KeyMaterialParser.kt @@ -27,12 +27,15 @@ import org.bouncycastle.openssl.PEMEncryptedKeyPair import org.bouncycastle.openssl.PEMKeyPair import org.bouncycastle.openssl.PEMParser import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter +import org.bouncycastle.openssl.jcajce.JcaPEMWriter import org.bouncycastle.openssl.jcajce.JceOpenSSLPKCS8DecryptorProviderBuilder import org.bouncycastle.openssl.jcajce.JcePEMDecryptorProviderBuilder +import org.bouncycastle.pkcs.PKCS10CertificationRequest import org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfo import java.io.ByteArrayInputStream import java.io.IOException import java.io.StringReader +import java.io.StringWriter import java.security.KeyFactory import java.security.KeyStore import java.security.PrivateKey @@ -56,9 +59,21 @@ object KeyMaterialParser { private class InvalidDerFormat : Exception() fun PublicKey.toPem(): String { - val base64 = Base64.encodeToString(encoded, Base64.NO_WRAP) - val wrapped = base64.chunked(64).joinToString("\n") - return "-----BEGIN PUBLIC KEY-----\n$wrapped\n-----END PUBLIC KEY-----\n" + val sw = StringWriter() + JcaPEMWriter(sw).use { it.writeObject(this) } + return sw.toString() + } + + fun PKCS10CertificationRequest.toPem(): String { + val sw = StringWriter() + JcaPEMWriter(sw).use { it.writeObject(this) } + return sw.toString() + } + + fun X509Certificate.toPem(): String { + val sw = StringWriter() + JcaPEMWriter(sw).use { it.writeObject(this) } + return sw.toString() } fun getLeafCertificates(certs: List): List { diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt index 883f0e2a5..a53231c80 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt @@ -16,6 +16,7 @@ package com.yubico.authenticator.piv +import android.os.Build import androidx.lifecycle.LifecycleOwner import com.yubico.authenticator.AppContextManager import com.yubico.authenticator.MainActivity @@ -62,7 +63,14 @@ import org.json.JSONObject import org.slf4j.LoggerFactory import java.io.IOException import java.security.cert.X509Certificate +import java.text.SimpleDateFormat +import java.time.Instant +import java.time.LocalDate import java.util.Arrays +import java.util.Calendar +import java.util.Date +import java.util.Locale +import java.util.TimeZone import java.util.concurrent.atomic.AtomicBoolean typealias PivAction = (Result) -> Unit @@ -154,7 +162,7 @@ class PivManager( ) "generate" -> generate( - (args["slot"] as String), + Slot.fromStringAlias(args["slot"] as String), (args["keyType"] as Int), (args["pinPolicy"] as Int), (args["touchPolicy"] as Int), @@ -551,7 +559,7 @@ class PivManager( } private suspend fun generate( - slot: String, + slot: Slot, keyType: Int, pinPolicy: Int, touchPolicy: Int, @@ -563,42 +571,60 @@ class PivManager( connectionHelper.useSession { piv -> try { + // Bug in yubikit-android KeyType.fromValue + val keyTypeValue = KeyType.entries.first { it.value.toUByte().toInt() == keyType } + val serial = pivViewModel.currentSerial.value.toString() doAuth(piv, serial) doVerifyPin(piv, serial) val keyValues = piv.generateKeyValues( - Slot.fromStringAlias(slot), - KeyType.fromValue(keyType), + slot, + keyTypeValue, PinPolicy.fromValue(pinPolicy), TouchPolicy.fromValue(touchPolicy) ) val publicKey = keyValues.toPublicKey() - val publicKeyPem = publicKey.encoded + val publicKeyPem = publicKey.toPem() val result = when (generateType) { - "publicKey" -> publicKeyPem.byteArrayToHexString() + "publicKey" -> publicKeyPem "csr" -> { if (subject == null) { throw IllegalArgumentException("Subject missing for csr") } - // TODO implement - //val csrBuilder = JcaPKCS10CertificationRequestBuilder(getX500Name(subject), publicKey) - //val csBuilder = JcaContentSignerBuilder("SHA256withRSA") - // - //val signer = csBuilder.build(keyPair.getPrivate()); - //csrBuilder.build(signer) - "" + generateCsr(piv, slot, publicKey, subject).toPem() } - "certificate" -> "" // TODO implement + "certificate" -> { + if (subject == null) { + throw IllegalArgumentException("Subject missing for csr") + } + + val format = SimpleDateFormat("yyyy-MM-dd", Locale.US) + format.timeZone = TimeZone.getTimeZone("UTC") + val validFromDate = format.parse(validFrom!!)!! + val validToDate = format.parse(validTo!!)!! + val cert = generateSelfSignedCertificate( + piv, + slot, + publicKey, + subject, + validFromDate, + validToDate + ) + val result = cert.toPem() + piv.putCertificate(slot, cert) + piv.putObject(ObjectId.CHUID, generateChuid()) + result + } else -> throw IllegalArgumentException("Invalid generate type: $generateType") } JSONObject( mapOf( - "public_key" to publicKeyPem.byteArrayToHexString(), + "public_key" to publicKeyPem, "result" to result ) ).toString() From 5c9b9af06f74f930bc98a0f435e45a0ad4ec7565 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Mon, 11 Aug 2025 16:05:53 +0200 Subject: [PATCH 16/28] export certificate --- .../yubico/authenticator/piv/PivManager.kt | 29 ++++++++++--------- .../yubico/authenticator/piv/PivViewModel.kt | 13 +++++++++ .../yubico/authenticator/piv/data/PivSlot.kt | 6 +++- lib/android/piv/state.dart | 9 +++--- lib/piv/views/actions.dart | 16 ++++++---- 5 files changed, 48 insertions(+), 25 deletions(-) diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt index a53231c80..b0736f8d2 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt @@ -16,7 +16,6 @@ package com.yubico.authenticator.piv -import android.os.Build import androidx.lifecycle.LifecycleOwner import com.yubico.authenticator.AppContextManager import com.yubico.authenticator.MainActivity @@ -24,7 +23,6 @@ import com.yubico.authenticator.MainViewModel import com.yubico.authenticator.NfcOverlayManager import com.yubico.authenticator.OperationContext import com.yubico.authenticator.device.DeviceManager -import com.yubico.authenticator.piv.data.CertInfo import com.yubico.authenticator.piv.data.PivSlot import com.yubico.authenticator.piv.data.PivState import com.yubico.authenticator.piv.data.SlotMetadata @@ -64,11 +62,7 @@ import org.slf4j.LoggerFactory import java.io.IOException import java.security.cert.X509Certificate import java.text.SimpleDateFormat -import java.time.Instant -import java.time.LocalDate import java.util.Arrays -import java.util.Calendar -import java.util.Date import java.util.Locale import java.util.TimeZone import java.util.concurrent.atomic.AtomicBoolean @@ -181,7 +175,7 @@ class PivManager( ) "getSlot" -> getSlot( - (args["slot"] as String), + Slot.fromStringAlias(args["slot"] as String), ) else -> throw NotImplementedError() @@ -410,10 +404,10 @@ class PivManager( val certificate = runPivOperation { piv.getCertificate(it) } PivSlot( - it.value, - metadata?.let(::SlotMetadata), - certificate?.let(::CertInfo), - null + slotId = it.value, + metadata = metadata?.let(::SlotMetadata), + certificate = certificate, + publicKeyMatch = null ) } @@ -696,8 +690,8 @@ class PivManager( certificate?.let { piv.putCertificate(slot, certificate) piv.putObject(ObjectId.CHUID, generateChuid()) - // TODO self.certificate = certificate } + pivViewModel.updateSlot(slot.stringAlias, metadata, certificate) JSONObject( mapOf( @@ -724,11 +718,18 @@ class PivManager( } private suspend fun getSlot( - slot: String + slot: Slot ): String = connectionHelper.useSession { piv -> try { - "" + JSONObject( + mapOf( + "id" to slot.value, + "name" to slot.stringAlias, + "metadata" to pivViewModel.getMetadata(slot.stringAlias), + "certificate" to pivViewModel.getCertificate(slot.stringAlias)?.toPem(), + ) + ).toString() } finally { } } diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivViewModel.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivViewModel.kt index d36c0fea1..bf8fe2f0f 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivViewModel.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivViewModel.kt @@ -24,6 +24,7 @@ import com.yubico.authenticator.piv.data.PivSlot import com.yubico.authenticator.piv.data.PivState import com.yubico.authenticator.piv.data.SlotMetadata import com.yubico.yubikit.piv.Slot +import java.security.cert.X509Certificate class PivViewModel : ViewModel() { private val _state = MutableLiveData() @@ -58,4 +59,16 @@ class PivViewModel : ViewModel() { slot.slotId == Slot.fromStringAlias(slotAlias).value }?.metadata + fun getCertificate(slotAlias: String): X509Certificate? = + _slots.value?.first { slot -> + slot.slotId == Slot.fromStringAlias(slotAlias).value + }?.certificate + + fun updateSlot(slotAlias: String, metadata: SlotMetadata?, certificate: X509Certificate?) { + val slotId = Slot.fromStringAlias(slotAlias).value + _slots.postValue(_slots.value?.map { slot -> + if (slot.slotId == slotId) slot.copy(metadata = metadata, certificate = certificate) + else slot + }) + } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivSlot.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivSlot.kt index 7b70add1c..74a4ba321 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivSlot.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivSlot.kt @@ -18,14 +18,18 @@ package com.yubico.authenticator.piv.data import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import java.security.cert.X509Certificate @Serializable data class PivSlot( @SerialName("slot") val slotId: Int, val metadata: SlotMetadata?, + @Transient + val certificate: X509Certificate? = null, @SerialName("cert_info") - val certInfo: CertInfo?, + val certInfo: CertInfo? = certificate?.let { CertInfo(it) }, @SerialName("public_key_match") val publicKeyMatch: Boolean? ) diff --git a/lib/android/piv/state.dart b/lib/android/piv/state.dart index 8b5bc2703..f4ba75103 100644 --- a/lib/android/piv/state.dart +++ b/lib/android/piv/state.dart @@ -529,12 +529,13 @@ class _AndroidPivSlotsNotifier extends PivSlotsNotifier { @override Future<(SlotMetadata?, String?)> read(SlotId slot) async { - final result = await piv.invoke('getSlot', {'slot': slot.hexId}); - final data = result['data']; - final metadata = data['metadata']; + final result = jsonDecode( + await piv.invoke('getSlot', {'slot': slot.hexId}), + ); + final metadata = result['metadata']; return ( metadata != null ? SlotMetadata.fromJson(metadata) : null, - data['certificate'] as String?, + result['certificate'] as String?, ); } } diff --git a/lib/piv/views/actions.dart b/lib/piv/views/actions.dart index c8a12b12c..37cd6bfb0 100644 --- a/lib/piv/views/actions.dart +++ b/lib/piv/views/actions.dart @@ -281,6 +281,8 @@ class PivActions extends ConsumerWidget { fileName: '$typeName-${intent.slot.slot.hexId}.$fileExt', allowedExtensions: [fileExt], type: FileType.custom, + bytes: + isAndroid ? Uint8List.fromList(utf8.encode(data)) : null, lockParentWindow: true, ); }); @@ -289,13 +291,15 @@ class PivActions extends ConsumerWidget { return false; } - // Windows only: Append extension if missing - if (Platform.isWindows && - !filePath.toLowerCase().endsWith('.$fileExt')) { - filePath += '.$fileExt'; + if (!isAndroid) { + // Windows only: Append extension if missing + if (Platform.isWindows && + !filePath.toLowerCase().endsWith('.$fileExt')) { + filePath += '.$fileExt'; + } + final file = File(filePath); + await file.writeAsString(data, flush: true); } - final file = File(filePath); - await file.writeAsString(data, flush: true); await withContext((context) async { showMessage(context, message); From daef23a36f93131614998efcf1d16b45d02097b2 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Tue, 12 Aug 2025 16:55:34 +0200 Subject: [PATCH 17/28] fix format --- lib/android/piv/state.dart | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/lib/android/piv/state.dart b/lib/android/piv/state.dart index f4ba75103..6da5b6ca7 100644 --- a/lib/android/piv/state.dart +++ b/lib/android/piv/state.dart @@ -340,15 +340,14 @@ class _AndroidPivSlotsNotifier extends PivSlotsNotifier { state = const AsyncValue.loading(); } else { final json = jsonDecode(event); - List? slots = - json != null - ? List.from( - (json as List) - .where((e) => _shownSlots.contains(e['slot'])) - .map((e) => PivSlot.fromJson(e)) - .toList(growable: false), - ) - : []; + List? slots = json != null + ? List.from( + (json as List) + .where((e) => _shownSlots.contains(e['slot'])) + .map((e) => PivSlot.fromJson(e)) + .toList(growable: false), + ) + : []; state = AsyncValue.data(slots); } From 886091ba960402c63a46f4a33ead09fe3a6da15e Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Wed, 13 Aug 2025 13:05:30 +0200 Subject: [PATCH 18/28] Update ConnectionHelper, support SCP --- .../authenticator/piv/PivConnectionHelper.kt | 28 ++---- .../yubico/authenticator/piv/PivManager.kt | 99 ++++++++++++------- 2 files changed, 73 insertions(+), 54 deletions(-) diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivConnectionHelper.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivConnectionHelper.kt index 59cad0368..bd02a633a 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivConnectionHelper.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivConnectionHelper.kt @@ -17,7 +17,6 @@ package com.yubico.authenticator.piv import com.yubico.authenticator.device.DeviceManager -import com.yubico.authenticator.yubikit.DeviceInfoHelper.Companion.getDeviceInfo import com.yubico.authenticator.yubikit.withConnection import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice import com.yubico.yubikit.core.smartcard.SmartCardConnection @@ -35,7 +34,7 @@ class PivConnectionHelper(private val deviceManager: DeviceManager) { return pendingAction != null } - fun invokePending(piv: YubiKitPivSession): Boolean { + fun invokePending(piv: SmartCardConnection): Boolean { var requestHandled = true pendingAction?.let { action -> pendingAction = null @@ -53,14 +52,12 @@ class PivConnectionHelper(private val deviceManager: DeviceManager) { } } - suspend fun useSession( - updateDeviceInfo: Boolean = false, - block: (YubiKitPivSession) -> T + suspend fun useSmartCardConnection( + block: (SmartCardConnection) -> T ): T { - PivManager.updateDeviceInfo.set(updateDeviceInfo) return deviceManager.withKey( - onUsb = { useSessionUsb(it, updateDeviceInfo, block) }, - onNfc = { useSessionNfc(block) }, + onUsb = { useSmartCardConnectionUsb(it, block) }, + onNfc = { useSmartCardConnectionNfc(block) }, onCancelled = { pendingAction?.invoke(Result.failure(CancellationException())) pendingAction = null @@ -68,20 +65,15 @@ class PivConnectionHelper(private val deviceManager: DeviceManager) { ) } - suspend fun useSessionUsb( + suspend fun useSmartCardConnectionUsb( device: UsbYubiKeyDevice, - updateDeviceInfo: Boolean = false, - block: (YubiKitPivSession) -> T + block: (SmartCardConnection) -> T ): T = device.withConnection { - block(YubiKitPivSession(it)) - }.also { - if (updateDeviceInfo) { - deviceManager.setDeviceInfo(runCatching { getDeviceInfo(device) }.getOrNull()) - } + block(it) } - suspend fun useSessionNfc( - block: (YubiKitPivSession) -> T + suspend fun useSmartCardConnectionNfc( + block: (SmartCardConnection) -> T ): Result { try { val result = suspendCoroutine { outer -> diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt index b0736f8d2..94966399b 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt @@ -34,7 +34,6 @@ import com.yubico.authenticator.piv.KeyMaterialParser.getLeafCertificates import com.yubico.authenticator.piv.KeyMaterialParser.parse import com.yubico.authenticator.piv.KeyMaterialParser.toPem import com.yubico.authenticator.setHandler -import com.yubico.authenticator.yubikit.DeviceInfoHelper.Companion.getDeviceInfo import com.yubico.authenticator.yubikit.withConnection import com.yubico.yubikit.android.transport.nfc.NfcYubiKeyDevice import com.yubico.yubikit.core.YubiKeyConnection @@ -47,6 +46,7 @@ import com.yubico.yubikit.core.smartcard.ApduException import com.yubico.yubikit.core.smartcard.SW import com.yubico.yubikit.core.smartcard.SmartCardConnection import com.yubico.yubikit.core.util.Result +import com.yubico.yubikit.management.Capability import com.yubico.yubikit.piv.KeyType import com.yubico.yubikit.piv.ManagementKeyType import com.yubico.yubikit.piv.ObjectId @@ -65,9 +65,8 @@ import java.text.SimpleDateFormat import java.util.Arrays import java.util.Locale import java.util.TimeZone -import java.util.concurrent.atomic.AtomicBoolean -typealias PivAction = (Result) -> Unit +typealias PivAction = (Result) -> Unit class PivManager( messenger: BinaryMessenger, @@ -79,10 +78,6 @@ class PivManager( mainViewModel: MainViewModel ) : AppContextManager(deviceManager) { - companion object { - val updateDeviceInfo = AtomicBoolean(false) - } - private val managementKeyStorage: MutableMap = mutableMapOf() private val pinStorage: MutableMap = mutableMapOf() @@ -188,6 +183,18 @@ class PivManager( else -> false } + private fun getPivSession(connection: SmartCardConnection): YubiKitPivSession { + // If PIV is FIPS capable, and we have scpKeyParams, we should use them + val fips = (deviceManager.deviceInfo?.fipsCapable ?: 0) and Capability.PIV.bit != 0 + val session = YubiKitPivSession(connection, if (fips) deviceManager.scpKeyParams else null) + +// if (!unlockOnConnect.compareAndSet(false, true)) { +// tryToUnlockOathSession(session) +// } + + return session + } + override fun activate() { super.activate() logger.debug("PivManager activated") @@ -225,10 +232,6 @@ class PivManager( device.withConnection { connection -> requestHandled = processYubiKey(connection, device) } - - if (updateDeviceInfo.getAndSet(false)) { - deviceManager.setDeviceInfo(runCatching { getDeviceInfo(device) }.getOrNull()) - } } catch (e: Exception) { logger.error("Cancelling pending action. Cause: ", e) @@ -247,7 +250,8 @@ class PivManager( private fun processYubiKey(connection: YubiKeyConnection, device: YubiKeyDevice): Boolean { var requestHandled = true - val piv = YubiKitPivSession(connection as SmartCardConnection) + val smartCardConnection = connection as SmartCardConnection + val piv = getPivSession(connection) val previousSerial = pivViewModel.currentSerial val currentSerial = piv.serialNumber @@ -258,10 +262,22 @@ class PivManager( currentSerial ) + // update UI with data from current PIV session + pivViewModel.setState( + PivState( + piv, + authenticated = false, + derivedKey = false, + storedKey = false, + supportsBio = false + ) + ) + val sameDevice = previousSerial.value == currentSerial if (device is NfcYubiKeyDevice && sameDevice) { - requestHandled = connectionHelper.invokePending(piv) + requestHandled = connectionHelper.invokePending(smartCardConnection) + } else { if (!sameDevice) { @@ -269,24 +285,27 @@ class PivManager( logger.debug("This is a different key than previous, invalidating the PIN token") connectionHelper.cancelPending() } - pivViewModel.setState( - PivState( - piv, - authenticated = false, - derivedKey = false, - storedKey = false, - supportsBio = false - ) - ) } + // update UI with data from new PIV session after operation perfomed + pivViewModel.setState( + PivState( + getPivSession(connection), + authenticated = false, + derivedKey = false, + storedKey = false, + supportsBio = false + ) + ) + pivViewModel.updateSlots(getSlots(piv)) return requestHandled } private suspend fun reset(): String = - connectionHelper.useSession { piv -> + connectionHelper.useSmartCardConnection { + val piv = getPivSession(it) piv.reset() "" } @@ -303,10 +322,11 @@ class PivManager( } private suspend fun authenticate(managementKey: ByteArray): String = - connectionHelper.useSession { piv -> + connectionHelper.useSmartCardConnection { try { val serial = pivViewModel.currentSerial.value.toString() managementKeyStorage[serial] = managementKey + val piv = getPivSession(it) doAuth(piv, serial) JSONObject(mapOf("status" to true)).toString() } catch (_: Exception) { @@ -323,8 +343,9 @@ class PivManager( } private suspend fun verifyPin(pin: CharArray): String = - connectionHelper.useSession { piv -> + connectionHelper.useSmartCardConnection { try { + val piv = getPivSession(it) val serial = pivViewModel.currentSerial.value.toString() pinStorage[serial] = pin.clone() handlePinPukErrors { doVerifyPin(piv, serial) } @@ -334,8 +355,9 @@ class PivManager( } private suspend fun changePin(pin: CharArray, newPin: CharArray): String = - connectionHelper.useSession { piv -> + connectionHelper.useSmartCardConnection { try { + val piv = getPivSession(it) handlePinPukErrors { piv.changePin(pin, newPin) } } finally { Arrays.fill(newPin, 0.toChar()) @@ -344,8 +366,9 @@ class PivManager( } private suspend fun changePuk(puk: CharArray, newPuk: CharArray): String = - connectionHelper.useSession { piv -> + connectionHelper.useSmartCardConnection { try { + val piv = getPivSession(it) handlePinPukErrors { piv.changePuk(puk, newPuk) } } finally { Arrays.fill(newPuk, 0.toChar()) @@ -358,14 +381,16 @@ class PivManager( keyType: ManagementKeyType, storeKey: Boolean ): String = - connectionHelper.useSession { piv -> + connectionHelper.useSmartCardConnection { + val piv = getPivSession(it) piv.setManagementKey(keyType, managementKey, false) // review require touch "" } private suspend fun unblockPin(puk: CharArray, newPin: CharArray): String = - connectionHelper.useSession { piv -> + connectionHelper.useSmartCardConnection { try { + val piv = getPivSession(it) handlePinPukErrors { piv.unblockPin(puk, newPin) } } finally { Arrays.fill(newPin, 0.toChar()) @@ -417,8 +442,9 @@ class PivManager( } private suspend fun delete(slot: Slot, deleteCert: Boolean, deleteKey: Boolean): String = - connectionHelper.useSession { piv -> + connectionHelper.useSmartCardConnection { try { + val piv = getPivSession(it) doAuth(piv, pivViewModel.currentSerial.value.toString()) if (!deleteCert && !deleteKey) { @@ -444,9 +470,9 @@ class PivManager( overwriteKey: Boolean, includeCertificate: Boolean ): String = - connectionHelper.useSession { piv -> + connectionHelper.useSmartCardConnection { try { - + val piv = getPivSession(it) doAuth(piv, pivViewModel.currentSerial.value.toString()) val sourceObject = if (includeCertificate) { @@ -562,8 +588,9 @@ class PivManager( validFrom: String?, validTo: String? ): String = - connectionHelper.useSession { piv -> + connectionHelper.useSmartCardConnection { try { + val piv = getPivSession(it) // Bug in yubikit-android KeyType.fromValue val keyTypeValue = KeyType.entries.first { it.value.toUByte().toInt() == keyType } @@ -653,9 +680,9 @@ class PivManager( pinPolicy: Int, touchPolicy: Int ): String = - connectionHelper.useSession { piv -> + connectionHelper.useSmartCardConnection { try { - + val piv = getPivSession(it) val serial = pivViewModel.currentSerial.value.toString() doAuth(piv, serial) @@ -720,7 +747,7 @@ class PivManager( private suspend fun getSlot( slot: Slot ): String = - connectionHelper.useSession { piv -> + connectionHelper.useSmartCardConnection { piv -> try { JSONObject( mapOf( From 25712cae3705e98f23dfe6bc29b303ec4bf89852 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Wed, 13 Aug 2025 15:45:10 +0200 Subject: [PATCH 19/28] implement generate CHUID --- android/app/build.gradle | 1 + .../yubico/authenticator/piv/PivManager.kt | 24 +++++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 8a8bc78ac..1d5364743 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -95,6 +95,7 @@ flutter { dependencies { api "com.yubico.yubikit:android:$project.yubiKitVersion" + api "com.yubico.yubikit:core:$project.yubiKitVersion" api "com.yubico.yubikit:management:$project.yubiKitVersion" api "com.yubico.yubikit:oath:$project.yubiKitVersion" api "com.yubico.yubikit:fido:$project.yubiKitVersion" diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt index 94966399b..d6a9b4924 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt @@ -46,6 +46,8 @@ import com.yubico.yubikit.core.smartcard.ApduException import com.yubico.yubikit.core.smartcard.SW import com.yubico.yubikit.core.smartcard.SmartCardConnection import com.yubico.yubikit.core.util.Result +import com.yubico.yubikit.core.util.Tlv +import com.yubico.yubikit.core.util.Tlvs import com.yubico.yubikit.management.Capability import com.yubico.yubikit.piv.KeyType import com.yubico.yubikit.piv.ManagementKeyType @@ -59,7 +61,10 @@ import io.flutter.plugin.common.MethodChannel import org.bouncycastle.asn1.x500.X500Name import org.json.JSONObject import org.slf4j.LoggerFactory +import java.io.ByteArrayOutputStream import java.io.IOException +import java.nio.charset.StandardCharsets +import java.security.SecureRandom import java.security.cert.X509Certificate import java.text.SimpleDateFormat import java.util.Arrays @@ -496,8 +501,23 @@ class PivManager( } private fun generateChuid(): ByteArray { - // TODO - return ByteArray(10) + // Non-Federal Issuer FASC-N + // [9999-9999-999999-0-1-0000000000300001] + val fascN = "D4E739DA739CED39CE739D836858210842108421C84210C3EB".hexStringToByteArray() + + // Expires on: 2030-01-01 -> "20300101" ASCII + val expiry = "20300101".toByteArray(StandardCharsets.US_ASCII) + + // Random 16-byte GUID + val guid = ByteArray(16).also { SecureRandom().nextBytes(it) } + + return Tlvs.encodeList(listOf( + Tlv(0x30, fascN), + Tlv(0x34, guid), + Tlv(0x35, expiry), + Tlv(0x3E, ByteArray(0)), + Tlv(0xFE, ByteArray(0)) + )) } private fun chooseCertificate(certificates: List?): X509Certificate? { From 741ca2784a3ef891cb9c3e00f4673085c791d3d6 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Wed, 13 Aug 2025 15:45:42 +0200 Subject: [PATCH 20/28] fix ISO date formatting --- .../main/kotlin/com/yubico/authenticator/piv/data/DataExt.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/DataExt.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/DataExt.kt index d6ef70f27..f23fea96f 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/DataExt.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/DataExt.kt @@ -33,10 +33,9 @@ fun ByteArray.byteArrayToHexString(): String = toHexString() fun String.hexStringToByteArray(): ByteArray = hexToByteArray() fun Date.isoFormat(): String = if (Build.VERSION.SDK_INT >= 26) { - toInstant().atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) + toInstant().atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ISO_LOCAL_DATE) } else { - @Suppress("SpellCheckingInspection") - val isoFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'" + val isoFormat = "yyyy-MM-dd" val sdf = SimpleDateFormat(isoFormat, Locale.US) sdf.timeZone = TimeZone.getTimeZone("UTC") sdf.format(this) From 09d58f1a0c1a2f6150d4aaf5d697ffdd462194d8 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Wed, 20 Aug 2025 08:55:55 +0200 Subject: [PATCH 21/28] Support for pivman data/pivman protected data --- .../authenticator/piv/PivConnectionHelper.kt | 8 +- .../yubico/authenticator/piv/PivManager.kt | 247 +++++++++++---- .../yubico/authenticator/piv/PivViewModel.kt | 4 + .../yubico/authenticator/piv/data/PivState.kt | 19 +- .../authenticator/piv/data/PivmanUtils.kt | 299 ++++++++++++++++++ lib/android/piv/state.dart | 3 + lib/piv/views/actions.dart | 5 + 7 files changed, 518 insertions(+), 67 deletions(-) create mode 100644 android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivmanUtils.kt diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivConnectionHelper.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivConnectionHelper.kt index bd02a633a..6205620e0 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivConnectionHelper.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivConnectionHelper.kt @@ -53,10 +53,11 @@ class PivConnectionHelper(private val deviceManager: DeviceManager) { } suspend fun useSmartCardConnection( + onComplete: ((SmartCardConnection) -> Unit)? = null, block: (SmartCardConnection) -> T ): T { return deviceManager.withKey( - onUsb = { useSmartCardConnectionUsb(it, block) }, + onUsb = { useSmartCardConnectionUsb(it, onComplete, block) }, onNfc = { useSmartCardConnectionNfc(block) }, onCancelled = { pendingAction?.invoke(Result.failure(CancellationException())) @@ -67,9 +68,10 @@ class PivConnectionHelper(private val deviceManager: DeviceManager) { suspend fun useSmartCardConnectionUsb( device: UsbYubiKeyDevice, + onComplete: ((SmartCardConnection) -> Unit)? = null, block: (SmartCardConnection) -> T - ): T = device.withConnection { - block(it) + ): T = device.withConnection { connection -> + block(connection).also { onComplete?.invoke(connection) } } suspend fun useSmartCardConnectionNfc( diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt index d6a9b4924..156ed371d 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt @@ -33,6 +33,11 @@ import com.yubico.authenticator.piv.data.isoFormat import com.yubico.authenticator.piv.KeyMaterialParser.getLeafCertificates import com.yubico.authenticator.piv.KeyMaterialParser.parse import com.yubico.authenticator.piv.KeyMaterialParser.toPem +import com.yubico.authenticator.piv.data.ManagementKeyMetadata +import com.yubico.authenticator.piv.data.PinMetadata +import com.yubico.authenticator.piv.data.PivStateMetadata +import com.yubico.authenticator.piv.data.PivmanData +import com.yubico.authenticator.piv.data.PivmanUtils import com.yubico.authenticator.setHandler import com.yubico.authenticator.yubikit.withConnection import com.yubico.yubikit.android.transport.nfc.NfcYubiKeyDevice @@ -61,7 +66,6 @@ import io.flutter.plugin.common.MethodChannel import org.bouncycastle.asn1.x500.X500Name import org.json.JSONObject import org.slf4j.LoggerFactory -import java.io.ByteArrayOutputStream import java.io.IOException import java.nio.charset.StandardCharsets import java.security.SecureRandom @@ -123,7 +127,7 @@ class PivManager( "setManagementKey" -> setManagementKey( (args["key"] as String).hexStringToByteArray(), - args["keyType"] as ManagementKeyType, + ManagementKeyType.fromValue((args["keyType"] as Integer).toByte()), args["storeKey"] as Boolean ) @@ -268,15 +272,7 @@ class PivManager( ) // update UI with data from current PIV session - pivViewModel.setState( - PivState( - piv, - authenticated = false, - derivedKey = false, - storedKey = false, - supportsBio = false - ) - ) + updatePivState(connection) val sameDevice = previousSerial.value == currentSerial @@ -292,21 +288,63 @@ class PivManager( } } - // update UI with data from new PIV session after operation perfomed + // update UI with data from new PIV session after operation performed + update(connection) + return requestHandled + } + + private var pivmanData: PivmanData?= null + + /** + * rereads the PIV state and slots + */ + private fun update(connection: SmartCardConnection) { + updatePivState(connection) + updatePivSlots(connection) + } + + private fun updatePivSlots(connection: SmartCardConnection) { + val piv = getPivSession(connection) + pivViewModel.updateSlots(getSlots(piv)) + } + + private fun updatePivState(connection: SmartCardConnection) { + val piv = getPivSession(connection) + pivmanData = PivmanUtils.getPivmanData(piv) + + val supportsBio = try { + piv.bioMetadata != null + } catch (e: Exception) { + when (e) { + is IOException, is ApduException, is UnsupportedOperationException -> false + else -> throw e + } + } + pivViewModel.setState( PivState( - getPivSession(connection), + piv, authenticated = false, - derivedKey = false, - storedKey = false, - supportsBio = false + derivedKey = pivmanData?.hasDerivedKey ?: false, + storedKey = pivmanData?.hasStoredKey ?: false, + pinAttempts = piv.pinAttempts, + supportsBio = supportsBio, + chuid = getObject(piv, ObjectId.CHUID)?.byteArrayToHexString(), + ccc = getObject(piv, ObjectId.CAPABILITY)?.byteArrayToHexString(), + metadata = PivStateMetadata( + ManagementKeyMetadata(piv.managementKeyMetadata), + PinMetadata(piv.pinMetadata), + PinMetadata(piv.pukMetadata) + ) ) ) + } - pivViewModel.updateSlots(getSlots(piv)) + private fun getObject(piv: YubiKitPivSession, id: Int): ByteArray? = + runCatching { piv.getObject(id) }.getOrElse { e -> + if (e is ApduException && e.sw == SW.FILE_NOT_FOUND) null else throw e + } - return requestHandled - } private suspend fun reset(): String = connectionHelper.useSmartCardConnection { @@ -315,11 +353,60 @@ class PivManager( "" } + companion object { + val defaultPin = "123456".toCharArray() + val defaultManagementKey = + "010203040506070801020304050607080102030405060708".hexStringToByteArray() + } + + private fun doAuthenticate(piv: PivSession, serial: String) = + try { + var authenticated = false + + val hasProtectedKey = pivmanData?.hasProtectedKey ?: false + + if (hasProtectedKey) { + // cannot use key from managementKeyStorage + // has to use PIN to get the key from the session + + val pin = pinStorage[serial] ?: defaultPin + piv.verifyPin(pin) + + val key = if (pivmanData?.hasDerivedKey ?: false) { + PivmanUtils.deriveManagementKey(pin, pivmanData?.salt!!) + } else if (pivmanData?.hasStoredKey ?: false) { + val pivmanProtectedData = PivmanUtils.getPivmanProtectedData(piv) + pivmanProtectedData.key + } else { + null + } + + key?.let { key -> + try { + piv.authenticate(key) + authenticated = true + } catch (e: Exception) { + if (e is ApduException && e.sw == SW.SECURITY_CONDITION_NOT_SATISFIED) { + // pass + } else { + throw e + } + } + piv.verifyPin(pin) + } + } else { + // the key is not protected + authenticateKeyBySerial(piv, serial) + } + authenticated + } catch (e: Exception) { + pinStorage.remove(serial) + throw e + } - private fun doAuth(piv: PivSession, serial: String) = + private fun authenticateKeyBySerial(piv: PivSession, serial: String) = try { - val managementKey = managementKeyStorage[serial] - ?: "010203040506070801020304050607080102030405060708".hexStringToByteArray() + val managementKey = managementKeyStorage[serial] ?: defaultManagementKey piv.authenticate(managementKey) } catch (e: Exception) { managementKeyStorage.remove(serial) @@ -327,31 +414,59 @@ class PivManager( } private suspend fun authenticate(managementKey: ByteArray): String = - connectionHelper.useSmartCardConnection { + connectionHelper.useSmartCardConnection(::updatePivState) { + val serial = pivViewModel.currentSerial() try { - val serial = pivViewModel.currentSerial.value.toString() managementKeyStorage[serial] = managementKey val piv = getPivSession(it) - doAuth(piv, serial) + authenticateKeyBySerial(piv, serial) JSONObject(mapOf("status" to true)).toString() } catch (_: Exception) { JSONObject(mapOf("status" to false)).toString() } } - private fun doVerifyPin(piv: PivSession, serial: String) = + private fun doVerifyPin(piv: PivSession, serial: String) : String = try { - pinStorage[serial]?.let { piv.verifyPin(it) } + var authenticated = false + pinStorage[serial]?.let {pin -> + piv.verifyPin(pin) + + val key = if (pivmanData?.hasDerivedKey ?: false) { + PivmanUtils.deriveManagementKey(pin, pivmanData?.salt!!) + } else if (pivmanData?.hasStoredKey ?:false ) { + val pivmanProtectedData = PivmanUtils.getPivmanProtectedData(piv) + pivmanProtectedData.key + } else { + null + } + + + key?.let { key -> + try { + piv.authenticate(key) + authenticated = true + } catch (e: Exception) { + if (e is ApduException && e.sw == SW.SECURITY_CONDITION_NOT_SATISFIED) { + // pass + } else { + throw e + } + } + piv.verifyPin(pin) + } + } + JSONObject(mapOf("status" to true, "authenticated" to authenticated)).toString() } catch (e: Exception) { pinStorage.remove(serial) throw e } private suspend fun verifyPin(pin: CharArray): String = - connectionHelper.useSmartCardConnection { + connectionHelper.useSmartCardConnection(::updatePivState) { try { val piv = getPivSession(it) - val serial = pivViewModel.currentSerial.value.toString() + val serial = pivViewModel.currentSerial() pinStorage[serial] = pin.clone() handlePinPukErrors { doVerifyPin(piv, serial) } } finally { @@ -360,10 +475,10 @@ class PivManager( } private suspend fun changePin(pin: CharArray, newPin: CharArray): String = - connectionHelper.useSmartCardConnection { + connectionHelper.useSmartCardConnection(::updatePivState) { try { val piv = getPivSession(it) - handlePinPukErrors { piv.changePin(pin, newPin) } + handlePinPukErrors { PivmanUtils.pivmanChangePin(piv, pin, newPin) } } finally { Arrays.fill(newPin, 0.toChar()) Arrays.fill(pin, 0.toChar()) @@ -371,7 +486,7 @@ class PivManager( } private suspend fun changePuk(puk: CharArray, newPuk: CharArray): String = - connectionHelper.useSmartCardConnection { + connectionHelper.useSmartCardConnection(::updatePivState) { try { val piv = getPivSession(it) handlePinPukErrors { piv.changePuk(puk, newPuk) } @@ -386,14 +501,22 @@ class PivManager( keyType: ManagementKeyType, storeKey: Boolean ): String = - connectionHelper.useSmartCardConnection { + connectionHelper.useSmartCardConnection(::updatePivState) { val piv = getPivSession(it) - piv.setManagementKey(keyType, managementKey, false) // review require touch + doVerifyPin(piv, pivViewModel.currentSerial()) + doAuthenticate(piv, pivViewModel.currentSerial()) + PivmanUtils.pivmanSetMgmKey( + piv, + newKey = managementKey, + algorithm = keyType, + touch = false, + storeOnDevice = storeKey + ) "" } private suspend fun unblockPin(puk: CharArray, newPin: CharArray): String = - connectionHelper.useSmartCardConnection { + connectionHelper.useSmartCardConnection(::updatePivState) { try { val piv = getPivSession(it) handlePinPukErrors { piv.unblockPin(puk, newPin) } @@ -447,10 +570,12 @@ class PivManager( } private suspend fun delete(slot: Slot, deleteCert: Boolean, deleteKey: Boolean): String = - connectionHelper.useSmartCardConnection { + connectionHelper.useSmartCardConnection(::update) { try { val piv = getPivSession(it) - doAuth(piv, pivViewModel.currentSerial.value.toString()) + val serial = pivViewModel.currentSerial() + doVerifyPin(piv, serial) + doAuthenticate(piv, serial) if (!deleteCert && !deleteKey) { throw IllegalArgumentException("Missing delete option") @@ -475,10 +600,13 @@ class PivManager( overwriteKey: Boolean, includeCertificate: Boolean ): String = - connectionHelper.useSmartCardConnection { + connectionHelper.useSmartCardConnection(::update) { try { val piv = getPivSession(it) - doAuth(piv, pivViewModel.currentSerial.value.toString()) + val serial = pivViewModel.currentSerial() + + doVerifyPin(piv, serial) + doAuthenticate(piv, serial) val sourceObject = if (includeCertificate) { piv.getObject(src.objectId) @@ -608,16 +736,17 @@ class PivManager( validFrom: String?, validTo: String? ): String = - connectionHelper.useSmartCardConnection { + connectionHelper.useSmartCardConnection(::update) { try { val piv = getPivSession(it) // Bug in yubikit-android KeyType.fromValue - val keyTypeValue = KeyType.entries.first { it.value.toUByte().toInt() == keyType } + val keyTypeValue = + KeyType.entries.first { entry -> entry.value.toUByte().toInt() == keyType } - val serial = pivViewModel.currentSerial.value.toString() - doAuth(piv, serial) + val serial = pivViewModel.currentSerial() doVerifyPin(piv, serial) + doAuthenticate(piv, serial) val keyValues = piv.generateKeyValues( slot, @@ -700,11 +829,13 @@ class PivManager( pinPolicy: Int, touchPolicy: Int ): String = - connectionHelper.useSmartCardConnection { + connectionHelper.useSmartCardConnection(::update) { try { val piv = getPivSession(it) - val serial = pivViewModel.currentSerial.value.toString() - doAuth(piv, serial) + val serial = pivViewModel.currentSerial() + + doVerifyPin(piv, serial) + doAuthenticate(piv, serial) val (certificates, privateKey) = parseFile(data, password) // TODO catch invalid password exception @@ -713,7 +844,7 @@ class PivManager( throw IllegalArgumentException("Failed to parse") } - var metadata : SlotMetadata? = null + var metadata: SlotMetadata? = null privateKey?.let { piv.putKey( slot, @@ -723,7 +854,7 @@ class PivManager( ) metadata = try { - SlotMetadata(piv.getSlotMetadata(slot)) + SlotMetadata(piv.getSlotMetadata(slot)) } catch (e: Exception) { when (e) { // TODO NotSupported @@ -742,14 +873,14 @@ class PivManager( JSONObject( mapOf( - "metadata" to metadata?.let { + "metadata" to metadata?.let { slotMetadata -> JSONObject( mapOf( - "key_type" to it.keyType.toInt(), - "pin_policy" to it.pinPolicy, - "touch_policy" to it.touchPolicy, - "generated" to it.generated, - "public_key" to it.publicKey?.toPublicKey()?.toPem() + "key_type" to slotMetadata.keyType.toInt(), + "pin_policy" to slotMetadata.pinPolicy, + "touch_policy" to slotMetadata.touchPolicy, + "generated" to slotMetadata.generated, + "public_key" to slotMetadata.publicKey?.toPublicKey()?.toPem() ) ) }, @@ -760,6 +891,9 @@ class PivManager( certificate?.encoded?.byteArrayToHexString() ) ).toString() + } catch (e: Exception) { + logger.error("Caught ", e) + throw e } finally { } } @@ -782,6 +916,11 @@ class PivManager( } override fun onDisconnected() { + pinStorage.clear() + managementKeyStorage.clear() + pivViewModel.setSerial(null) + pivViewModel.updateSlots(emptyList()) + pivmanData = null } override fun onTimeout() { diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivViewModel.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivViewModel.kt index bf8fe2f0f..b657d4358 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivViewModel.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivViewModel.kt @@ -71,4 +71,8 @@ class PivViewModel : ViewModel() { else slot }) } + + fun currentSerial(): String { + return currentSerial.value?.toString() ?: "NO DEVICE" + } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivState.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivState.kt index 893f33bbb..cd16b3ffb 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivState.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivState.kt @@ -45,7 +45,11 @@ data class PivState( authenticated: Boolean, derivedKey: Boolean, storedKey: Boolean, - supportsBio: Boolean + pinAttempts: Int, + supportsBio: Boolean, + chuid: String?, + ccc: String?, + metadata: PivStateMetadata? ) : this( Version( piv.version.major, @@ -55,16 +59,11 @@ data class PivState( authenticated, derivedKey, storedKey, - piv.pinAttempts, + pinAttempts, supportsBio, - null, - null, - PivStateMetadata( - ManagementKeyMetadata(piv.managementKeyMetadata), - PinMetadata(piv.pinMetadata), - PinMetadata(piv.pukMetadata) - ) - + chuid, + ccc, + metadata ) override fun toJson(): String { diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivmanUtils.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivmanUtils.kt new file mode 100644 index 000000000..926d915b9 --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivmanUtils.kt @@ -0,0 +1,299 @@ +/* + * Copyright (C) 2025 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.yubico.authenticator.piv.data + +import com.yubico.authenticator.piv.YubiKitPivSession +import com.yubico.yubikit.core.smartcard.ApduException +import com.yubico.yubikit.core.smartcard.SW +import com.yubico.yubikit.core.util.Tlv +import com.yubico.yubikit.core.util.Tlvs +import com.yubico.yubikit.piv.ManagementKeyType +import com.yubico.yubikit.piv.ObjectId +import org.slf4j.LoggerFactory +import java.nio.ByteBuffer +import java.security.SecureRandom +import java.security.spec.KeySpec +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.PBEKeySpec + +private const val FLAG_PUK_BLOCKED = 0x01 +private const val FLAG_MGM_KEY_PROTECTED = 0x02 + +private const val TLV_TAG_PIVMAN_DATA = 0x80 +private const val TLV_TAG_FLAGS = 0x81 +private const val TLV_TAG_SALT = 0x82 +private const val TLV_TAG_TIMESTAMP = 0x83 + +private const val TLV_TAG_PIVMAN_PROTECTED_DATA = 0x88 +private const val TLV_TAG_KEY = 0x89 + +class PivmanData(rawData: ByteArray = Tlv(TLV_TAG_PIVMAN_DATA, null).bytes) { + + private var _flags: Int? = null + var salt: ByteArray? = null + var pinTimestamp: Int? = null + + init { + val data = Tlvs.decodeMap(Tlv.parse(rawData).value) + _flags = data[TLV_TAG_FLAGS]?.let { + ByteBuffer.wrap(it).get().toInt() and 0xFF + } + salt = data[TLV_TAG_SALT] + pinTimestamp = data[TLV_TAG_TIMESTAMP]?.let { ByteBuffer.wrap(it).int } + } + + private fun getFlag(mask: Int): Boolean = ((_flags ?: 0) and mask) != 0 + + private fun setFlag(mask: Int, value: Boolean) { + if (value) { + _flags = (_flags ?: 0) or mask + } else if (_flags != null) { + _flags = _flags!! and mask.inv() + } + } + + var pukBlocked: Boolean + get() = getFlag(FLAG_PUK_BLOCKED) + set(value) = setFlag(FLAG_PUK_BLOCKED, value) + + var mgmKeyProtected: Boolean + get() = getFlag(FLAG_MGM_KEY_PROTECTED) + set(value) = setFlag(FLAG_MGM_KEY_PROTECTED, value) + + val hasProtectedKey: Boolean + get() = hasDerivedKey || hasStoredKey + + val hasDerivedKey: Boolean + get() = salt != null + + val hasStoredKey: Boolean + get() = mgmKeyProtected + + fun getBytes(): ByteArray { + var data = ByteArray(0) + if (_flags != null) { + val flagBytes = ByteBuffer.allocate(1).put(_flags!!.toByte()).array() + data += Tlv(TLV_TAG_FLAGS, flagBytes).bytes + } + if (salt != null) { + data += Tlv(TLV_TAG_SALT, salt).bytes + } + if (pinTimestamp != null) { + val tsBytes = ByteBuffer.allocate(4).putInt(pinTimestamp!!).array() + data += Tlv(TLV_TAG_TIMESTAMP, tsBytes).bytes + } + return if (data.isNotEmpty()) Tlv(TLV_TAG_PIVMAN_DATA, data).bytes else ByteArray(0) + } +} + +class PivmanProtectedData(rawData: ByteArray = Tlv(TLV_TAG_PIVMAN_PROTECTED_DATA, null).bytes) { + var key: ByteArray? = null + + init { + val tlv = Tlv.parse(rawData) + val data = Tlvs.decodeMap(tlv.value) + key = data[TLV_TAG_KEY] + } + + fun getBytes(): ByteArray { + var data = ByteArray(0) + if (key != null) { + data += Tlv(TLV_TAG_KEY, key).bytes + } + return if (data.isNotEmpty()) Tlv(TLV_TAG_PIVMAN_PROTECTED_DATA, data).bytes else ByteArray( + 0 + ) + } +} + +object PivmanUtils { + private val logger = LoggerFactory.getLogger(PivmanUtils::class.java) + + /** + * Reads and parses Pivman data from the given PIV session. + * If no data exists on the device, returns a blank PivmanData instance. + * + * @param piv The YubiKitPivSession to read from. + * @return The parsed [PivmanData] object. + * @throws ApduException if an error occurs other than file not found. + */ + internal fun getPivmanData(piv: YubiKitPivSession): PivmanData { + logger.trace("Reading pivman data") + try { + return PivmanData(piv.getObject(ObjectId.PIVMAN_DATA)) + } catch (e: ApduException) { + if (e.sw == SW.FILE_NOT_FOUND) { + logger.trace("No pivman data, initializing blank") + return PivmanData() + } + throw e + } + } + + /** + * Reads and parses protected Pivman data from the given PIV session. + * If no protected data exists, returns a blank PivmanProtectedData instance. + * + * @param piv The PivSession to read from. + * @return The parsed [PivmanProtectedData] object. + * @throws IllegalArgumentException if the protected data is invalid. + * @throws ApduException if an error occurs other than file not found. + */ + internal fun getPivmanProtectedData(piv: YubiKitPivSession): PivmanProtectedData { + logger.trace("Reading protected pivman data") + try { + return PivmanProtectedData(piv.getObject(ObjectId.PIVMAN_PROTECTED_DATA)) + } catch (e: ApduException) { + if (e.sw == SW.FILE_NOT_FOUND) { + logger.trace("No pivman protected data, initializing blank") + return PivmanProtectedData() + } + throw e + } catch (_: Exception) { + throw IllegalArgumentException( + "Invalid data in protected slot (${ + ObjectId.PIVMAN_PROTECTED_DATA.toString(16) + })" + ) + } + } + + /** + * Sets the management key on the PIV session and updates relevant Pivman data. + * Optionally stores the management key in protected data on the device. + * + * @param piv The PivSession to operate on. + * @param newKey The new management key as a [ByteArray]. + * @param algorithm The type of management key algorithm. + * @param touch Whether touch is required to use the management key. + * @param storeOnDevice If true, stores the key in protected data on the device. + * @throws ApduException if an error occurs while writing to the device. + */ + internal fun pivmanSetMgmKey( + piv: YubiKitPivSession, + newKey: ByteArray, + algorithm: ManagementKeyType, + touch: Boolean = false, + storeOnDevice: Boolean = false, + ) { + val pivman = getPivmanData(piv) + val pivmanOldBytes = pivman.getBytes() + var pivmanProt: PivmanProtectedData? = null + + if (storeOnDevice || (!storeOnDevice && pivman.hasStoredKey)) { + try { + pivmanProt = getPivmanProtectedData(piv) + } catch (e: Exception) { + logger.trace("Failed to initialize protected pivman data: {}", e.message) + if (storeOnDevice) throw e + } + } + + piv.setManagementKey(algorithm, newKey, touch) + + if (pivman.hasDerivedKey) { + logger.trace("Clearing salt in pivman data") + pivman.salt = null + } + + pivman.mgmKeyProtected = storeOnDevice + + val pivmanBytes = pivman.getBytes() + if (!pivmanOldBytes.contentEquals(pivmanBytes)) { + piv.putObject(ObjectId.PIVMAN_DATA, pivmanBytes) + } + + if (pivmanProt != null) { + if (storeOnDevice) { + logger.trace("Storing key in protected pivman data") + pivmanProt.key = newKey + piv.putObject(ObjectId.PIVMAN_PROTECTED_DATA, pivmanProt.getBytes()) + } else if (pivmanProt.key != null) { + logger.trace("Clearing old key in protected pivman data") + try { + pivmanProt.key = null + piv.putObject(ObjectId.PIVMAN_PROTECTED_DATA, pivmanProt.getBytes()) + } catch (e: ApduException) { + logger.trace("No PIN provided, can't clear key... ({})", e.message) + } + } + } + } + + /** + * Changes the PIN on the YubiKey and, if applicable, updates the derived management key. + * + * @param piv The YubiKitPivSession to operate on. + * @param oldPin The current PIN as a [CharArray]. + * @param newPin The new PIN as a [CharArray]. + * @throws ApduException if an error occurs during PIN change. + */ + internal fun pivmanChangePin(piv: YubiKitPivSession, oldPin: CharArray, newPin: CharArray) { + piv.changePin(oldPin, newPin) + + val pivmanData = getPivmanData(piv) + if (pivmanData.hasDerivedKey) { + logger.trace("Has derived management key, update for new PIN") + piv.authenticate(deriveManagementKey(oldPin, pivmanData.salt!!)) + piv.verifyPin(newPin) + val newSalt = SecureRandom().generateSeed(16) + val newKey = deriveManagementKey(newPin, newSalt) + piv.setManagementKey(ManagementKeyType.TDES, newKey, false) + pivmanData.salt = newSalt + piv.putObject(ObjectId.PIVMAN_DATA, pivmanData.getBytes()) + } + } + + /** + * Sets the maximum number of PIN and PUK attempts on the YubiKey and clears the blocked status if needed. + * + * @param piv The YubiKitPivSession to operate on. + * @param pinAttempts The number of allowed PIN attempts. + * @param pukAttempts The number of allowed PUK attempts. + * @throws ApduException if an error occurs during the operation. + */ + internal fun pivmanSetPinAttempts(piv: YubiKitPivSession, pinAttempts: Int, pukAttempts: Int) { + piv.setPinAttempts(pinAttempts, pukAttempts) + val pivman = getPivmanData(piv) + if (pivman.pukBlocked) { + pivman.pukBlocked = false + piv.putObject(ObjectId.PIVMAN_DATA, pivman.getBytes()) + } + } + + /** + * Derives a management key from the user's PIN and a salt using PBKDF2. + * + * **Deprecated:** This method of derivation is deprecated! Protect the management key using [PivmanProtectedData] instead. + * + * @param pin The PIN as a [CharArray]. + * @param salt The salt as a [ByteArray]. + * @return The derived management key as a [ByteArray]. + */ + @Deprecated( + "This method of derivation is deprecated! Protect the management key using PivmanProtectedData instead.", + ReplaceWith("PivmanProtectedData") + ) + internal fun deriveManagementKey(pin: CharArray, salt: ByteArray): ByteArray { + val iterations = 10000 + val keyLength = 24 * 8 // 24 bytes = 192 bits (for TDES) + val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1") + val spec: KeySpec = PBEKeySpec(pin, salt, iterations, keyLength) + val key = factory.generateSecret(spec).encoded + return key + } +} \ No newline at end of file diff --git a/lib/android/piv/state.dart b/lib/android/piv/state.dart index 6da5b6ca7..d26ab59c7 100644 --- a/lib/android/piv/state.dart +++ b/lib/android/piv/state.dart @@ -29,6 +29,7 @@ import '../../core/models.dart'; import '../../exception/no_data_exception.dart'; import '../../piv/models.dart'; import '../../piv/state.dart'; +import '../app_methods.dart'; import '../overlay/nfc/method_channel_notifier.dart' show MethodChannelNotifier; // TODO final _log = Logger('android.piv.state'); @@ -416,6 +417,7 @@ class _AndroidPivSlotsNotifier extends PivSlotsNotifier { // } // }); // + await preserveConnectedDeviceWhenPaused(); final (type, subject, validFrom, validTo) = switch (parameters) { PivGeneratePublicKeyParameters() => ( GenerateType.publicKey, @@ -528,6 +530,7 @@ class _AndroidPivSlotsNotifier extends PivSlotsNotifier { @override Future<(SlotMetadata?, String?)> read(SlotId slot) async { + await preserveConnectedDeviceWhenPaused(); final result = jsonDecode( await piv.invoke('getSlot', {'slot': slot.hexId}), ); diff --git a/lib/piv/views/actions.dart b/lib/piv/views/actions.dart index 34b8ec472..9aceb0a65 100644 --- a/lib/piv/views/actions.dart +++ b/lib/piv/views/actions.dart @@ -24,6 +24,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../android/app_methods.dart'; import '../../app/message.dart'; import '../../app/models.dart'; import '../../app/shortcuts.dart'; @@ -210,6 +211,10 @@ class PivActions extends ConsumerWidget { return false; } + if (Platform.isAndroid) { + await preserveConnectedDeviceWhenPaused(); + } + final picked = await withContext((context) async { final l10n = AppLocalizations.of(context); return await FilePicker.platform.pickFiles( From b047f5a676c4d2c3945a48df9ee2a9b0f5533e22 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Wed, 20 Aug 2025 09:02:40 +0200 Subject: [PATCH 22/28] reorganize pivmandata --- .../yubico/authenticator/piv/PivManager.kt | 1 - .../piv/{data => }/PivmanUtils.kt | 120 +----------------- .../authenticator/piv/data/PivmanData.kt | 88 +++++++++++++ .../piv/data/PivmanProtectedData.kt | 43 +++++++ 4 files changed, 137 insertions(+), 115 deletions(-) rename android/app/src/main/kotlin/com/yubico/authenticator/piv/{data => }/PivmanUtils.kt (66%) create mode 100644 android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivmanData.kt create mode 100644 android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivmanProtectedData.kt diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt index 156ed371d..27d0cfbfa 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt @@ -37,7 +37,6 @@ import com.yubico.authenticator.piv.data.ManagementKeyMetadata import com.yubico.authenticator.piv.data.PinMetadata import com.yubico.authenticator.piv.data.PivStateMetadata import com.yubico.authenticator.piv.data.PivmanData -import com.yubico.authenticator.piv.data.PivmanUtils import com.yubico.authenticator.setHandler import com.yubico.authenticator.yubikit.withConnection import com.yubico.yubikit.android.transport.nfc.NfcYubiKeyDevice diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivmanUtils.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivmanUtils.kt similarity index 66% rename from android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivmanUtils.kt rename to android/app/src/main/kotlin/com/yubico/authenticator/piv/PivmanUtils.kt index 926d915b9..7563c2367 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivmanUtils.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivmanUtils.kt @@ -1,125 +1,17 @@ -/* - * Copyright (C) 2025 Yubico. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ +package com.yubico.authenticator.piv -package com.yubico.authenticator.piv.data - -import com.yubico.authenticator.piv.YubiKitPivSession +import com.yubico.authenticator.piv.data.PivmanData +import com.yubico.authenticator.piv.data.PivmanProtectedData import com.yubico.yubikit.core.smartcard.ApduException import com.yubico.yubikit.core.smartcard.SW -import com.yubico.yubikit.core.util.Tlv -import com.yubico.yubikit.core.util.Tlvs import com.yubico.yubikit.piv.ManagementKeyType import com.yubico.yubikit.piv.ObjectId import org.slf4j.LoggerFactory -import java.nio.ByteBuffer import java.security.SecureRandom import java.security.spec.KeySpec import javax.crypto.SecretKeyFactory import javax.crypto.spec.PBEKeySpec -private const val FLAG_PUK_BLOCKED = 0x01 -private const val FLAG_MGM_KEY_PROTECTED = 0x02 - -private const val TLV_TAG_PIVMAN_DATA = 0x80 -private const val TLV_TAG_FLAGS = 0x81 -private const val TLV_TAG_SALT = 0x82 -private const val TLV_TAG_TIMESTAMP = 0x83 - -private const val TLV_TAG_PIVMAN_PROTECTED_DATA = 0x88 -private const val TLV_TAG_KEY = 0x89 - -class PivmanData(rawData: ByteArray = Tlv(TLV_TAG_PIVMAN_DATA, null).bytes) { - - private var _flags: Int? = null - var salt: ByteArray? = null - var pinTimestamp: Int? = null - - init { - val data = Tlvs.decodeMap(Tlv.parse(rawData).value) - _flags = data[TLV_TAG_FLAGS]?.let { - ByteBuffer.wrap(it).get().toInt() and 0xFF - } - salt = data[TLV_TAG_SALT] - pinTimestamp = data[TLV_TAG_TIMESTAMP]?.let { ByteBuffer.wrap(it).int } - } - - private fun getFlag(mask: Int): Boolean = ((_flags ?: 0) and mask) != 0 - - private fun setFlag(mask: Int, value: Boolean) { - if (value) { - _flags = (_flags ?: 0) or mask - } else if (_flags != null) { - _flags = _flags!! and mask.inv() - } - } - - var pukBlocked: Boolean - get() = getFlag(FLAG_PUK_BLOCKED) - set(value) = setFlag(FLAG_PUK_BLOCKED, value) - - var mgmKeyProtected: Boolean - get() = getFlag(FLAG_MGM_KEY_PROTECTED) - set(value) = setFlag(FLAG_MGM_KEY_PROTECTED, value) - - val hasProtectedKey: Boolean - get() = hasDerivedKey || hasStoredKey - - val hasDerivedKey: Boolean - get() = salt != null - - val hasStoredKey: Boolean - get() = mgmKeyProtected - - fun getBytes(): ByteArray { - var data = ByteArray(0) - if (_flags != null) { - val flagBytes = ByteBuffer.allocate(1).put(_flags!!.toByte()).array() - data += Tlv(TLV_TAG_FLAGS, flagBytes).bytes - } - if (salt != null) { - data += Tlv(TLV_TAG_SALT, salt).bytes - } - if (pinTimestamp != null) { - val tsBytes = ByteBuffer.allocate(4).putInt(pinTimestamp!!).array() - data += Tlv(TLV_TAG_TIMESTAMP, tsBytes).bytes - } - return if (data.isNotEmpty()) Tlv(TLV_TAG_PIVMAN_DATA, data).bytes else ByteArray(0) - } -} - -class PivmanProtectedData(rawData: ByteArray = Tlv(TLV_TAG_PIVMAN_PROTECTED_DATA, null).bytes) { - var key: ByteArray? = null - - init { - val tlv = Tlv.parse(rawData) - val data = Tlvs.decodeMap(tlv.value) - key = data[TLV_TAG_KEY] - } - - fun getBytes(): ByteArray { - var data = ByteArray(0) - if (key != null) { - data += Tlv(TLV_TAG_KEY, key).bytes - } - return if (data.isNotEmpty()) Tlv(TLV_TAG_PIVMAN_PROTECTED_DATA, data).bytes else ByteArray( - 0 - ) - } -} - object PivmanUtils { private val logger = LoggerFactory.getLogger(PivmanUtils::class.java) @@ -128,8 +20,8 @@ object PivmanUtils { * If no data exists on the device, returns a blank PivmanData instance. * * @param piv The YubiKitPivSession to read from. - * @return The parsed [PivmanData] object. - * @throws ApduException if an error occurs other than file not found. + * @return The parsed [com.yubico.authenticator.piv.data.PivmanData] object. + * @throws com.yubico.yubikit.core.smartcard.ApduException if an error occurs other than file not found. */ internal fun getPivmanData(piv: YubiKitPivSession): PivmanData { logger.trace("Reading pivman data") @@ -149,7 +41,7 @@ object PivmanUtils { * If no protected data exists, returns a blank PivmanProtectedData instance. * * @param piv The PivSession to read from. - * @return The parsed [PivmanProtectedData] object. + * @return The parsed [com.yubico.authenticator.piv.data.PivmanProtectedData] object. * @throws IllegalArgumentException if the protected data is invalid. * @throws ApduException if an error occurs other than file not found. */ diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivmanData.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivmanData.kt new file mode 100644 index 000000000..e246caa29 --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivmanData.kt @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2025 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.yubico.authenticator.piv.data + +import com.yubico.yubikit.core.util.Tlv +import com.yubico.yubikit.core.util.Tlvs +import java.nio.ByteBuffer + +private const val FLAG_PUK_BLOCKED = 0x01 +private const val FLAG_MGM_KEY_PROTECTED = 0x02 + +private const val TLV_TAG_PIVMAN_DATA = 0x80 +private const val TLV_TAG_FLAGS = 0x81 +private const val TLV_TAG_SALT = 0x82 +private const val TLV_TAG_TIMESTAMP = 0x83 + +class PivmanData(rawData: ByteArray = Tlv(TLV_TAG_PIVMAN_DATA, null).bytes) { + + private var _flags: Int? = null + var salt: ByteArray? = null + var pinTimestamp: Int? = null + + init { + val data = Tlvs.decodeMap(Tlv.parse(rawData).value) + _flags = data[TLV_TAG_FLAGS]?.let { + ByteBuffer.wrap(it).get().toInt() and 0xFF + } + salt = data[TLV_TAG_SALT] + pinTimestamp = data[TLV_TAG_TIMESTAMP]?.let { ByteBuffer.wrap(it).int } + } + + private fun getFlag(mask: Int): Boolean = ((_flags ?: 0) and mask) != 0 + + private fun setFlag(mask: Int, value: Boolean) { + if (value) { + _flags = (_flags ?: 0) or mask + } else if (_flags != null) { + _flags = _flags!! and mask.inv() + } + } + + var pukBlocked: Boolean + get() = getFlag(FLAG_PUK_BLOCKED) + set(value) = setFlag(FLAG_PUK_BLOCKED, value) + + var mgmKeyProtected: Boolean + get() = getFlag(FLAG_MGM_KEY_PROTECTED) + set(value) = setFlag(FLAG_MGM_KEY_PROTECTED, value) + + val hasProtectedKey: Boolean + get() = hasDerivedKey || hasStoredKey + + val hasDerivedKey: Boolean + get() = salt != null + + val hasStoredKey: Boolean + get() = mgmKeyProtected + + fun getBytes(): ByteArray { + var data = ByteArray(0) + if (_flags != null) { + val flagBytes = ByteBuffer.allocate(1).put(_flags!!.toByte()).array() + data += Tlv(TLV_TAG_FLAGS, flagBytes).bytes + } + if (salt != null) { + data += Tlv(TLV_TAG_SALT, salt).bytes + } + if (pinTimestamp != null) { + val tsBytes = ByteBuffer.allocate(4).putInt(pinTimestamp!!).array() + data += Tlv(TLV_TAG_TIMESTAMP, tsBytes).bytes + } + return if (data.isNotEmpty()) Tlv(TLV_TAG_PIVMAN_DATA, data).bytes else ByteArray(0) + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivmanProtectedData.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivmanProtectedData.kt new file mode 100644 index 000000000..97ca50848 --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/data/PivmanProtectedData.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2025 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.yubico.authenticator.piv.data + +import com.yubico.yubikit.core.util.Tlv +import com.yubico.yubikit.core.util.Tlvs + +private const val TLV_TAG_PIVMAN_PROTECTED_DATA = 0x88 +private const val TLV_TAG_KEY = 0x89 + +class PivmanProtectedData(rawData: ByteArray = Tlv(TLV_TAG_PIVMAN_PROTECTED_DATA, null).bytes) { + var key: ByteArray? = null + + init { + val tlv = Tlv.parse(rawData) + val data = Tlvs.decodeMap(tlv.value) + key = data[TLV_TAG_KEY] + } + + fun getBytes(): ByteArray { + var data = ByteArray(0) + if (key != null) { + data += Tlv(TLV_TAG_KEY, key).bytes + } + return if (data.isNotEmpty()) Tlv(TLV_TAG_PIVMAN_PROTECTED_DATA, data).bytes else ByteArray( + 0 + ) + } +} \ No newline at end of file From 315a8dc70b360f9b8a6519b7e159f8f62e274596 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Wed, 20 Aug 2025 09:20:16 +0200 Subject: [PATCH 23/28] support keys without metadata and serial features --- .../yubico/authenticator/piv/PivManager.kt | 91 +++++++++++-------- 1 file changed, 52 insertions(+), 39 deletions(-) diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt index 27d0cfbfa..c4112d3c7 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt @@ -23,20 +23,20 @@ import com.yubico.authenticator.MainViewModel import com.yubico.authenticator.NfcOverlayManager import com.yubico.authenticator.OperationContext import com.yubico.authenticator.device.DeviceManager -import com.yubico.authenticator.piv.data.PivSlot -import com.yubico.authenticator.piv.data.PivState -import com.yubico.authenticator.piv.data.SlotMetadata -import com.yubico.authenticator.piv.data.byteArrayToHexString -import com.yubico.authenticator.piv.data.fingerprint -import com.yubico.authenticator.piv.data.hexStringToByteArray -import com.yubico.authenticator.piv.data.isoFormat import com.yubico.authenticator.piv.KeyMaterialParser.getLeafCertificates import com.yubico.authenticator.piv.KeyMaterialParser.parse import com.yubico.authenticator.piv.KeyMaterialParser.toPem import com.yubico.authenticator.piv.data.ManagementKeyMetadata import com.yubico.authenticator.piv.data.PinMetadata +import com.yubico.authenticator.piv.data.PivSlot +import com.yubico.authenticator.piv.data.PivState import com.yubico.authenticator.piv.data.PivStateMetadata import com.yubico.authenticator.piv.data.PivmanData +import com.yubico.authenticator.piv.data.SlotMetadata +import com.yubico.authenticator.piv.data.byteArrayToHexString +import com.yubico.authenticator.piv.data.fingerprint +import com.yubico.authenticator.piv.data.hexStringToByteArray +import com.yubico.authenticator.piv.data.isoFormat import com.yubico.authenticator.setHandler import com.yubico.authenticator.yubikit.withConnection import com.yubico.yubikit.android.transport.nfc.NfcYubiKeyDevice @@ -58,6 +58,8 @@ import com.yubico.yubikit.piv.ManagementKeyType import com.yubico.yubikit.piv.ObjectId import com.yubico.yubikit.piv.PinPolicy import com.yubico.yubikit.piv.PivSession +import com.yubico.yubikit.piv.PivSession.FEATURE_METADATA +import com.yubico.yubikit.piv.PivSession.FEATURE_SERIAL import com.yubico.yubikit.piv.Slot import com.yubico.yubikit.piv.TouchPolicy import io.flutter.plugin.common.BinaryMessenger @@ -262,7 +264,11 @@ class PivManager( val piv = getPivSession(connection) val previousSerial = pivViewModel.currentSerial - val currentSerial = piv.serialNumber + val currentSerial = if (piv.supports(FEATURE_SERIAL)) { + piv.serialNumber + } else { + null + } pivViewModel.setSerial(currentSerial) logger.debug( "Previous serial: {}, current serial: {}", @@ -292,7 +298,7 @@ class PivManager( return requestHandled } - private var pivmanData: PivmanData?= null + private var pivmanData: PivmanData? = null /** * rereads the PIV state and slots @@ -330,11 +336,16 @@ class PivManager( supportsBio = supportsBio, chuid = getObject(piv, ObjectId.CHUID)?.byteArrayToHexString(), ccc = getObject(piv, ObjectId.CAPABILITY)?.byteArrayToHexString(), - metadata = PivStateMetadata( - ManagementKeyMetadata(piv.managementKeyMetadata), - PinMetadata(piv.pinMetadata), - PinMetadata(piv.pukMetadata) - ) + metadata = if (piv.supports(FEATURE_METADATA)) { + PivStateMetadata( + ManagementKeyMetadata(piv.managementKeyMetadata), + PinMetadata(piv.pinMetadata), + PinMetadata(piv.pukMetadata) + ) + } else { + null + } + ) ) } @@ -405,7 +416,7 @@ class PivManager( private fun authenticateKeyBySerial(piv: PivSession, serial: String) = try { - val managementKey = managementKeyStorage[serial] ?: defaultManagementKey + val managementKey = managementKeyStorage[serial] ?: defaultManagementKey piv.authenticate(managementKey) } catch (e: Exception) { managementKeyStorage.remove(serial) @@ -425,15 +436,15 @@ class PivManager( } } - private fun doVerifyPin(piv: PivSession, serial: String) : String = + private fun doVerifyPin(piv: PivSession, serial: String): String = try { var authenticated = false - pinStorage[serial]?.let {pin -> + pinStorage[serial]?.let { pin -> piv.verifyPin(pin) - val key = if (pivmanData?.hasDerivedKey ?: false) { + val key = if (pivmanData?.hasDerivedKey ?: false) { PivmanUtils.deriveManagementKey(pin, pivmanData?.salt!!) - } else if (pivmanData?.hasStoredKey ?:false ) { + } else if (pivmanData?.hasStoredKey ?: false) { val pivmanProtectedData = PivmanUtils.getPivmanProtectedData(piv) pivmanProtectedData.key } else { @@ -525,7 +536,7 @@ class PivManager( } } - private fun handlePinPukErrors(block: () -> Unit) : String { + private fun handlePinPukErrors(block: () -> Unit): String { try { block() return JSONObject(mapOf("status" to "success")).toString() @@ -638,13 +649,15 @@ class PivManager( // Random 16-byte GUID val guid = ByteArray(16).also { SecureRandom().nextBytes(it) } - return Tlvs.encodeList(listOf( - Tlv(0x30, fascN), - Tlv(0x34, guid), - Tlv(0x35, expiry), - Tlv(0x3E, ByteArray(0)), - Tlv(0xFE, ByteArray(0)) - )) + return Tlvs.encodeList( + listOf( + Tlv(0x30, fascN), + Tlv(0x34, guid), + Tlv(0x35, expiry), + Tlv(0x3E, ByteArray(0)), + Tlv(0xFE, ByteArray(0)) + ) + ) } private fun chooseCertificate(certificates: List?): X509Certificate? { @@ -671,7 +684,7 @@ class PivManager( ) } - private fun publicKeyMatch(certificate: X509Certificate?, metadata: SlotMetadata?) : Boolean? { + private fun publicKeyMatch(certificate: X509Certificate?, metadata: SlotMetadata?): Boolean? { if (certificate == null || metadata == null) { return null } @@ -788,6 +801,7 @@ class PivManager( piv.putObject(ObjectId.CHUID, generateChuid()) result } + else -> throw IllegalArgumentException("Invalid generate type: $generateType") } @@ -808,17 +822,17 @@ class PivManager( data: String, password: String? ): KeyMaterial = try { - parse(data.hexStringToByteArray(), password?.toCharArray()) - } catch (e: Exception) { - when (e) { - is IllegalArgumentException, is IOException -> KeyMaterial( - emptyList(), - null - ) + parse(data.hexStringToByteArray(), password?.toCharArray()) + } catch (e: Exception) { + when (e) { + is IllegalArgumentException, is IOException -> KeyMaterial( + emptyList(), + null + ) - else -> throw e - } + else -> throw e } + } private suspend fun importFile( @@ -856,8 +870,7 @@ class PivManager( SlotMetadata(piv.getSlotMetadata(slot)) } catch (e: Exception) { when (e) { - // TODO NotSupported - is ApduException, is BadResponseException -> null + is ApduException, is BadResponseException, is UnsupportedOperationException -> null else -> throw e } } From 32be31875cf606844ddf58378f13019c7f5be0a9 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Wed, 20 Aug 2025 10:23:14 +0200 Subject: [PATCH 24/28] update R8 --- android/app/proguard-rules.pro | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 75a1fdf7e..afe030da5 100755 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -60,4 +60,18 @@ -keepclassmembers class org.slf4j.impl.** { *; } -keepattributes *Annotation* --keep public class ch.qos.logback.classic.android.LogcatAppender \ No newline at end of file +-keep public class ch.qos.logback.classic.android.LogcatAppender + + +-keep class org.bouncycastle.** { *; } + +# these are not part of Android SDK +-dontwarn javax.naming.Binding +-dontwarn javax.naming.NamingEnumeration +-dontwarn javax.naming.NamingException +-dontwarn javax.naming.directory.Attribute +-dontwarn javax.naming.directory.Attributes +-dontwarn javax.naming.directory.DirContext +-dontwarn javax.naming.directory.InitialDirContext +-dontwarn javax.naming.directory.SearchControls +-dontwarn javax.naming.directory.SearchResult From b64dfd6d30a0898a9b6813abfb2ea9e2aec2bb15 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Wed, 20 Aug 2025 14:25:05 +0200 Subject: [PATCH 25/28] Wait for key removal during NFC actions --- .../com/yubico/authenticator/MainActivity.kt | 6 ++-- .../authenticator/device/DeviceManager.kt | 4 +-- .../authenticator/fido/FidoResetHelper.kt | 4 +-- .../authenticator/piv/PivConnectionHelper.kt | 3 ++ .../yubico/authenticator/piv/PivManager.kt | 4 +-- .../yubico/authenticator/yubikit/NfcState.kt | 29 +++++++++++++++++-- lib/android/overlay/nfc/nfc_overlay.dart | 17 +++++++++++ lib/android/state.dart | 8 +++-- lib/l10n/app_cs.arb | 2 ++ lib/l10n/app_de.arb | 2 ++ lib/l10n/app_en.arb | 2 ++ lib/l10n/app_es.arb | 2 ++ lib/l10n/app_fr.arb | 2 ++ lib/l10n/app_ja.arb | 2 ++ lib/l10n/app_pl.arb | 2 ++ lib/l10n/app_sk.arb | 2 ++ lib/l10n/app_sv.arb | 2 ++ lib/l10n/app_tr.arb | 2 ++ lib/l10n/app_vi.arb | 2 ++ lib/l10n/app_zh.arb | 2 ++ lib/l10n/app_zh_TW.arb | 2 ++ 21 files changed, 86 insertions(+), 15 deletions(-) diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt b/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt index 92ebcc7e0..cd01f8923 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt @@ -375,7 +375,7 @@ class MainActivity : FlutterFragmentActivity() { logger.debug("Processing pending action in context {}", it) if (it.processYubiKey(device)) { if (device is NfcYubiKeyDevice) { - appMethodChannel.nfcStateChanged(NfcState.SUCCESS) + appMethodChannel.nfcStateChanged(NfcState.getSuccessState()) } } if (device is NfcYubiKeyDevice) { @@ -448,7 +448,7 @@ class MainActivity : FlutterFragmentActivity() { val requestHandled = it.processYubiKey(device) if (requestHandled) { if (device is NfcYubiKeyDevice) { - appMethodChannel.nfcStateChanged(NfcState.SUCCESS) + appMethodChannel.nfcStateChanged(NfcState.getSuccessState()) } } if (!switchedContextManager && device is NfcYubiKeyDevice) { @@ -458,7 +458,7 @@ class MainActivity : FlutterFragmentActivity() { } } catch (e: Exception) { logger.debug("Caught Exception during YubiKey processing: ", e) - appMethodChannel.nfcStateChanged(NfcState.FAILURE) + appMethodChannel.nfcStateChanged(NfcState.getFailureState()) } } diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/device/DeviceManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/device/DeviceManager.kt index 2701fca27..e8f2bd812 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/device/DeviceManager.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/device/DeviceManager.kt @@ -181,10 +181,10 @@ class DeviceManager( try { return onNfc.invoke().value.also { - appMethodChannel.nfcStateChanged(NfcState.SUCCESS) + appMethodChannel.nfcStateChanged(NfcState.getSuccessState()) } } catch (e: Exception) { - appMethodChannel.nfcStateChanged(NfcState.FAILURE) + appMethodChannel.nfcStateChanged(NfcState.getFailureState()) throw e } } diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoResetHelper.kt b/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoResetHelper.kt index 07b9b258a..a08684c80 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoResetHelper.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoResetHelper.kt @@ -224,7 +224,7 @@ class FidoResetHelper( FidoManager.updateDeviceInfo.set(true) connectionHelper.useSessionNfc { fidoSession -> doReset(fidoSession) - appMethodChannel.nfcStateChanged(NfcState.SUCCESS) + appMethodChannel.nfcStateChanged(NfcState.getSuccessState()) continuation.resume(Unit) }.value } catch (cancellationException: CancellationException) { @@ -233,7 +233,7 @@ class FidoResetHelper( } catch (e: Throwable) { // on NFC, clean device info in this situation mainViewModel.setDeviceInfo(null) - appMethodChannel.nfcStateChanged(NfcState.FAILURE) + appMethodChannel.nfcStateChanged(NfcState.getFailureState()) logger.error("Failure during FIDO reset:", e) continuation.resumeWithException(e) } diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivConnectionHelper.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivConnectionHelper.kt index 6205620e0..fdfbfde90 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivConnectionHelper.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivConnectionHelper.kt @@ -17,6 +17,7 @@ package com.yubico.authenticator.piv import com.yubico.authenticator.device.DeviceManager +import com.yubico.authenticator.yubikit.NfcState import com.yubico.authenticator.yubikit.withConnection import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice import com.yubico.yubikit.core.smartcard.SmartCardConnection @@ -54,8 +55,10 @@ class PivConnectionHelper(private val deviceManager: DeviceManager) { suspend fun useSmartCardConnection( onComplete: ((SmartCardConnection) -> Unit)? = null, + waitForNfcKeyRemoval: Boolean = false, block: (SmartCardConnection) -> T ): T { + NfcState.waitForNfcKeyRemoval = waitForNfcKeyRemoval return deviceManager.withKey( onUsb = { useSmartCardConnectionUsb(it, onComplete, block) }, onNfc = { useSmartCardConnectionNfc(block) }, diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt index c4112d3c7..2d1eeddc9 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt @@ -748,7 +748,7 @@ class PivManager( validFrom: String?, validTo: String? ): String = - connectionHelper.useSmartCardConnection(::update) { + connectionHelper.useSmartCardConnection(::update, true) { try { val piv = getPivSession(it) @@ -913,7 +913,7 @@ class PivManager( private suspend fun getSlot( slot: Slot ): String = - connectionHelper.useSmartCardConnection { piv -> + connectionHelper.useSmartCardConnection(waitForNfcKeyRemoval = true) { piv -> try { JSONObject( mapOf( diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/yubikit/NfcState.kt b/android/app/src/main/kotlin/com/yubico/authenticator/yubikit/NfcState.kt index 5a919c181..158aa64fa 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/yubikit/NfcState.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/yubikit/NfcState.kt @@ -22,7 +22,30 @@ enum class NfcState(val value: Int) { ONGOING(2), SUCCESS(3), FAILURE(4), - USB_ACTIVITY_ONGOING(5), - USB_ACTIVITY_SUCCESS(6), - USB_ACTIVITY_FAILURE(7) + WAIT_FOR_REMOVAL(5), + USB_ACTIVITY_ONGOING(6), + USB_ACTIVITY_SUCCESS(7), + USB_ACTIVITY_FAILURE(8); + + companion object { + + private var _waitForNfcKeyRemoval: Boolean = false + var waitForNfcKeyRemoval: Boolean + get() { + val value = _waitForNfcKeyRemoval + _waitForNfcKeyRemoval = false // Reset after read + return value + } + set(value) { + _waitForNfcKeyRemoval = value + } + + fun getSuccessState(): NfcState = + if (waitForNfcKeyRemoval) + WAIT_FOR_REMOVAL + else + SUCCESS + + fun getFailureState(): NfcState = FAILURE.also { waitForNfcKeyRemoval = false } + } } \ No newline at end of file diff --git a/lib/android/overlay/nfc/nfc_overlay.dart b/lib/android/overlay/nfc/nfc_overlay.dart index 8c7fa9a69..6c0f10610 100755 --- a/lib/android/overlay/nfc/nfc_overlay.dart +++ b/lib/android/overlay/nfc/nfc_overlay.dart @@ -74,8 +74,12 @@ class _NfcOverlayNotifier extends Notifier { case NfcState.disabled: _log.debug('Received state: disabled'); break; + case NfcState.waitForRemoval: + notifier.send(showRemoveKey()); + break; case NfcState.idle: _log.debug('Received state: idle'); + notifier.send(const NfcHideViewEvent()); break; case NfcState.usbActivityOngoing: const timeout = 300; @@ -159,6 +163,19 @@ class _NfcOverlayNotifier extends Notifier { ); } + NfcEvent showRemoveKey() { + final l10n = ref.read(l10nProvider); + ref.read(nfcOverlayWidgetProperties.notifier).update(hasCloseButton: false); + return NfcSetViewEvent( + child: NfcContentWidget( + title: l10n.s_success, + subtitle: l10n.s_nfc_remove_key, + icon: const NfcIconSuccess(), + ), + showIfHidden: false, + ); + } + NfcEvent showFailed() { final l10n = ref.read(l10nProvider); ref.read(nfcOverlayWidgetProperties.notifier).update(hasCloseButton: false); diff --git a/lib/android/state.dart b/lib/android/state.dart index 4aace5fd0..2bab7b6f5 100644 --- a/lib/android/state.dart +++ b/lib/android/state.dart @@ -101,6 +101,7 @@ enum NfcState { ongoing, success, failure, + waitForRemoval, usbActivityOngoing, usbActivitySuccess, usbActivityFailure, @@ -116,9 +117,10 @@ class NfcStateNotifier extends StateNotifier { 2 => NfcState.ongoing, 3 => NfcState.success, 4 => NfcState.failure, - 5 => NfcState.usbActivityOngoing, - 6 => NfcState.usbActivitySuccess, - 7 => NfcState.usbActivityFailure, + 5 => NfcState.waitForRemoval, + 6 => NfcState.usbActivityOngoing, + 7 => NfcState.usbActivitySuccess, + 8 => NfcState.usbActivityFailure, _ => NfcState.disabled, }; diff --git a/lib/l10n/app_cs.arb b/lib/l10n/app_cs.arb index e05891608..9a63ee1c7 100644 --- a/lib/l10n/app_cs.arb +++ b/lib/l10n/app_cs.arb @@ -42,6 +42,7 @@ "s_import": "Importovat", "s_overwrite": "Přepsat", "s_done": "Hotovo", + "s_success": null, "s_more": null, "s_label": "Jmenovka", "s_name": "Název", @@ -1070,6 +1071,7 @@ "s_nfc_ready_to_scan": "Připraveno ke skenování", "s_nfc_hold_still": "Neodstraňujte YubiKey\u2026", "s_nfc_tap_your_yubikey": "Přiložte YubiKey", + "s_nfc_remove_key": null, "l_nfc_failed_to_scan": "Nepodařilo se naskenovat, zkuste to znovu", "@_usb": {}, diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 8bd49db77..633689b99 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -42,6 +42,7 @@ "s_import": "Importieren", "s_overwrite": "Überschreiben", "s_done": "Erledigt", + "s_success": null, "s_more": null, "s_label": "Namen", "s_name": "Name", @@ -1070,6 +1071,7 @@ "s_nfc_ready_to_scan": "Bereit zum Scannen", "s_nfc_hold_still": "Deinen YubiKey nicht bewegen\u2026", "s_nfc_tap_your_yubikey": "Halte Deinen YubiKey an den Leser", + "s_nfc_remove_key": null, "l_nfc_failed_to_scan": "Scanvorgang fehlgeschlagen, versuche es nochmal", "@_usb": {}, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 99cf05c06..cd5cfa81d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -42,6 +42,7 @@ "s_import": "Import", "s_overwrite": "Overwrite", "s_done": "Done", + "s_success": "Success", "s_more": "More", "s_label": "Label", "s_name": "Name", @@ -1070,6 +1071,7 @@ "s_nfc_ready_to_scan": "Ready to scan", "s_nfc_hold_still": "Hold still\u2026", "s_nfc_tap_your_yubikey": "Tap your YubiKey", + "s_nfc_remove_key": "Remove YubiKey", "l_nfc_failed_to_scan": "Failed to scan, try again", "@_usb": {}, diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 18624f530..d55e1bf0b 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -42,6 +42,7 @@ "s_import": "Importar", "s_overwrite": "Sobreescribir", "s_done": "Hecho", + "s_success": null, "s_more": null, "s_label": "Etiqueta", "s_name": "Nombre", @@ -1070,6 +1071,7 @@ "s_nfc_ready_to_scan": "Listo para escanear", "s_nfc_hold_still": "Mantén presionado\u2026", "s_nfc_tap_your_yubikey": "Toca tu YubiKey", + "s_nfc_remove_key": null, "l_nfc_failed_to_scan": "Error al escanear, inténtalo de nuevo", "@_usb": {}, diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 6a55dd264..c512a4857 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -42,6 +42,7 @@ "s_import": "Importer", "s_overwrite": "Écraser", "s_done": "Terminé", + "s_success": null, "s_more": null, "s_label": "Étiquette", "s_name": "Nom", @@ -1070,6 +1071,7 @@ "s_nfc_ready_to_scan": "Prêt à scanner", "s_nfc_hold_still": "Ne bougez pas\u2026", "s_nfc_tap_your_yubikey": "Touchez votre YubiKey", + "s_nfc_remove_key": null, "l_nfc_failed_to_scan": "Échec de l'analyse, réessayez", "@_usb": {}, diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index ce4dde446..af7d86467 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -42,6 +42,7 @@ "s_import": "インポート", "s_overwrite": "上書き", "s_done": "完了", + "s_success": null, "s_more": null, "s_label": "ラベル", "s_name": "名前", @@ -1070,6 +1071,7 @@ "s_nfc_ready_to_scan": "スキャンの準備完了", "s_nfc_hold_still": "そのまま保持してください\u2026", "s_nfc_tap_your_yubikey": "YubiKey をタップしてください", + "s_nfc_remove_key": null, "l_nfc_failed_to_scan": "スキャンに失敗しました。もう一度お試しください", "@_usb": {}, diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 464c756ae..8ff0327ea 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -42,6 +42,7 @@ "s_import": "Importuj", "s_overwrite": "Nadpisz", "s_done": "Gotowe", + "s_success": null, "s_more": null, "s_label": "Etykieta", "s_name": "Nazwa", @@ -1070,6 +1071,7 @@ "s_nfc_ready_to_scan": "Gotowy do skanowania", "s_nfc_hold_still": "Nie ruszaj się\u2026", "s_nfc_tap_your_yubikey": "Zbliż klucz YubiKey", + "s_nfc_remove_key": null, "l_nfc_failed_to_scan": "Nie udało się zeskanować. Spróbuj ponownie", "@_usb": {}, diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index 2ea5fe5d0..f3d5fb91d 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -42,6 +42,7 @@ "s_import": "Importovať", "s_overwrite": "Prepísať", "s_done": "Hotovo", + "s_success": null, "s_more": null, "s_label": "Menovka", "s_name": "Názov", @@ -1070,6 +1071,7 @@ "s_nfc_ready_to_scan": "Pripravené na skenovanie", "s_nfc_hold_still": "Prosím držte pevne\u2026", "s_nfc_tap_your_yubikey": "Ťuknite na svoj YubiKey", + "s_nfc_remove_key": null, "l_nfc_failed_to_scan": "Skenovanie sa nepodarilo, skúste to znova", "@_usb": {}, diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index ac13ef391..5252c5917 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -42,6 +42,7 @@ "s_import": "Importera", "s_overwrite": "Skriv över", "s_done": "Klar", + "s_success": null, "s_more": null, "s_label": "Etikett", "s_name": "Namn", @@ -1070,6 +1071,7 @@ "s_nfc_ready_to_scan": "Redo att skanna", "s_nfc_hold_still": "Håll stilla\u2026", "s_nfc_tap_your_yubikey": "Tryck på din YubiKey", + "s_nfc_remove_key": null, "l_nfc_failed_to_scan": "Misslyckades med skanningen, försök igen", "@_usb": {}, diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index b2a951878..67fda4dc0 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -42,6 +42,7 @@ "s_import": "İçe Aktar", "s_overwrite": "Geçerli Olanın Üzerine Yaz", "s_done": "Tamamlandı", + "s_success": null, "s_more": null, "s_label": "Etiket", "s_name": "İsim", @@ -1070,6 +1071,7 @@ "s_nfc_ready_to_scan": "Taramaya hazır", "s_nfc_hold_still": "Aynı yerde tutmaya devam edin\u2026", "s_nfc_tap_your_yubikey": "YubiKey'inize dokunun", + "s_nfc_remove_key": null, "l_nfc_failed_to_scan": "Tarama başarısız, tekrar deneyin", "@_usb": {}, diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 94ab00536..53f43625b 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -42,6 +42,7 @@ "s_import": "Nhập khẩu", "s_overwrite": "Ghi đè", "s_done": "Xong", + "s_success": null, "s_more": null, "s_label": "Nhãn", "s_name": "Tên", @@ -1070,6 +1071,7 @@ "s_nfc_ready_to_scan": "Sẵn sàng để quét", "s_nfc_hold_still": "Giữ yên\u2026", "s_nfc_tap_your_yubikey": "Nhấn vào YubiKey của bạn", + "s_nfc_remove_key": null, "l_nfc_failed_to_scan": "Không quét được, hãy thử lại", "@_usb": {}, diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index fc23db61b..ce0c3b0b6 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -42,6 +42,7 @@ "s_import": "导入", "s_overwrite": "覆写", "s_done": "完成", + "s_success": null, "s_more": null, "s_label": "标签", "s_name": "名字", @@ -1070,6 +1071,7 @@ "s_nfc_ready_to_scan": "已准备好扫描", "s_nfc_hold_still": "保持不动\u2026", "s_nfc_tap_your_yubikey": "读取您的 YubiKey", + "s_nfc_remove_key": null, "l_nfc_failed_to_scan": "扫描失败,请重试", "@_usb": {}, diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 68bd6cae2..ef1b96ea7 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -42,6 +42,7 @@ "s_import": "導入", "s_overwrite": "覆寫", "s_done": "完成", + "s_success": null, "s_more": null, "s_label": "標籤", "s_name": "名稱", @@ -1070,6 +1071,7 @@ "s_nfc_ready_to_scan": "準備掃描", "s_nfc_hold_still": "保持不動\u2026", "s_nfc_tap_your_yubikey": "點選您的 YubiKey", + "s_nfc_remove_key": null, "l_nfc_failed_to_scan": "掃描失敗,請重試", "@_usb": {}, From fac34603db4666b71b9b43099b8b7797024084f5 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Mon, 25 Aug 2025 08:38:45 +0200 Subject: [PATCH 26/28] adjust generate key dialog for Android --- lib/android/piv/state.dart | 64 ++++----- lib/app/message.dart | 18 +++ lib/exception/platform_exception_decoder.dart | 7 + lib/l10n/app_cs.arb | 1 + lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 1 + lib/l10n/app_es.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_ja.arb | 1 + lib/l10n/app_pl.arb | 1 + lib/l10n/app_sk.arb | 1 + lib/l10n/app_sv.arb | 1 + lib/l10n/app_tr.arb | 1 + lib/l10n/app_vi.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/l10n/app_zh_TW.arb | 1 + lib/piv/views/generate_key_dialog.dart | 121 ++++++++++++++---- 17 files changed, 161 insertions(+), 62 deletions(-) diff --git a/lib/android/piv/state.dart b/lib/android/piv/state.dart index d26ab59c7..f9ecbcc4f 100644 --- a/lib/android/piv/state.dart +++ b/lib/android/piv/state.dart @@ -19,20 +19,24 @@ import 'dart:convert'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logging/logging.dart'; // TODO import 'package:logging/logging.dart'; +import '../../app/logging.dart'; import '../../app/models.dart'; // TODO import '../../app/state.dart'; // TODO import '../../app/views/user_interaction.dart'; import '../../core/models.dart'; +import '../../exception/cancellation_exception.dart'; import '../../exception/no_data_exception.dart'; +import '../../exception/platform_exception_decoder.dart'; import '../../piv/models.dart'; import '../../piv/state.dart'; import '../app_methods.dart'; import '../overlay/nfc/method_channel_notifier.dart' show MethodChannelNotifier; -// TODO final _log = Logger('android.piv.state'); +final _log = Logger('android.piv.state'); // final _managementKeyProvider = StateProvider.autoDispose // .family((ref, _) => null); @@ -398,25 +402,7 @@ class _AndroidPivSlotsNotifier extends PivSlotsNotifier { TouchPolicy touchPolicy = TouchPolicy.dfault, String? pin, }) async { - // final withContext = ref.watch(withContextProvider); - // - // final signaler = Signaler(); - // UserInteractionController? controller; try { - // signaler.signals.listen((signal) async { - // if (signal.status == 'touch') { - // controller = await withContext((context) async { - // final l10n = AppLocalizations.of(context); - // return promptUserInteraction( - // context, - // icon: const Icon(Symbols.touch_app), - // title: l10n.s_touch_required, - // description: l10n.l_touch_button_now, - // ); - // }); - // } - // }); - // await preserveConnectedDeviceWhenPaused(); final (type, subject, validFrom, validTo) = switch (parameters) { PivGeneratePublicKeyParameters() => ( @@ -446,35 +432,35 @@ class _AndroidPivSlotsNotifier extends PivSlotsNotifier { ), }; - //final pin = ref.read(_pinProvider(_session.devicePath)); - final result = jsonDecode( - await piv.invoke( - 'generate', - { - 'slot': slot.hexId, - 'keyType': keyType.value, - 'pinPolicy': pinPolicy.value, - 'touchPolicy': touchPolicy.value, - 'subject': subject, - 'generateType': type.name, - 'validFrom': validFrom, - 'validTo': validTo, - }, - //signal: signaler, - ), + await piv.invoke('generate', { + 'slot': slot.hexId, + 'keyType': keyType.value, + 'pinPolicy': pinPolicy.value, + 'touchPolicy': touchPolicy.value, + 'subject': subject, + 'generateType': type.name, + 'validFrom': validFrom, + 'validTo': validTo, + }), ); - ref.invalidateSelf(); + //ref.invalidateSelf(); return PivGenerateResult.fromJson({ 'generate_type': type.name, ...result, }); - } finally { - //controller?.close(); + } on PlatformException catch (pe) { + var decodedException = pe.decode(); + if (decodedException is CancellationException) { + _log.debug('User cancelled generate key PIV operation'); + } else { + _log.error('Generate key PIV operation failed.', pe); + } + + throw decodedException; } - //return PivGenerateResult.fromJson({}); } @override diff --git a/lib/app/message.dart b/lib/app/message.dart index 4034eea02..1ada34051 100755 --- a/lib/app/message.dart +++ b/lib/app/message.dart @@ -19,8 +19,26 @@ import 'dart:ui'; import 'package:flutter/material.dart'; +import '../desktop/models.dart'; +import '../exception/apdu_exception.dart'; +import '../exception/tag_lost_exception.dart'; import '../widgets/toast.dart'; +void Function() showExceptionMessage( + BuildContext context, + Exception e, { + Duration duration = const Duration(seconds: 2), +}) { + final message = e is RpcError + ? (e as RpcError).message + : e is ApduException + ? e.message + : e is TagLostException + ? e.message + : e.toString(); + return showToast(context, message, duration: duration); +} + void Function() showMessage( BuildContext context, String message, { diff --git a/lib/exception/platform_exception_decoder.dart b/lib/exception/platform_exception_decoder.dart index 0d11f2916..d67a55528 100644 --- a/lib/exception/platform_exception_decoder.dart +++ b/lib/exception/platform_exception_decoder.dart @@ -18,12 +18,15 @@ import 'package:flutter/services.dart'; import 'apdu_exception.dart'; import 'cancellation_exception.dart'; +import 'tag_lost_exception.dart'; extension Decoder on PlatformException { bool _isCancellation() => code == 'CancellationException'; bool _isApduException() => code == 'ApduException'; + bool _isTagLostException() => code == 'TagLostException'; + Exception decode() { if (_isCancellation()) { return CancellationException(); @@ -43,6 +46,10 @@ extension Decoder on PlatformException { } } + if (_isTagLostException()) { + return TagLostException('NFC communication issue', details); + } + // original exception return this; } diff --git a/lib/l10n/app_cs.arb b/lib/l10n/app_cs.arb index 9a63ee1c7..9fc602d3a 100644 --- a/lib/l10n/app_cs.arb +++ b/lib/l10n/app_cs.arb @@ -707,6 +707,7 @@ "p_warning_delete_certificate": "Varování! Tato akce odstraní certifikát z vašeho YubiKey.", "p_warning_delete_key": "Varování! Tato akce odstraní soukromý klíč z vašeho YubiKey.", "p_warning_delete_certificate_and_key": "Varování! Tato akce odstraní certifikát a soukromý klíč z vašeho YubiKey.", + "p_warning_usb_preferred": null, "p_delete_certificate_desc": "Tímto odstraníte certifikát v PIV slotu {slot}.", "@p_delete_certificate_desc": { "placeholders": { diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 633689b99..bd9de71ed 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -707,6 +707,7 @@ "p_warning_delete_certificate": "Achtung! Diese Aktion löscht das Zertifikat von deinem YubiKey.", "p_warning_delete_key": "Achtung! Diese Aktion löscht den privaten Schlüssel von deinem YubiKey.", "p_warning_delete_certificate_and_key": "Achtung! Diese Aktion löscht das Zertifikat und den privaten Schlüssel von deinem YubiKey.", + "p_warning_usb_preferred": null, "p_delete_certificate_desc": "Dadurch wird das Zertifikat im PIV-Slot {slot} gelöscht.", "@p_delete_certificate_desc": { "placeholders": { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index cd5cfa81d..62038296c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -707,6 +707,7 @@ "p_warning_delete_certificate": "Warning! This action will delete the certificate from your YubiKey.", "p_warning_delete_key": "Warning! This action will delete the private key from your YubiKey.", "p_warning_delete_certificate_and_key": "Warning! This action will delete the certificate and private key from your YubiKey.", + "p_warning_usb_preferred": "Prefer USB for RSA keys. NFC may result in errors.", "p_delete_certificate_desc": "This will delete the certificate in PIV slot {slot}.", "@p_delete_certificate_desc": { "placeholders": { diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index d55e1bf0b..2ef7a6ee2 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -707,6 +707,7 @@ "p_warning_delete_certificate": "¡Advertencia! Esta acción eliminará el certificado de tu YubiKey.", "p_warning_delete_key": "¡Advertencia! Esta acción borrará la clave privada de tu YubiKey.", "p_warning_delete_certificate_and_key": "¡Advertencia! Esta acción borrará el certificado y la clave privada de tu YubiKey.", + "p_warning_usb_preferred": null, "p_delete_certificate_desc": "Esto borrará el certificado en la ranura PIV {slot}.", "@p_delete_certificate_desc": { "placeholders": { diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index c512a4857..a3af77f88 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -707,6 +707,7 @@ "p_warning_delete_certificate": "Attention\u00a0! Cela supprimera le certificat de votre YubiKey.", "p_warning_delete_key": "Attention! Cette action supprimera la clé privée de votre YubiKey.", "p_warning_delete_certificate_and_key": "Attention! Cette action supprimera le certificat et la clé privée de votre YubiKey.", + "p_warning_usb_preferred": null, "p_delete_certificate_desc": "Cela supprimera le certificat dans l'emplacement PIV {slot}.", "@p_delete_certificate_desc": { "placeholders": { diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index af7d86467..74327c5ee 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -707,6 +707,7 @@ "p_warning_delete_certificate": "警告!YubiKey から証明書が削除されます。", "p_warning_delete_key": "警告!YubiKeyから秘密鍵を削除します。", "p_warning_delete_certificate_and_key": "警告!証明書と秘密鍵を YubiKey から削除します。", + "p_warning_usb_preferred": null, "p_delete_certificate_desc": "PIV スロット {slot} の証明書を削除します。", "@p_delete_certificate_desc": { "placeholders": { diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 8ff0327ea..241acb220 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -707,6 +707,7 @@ "p_warning_delete_certificate": "Ostrzeżenie! Spowoduje to usunięcie certyfikatu z klucza YubiKey.", "p_warning_delete_key": "Ostrzeżenie! Spowoduje to usunięcie klucza prywatnego z YubiKey.", "p_warning_delete_certificate_and_key": "Ostrzeżenie! Spowoduje to usunięcie certyfikatu i klucza prywatnego z YubiKey.", + "p_warning_usb_preferred": null, "p_delete_certificate_desc": "Spowoduje to usunięcie certyfikatu ze slotu PIV {slot}.", "@p_delete_certificate_desc": { "placeholders": { diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index f3d5fb91d..11b9a047f 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -707,6 +707,7 @@ "p_warning_delete_certificate": "Varovanie! Táto akcia vymaže certifikát z vášho YubiKey.", "p_warning_delete_key": "Varovanie! Táto akcia vymaže súkromný kľúč z vášho YubiKey.", "p_warning_delete_certificate_and_key": "Varovanie! Táto akcia vymaže certifikát a súkromný kľúč z vášho YubiKey.", + "p_warning_usb_preferred": null, "p_delete_certificate_desc": "Týmto sa odstráni certifikát v slote PIV {slot}.", "@p_delete_certificate_desc": { "placeholders": { diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index 5252c5917..beb7156f9 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -707,6 +707,7 @@ "p_warning_delete_certificate": "Varning för detta! Denna åtgärd kommer att radera certifikatet från YubiKey.", "p_warning_delete_key": "Varning för detta! Denna åtgärd raderar den privata nyckeln från din YubiKey.", "p_warning_delete_certificate_and_key": "Varning för detta! Denna åtgärd raderar certifikatet och den privata nyckeln från din YubiKey.", + "p_warning_usb_preferred": null, "p_delete_certificate_desc": "Detta kommer att radera certifikatet i PIV slot {slot}.", "@p_delete_certificate_desc": { "placeholders": { diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 67fda4dc0..e8ff2d019 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -707,6 +707,7 @@ "p_warning_delete_certificate": "Uyarı! Bu işlem sertifikayı YubiKey'inizden silecektir.", "p_warning_delete_key": "Uyarı! Bu işlem özel anahtarı YubiKey'inizden silecektir.", "p_warning_delete_certificate_and_key": "Uyarı! Bu işlem sertifikayı ve özel anahtarı YubiKey'inizden silecektir.", + "p_warning_usb_preferred": null, "p_delete_certificate_desc": "Bu işlem {slot} PIV yuvasındaki sertifikayı silecektir.", "@p_delete_certificate_desc": { "placeholders": { diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 53f43625b..3ee010289 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -707,6 +707,7 @@ "p_warning_delete_certificate": "Cảnh báo! Hành động này sẽ xóa chứng chỉ khỏi YubiKey của bạn.", "p_warning_delete_key": "Cảnh báo! Hành động này sẽ xóa khóa riêng khỏi YubiKey của bạn.", "p_warning_delete_certificate_and_key": "Cảnh báo! Hành động này sẽ xóa chứng chỉ và khóa riêng khỏi YubiKey của bạn.", + "p_warning_usb_preferred": null, "p_delete_certificate_desc": null, "@p_delete_certificate_desc": { "placeholders": { diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index ce0c3b0b6..ad4057dc5 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -707,6 +707,7 @@ "p_warning_delete_certificate": "警告!此操作将从您的 YubiKey 中删除证书。", "p_warning_delete_key": "警告!此操作将从您的 Yubikey 中删除私钥。", "p_warning_delete_certificate_and_key": "警告!此操作将从您的 YubiKey 中删除证书和私钥。", + "p_warning_usb_preferred": null, "p_delete_certificate_desc": "这将删除 PIV 槽位 {slot}中的证书。", "@p_delete_certificate_desc": { "placeholders": { diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index ef1b96ea7..500c99938 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -707,6 +707,7 @@ "p_warning_delete_certificate": "警告!此動作將從您的 YubiKey 中刪除帳戶。", "p_warning_delete_key": "警告!此動作將從您的 YubiKey 中刪除私人密鑰。", "p_warning_delete_certificate_and_key": "警告!此動作將從您的 YubiKey 中刪除憑證和私人密鑰。", + "p_warning_usb_preferred": null, "p_delete_certificate_desc": "這將刪除 PIV 插槽 {slot}中的憑證。", "@p_delete_certificate_desc": { "placeholders": { diff --git a/lib/piv/views/generate_key_dialog.dart b/lib/piv/views/generate_key_dialog.dart index 079701446..284769c36 100644 --- a/lib/piv/views/generate_key_dialog.dart +++ b/lib/piv/views/generate_key_dialog.dart @@ -14,8 +14,9 @@ * limitations under the License. */ -import 'package:flutter/material.dart'; +import 'dart:io'; +import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -23,6 +24,7 @@ import '../../app/message.dart'; import '../../app/models.dart'; import '../../app/state.dart'; import '../../core/models.dart'; +import '../../exception/cancellation_exception.dart'; import '../../generated/l10n/app_localizations.dart'; import '../../widgets/app_input_decoration.dart'; import '../../widgets/app_text_field.dart'; @@ -40,6 +42,7 @@ class GenerateKeyDialog extends ConsumerStatefulWidget { final PivState pivState; final PivSlot pivSlot; final bool showMatch; + GenerateKeyDialog(this.devicePath, this.pivState, this.pivSlot, {super.key}) : showMatch = pivSlot.slot != SlotId.cardAuth && pivState.supportsBio; @@ -59,6 +62,7 @@ class _GenerateKeyDialogState extends ConsumerState { late DateTime _validToMax; late bool _allowMatch; bool _generating = false; + final _subjectFocus = FocusNode(); @override void initState() { @@ -73,6 +77,12 @@ class _GenerateKeyDialogState extends ConsumerState { _allowMatch = widget.showMatch; } + @override + void dispose() { + _subjectFocus.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); @@ -121,29 +131,40 @@ class _GenerateKeyDialogState extends ConsumerState { return; } - final result = await pivNotifier.generate( - widget.pivSlot.slot, - _keyType, - pinPolicy: getPinPolicy(widget.pivSlot.slot, _allowMatch), - parameters: switch (_generateType) { - GenerateType.publicKey => - PivGenerateParameters.publicKey(), - GenerateType.certificate => - PivGenerateParameters.certificate( + try { + final result = await pivNotifier.generate( + widget.pivSlot.slot, + _keyType, + pinPolicy: getPinPolicy(widget.pivSlot.slot, _allowMatch), + parameters: switch (_generateType) { + GenerateType.publicKey => + PivGenerateParameters.publicKey(), + GenerateType.certificate => + PivGenerateParameters.certificate( + subject: _subject, + validFrom: _validFrom, + validTo: _validTo, + ), + GenerateType.csr => PivGenerateParameters.csr( subject: _subject, - validFrom: _validFrom, - validTo: _validTo, ), - GenerateType.csr => PivGenerateParameters.csr( - subject: _subject, - ), - }, - ); + }, + ); - await ref.read(withContextProvider)((context) async { - Navigator.of(context).pop(result); - showMessage(context, l10n.s_private_key_generated); - }); + await ref.read(withContextProvider)((context) async { + Navigator.of(context).pop(result); + showMessage(context, l10n.s_private_key_generated); + }); + } on Exception catch (e) { + setState(() { + _generating = false; + }); + if (e is! CancellationException) { + await ref.read(withContextProvider)((context) async { + showExceptionMessage(context, e); + }); + } + } } : null, child: Text(l10n.s_save), @@ -162,6 +183,7 @@ class _GenerateKeyDialogState extends ConsumerState { ), AppTextField( autofocus: true, + focusNode: _subjectFocus, key: keys.subjectField, decoration: AppInputDecoration( border: const OutlineInputBorder(), @@ -237,7 +259,7 @@ class _GenerateKeyDialogState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.symmetric(vertical: 4.0), + padding: const EdgeInsets.fromLTRB(0, 12.0, 0.0, 4.0), child: Icon( Symbols.tune, color: colorScheme.onSurfaceVariant, @@ -248,7 +270,7 @@ class _GenerateKeyDialogState extends ConsumerState { child: Wrap( crossAxisAlignment: WrapCrossAlignment.start, spacing: 4.0, - runSpacing: 8.0, + runSpacing: 0.0, children: [ ChoiceFilterChip( tooltip: l10n.s_algorithm, @@ -264,6 +286,7 @@ class _GenerateKeyDialogState extends ConsumerState { onChanged: _generating ? null : (value) { + _subjectFocus.unfocus(); setState(() { _keyType = value; if (value == KeyType.x25519) { @@ -362,6 +385,58 @@ class _GenerateKeyDialogState extends ConsumerState { ), ), ), + SizedBox(width: double.infinity), // wrap! + if (Platform.isAndroid && + ref + .watch(attachedDevicesProvider) + .firstOrNull + ?.transport != + Transport.usb && + [ + KeyType.rsa1024, + KeyType.rsa2048, + KeyType.rsa3072, + KeyType.rsa4096, + ].contains(_keyType)) + Padding( + padding: const EdgeInsets.fromLTRB( + 4, + 0, + 0, + 0, + ), + child: RichText( + text: TextSpan( + children: [ + WidgetSpan( + alignment: + PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsets.fromLTRB( + 0, + 0, + 4, + 0, + ), + child: Icon( + Symbols.warning_amber_rounded, + color: + colorScheme.onSurfaceVariant, + size: 14, + ), + ), + ), + TextSpan( + style: theme.textTheme.bodySmall + ?.copyWith( + fontWeight: FontWeight.bold, + ), + text: l10n.p_warning_usb_preferred, + ), + ], + ), + ), + ), ], ), ), From 25b6c21edd94557bdc0b8a9582c892f435ec16a4 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Mon, 25 Aug 2025 09:00:11 +0200 Subject: [PATCH 27/28] support cancellations, add forgotten file --- lib/android/piv/state.dart | 56 ++++++++++++++++----------- lib/exception/tag_lost_exception.dart | 27 +++++++++++++ lib/piv/views/actions.dart | 15 +++++-- lib/piv/views/import_file_dialog.dart | 12 +++--- 4 files changed, 80 insertions(+), 30 deletions(-) create mode 100644 lib/exception/tag_lost_exception.dart diff --git a/lib/android/piv/state.dart b/lib/android/piv/state.dart index f9ecbcc4f..8a16c6791 100644 --- a/lib/android/piv/state.dart +++ b/lib/android/piv/state.dart @@ -369,12 +369,16 @@ class _AndroidPivSlotsNotifier extends PivSlotsNotifier { @override Future delete(SlotId slot, bool deleteCert, bool deleteKey) async { - await piv.invoke('delete', { - 'slot': slot.hexId, - 'deleteCert': deleteCert, - 'deleteKey': deleteKey, - }); - ref.invalidateSelf(); + try { + await piv.invoke('delete', { + 'slot': slot.hexId, + 'deleteCert': deleteCert, + 'deleteKey': deleteKey, + }); + ref.invalidateSelf(); + } on PlatformException catch (pe) { + throw pe.decode(); + } } @override @@ -384,13 +388,17 @@ class _AndroidPivSlotsNotifier extends PivSlotsNotifier { bool overwriteKey, bool includeCertificate, ) async { - await piv.invoke('moveKey', { - 'slot': source.hexId, - 'destination': destination.hexId, - 'overwriteKey': overwriteKey, - 'includeCertificate': includeCertificate, - }); - ref.invalidateSelf(); + try { + await piv.invoke('moveKey', { + 'slot': source.hexId, + 'destination': destination.hexId, + 'overwriteKey': overwriteKey, + 'includeCertificate': includeCertificate, + }); + ref.invalidateSelf(); + } on PlatformException catch (pe) { + throw pe.decode(); + } } @override @@ -516,15 +524,19 @@ class _AndroidPivSlotsNotifier extends PivSlotsNotifier { @override Future<(SlotMetadata?, String?)> read(SlotId slot) async { - await preserveConnectedDeviceWhenPaused(); - final result = jsonDecode( - await piv.invoke('getSlot', {'slot': slot.hexId}), - ); - final metadata = result['metadata']; - return ( - metadata != null ? SlotMetadata.fromJson(metadata) : null, - result['certificate'] as String?, - ); + try { + await preserveConnectedDeviceWhenPaused(); + final result = jsonDecode( + await piv.invoke('getSlot', {'slot': slot.hexId}), + ); + final metadata = result['metadata']; + return ( + metadata != null ? SlotMetadata.fromJson(metadata) : null, + result['certificate'] as String?, + ); + } on PlatformException catch (pe) { + throw pe.decode(); + } } } diff --git a/lib/exception/tag_lost_exception.dart b/lib/exception/tag_lost_exception.dart new file mode 100644 index 000000000..efbd55027 --- /dev/null +++ b/lib/exception/tag_lost_exception.dart @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2023 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +class TagLostException implements Exception { + final String message; + final String? details; + + TagLostException(this.message, this.details); + + @override + String toString() { + return 'ApduException[$message]'; + } +} diff --git a/lib/piv/views/actions.dart b/lib/piv/views/actions.dart index 9aceb0a65..dd90642ac 100644 --- a/lib/piv/views/actions.dart +++ b/lib/piv/views/actions.dart @@ -30,6 +30,7 @@ import '../../app/models.dart'; import '../../app/shortcuts.dart'; import '../../app/state.dart'; import '../../core/state.dart'; +import '../../exception/cancellation_exception.dart'; import '../../generated/l10n/app_localizations.dart'; import '../features.dart' as features; import '../keys.dart' as keys; @@ -248,9 +249,17 @@ class PivActions extends ConsumerWidget { ExportIntent: CallbackAction( onInvoke: (intent) async { final l10n = AppLocalizations.of(context); - final (metadata, cert) = await ref - .read(pivSlotsProvider(devicePath).notifier) - .read(intent.slot.slot); + + SlotMetadata? metadata; + String? cert; + + try { + (metadata, cert) = await ref + .read(pivSlotsProvider(devicePath).notifier) + .read(intent.slot.slot); + } on CancellationException catch (_) { + return false; + } String title; String message; diff --git a/lib/piv/views/import_file_dialog.dart b/lib/piv/views/import_file_dialog.dart index b758d69b7..ef266b434 100644 --- a/lib/piv/views/import_file_dialog.dart +++ b/lib/piv/views/import_file_dialog.dart @@ -265,11 +265,13 @@ class _ImportFileDialogState extends ConsumerState { void Function()? close; try { close = await withContext( - (context) async => showMessage( - context, - l10n.l_importing_file, - duration: const Duration(seconds: 30), - ), + (context) async => !Platform.isAndroid + ? showMessage( + context, + l10n.l_importing_file, + duration: const Duration(seconds: 30), + ) + : () {}, ); await ref .read( From bbd579af9ca28fffb5f432a8d3247a12cd8f02cc Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Fri, 29 Aug 2025 17:56:32 +0200 Subject: [PATCH 28/28] fix authentication for key generation --- .../yubico/authenticator/piv/PivManager.kt | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt index 2d1eeddc9..504ad8b25 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/piv/PivManager.kt @@ -163,8 +163,8 @@ class PivManager( "generate" -> generate( Slot.fromStringAlias(args["slot"] as String), (args["keyType"] as Int), - (args["pinPolicy"] as Int), - (args["touchPolicy"] as Int), + PinPolicy.fromValue(args["pinPolicy"] as Int), + TouchPolicy.fromValue(args["touchPolicy"] as Int), (args["subject"] as String?), (args["generateType"] as String), (args["validFrom"] as String?), @@ -175,8 +175,8 @@ class PivManager( Slot.fromStringAlias(args["slot"] as String), (args["data"] as String), (args["password"] as String?), - (args["pinPolicy"] as Int), - (args["touchPolicy"] as Int), + PinPolicy.fromValue(args["pinPolicy"] as Int), + TouchPolicy.fromValue(args["touchPolicy"] as Int) ) "getSlot" -> getSlot( @@ -741,8 +741,8 @@ class PivManager( private suspend fun generate( slot: Slot, keyType: Int, - pinPolicy: Int, - touchPolicy: Int, + pinPolicy: PinPolicy, + touchPolicy: TouchPolicy, subject: String?, generateType: String, validFrom: String?, @@ -757,16 +757,19 @@ class PivManager( KeyType.entries.first { entry -> entry.value.toUByte().toInt() == keyType } val serial = pivViewModel.currentSerial() - doVerifyPin(piv, serial) doAuthenticate(piv, serial) val keyValues = piv.generateKeyValues( slot, keyTypeValue, - PinPolicy.fromValue(pinPolicy), - TouchPolicy.fromValue(touchPolicy) + pinPolicy, + touchPolicy ) + if (pinPolicy != PinPolicy.NEVER) { + doVerifyPin(piv, serial) + } + val publicKey = keyValues.toPublicKey() val publicKeyPem = publicKey.toPem() @@ -839,8 +842,8 @@ class PivManager( slot: Slot, data: String, password: String?, - pinPolicy: Int, - touchPolicy: Int + pinPolicy: PinPolicy, + touchPolicy: TouchPolicy ): String = connectionHelper.useSmartCardConnection(::update) { try { @@ -851,8 +854,6 @@ class PivManager( doAuthenticate(piv, serial) val (certificates, privateKey) = parseFile(data, password) - // TODO catch invalid password exception - if (privateKey == null && certificates.isEmpty()) { throw IllegalArgumentException("Failed to parse") } @@ -862,8 +863,8 @@ class PivManager( piv.putKey( slot, PrivateKeyValues.fromPrivateKey(privateKey), - PinPolicy.fromValue(pinPolicy), - TouchPolicy.fromValue(touchPolicy) + pinPolicy, + touchPolicy ) metadata = try {