Merge branch 'main' into adamve/fix/android_bitesize

This commit is contained in:
Adam Velebil 2023-08-02 16:10:45 +02:00
commit 4f866a89ab
No known key found for this signature in database
GPG Key ID: C9B1E4A3CBBD2E10
105 changed files with 9763 additions and 1899 deletions

View File

@ -17,7 +17,7 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: 'stable'
flutter-version: '3.10.1'
flutter-version: '3.10.6'
- run: |
flutter config
flutter --version

View File

@ -7,7 +7,7 @@ jobs:
runs-on: ubuntu-latest
env:
FLUTTER: '3.10.1'
FLUTTER: '3.10.6'
steps:
- uses: actions/checkout@v3

View File

@ -43,7 +43,7 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: 'stable'
flutter-version: '3.10.1'
flutter-version: '3.10.6'
- run: |
flutter config
flutter --version

View File

@ -7,10 +7,10 @@ jobs:
runs-on: ubuntu-latest
env:
PYVER: '3.11.3'
FLUTTER: '3.10.1'
PYVER: '3.11.4'
FLUTTER: '3.10.6'
container:
image: ubuntu:18.04
image: ubuntu:20.04
env:
DEBIAN_FRONTEND: noninteractive
@ -20,7 +20,7 @@ jobs:
export PYVER_MINOR=${PYVER%.*}
echo "PYVER_MINOR: $PYVER_MINOR"
apt-get update
apt-get install -qq software-properties-common libnotify-dev libayatana-appindicator3-dev patchelf
apt-get install -qq curl software-properties-common libnotify-dev libayatana-appindicator3-dev patchelf
add-apt-repository -y ppa:git-core/ppa
add-apt-repository -y ppa:deadsnakes/ppa
apt-get install -qq git python$PYVER_MINOR-dev python$PYVER_MINOR-venv
@ -61,7 +61,9 @@ jobs:
apt-get install -qq swig libpcsclite-dev build-essential cmake
python -m ensurepip --user
python -m pip install -U pip pipx
pipx ensurepath
# pipx ensurepath
echo "export PATH=$PATH:$HOME/.local/bin" >> ~/.bashrc
. ~/.bashrc # Needed to ensure poetry on PATH
pipx install poetry
- name: Build the Helper

View File

@ -7,7 +7,7 @@ jobs:
runs-on: macos-latest
env:
PYVER: '3.11.3'
PYVER: '3.11.4'
MACOSX_DEPLOYMENT_TARGET: "10.15"
steps:
@ -49,7 +49,7 @@ jobs:
with:
channel: 'stable'
architecture: 'x64'
flutter-version: '3.10.1'
flutter-version: '3.10.6'
- run: flutter config --enable-macos-desktop
- run: flutter --version

View File

@ -7,7 +7,7 @@ jobs:
runs-on: windows-latest
env:
PYVER: '3.11.3'
PYVER: '3.11.4'
steps:
- uses: actions/checkout@v3
@ -45,7 +45,7 @@ jobs:
- uses: subosito/flutter-action@v2
with:
channel: 'stable'
flutter-version: '3.10.1'
flutter-version: '3.10.6'
- run: flutter config --enable-windows-desktop
- run: flutter --version

View File

@ -100,11 +100,13 @@ dependencies {
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'
implementation "androidx.core:core-ktx:1.10.1"
implementation 'androidx.fragment:fragment-ktx:1.5.7'
implementation 'androidx.fragment:fragment-ktx:1.6.0'
implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'com.google.android.material:material:1.9.0'
implementation 'com.github.tony19:logback-android:3.0.0'
// testing dependencies
testImplementation "junit:junit:$project.junitVersion"
testImplementation "org.mockito:mockito-core:$project.mockitoVersion"

View File

@ -0,0 +1,31 @@
<!--
~ 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.
-->
<configuration xmlns="https://tony19.github.io/logback-android/xml"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://tony19.github.io/logback-android/xml https://cdn.jsdelivr.net/gh/tony19/logback-android/logback.xsd" >
<appender name="logcat" class="ch.qos.logback.classic.android.LogcatAppender">
<encoder>
<pattern>%msg</pattern>
</encoder>
</appender>
<!-- Write TRACE (and higher-level) messages to logcat -->
<root level="TRACE">
<appender-ref ref="logcat" />
</root>
</configuration>

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 Yubico.
* Copyright (C) 2022-2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -16,14 +16,18 @@
package com.yubico.authenticator
import com.yubico.authenticator.logging.Log
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.CoroutineScope
import org.slf4j.LoggerFactory
class AppContext(messenger: BinaryMessenger, coroutineScope: CoroutineScope, private val appViewModel: MainViewModel) {
private val channel = MethodChannel(messenger, "android.state.appContext")
private val logger = LoggerFactory.getLogger(AppContext::class.java)
init {
channel.setHandler(coroutineScope) { method, args ->
when (method) {
@ -36,11 +40,7 @@ class AppContext(messenger: BinaryMessenger, coroutineScope: CoroutineScope, pri
private suspend fun setContext(subPageIndex: Int): String {
val appContext = OperationContext.getByValue(subPageIndex)
appViewModel.setAppContext(appContext)
Log.d(TAG, "App context is now $appContext")
logger.debug("App context is now {}", appContext)
return NULL
}
companion object {
const val TAG = "appContext"
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 Yubico.
* Copyright (C) 2022-2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -19,7 +19,8 @@ package com.yubico.authenticator
import android.content.Context
import android.content.SharedPreferences
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import com.yubico.authenticator.logging.Log
import org.slf4j.LoggerFactory
class AppPreferences(context: Context) {
companion object {
@ -32,15 +33,15 @@ class AppPreferences(context: Context) {
const val PREF_CLIP_KBD_LAYOUT = "flutter.prefClipKbdLayout"
const val DEFAULT_CLIP_KBD_LAYOUT = "US"
const val TAG = "AppPreferences"
}
private val logger = LoggerFactory.getLogger(AppPreferences::class.java)
private val prefs: SharedPreferences =
context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE).also {
Log.d(TAG, "Current app preferences:")
logger.debug("Current app preferences:")
it.all.map { preference ->
Log.d(TAG, "${preference.key}: ${preference.value}")
logger.debug("{}: {}", preference.key, preference.value)
}
}
@ -66,12 +67,12 @@ class AppPreferences(context: Context) {
get() = prefs.getBoolean(PREF_USB_OPEN_APP, false)
fun registerListener(listener: OnSharedPreferenceChangeListener) {
Log.d(TAG, "registering change listener")
logger.debug("registering change listener")
prefs.registerOnSharedPreferenceChangeListener(listener)
}
fun unregisterListener(listener: OnSharedPreferenceChangeListener) {
prefs.unregisterOnSharedPreferenceChangeListener(listener)
Log.d(TAG, "unregistered change listener")
logger.debug("unregistered change listener")
}
}

View File

@ -23,11 +23,12 @@ import android.content.ClipboardManager
import android.content.Context
import android.os.Build
import android.os.PersistableBundle
import com.yubico.authenticator.logging.Log
import org.slf4j.LoggerFactory
object ClipboardUtil {
private const val TAG = "ClipboardUtil"
private val logger = LoggerFactory.getLogger(ClipboardUtil::class.java)
fun setPrimaryClip(context: Context, toClipboard: String, isSensitive: Boolean) {
try {
@ -41,7 +42,7 @@ object ClipboardUtil {
clipboardManager.setPrimaryClip(clipData)
} catch (e: Exception) {
Log.e(TAG, "Failed to set string to clipboard", e.stackTraceToString())
logger.error( "Failed to set string to clipboard", e)
throw UnsupportedOperationException()
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 Yubico.
* Copyright (C) 2022-2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -91,8 +91,4 @@ class DialogManager(messenger: BinaryMessenger, private val coroutineScope: Coro
}
return NULL
}
companion object {
const val TAG = "dialogManager"
}
}

View File

@ -39,7 +39,6 @@ import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.lifecycle.lifecycleScope
import com.yubico.authenticator.logging.FlutterLog
import com.yubico.authenticator.logging.Log
import com.yubico.authenticator.oath.AppLinkMethodChannel
import com.yubico.authenticator.oath.OathManager
import com.yubico.authenticator.oath.OathViewModel
@ -48,7 +47,6 @@ import com.yubico.yubikit.android.transport.nfc.NfcConfiguration
import com.yubico.yubikit.android.transport.nfc.NfcNotAvailable
import com.yubico.yubikit.android.transport.nfc.NfcYubiKeyDevice
import com.yubico.yubikit.android.transport.usb.UsbConfiguration
import com.yubico.yubikit.core.Logger
import com.yubico.yubikit.core.YubiKeyDevice
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
@ -56,6 +54,7 @@ import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.launch
import org.json.JSONObject
import org.slf4j.LoggerFactory
import java.io.Closeable
import java.util.concurrent.Executors
@ -73,6 +72,8 @@ class MainActivity : FlutterFragmentActivity() {
private val qrScannerCameraClosedBR = QRScannerCameraClosedBR()
private val nfcAdapterStateChangeBR = NfcAdapterStateChangedBR()
private val logger = LoggerFactory.getLogger(MainActivity::class.java)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -85,8 +86,6 @@ class MainActivity : FlutterFragmentActivity() {
allowScreenshots(false)
yubikit = YubiKitManager(this)
setupYubiKitLogger()
}
/**
@ -117,7 +116,7 @@ class MainActivity : FlutterFragmentActivity() {
private fun startNfcDiscovery() =
try {
Log.d(TAG, "Starting nfc discovery")
logger.debug("Starting nfc discovery")
yubikit.startNfcDiscovery(
nfcConfiguration.disableNfcDiscoverySound(appPreferences.silenceNfcSounds),
this,
@ -131,16 +130,16 @@ class MainActivity : FlutterFragmentActivity() {
private fun stopNfcDiscovery() {
if (hasNfc) {
yubikit.stopNfcDiscovery(this)
Log.d(TAG, "Stopped nfc discovery")
logger.debug("Stopped nfc discovery")
}
}
private fun startUsbDiscovery() {
Log.d(TAG, "Starting usb discovery")
logger.debug("Starting usb discovery")
val usbConfiguration = UsbConfiguration().handlePermissions(true)
yubikit.startUsbDiscovery(usbConfiguration) { device ->
viewModel.setConnectedYubiKey(device) {
Log.d(TAG, "YubiKey was disconnected, stopping usb discovery")
logger.debug("YubiKey was disconnected, stopping usb discovery")
stopUsbDiscovery()
}
processYubiKey(device)
@ -149,22 +148,7 @@ class MainActivity : FlutterFragmentActivity() {
private fun stopUsbDiscovery() {
yubikit.stopUsbDiscovery()
Log.d(TAG, "Stopped usb discovery")
}
private fun setupYubiKitLogger() {
Logger.setLogger(object : Logger() {
private val TAG = "yubikit"
override fun logDebug(message: String) {
// redirect yubikit debug logs to traffic
Log.t(TAG, message)
}
override fun logError(message: String, throwable: Throwable) {
Log.e(TAG, message, throwable.message ?: throwable.toString())
}
})
logger.debug("Stopped usb discovery")
}
@SuppressLint("WrongConstant")
@ -229,7 +213,7 @@ class MainActivity : FlutterFragmentActivity() {
startNfcDiscovery()
}
} catch (e: Throwable) {
Log.e(TAG, "Error processing YubiKey in AppContextManager", e.toString())
logger.error("Error processing YubiKey in AppContextManager", e)
}
}
} else {
@ -279,7 +263,7 @@ class MainActivity : FlutterFragmentActivity() {
try {
it.processYubiKey(device)
} catch (e: Throwable) {
Log.e(TAG, "Error processing YubiKey in AppContextManager", e.toString())
logger.error("Error processing YubiKey in AppContextManager", e)
}
}
}
@ -335,7 +319,6 @@ class MainActivity : FlutterFragmentActivity() {
}
companion object {
const val TAG = "MainActivity"
const val YUBICO_VENDOR_ID = 4176
const val FLAG_SECURE = WindowManager.LayoutParams.FLAG_SECURE
}
@ -363,6 +346,9 @@ class MainActivity : FlutterFragmentActivity() {
}
class NfcAdapterStateChangedBR : BroadcastReceiver() {
private val logger = LoggerFactory.getLogger(NfcAdapterStateChangedBR::class.java)
companion object {
val intentFilter = IntentFilter("android.nfc.action.ADAPTER_STATE_CHANGED")
}
@ -370,7 +356,7 @@ class MainActivity : FlutterFragmentActivity() {
override fun onReceive(context: Context?, intent: Intent?) {
intent?.let {
val state = it.getIntExtra("android.nfc.extra.ADAPTER_STATE", 0)
Log.d(TAG, "NfcAdapter state changed to $state")
logger.debug("NfcAdapter state changed to {}", state)
if (state == STATE_ON || state == STATE_TURNING_OFF) {
(context as? MainActivity)?.appMethodChannel?.nfcAdapterStateChanged(state == STATE_ON)
}
@ -430,7 +416,7 @@ class MainActivity : FlutterFragmentActivity() {
startActivity(Intent(ACTION_NFC_SETTINGS))
result.success(true)
}
else -> Log.w(TAG, "Unknown app method: ${methodCall.method}")
else -> logger.warn("Unknown app method: {}", methodCall.method)
}
}
}
@ -446,10 +432,10 @@ class MainActivity : FlutterFragmentActivity() {
private fun allowScreenshots(value: Boolean): Boolean {
// Note that FLAG_SECURE is the inverse of allowScreenshots
if (value) {
Log.d(TAG, "Clearing FLAG_SECURE (allow screenshots)")
logger.debug("Clearing FLAG_SECURE (allow screenshots)")
window.clearFlags(FLAG_SECURE)
} else {
Log.d(TAG, "Setting FLAG_SECURE (disallow screenshots)")
logger.debug("Setting FLAG_SECURE (disallow screenshots)")
window.setFlags(FLAG_SECURE, FLAG_SECURE)
}

View File

@ -24,9 +24,12 @@ import android.nfc.Tag
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import com.yubico.authenticator.logging.Log
import com.yubico.authenticator.ndef.KeyboardLayout
import com.yubico.yubikit.core.util.NdefUtils
import org.slf4j.LoggerFactory
import java.nio.charset.StandardCharsets
typealias ResourceId = Int
@ -34,6 +37,8 @@ typealias ResourceId = Int
class NdefActivity : Activity() {
private lateinit var appPreferences: AppPreferences
private val logger = LoggerFactory.getLogger(NdefActivity::class.java)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
appPreferences = AppPreferences(this)
@ -68,10 +73,9 @@ class NdefActivity : Activity() {
}
} catch (illegalArgumentException: IllegalArgumentException) {
Log.e(
TAG,
logger.error(
illegalArgumentException.message ?: "Failure when handling YubiKey OTP",
illegalArgumentException.stackTraceToString()
illegalArgumentException
)
showToast(R.string.otp_parse_failure, Toast.LENGTH_LONG)
} catch (_: UnsupportedOperationException) {
@ -111,10 +115,6 @@ class NdefActivity : Activity() {
}
}
companion object {
const val TAG = "YubicoAuthenticatorOTPActivity"
}
enum class OtpType {
Otp, Password
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 Yubico.
* Copyright (C) 2022-2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -16,11 +16,16 @@
package com.yubico.authenticator.logging
import android.util.Log
import ch.qos.logback.classic.Level
import com.yubico.authenticator.BuildConfig
import org.slf4j.Logger
import org.slf4j.LoggerFactory
object Log {
private val logger = LoggerFactory.getLogger("com.yubico.authenticator")
enum class LogLevel {
TRAFFIC,
DEBUG,
@ -42,34 +47,10 @@ object Log {
LogLevel.INFO
}
private const val TAG = "yubico-authenticator"
@Suppress("unused")
fun t(tag: String, message: String, error: String? = null) {
log(LogLevel.TRAFFIC, tag, message, error)
init {
setLevel(level)
}
@Suppress("unused")
fun d(tag: String, message: String, error: String? = null) {
log(LogLevel.DEBUG, tag, message, error)
}
@Suppress("unused")
fun i(tag: String, message: String, error: String? = null) {
log(LogLevel.INFO, tag, message, error)
}
@Suppress("unused")
fun w(tag: String, message: String, error: String? = null) {
log(LogLevel.WARNING, tag, message, error)
}
@Suppress("unused")
fun e(tag: String, message: String, error: String? = null) {
log(LogLevel.ERROR, tag, message, error)
}
@Suppress("unused")
fun log(level: LogLevel, loggerName: String, message: String, error: String?) {
if (level < this.level) {
return
@ -79,27 +60,33 @@ object Log {
buffer.removeAt(0)
}
val logMessage = "[$loggerName] ${level.name}: $message".also {
val logMessage = (if (error == null)
"[$loggerName] ${level.name}: $message"
else
"[$loggerName] ${level.name}: $message (err: $error)"
).also {
buffer.add(it)
}
when (level) {
LogLevel.TRAFFIC -> Log.v(TAG, logMessage)
LogLevel.DEBUG -> Log.d(TAG, logMessage)
LogLevel.INFO -> Log.i(TAG, logMessage)
LogLevel.WARNING -> Log.w(TAG, logMessage)
LogLevel.ERROR -> Log.e(TAG, logMessage)
}
error?.let {
Log.e(TAG, "[$loggerName] ${level.name}(details): $error".also {
buffer.add(it)
})
LogLevel.TRAFFIC -> logger.trace(logMessage)
LogLevel.DEBUG -> logger.debug(logMessage)
LogLevel.INFO -> logger.info(logMessage)
LogLevel.WARNING -> logger.warn(logMessage)
LogLevel.ERROR -> logger.error(logMessage)
}
}
@Suppress("unused")
fun setLevel(newLevel: LogLevel) {
level = newLevel
val root = LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME) as ch.qos.logback.classic.Logger
root.level = when (newLevel) {
LogLevel.TRAFFIC -> Level.TRACE
LogLevel.DEBUG -> Level.DEBUG
LogLevel.INFO -> Level.INFO
LogLevel.WARNING -> Level.WARN
LogLevel.ERROR -> Level.ERROR
}
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 Yubico.
* Copyright (C) 2022-2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -17,25 +17,26 @@
package com.yubico.authenticator.oath
import android.net.Uri
import androidx.annotation.UiThread
import com.yubico.authenticator.logging.Log
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
import org.json.JSONObject
import org.slf4j.LoggerFactory
class AppLinkMethodChannel(messenger: BinaryMessenger) {
private val methodChannel = MethodChannel(messenger, "app.link.methods")
private val logger = LoggerFactory.getLogger(AppLinkMethodChannel::class.java)
@UiThread
fun handleUri(uri: Uri) {
Log.t(TAG, "Handling URI: $uri")
logger.trace("Handling URI: {}", uri)
methodChannel.invokeMethod(
"handleOtpAuthLink",
JSONObject(mapOf("link" to uri.toString())).toString()
)
}
companion object {
const val TAG = "AppLinkMethodChannel"
}
}

View File

@ -61,6 +61,7 @@ import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.*
import kotlinx.serialization.encodeToString
import org.slf4j.LoggerFactory
import java.net.URI
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
@ -77,7 +78,6 @@ class OathManager(
private val appPreferences: AppPreferences,
) : AppContextManager {
companion object {
const val TAG = "OathManager"
const val NFC_DATA_CLEANUP_DELAY = 30L * 1000 // 30s
val OTP_AID = byteArrayOf(0xa0.toByte(), 0x00, 0x00, 0x05, 0x27, 0x20, 0x01, 0x01)
}
@ -98,6 +98,8 @@ class OathManager(
)
}
private val logger = LoggerFactory.getLogger(OathManager::class.java)
@TargetApi(Build.VERSION_CODES.M)
private fun createKeyStoreProviderM(): KeyProvider = KeyStoreProvider()
@ -117,7 +119,7 @@ class OathManager(
// cancel any pending actions, except for addToAny
if (!addToAny) {
pendingAction?.let {
Log.d(TAG, "Cancelling pending action/closing nfc dialog.")
logger.debug("Cancelling pending action/closing nfc dialog.")
it.invoke(Result.failure(CancellationException()))
coroutineScope.launch {
dialogManager.closeDialog()
@ -134,7 +136,7 @@ class OathManager(
if (canInvoke) {
if (appViewModel.connectedYubiKey.value == null) {
// no USB YubiKey is connected, reset known data on resume
Log.d(TAG, "Removing NFC data after resume.")
logger.debug("Removing NFC data after resume.")
appViewModel.setDeviceInfo(null)
oathViewModel.setSessionState(null)
}
@ -169,7 +171,7 @@ class OathManager(
refreshJob = coroutineScope.launch {
val delayMs = earliest - now
Log.d(TAG, "Will execute refresh in ${delayMs}ms")
logger.debug("Will execute refresh in {}ms", delayMs)
if (delayMs > 0) {
delay(delayMs)
}
@ -177,9 +179,9 @@ class OathManager(
if (currentState.isAtLeast(Lifecycle.State.RESUMED)) {
requestRefresh()
} else {
Log.d(
TAG,
"Cannot run credential refresh in current lifecycle state: $currentState"
logger.debug(
"Cannot run credential refresh in current lifecycle state: {}",
currentState
)
}
}
@ -257,7 +259,7 @@ class OathManager(
try {
oathViewModel.updateCredentials(calculateOathCodes(session))
} catch (error: Exception) {
Log.e(TAG, "Failed to refresh codes", error.toString())
logger.error("Failed to refresh codes", error)
}
}
} else {
@ -296,7 +298,7 @@ class OathManager(
try {
SmartCardProtocol(connection).select(OTP_AID)
} catch (e: Exception) {
Log.e(TAG, "Failed to recognize this OATH device.")
logger.error("Failed to recognize this OATH device.")
// we know this is NFC device and it supports OATH
val oathCapabilities = Capabilities(nfc = 0x20)
appViewModel.setDeviceInfo(
@ -325,25 +327,24 @@ class OathManager(
}
}
Log.d(
TAG,
logger.debug(
"Successfully read Oath session info (and credentials if unlocked) from connected key"
)
} catch (e: Exception) {
// OATH not enabled/supported, try to get DeviceInfo over other USB interfaces
Log.e(TAG, "Failed to connect to CCID", e.toString())
logger.error("Failed to connect to CCID", e)
if (device.transport == Transport.USB || e is ApplicationNotAvailableException) {
val deviceInfo = try {
getDeviceInfo(device)
} catch (e: IllegalArgumentException) {
Log.d(TAG, "Device was not recognized")
logger.debug("Device was not recognized")
UnknownDevice.copy(isNfc = device.transport == Transport.NFC)
} catch (e: Exception) {
Log.d(TAG, "Failure getting device info: ${e.message}")
logger.error("Failure getting device info", e)
null
}
Log.d(TAG, "Setting device info: $deviceInfo")
logger.debug("Setting device info: {}", deviceInfo)
appViewModel.setDeviceInfo(deviceInfo)
}
@ -377,7 +378,7 @@ class OathManager(
Code.from(code)
)
Log.d(TAG, "Added cred $credential")
logger.debug("Added cred {}", credential)
jsonSerializer.encodeToString(addedCred)
}
}
@ -431,7 +432,7 @@ class OathManager(
session.setAccessKey(accessKey)
keyManager.addKey(session.deviceId, accessKey, false)
oathViewModel.setSessionState(Session(session, false))
Log.d(TAG, "Successfully set password")
logger.debug("Successfully set password")
NULL
}
@ -443,7 +444,7 @@ class OathManager(
session.deleteAccessKey()
keyManager.removeKey(session.deviceId)
oathViewModel.setSessionState(Session(session, false))
Log.d(TAG, "Successfully unset password")
logger.debug("Successfully unset password")
return@useOathSession NULL
}
}
@ -452,7 +453,7 @@ class OathManager(
private suspend fun forgetPassword(): String {
keyManager.clearAll()
Log.d(TAG, "Cleared all keys.")
logger.debug("Cleared all keys.")
oathViewModel.sessionState.value?.let {
oathViewModel.setSessionState(
it.copy(
@ -516,7 +517,7 @@ class OathManager(
oathViewModel.updateCredentials(calculateOathCodes(session))
} catch (apduException: ApduException) {
if (apduException.sw == SW.SECURITY_CONDITION_NOT_SATISFIED) {
Log.d(TAG, "Handled oath credential refresh on locked session.")
logger.debug("Handled oath credential refresh on locked session.")
oathViewModel.setSessionState(
Session(
session,
@ -524,10 +525,9 @@ class OathManager(
)
)
} else {
Log.e(
TAG,
logger.error(
"Unexpected sw when refreshing oath credentials",
apduException.message
apduException
)
}
}
@ -543,7 +543,7 @@ class OathManager(
Credential(credential, session.deviceId),
code
)
Log.d(TAG, "Code calculated $code")
logger.debug("Code calculated {}", code)
jsonSerializer.encodeToString(code)
}
@ -679,7 +679,7 @@ class OathManager(
})
}
dialogManager.showDialog(Icon.NFC, "Tap your key", title) {
Log.d(TAG, "Cancelled Dialog $title")
logger.debug("Cancelled Dialog {}", title)
pendingAction?.invoke(Result.failure(CancellationException()))
pendingAction = null
}

View File

@ -17,8 +17,6 @@
package com.yubico.authenticator.yubikit
import com.yubico.authenticator.device.Info
import com.yubico.authenticator.logging.Log
import com.yubico.authenticator.oath.OathManager
import com.yubico.authenticator.compatUtil
import com.yubico.yubikit.android.transport.nfc.NfcYubiKeyDevice
import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice
@ -29,22 +27,25 @@ import com.yubico.yubikit.core.smartcard.SmartCardConnection
import com.yubico.yubikit.management.DeviceInfo
import com.yubico.yubikit.support.DeviceUtil
import org.slf4j.LoggerFactory
suspend fun getDeviceInfo(device: YubiKeyDevice): Info {
val pid = (device as? UsbYubiKeyDevice)?.pid
val logger = LoggerFactory.getLogger("getDeviceInfo")
val deviceInfo = runCatching {
device.withConnection<SmartCardConnection, DeviceInfo> { DeviceUtil.readInfo(it, pid) }
}.recoverCatching { t ->
Log.d(OathManager.TAG, "Smart card connection not available: ${t.message}")
logger.debug("Smart card connection not available: {}", t.message)
device.withConnection<OtpConnection, DeviceInfo> { DeviceUtil.readInfo(it, pid) }
}.recoverCatching { t ->
Log.d(OathManager.TAG, "OTP connection not available: ${t.message}")
logger.debug("OTP connection not available: {}", t.message)
device.withConnection<FidoConnection, DeviceInfo> { DeviceUtil.readInfo(it, pid) }
}.recoverCatching { t ->
Log.d(OathManager.TAG, "FIDO connection not available: ${t.message}")
logger.debug("FIDO connection not available: {}", t.message)
return SkyHelper(compatUtil).getDeviceInfo(device)
}.getOrElse {
Log.e(OathManager.TAG, "Failed to recognize device: ${it.message}")
logger.debug("Failed to recognize device: {}", it.message)
throw it
}

View File

@ -24,9 +24,9 @@ allprojects {
targetSdkVersion = 33
compileSdkVersion = 33
yubiKitVersion = "2.2.0"
yubiKitVersion = "2.3.0"
junitVersion = "4.13.2"
mockitoVersion = "5.3.1"
mockitoVersion = "5.4.0"
}
}

View File

@ -49,7 +49,7 @@ android {
}
dependencies {
def camerax_version = "1.2.2"
def camerax_version = "1.2.3"
implementation "androidx.camera:camera-lifecycle:${camerax_version}"
implementation "androidx.camera:camera-view:${camerax_version}"
implementation "androidx.camera:camera-camera2:${camerax_version}"

View File

@ -93,7 +93,7 @@ if len(sys.argv) != 2:
target = sys.argv[1]
with open(target) as f:
with open(target, encoding='utf-8') as f:
values = json.load(f, object_pairs_hook=check_duplicate_keys)
strings = {k: v for k, v in values.items() if not k.startswith("@")}

View File

@ -24,6 +24,7 @@ from .oath import OathNode
from .fido import Ctap2Node
from .yubiotp import YubiOtpNode
from .management import ManagementNode
from .piv import PivNode
from .qr import scan_qr
from ykman import __version__ as ykman_version
from ykman.base import PID
@ -391,6 +392,13 @@ class ConnectionNode(RpcNode):
def oath(self):
return OathNode(self._connection)
@child(
condition=lambda self: isinstance(self._connection, SmartCardConnection)
and CAPABILITY.PIV in self.capabilities
)
def piv(self):
return PivNode(self._connection)
@child(
condition=lambda self: isinstance(self._connection, FidoConnection)
and CAPABILITY.FIDO2 in self.capabilities

457
helper/helper/piv.py Normal file
View File

@ -0,0 +1,457 @@
# 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.
from .base import (
RpcNode,
action,
child,
RpcException,
ChildResetException,
TimeoutException,
AuthRequiredException,
)
from yubikit.core import NotSupportedError, BadResponseError
from yubikit.core.smartcard import ApduError, SW
from yubikit.piv import (
PivSession,
OBJECT_ID,
MANAGEMENT_KEY_TYPE,
InvalidPinError,
SLOT,
require_version,
KEY_TYPE,
PIN_POLICY,
TOUCH_POLICY,
)
from ykman.piv import (
get_pivman_data,
get_pivman_protected_data,
derive_management_key,
pivman_set_mgm_key,
pivman_change_pin,
generate_self_signed_certificate,
generate_csr,
generate_chuid,
)
from ykman.util import (
parse_certificates,
parse_private_key,
get_leaf_certificates,
InvalidPasswordError,
)
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
from cryptography.hazmat.primitives import hashes
from dataclasses import asdict
from enum import Enum, unique
from threading import Timer
from time import time
import datetime
import logging
logger = logging.getLogger(__name__)
_date_format = "%Y-%m-%d"
class InvalidPinException(RpcException):
def __init__(self, cause):
super().__init__(
"invalid-pin",
"Wrong PIN",
dict(attempts_remaining=cause.attempts_remaining),
)
@unique
class GENERATE_TYPE(str, Enum):
CSR = "csr"
CERTIFICATE = "certificate"
class PivNode(RpcNode):
def __init__(self, connection):
super().__init__()
self.session = PivSession(connection)
self._pivman_data = get_pivman_data(self.session)
self._authenticated = False
def __call__(self, *args, **kwargs):
try:
return super().__call__(*args, **kwargs)
except ApduError as e:
if e.sw == SW.SECURITY_CONDITION_NOT_SATISFIED:
raise AuthRequiredException()
# TODO: This should probably be in a baseclass of all "AppNodes".
raise ChildResetException(f"SW: {e.sw:x}")
except InvalidPinError as e:
raise InvalidPinException(cause=e)
def _get_object(self, object_id):
try:
return self.session.get_object(object_id)
except ApduError as e:
if e.sw == SW.FILE_NOT_FOUND:
return None
raise
except BadResponseError:
logger.warning(f"Couldn't read data object {object_id}", exc_info=True)
return None
def get_data(self):
try:
pin_md = self.session.get_pin_metadata()
puk_md = self.session.get_puk_metadata()
mgm_md = self.session.get_management_key_metadata()
pin_attempts = pin_md.attempts_remaining
metadata = dict(
pin_metadata=asdict(pin_md),
puk_metadata=asdict(puk_md),
management_key_metadata=asdict(mgm_md),
)
except NotSupportedError:
pin_attempts = self.session.get_pin_attempts()
metadata = None
return dict(
version=self.session.version,
authenticated=self._authenticated,
derived_key=self._pivman_data.has_derived_key,
stored_key=self._pivman_data.has_stored_key,
chuid=self._get_object(OBJECT_ID.CHUID),
ccc=self._get_object(OBJECT_ID.CAPABILITY),
pin_attempts=pin_attempts,
metadata=metadata,
)
def _authenticate(self, key, signal):
try:
metadata = self.session.get_management_key_metadata()
key_type = metadata.key_type
if metadata.touch_policy != TOUCH_POLICY.NEVER:
signal("touch")
timer = None
except NotSupportedError:
key_type = MANAGEMENT_KEY_TYPE.TDES
timer = Timer(0.5, lambda: signal("touch"))
timer.start()
try:
# TODO: Check if this is needed, maybe SW is enough
start = time()
self.session.authenticate(key_type, key)
except ApduError as e:
if e.sw == SW.SECURITY_CONDITION_NOT_SATISFIED and time() - start > 5:
raise TimeoutException()
raise
finally:
if timer:
timer.cancel()
self._authenticated = True
@action
def verify_pin(self, params, event, signal):
pin = params.pop("pin")
self.session.verify_pin(pin)
key = None
if self._pivman_data.has_derived_key:
key = derive_management_key(pin, self._pivman_data.salt)
elif self._pivman_data.has_stored_key:
pivman_prot = get_pivman_protected_data(self.session)
key = pivman_prot.key
if key:
try:
self._authenticate(key, signal)
except ApduError as e:
if e.sw == SW.SECURITY_CONDITION_NOT_SATISFIED:
pass # Authenticate failed, bad derived key?
# Ensure verify was the last thing we did
self.session.verify_pin(pin)
return dict(status=True, authenticated=self._authenticated)
@action
def authenticate(self, params, event, signal):
key = bytes.fromhex(params.pop("key"))
try:
self._authenticate(key, signal)
return dict(status=True)
except ApduError as e:
if e.sw == SW.SECURITY_CONDITION_NOT_SATISFIED:
return dict(status=False)
raise
@action(condition=lambda self: self._authenticated)
def set_key(self, params, event, signal):
key_type = MANAGEMENT_KEY_TYPE(params.pop("key_type", MANAGEMENT_KEY_TYPE.TDES))
key = bytes.fromhex(params.pop("key"))
store_key = params.pop("store_key", False)
pivman_set_mgm_key(self.session, key, key_type, False, store_key)
self._pivman_data = get_pivman_data(self.session)
return dict()
@action
def change_pin(self, params, event, signal):
old_pin = params.pop("pin")
new_pin = params.pop("new_pin")
pivman_change_pin(self.session, old_pin, new_pin)
return dict()
@action
def change_puk(self, params, event, signal):
old_puk = params.pop("puk")
new_puk = params.pop("new_puk")
self.session.change_puk(old_puk, new_puk)
return dict()
@action
def unblock_pin(self, params, event, signal):
puk = params.pop("puk")
new_pin = params.pop("new_pin")
self.session.unblock_pin(puk, new_pin)
return dict()
@action
def reset(self, params, event, signal):
self.session.reset()
self._authenticated = False
self._pivman_data = get_pivman_data(self.session)
return dict()
@child
def slots(self):
return SlotsNode(self.session)
def _slot_for(name):
return SLOT(int(name, base=16))
def _parse_file(data, password=None):
if password:
password = password.encode()
try:
certs = parse_certificates(data, password)
except (ValueError, TypeError):
certs = []
try:
private_key = parse_private_key(data, password)
except (ValueError, TypeError):
private_key = None
return private_key, certs
class SlotsNode(RpcNode):
def __init__(self, session):
super().__init__()
self.session = session
try:
require_version(session.version, (5, 3, 0))
self._has_metadata = True
except NotSupportedError:
self._has_metadata = False
self.refresh()
def refresh(self):
self._slots = {}
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()
def list_children(self):
return {
f"{int(slot):02x}": dict(
slot=int(slot),
name=slot.name,
has_key=metadata is not None if self._has_metadata else None,
cert_info=dict(
subject=cert.subject.rfc4514_string(),
issuer=cert.issuer.rfc4514_string(),
serial=hex(cert.serial_number)[2:],
not_valid_before=cert.not_valid_before.isoformat(),
not_valid_after=cert.not_valid_after.isoformat(),
fingerprint=cert.fingerprint(hashes.SHA256()),
)
if cert
else None,
)
for slot, (metadata, cert) in self._slots.items()
}
def create_child(self, name):
slot = _slot_for(name)
if slot in self._slots:
metadata, certificate = self._slots[slot]
return SlotNode(self.session, slot, metadata, certificate, self.refresh)
return super().create_child(name)
@action
def examine_file(self, params, event, signal):
data = bytes.fromhex(params.pop("data"))
password = params.pop("password", None)
try:
private_key, certs = _parse_file(data, password)
return dict(
status=True,
password=password is not None,
private_key=bool(private_key),
certificates=len(certs),
)
except InvalidPasswordError:
logger.debug("Invalid or missing password", exc_info=True)
return dict(status=False)
class SlotNode(RpcNode):
def __init__(self, session, slot, metadata, certificate, refresh):
super().__init__()
self.session = session
self.slot = slot
self.metadata = metadata
self.certificate = certificate
self._refresh = refresh
def get_data(self):
return dict(
id=f"{int(self.slot):02x}",
name=self.slot.name,
metadata=asdict(self.metadata) if self.metadata else None,
certificate=self.certificate.public_bytes(encoding=Encoding.PEM).decode()
if self.certificate
else None,
)
@action(condition=lambda self: self.certificate)
def delete(self, params, event, signal):
self.session.delete_certificate(self.slot)
self.session.put_object(OBJECT_ID.CHUID, generate_chuid())
self._refresh()
self.certificate = None
return dict()
@action
def import_file(self, params, event, signal):
data = bytes.fromhex(params.pop("data"))
password = params.pop("password", None)
try:
private_key, certs = _parse_file(data, password)
except InvalidPasswordError:
logger.debug("Invalid or missing password", exc_info=True)
raise ValueError("Wrong/Missing password")
# Exception?
if not certs and not private_key:
raise ValueError("Failed to parse")
metadata = None
if private_key:
pin_policy = PIN_POLICY(params.pop("pin_policy", PIN_POLICY.DEFAULT))
touch_policy = TOUCH_POLICY(
params.pop("touch_policy", TOUCH_POLICY.DEFAULT)
)
self.session.put_key(self.slot, private_key, pin_policy, touch_policy)
try:
metadata = self.session.get_slot_metadata(self.slot)
except (ApduError, BadResponseError):
pass
if certs:
if len(certs) > 1:
leafs = get_leaf_certificates(certs)
certificate = leafs[0]
else:
certificate = certs[0]
self.session.put_certificate(self.slot, certificate)
self.session.put_object(OBJECT_ID.CHUID, generate_chuid())
self.certificate = certificate
self._refresh()
return dict(
metadata=asdict(metadata) if metadata else None,
public_key=private_key.public_key()
.public_bytes(
encoding=Encoding.PEM, format=PublicFormat.SubjectPublicKeyInfo
)
.decode()
if private_key
else None,
certificate=self.certificate.public_bytes(encoding=Encoding.PEM).decode()
if certs
else None,
)
@action
def generate(self, params, event, signal):
key_type = KEY_TYPE(params.pop("key_type"))
pin_policy = PIN_POLICY(params.pop("pin_policy", PIN_POLICY.DEFAULT))
touch_policy = TOUCH_POLICY(params.pop("touch_policy", TOUCH_POLICY.DEFAULT))
subject = params.pop("subject")
generate_type = GENERATE_TYPE(params.pop("generate_type", GENERATE_TYPE.CERTIFICATE))
public_key = self.session.generate_key(
self.slot, key_type, pin_policy, touch_policy
)
if pin_policy != PIN_POLICY.NEVER:
# TODO: Check if verified?
pin = params.pop("pin")
self.session.verify_pin(pin)
if touch_policy in (TOUCH_POLICY.ALWAYS, TOUCH_POLICY.CACHED):
signal("touch")
if generate_type == GENERATE_TYPE.CSR:
result = generate_csr(self.session, self.slot, public_key, subject)
elif generate_type == GENERATE_TYPE.CERTIFICATE:
now = datetime.datetime.utcnow()
then = now + datetime.timedelta(days=365)
valid_from = params.pop("valid_from", now.strftime(_date_format))
valid_to = params.pop("valid_to", then.strftime(_date_format))
result = generate_self_signed_certificate(
self.session,
self.slot,
public_key,
subject,
datetime.datetime.strptime(valid_from, _date_format),
datetime.datetime.strptime(valid_to, _date_format),
)
self.session.put_certificate(self.slot, result)
self.session.put_object(OBJECT_ID.CHUID, generate_chuid())
else:
raise ValueError("Unsupported GENERATE_TYPE")
self._refresh()
return dict(
public_key=public_key.public_bytes(
encoding=Encoding.PEM, format=PublicFormat.SubjectPublicKeyInfo
).decode(),
result=result.public_bytes(encoding=Encoding.PEM).decode(),
)

306
helper/poetry.lock generated
View File

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.4.0 and should not be changed by hand.
# This file is automatically @generated by Poetry 1.4.1 and should not be changed by hand.
[[package]]
name = "altgraph"
@ -91,14 +91,14 @@ pycparser = "*"
[[package]]
name = "click"
version = "8.1.3"
version = "8.1.6"
description = "Composable command line interface toolkit"
category = "main"
optional = false
python-versions = ">=3.7"
files = [
{file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"},
{file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"},
{file = "click-8.1.6-py3-none-any.whl", hash = "sha256:fa244bb30b3b5ee2cae3da8f55c9e5e0c0e86093306301fb418eb9dc40fbded5"},
{file = "click-8.1.6.tar.gz", hash = "sha256:48ee849951919527a045bfe3bf7baa8a959c423134e1a5b98c05c20ba75a1cbd"},
]
[package.dependencies]
@ -118,31 +118,35 @@ files = [
[[package]]
name = "cryptography"
version = "40.0.2"
version = "41.0.2"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
category = "main"
optional = false
python-versions = ">=3.6"
python-versions = ">=3.7"
files = [
{file = "cryptography-40.0.2-cp36-abi3-macosx_10_12_universal2.whl", hash = "sha256:8f79b5ff5ad9d3218afb1e7e20ea74da5f76943ee5edb7f76e56ec5161ec782b"},
{file = "cryptography-40.0.2-cp36-abi3-macosx_10_12_x86_64.whl", hash = "sha256:05dc219433b14046c476f6f09d7636b92a1c3e5808b9a6536adf4932b3b2c440"},
{file = "cryptography-40.0.2-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4df2af28d7bedc84fe45bd49bc35d710aede676e2a4cb7fc6d103a2adc8afe4d"},
{file = "cryptography-40.0.2-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dcca15d3a19a66e63662dc8d30f8036b07be851a8680eda92d079868f106288"},
{file = "cryptography-40.0.2-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a04386fb7bc85fab9cd51b6308633a3c271e3d0d3eae917eebab2fac6219b6d2"},
{file = "cryptography-40.0.2-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:adc0d980fd2760c9e5de537c28935cc32b9353baaf28e0814df417619c6c8c3b"},
{file = "cryptography-40.0.2-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:d5a1bd0e9e2031465761dfa920c16b0065ad77321d8a8c1f5ee331021fda65e9"},
{file = "cryptography-40.0.2-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:a95f4802d49faa6a674242e25bfeea6fc2acd915b5e5e29ac90a32b1139cae1c"},
{file = "cryptography-40.0.2-cp36-abi3-win32.whl", hash = "sha256:aecbb1592b0188e030cb01f82d12556cf72e218280f621deed7d806afd2113f9"},
{file = "cryptography-40.0.2-cp36-abi3-win_amd64.whl", hash = "sha256:b12794f01d4cacfbd3177b9042198f3af1c856eedd0a98f10f141385c809a14b"},
{file = "cryptography-40.0.2-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:142bae539ef28a1c76794cca7f49729e7c54423f615cfd9b0b1fa90ebe53244b"},
{file = "cryptography-40.0.2-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:956ba8701b4ffe91ba59665ed170a2ebbdc6fc0e40de5f6059195d9f2b33ca0e"},
{file = "cryptography-40.0.2-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:4f01c9863da784558165f5d4d916093737a75203a5c5286fde60e503e4276c7a"},
{file = "cryptography-40.0.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:3daf9b114213f8ba460b829a02896789751626a2a4e7a43a28ee77c04b5e4958"},
{file = "cryptography-40.0.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:48f388d0d153350f378c7f7b41497a54ff1513c816bcbbcafe5b829e59b9ce5b"},
{file = "cryptography-40.0.2-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c0764e72b36a3dc065c155e5b22f93df465da9c39af65516fe04ed3c68c92636"},
{file = "cryptography-40.0.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:cbaba590180cba88cb99a5f76f90808a624f18b169b90a4abb40c1fd8c19420e"},
{file = "cryptography-40.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7a38250f433cd41df7fcb763caa3ee9362777fdb4dc642b9a349721d2bf47404"},
{file = "cryptography-40.0.2.tar.gz", hash = "sha256:c33c0d32b8594fa647d2e01dbccc303478e16fdd7cf98652d5b3ed11aa5e5c99"},
{file = "cryptography-41.0.2-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:01f1d9e537f9a15b037d5d9ee442b8c22e3ae11ce65ea1f3316a41c78756b711"},
{file = "cryptography-41.0.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:079347de771f9282fbfe0e0236c716686950c19dee1b76240ab09ce1624d76d7"},
{file = "cryptography-41.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:439c3cc4c0d42fa999b83ded80a9a1fb54d53c58d6e59234cfe97f241e6c781d"},
{file = "cryptography-41.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f14ad275364c8b4e525d018f6716537ae7b6d369c094805cae45300847e0894f"},
{file = "cryptography-41.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:84609ade00a6ec59a89729e87a503c6e36af98ddcd566d5f3be52e29ba993182"},
{file = "cryptography-41.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:49c3222bb8f8e800aead2e376cbef687bc9e3cb9b58b29a261210456a7783d83"},
{file = "cryptography-41.0.2-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:d73f419a56d74fef257955f51b18d046f3506270a5fd2ac5febbfa259d6c0fa5"},
{file = "cryptography-41.0.2-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:2a034bf7d9ca894720f2ec1d8b7b5832d7e363571828037f9e0c4f18c1b58a58"},
{file = "cryptography-41.0.2-cp37-abi3-win32.whl", hash = "sha256:d124682c7a23c9764e54ca9ab5b308b14b18eba02722b8659fb238546de83a76"},
{file = "cryptography-41.0.2-cp37-abi3-win_amd64.whl", hash = "sha256:9c3fe6534d59d071ee82081ca3d71eed3210f76ebd0361798c74abc2bcf347d4"},
{file = "cryptography-41.0.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a719399b99377b218dac6cf547b6ec54e6ef20207b6165126a280b0ce97e0d2a"},
{file = "cryptography-41.0.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:182be4171f9332b6741ee818ec27daff9fb00349f706629f5cbf417bd50e66fd"},
{file = "cryptography-41.0.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:7a9a3bced53b7f09da251685224d6a260c3cb291768f54954e28f03ef14e3766"},
{file = "cryptography-41.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f0dc40e6f7aa37af01aba07277d3d64d5a03dc66d682097541ec4da03cc140ee"},
{file = "cryptography-41.0.2-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:674b669d5daa64206c38e507808aae49904c988fa0a71c935e7006a3e1e83831"},
{file = "cryptography-41.0.2-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7af244b012711a26196450d34f483357e42aeddb04128885d95a69bd8b14b69b"},
{file = "cryptography-41.0.2-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9b6d717393dbae53d4e52684ef4f022444fc1cce3c48c38cb74fca29e1f08eaa"},
{file = "cryptography-41.0.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:192255f539d7a89f2102d07d7375b1e0a81f7478925b3bc2e0549ebf739dae0e"},
{file = "cryptography-41.0.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f772610fe364372de33d76edcd313636a25684edb94cee53fd790195f5989d14"},
{file = "cryptography-41.0.2-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:b332cba64d99a70c1e0836902720887fb4529ea49ea7f5462cf6640e095e11d2"},
{file = "cryptography-41.0.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9a6673c1828db6270b76b22cc696f40cde9043eb90373da5c2f8f2158957f42f"},
{file = "cryptography-41.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:342f3767e25876751e14f8459ad85e77e660537ca0a066e10e75df9c9e9099f0"},
{file = "cryptography-41.0.2.tar.gz", hash = "sha256:7d230bf856164de164ecb615ccc14c7fc6de6906ddd5b491f3af90d3514c925c"},
]
[package.dependencies]
@ -151,23 +155,23 @@ cffi = ">=1.12"
[package.extras]
docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"]
docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"]
pep8test = ["black", "check-manifest", "mypy", "ruff"]
sdist = ["setuptools-rust (>=0.11.4)"]
nox = ["nox"]
pep8test = ["black", "check-sdist", "mypy", "ruff"]
sdist = ["build"]
ssh = ["bcrypt (>=3.1.5)"]
test = ["iso8601", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-shard (>=0.1.2)", "pytest-subtests", "pytest-xdist"]
test = ["pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"]
test-randomorder = ["pytest-randomly"]
tox = ["tox"]
[[package]]
name = "exceptiongroup"
version = "1.1.1"
version = "1.1.2"
description = "Backport of PEP 654 (exception groups)"
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "exceptiongroup-1.1.1-py3-none-any.whl", hash = "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e"},
{file = "exceptiongroup-1.1.1.tar.gz", hash = "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"},
{file = "exceptiongroup-1.1.2-py3-none-any.whl", hash = "sha256:e346e69d186172ca7cf029c8c1d16235aa0e04035e5750b4b95039e65204328f"},
{file = "exceptiongroup-1.1.2.tar.gz", hash = "sha256:12c3e887d6485d16943a309616de20ae5582633e0a2eda17f4e10fd61c1e8af5"},
]
[package.extras]
@ -175,32 +179,32 @@ test = ["pytest (>=6)"]
[[package]]
name = "fido2"
version = "1.1.1"
version = "1.1.2"
description = "FIDO2/WebAuthn library for implementing clients and servers."
category = "main"
optional = false
python-versions = ">=3.7,<4.0"
files = [
{file = "fido2-1.1.1-py3-none-any.whl", hash = "sha256:54017b69522b1581e4222443a0b3fff5eb2626f8e773a4a7b955f3e55fb3b4fc"},
{file = "fido2-1.1.1.tar.gz", hash = "sha256:5dc495ca8c59c1c337383b4b8c314d46b92d5c6fc650e71984c6d7f954079fc3"},
{file = "fido2-1.1.2-py3-none-any.whl", hash = "sha256:a3b7d7d233dec3a4fa0d6178fc34d1cce17b820005a824f6ab96917a1e3be8d8"},
{file = "fido2-1.1.2.tar.gz", hash = "sha256:6110d913106f76199201b32d262b2857562cc46ba1d0b9c51fbce30dc936c573"},
]
[package.dependencies]
cryptography = ">=2.6,<35 || >35,<43"
cryptography = ">=2.6,<35 || >35,<44"
[package.extras]
pcsc = ["pyscard (>=1.9,<3)"]
[[package]]
name = "importlib-metadata"
version = "6.4.1"
version = "6.8.0"
description = "Read metadata from Python packages"
category = "main"
optional = false
python-versions = ">=3.7"
python-versions = ">=3.8"
files = [
{file = "importlib_metadata-6.4.1-py3-none-any.whl", hash = "sha256:63ace321e24167d12fbb176b6015f4dbe06868c54a2af4f15849586afb9027fd"},
{file = "importlib_metadata-6.4.1.tar.gz", hash = "sha256:eb1a7933041f0f85c94cd130258df3fb0dec060ad8c1c9318892ef4192c47ce1"},
{file = "importlib_metadata-6.8.0-py3-none-any.whl", hash = "sha256:3ebb78df84a805d7698245025b975d9d67053cd94c79245ba4b3eb694abe68bb"},
{file = "importlib_metadata-6.8.0.tar.gz", hash = "sha256:dbace7892d8c0c4ac1ad096662232f831d4e64f4c4545bd53016a3e9d4654743"},
]
[package.dependencies]
@ -209,26 +213,26 @@ zipp = ">=0.5"
[package.extras]
docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
perf = ["ipython"]
testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"]
testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"]
[[package]]
name = "importlib-resources"
version = "5.12.0"
version = "6.0.0"
description = "Read resources from Python packages"
category = "main"
optional = false
python-versions = ">=3.7"
python-versions = ">=3.8"
files = [
{file = "importlib_resources-5.12.0-py3-none-any.whl", hash = "sha256:7b1deeebbf351c7578e09bf2f63fa2ce8b5ffec296e0d349139d43cca061a81a"},
{file = "importlib_resources-5.12.0.tar.gz", hash = "sha256:4be82589bf5c1d7999aedf2a45159d10cb3ca4f19b2271f8792bc8e6da7b22f6"},
{file = "importlib_resources-6.0.0-py3-none-any.whl", hash = "sha256:d952faee11004c045f785bb5636e8f885bed30dc3c940d5d42798a2a4541c185"},
{file = "importlib_resources-6.0.0.tar.gz", hash = "sha256:4cf94875a8368bd89531a756df9a9ebe1f150e0f885030b461237bc7f2d905f2"},
]
[package.dependencies]
zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""}
[package.extras]
docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
testing = ["flake8 (<5)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"]
docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-ruff"]
[[package]]
name = "iniconfig"
@ -244,22 +248,22 @@ files = [
[[package]]
name = "jaraco-classes"
version = "3.2.3"
version = "3.3.0"
description = "Utility functions for Python class constructs"
category = "main"
optional = false
python-versions = ">=3.7"
python-versions = ">=3.8"
files = [
{file = "jaraco.classes-3.2.3-py3-none-any.whl", hash = "sha256:2353de3288bc6b82120752201c6b1c1a14b058267fa424ed5ce5984e3b922158"},
{file = "jaraco.classes-3.2.3.tar.gz", hash = "sha256:89559fa5c1d3c34eff6f631ad80bb21f378dbcbb35dd161fd2c6b93f5be2f98a"},
{file = "jaraco.classes-3.3.0-py3-none-any.whl", hash = "sha256:10afa92b6743f25c0cf5f37c6bb6e18e2c5bb84a16527ccfc0040ea377e7aaeb"},
{file = "jaraco.classes-3.3.0.tar.gz", hash = "sha256:c063dd08e89217cee02c8d5e5ec560f2c8ce6cdc2fcdc2e68f7b2e5547ed3621"},
]
[package.dependencies]
more-itertools = "*"
[package.extras]
docs = ["jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"]
testing = ["flake8 (<5)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"]
docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-ruff"]
[[package]]
name = "jeepney"
@ -319,64 +323,64 @@ altgraph = ">=0.17"
[[package]]
name = "more-itertools"
version = "9.1.0"
version = "10.0.0"
description = "More routines for operating on iterables, beyond itertools"
category = "main"
optional = false
python-versions = ">=3.7"
python-versions = ">=3.8"
files = [
{file = "more-itertools-9.1.0.tar.gz", hash = "sha256:cabaa341ad0389ea83c17a94566a53ae4c9d07349861ecb14dc6d0345cf9ac5d"},
{file = "more_itertools-9.1.0-py3-none-any.whl", hash = "sha256:d2bc7f02446e86a68911e58ded76d6561eea00cddfb2a91e7019bbb586c799f3"},
{file = "more-itertools-10.0.0.tar.gz", hash = "sha256:cd65437d7c4b615ab81c0640c0480bc29a550ea032891977681efd28344d51e1"},
{file = "more_itertools-10.0.0-py3-none-any.whl", hash = "sha256:928d514ffd22b5b0a8fce326d57f423a55d2ff783b093bab217eda71e732330f"},
]
[[package]]
name = "mss"
version = "8.0.3"
version = "9.0.1"
description = "An ultra fast cross-platform multiple screenshots module in pure python using ctypes."
category = "main"
optional = false
python-versions = ">=3.8"
files = [
{file = "mss-8.0.3-py3-none-any.whl", hash = "sha256:87c1eda213dab83431013ca98ee7217e536439f28446b979bb38d8f7af5c7d34"},
{file = "mss-8.0.3.tar.gz", hash = "sha256:07dc0602e325434e867621f257a8ec6ea14bdffd00bfa554a69bef554af7f524"},
{file = "mss-9.0.1-py3-none-any.whl", hash = "sha256:7ee44db7ab14cbea6a3eb63813c57d677a109ca5979d3b76046e4bddd3ca1a0b"},
{file = "mss-9.0.1.tar.gz", hash = "sha256:6eb7b9008cf27428811fa33aeb35f3334db81e3f7cc2dd49ec7c6e5a94b39f12"},
]
[[package]]
name = "numpy"
version = "1.24.2"
version = "1.24.4"
description = "Fundamental package for array computing in Python"
category = "main"
optional = false
python-versions = ">=3.8"
files = [
{file = "numpy-1.24.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eef70b4fc1e872ebddc38cddacc87c19a3709c0e3e5d20bf3954c147b1dd941d"},
{file = "numpy-1.24.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e8d2859428712785e8a8b7d2b3ef0a1d1565892367b32f915c4a4df44d0e64f5"},
{file = "numpy-1.24.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6524630f71631be2dabe0c541e7675db82651eb998496bbe16bc4f77f0772253"},
{file = "numpy-1.24.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a51725a815a6188c662fb66fb32077709a9ca38053f0274640293a14fdd22978"},
{file = "numpy-1.24.2-cp310-cp310-win32.whl", hash = "sha256:2620e8592136e073bd12ee4536149380695fbe9ebeae845b81237f986479ffc9"},
{file = "numpy-1.24.2-cp310-cp310-win_amd64.whl", hash = "sha256:97cf27e51fa078078c649a51d7ade3c92d9e709ba2bfb97493007103c741f1d0"},
{file = "numpy-1.24.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7de8fdde0003f4294655aa5d5f0a89c26b9f22c0a58790c38fae1ed392d44a5a"},
{file = "numpy-1.24.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4173bde9fa2a005c2c6e2ea8ac1618e2ed2c1c6ec8a7657237854d42094123a0"},
{file = "numpy-1.24.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4cecaed30dc14123020f77b03601559fff3e6cd0c048f8b5289f4eeabb0eb281"},
{file = "numpy-1.24.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a23f8440561a633204a67fb44617ce2a299beecf3295f0d13c495518908e910"},
{file = "numpy-1.24.2-cp311-cp311-win32.whl", hash = "sha256:e428c4fbfa085f947b536706a2fc349245d7baa8334f0c5723c56a10595f9b95"},
{file = "numpy-1.24.2-cp311-cp311-win_amd64.whl", hash = "sha256:557d42778a6869c2162deb40ad82612645e21d79e11c1dc62c6e82a2220ffb04"},
{file = "numpy-1.24.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d0a2db9d20117bf523dde15858398e7c0858aadca7c0f088ac0d6edd360e9ad2"},
{file = "numpy-1.24.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c72a6b2f4af1adfe193f7beb91ddf708ff867a3f977ef2ec53c0ffb8283ab9f5"},
{file = "numpy-1.24.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c29e6bd0ec49a44d7690ecb623a8eac5ab8a923bce0bea6293953992edf3a76a"},
{file = "numpy-1.24.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2eabd64ddb96a1239791da78fa5f4e1693ae2dadc82a76bc76a14cbb2b966e96"},
{file = "numpy-1.24.2-cp38-cp38-win32.whl", hash = "sha256:e3ab5d32784e843fc0dd3ab6dcafc67ef806e6b6828dc6af2f689be0eb4d781d"},
{file = "numpy-1.24.2-cp38-cp38-win_amd64.whl", hash = "sha256:76807b4063f0002c8532cfeac47a3068a69561e9c8715efdad3c642eb27c0756"},
{file = "numpy-1.24.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4199e7cfc307a778f72d293372736223e39ec9ac096ff0a2e64853b866a8e18a"},
{file = "numpy-1.24.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:adbdce121896fd3a17a77ab0b0b5eedf05a9834a18699db6829a64e1dfccca7f"},
{file = "numpy-1.24.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:889b2cc88b837d86eda1b17008ebeb679d82875022200c6e8e4ce6cf549b7acb"},
{file = "numpy-1.24.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f64bb98ac59b3ea3bf74b02f13836eb2e24e48e0ab0145bbda646295769bd780"},
{file = "numpy-1.24.2-cp39-cp39-win32.whl", hash = "sha256:63e45511ee4d9d976637d11e6c9864eae50e12dc9598f531c035265991910468"},
{file = "numpy-1.24.2-cp39-cp39-win_amd64.whl", hash = "sha256:a77d3e1163a7770164404607b7ba3967fb49b24782a6ef85d9b5f54126cc39e5"},
{file = "numpy-1.24.2-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:92011118955724465fb6853def593cf397b4a1367495e0b59a7e69d40c4eb71d"},
{file = "numpy-1.24.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9006288bcf4895917d02583cf3411f98631275bc67cce355a7f39f8c14338fa"},
{file = "numpy-1.24.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:150947adbdfeceec4e5926d956a06865c1c690f2fd902efede4ca6fe2e657c3f"},
{file = "numpy-1.24.2.tar.gz", hash = "sha256:003a9f530e880cb2cd177cba1af7220b9aa42def9c4afc2a2fc3ee6be7eb2b22"},
{file = "numpy-1.24.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c0bfb52d2169d58c1cdb8cc1f16989101639b34c7d3ce60ed70b19c63eba0b64"},
{file = "numpy-1.24.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ed094d4f0c177b1b8e7aa9cba7d6ceed51c0e569a5318ac0ca9a090680a6a1b1"},
{file = "numpy-1.24.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79fc682a374c4a8ed08b331bef9c5f582585d1048fa6d80bc6c35bc384eee9b4"},
{file = "numpy-1.24.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ffe43c74893dbf38c2b0a1f5428760a1a9c98285553c89e12d70a96a7f3a4d6"},
{file = "numpy-1.24.4-cp310-cp310-win32.whl", hash = "sha256:4c21decb6ea94057331e111a5bed9a79d335658c27ce2adb580fb4d54f2ad9bc"},
{file = "numpy-1.24.4-cp310-cp310-win_amd64.whl", hash = "sha256:b4bea75e47d9586d31e892a7401f76e909712a0fd510f58f5337bea9572c571e"},
{file = "numpy-1.24.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f136bab9c2cfd8da131132c2cf6cc27331dd6fae65f95f69dcd4ae3c3639c810"},
{file = "numpy-1.24.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2926dac25b313635e4d6cf4dc4e51c8c0ebfed60b801c799ffc4c32bf3d1254"},
{file = "numpy-1.24.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:222e40d0e2548690405b0b3c7b21d1169117391c2e82c378467ef9ab4c8f0da7"},
{file = "numpy-1.24.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7215847ce88a85ce39baf9e89070cb860c98fdddacbaa6c0da3ffb31b3350bd5"},
{file = "numpy-1.24.4-cp311-cp311-win32.whl", hash = "sha256:4979217d7de511a8d57f4b4b5b2b965f707768440c17cb70fbf254c4b225238d"},
{file = "numpy-1.24.4-cp311-cp311-win_amd64.whl", hash = "sha256:b7b1fc9864d7d39e28f41d089bfd6353cb5f27ecd9905348c24187a768c79694"},
{file = "numpy-1.24.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1452241c290f3e2a312c137a9999cdbf63f78864d63c79039bda65ee86943f61"},
{file = "numpy-1.24.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:04640dab83f7c6c85abf9cd729c5b65f1ebd0ccf9de90b270cd61935eef0197f"},
{file = "numpy-1.24.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5425b114831d1e77e4b5d812b69d11d962e104095a5b9c3b641a218abcc050e"},
{file = "numpy-1.24.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd80e219fd4c71fc3699fc1dadac5dcf4fd882bfc6f7ec53d30fa197b8ee22dc"},
{file = "numpy-1.24.4-cp38-cp38-win32.whl", hash = "sha256:4602244f345453db537be5314d3983dbf5834a9701b7723ec28923e2889e0bb2"},
{file = "numpy-1.24.4-cp38-cp38-win_amd64.whl", hash = "sha256:692f2e0f55794943c5bfff12b3f56f99af76f902fc47487bdfe97856de51a706"},
{file = "numpy-1.24.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2541312fbf09977f3b3ad449c4e5f4bb55d0dbf79226d7724211acc905049400"},
{file = "numpy-1.24.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9667575fb6d13c95f1b36aca12c5ee3356bf001b714fc354eb5465ce1609e62f"},
{file = "numpy-1.24.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a86ed21e4f87050382c7bc96571755193c4c1392490744ac73d660e8f564a9"},
{file = "numpy-1.24.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d11efb4dbecbdf22508d55e48d9c8384db795e1b7b51ea735289ff96613ff74d"},
{file = "numpy-1.24.4-cp39-cp39-win32.whl", hash = "sha256:6620c0acd41dbcb368610bb2f4d83145674040025e5536954782467100aa8835"},
{file = "numpy-1.24.4-cp39-cp39-win_amd64.whl", hash = "sha256:befe2bf740fd8373cf56149a5c23a0f601e82869598d41f8e188a0e9869926f8"},
{file = "numpy-1.24.4-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:31f13e25b4e304632a4619d0e0777662c2ffea99fcae2029556b17d8ff958aef"},
{file = "numpy-1.24.4-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95f7ac6540e95bc440ad77f56e520da5bf877f87dca58bd095288dce8940532a"},
{file = "numpy-1.24.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:e98f220aa76ca2a977fe435f5b04d7b3470c0a2e6312907b37ba6068f26787f2"},
{file = "numpy-1.24.4.tar.gz", hash = "sha256:80f5e3a4e498641401868df4208b74581206afbee7cf7b8329daae82676d9463"},
]
[[package]]
@ -485,14 +489,14 @@ tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "pa
[[package]]
name = "pluggy"
version = "1.0.0"
version = "1.2.0"
description = "plugin and hook calling mechanisms for python"
category = "dev"
optional = false
python-versions = ">=3.6"
python-versions = ">=3.7"
files = [
{file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
{file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
{file = "pluggy-1.2.0-py3-none-any.whl", hash = "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849"},
{file = "pluggy-1.2.0.tar.gz", hash = "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3"},
]
[package.extras]
@ -513,24 +517,24 @@ files = [
[[package]]
name = "pyinstaller"
version = "5.10.1"
version = "5.13.0"
description = "PyInstaller bundles a Python application and all its dependencies into a single package."
category = "dev"
optional = false
python-versions = "<3.12,>=3.7"
python-versions = "<3.13,>=3.7"
files = [
{file = "pyinstaller-5.10.1-py3-none-macosx_10_13_universal2.whl", hash = "sha256:247b99c52dc3cf69eba905da30dbca0a8ea309e1058cab44658ac838d9b8f2f0"},
{file = "pyinstaller-5.10.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:2d16641a495593d174504263b038a6d3d46b3b15a381ccb216cf6cce67723512"},
{file = "pyinstaller-5.10.1-py3-none-manylinux2014_i686.whl", hash = "sha256:df97aaf1103a1c485aa3c9947792a86675e370f5ce9b436b4a84e34a4180c8d2"},
{file = "pyinstaller-5.10.1-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:333b4ffda38d9c0a561c38429dd9848d37aa78f3b8ea8a6f2b2e69a60d523c02"},
{file = "pyinstaller-5.10.1-py3-none-manylinux2014_s390x.whl", hash = "sha256:6afc7aa4885ffd3e6121a8cf2138830099f874c18cb5869bed8c1a42db82d060"},
{file = "pyinstaller-5.10.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:85e39e36d03355423636907a26a9bfa06fdc93cb1086441b19d2d0ca448479fa"},
{file = "pyinstaller-5.10.1-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:7a1db833bb0302b66ae3ae337fbd5487699658ce869ca4d538b5359b8179e83a"},
{file = "pyinstaller-5.10.1-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:bb7de35cd209a0a0358aec761a273ae951d2161c03728f15d9a640d06a88e472"},
{file = "pyinstaller-5.10.1-py3-none-win32.whl", hash = "sha256:9e9a38f41f8280c8e29b294716992852281b41fbe64ba330ebab671efe27b26d"},
{file = "pyinstaller-5.10.1-py3-none-win_amd64.whl", hash = "sha256:915a502802c751bafd92d568ac57468ec6cdf252b8308aa9a167bbc2c565ad2d"},
{file = "pyinstaller-5.10.1-py3-none-win_arm64.whl", hash = "sha256:f677fbc151db1eb00ada94e86ed128e7b359cbd6bf3f6ea815afdde687692d46"},
{file = "pyinstaller-5.10.1.tar.gz", hash = "sha256:6ecc464bf56919bf2d6bff275f38d85ff08ae747b8ead3a0c26cf85573b3c723"},
{file = "pyinstaller-5.13.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:7fdd319828de679f9c5e381eff998ee9b4164bf4457e7fca56946701cf002c3f"},
{file = "pyinstaller-5.13.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:0df43697c4914285ecd333be968d2cd042ab9b2670124879ee87931d2344eaf5"},
{file = "pyinstaller-5.13.0-py3-none-manylinux2014_i686.whl", hash = "sha256:28d9742c37e9fb518444b12f8c8ab3cb4ba212d752693c34475c08009aa21ccf"},
{file = "pyinstaller-5.13.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e5fb17de6c325d3b2b4ceaeb55130ad7100a79096490e4c5b890224406fa42f4"},
{file = "pyinstaller-5.13.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:78975043edeb628e23a73fb3ef0a273cda50e765f1716f75212ea3e91b09dede"},
{file = "pyinstaller-5.13.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:cd7d5c06f2847195a23d72ede17c60857d6f495d6f0727dc6c9bc1235f2eb79c"},
{file = "pyinstaller-5.13.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:24009eba63cfdbcde6d2634e9c87f545eb67249ddf3b514e0cd3b2cdaa595828"},
{file = "pyinstaller-5.13.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:1fde4381155f21d6354dc450dcaa338cd8a40aaacf6bd22b987b0f3e1f96f3ee"},
{file = "pyinstaller-5.13.0-py3-none-win32.whl", hash = "sha256:2d03419904d1c25c8968b0ad21da0e0f33d8d65716e29481b5bd83f7f342b0c5"},
{file = "pyinstaller-5.13.0-py3-none-win_amd64.whl", hash = "sha256:9fc27c5a853b14a90d39c252707673c7a0efec921cd817169aff3af0fca8c127"},
{file = "pyinstaller-5.13.0-py3-none-win_arm64.whl", hash = "sha256:3a331951f9744bc2379ea5d65d36f3c828eaefe2785f15039592cdc08560b262"},
{file = "pyinstaller-5.13.0.tar.gz", hash = "sha256:5e446df41255e815017d96318e39f65a3eb807e74a796c7e7ff7f13b6366a2e9"},
]
[package.dependencies]
@ -538,7 +542,7 @@ altgraph = "*"
macholib = {version = ">=1.8", markers = "sys_platform == \"darwin\""}
pefile = {version = ">=2022.5.30", markers = "sys_platform == \"win32\""}
pyinstaller-hooks-contrib = ">=2021.4"
pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""}
pywin32-ctypes = {version = ">=0.2.1", markers = "sys_platform == \"win32\""}
setuptools = ">=42.0.0"
[package.extras]
@ -547,14 +551,14 @@ hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"]
[[package]]
name = "pyinstaller-hooks-contrib"
version = "2023.2"
version = "2023.6"
description = "Community maintained hooks for PyInstaller"
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "pyinstaller-hooks-contrib-2023.2.tar.gz", hash = "sha256:7fb856a81fd06a717188a3175caa77e902035cc067b00b583c6409c62497b23f"},
{file = "pyinstaller_hooks_contrib-2023.2-py2.py3-none-any.whl", hash = "sha256:e02c5f0ee3d4f5814588c2128caf5036c058ba764aaf24d957bb5311ad8690ad"},
{file = "pyinstaller-hooks-contrib-2023.6.tar.gz", hash = "sha256:596a72009d8692b043e0acbf5e1b476d93149900142ba01845dded91a0770cb5"},
{file = "pyinstaller_hooks_contrib-2023.6-py2.py3-none-any.whl", hash = "sha256:aa6d7d038814df6aa7bec7bdbebc7cb4c693d3398df858f6062957f0797d397b"},
]
[[package]]
@ -580,14 +584,14 @@ pyro = ["Pyro"]
[[package]]
name = "pytest"
version = "7.3.1"
version = "7.4.0"
description = "pytest: simple powerful testing with Python"
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "pytest-7.3.1-py3-none-any.whl", hash = "sha256:3799fa815351fea3a5e96ac7e503a96fa51cc9942c3753cda7651b93c1cfa362"},
{file = "pytest-7.3.1.tar.gz", hash = "sha256:434afafd78b1d78ed0addf160ad2b77a30d35d4bdf8af234fe621919d9ed15e3"},
{file = "pytest-7.4.0-py3-none-any.whl", hash = "sha256:78bf16451a2eb8c7a2ea98e32dc119fd2aa758f1d5d66dbf0a59d69a3969df32"},
{file = "pytest-7.4.0.tar.gz", hash = "sha256:b4bf8c45bd59934ed84001ad51e11b4ee40d40a1229d2c79f9c592b0a3f6bd8a"},
]
[package.dependencies]
@ -599,7 +603,7 @@ pluggy = ">=0.12,<2.0"
tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
[package.extras]
testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
[[package]]
name = "pywin32"
@ -627,14 +631,14 @@ files = [
[[package]]
name = "pywin32-ctypes"
version = "0.2.0"
description = ""
version = "0.2.2"
description = "A (partial) reimplementation of pywin32 using ctypes/cffi"
category = "main"
optional = false
python-versions = "*"
python-versions = ">=3.6"
files = [
{file = "pywin32-ctypes-0.2.0.tar.gz", hash = "sha256:24ffc3b341d457d48e8922352130cf2644024a4ff09762a2261fd34c36ee5942"},
{file = "pywin32_ctypes-0.2.0-py2.py3-none-any.whl", hash = "sha256:9dc2d991b3479cc2df15930958b674a48a227d5361d413827a4cfd0b5876fc98"},
{file = "pywin32-ctypes-0.2.2.tar.gz", hash = "sha256:3426e063bdd5fd4df74a14fa3cf80a0b42845a87e1d1e81f6549f9daec593a60"},
{file = "pywin32_ctypes-0.2.2-py3-none-any.whl", hash = "sha256:bf490a1a709baf35d688fe0ecf980ed4de11d2b3e37b51e5442587a75d9957e7"},
]
[[package]]
@ -655,19 +659,19 @@ jeepney = ">=0.6"
[[package]]
name = "setuptools"
version = "67.6.1"
version = "68.0.0"
description = "Easily download, build, install, upgrade, and uninstall Python packages"
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "setuptools-67.6.1-py3-none-any.whl", hash = "sha256:e728ca814a823bf7bf60162daf9db95b93d532948c4c0bea762ce62f60189078"},
{file = "setuptools-67.6.1.tar.gz", hash = "sha256:257de92a9d50a60b8e22abfcbb771571fde0dbf3ec234463212027a4eeecbe9a"},
{file = "setuptools-68.0.0-py3-none-any.whl", hash = "sha256:11e52c67415a381d10d6b462ced9cfb97066179f0e871399e006c4ab101fc85f"},
{file = "setuptools-68.0.0.tar.gz", hash = "sha256:baf1fdb41c6da4cd2eae722e135500da913332ab3f2f5c7d33af9b492acb5235"},
]
[package.extras]
docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
[[package]]
@ -684,14 +688,14 @@ files = [
[[package]]
name = "yubikey-manager"
version = "5.1.0"
version = "5.1.1"
description = "Tool for managing your YubiKey configuration."
category = "main"
optional = false
python-versions = ">=3.7,<4.0"
files = [
{file = "yubikey_manager-5.1.0-py3-none-any.whl", hash = "sha256:72ac412319ee9c9db13173a68326de11478f1e8b3ed13b25bb3d33157b3f958e"},
{file = "yubikey_manager-5.1.0.tar.gz", hash = "sha256:d33efc9f82e511fd4d7c9397f6c40b37c7260221ca06fac93daeb4a46b1eb173"},
{file = "yubikey_manager-5.1.1-py3-none-any.whl", hash = "sha256:67291f1d9396d99845b710eabfb4b5ba41b5fa6cc0011104267f91914c1867e3"},
{file = "yubikey_manager-5.1.1.tar.gz", hash = "sha256:684102affd4a0d29611756da263c22f8e67226e80f65c5460c8c5608f9c0d58d"},
]
[package.dependencies]
@ -704,44 +708,44 @@ pywin32 = {version = ">=223", markers = "sys_platform == \"win32\""}
[[package]]
name = "zipp"
version = "3.15.0"
version = "3.16.2"
description = "Backport of pathlib-compatible object wrapper for zip files"
category = "main"
optional = false
python-versions = ">=3.7"
python-versions = ">=3.8"
files = [
{file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"},
{file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"},
{file = "zipp-3.16.2-py3-none-any.whl", hash = "sha256:679e51dd4403591b2d6838a48de3d283f3d188412a9782faadf845f298736ba0"},
{file = "zipp-3.16.2.tar.gz", hash = "sha256:ebc15946aa78bd63458992fc81ec3b6f7b1e92d51c35e6de1c3804e73b799147"},
]
[package.extras]
docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"]
docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"]
[[package]]
name = "zxing-cpp"
version = "2.0.0"
version = "2.1.0"
description = "Python bindings for the zxing-cpp barcode library"
category = "main"
optional = false
python-versions = ">=3.6"
files = [
{file = "zxing-cpp-2.0.0.tar.gz", hash = "sha256:1b67b221aae15aad9b5609d99c38d57875bc0a4fef864142d7ca37e9ee7880b0"},
{file = "zxing_cpp-2.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:54282d0e5c573754049113a0cdbf14cc1c6b986432a367d8a788112afa92a1d5"},
{file = "zxing_cpp-2.0.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76caafb8fc1e12c2e5ec33ce4f340a0e15e9a2aabfbfeaec170e8a2b405b8a77"},
{file = "zxing_cpp-2.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95dd06dc559f53c1ca0eb59dbaebd802ebc839937baaf2f8d2b3def3e814c07f"},
{file = "zxing_cpp-2.0.0-cp310-cp310-win32.whl", hash = "sha256:ea54fd242f93eea7bf039a68287e5e57fdf77d78e3bd5b4cbb2d289bb3380d63"},
{file = "zxing_cpp-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:8da9c912cca5829eedb2800ce3eaa1b1e52742f536aa9e798be69bf09639f399"},
{file = "zxing_cpp-2.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f70eefa5dc1fd9238087c024ef22f3d99ba79cb932a2c5bc5b0f1e152037722e"},
{file = "zxing_cpp-2.0.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:97919f07c62edf1c8e0722fd64893057ce636b7067cf47bd593e98cc7e404d74"},
{file = "zxing_cpp-2.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fd89065f620d6b78281308c6abfb760d95760a1c9b88eb7ac612b52b331bd41"},
{file = "zxing_cpp-2.0.0-cp311-cp311-win32.whl", hash = "sha256:631a0c783ad233c85295e0cf4cd7740f1fe2853124c61b1ef6bcf7eb5d2fa5e6"},
{file = "zxing_cpp-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:9f0c2c03f5df470ef71a7590be5042161e7590da767d4260a6d0d61a3fa80b88"},
{file = "zxing_cpp-2.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5ce391f21763f00d5be3431e16d075e263e4b9205c2cf55d708625cb234b1f15"},
{file = "zxing_cpp-2.0.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0eefdfad91e15e3f5b7ed16d83806a36f96ca482f4b042baa6297784a58b0b3"},
{file = "zxing_cpp-2.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d665c45029346c70ae3df5dbc36f6335ffe4f275e98dc43772fa32a65844196"},
{file = "zxing_cpp-2.0.0-cp39-cp39-win32.whl", hash = "sha256:214a6a0e49b92fda8d2761c74f5bfd24a677b9bf1d0ef0e083412486af97faa9"},
{file = "zxing_cpp-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:a788551ddf3a6ba1152ff9a0b81d57018a3cc586544087c39d881428745faf1f"},
{file = "zxing-cpp-2.1.0.tar.gz", hash = "sha256:7a8a468b420bf391707431d5a0dd881cb41033ae15f87820d93d5707c7bc55bc"},
{file = "zxing_cpp-2.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:26d27f61d627c06cc3e91b1ce816bd780c9227fd10b7ca961264f67bfb3bdf66"},
{file = "zxing_cpp-2.1.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4d9655c7d682ce252fe5c25f22c6fafe4c5ac493830fa8a2c062c85d061ce3b4"},
{file = "zxing_cpp-2.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:313bac052bd38bd2cedaa2610d880b3d62254dd6d8be01795559b73872c54ed0"},
{file = "zxing_cpp-2.1.0-cp310-cp310-win32.whl", hash = "sha256:0a178683b66422ac01ae35f749d58c50b271f9ab18def1c286f5fc61bcf81fa7"},
{file = "zxing_cpp-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:650d8f6731f11c04f4662a48f1efa9dc26c97bbdfa4f9b14b4683f43b7ccde4d"},
{file = "zxing_cpp-2.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4163d72975191d40c879bc130d5e8aa1eef5d5e6bfe820d94b5c9a2cb10d664e"},
{file = "zxing_cpp-2.1.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:843f72a1f2a8c397b4d92f757488b03d8597031e907442382d5662fd96b0fd21"},
{file = "zxing_cpp-2.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66d01d40bacc7e5b40e9fa474dab64f2e75a091c6e7c9d4a6b539b5a724127e3"},
{file = "zxing_cpp-2.1.0-cp311-cp311-win32.whl", hash = "sha256:8397ce7e1a7a92cd8f0045a4c64e4fcd97f4aaa51441d27bcb76eeda0a1917bc"},
{file = "zxing_cpp-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:a54cd56c0898cb63a08517b7d630484690a9bad4da1e443aebe64b7077444d90"},
{file = "zxing_cpp-2.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ab8fff5791e1d858390e45325500f6a17d5d3b6ac0237ae84ceda6f5b7a3685a"},
{file = "zxing_cpp-2.1.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba91ba2af0cc75c9e53bf95963f409c6fa26aa7df38469e2cdcb5b38a6c7c1c7"},
{file = "zxing_cpp-2.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7ba898e4f5ee9cd426d4271ff8b26911e3346b1cb4262f06fdc917e42b7c123"},
{file = "zxing_cpp-2.1.0-cp39-cp39-win32.whl", hash = "sha256:da081b763032b05326ddc53d3ad28a8b7603d662ccce2ff29fd204d587d3cac9"},
{file = "zxing_cpp-2.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:7245e551fc30e9708c0fd0f4d0d15f29c0b85075d20c18ddc53b87956a469544"},
]
[package.dependencies]
@ -750,4 +754,4 @@ numpy = "*"
[metadata]
lock-version = "2.0"
python-versions = "^3.8"
content-hash = "f0fc2e7d5ef423dc8b247ab6b968a63c331e78bd74bd72020b634f6823a74e3d"
content-hash = "0ada1b4785281f6f18fb483a1d84504be84adc84aec8c8c7812cc92ea44656d8"

View File

@ -10,14 +10,14 @@ packages = [
[tool.poetry.dependencies]
python = "^3.8"
yubikey-manager = "5.1.0"
mss = "^8.0.3"
yubikey-manager = "5.1.1"
mss = "^9.0.1"
zxing-cpp = "^2.0.0"
Pillow = "^9.5.0"
[tool.poetry.dev-dependencies]
pyinstaller = {version = "^5.10.1", python = "<3.12"}
pytest = "^7.3.1"
pyinstaller = {version = "^5.12.0", python = "<3.12"}
pytest = "^7.3.2"
[build-system]
requires = ["poetry-core>=1.0.0"]

View File

@ -15,4 +15,6 @@
*/
/// list of YubiKey serial numbers which are approved to be used with integration tests
var approvedYubiKeys = <String>[];
var approvedYubiKeys = <String>[
'',
];

View File

@ -14,14 +14,14 @@
* limitations under the License.
*/
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
import 'package:integration_test/integration_test.dart';
import 'package:yubico_authenticator/app/views/keys.dart' as app_keys;
import 'package:yubico_authenticator/management/views/keys.dart'
as management_keys;
import 'test_util.dart';
import 'utils/test_util.dart';
Key _getCapabilityWidgetKey(bool isUsb, String name) =>
Key('management.keys.capability.${isUsb ? 'usb' : 'nfc'}.$name');
@ -37,7 +37,8 @@ void main() {
group('Management UI tests', () {
appTest('Drawer items exist', (WidgetTester tester) async {
await tester.openDrawer();
expect(find.byKey(app_keys.managementAppDrawer), findsOneWidget);
expect(find.byKey(app_keys.managementAppDrawer).hitTestable(),
findsOneWidget);
});
});

View File

@ -19,8 +19,8 @@ import 'package:integration_test/integration_test.dart';
import 'package:yubico_authenticator/core/state.dart';
import 'package:yubico_authenticator/oath/keys.dart' as keys;
import 'oath_test_util.dart';
import 'test_util.dart';
import 'utils/oath_test_util.dart';
import 'utils/test_util.dart';
void main() {
var binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();

View File

@ -30,9 +30,10 @@ Future<void> startUp(WidgetTester tester,
// only wait for yubikey connection when needed
// needs_yubikey defaults to true
if (startUpParams['needs_yubikey'] != false) {
await tester.openDrawer();
// wait for a YubiKey connection
await tester.waitForFinder(find.descendant(
of: tester.findDeviceButton(),
of: find.byKey(app_keys.deviceInfoListTile),
matching: find.byWidgetPredicate((widget) =>
widget is DeviceAvatar && widget.key != app_keys.noDeviceAvatar)));
}

View File

@ -17,12 +17,13 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:yubico_authenticator/core/state.dart';
import 'package:yubico_authenticator/app/views/keys.dart' as app_keys;
import 'package:yubico_authenticator/oath/keys.dart' as keys;
import 'package:yubico_authenticator/oath/views/account_list.dart';
import 'package:yubico_authenticator/oath/views/account_view.dart';
import 'android/util.dart';
import 'test_util.dart';
import '../utils/test_util.dart';
class Account {
final String? issuer;
@ -235,8 +236,12 @@ extension OathFunctions on WidgetTester {
/// now the account dialog is shown
/// TODO verify it shows correct issuer and name
/// close the account dialog by tapping out of it
await tapAt(const Offset(10, 10));
/// close the account dialog by tapping the close button
var closeButton = find.byKey(app_keys.closeButton).hitTestable();
// Wait for toast to clear
await waitForFinder(closeButton);
await tap(closeButton);
await longWait();
/// verify accounts in the list

View File

@ -17,18 +17,17 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:yubico_authenticator/app/views/device_button.dart';
import 'package:yubico_authenticator/app/views/keys.dart' as app_keys;
import 'package:yubico_authenticator/app/views/keys.dart';
import 'package:yubico_authenticator/core/state.dart';
import 'package:yubico_authenticator/management/views/keys.dart';
import 'android/util.dart' as android_test_util;
import 'approved_yubikeys.dart';
import '../_approved_yubikeys.dart';
import 'desktop/util.dart' as desktop_test_util;
const shortWaitMs = 10;
const longWaitMs = 50;
const shortWaitMs = 500;
const longWaitMs = 500;
/// information about YubiKey as seen by the app
String? yubiKeyName;
@ -65,16 +64,6 @@ extension AppWidgetTester on WidgetTester {
return f;
}
Finder findDeviceButton() {
return find.byType(DeviceButton).hitTestable();
}
/// Taps the device button
Future<void> tapDeviceButton() async {
await tap(findDeviceButton());
await pump(const Duration(milliseconds: 500));
}
Finder findActionIconButton() {
return find.byKey(actionsIconButtonKey).hitTestable();
}
@ -119,7 +108,7 @@ extension AppWidgetTester on WidgetTester {
await openDrawer();
}
await tap(find.byKey(managementAppDrawer));
await tap(find.byKey(managementAppDrawer).hitTestable());
await pump(const Duration(milliseconds: 500));
expect(find.byKey(screenKey), findsOneWidget);
@ -153,17 +142,22 @@ extension AppWidgetTester on WidgetTester {
return;
}
await tapDeviceButton();
await openDrawer();
var deviceInfo = find.byKey(app_keys.deviceInfoListTile);
if (deviceInfo.evaluate().isNotEmpty) {
ListTile lt = deviceInfo.evaluate().single.widget as ListTile;
ListTile lt = find
.descendant(of: deviceInfo, matching: find.byType(ListTile))
.evaluate()
.single
.widget as ListTile;
//ListTile lt = deviceInfo.evaluate().single.widget as ListTile;
yubiKeyName = (lt.title as Text).data;
var subtitle = (lt.subtitle as Text?)?.data;
if (subtitle != null) {
RegExpMatch? match = RegExp(r'S/N: (\d.*) F/W: (\d\.\d\.\d)')
.firstMatch(subtitle);
RegExpMatch? match =
RegExp(r'S/N: (\d.*) F/W: (\d\.\d\.\d)').firstMatch(subtitle);
if (match != null) {
yubiKeySerialNumber = match.group(1);
yubiKeyFirmware = match.group(2);
@ -177,7 +171,7 @@ extension AppWidgetTester on WidgetTester {
}
// close the opened menu
await tapTopLeftCorner();
await closeDrawer();
testLog(false,
'Connected YubiKey: $yubiKeySerialNumber/$yubiKeyFirmware - $yubiKeyName');

View File

@ -23,22 +23,19 @@ import '../../app/models.dart';
import '../../app/state.dart';
import '../../management/state.dart';
final androidManagementState = StateNotifierProvider.autoDispose
.family<ManagementStateNotifier, AsyncValue<DeviceInfo>, DevicePath>(
(ref, devicePath) {
// Make sure to rebuild if currentDevice changes (as on reboot)
ref.watch(currentDeviceProvider);
final notifier = _AndroidManagementStateNotifier(ref);
return notifier..refresh();
},
final androidManagementState = AsyncNotifierProvider.autoDispose
.family<ManagementStateNotifier, DeviceInfo, DevicePath>(
_AndroidManagementStateNotifier.new,
);
class _AndroidManagementStateNotifier extends ManagementStateNotifier {
final Ref _ref;
@override
FutureOr<DeviceInfo> build(DevicePath devicePath) {
// Make sure to rebuild if currentDevice changes (as on reboot)
ref.watch(currentDeviceProvider);
_AndroidManagementStateNotifier(this._ref) : super();
void refresh() async {}
return Completer<DeviceInfo>().future;
}
@override
Future<void> setMode(
@ -55,6 +52,6 @@ class _AndroidManagementStateNotifier extends ManagementStateNotifier {
state = const AsyncValue.loading();
}
_ref.read(attachedDevicesProvider.notifier).refresh();
ref.read(attachedDevicesProvider.notifier).refresh();
}
}

View File

@ -36,33 +36,31 @@ final _log = Logger('android.oath.state');
const _methods = MethodChannel('android.oath.methods');
final androidOathStateProvider = StateNotifierProvider.autoDispose
.family<OathStateNotifier, AsyncValue<OathState>, DevicePath>(
(ref, devicePath) => _AndroidOathStateNotifier());
final androidOathStateProvider = AsyncNotifierProvider.autoDispose
.family<OathStateNotifier, OathState, DevicePath>(
_AndroidOathStateNotifier.new);
class _AndroidOathStateNotifier extends OathStateNotifier {
final _events = const EventChannel('android.oath.sessionState');
late StreamSubscription _sub;
_AndroidOathStateNotifier() : super() {
@override
FutureOr<OathState> build(DevicePath arg) {
_sub = _events.receiveBroadcastStream().listen((event) {
final json = jsonDecode(event);
if (mounted) {
if (json == null) {
state = const AsyncValue.loading();
} else {
final oathState = OathState.fromJson(json);
state = AsyncValue.data(oathState);
}
}
}, onError: (err, stackTrace) {
state = AsyncValue.error(err, stackTrace);
});
}
@override
void dispose() {
_sub.cancel();
super.dispose();
ref.onDispose(_sub.cancel);
return Completer<OathState>().future;
}
@override

View File

@ -20,7 +20,6 @@ import 'dart:ui';
import 'package:flutter/material.dart';
import '../widgets/toast.dart';
import 'models.dart';
void Function() showMessage(
BuildContext context,
@ -29,42 +28,12 @@ void Function() showMessage(
}) =>
showToast(context, message, duration: duration);
Future<void> showBottomMenu(
BuildContext context, List<MenuAction> actions) async {
await showBlurDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Options'),
contentPadding: const EdgeInsets.only(bottom: 24, top: 4),
content: Column(
mainAxisSize: MainAxisSize.min,
children: actions
.map((a) => ListTile(
leading: a.icon,
title: Text(a.text),
contentPadding:
const EdgeInsets.symmetric(horizontal: 24),
enabled: a.intent != null,
onTap: a.intent == null
? null
: () {
Navigator.pop(context);
Actions.invoke(context, a.intent!);
},
))
.toList(),
),
);
});
}
Future<T?> showBlurDialog<T>({
required BuildContext context,
required Widget Function(BuildContext) builder,
RouteSettings? routeSettings,
}) =>
showGeneralDialog(
}) async =>
await showGeneralDialog<T>(
context: context,
barrierDismissible: true,
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,

View File

@ -53,6 +53,7 @@ enum Application {
String getDisplayName(AppLocalizations l10n) => switch (this) {
Application.oath => l10n.s_authenticator,
Application.fido => l10n.s_webauthn,
Application.piv => l10n.s_piv,
_ => name.substring(0, 1).toUpperCase() + name.substring(1),
};
@ -115,14 +116,20 @@ class DeviceNode with _$DeviceNode {
map(usbYubiKey: (_) => Transport.usb, nfcReader: (_) => Transport.nfc);
}
enum ActionStyle { normal, primary, error }
@freezed
class MenuAction with _$MenuAction {
factory MenuAction({
required String text,
class ActionItem with _$ActionItem {
factory ActionItem({
required Widget icon,
String? trailing,
required String title,
String? subtitle,
String? shortcut,
Widget? trailing,
Intent? intent,
}) = _MenuAction;
ActionStyle? actionStyle,
Key? key,
}) = _ActionItem;
}
@freezed

View File

@ -624,30 +624,42 @@ abstract class NfcReaderNode extends DeviceNode {
}
/// @nodoc
mixin _$MenuAction {
String get text => throw _privateConstructorUsedError;
mixin _$ActionItem {
Widget get icon => throw _privateConstructorUsedError;
String? get trailing => throw _privateConstructorUsedError;
String get title => throw _privateConstructorUsedError;
String? get subtitle => throw _privateConstructorUsedError;
String? get shortcut => throw _privateConstructorUsedError;
Widget? get trailing => throw _privateConstructorUsedError;
Intent? get intent => throw _privateConstructorUsedError;
ActionStyle? get actionStyle => throw _privateConstructorUsedError;
Key? get key => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$MenuActionCopyWith<MenuAction> get copyWith =>
$ActionItemCopyWith<ActionItem> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $MenuActionCopyWith<$Res> {
factory $MenuActionCopyWith(
MenuAction value, $Res Function(MenuAction) then) =
_$MenuActionCopyWithImpl<$Res, MenuAction>;
abstract class $ActionItemCopyWith<$Res> {
factory $ActionItemCopyWith(
ActionItem value, $Res Function(ActionItem) then) =
_$ActionItemCopyWithImpl<$Res, ActionItem>;
@useResult
$Res call({String text, Widget icon, String? trailing, Intent? intent});
$Res call(
{Widget icon,
String title,
String? subtitle,
String? shortcut,
Widget? trailing,
Intent? intent,
ActionStyle? actionStyle,
Key? key});
}
/// @nodoc
class _$MenuActionCopyWithImpl<$Res, $Val extends MenuAction>
implements $MenuActionCopyWith<$Res> {
_$MenuActionCopyWithImpl(this._value, this._then);
class _$ActionItemCopyWithImpl<$Res, $Val extends ActionItem>
implements $ActionItemCopyWith<$Res> {
_$ActionItemCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
@ -657,140 +669,223 @@ class _$MenuActionCopyWithImpl<$Res, $Val extends MenuAction>
@pragma('vm:prefer-inline')
@override
$Res call({
Object? text = null,
Object? icon = null,
Object? title = null,
Object? subtitle = freezed,
Object? shortcut = freezed,
Object? trailing = freezed,
Object? intent = freezed,
Object? actionStyle = freezed,
Object? key = freezed,
}) {
return _then(_value.copyWith(
text: null == text
? _value.text
: text // ignore: cast_nullable_to_non_nullable
as String,
icon: null == icon
? _value.icon
: icon // ignore: cast_nullable_to_non_nullable
as Widget,
title: null == title
? _value.title
: title // ignore: cast_nullable_to_non_nullable
as String,
subtitle: freezed == subtitle
? _value.subtitle
: subtitle // ignore: cast_nullable_to_non_nullable
as String?,
shortcut: freezed == shortcut
? _value.shortcut
: shortcut // ignore: cast_nullable_to_non_nullable
as String?,
trailing: freezed == trailing
? _value.trailing
: trailing // ignore: cast_nullable_to_non_nullable
as String?,
as Widget?,
intent: freezed == intent
? _value.intent
: intent // ignore: cast_nullable_to_non_nullable
as Intent?,
actionStyle: freezed == actionStyle
? _value.actionStyle
: actionStyle // ignore: cast_nullable_to_non_nullable
as ActionStyle?,
key: freezed == key
? _value.key
: key // ignore: cast_nullable_to_non_nullable
as Key?,
) as $Val);
}
}
/// @nodoc
abstract class _$$_MenuActionCopyWith<$Res>
implements $MenuActionCopyWith<$Res> {
factory _$$_MenuActionCopyWith(
_$_MenuAction value, $Res Function(_$_MenuAction) then) =
__$$_MenuActionCopyWithImpl<$Res>;
abstract class _$$_ActionItemCopyWith<$Res>
implements $ActionItemCopyWith<$Res> {
factory _$$_ActionItemCopyWith(
_$_ActionItem value, $Res Function(_$_ActionItem) then) =
__$$_ActionItemCopyWithImpl<$Res>;
@override
@useResult
$Res call({String text, Widget icon, String? trailing, Intent? intent});
$Res call(
{Widget icon,
String title,
String? subtitle,
String? shortcut,
Widget? trailing,
Intent? intent,
ActionStyle? actionStyle,
Key? key});
}
/// @nodoc
class __$$_MenuActionCopyWithImpl<$Res>
extends _$MenuActionCopyWithImpl<$Res, _$_MenuAction>
implements _$$_MenuActionCopyWith<$Res> {
__$$_MenuActionCopyWithImpl(
_$_MenuAction _value, $Res Function(_$_MenuAction) _then)
class __$$_ActionItemCopyWithImpl<$Res>
extends _$ActionItemCopyWithImpl<$Res, _$_ActionItem>
implements _$$_ActionItemCopyWith<$Res> {
__$$_ActionItemCopyWithImpl(
_$_ActionItem _value, $Res Function(_$_ActionItem) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? text = null,
Object? icon = null,
Object? title = null,
Object? subtitle = freezed,
Object? shortcut = freezed,
Object? trailing = freezed,
Object? intent = freezed,
Object? actionStyle = freezed,
Object? key = freezed,
}) {
return _then(_$_MenuAction(
text: null == text
? _value.text
: text // ignore: cast_nullable_to_non_nullable
as String,
return _then(_$_ActionItem(
icon: null == icon
? _value.icon
: icon // ignore: cast_nullable_to_non_nullable
as Widget,
title: null == title
? _value.title
: title // ignore: cast_nullable_to_non_nullable
as String,
subtitle: freezed == subtitle
? _value.subtitle
: subtitle // ignore: cast_nullable_to_non_nullable
as String?,
shortcut: freezed == shortcut
? _value.shortcut
: shortcut // ignore: cast_nullable_to_non_nullable
as String?,
trailing: freezed == trailing
? _value.trailing
: trailing // ignore: cast_nullable_to_non_nullable
as String?,
as Widget?,
intent: freezed == intent
? _value.intent
: intent // ignore: cast_nullable_to_non_nullable
as Intent?,
actionStyle: freezed == actionStyle
? _value.actionStyle
: actionStyle // ignore: cast_nullable_to_non_nullable
as ActionStyle?,
key: freezed == key
? _value.key
: key // ignore: cast_nullable_to_non_nullable
as Key?,
));
}
}
/// @nodoc
class _$_MenuAction implements _MenuAction {
_$_MenuAction(
{required this.text, required this.icon, this.trailing, this.intent});
class _$_ActionItem implements _ActionItem {
_$_ActionItem(
{required this.icon,
required this.title,
this.subtitle,
this.shortcut,
this.trailing,
this.intent,
this.actionStyle,
this.key});
@override
final String text;
@override
final Widget icon;
@override
final String? trailing;
final String title;
@override
final String? subtitle;
@override
final String? shortcut;
@override
final Widget? trailing;
@override
final Intent? intent;
@override
final ActionStyle? actionStyle;
@override
final Key? key;
@override
String toString() {
return 'MenuAction(text: $text, icon: $icon, trailing: $trailing, intent: $intent)';
return 'ActionItem(icon: $icon, title: $title, subtitle: $subtitle, shortcut: $shortcut, trailing: $trailing, intent: $intent, actionStyle: $actionStyle, key: $key)';
}
@override
bool operator ==(dynamic other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$_MenuAction &&
(identical(other.text, text) || other.text == text) &&
other is _$_ActionItem &&
(identical(other.icon, icon) || other.icon == icon) &&
(identical(other.title, title) || other.title == title) &&
(identical(other.subtitle, subtitle) ||
other.subtitle == subtitle) &&
(identical(other.shortcut, shortcut) ||
other.shortcut == shortcut) &&
(identical(other.trailing, trailing) ||
other.trailing == trailing) &&
(identical(other.intent, intent) || other.intent == intent));
(identical(other.intent, intent) || other.intent == intent) &&
(identical(other.actionStyle, actionStyle) ||
other.actionStyle == actionStyle) &&
(identical(other.key, key) || other.key == key));
}
@override
int get hashCode => Object.hash(runtimeType, text, icon, trailing, intent);
int get hashCode => Object.hash(runtimeType, icon, title, subtitle, shortcut,
trailing, intent, actionStyle, key);
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$_MenuActionCopyWith<_$_MenuAction> get copyWith =>
__$$_MenuActionCopyWithImpl<_$_MenuAction>(this, _$identity);
_$$_ActionItemCopyWith<_$_ActionItem> get copyWith =>
__$$_ActionItemCopyWithImpl<_$_ActionItem>(this, _$identity);
}
abstract class _MenuAction implements MenuAction {
factory _MenuAction(
{required final String text,
required final Widget icon,
final String? trailing,
final Intent? intent}) = _$_MenuAction;
abstract class _ActionItem implements ActionItem {
factory _ActionItem(
{required final Widget icon,
required final String title,
final String? subtitle,
final String? shortcut,
final Widget? trailing,
final Intent? intent,
final ActionStyle? actionStyle,
final Key? key}) = _$_ActionItem;
@override
String get text;
@override
Widget get icon;
@override
String? get trailing;
String get title;
@override
String? get subtitle;
@override
String? get shortcut;
@override
Widget? get trailing;
@override
Intent? get intent;
@override
ActionStyle? get actionStyle;
@override
Key? get key;
@override
@JsonKey(ignore: true)
_$$_MenuActionCopyWith<_$_MenuAction> get copyWith =>
_$$_ActionItemCopyWith<_$_ActionItem> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@ -28,6 +28,7 @@ import '../oath/keys.dart';
import 'message.dart';
import 'models.dart';
import 'state.dart';
import 'views/keys.dart';
import 'views/settings_page.dart';
class OpenIntent extends Intent {
@ -100,7 +101,10 @@ Widget registerGlobalShortcuts(
}),
NextDeviceIntent: CallbackAction<NextDeviceIntent>(onInvoke: (_) {
ref.read(withContextProvider)((context) async {
if (!Navigator.of(context).canPop()) {
// Only allow switching keys if no other views are open,
// with the exception of the drawer.
if (!Navigator.of(context).canPop() ||
scaffoldGlobalKey.currentState?.isDrawerOpen == true) {
final attached = ref
.read(attachedDevicesProvider)
.whereType<UsbYubiKeyNode>()

View File

@ -0,0 +1,109 @@
/*
* 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.
*/
import 'package:flutter/material.dart';
import '../../widgets/list_title.dart';
import '../models.dart';
class ActionListItem extends StatelessWidget {
final Widget icon;
final String title;
final String? subtitle;
final Widget? trailing;
final void Function(BuildContext context)? onTap;
final ActionStyle actionStyle;
const ActionListItem({
super.key,
required this.icon,
required this.title,
this.subtitle,
this.trailing,
this.onTap,
this.actionStyle = ActionStyle.normal,
});
@override
Widget build(BuildContext context) {
final theme =
ButtonTheme.of(context).colorScheme ?? Theme.of(context).colorScheme;
final (foreground, background) = switch (actionStyle) {
ActionStyle.normal => (theme.onSecondary, theme.secondary),
ActionStyle.primary => (theme.onPrimary, theme.primary),
ActionStyle.error => (theme.onError, theme.error),
};
return ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(30)),
title: Text(title),
subtitle: subtitle != null ? Text(subtitle!) : null,
leading: Opacity(
opacity: onTap != null ? 1.0 : 0.4,
child: CircleAvatar(
foregroundColor: foreground,
backgroundColor: background,
child: icon,
),
),
trailing: trailing,
onTap: onTap != null ? () => onTap?.call(context) : null,
enabled: onTap != null,
);
}
}
class ActionListSection extends StatelessWidget {
final String title;
final List<ActionListItem> children;
const ActionListSection(this.title, {super.key, required this.children});
factory ActionListSection.fromMenuActions(BuildContext context, String title,
{Key? key, required List<ActionItem> actions}) {
return ActionListSection(
key: key,
title,
children: actions.map((action) {
final intent = action.intent;
return ActionListItem(
key: action.key,
actionStyle: action.actionStyle ?? ActionStyle.normal,
icon: action.icon,
title: action.title,
subtitle: action.subtitle,
onTap: intent != null
? (context) => Actions.invoke(context, intent)
: null,
trailing: action.trailing,
);
}).toList(),
);
}
@override
Widget build(BuildContext context) => SizedBox(
width: 360,
child: Column(children: [
ListTitle(
title,
textStyle: Theme.of(context).textTheme.bodyLarge,
),
...children,
]),
);
}

View File

@ -0,0 +1,66 @@
/*
* Copyright (C) 2022-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.
*/
import 'dart:async';
import 'package:flutter/material.dart';
import '../models.dart';
Future showPopupMenu(BuildContext context, Offset globalPosition,
List<ActionItem> actions) =>
showMenu(
context: context,
position: RelativeRect.fromLTRB(
globalPosition.dx,
globalPosition.dy,
globalPosition.dx,
0,
),
items: actions.map((e) => _buildMenuItem(context, e)).toList(),
);
PopupMenuItem _buildMenuItem(BuildContext context, ActionItem actionItem) {
final intent = actionItem.intent;
final enabled = intent != null;
final shortcut = actionItem.shortcut;
return PopupMenuItem(
enabled: enabled,
onTap: enabled
? () {
// Wait for popup menu to close before running action.
Timer.run(() {
Actions.invoke(context, intent);
});
}
: null,
child: ListTile(
key: actionItem.key,
enabled: enabled,
dense: true,
contentPadding: EdgeInsets.zero,
minLeadingWidth: 0,
title: Text(actionItem.title),
leading: actionItem.icon,
trailing: shortcut != null
? Opacity(
opacity: 0.5,
child: Text(shortcut, textScaleFactor: 0.7),
)
: null,
),
);
}

View File

@ -0,0 +1,137 @@
/*
* 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.
*/
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../core/state.dart';
import '../models.dart';
import '../shortcuts.dart';
import 'action_popup_menu.dart';
class AppListItem extends StatefulWidget {
final Widget? leading;
final String title;
final String? subtitle;
final Widget? trailing;
final List<ActionItem> Function(BuildContext context)? buildPopupActions;
final Intent? activationIntent;
const AppListItem({
super.key,
this.leading,
required this.title,
this.subtitle,
this.trailing,
this.buildPopupActions,
this.activationIntent,
});
@override
State<StatefulWidget> createState() => _AppListItemState();
}
class _AppListItemState extends State<AppListItem> {
final FocusNode _focusNode = FocusNode();
int _lastTap = 0;
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final subtitle = widget.subtitle;
final buildPopupActions = widget.buildPopupActions;
final activationIntent = widget.activationIntent;
final trailing = widget.trailing;
return Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.enter): const OpenIntent(),
LogicalKeySet(LogicalKeyboardKey.space): const OpenIntent(),
},
child: InkWell(
focusNode: _focusNode,
borderRadius: BorderRadius.circular(30),
onSecondaryTapDown: buildPopupActions == null
? null
: (details) {
showPopupMenu(
context,
details.globalPosition,
buildPopupActions(context),
);
},
onTap: () {
if (isDesktop) {
final now = DateTime.now().millisecondsSinceEpoch;
if (now - _lastTap < 500) {
setState(() {
_lastTap = 0;
});
Actions.invoke(context, activationIntent ?? const OpenIntent());
} else {
_focusNode.requestFocus();
setState(() {
_lastTap = now;
});
}
} else {
Actions.invoke<OpenIntent>(context, const OpenIntent());
}
},
onLongPress: activationIntent == null
? null
: () {
Actions.invoke(context, activationIntent);
},
child: Stack(
alignment: AlignmentDirectional.center,
children: [
const SizedBox(height: 64),
ListTile(
leading: widget.leading,
title: Text(
widget.title,
overflow: TextOverflow.fade,
maxLines: 1,
softWrap: false,
),
subtitle: subtitle != null
? Text(
subtitle,
overflow: TextOverflow.fade,
maxLines: 1,
softWrap: false,
)
: null,
trailing: trailing == null
? null
: Focus(
skipTraversal: true,
descendantsAreTraversable: false,
child: trailing,
),
),
],
),
),
);
}
}

View File

@ -16,18 +16,23 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:yubico_authenticator/core/state.dart';
import '../../widgets/delayed_visibility.dart';
import '../message.dart';
import 'device_button.dart';
import 'keys.dart';
import 'main_drawer.dart';
import 'navigation.dart';
// We use global keys here to maintain the NavigatorContent between AppPages.
final _navKey = GlobalKey();
final _navExpandedKey = GlobalKey();
class AppPage extends StatelessWidget {
final Widget? title;
final Widget child;
final List<Widget> actions;
final Widget Function(BuildContext context)? keyActionsBuilder;
final bool keyActionsBadge;
final bool centered;
final bool delayedContent;
final Widget Function(BuildContext context)? actionButtonBuilder;
@ -40,31 +45,49 @@ class AppPage extends StatelessWidget {
this.keyActionsBuilder,
this.actionButtonBuilder,
this.delayedContent = false,
this.keyActionsBadge = false,
});
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < 540) {
// Single column layout
return _buildScaffold(context, true);
final bool singleColumn;
final bool hasRail;
if (isAndroid) {
final isPortrait = constraints.maxWidth < constraints.maxHeight;
singleColumn = isPortrait || constraints.maxWidth < 600;
hasRail = constraints.maxWidth > 600;
} else {
// Two-column layout
singleColumn = constraints.maxWidth < 600;
hasRail = constraints.maxWidth > 400;
}
if (singleColumn) {
// Single column layout, maybe with rail
return _buildScaffold(context, true, hasRail);
} else {
// Fully expanded layout
return Scaffold(
body: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 280,
child: DrawerTheme(
data: DrawerTheme.of(context).copyWith(
// Don't color the drawer differently
surfaceTintColor: Colors.transparent,
child: SingleChildScrollView(
child: Column(
children: [
_buildLogo(context),
NavigationContent(
key: _navExpandedKey,
shouldPop: false,
extended: true,
),
],
),
child: const MainPageDrawer(shouldPop: false),
),
),
Expanded(
child: _buildScaffold(context, false),
child: _buildScaffold(context, false, false),
),
],
),
@ -73,7 +96,49 @@ class AppPage extends StatelessWidget {
},
);
Widget _buildScrollView() {
Widget _buildLogo(BuildContext context) {
final color =
Theme.of(context).brightness == Brightness.dark ? 'white' : 'green';
return Padding(
padding: const EdgeInsets.only(top: 16, bottom: 12),
child: Image.asset(
'assets/graphics/yubico-$color.png',
alignment: Alignment.centerLeft,
height: 28,
filterQuality: FilterQuality.medium,
),
);
}
Widget _buildDrawer(BuildContext context) {
return Drawer(
child: SafeArea(
child: SingleChildScrollView(
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: const EdgeInsets.only(left: 16),
child: DrawerButton(
onPressed: () {
Navigator.of(context).pop();
},
),
),
_buildLogo(context),
const SizedBox(width: 48),
],
),
NavigationContent(key: _navExpandedKey, extended: true),
],
),
),
));
}
Widget _buildMainContent() {
final content = Column(
children: [
child,
@ -81,8 +146,7 @@ class AppPage extends StatelessWidget {
Align(
alignment: centered ? Alignment.center : Alignment.centerLeft,
child: Padding(
padding:
const EdgeInsets.symmetric(vertical: 16.0, horizontal: 18.0),
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 18),
child: Wrap(
spacing: 4,
runSpacing: 4,
@ -93,6 +157,7 @@ class AppPage extends StatelessWidget {
],
);
return SingleChildScrollView(
primary: false,
child: SafeArea(
child: Center(
child: SizedBox(
@ -110,7 +175,27 @@ class AppPage extends StatelessWidget {
);
}
Scaffold _buildScaffold(BuildContext context, bool hasDrawer) {
Scaffold _buildScaffold(BuildContext context, bool hasDrawer, bool hasRail) {
var body =
centered ? Center(child: _buildMainContent()) : _buildMainContent();
if (hasRail) {
body = Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 72,
child: SingleChildScrollView(
child: NavigationContent(
key: _navKey,
shouldPop: false,
extended: false,
),
),
),
Expanded(child: body),
],
);
}
return Scaffold(
key: scaffoldGlobalKey,
appBar: AppBar(
@ -118,6 +203,20 @@ class AppPage extends StatelessWidget {
titleSpacing: hasDrawer ? 2 : 8,
centerTitle: true,
titleTextStyle: Theme.of(context).textTheme.titleLarge,
leadingWidth: hasRail ? 84 : null,
leading: hasRail
? const Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: DrawerButton(),
)),
SizedBox(width: 12),
],
)
: null,
actions: [
if (actionButtonBuilder == null && keyActionsBuilder != null)
Padding(
@ -127,20 +226,25 @@ class AppPage extends StatelessWidget {
onPressed: () {
showBlurDialog(context: context, builder: keyActionsBuilder!);
},
icon: const Icon(Icons.tune),
icon: keyActionsBadge
? const Badge(
child: Icon(Icons.tune),
)
: const Icon(Icons.tune),
iconSize: 24,
tooltip: AppLocalizations.of(context)!.s_configure_yk,
padding: const EdgeInsets.all(12),
),
),
if (actionButtonBuilder != null)
Padding(
padding: const EdgeInsets.only(right: 12),
child: actionButtonBuilder?.call(context) ?? const DeviceButton(),
child: actionButtonBuilder!.call(context),
),
],
),
drawer: hasDrawer ? const MainPageDrawer() : null,
body: centered ? Center(child: _buildScrollView()) : _buildScrollView(),
drawer: hasDrawer ? _buildDrawer(context) : null,
body: body,
);
}
}

View File

@ -1,62 +0,0 @@
/*
* Copyright (C) 2022 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 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../../core/state.dart';
import '../message.dart';
import 'device_avatar.dart';
import 'device_picker_dialog.dart';
class _CircledDeviceAvatar extends ConsumerWidget {
final double radius;
const _CircledDeviceAvatar(this.radius);
@override
Widget build(BuildContext context, WidgetRef ref) => CircleAvatar(
radius: radius,
backgroundColor: Theme.of(context).colorScheme.primary,
child: IconTheme(
// Force the standard icon theme
data: IconTheme.of(context),
child: DeviceAvatar.currentDevice(ref, radius: radius - 1),
),
);
}
class DeviceButton extends ConsumerWidget {
final double radius;
const DeviceButton({super.key, this.radius = 16});
@override
Widget build(BuildContext context, WidgetRef ref) {
return IconButton(
tooltip: isAndroid
? AppLocalizations.of(context)!.s_yk_information
: AppLocalizations.of(context)!.s_select_yk,
icon: _CircledDeviceAvatar(radius),
onPressed: () async {
await showBlurDialog(
context: context,
builder: (context) => const DevicePickerDialog(),
routeSettings: const RouteSettings(name: 'device_picker'),
);
},
);
}
}

View File

@ -0,0 +1,405 @@
/*
* Copyright (C) 2022-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.
*/
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../../core/state.dart';
import '../../management/models.dart';
import '../models.dart';
import '../state.dart';
import 'device_avatar.dart';
import 'keys.dart' as keys;
final _hiddenDevicesProvider =
StateNotifierProvider<_HiddenDevicesNotifier, List<String>>(
(ref) => _HiddenDevicesNotifier(ref.watch(prefProvider)));
class _HiddenDevicesNotifier extends StateNotifier<List<String>> {
static const String _key = 'DEVICE_PICKER_HIDDEN';
final SharedPreferences _prefs;
_HiddenDevicesNotifier(this._prefs) : super(_prefs.getStringList(_key) ?? []);
void showAll() {
state = [];
_prefs.setStringList(_key, state);
}
void hideDevice(DevicePath devicePath) {
state = [...state, devicePath.key];
_prefs.setStringList(_key, state);
}
}
List<(Widget, bool)> buildDeviceList(
BuildContext context, WidgetRef ref, bool extended) {
final l10n = AppLocalizations.of(context)!;
final hidden = ref.watch(_hiddenDevicesProvider);
final devices = ref
.watch(attachedDevicesProvider)
.where((e) => !hidden.contains(e.path.key))
.toList();
final currentNode = ref.watch(currentDeviceProvider);
final showUsb = isDesktop && devices.whereType<UsbYubiKeyNode>().isEmpty;
return [
if (showUsb)
(
_DeviceRow(
leading: const DeviceAvatar(child: Icon(Icons.usb)),
title: l10n.s_usb,
subtitle: l10n.l_no_yk_present,
onTap: () {
ref.read(currentDeviceProvider.notifier).setCurrentDevice(null);
},
selected: currentNode == null,
extended: extended,
),
currentNode == null
),
...devices.map(
(e) => e.path == currentNode?.path
? (
_buildCurrentDeviceRow(
context,
ref,
e,
ref.watch(currentDeviceDataProvider),
extended,
),
true
)
: (
e.map(
usbYubiKey: (node) => _buildDeviceRow(
context,
ref,
node,
node.info,
extended,
),
nfcReader: (node) => _NfcDeviceRow(node, extended: extended),
),
false
),
),
];
}
class DevicePickerContent extends ConsumerWidget {
final bool extended;
const DevicePickerContent({super.key, this.extended = true});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final hidden = ref.watch(_hiddenDevicesProvider);
final devices = ref
.watch(attachedDevicesProvider)
.where((e) => !hidden.contains(e.path.key))
.toList();
final currentNode = ref.watch(currentDeviceProvider);
final showUsb = isDesktop && devices.whereType<UsbYubiKeyNode>().isEmpty;
List<Widget> children = [
if (showUsb)
_DeviceRow(
leading: const DeviceAvatar(child: Icon(Icons.usb)),
title: l10n.s_usb,
subtitle: l10n.l_no_yk_present,
onTap: () {
ref.read(currentDeviceProvider.notifier).setCurrentDevice(null);
},
selected: currentNode == null,
extended: extended,
),
...devices.map(
(e) => e.path == currentNode?.path
? _buildCurrentDeviceRow(
context,
ref,
e,
ref.watch(currentDeviceDataProvider),
extended,
)
: e.map(
usbYubiKey: (node) => _buildDeviceRow(
context,
ref,
node,
node.info,
extended,
),
nfcReader: (node) => _NfcDeviceRow(node, extended: extended),
),
),
];
return GestureDetector(
onSecondaryTapDown: hidden.isEmpty
? null
: (details) {
showMenu(
context: context,
position: RelativeRect.fromLTRB(
details.globalPosition.dx,
details.globalPosition.dy,
details.globalPosition.dx,
0,
),
items: [
PopupMenuItem(
onTap: () {
ref.read(_hiddenDevicesProvider.notifier).showAll();
},
child: ListTile(
title: Text(l10n.s_show_hidden_devices),
dense: true,
contentPadding: EdgeInsets.zero,
),
),
],
);
},
child: Column(
children: children,
),
);
}
}
String _getDeviceInfoString(BuildContext context, DeviceInfo info) {
final l10n = AppLocalizations.of(context)!;
final serial = info.serial;
return [
if (serial != null) l10n.s_sn_serial(serial),
if (info.version.isAtLeast(1))
l10n.s_fw_version(info.version)
else
l10n.s_unknown_type,
].join(' ');
}
List<String> _getDeviceStrings(
BuildContext context, DeviceNode node, AsyncValue<YubiKeyData> data) {
final l10n = AppLocalizations.of(context)!;
final messages = data.whenOrNull(
data: (data) => [data.name, _getDeviceInfoString(context, data.info)],
error: (error, _) => switch (error) {
'device-inaccessible' => [node.name, l10n.s_yk_inaccessible],
'unknown-device' => [l10n.s_unknown_device],
_ => null,
},
) ??
[l10n.l_no_yk_present];
// Add the NFC reader name, unless it's already included (as device name, like on Android)
if (node is NfcReaderNode && !messages.contains(node.name)) {
messages.add(node.name);
}
return messages;
}
class _DeviceRow extends StatelessWidget {
final Widget leading;
final String title;
final String subtitle;
final bool extended;
final bool selected;
final void Function() onTap;
const _DeviceRow({
super.key,
required this.leading,
required this.title,
required this.subtitle,
required this.extended,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final tooltip = '$title\n$subtitle';
if (extended) {
final colorScheme = Theme.of(context).colorScheme;
return Tooltip(
message: tooltip,
child: ListTile(
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(48)),
contentPadding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 0),
horizontalTitleGap: 8,
leading: IconTheme(
// Force the standard icon theme
data: IconTheme.of(context),
child: leading,
),
title: Text(title, overflow: TextOverflow.fade, softWrap: false),
subtitle:
Text(subtitle, overflow: TextOverflow.fade, softWrap: false),
dense: true,
tileColor: selected ? colorScheme.primary : null,
textColor: selected ? colorScheme.onPrimary : null,
onTap: onTap,
),
);
} else {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.5),
child: selected
? IconButton.filled(
tooltip: tooltip,
icon: IconTheme(
// Force the standard icon theme
data: IconTheme.of(context),
child: leading,
),
padding: const EdgeInsets.symmetric(horizontal: 12),
onPressed: onTap,
)
: IconButton(
tooltip: tooltip,
icon: IconTheme(
// Force the standard icon theme
data: IconTheme.of(context),
child: leading,
),
padding: const EdgeInsets.symmetric(horizontal: 8),
onPressed: onTap,
),
);
}
}
}
_DeviceRow _buildDeviceRow(
BuildContext context,
WidgetRef ref,
DeviceNode node,
DeviceInfo? info,
bool extended,
) {
final l10n = AppLocalizations.of(context)!;
final subtitle = node.when(
usbYubiKey: (_, __, ___, info) => info == null
? l10n.s_yk_inaccessible
: _getDeviceInfoString(context, info),
nfcReader: (_, __) => l10n.s_select_to_scan,
);
return _DeviceRow(
key: ValueKey(node.path.key),
leading: IconTheme(
// Force the standard icon theme
data: IconTheme.of(context),
child: DeviceAvatar.deviceNode(node),
),
title: node.name,
subtitle: subtitle,
extended: extended,
selected: false,
onTap: () {
ref.read(currentDeviceProvider.notifier).setCurrentDevice(node);
},
);
}
_DeviceRow _buildCurrentDeviceRow(
BuildContext context,
WidgetRef ref,
DeviceNode node,
AsyncValue<YubiKeyData> data,
bool extended,
) {
final messages = _getDeviceStrings(context, node, data);
if (messages.length > 2) {
// Don't show readername
messages.removeLast();
}
final title = messages.removeAt(0);
final subtitle = messages.join('\n');
return _DeviceRow(
key: keys.deviceInfoListTile,
leading: data.maybeWhen(
data: (data) =>
DeviceAvatar.yubiKeyData(data, radius: extended ? null : 16),
orElse: () => DeviceAvatar.deviceNode(node, radius: extended ? null : 16),
),
title: title,
subtitle: subtitle,
extended: extended,
selected: true,
onTap: () {},
);
}
class _NfcDeviceRow extends ConsumerWidget {
final DeviceNode node;
final bool extended;
const _NfcDeviceRow(this.node, {required this.extended});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final hidden = ref.watch(_hiddenDevicesProvider);
return GestureDetector(
onSecondaryTapDown: (details) {
showMenu(
context: context,
position: RelativeRect.fromLTRB(
details.globalPosition.dx,
details.globalPosition.dy,
details.globalPosition.dx,
0,
),
items: [
PopupMenuItem(
enabled: hidden.isNotEmpty,
onTap: () {
ref.read(_hiddenDevicesProvider.notifier).showAll();
},
child: ListTile(
title: Text(l10n.s_show_hidden_devices),
dense: true,
contentPadding: EdgeInsets.zero,
enabled: hidden.isNotEmpty,
),
),
PopupMenuItem(
onTap: () {
ref.read(_hiddenDevicesProvider.notifier).hideDevice(node.path);
},
child: ListTile(
title: Text(l10n.s_hide_device),
dense: true,
contentPadding: EdgeInsets.zero,
),
),
],
);
},
child: _buildDeviceRow(context, ref, node, null, extended),
);
}
}

View File

@ -1,362 +0,0 @@
/*
* Copyright (C) 2022 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 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../../core/state.dart';
import '../../management/models.dart';
import '../models.dart';
import '../state.dart';
import 'device_avatar.dart';
import 'keys.dart';
final _hiddenDevicesProvider =
StateNotifierProvider<_HiddenDevicesNotifier, List<String>>(
(ref) => _HiddenDevicesNotifier(ref.watch(prefProvider)));
class _HiddenDevicesNotifier extends StateNotifier<List<String>> {
static const String _key = 'DEVICE_PICKER_HIDDEN';
final SharedPreferences _prefs;
_HiddenDevicesNotifier(this._prefs) : super(_prefs.getStringList(_key) ?? []);
void showAll() {
state = [];
_prefs.setStringList(_key, state);
}
void hideDevice(DevicePath devicePath) {
state = [...state, devicePath.key];
_prefs.setStringList(_key, state);
}
}
class DevicePickerDialog extends StatefulWidget {
const DevicePickerDialog({super.key});
@override
State<StatefulWidget> createState() => _DevicePickerDialogState();
}
class _DevicePickerDialogState extends State<DevicePickerDialog> {
late FocusScopeNode _focus;
@override
void initState() {
super.initState();
_focus = FocusScopeNode();
}
@override
void dispose() {
_focus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// This keeps the focus in the dialog, even if the underlying page
// changes as it does when a new device is selected.
return FocusScope(
node: _focus,
autofocus: true,
onFocusChange: (focused) {
if (!focused) {
_focus.requestFocus();
}
},
child: const _DevicePickerContent(),
);
}
}
class _DevicePickerContent extends ConsumerWidget {
const _DevicePickerContent();
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final hidden = ref.watch(_hiddenDevicesProvider);
final devices = ref
.watch(attachedDevicesProvider)
.where((e) => !hidden.contains(e.path.key))
.toList();
final currentNode = ref.watch(currentDeviceProvider);
final Widget hero;
final bool showUsb;
if (currentNode != null) {
showUsb = isDesktop && devices.whereType<UsbYubiKeyNode>().isEmpty;
devices.removeWhere((e) => e.path == currentNode.path);
hero = _CurrentDeviceRow(
currentNode,
ref.watch(currentDeviceDataProvider),
);
} else {
hero = Column(
children: [
_HeroAvatar(
child: DeviceAvatar(
radius: 64,
child: Icon(isAndroid ? Icons.no_cell : Icons.usb),
),
),
ListTile(
title: Center(child: Text(l10n.l_no_yk_present)),
subtitle: Center(
child: Text(isAndroid ? l10n.l_insert_or_tap_yk : l10n.s_usb)),
),
],
);
showUsb = false;
}
List<Widget> others = [
if (showUsb)
ListTile(
leading: const Padding(
padding: EdgeInsets.symmetric(horizontal: 4),
child: DeviceAvatar(child: Icon(Icons.usb)),
),
title: Text(l10n.s_usb),
subtitle: Text(l10n.l_no_yk_present),
onTap: () {
ref.read(currentDeviceProvider.notifier).setCurrentDevice(null);
},
),
...devices.map(
(e) => e.map(
usbYubiKey: (node) => _DeviceRow(node, info: node.info),
nfcReader: (node) => _NfcDeviceRow(node),
),
),
];
return GestureDetector(
onSecondaryTapDown: hidden.isEmpty
? null
: (details) {
showMenu(
context: context,
position: RelativeRect.fromLTRB(
details.globalPosition.dx,
details.globalPosition.dy,
details.globalPosition.dx,
0,
),
items: [
PopupMenuItem(
onTap: () {
ref.read(_hiddenDevicesProvider.notifier).showAll();
},
child: ListTile(
title: Text(l10n.s_show_hidden_devices),
dense: true,
contentPadding: EdgeInsets.zero,
),
),
],
);
},
child: SimpleDialog(
children: [
hero,
if (others.isNotEmpty)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 24),
child: Divider(),
),
...others,
],
),
);
}
}
String _getDeviceInfoString(BuildContext context, DeviceInfo info) {
final l10n = AppLocalizations.of(context)!;
final serial = info.serial;
return [
if (serial != null) l10n.s_sn_serial(serial),
if (info.version.isAtLeast(1))
l10n.s_fw_version(info.version)
else
l10n.s_unknown_type,
].join(' ');
}
List<String> _getDeviceStrings(
BuildContext context, DeviceNode node, AsyncValue<YubiKeyData> data) {
final l10n = AppLocalizations.of(context)!;
final messages = data.whenOrNull(
data: (data) => [data.name, _getDeviceInfoString(context, data.info)],
error: (error, _) => switch (error) {
'device-inaccessible' => [node.name, l10n.s_yk_inaccessible],
'unknown-device' => [l10n.s_unknown_device],
_ => null,
},
) ??
[l10n.l_no_yk_present];
// Add the NFC reader name, unless it's already included (as device name, like on Android)
if (node is NfcReaderNode && !messages.contains(node.name)) {
messages.add(node.name);
}
return messages;
}
class _HeroAvatar extends StatelessWidget {
final Widget child;
const _HeroAvatar({required this.child});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
theme.colorScheme.inverseSurface.withOpacity(0.6),
theme.colorScheme.inverseSurface.withOpacity(0.25),
(DialogTheme.of(context).backgroundColor ??
theme.dialogBackgroundColor)
.withOpacity(0),
],
),
),
padding: const EdgeInsets.all(12),
child: Theme(
// Give the avatar a transparent background
data: theme.copyWith(
colorScheme:
theme.colorScheme.copyWith(surfaceVariant: Colors.transparent)),
child: child,
),
);
}
}
class _CurrentDeviceRow extends StatelessWidget {
final DeviceNode node;
final AsyncValue<YubiKeyData> data;
const _CurrentDeviceRow(this.node, this.data);
@override
Widget build(BuildContext context) {
final hero = data.maybeWhen(
data: (data) => DeviceAvatar.yubiKeyData(data, radius: 64),
orElse: () => DeviceAvatar.deviceNode(node, radius: 64),
);
final messages = _getDeviceStrings(context, node, data);
return Column(
children: [
_HeroAvatar(child: hero),
ListTile(
key: deviceInfoListTile,
title: Text(messages.removeAt(0), textAlign: TextAlign.center),
isThreeLine: messages.length > 1,
subtitle: Text(messages.join('\n'), textAlign: TextAlign.center),
)
],
);
}
}
class _DeviceRow extends ConsumerWidget {
final DeviceNode node;
final DeviceInfo? info;
const _DeviceRow(this.node, {this.info});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
return ListTile(
leading: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: DeviceAvatar.deviceNode(node),
),
title: Text(node.name),
subtitle: Text(
node.when(
usbYubiKey: (_, __, ___, info) => info == null
? l10n.s_yk_inaccessible
: _getDeviceInfoString(context, info),
nfcReader: (_, __) => l10n.s_select_to_scan,
),
),
onTap: () {
ref.read(currentDeviceProvider.notifier).setCurrentDevice(node);
},
);
}
}
class _NfcDeviceRow extends ConsumerWidget {
final DeviceNode node;
const _NfcDeviceRow(this.node);
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final hidden = ref.watch(_hiddenDevicesProvider);
return GestureDetector(
onSecondaryTapDown: (details) {
showMenu(
context: context,
position: RelativeRect.fromLTRB(
details.globalPosition.dx,
details.globalPosition.dy,
details.globalPosition.dx,
0,
),
items: [
PopupMenuItem(
enabled: hidden.isNotEmpty,
onTap: () {
ref.read(_hiddenDevicesProvider.notifier).showAll();
},
child: ListTile(
title: Text(l10n.s_show_hidden_devices),
dense: true,
contentPadding: EdgeInsets.zero,
enabled: hidden.isNotEmpty,
),
),
PopupMenuItem(
onTap: () {
ref.read(_hiddenDevicesProvider.notifier).hideDevice(node.path);
},
child: ListTile(
title: Text(l10n.s_hide_device),
dense: true,
contentPadding: EdgeInsets.zero,
),
),
],
);
},
child: _DeviceRow(node),
);
}
}

View File

@ -0,0 +1,51 @@
/*
* 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.
*/
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'keys.dart' as keys;
class FsDialog extends StatelessWidget {
final Widget child;
const FsDialog({required this.child, super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Dialog.fullscreen(
backgroundColor: Theme.of(context).colorScheme.background.withAlpha(100),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: SingleChildScrollView(child: child),
),
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: TextButton.icon(
key: keys.closeButton,
icon: const Icon(Icons.close),
label: Text(l10n.s_close),
onPressed: () {
Navigator.of(context).pop();
},
),
)
],
),
);
}
}

View File

@ -30,3 +30,6 @@ const managementAppDrawer = Key('$_prefix.drawer.management');
// settings page
const themeModeSetting = Key('$_prefix.settings.theme_mode');
Key themeModeOption(ThemeMode mode) => Key('$_prefix.theme_mode.${mode.name}');
// misc buttons
const closeButton = Key('$_prefix.close_button');

View File

@ -1,147 +0,0 @@
/*
* Copyright (C) 2022 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 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../management/views/management_screen.dart';
import '../message.dart';
import '../models.dart';
import '../shortcuts.dart';
import '../state.dart';
import 'keys.dart';
extension on Application {
IconData get _icon => switch (this) {
Application.oath => Icons.supervisor_account_outlined,
Application.fido => Icons.security_outlined,
Application.otp => Icons.password_outlined,
Application.piv => Icons.approval_outlined,
Application.management => Icons.construction_outlined,
Application.openpgp => Icons.key_outlined,
Application.hsmauth => Icons.key_outlined,
};
IconData get _filledIcon => switch (this) {
Application.oath => Icons.supervisor_account,
Application.fido => Icons.security,
Application.otp => Icons.password,
Application.piv => Icons.approval,
Application.management => Icons.construction,
Application.openpgp => Icons.key,
Application.hsmauth => Icons.key,
};
}
class MainPageDrawer extends ConsumerWidget {
final bool shouldPop;
const MainPageDrawer({this.shouldPop = true, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final supportedApps = ref.watch(supportedAppsProvider);
final data = ref.watch(currentDeviceDataProvider).valueOrNull;
final color =
Theme.of(context).brightness == Brightness.dark ? 'white' : 'green';
final availableApps = data != null
? supportedApps
.where(
(app) => app.getAvailability(data) != Availability.unsupported)
.toList()
: <Application>[];
final hasManagement = availableApps.remove(Application.management);
return NavigationDrawer(
selectedIndex: availableApps.indexOf(ref.watch(currentAppProvider)),
onDestinationSelected: (index) {
if (shouldPop) Navigator.of(context).pop();
if (index < availableApps.length) {
// Switch to selected app
final app = availableApps[index];
ref.read(currentAppProvider.notifier).setCurrentApp(app);
} else {
// Handle action
index -= availableApps.length;
if (!hasManagement) {
index++;
}
switch (index) {
case 0:
showBlurDialog(
context: context,
// data must be non-null when index == 0
builder: (context) => ManagementScreen(data!),
);
break;
case 1:
Actions.maybeInvoke(context, const SettingsIntent());
break;
case 2:
Actions.maybeInvoke(context, const AboutIntent());
break;
}
}
},
children: [
Padding(
padding: const EdgeInsets.only(top: 19.0, left: 30.0, bottom: 12.0),
child: Image.asset(
'assets/graphics/yubico-$color.png',
alignment: Alignment.centerLeft,
height: 28,
filterQuality: FilterQuality.medium,
),
),
const Divider(indent: 16.0, endIndent: 28.0),
if (data != null) ...[
// Normal YubiKey Applications
...availableApps.map((app) => NavigationDrawerDestination(
label: Text(app.getDisplayName(l10n)),
icon: Icon(app._icon),
selectedIcon: Icon(app._filledIcon),
)),
// Management app
if (hasManagement) ...[
NavigationDrawerDestination(
key: managementAppDrawer,
label: Text(
l10n.s_toggle_applications,
),
icon: Icon(Application.management._icon),
selectedIcon: Icon(Application.management._filledIcon),
),
],
const Divider(indent: 16.0, endIndent: 28.0),
],
// Non-YubiKey pages
NavigationDrawerDestination(
label: Text(l10n.s_settings),
icon: const Icon(Icons.settings_outlined),
),
NavigationDrawerDestination(
label: Text(l10n.s_help_and_about),
icon: const Icon(Icons.help_outline),
),
],
);
}
}

View File

@ -26,6 +26,7 @@ import '../../fido/views/fido_screen.dart';
import '../../oath/models.dart';
import '../../oath/views/add_account_page.dart';
import '../../oath/views/oath_screen.dart';
import '../../piv/views/piv_screen.dart';
import '../../widgets/custom_icons.dart';
import '../message.dart';
import '../models.dart';
@ -161,6 +162,7 @@ class MainPage extends ConsumerWidget {
return switch (app) {
Application.oath => OathScreen(data.node.path),
Application.fido => FidoScreen(data),
Application.piv => PivScreen(data.node.path),
_ => MessagePage(
header: l10n.s_app_not_supported,
message: l10n.l_app_not_supported_desc,

View File

@ -27,6 +27,7 @@ class MessagePage extends StatelessWidget {
final bool delayedContent;
final Widget Function(BuildContext context)? keyActionsBuilder;
final Widget Function(BuildContext context)? actionButtonBuilder;
final bool keyActionsBadge;
const MessagePage({
super.key,
@ -38,6 +39,7 @@ class MessagePage extends StatelessWidget {
this.keyActionsBuilder,
this.actionButtonBuilder,
this.delayedContent = false,
this.keyActionsBadge = false,
});
@override
@ -46,6 +48,7 @@ class MessagePage extends StatelessWidget {
centered: true,
actions: actions,
keyActionsBuilder: keyActionsBuilder,
keyActionsBadge: keyActionsBadge,
actionButtonBuilder: actionButtonBuilder,
delayedContent: delayedContent,
child: Padding(

View File

@ -0,0 +1,213 @@
/*
* 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.
*/
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../../management/views/management_screen.dart';
import '../message.dart';
import '../models.dart';
import '../shortcuts.dart';
import '../state.dart';
import 'device_picker.dart';
import 'keys.dart';
class NavigationItem extends StatelessWidget {
final Widget leading;
final String title;
final bool collapsed;
final bool selected;
final void Function() onTap;
const NavigationItem({
super.key,
required this.leading,
required this.title,
this.collapsed = false,
this.selected = false,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
if (collapsed) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: selected
? Theme(
data: theme.copyWith(
colorScheme: colorScheme.copyWith(
primary: colorScheme.secondaryContainer,
onPrimary: colorScheme.onSecondaryContainer)),
child: IconButton.filled(
icon: leading,
tooltip: title,
padding: const EdgeInsets.symmetric(horizontal: 16),
onPressed: onTap,
),
)
: IconButton(
icon: leading,
tooltip: title,
padding: const EdgeInsets.symmetric(horizontal: 16),
onPressed: onTap,
),
);
} else {
return ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(48)),
leading: leading,
title: Text(title),
minVerticalPadding: 16,
onTap: onTap,
tileColor: selected ? colorScheme.secondaryContainer : null,
textColor: selected ? colorScheme.onSecondaryContainer : null,
iconColor: selected ? colorScheme.onSecondaryContainer : null,
);
}
}
}
extension on Application {
IconData get _icon => switch (this) {
Application.oath => Icons.supervisor_account_outlined,
Application.fido => Icons.security_outlined,
Application.otp => Icons.password_outlined,
Application.piv => Icons.approval_outlined,
Application.management => Icons.construction_outlined,
Application.openpgp => Icons.key_outlined,
Application.hsmauth => Icons.key_outlined,
};
IconData get _filledIcon => switch (this) {
Application.oath => Icons.supervisor_account,
Application.fido => Icons.security,
Application.otp => Icons.password,
Application.piv => Icons.approval,
Application.management => Icons.construction,
Application.openpgp => Icons.key,
Application.hsmauth => Icons.key,
};
}
class NavigationContent extends ConsumerWidget {
final bool shouldPop;
final bool extended;
const NavigationContent(
{super.key, this.shouldPop = true, this.extended = false});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final supportedApps = ref.watch(supportedAppsProvider);
final data = ref.watch(currentDeviceDataProvider).valueOrNull;
final availableApps = data != null
? supportedApps
.where(
(app) => app.getAvailability(data) != Availability.unsupported)
.toList()
: <Application>[];
final hasManagement = availableApps.remove(Application.management);
final currentApp = ref.watch(currentAppProvider);
return Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
AnimatedSize(
duration: const Duration(milliseconds: 150),
child: DevicePickerContent(extended: extended),
),
const SizedBox(height: 32),
AnimatedSize(
duration: const Duration(milliseconds: 150),
child: Column(
children: [
if (data != null) ...[
// Normal YubiKey Applications
...availableApps.map((app) => NavigationItem(
title: app.getDisplayName(l10n),
leading: app == currentApp
? Icon(app._filledIcon)
: Icon(app._icon),
collapsed: !extended,
selected: app == currentApp,
onTap: () {
ref
.read(currentAppProvider.notifier)
.setCurrentApp(app);
if (shouldPop) {
Navigator.of(context).pop();
}
},
)),
// Management app
if (hasManagement) ...[
NavigationItem(
key: managementAppDrawer,
leading: Icon(Application.management._icon),
title: l10n.s_toggle_applications,
collapsed: !extended,
onTap: () {
showBlurDialog(
context: context,
// data must be non-null when index == 0
builder: (context) => ManagementScreen(data),
);
},
),
],
const SizedBox(height: 32),
],
],
),
),
// Non-YubiKey pages
NavigationItem(
leading: const Icon(Icons.settings_outlined),
title: l10n.s_settings,
collapsed: !extended,
onTap: () {
if (shouldPop) {
Navigator.of(context).pop();
}
Actions.maybeInvoke(context, const SettingsIntent());
},
),
NavigationItem(
leading: const Icon(Icons.help_outline),
title: l10n.s_help_and_about,
collapsed: !extended,
onTap: () {
if (shouldPop) {
Navigator.of(context).pop();
}
Actions.maybeInvoke(context, const AboutIntent());
},
),
],
),
);
}
}

View File

@ -16,6 +16,7 @@
import 'package:collection/collection.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:intl/intl.dart';
import '../management/models.dart';
@ -152,3 +153,5 @@ class Version with _$Version implements Comparable<Version> {
return a - b;
}
}
final DateFormat dateFormatter = DateFormat('yyyy-MM-dd');

View File

@ -18,6 +18,8 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../app/models.dart';
bool get isDesktop {
return const [
TargetPlatform.windows,
@ -36,21 +38,16 @@ final prefProvider = Provider<SharedPreferences>((ref) {
});
abstract class ApplicationStateNotifier<T>
extends StateNotifier<AsyncValue<T>> {
ApplicationStateNotifier() : super(const AsyncValue.loading());
extends AutoDisposeFamilyAsyncNotifier<T, DevicePath> {
ApplicationStateNotifier() : super();
@protected
Future<void> updateState(Future<T> Function() guarded) async {
final result = await AsyncValue.guard(guarded);
if (mounted) {
state = result;
}
state = await AsyncValue.guard(guarded);
}
@protected
void setData(T value) {
if (mounted) {
state = AsyncValue.data(value);
}
}
}

View File

@ -23,6 +23,7 @@ import 'package:logging/logging.dart';
import 'package:yubico_authenticator/app/logging.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../fido/models.dart';
import '../../fido/state.dart';
import '../models.dart';
@ -45,47 +46,70 @@ final _sessionProvider =
},
);
final desktopFidoState = StateNotifierProvider.autoDispose
.family<FidoStateNotifier, AsyncValue<FidoState>, DevicePath>(
(ref, devicePath) {
final session = ref.watch(_sessionProvider(devicePath));
final desktopFidoState = AsyncNotifierProvider.autoDispose
.family<FidoStateNotifier, FidoState, DevicePath>(
_DesktopFidoStateNotifier.new);
class _DesktopFidoStateNotifier extends FidoStateNotifier {
late RpcNodeSession _session;
late StateController<String?> _pinController;
FutureOr<FidoState> _build(DevicePath devicePath) async {
var result = await _session.command('get');
FidoState fidoState = FidoState.fromJson(result['data']);
if (fidoState.hasPin && !fidoState.unlocked) {
final pin = ref.read(_pinProvider(devicePath));
if (pin != null) {
await unlock(pin);
result = await _session.command('get');
fidoState = FidoState.fromJson(result['data']);
}
}
_log.debug('application status', jsonEncode(fidoState));
return fidoState;
}
@override
FutureOr<FidoState> build(DevicePath devicePath) async {
_session = ref.watch(_sessionProvider(devicePath));
if (Platform.isWindows) {
// Make sure to rebuild if isAdmin changes
ref.watch(rpcStateProvider.select((state) => state.isAdmin));
}
final notifier = _DesktopFidoStateNotifier(
session,
ref.watch(_pinProvider(devicePath).notifier),
);
session.setErrorHandler('state-reset', (_) async {
ref.invalidate(_sessionProvider(devicePath));
});
session.setErrorHandler('auth-required', (_) async {
final pin = ref.read(_pinProvider(devicePath));
if (pin != null) {
await notifier.unlock(pin);
ref.listen<WindowState>(
windowStateProvider,
(prev, next) async {
if (prev?.active == false && next.active) {
// Refresh state on active
final newState = await _build(devicePath);
if (state.valueOrNull != newState) {
state = AsyncValue.data(newState);
}
}
});
ref.onDispose(() {
session.unsetErrorHandler('auth-required');
});
ref.onDispose(() {
session.unsetErrorHandler('state-reset');
});
return notifier..refresh();
},
);
class _DesktopFidoStateNotifier extends FidoStateNotifier {
final RpcNodeSession _session;
final StateController<String?> _pinController;
_DesktopFidoStateNotifier(this._session, this._pinController) : super();
Future<void> refresh() => updateState(() async {
final result = await _session.command('get');
_log.debug('application status', jsonEncode(result));
return FidoState.fromJson(result['data']);
_pinController = ref.watch(_pinProvider(devicePath).notifier);
_session.setErrorHandler('state-reset', (_) async {
ref.invalidate(_sessionProvider(devicePath));
});
_session.setErrorHandler('auth-required', (_) async {
final pin = ref.read(_pinProvider(devicePath));
if (pin != null) {
await unlock(pin);
}
});
ref.onDispose(() {
_session.unsetErrorHandler('auth-required');
});
ref.onDispose(() {
_session.unsetErrorHandler('state-reset');
});
return _build(devicePath);
}
@override
Stream<InteractionEvent> reset() {
@ -105,8 +129,8 @@ class _DesktopFidoStateNotifier extends FidoStateNotifier {
controller.onListen = () async {
try {
await _session.command('reset', signal: signaler);
await refresh();
await controller.sink.close();
ref.invalidateSelf();
} catch (e) {
controller.sink.addError(e);
}
@ -151,22 +175,38 @@ class _DesktopFidoStateNotifier extends FidoStateNotifier {
}
}
final desktopFingerprintProvider = StateNotifierProvider.autoDispose.family<
FidoFingerprintsNotifier, AsyncValue<List<Fingerprint>>, DevicePath>(
(ref, devicePath) => _DesktopFidoFingerprintsNotifier(
ref.watch(_sessionProvider(devicePath)),
));
final desktopFingerprintProvider = AsyncNotifierProvider.autoDispose
.family<FidoFingerprintsNotifier, List<Fingerprint>, DevicePath>(
_DesktopFidoFingerprintsNotifier.new);
class _DesktopFidoFingerprintsNotifier extends FidoFingerprintsNotifier {
final RpcNodeSession _session;
late RpcNodeSession _session;
_DesktopFidoFingerprintsNotifier(this._session) {
_refresh();
@override
FutureOr<List<Fingerprint>> build(DevicePath devicePath) async {
_session = ref.watch(_sessionProvider(devicePath));
ref.watch(fidoStateProvider(devicePath));
// Refresh on active
ref.listen<WindowState>(
windowStateProvider,
(prev, next) async {
if (prev?.active == false && next.active) {
// Refresh state on active
final newState = await _build(devicePath);
if (state.valueOrNull != newState) {
state = AsyncValue.data(newState);
}
}
},
);
return _build(devicePath);
}
Future<void> _refresh() async {
FutureOr<List<Fingerprint>> _build(DevicePath devicePath) async {
final result = await _session.command('fingerprints');
setItems((result['children'] as Map<String, dynamic>)
return List.unmodifiable((result['children'] as Map<String, dynamic>)
.entries
.map((e) => Fingerprint(e.key, e.value['name']))
.toList());
@ -176,7 +216,7 @@ class _DesktopFidoFingerprintsNotifier extends FidoFingerprintsNotifier {
Future<void> deleteFingerprint(Fingerprint fingerprint) async {
await _session
.command('delete', target: ['fingerprints', fingerprint.templateId]);
await _refresh();
ref.invalidate(fidoStateProvider(_session.devicePath));
}
@override
@ -184,15 +224,11 @@ class _DesktopFidoFingerprintsNotifier extends FidoFingerprintsNotifier {
final controller = StreamController<FingerprintEvent>();
final signaler = Signaler();
signaler.signals.listen((signal) {
switch (signal.status) {
case 'capture':
controller.sink
.add(FingerprintEvent.capture(signal.body['remaining']));
break;
case 'capture-error':
controller.sink.add(FingerprintEvent.error(signal.body['code']));
break;
}
controller.sink.add(switch (signal.status) {
'capture' => FingerprintEvent.capture(signal.body['remaining']),
'capture-error' => FingerprintEvent.error(signal.body['code']),
final other => throw UnimplementedError(other),
});
});
controller.onCancel = () {
@ -210,7 +246,7 @@ class _DesktopFidoFingerprintsNotifier extends FidoFingerprintsNotifier {
);
controller.sink
.add(FingerprintEvent.complete(Fingerprint.fromJson(result)));
await _refresh();
ref.invalidate(fidoStateProvider(_session.devicePath));
await controller.sink.close();
} catch (e) {
controller.sink.addError(e);
@ -227,25 +263,41 @@ class _DesktopFidoFingerprintsNotifier extends FidoFingerprintsNotifier {
target: ['fingerprints', fingerprint.templateId],
params: {'name': name});
final renamed = fingerprint.copyWith(name: name);
await _refresh();
ref.invalidate(fidoStateProvider(_session.devicePath));
return renamed;
}
}
final desktopCredentialProvider = StateNotifierProvider.autoDispose.family<
FidoCredentialsNotifier, AsyncValue<List<FidoCredential>>, DevicePath>(
(ref, devicePath) => _DesktopFidoCredentialsNotifier(
ref.watch(_sessionProvider(devicePath)),
));
final desktopCredentialProvider = AsyncNotifierProvider.autoDispose
.family<FidoCredentialsNotifier, List<FidoCredential>, DevicePath>(
_DesktopFidoCredentialsNotifier.new);
class _DesktopFidoCredentialsNotifier extends FidoCredentialsNotifier {
final RpcNodeSession _session;
late RpcNodeSession _session;
_DesktopFidoCredentialsNotifier(this._session) {
_refresh();
@override
FutureOr<List<FidoCredential>> build(DevicePath devicePath) async {
_session = ref.watch(_sessionProvider(devicePath));
ref.watch(fidoStateProvider(devicePath));
// Refresh on active
ref.listen<WindowState>(
windowStateProvider,
(prev, next) async {
if (prev?.active == false && next.active) {
// Refresh state on active
final newState = await _build(devicePath);
if (state.valueOrNull != newState) {
state = AsyncValue.data(newState);
}
}
},
);
return _build(devicePath);
}
Future<void> _refresh() async {
FutureOr<List<FidoCredential>> _build(DevicePath devicePath) async {
final List<FidoCredential> creds = [];
final rps = await _session.command('credentials');
for (final rpId in (rps['children'] as Map<String, dynamic>).keys) {
@ -258,7 +310,7 @@ class _DesktopFidoCredentialsNotifier extends FidoCredentialsNotifier {
userName: e.value['user_name']));
}
}
setItems(creds);
return List.unmodifiable(creds);
}
@override
@ -268,6 +320,6 @@ class _DesktopFidoCredentialsNotifier extends FidoCredentialsNotifier {
credential.rpId,
credential.credentialId,
]);
await _refresh();
ref.invalidate(fidoStateProvider(_session.devicePath));
}
}

View File

@ -41,11 +41,13 @@ import '../core/state.dart';
import '../fido/state.dart';
import '../management/state.dart';
import '../oath/state.dart';
import '../piv/state.dart';
import '../version.dart';
import 'devices.dart';
import 'fido/state.dart';
import 'management/state.dart';
import 'oath/state.dart';
import 'piv/state.dart';
import 'qr_scanner.dart';
import 'rpc.dart';
import 'state.dart';
@ -177,6 +179,7 @@ Future<Widget> initialize(List<String> argv) async {
supportedAppsProvider.overrideWithValue([
Application.oath,
Application.fido,
Application.piv,
Application.management,
]),
prefProvider.overrideWithValue(prefs),
@ -184,6 +187,12 @@ Future<Widget> initialize(List<String> argv) async {
windowStateProvider.overrideWith(
(ref) => ref.watch(desktopWindowStateProvider),
),
clipboardProvider.overrideWith(
(ref) => ref.watch(desktopClipboardProvider),
),
supportedThemesProvider.overrideWith(
(ref) => ref.watch(desktopSupportedThemesProvider),
),
attachedDevicesProvider.overrideWith(
() => DesktopDevicesNotifier(),
),
@ -206,12 +215,9 @@ Future<Widget> initialize(List<String> argv) async {
fidoStateProvider.overrideWithProvider(desktopFidoState),
fingerprintProvider.overrideWithProvider(desktopFingerprintProvider),
credentialProvider.overrideWithProvider(desktopCredentialProvider),
clipboardProvider.overrideWith(
(ref) => ref.watch(desktopClipboardProvider),
),
supportedThemesProvider.overrideWith(
(ref) => ref.watch(desktopSupportedThemesProvider),
)
// PIV
pivStateProvider.overrideWithProvider(desktopPivState),
pivSlotsProvider.overrideWithProvider(desktopPivSlots),
],
child: YubicoAuthenticatorApp(
page: Consumer(

View File

@ -36,30 +36,28 @@ final _sessionProvider =
RpcNodeSession(ref.watch(rpcProvider).requireValue, devicePath, []),
);
final desktopManagementState = StateNotifierProvider.autoDispose
.family<ManagementStateNotifier, AsyncValue<DeviceInfo>, DevicePath>(
(ref, devicePath) {
final desktopManagementState = AsyncNotifierProvider.autoDispose
.family<ManagementStateNotifier, DeviceInfo, DevicePath>(
_DesktopManagementStateNotifier.new);
class _DesktopManagementStateNotifier extends ManagementStateNotifier {
late RpcNodeSession _session;
List<String> _subpath = [];
_DesktopManagementStateNotifier() : super();
@override
FutureOr<DeviceInfo> build(DevicePath devicePath) async {
// Make sure to rebuild if currentDevice changes (as on reboot)
ref.watch(currentDeviceProvider);
final session = ref.watch(_sessionProvider(devicePath));
final notifier = _DesktopManagementStateNotifier(ref, session);
session.setErrorHandler('state-reset', (_) async {
_session = ref.watch(_sessionProvider(devicePath));
_session.setErrorHandler('state-reset', (_) async {
ref.invalidate(_sessionProvider(devicePath));
});
ref.onDispose(() {
session.unsetErrorHandler('state-reset');
_session.unsetErrorHandler('state-reset');
});
return notifier..refresh();
},
);
class _DesktopManagementStateNotifier extends ManagementStateNotifier {
final Ref _ref;
final RpcNodeSession _session;
List<String> _subpath = [];
_DesktopManagementStateNotifier(this._ref, this._session) : super();
Future<void> refresh() => updateState(() async {
final result = await _session.command('get');
final info = DeviceInfo.fromJson(result['data']['info']);
final interfaces = (result['children'] as Map).keys.toSet();
@ -82,7 +80,7 @@ class _DesktopManagementStateNotifier extends ManagementStateNotifier {
}
}
throw 'Failed connection over all interfaces';
});
}
@override
Future<void> setMode(
@ -94,7 +92,7 @@ class _DesktopManagementStateNotifier extends ManagementStateNotifier {
'challenge_response_timeout': challengeResponseTimeout,
'auto_eject_timeout': autoEjectTimeout,
});
_ref.read(attachedDevicesProvider.notifier).refresh();
ref.read(attachedDevicesProvider.notifier).refresh();
}
@override
@ -111,6 +109,6 @@ class _DesktopManagementStateNotifier extends ManagementStateNotifier {
'new_lock_code': newLockCode,
'reboot': reboot,
});
_ref.read(attachedDevicesProvider.notifier).refresh();
ref.read(attachedDevicesProvider.notifier).refresh();
}
}

View File

@ -57,56 +57,48 @@ class _LockKeyNotifier extends StateNotifier<String?> {
}
}
final desktopOathState = StateNotifierProvider.autoDispose
.family<OathStateNotifier, AsyncValue<OathState>, DevicePath>(
(ref, devicePath) {
final session = ref.watch(_sessionProvider(devicePath));
final notifier = _DesktopOathStateNotifier(session, ref);
session
final desktopOathState = AsyncNotifierProvider.autoDispose
.family<OathStateNotifier, OathState, DevicePath>(
_DesktopOathStateNotifier.new);
class _DesktopOathStateNotifier extends OathStateNotifier {
late RpcNodeSession _session;
@override
FutureOr<OathState> build(DevicePath devicePath) async {
_session = ref.watch(_sessionProvider(devicePath));
_session
..setErrorHandler('state-reset', (_) async {
ref.invalidate(_sessionProvider(devicePath));
})
..setErrorHandler('auth-required', (_) async {
await notifier.refresh();
ref.invalidateSelf();
});
ref.onDispose(() {
session
_session
..unsetErrorHandler('state-reset')
..unsetErrorHandler('auth-required');
});
return notifier..refresh();
},
);
class _DesktopOathStateNotifier extends OathStateNotifier {
final RpcNodeSession _session;
final Ref _ref;
_DesktopOathStateNotifier(this._session, this._ref) : super();
refresh() => updateState(() async {
final result = await _session.command('get');
_log.debug('application status', jsonEncode(result));
var oathState = OathState.fromJson(result['data']);
final key = _ref.read(_oathLockKeyProvider(_session.devicePath));
final key = ref.read(_oathLockKeyProvider(_session.devicePath));
if (oathState.locked && key != null) {
final result =
await _session.command('validate', params: {'key': key});
final result = await _session.command('validate', params: {'key': key});
if (result['valid']) {
oathState = oathState.copyWith(locked: false);
} else {
_ref
.read(_oathLockKeyProvider(_session.devicePath).notifier)
.unsetKey();
ref.read(_oathLockKeyProvider(_session.devicePath).notifier).unsetKey();
}
}
return oathState;
});
}
@override
Future<void> reset() async {
await _session.command('reset');
_ref.read(_oathLockKeyProvider(_session.devicePath).notifier).unsetKey();
_ref.invalidate(_sessionProvider(_session.devicePath));
ref.read(_oathLockKeyProvider(_session.devicePath).notifier).unsetKey();
ref.invalidate(_sessionProvider(_session.devicePath));
}
@override
@ -120,7 +112,7 @@ class _DesktopOathStateNotifier extends OathStateNotifier {
final bool remembered = validate['remembered'];
if (valid) {
_log.debug('applet unlocked');
_ref.read(_oathLockKeyProvider(_session.devicePath).notifier).setKey(key);
ref.read(_oathLockKeyProvider(_session.devicePath).notifier).setKey(key);
setData(state.value!.copyWith(
locked: false,
remembered: remembered,
@ -158,7 +150,7 @@ class _DesktopOathStateNotifier extends OathStateNotifier {
await _session.command('derive', params: {'password': password});
var key = derive['key'];
await _session.command('set_key', params: {'key': key});
_ref.read(_oathLockKeyProvider(_session.devicePath).notifier).setKey(key);
ref.read(_oathLockKeyProvider(_session.devicePath).notifier).setKey(key);
}
_log.debug('OATH key set');
@ -177,7 +169,7 @@ class _DesktopOathStateNotifier extends OathStateNotifier {
}
}
await _session.command('unset_key');
_ref.read(_oathLockKeyProvider(_session.devicePath).notifier).unsetKey();
ref.read(_oathLockKeyProvider(_session.devicePath).notifier).unsetKey();
setData(oathState.copyWith(hasKey: false, locked: false));
return true;
}
@ -185,7 +177,7 @@ class _DesktopOathStateNotifier extends OathStateNotifier {
@override
Future<void> forgetPassword() async {
await _session.command('forget');
_ref.read(_oathLockKeyProvider(_session.devicePath).notifier).unsetKey();
ref.read(_oathLockKeyProvider(_session.devicePath).notifier).unsetKey();
setData(state.value!.copyWith(remembered: false));
}
}

429
lib/desktop/piv/state.dart Normal file
View File

@ -0,0 +1,429 @@
/*
* 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.
*/
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:logging/logging.dart';
import '../../app/logging.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../app/views/user_interaction.dart';
import '../../core/models.dart';
import '../../piv/models.dart';
import '../../piv/state.dart';
import '../models.dart';
import '../rpc.dart';
import '../state.dart';
final _log = Logger('desktop.piv.state');
final _managementKeyProvider =
StateProvider.autoDispose.family<String?, DevicePath>(
(ref, _) => null,
);
final _pinProvider = StateProvider.autoDispose.family<String?, DevicePath>(
(ref, _) => null,
);
final _sessionProvider =
Provider.autoDispose.family<RpcNodeSession, DevicePath>(
(ref, devicePath) {
// Make sure the managementKey and PIN are held for the duration of the session.
ref.watch(_managementKeyProvider(devicePath));
ref.watch(_pinProvider(devicePath));
return RpcNodeSession(
ref.watch(rpcProvider).requireValue, devicePath, ['ccid', 'piv']);
},
);
final desktopPivState = AsyncNotifierProvider.autoDispose
.family<PivStateNotifier, PivState, DevicePath>(
_DesktopPivStateNotifier.new);
class _DesktopPivStateNotifier extends PivStateNotifier {
late RpcNodeSession _session;
late DevicePath _devicePath;
@override
FutureOr<PivState> build(DevicePath devicePath) async {
_session = ref.watch(_sessionProvider(devicePath));
_session
..setErrorHandler('state-reset', (_) async {
ref.invalidate(_sessionProvider(devicePath));
})
..setErrorHandler('auth-required', (e) async {
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)) {
ref.invalidateSelf();
} else {
ref.read(_managementKeyProvider(devicePath).notifier).state = null;
ref.invalidateSelf();
throw e;
}
} else {
ref.invalidateSelf();
throw e;
}
});
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(result['data']);
return pivState;
}
@override
Future<void> reset() async {
await _session.command('reset');
ref.read(_managementKeyProvider(_devicePath).notifier).state = null;
ref.invalidate(_sessionProvider(_session.devicePath));
}
@override
Future<bool> 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(Icons.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;
}
} finally {
controller?.close();
}
}
@override
Future<PinVerificationStatus> 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(Icons.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 RpcError catch (e) {
if (e.status == 'invalid-pin') {
return PinVerificationStatus.failure(e.body['attempts_remaining']);
}
rethrow;
} finally {
controller?.close();
ref.invalidateSelf();
}
}
@override
Future<PinVerificationStatus> 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 RpcError catch (e) {
if (e.status == 'invalid-pin') {
return PinVerificationStatus.failure(e.body['attempts_remaining']);
}
rethrow;
} finally {
ref.invalidateSelf();
}
}
@override
Future<PinVerificationStatus> changePuk(String puk, String newPuk) async {
try {
await _session.command(
'change_puk',
params: {'puk': puk, 'new_puk': newPuk},
);
return const PinVerificationStatus.success();
} on RpcError catch (e) {
if (e.status == 'invalid-pin') {
return PinVerificationStatus.failure(e.body['attempts_remaining']);
}
rethrow;
} finally {
ref.invalidateSelf();
}
}
@override
Future<void> 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<PinVerificationStatus> unblockPin(String puk, String newPin) async {
try {
await _session.command(
'unblock_pin',
params: {'puk': puk, 'new_pin': newPin},
);
return const PinVerificationStatus.success();
} on RpcError catch (e) {
if (e.status == 'invalid-pin') {
return PinVerificationStatus.failure(e.body['attempts_remaining']);
}
rethrow;
} finally {
ref.invalidateSelf();
}
}
}
final _shownSlots = SlotId.values.map((slot) => slot.id).toList();
final desktopPivSlots = AsyncNotifierProvider.autoDispose
.family<PivSlotsNotifier, List<PivSlot>, DevicePath>(
_DesktopPivSlotsNotifier.new);
class _DesktopPivSlotsNotifier extends PivSlotsNotifier {
late RpcNodeSession _session;
@override
FutureOr<List<PivSlot>> build(DevicePath devicePath) async {
_session = ref.watch(_sessionProvider(devicePath));
final result = await _session.command('get', target: ['slots']);
return (result['children'] as Map<String, dynamic>)
.values
.where((e) => _shownSlots.contains(e['slot']))
.map((e) => PivSlot.fromJson(e))
.toList();
}
@override
Future<void> delete(SlotId slot) async {
await _session.command('delete', target: ['slots', slot.hexId]);
ref.invalidateSelf();
}
@override
Future<PivGenerateResult> 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(Icons.touch_app),
title: l10n.s_touch_required,
description: l10n.l_touch_button_now,
);
},
);
}
});
final (type, subject, validFrom, validTo) = parameters.when(
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();
}
}
@override
Future<PivExamineResult> examine(String data, {String? password}) async {
final result = await _session.command('examine_file', target: [
'slots',
], params: {
'data': data,
'password': password,
});
if (result['status']) {
return PivExamineResult.fromJson({'runtimeType': 'result', ...result});
} else {
return PivExamineResult.invalidPassword();
}
}
@override
Future<PivImportResult> 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);
}
@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?,
);
}
}

45
lib/fido/keys.dart Normal file
View File

@ -0,0 +1,45 @@
/*
* Copyright (C) 2022-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.
*/
import 'package:flutter/material.dart';
const _prefix = 'fido.keys';
const _keyAction = '$_prefix.actions';
const _credentialAction = '$_prefix.credential.actions';
const _fingerprintAction = '$_prefix.fingerprint.actions';
// Key actions
const managePinAction = Key('$_keyAction.manage_pin');
const addFingerprintAction = Key('$_keyAction.add_fingerprint');
const resetAction = Key('$_keyAction.reset');
// Credential actions
const editCredentialAction = Key('$_credentialAction.edit');
const deleteCredentialAction = Key('$_credentialAction.delete');
// Fingerprint actions
const editFingerintAction = Key('$_fingerprintAction.edit');
const deleteFingerprintAction = Key('$_fingerprintAction.delete');
const saveButton = Key('$_prefix.save');
const deleteButton = Key('$_prefix.delete');
const unlockButton = Key('$_prefix.unlock');
const managementKeyField = Key('$_prefix.management_key');
const pinPukField = Key('$_prefix.pin_puk');
const newPinPukField = Key('$_prefix.new_pin_puk');
const confirmPinPukField = Key('$_prefix.confirm_pin_puk');
const subjectField = Key('$_prefix.subject');

View File

@ -41,6 +41,8 @@ class FidoState with _$FidoState {
info['options']['credentialMgmtPreview'] == true;
bool? get bioEnroll => info['options']['bioEnroll'];
bool get alwaysUv => info['options']['alwaysUv'] == true;
}
@freezed

View File

@ -14,16 +14,15 @@
* limitations under the License.
*/
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../app/models.dart';
import '../core/state.dart';
import 'models.dart';
final fidoStateProvider = StateNotifierProvider.autoDispose
.family<FidoStateNotifier, AsyncValue<FidoState>, DevicePath>(
(ref, devicePath) => throw UnimplementedError(),
final fidoStateProvider = AsyncNotifierProvider.autoDispose
.family<FidoStateNotifier, FidoState, DevicePath>(
() => throw UnimplementedError(),
);
abstract class FidoStateNotifier extends ApplicationStateNotifier<FidoState> {
@ -32,36 +31,24 @@ abstract class FidoStateNotifier extends ApplicationStateNotifier<FidoState> {
Future<PinResult> unlock(String pin);
}
abstract class LockedCollectionNotifier<T>
extends StateNotifier<AsyncValue<List<T>>> {
LockedCollectionNotifier() : super(const AsyncValue.loading());
@protected
void setItems(List<T> items) {
if (mounted) {
state = AsyncValue.data(List.unmodifiable(items));
}
}
}
final fingerprintProvider = StateNotifierProvider.autoDispose.family<
FidoFingerprintsNotifier, AsyncValue<List<Fingerprint>>, DevicePath>(
(ref, arg) => throw UnimplementedError(),
final fingerprintProvider = AsyncNotifierProvider.autoDispose
.family<FidoFingerprintsNotifier, List<Fingerprint>, DevicePath>(
() => throw UnimplementedError(),
);
abstract class FidoFingerprintsNotifier
extends LockedCollectionNotifier<Fingerprint> {
extends AutoDisposeFamilyAsyncNotifier<List<Fingerprint>, DevicePath> {
Stream<FingerprintEvent> registerFingerprint({String? name});
Future<Fingerprint> renameFingerprint(Fingerprint fingerprint, String name);
Future<void> deleteFingerprint(Fingerprint fingerprint);
}
final credentialProvider = StateNotifierProvider.autoDispose.family<
FidoCredentialsNotifier, AsyncValue<List<FidoCredential>>, DevicePath>(
(ref, arg) => throw UnimplementedError(),
final credentialProvider = AsyncNotifierProvider.autoDispose
.family<FidoCredentialsNotifier, List<FidoCredential>, DevicePath>(
() => throw UnimplementedError(),
);
abstract class FidoCredentialsNotifier
extends LockedCollectionNotifier<FidoCredential> {
extends AutoDisposeFamilyAsyncNotifier<List<FidoCredential>, DevicePath> {
Future<void> deleteCredential(FidoCredential credential);
}

View File

@ -0,0 +1,55 @@
/*
* 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.
*/
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../../app/models.dart';
import '../../app/shortcuts.dart';
import '../keys.dart' as keys;
List<ActionItem> buildFingerprintActions(AppLocalizations l10n) {
return [
ActionItem(
key: keys.editFingerintAction,
icon: const Icon(Icons.edit),
title: l10n.s_rename_fp,
subtitle: l10n.l_rename_fp_desc,
intent: const EditIntent(),
),
ActionItem(
key: keys.deleteFingerprintAction,
actionStyle: ActionStyle.error,
icon: const Icon(Icons.delete),
title: l10n.s_delete_fingerprint,
subtitle: l10n.l_delete_fingerprint_desc,
intent: const DeleteIntent(),
),
];
}
List<ActionItem> buildCredentialActions(AppLocalizations l10n) {
return [
ActionItem(
key: keys.deleteCredentialAction,
actionStyle: ActionStyle.error,
icon: const Icon(Icons.delete),
title: l10n.s_delete_passkey,
subtitle: l10n.l_delete_account_desc,
intent: const DeleteIntent(),
),
];
}

View File

@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/shortcuts.dart';
import '../../app/state.dart';
import '../../app/views/fs_dialog.dart';
import '../../app/views/action_list.dart';
import '../models.dart';
import 'actions.dart';
import 'delete_credential_dialog.dart';
class CredentialDialog extends ConsumerWidget {
final FidoCredential credential;
const CredentialDialog(this.credential, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// TODO: Solve this in a cleaner way
final node = ref.watch(currentDeviceDataProvider).valueOrNull?.node;
if (node == null) {
// The rest of this method assumes there is a device, and will throw an exception if not.
// This will never be shown, as the dialog will be immediately closed
return const SizedBox();
}
final l10n = AppLocalizations.of(context)!;
return Actions(
actions: {
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async {
final withContext = ref.read(withContextProvider);
final bool? deleted =
await ref.read(withContextProvider)((context) async =>
await showBlurDialog(
context: context,
builder: (context) => DeleteCredentialDialog(
node.path,
credential,
),
) ??
false);
// Pop the account dialog if deleted
if (deleted == true) {
await withContext((context) async {
Navigator.of(context).pop();
});
}
return deleted;
}),
},
child: FocusScope(
autofocus: true,
child: FsDialog(
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(top: 48, bottom: 32),
child: Column(
children: [
Text(
credential.userName,
style: Theme.of(context).textTheme.headlineSmall,
softWrap: true,
textAlign: TextAlign.center,
),
Text(
credential.rpId,
softWrap: true,
textAlign: TextAlign.center,
// This is what ListTile uses for subtitle
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
color: Theme.of(context).textTheme.bodySmall!.color,
),
),
const SizedBox(height: 16),
const Icon(Icons.person, size: 72),
],
),
),
ActionListSection.fromMenuActions(
context,
l10n.s_actions,
actions: buildCredentialActions(l10n),
),
],
),
),
),
);
}
}

View File

@ -38,14 +38,14 @@ class DeleteCredentialDialog extends ConsumerWidget {
final label = credential.userName;
return ResponsiveDialog(
title: Text(l10n.s_delete_credential),
title: Text(l10n.s_delete_passkey),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.p_warning_delete_credential),
Text(l10n.l_credential(label)),
Text(l10n.p_warning_delete_passkey),
Text(l10n.l_passkey(label)),
]
.map((e) => Padding(
child: e,
@ -63,7 +63,7 @@ class DeleteCredentialDialog extends ConsumerWidget {
await ref.read(withContextProvider)(
(context) async {
Navigator.of(context).pop(true);
showMessage(context, l10n.s_credential_deleted);
showMessage(context, l10n.s_passkey_deleted);
},
);
},

View File

@ -0,0 +1,109 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/shortcuts.dart';
import '../../app/state.dart';
import '../../app/views/fs_dialog.dart';
import '../../app/views/action_list.dart';
import '../models.dart';
import 'actions.dart';
import 'delete_fingerprint_dialog.dart';
import 'rename_fingerprint_dialog.dart';
class FingerprintDialog extends ConsumerWidget {
final Fingerprint fingerprint;
const FingerprintDialog(this.fingerprint, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// TODO: Solve this in a cleaner way
final node = ref.watch(currentDeviceDataProvider).valueOrNull?.node;
if (node == null) {
// The rest of this method assumes there is a device, and will throw an exception if not.
// This will never be shown, as the dialog will be immediately closed
return const SizedBox();
}
final l10n = AppLocalizations.of(context)!;
return Actions(
actions: {
EditIntent: CallbackAction<EditIntent>(onInvoke: (_) async {
final withContext = ref.read(withContextProvider);
final Fingerprint? renamed =
await withContext((context) async => await showBlurDialog(
context: context,
builder: (context) => RenameFingerprintDialog(
node.path,
fingerprint,
),
));
if (renamed != null) {
// Replace the dialog with the renamed credential
await withContext((context) async {
Navigator.of(context).pop();
await showBlurDialog(
context: context,
builder: (context) {
return FingerprintDialog(renamed);
},
);
});
}
return renamed;
}),
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async {
final withContext = ref.read(withContextProvider);
final bool? deleted =
await ref.read(withContextProvider)((context) async =>
await showBlurDialog(
context: context,
builder: (context) => DeleteFingerprintDialog(
node.path,
fingerprint,
),
) ??
false);
// Pop the account dialog if deleted
if (deleted == true) {
await withContext((context) async {
Navigator.of(context).pop();
});
}
return deleted;
}),
},
child: FocusScope(
autofocus: true,
child: FsDialog(
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(top: 48, bottom: 32),
child: Column(
children: [
Text(
fingerprint.label,
style: Theme.of(context).textTheme.headlineSmall,
softWrap: true,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
const Icon(Icons.fingerprint, size: 72),
],
),
),
ActionListSection.fromMenuActions(
context,
l10n.s_actions,
actions: buildFingerprintActions(l10n),
),
],
),
),
),
);
}
}

View File

@ -19,32 +19,43 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../widgets/list_title.dart';
import '../../app/views/fs_dialog.dart';
import '../../app/views/action_list.dart';
import '../models.dart';
import '../keys.dart' as keys;
import 'add_fingerprint_dialog.dart';
import 'pin_dialog.dart';
import 'reset_dialog.dart';
bool fidoShowActionsNotifier(FidoState state) {
return (state.alwaysUv && !state.hasPin) || state.bioEnroll == false;
}
Widget fidoBuildActions(
BuildContext context, DeviceNode node, FidoState state, int fingerprints) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context).colorScheme;
return SimpleDialog(
return FsDialog(
child: Column(
children: [
if (state.bioEnroll != null) ...[
ListTitle(l10n.s_setup,
textStyle: Theme.of(context).textTheme.bodyLarge),
ListTile(
leading: const CircleAvatar(child: Icon(Icons.fingerprint_outlined)),
title: Text(l10n.s_add_fingerprint),
if (state.bioEnroll != null)
ActionListSection(
l10n.s_setup,
children: [
ActionListItem(
key: keys.addFingerprintAction,
actionStyle: ActionStyle.primary,
icon: const Icon(Icons.fingerprint_outlined),
title: l10n.s_add_fingerprint,
subtitle: state.unlocked
? Text(l10n.l_fingerprints_used(fingerprints))
: Text(state.hasPin
? l10n.l_fingerprints_used(fingerprints)
: state.hasPin
? l10n.l_unlock_pin_first
: l10n.l_set_pin_first),
enabled: state.unlocked && fingerprints < 5,
: l10n.l_set_pin_first,
trailing:
fingerprints == 0 ? const Icon(Icons.warning_amber) : null,
onTap: state.unlocked && fingerprints < 5
? () {
? (context) {
Navigator.of(context).pop();
showBlurDialog(
context: context,
@ -54,30 +65,34 @@ Widget fidoBuildActions(
: null,
),
],
ListTitle(l10n.s_manage,
textStyle: Theme.of(context).textTheme.bodyLarge),
ListTile(
leading: const CircleAvatar(child: Icon(Icons.pin_outlined)),
title: Text(state.hasPin ? l10n.s_change_pin : l10n.s_set_pin),
subtitle: Text(state.hasPin
),
ActionListSection(
l10n.s_manage,
children: [
ActionListItem(
key: keys.managePinAction,
icon: const Icon(Icons.pin_outlined),
title: state.hasPin ? l10n.s_change_pin : l10n.s_set_pin,
subtitle: state.hasPin
? l10n.s_fido_pin_protection
: l10n.l_fido_pin_protection_optional),
onTap: () {
: l10n.l_fido_pin_protection_optional,
trailing: state.alwaysUv && !state.hasPin
? const Icon(Icons.warning_amber)
: null,
onTap: (context) {
Navigator.of(context).pop();
showBlurDialog(
context: context,
builder: (context) => FidoPinDialog(node.path, state),
);
}),
ListTile(
leading: CircleAvatar(
foregroundColor: theme.onError,
backgroundColor: theme.error,
child: const Icon(Icons.delete_outline),
),
title: Text(l10n.s_reset_fido),
subtitle: Text(l10n.l_factory_reset_this_app),
onTap: () {
ActionListItem(
key: keys.resetAction,
actionStyle: ActionStyle.error,
icon: const Icon(Icons.delete_outline),
title: l10n.s_reset_fido,
subtitle: l10n.l_factory_reset_this_app,
onTap: (context) {
Navigator.of(context).pop();
showBlurDialog(
context: context,
@ -86,5 +101,8 @@ Widget fidoBuildActions(
},
),
],
)
],
),
);
}

View File

@ -43,6 +43,7 @@ class FidoLockedPage extends ConsumerWidget {
header: l10n.s_no_fingerprints,
message: l10n.l_set_pin_fingerprints,
keyActionsBuilder: _buildActions,
keyActionsBadge: fidoShowActionsNotifier(state),
);
} else {
return MessagePage(
@ -53,6 +54,7 @@ class FidoLockedPage extends ConsumerWidget {
: l10n.l_ready_to_use,
message: l10n.l_optionally_set_a_pin,
keyActionsBuilder: _buildActions,
keyActionsBadge: fidoShowActionsNotifier(state),
);
}
}

View File

@ -20,14 +20,19 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/shortcuts.dart';
import '../../app/views/app_list_item.dart';
import '../../app/views/app_page.dart';
import '../../app/views/graphics.dart';
import '../../app/views/message_page.dart';
import '../../widgets/list_title.dart';
import '../models.dart';
import '../state.dart';
import 'actions.dart';
import 'credential_dialog.dart';
import 'delete_credential_dialog.dart';
import 'delete_fingerprint_dialog.dart';
import 'fingerprint_dialog.dart';
import 'key_actions.dart';
import 'rename_fingerprint_dialog.dart';
@ -48,42 +53,26 @@ class FidoUnlockedPage extends ConsumerWidget {
}
final creds = data.value;
if (creds.isNotEmpty) {
children.add(ListTitle(l10n.s_credentials));
children.addAll(
creds.map(
(cred) => ListTile(
leading: CircleAvatar(
foregroundColor: Theme.of(context).colorScheme.onPrimary,
backgroundColor: Theme.of(context).colorScheme.primary,
child: const Icon(Icons.person),
),
title: Text(
cred.userName,
softWrap: false,
overflow: TextOverflow.fade,
),
subtitle: Text(
cred.rpId,
softWrap: false,
overflow: TextOverflow.fade,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: () {
showBlurDialog(
children.add(ListTitle(l10n.s_passkeys));
children.addAll(creds.map((cred) => Actions(
actions: {
OpenIntent: CallbackAction<OpenIntent>(
onInvoke: (_) => showBlurDialog(
context: context,
builder: (context) =>
DeleteCredentialDialog(node.path, cred),
);
builder: (context) => CredentialDialog(cred),
)),
DeleteIntent: CallbackAction<DeleteIntent>(
onInvoke: (_) => showBlurDialog(
context: context,
builder: (context) => DeleteCredentialDialog(
node.path,
cred,
),
),
),
},
icon: const Icon(Icons.delete_outline)),
],
),
),
),
);
child: _CredentialListItem(cred),
)));
}
}
@ -97,40 +86,31 @@ class FidoUnlockedPage extends ConsumerWidget {
if (fingerprints.isNotEmpty) {
nFingerprints = fingerprints.length;
children.add(ListTitle(l10n.s_fingerprints));
children.addAll(fingerprints.map((fp) => ListTile(
leading: CircleAvatar(
foregroundColor: Theme.of(context).colorScheme.onSecondary,
backgroundColor: Theme.of(context).colorScheme.secondary,
child: const Icon(Icons.fingerprint),
),
title: Text(
fp.label,
softWrap: false,
overflow: TextOverflow.fade,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: () {
showBlurDialog(
children.addAll(fingerprints.map((fp) => Actions(
actions: {
OpenIntent: CallbackAction<OpenIntent>(
onInvoke: (_) => showBlurDialog(
context: context,
builder: (context) =>
RenameFingerprintDialog(node.path, fp),
);
},
icon: const Icon(Icons.edit_outlined)),
IconButton(
onPressed: () {
showBlurDialog(
builder: (context) => FingerprintDialog(fp),
)),
EditIntent: CallbackAction<EditIntent>(
onInvoke: (_) => showBlurDialog(
context: context,
builder: (context) =>
DeleteFingerprintDialog(node.path, fp),
);
},
icon: const Icon(Icons.delete_outline)),
],
builder: (context) => RenameFingerprintDialog(
node.path,
fp,
),
)),
DeleteIntent: CallbackAction<DeleteIntent>(
onInvoke: (_) => showBlurDialog(
context: context,
builder: (context) => DeleteFingerprintDialog(
node.path,
fp,
),
)),
},
child: _FingerprintListItem(fp),
)));
}
}
@ -140,6 +120,7 @@ class FidoUnlockedPage extends ConsumerWidget {
title: Text(l10n.s_webauthn),
keyActionsBuilder: (context) =>
fidoBuildActions(context, node, state, nFingerprints),
keyActionsBadge: fidoShowActionsNotifier(state),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: children),
);
@ -153,6 +134,7 @@ class FidoUnlockedPage extends ConsumerWidget {
message: l10n.l_add_one_or_more_fps,
keyActionsBuilder: (context) =>
fidoBuildActions(context, node, state, 0),
keyActionsBadge: fidoShowActionsNotifier(state),
);
}
@ -162,6 +144,7 @@ class FidoUnlockedPage extends ConsumerWidget {
header: l10n.l_no_discoverable_accounts,
message: l10n.l_register_sk_on_websites,
keyActionsBuilder: (context) => fidoBuildActions(context, node, state, 0),
keyActionsBadge: fidoShowActionsNotifier(state),
);
}
@ -172,3 +155,50 @@ class FidoUnlockedPage extends ConsumerWidget {
child: const CircularProgressIndicator(),
);
}
class _CredentialListItem extends StatelessWidget {
final FidoCredential credential;
const _CredentialListItem(this.credential);
@override
Widget build(BuildContext context) {
return AppListItem(
leading: CircleAvatar(
foregroundColor: Theme.of(context).colorScheme.onPrimary,
backgroundColor: Theme.of(context).colorScheme.primary,
child: const Icon(Icons.person),
),
title: credential.userName,
subtitle: credential.rpId,
trailing: OutlinedButton(
onPressed: Actions.handler(context, const OpenIntent()),
child: const Icon(Icons.more_horiz),
),
buildPopupActions: (context) =>
buildCredentialActions(AppLocalizations.of(context)!),
);
}
}
class _FingerprintListItem extends StatelessWidget {
final Fingerprint fingerprint;
const _FingerprintListItem(this.fingerprint);
@override
Widget build(BuildContext context) {
return AppListItem(
leading: CircleAvatar(
foregroundColor: Theme.of(context).colorScheme.onSecondary,
backgroundColor: Theme.of(context).colorScheme.secondary,
child: const Icon(Icons.fingerprint),
),
title: fingerprint.label,
trailing: OutlinedButton(
onPressed: Actions.handler(context, const OpenIntent()),
child: const Icon(Icons.more_horiz),
),
buildPopupActions: (context) =>
buildFingerprintActions(AppLocalizations.of(context)!),
);
}
}

445
lib/l10n/app_de.arb Normal file
View File

@ -0,0 +1,445 @@
{
"@@locale": "de",
"@_readme": {
"notes": [
"Alle Zeichenketten beginnen mit einem Großbuchstaben.",
"Gruppieren Sie Zeichenketten nach Kategorie, aber fügen Sie nicht zu einem Bereich hinzu, wenn sie in mehreren Bereichen verwendet werden können.",
"Führen Sie check_strings.py für die .arb Datei aus, um Probleme zu finden. Passen Sie @_lint_rules nach Sprache an wie nötig."
],
"prefixes": {
"s_": "Ein einzelnes Wort oder wenige Wörter. Sollte kurz genug sein, um auf einer Schaltfläche oder einer Überschrift angezeigt zu werden.",
"l_": "Eine einzelne Zeile, kann umbgebrochen werden. Sollte nicht mehr als einen Satz umfassen und nicht mit einem Punkt enden.",
"p_": "Ein oder mehrere ganze Sätze mit allen Satzzeichen.",
"q_": "Eine Frage, die mit einem Fragezeichen endet."
}
},
"@_lint_rules": {
"s_max_words": 4,
"s_max_length": 32
},
"app_name": "Yubico Authenticator",
"s_save": "Speichern",
"s_cancel": "Abbrechen",
"s_close": "Schließen",
"s_delete": "Löschen",
"s_quit": "Beenden",
"s_unlock": "Entsperren",
"s_calculate": "Berechnen",
"s_label": "Beschriftung",
"s_name": "Name",
"s_usb": "USB",
"s_nfc": "NFC",
"s_show_window": "Fenster anzeigen",
"s_hide_window": "Fenster verstecken",
"q_rename_target": "{label} umbenennen?",
"@q_rename_target" : {
"placeholders": {
"label": {}
}
},
"s_about": "Über",
"s_appearance": "Aussehen",
"s_authenticator": "Authenticator",
"s_manage": "Verwalten",
"s_setup": "Einrichten",
"s_settings": "Einstellungen",
"s_webauthn": "WebAuthn",
"s_help_and_about": "Hilfe und Über",
"s_help_and_feedback": "Hilfe und Feedback",
"s_send_feedback": "Senden Sie uns Feedback",
"s_i_need_help": "Ich brauche Hilfe",
"s_troubleshooting": "Problembehebung",
"s_terms_of_use": "Nutzungsbedingungen",
"s_privacy_policy": "Datenschutzerklärung",
"s_open_src_licenses": "Open Source-Lizenzen",
"s_configure_yk": "YubiKey konfigurieren",
"s_please_wait": "Bitte warten\u2026",
"s_secret_key": "Geheimer Schlüssel",
"s_invalid_length": "Ungültige Länge",
"s_require_touch": "Berührung ist erforderlich",
"q_have_account_info": "Haben Sie Konto-Informationen?",
"s_run_diagnostics": "Diagnose ausführen",
"s_log_level": "Log-Level: {level}",
"@s_log_level": {
"placeholders": {
"level": {}
}
},
"s_character_count": "Anzahl Zeichen",
"s_learn_more": "Mehr\u00a0erfahren",
"@_language": {},
"s_language": "Sprache",
"l_enable_community_translations": "Übersetzungen der Gemeinschaft aktivieren",
"p_community_translations_desc": "Diese Übersetzungen werden von der Gemeinschaft erstellt und gewartet. Sie könnten Fehler enthalten oder unvollständig sein.",
"@_theme": {},
"s_app_theme": "App Theme",
"s_choose_app_theme": "App Theme auswählen",
"s_system_default": "Standard des Systems",
"s_light_mode": "Heller Modus",
"s_dark_mode": "Dunkler Modus",
"@_yubikey_selection": {},
"s_yk_information": "YubiKey Information",
"s_select_yk": "YubiKey auswählen",
"s_select_to_scan": "Zum Scannen auswählen",
"s_hide_device": "Gerät verstecken",
"s_show_hidden_devices": "Versteckte Geräte anzeigen",
"s_sn_serial": "S/N: {serial}",
"@s_sn_serial" : {
"placeholders": {
"serial": {}
}
},
"s_fw_version": "F/W: {version}",
"@s_fw_version" : {
"placeholders": {
"version": {}
}
},
"@_yubikey_interactions": {},
"l_insert_yk": "YubiKey anschließen",
"l_insert_or_tap_yk": "YubiKey anschließen oder dagegenhalten",
"l_unplug_yk": "Entfernen Sie Ihren YubiKey",
"l_reinsert_yk": "Schließen Sie Ihren YubiKey wieder an",
"l_place_on_nfc_reader": "Halten Sie Ihren YubiKey zum NFC-Leser",
"l_replace_yk_on_reader": "Halten Sie Ihren YubiKey wieder zum Leser",
"l_remove_yk_from_reader": "Entfernen Sie Ihren YubiKey vom NFC-Leser",
"p_try_reinsert_yk": "Versuchen Sie Ihren YubiKey zu entfernen und wieder anzuschließen.",
"s_touch_required": "Berührung erforderlich",
"l_touch_button_now": "Berühren Sie jetzt die Schaltfläche auf Ihrem YubiKey",
"l_keep_touching_yk": "Berühren Sie Ihren YubiKey wiederholt\u2026",
"@_app_configuration": {},
"s_toggle_applications": "Anwendungen umschalten",
"l_min_one_interface": "Mindestens ein Interface muss aktiviert sein",
"s_reconfiguring_yk": "YubiKey wird neu konfiguriert\u2026",
"s_config_updated": "Konfiguration aktualisiert",
"l_config_updated_reinsert": "Konfiguration aktualisiert, entfernen Sie Ihren YubiKey und schließen ihn wieder an",
"s_app_not_supported": "Anwendung nicht unterstützt",
"l_app_not_supported_on_yk": "Der verwendete YubiKey unterstützt die Anwendung '{app}' nicht",
"@l_app_not_supported_on_yk" : {
"placeholders": {
"app": {}
}
},
"l_app_not_supported_desc": "Diese Anwendung wird nicht unterstützt",
"s_app_disabled": "Anwendung deaktiviert",
"l_app_disabled_desc": "Aktivieren Sie die Anwendung '{app}' auf Ihrem YubiKey für Zugriff",
"@l_app_disabled_desc" : {
"placeholders": {
"app": {}
}
},
"s_fido_disabled": "FIDO2 deaktiviert",
"l_webauthn_req_fido2": "WebAuthn erfordert, dass die FIDO2 Anwendung auf Ihrem YubiKey aktiviert ist",
"@_connectivity_issues": {},
"l_helper_not_responding": "Der Helper-Prozess antwortet nicht",
"l_yk_no_access": "Auf diesen YubiKey kann nicht zugegriffen werden",
"s_yk_inaccessible": "Gerät nicht zugänglich",
"l_open_connection_failed": "Konnte keine Verbindung öffnen",
"l_ccid_connection_failed": "Konnte keine Smartcard-Verbindung öffnen",
"p_ccid_service_unavailable": "Stellen Sie sicher, dass Ihr Smartcard-Service funktioniert.",
"p_pcscd_unavailable": "Stellen Sie sicher, dass pcscd installiert ist und ausgeführt wird.",
"l_no_yk_present": "Kein YubiKey vorhanden",
"s_unknown_type": "Unbekannter Typ",
"s_unknown_device": "Unbekanntes Gerät",
"s_unsupported_yk": "Nicht unterstützter YubiKey",
"s_yk_not_recognized": "Geräte nicht erkannt",
"@_general_errors": {},
"l_error_occured": "Es ist ein Fehler aufgetreten",
"s_application_error": "Anwendungs-Fehler",
"l_import_error": "Import-Fehler",
"l_file_not_found": "Datei nicht gefunden",
"l_file_too_big": "Datei ist zu groß",
"l_filesystem_error": "Fehler beim Dateisystem-Zugriff",
"@_pins": {},
"s_pin": "PIN",
"s_set_pin": "PIN setzen",
"s_change_pin": "PIN ändern",
"s_current_pin": "Derzeitige PIN",
"s_new_pin": "Neue PIN",
"s_confirm_pin": "PIN bestätigen",
"l_new_pin_len": "Neue PIN muss mindestens {length} Zeichen lang sein",
"@l_new_pin_len" : {
"placeholders": {
"length": {}
}
},
"s_pin_set": "PIN gesetzt",
"l_set_pin_failed": "PIN konnte nicht gesetzt werden: {message}",
"@l_set_pin_failed" : {
"placeholders": {
"message": {}
}
},
"l_wrong_pin_attempts_remaining": "Falsche PIN, {retries} Versuch(e) verbleibend",
"@l_wrong_pin_attempts_remaining" : {
"placeholders": {
"retries": {}
}
},
"s_fido_pin_protection": "FIDO PIN Schutz",
"l_fido_pin_protection_optional": "Optionaler FIDO PIN Schutz",
"l_enter_fido2_pin": "Geben Sie die FIDO2 PIN für Ihren YubiKey ein",
"l_optionally_set_a_pin": "Setzen Sie optional eine PIN, um den Zugriff auf Ihren YubiKey zu schützen\nAls Sicherheitsschlüssel auf Webseiten registrieren",
"l_pin_blocked_reset": "PIN ist blockiert; setzen Sie die FIDO Anwendung auf Werkseinstellung zurück",
"l_set_pin_first": "Zuerst ist eine PIN erforderlich",
"l_unlock_pin_first": "Zuerst mit PIN entsperren",
"l_pin_soft_locked": "PIN wurde blockiert bis der YubiKey entfernt und wieder angeschlossen wird",
"p_enter_current_pin_or_reset": "Geben Sie Ihre aktuelle PIN ein. Wenn Sie die PIN nicht wissen, müssen Sie den YubiKey zurücksetzen.",
"p_enter_new_fido2_pin": "Geben Sie Ihre neue PIN ein. Eine PIN muss mindestens {length} Zeichen lang sein und kann Buchstaben, Ziffern und spezielle Zeichen enthalten.",
"@p_enter_new_fido2_pin" : {
"placeholders": {
"length": {}
}
},
"@_passwords": {},
"s_password": "Passwort",
"s_manage_password": "Passwort verwalten",
"s_set_password": "Passwort setzen",
"s_password_set": "Passwort gesetzt",
"l_optional_password_protection": "Optionaler Passwortschutz",
"s_new_password": "Neues Passwort",
"s_current_password": "Aktuelles Passwort",
"s_confirm_password": "Passwort bestätigen",
"s_wrong_password": "Falsches Passwort",
"s_remove_password": "Passwort entfernen",
"s_password_removed": "Passwort entfernt",
"s_remember_password": "Passwort speichern",
"s_clear_saved_password": "Gespeichertes Passwort entfernen",
"s_password_forgotten": "Passwort vergessen",
"l_keystore_unavailable": "Passwortspeicher des Betriebssystems nicht verfügbar",
"l_remember_pw_failed": "Konnte Passwort nicht speichern",
"l_unlock_first": "Zuerst mit Passwort entsperren",
"l_enter_oath_pw": "Das OATH-Passwort für Ihren YubiKey eingeben",
"p_enter_current_password_or_reset": "Geben Sie Ihr aktuelles Passwort ein. Wenn Sie Ihr Passwort nicht wissen, müssen Sie den YubiKey zurücksetzen.",
"p_enter_new_password": "Geben Sie Ihr neues Passwort ein. Ein Passwort kann Buchstaben, Ziffern und spezielle Zeichen enthalten.",
"@_oath_accounts": {},
"l_account": "Konto: {label}",
"@l_account" : {
"placeholders": {
"label": {}
}
},
"s_accounts": "Konten",
"s_no_accounts": "Keine Konten",
"s_add_account": "Konto hinzufügen",
"s_account_added": "Konto hinzugefügt",
"l_account_add_failed": "Fehler beim Hinzufügen des Kontos: {message}",
"@l_account_add_failed" : {
"placeholders": {
"message": {}
}
},
"l_account_name_required": "Ihr Konto muss einen Namen haben",
"l_name_already_exists": "Für diesen Aussteller existiert dieser Name bereits",
"l_invalid_character_issuer": "Ungültiges Zeichen: ':' ist im Aussteller nicht erlaubt",
"s_pinned": "Angepinnt",
"s_pin_account": "Konto anpinnen",
"s_unpin_account": "Konto nicht mehr anpinnen",
"s_no_pinned_accounts": "Keine angepinnten Konten",
"s_rename_account": "Konto umbenennen",
"s_account_renamed": "Konto umbenannt",
"p_rename_will_change_account_displayed": "Das ändert die Anzeige dieses Kontos in der Liste.",
"s_delete_account": "Konto löschen",
"s_account_deleted": "Konto gelöscht",
"p_warning_delete_account": "Vorsicht! Das löscht das Konto von Ihrem YubiKey.",
"p_warning_disable_credential": "Sie werden keine OTPs für dieses Konto mehr erstellen können. Deaktivieren Sie diese Anmeldeinformation zuerst auf der Webseite, um nicht aus dem Konto ausgesperrt zu werden.",
"s_account_name": "Kontoname",
"s_search_accounts": "Konten durchsuchen",
"l_accounts_used": "{used} von {capacity} Konten verwendet",
"@l_accounts_used" : {
"placeholders": {
"used": {},
"capacity": {}
}
},
"s_num_digits": "{num} Ziffern",
"@s_num_digits" : {
"placeholders": {
"num": {}
}
},
"s_num_sec": "{num} sek",
"@s_num_sec" : {
"placeholders": {
"num": {}
}
},
"s_issuer_optional": "Aussteller (optional)",
"s_counter_based": "Zähler-basiert",
"s_time_based": "Zeit-basiert",
"@_fido_credentials": {},
"l_credential": "Anmeldeinformation: {label}",
"@l_credential" : {
"placeholders": {
"label": {}
}
},
"s_credentials": "Anmeldeinformationen",
"l_ready_to_use": "Bereit zur Verwendung",
"l_register_sk_on_websites": "Als Sicherheitsschlüssel auf Webseiten registrieren",
"l_no_discoverable_accounts": "Keine erkennbaren Konten",
"s_delete_credential": "Anmeldeinformation löschen",
"s_credential_deleted": "Anmeldeinformation gelöscht",
"p_warning_delete_credential": "Das löscht die Anmeldeinformation von Ihrem YubiKey.",
"@_fingerprints": {},
"l_fingerprint": "Fingerabdruck: {label}",
"@l_fingerprint" : {
"placeholders": {
"label": {}
}
},
"s_fingerprints": "Fingerabdrücke",
"l_fingerprint_captured": "Fingerabdruck erfolgreich aufgenommen!",
"s_fingerprint_added": "Fingerabdruck hinzugefügt",
"l_setting_name_failed": "Fehler beim Setzen des Namens: {message}",
"@l_setting_name_failed" : {
"placeholders": {
"message": {}
}
},
"s_add_fingerprint": "Fingerabdruck hinzufügen",
"l_fp_step_1_capture": "Schritt 1/2: Fingerabdruck aufnehmen",
"l_fp_step_2_name": "Schritt 2/2: Fingerabdruck benennen",
"s_delete_fingerprint": "Fingerabdruck löschen",
"s_fingerprint_deleted": "Fingerabdruck gelöscht",
"p_warning_delete_fingerprint": "Das löscht den Fingerabdruck von Ihrem YubiKey.",
"s_no_fingerprints": "Keine Fingerabdrücke",
"l_set_pin_fingerprints": "Setzen Sie eine PIN um Fingerabdrücke zu registrieren",
"l_no_fps_added": "Es wurden keine Fingerabdrücke hinzugefügt",
"s_rename_fp": "Fingerabdruck umbenennen",
"s_fingerprint_renamed": "Fingerabdruck umbenannt",
"l_rename_fp_failed": "Fehler beim Umbenennen: {message}",
"@l_rename_fp_failed" : {
"placeholders": {
"message": {}
}
},
"l_add_one_or_more_fps": "Fügen Sie einen oder bis zu fünf Fingerabdrücke hinzu",
"l_fingerprints_used": "{used}/5 Fingerabdrücke registriert",
"@l_fingerprints_used": {
"placeholders": {
"used": {}
}
},
"p_press_fingerprint_begin": "Drücken Sie Ihren Finger gegen den YubiKey um zu beginnen.",
"p_will_change_label_fp": "Das ändert die Beschriftung des Fingerabdrucks.",
"@_permissions": {},
"s_enable_nfc": "NFC aktivieren",
"s_permission_denied": "Zugriff verweigert",
"l_elevating_permissions": "Erhöhe Berechtigungen\u2026",
"s_review_permissions": "Berechtigungen überprüfen",
"p_elevated_permissions_required": "Die Verwaltung dieses Geräts benötigt erhöhte Berechtigungen.",
"p_webauthn_elevated_permissions_required": "WebAuthn-Verwaltung benötigt erhöhte Berechtigungen.",
"p_need_camera_permission": "Yubico Authenticator benötigt Zugriff auf die Kamera um QR-Codes aufnehmen zu können.",
"@_qr_codes": {},
"s_qr_scan": "QR-Code aufnehmen",
"l_qr_scanned": "QR-Code aufgenommen",
"l_invalid_qr": "Ungültiger QR-Code",
"l_qr_not_found": "Kein QR-Code gefunden",
"l_qr_not_read": "Fehler beim Lesen des QR-Codes: {message}",
"@l_qr_not_read" : {
"placeholders": {
"message": {}
}
},
"l_point_camera_scan": "Halten Sie Ihre Kamera auf einen QR-Code um ihn aufzunehmen",
"q_want_to_scan": "Möchten Sie aufnehmen?",
"q_no_qr": "Kein QR-Code?",
"s_enter_manually": "Manuell eingeben",
"@_factory_reset": {},
"s_reset": "Zurücksetzen",
"s_factory_reset": "Werkseinstellungen",
"l_factory_reset_this_app": "Anwendung auf Werkseinstellung zurücksetzen",
"s_reset_oath": "OATH zurücksetzen",
"l_oath_application_reset": "OATH Anwendung zurücksetzen",
"s_reset_fido": "FIDO zurücksetzen",
"l_fido_app_reset": "FIDO Anwendung zurückgesetzt",
"l_press_reset_to_begin": "Drücken Sie Zurücksetzen um zu beginnen\u2026",
"l_reset_failed": "Fehler beim Zurücksetzen: {message}",
"@l_reset_failed" : {
"placeholders": {
"message": {}
}
},
"p_warning_factory_reset": "Achtung! Das löscht alle OATH TOTP/HOTP Konten unwiederbringlich von Ihrem YubiKey.",
"p_warning_disable_credentials": "Ihre OATH Anmeldeinformationen und jedes gesetzte Passwort wird von diesem YubiKey entfernt. Deaktivieren Sie diese zuerst auf den zugehörigen Webseiten, um nicht aus ihren Konten ausgesperrt zu werden.",
"p_warning_deletes_accounts": "Achtung! Das löscht alle U2F und FIDO2 Konten unwiederbringlich von Ihrem YubiKey.",
"p_warning_disable_accounts": "Ihre Anmeldeinformationen und jede gesetzte PIN wird von diesem YubiKey entfernt. Deaktivieren Sie diese zuerst auf den zugehörigen Webseiten, um nicht aus ihren Konten ausgesperrt zu werden.",
"@_copy_to_clipboard": {},
"l_copy_to_clipboard": "In die Zwischenablage kopieren",
"s_code_copied": "Code kopiert",
"l_code_copied_clipboard": "Code in die Zwischenablage kopiert",
"s_copy_log": "Log kopiert",
"l_log_copied": "Log in die Zwischenablage kopiert",
"l_diagnostics_copied": "Diagnosedaten in die Zwischenablage kopiert",
"p_target_copied_clipboard": "{label} in die Zwischenablage kopiert.",
"@p_target_copied_clipboard" : {
"placeholders": {
"label": {}
}
},
"@_custom_icons": {},
"s_custom_icons": "Eigene Icons",
"l_set_icons_for_accounts": "Icons für Konten setzen",
"p_custom_icons_description": "Icon-Pakete machen Ihre Konten mit bekannten Logos und Farben leichter unterscheidbar.",
"s_replace_icon_pack": "Icon-Paket ersetzen",
"l_loading_icon_pack": "Lade Icon-Paket\u2026",
"s_load_icon_pack": "Icon-Paket laden",
"s_remove_icon_pack": "Icon-Paket entfernen",
"l_icon_pack_removed": "Icon-Paket entfernt",
"l_remove_icon_pack_failed": "Fehler beim Entfernen des Icon-Pakets",
"s_choose_icon_pack": "Icon-Paket auswählen",
"l_icon_pack_imported": "Icon-Paket importiert",
"l_import_icon_pack_failed": "Fehler beim Importieren des Icon-Pakets: {message}",
"@l_import_icon_pack_failed": {
"placeholders": {
"message": {}
}
},
"l_invalid_icon_pack": "Ungültiges Icon-Paket",
"l_icon_pack_copy_failed": "Kopieren der Dateien des Icon-Pakets fehlgeschlagen",
"@_android_settings": {},
"s_nfc_options": "NFC Optionen",
"l_on_yk_nfc_tap": "Bei YubiKey NFC-Berührung",
"l_launch_ya": "Yubico Authenticator starten",
"l_copy_otp_clipboard": "OTP in Zwischenablage kopieren",
"l_launch_and_copy_otp": "App starten und OTP kopieren",
"l_kbd_layout_for_static": "Tastaturlayout (für statisches Passwort)",
"s_choose_kbd_layout": "Tastaturlayout auswählen",
"l_bypass_touch_requirement": "Notwendigkeit zur Berührung umgehen",
"l_bypass_touch_requirement_on": "Konten, die Berührung erfordern, werden automatisch über NFC angezeigt",
"l_bypass_touch_requirement_off": "Konten, die Berührung erfordern, benötigen eine zusätzliche NFC-Berührung",
"s_silence_nfc_sounds": "NFC-Töne stummschalten",
"l_silence_nfc_sounds_on": "Keine Töne werden bei NFC-Berührung abgespielt",
"l_silence_nfc_sounds_off": "Töne werden bei NFC-Berührung abgespielt",
"s_usb_options": "USB Optionen",
"l_launch_app_on_usb": "Starten, wenn YubiKey angesteckt wird",
"l_launch_app_on_usb_on": "Das verhindert, dass andere Anwendungen den YubiKey über USB nutzen",
"l_launch_app_on_usb_off": "Andere Anwendungen können den YubiKey über USB nutzen",
"s_allow_screenshots": "Bildschirmfotos erlauben",
"@_eof": {}
}

View File

@ -29,6 +29,7 @@
"s_quit": "Quit",
"s_unlock": "Unlock",
"s_calculate": "Calculate",
"s_import": "Import",
"s_label": "Label",
"s_name": "Name",
"s_usb": "USB",
@ -41,13 +42,21 @@
"label": {}
}
},
"l_bullet": "• {item}",
"@l_bullet" : {
"placeholders": {
"item": {}
}
},
"s_about": "About",
"s_appearance": "Appearance",
"s_authenticator": "Authenticator",
"s_actions": "Actions",
"s_manage": "Manage",
"s_setup": "Setup",
"s_settings": "Settings",
"s_piv": "PIV",
"s_webauthn": "WebAuthn",
"s_help_and_about": "Help and about",
"s_help_and_feedback": "Help and feedback",
@ -60,6 +69,7 @@
"s_configure_yk": "Configure YubiKey",
"s_please_wait": "Please wait\u2026",
"s_secret_key": "Secret key",
"s_private_key": "Private key",
"s_invalid_length": "Invalid length",
"s_require_touch": "Require touch",
"q_have_account_info": "Have account info?",
@ -165,11 +175,17 @@
"@_pins": {},
"s_pin": "PIN",
"s_puk": "PUK",
"s_set_pin": "Set PIN",
"s_change_pin": "Change PIN",
"s_change_puk": "Change PUK",
"s_current_pin": "Current PIN",
"s_current_puk": "Current PUK",
"s_new_pin": "New PIN",
"s_new_puk": "New PUK",
"s_confirm_pin": "Confirm PIN",
"s_confirm_puk": "Confirm PUK",
"s_unblock_pin": "Unblock PIN",
"l_new_pin_len": "New PIN must be at least {length} characters",
"@l_new_pin_len" : {
"placeholders": {
@ -177,12 +193,19 @@
}
},
"s_pin_set": "PIN set",
"s_puk_set": "PUK set",
"l_set_pin_failed": "Failed to set PIN: {message}",
"@l_set_pin_failed" : {
"placeholders": {
"message": {}
}
},
"l_attempts_remaining": "{retries} attempt(s) remaining",
"@l_attempts_remaining" : {
"placeholders": {
"retries": {}
}
},
"l_wrong_pin_attempts_remaining": "Wrong PIN, {retries} attempt(s) remaining",
"@l_wrong_pin_attempts_remaining" : {
"placeholders": {
@ -197,13 +220,23 @@
"l_set_pin_first": "A PIN is required first",
"l_unlock_pin_first": "Unlock with PIN first",
"l_pin_soft_locked": "PIN has been blocked until the YubiKey is removed and reinserted",
"p_enter_current_pin_or_reset": "Enter your current PIN. If you don't know your PIN, you'll need to reset the YubiKey.",
"p_enter_current_pin_or_reset": "Enter your current PIN. If you don't know your PIN, you'll need to unblock it with the PUK or reset the YubiKey.",
"p_enter_current_puk_or_reset": "Enter your current PUK. If you don't know your PUK, you'll need to reset the YubiKey.",
"p_enter_new_fido2_pin": "Enter your new PIN. A PIN must be at least {length} characters long and may contain letters, numbers and special characters.",
"@p_enter_new_fido2_pin" : {
"placeholders": {
"length": {}
}
},
"s_pin_required": "PIN required",
"p_pin_required_desc": "The action you are about to perform requires the PIV PIN to be entered.",
"l_piv_pin_blocked": "Blocked, use PUK to reset",
"p_enter_new_piv_pin_puk": "Enter a new {name} to set. Must be 6-8 characters.",
"@p_enter_new_piv_pin_puk" : {
"placeholders": {
"name": {}
}
},
"@_passwords": {},
"s_password": "Password",
@ -227,6 +260,21 @@
"p_enter_current_password_or_reset": "Enter your current password. If you don't know your password, you'll need to reset the YubiKey.",
"p_enter_new_password": "Enter your new password. A password may contain letters, numbers and special characters.",
"@_management_key": {},
"s_management_key": "Management key",
"s_current_management_key": "Current management key",
"s_new_management_key": "New management key",
"l_change_management_key": "Change management key",
"p_change_management_key_desc": "Change your management key. You can optionally choose to allow the PIN to be used instead of the management key.",
"l_management_key_changed": "Management key changed",
"l_default_key_used": "Default management key used",
"l_warning_default_key": "Warning: Default key used",
"s_protect_key": "Protect with PIN",
"l_pin_protected_key": "PIN can be used instead",
"l_wrong_key": "Wrong key",
"l_unlock_piv_management": "Unlock PIV management",
"p_unlock_piv_management_desc": "The action you are about to perform requires the PIV management key. Provide this key to unlock management functionality for this session.",
"@_oath_accounts": {},
"l_account": "Account: {label}",
"@l_account" : {
@ -251,10 +299,13 @@
"s_pin_account": "Pin account",
"s_unpin_account": "Unpin account",
"s_no_pinned_accounts": "No pinned accounts",
"l_pin_account_desc": "Keep your important accounts together",
"s_rename_account": "Rename account",
"l_rename_account_desc": "Edit the issuer/name of the account",
"s_account_renamed": "Account renamed",
"p_rename_will_change_account_displayed": "This will change how the account is displayed in the list.",
"s_delete_account": "Delete account",
"l_delete_account_desc": "Remove the account from your YubiKey",
"s_account_deleted": "Account deleted",
"p_warning_delete_account": "Warning! This action will delete the account from your YubiKey.",
"p_warning_disable_credential": "You will no longer be able to generate OTPs for this account. Make sure to first disable this credential from the website to avoid being locked out of your account.",
@ -282,21 +333,25 @@
"s_issuer_optional": "Issuer (optional)",
"s_counter_based": "Counter based",
"s_time_based": "Time based",
"l_copy_code_desc": "Easily paste the code into another app",
"s_calculate_code": "Calculate code",
"l_calculate_code_desc": "Get a new code from your YubiKey",
"@_fido_credentials": {},
"l_credential": "Credential: {label}",
"@l_credential" : {
"l_passkey": "Passkey: {label}",
"@l_passkey" : {
"placeholders": {
"label": {}
}
},
"s_credentials": "Credentials",
"s_passkeys": "Passkeys",
"l_ready_to_use": "Ready to use",
"l_register_sk_on_websites": "Register as a Security Key on websites",
"l_no_discoverable_accounts": "No discoverable accounts",
"s_delete_credential": "Delete credential",
"s_credential_deleted": "Credential deleted",
"p_warning_delete_credential": "This will delete the credential from your YubiKey.",
"l_no_discoverable_accounts": "No Passkeys stored",
"s_delete_passkey": "Delete Passkey",
"l_delete_passkey_desc": "Remove the Passkey from the YubiKey",
"s_passkey_deleted": "Passkey deleted",
"p_warning_delete_passkey": "This will delete the Passkey from your YubiKey.",
"@_fingerprints": {},
"l_fingerprint": "Fingerprint: {label}",
@ -318,12 +373,14 @@
"l_fp_step_1_capture": "Step 1/2: Capture fingerprint",
"l_fp_step_2_name": "Step 2/2: Name fingerprint",
"s_delete_fingerprint": "Delete fingerprint",
"l_delete_fingerprint_desc": "Remove the fingerprint from the YubiKey",
"s_fingerprint_deleted": "Fingerprint deleted",
"p_warning_delete_fingerprint": "This will delete the fingerprint from your YubiKey.",
"s_no_fingerprints": "No fingerprints",
"l_set_pin_fingerprints": "Set a PIN to register fingerprints",
"l_no_fps_added": "No fingerprints have been added",
"s_rename_fp": "Rename fingerprint",
"l_rename_fp_desc": "Change the label",
"s_fingerprint_renamed": "Fingerprint renamed",
"l_rename_fp_failed": "Error renaming: {message}",
"@l_rename_fp_failed" : {
@ -341,6 +398,88 @@
"p_press_fingerprint_begin": "Press your finger against the YubiKey to begin.",
"p_will_change_label_fp": "This will change the label of the fingerprint.",
"@_certificates": {},
"s_certificate": "Certificate",
"s_certificates": "Certificates",
"s_csr": "CSR",
"s_subject": "Subject",
"l_export_csr_file": "Save CSR to file",
"l_select_import_file": "Select file to import",
"l_export_certificate": "Export certificate",
"l_export_certificate_file": "Export certificate to file",
"l_export_certificate_desc": "Export the certificate to a file",
"l_certificate_exported": "Certificate exported",
"l_import_file": "Import file",
"l_import_desc": "Import a key and/or certificate",
"l_delete_certificate": "Delete certificate",
"l_delete_certificate_desc": "Remove the certificate from your YubiKey",
"l_subject_issuer": "Subject: {subject}, Issuer: {issuer}",
"@l_subject_issuer" : {
"placeholders": {
"subject": {},
"issuer": {}
}
},
"l_serial": "Serial: {serial}",
"@l_serial" : {
"placeholders": {
"serial": {}
}
},
"l_certificate_fingerprint": "Fingerprint: {fingerprint}",
"@l_certificate_fingerprint" : {
"placeholders": {
"fingerprint": {}
}
},
"l_valid": "Valid: {not_before} - {not_after}",
"@l_valid" : {
"placeholders": {
"not_before": {},
"not_after": {}
}
},
"l_no_certificate": "No certificate loaded",
"l_key_no_certificate": "Key without certificate loaded",
"s_generate_key": "Generate key",
"l_generate_desc": "Generate a new certificate or CSR",
"p_generate_desc": "This will generate a new key on the YubiKey in PIV slot {slot}. The public key will be embedded into a self-signed certificate stored on the YubiKey, or in a certificate signing request (CSR) saved to file.",
"@p_generate_desc" : {
"placeholders": {
"slot": {}
}
},
"l_generating_private_key": "Generating private key\u2026",
"s_private_key_generated": "Private key generated",
"p_warning_delete_certificate": "Warning! This action will delete the certificate from your YubiKey.",
"q_delete_certificate_confirm": "Delete the certficate in PIV slot {slot}?",
"@q_delete_certificate_confirm" : {
"placeholders": {
"slot": {}
}
},
"l_certificate_deleted": "Certificate deleted",
"p_password_protected_file": "The selected file is password protected. Enter the password to proceed.",
"p_import_items_desc": "The following items will be imported into PIV slot {slot}.",
"@p_import_items_desc" : {
"placeholders": {
"slot": {}
}
},
"@_piv_slots": {},
"s_slot_display_name": "{name} ({hexid})",
"@s_slot_display_name" : {
"placeholders": {
"name": {},
"hexid": {}
}
},
"s_slot_9a": "Authentication",
"s_slot_9c": "Digital Signature",
"s_slot_9d": "Key Management",
"s_slot_9e": "Card Authentication",
"@_permissions": {},
"s_enable_nfc": "Enable NFC",
"s_permission_denied": "Permission denied",
@ -381,10 +520,14 @@
"message": {}
}
},
"s_reset_piv": "Reset PIV",
"l_piv_app_reset": "PIV application reset",
"p_warning_factory_reset": "Warning! This will irrevocably delete all OATH TOTP/HOTP accounts from your YubiKey.",
"p_warning_disable_credentials": "Your OATH credentials, as well as any password set, will be removed from this YubiKey. Make sure to first disable these from their respective web sites to avoid being locked out of your accounts.",
"p_warning_deletes_accounts": "Warning! This will irrevocably delete all U2F and FIDO2 accounts from your YubiKey.",
"p_warning_disable_accounts": "Your credentials, as well as any PIN set, will be removed from this YubiKey. Make sure to first disable these from their respective web sites to avoid being locked out of your accounts.",
"p_warning_piv_reset": "Warning! All data stored for PIV will be irrevocably deleted from your YubiKey.",
"p_warning_piv_reset_desc": "This includes private keys and certificates. Your PIN, PUK, and management key will be reset to their factory default values.",
"@_copy_to_clipboard": {},
"l_copy_to_clipboard": "Copy to clipboard",

View File

@ -20,9 +20,9 @@ import 'package:yubico_authenticator/management/models.dart';
import '../app/models.dart';
import '../core/state.dart';
final managementStateProvider = StateNotifierProvider.autoDispose
.family<ManagementStateNotifier, AsyncValue<DeviceInfo>, DevicePath>(
(ref, devicePath) => throw UnimplementedError(),
final managementStateProvider = AsyncNotifierProvider.autoDispose
.family<ManagementStateNotifier, DeviceInfo, DevicePath>(
() => throw UnimplementedError(),
);
abstract class ManagementStateNotifier

View File

@ -118,7 +118,6 @@ class _CapabilitiesForm extends StatelessWidget {
leading: const Icon(Icons.usb),
title: Text(l10n.s_usb),
contentPadding: const EdgeInsets.only(bottom: 8),
horizontalTitleGap: 0,
),
_CapabilityForm(
type: _CapabilityType.usb,
@ -139,7 +138,6 @@ class _CapabilitiesForm extends StatelessWidget {
leading: nfcIcon,
title: Text(l10n.s_nfc),
contentPadding: const EdgeInsets.only(bottom: 8),
horizontalTitleGap: 0,
),
_CapabilityForm(
type: _CapabilityType.nfc,

View File

@ -17,18 +17,28 @@
import 'package:flutter/material.dart';
const _prefix = 'oath.keys';
const setOrManagePasswordAction = Key('$_prefix.set_or_manage_password');
const addAccountAction = Key('$_prefix.add_account');
const resetAction = Key('$_prefix.reset');
const customIconsAction = Key('$_prefix.custom_icons');
const noAccountsView = Key('$_prefix.no_accounts');
const _keyAction = '$_prefix.actions';
const _accountAction = '$_prefix.account.actions';
// This is global so we can access it from the global Ctrl+F shortcut.
final searchAccountsField = GlobalKey();
// Key actions
const setOrManagePasswordAction =
Key('$_keyAction.action.set_or_manage_password');
const addAccountAction = Key('$_keyAction.add_account');
const resetAction = Key('$_keyAction.reset');
const customIconsAction = Key('$_keyAction.custom_icons');
// Credential actions
const copyAction = Key('$_accountAction.copy');
const calculateAction = Key('$_accountAction.calculate');
const togglePinAction = Key('$_accountAction.toggle_pin');
const editAction = Key('$_accountAction.edit');
const deleteAction = Key('$_accountAction.delete');
const noAccountsView = Key('$_prefix.no_accounts');
const passwordField = Key('$_prefix.password');
const currentPasswordField = Key('$_prefix.current_password');
const newPasswordField = Key('$_prefix.new_password');

View File

@ -37,9 +37,9 @@ class SearchNotifier extends StateNotifier<String> {
}
}
final oathStateProvider = StateNotifierProvider.autoDispose
.family<OathStateNotifier, AsyncValue<OathState>, DevicePath>(
(ref, devicePath) => throw UnimplementedError(),
final oathStateProvider = AsyncNotifierProvider.autoDispose
.family<OathStateNotifier, OathState, DevicePath>(
() => throw UnimplementedError(),
);
abstract class OathStateNotifier extends ApplicationStateNotifier<OathState> {

View File

@ -23,6 +23,8 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../../app/message.dart';
import '../../app/shortcuts.dart';
import '../../app/state.dart';
import '../../app/views/fs_dialog.dart';
import '../../app/views/action_list.dart';
import '../../core/models.dart';
import '../../core/state.dart';
import '../models.dart';
@ -37,60 +39,6 @@ class AccountDialog extends ConsumerWidget {
const AccountDialog(this.credential, {super.key});
List<Widget> _buildActions(BuildContext context, AccountHelper helper) {
final l10n = AppLocalizations.of(context)!;
final actions = helper.buildActions();
final theme =
ButtonTheme.of(context).colorScheme ?? Theme.of(context).colorScheme;
final copy =
actions.firstWhere(((e) => e.text == l10n.l_copy_to_clipboard));
final delete = actions.firstWhere(((e) => e.text == l10n.s_delete_account));
final colors = {
copy: (theme.primary, theme.onPrimary),
delete: (theme.error, theme.onError),
};
// If we can't copy, but can calculate, highlight that button instead
if (copy.intent == null) {
final calculates = actions.where(((e) => e.text == l10n.s_calculate));
if (calculates.isNotEmpty) {
colors[calculates.first] = (theme.primary, theme.onPrimary);
}
}
return actions.map((e) {
final intent = e.intent;
final (firstColor, secondColor) =
colors[e] ?? (theme.secondary, theme.onSecondary);
final tooltip = e.trailing != null ? '${e.text}\n${e.trailing}' : e.text;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 6.0),
child: CircleAvatar(
backgroundColor: intent != null ? firstColor : theme.secondary,
foregroundColor: secondColor,
child: IconButton(
style: IconButton.styleFrom(
backgroundColor: intent != null ? firstColor : theme.secondary,
foregroundColor: secondColor,
disabledBackgroundColor: theme.onSecondary.withOpacity(0.2),
fixedSize: const Size.square(38),
),
icon: e.icon,
iconSize: 22,
tooltip: tooltip,
onPressed: intent != null
? () {
Actions.invoke(context, intent);
}
: null,
),
),
);
}).toList();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
// TODO: Solve this in a cleaner way
@ -168,42 +116,11 @@ class AccountDialog extends ConsumerWidget {
}
return FocusScope(
autofocus: true,
child: AlertDialog(
title: Center(
child: Text(
helper.title,
style: Theme.of(context).textTheme.headlineSmall,
softWrap: true,
textAlign: TextAlign.center,
),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0),
content: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
child: FsDialog(
child: Column(
children: [
if (subtitle != null)
Text(
subtitle,
softWrap: true,
textAlign: TextAlign.center,
// This is what ListTile uses for subtitle
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
color: Theme.of(context).textTheme.bodySmall!.color,
),
),
const SizedBox(height: 12.0),
DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.rectangle,
color: Theme.of(context).colorScheme.surfaceVariant,
borderRadius: const BorderRadius.all(Radius.circular(30.0)),
),
child: Center(
child: FittedBox(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0, vertical: 8.0),
Padding(
padding: const EdgeInsets.only(top: 48, bottom: 16),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
@ -220,20 +137,30 @@ class AccountDialog extends ConsumerWidget {
],
),
),
Text(
helper.title,
style: Theme.of(context).textTheme.headlineSmall,
softWrap: true,
textAlign: TextAlign.center,
),
if (subtitle != null)
Text(
subtitle,
softWrap: true,
textAlign: TextAlign.center,
// This is what ListTile uses for subtitle
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
color: Theme.of(context).textTheme.bodySmall!.color,
),
),
const SizedBox(height: 32),
ActionListSection.fromMenuActions(
context,
AppLocalizations.of(context)!.s_actions,
actions: helper.buildActions(),
),
],
),
actionsPadding: const EdgeInsets.symmetric(vertical: 10.0),
actions: [
Center(
child: FittedBox(
alignment: Alignment.center,
child: Row(children: _buildActions(context, helper)),
),
)
],
),
);
},

View File

@ -29,6 +29,7 @@ import '../../widgets/circle_timer.dart';
import '../../widgets/custom_icons.dart';
import '../models.dart';
import '../state.dart';
import '../keys.dart' as keys;
import 'actions.dart';
/// Support class for presenting an OATH account.
@ -53,7 +54,7 @@ class AccountHelper {
String get title => credential.issuer ?? credential.name;
String? get subtitle => credential.issuer != null ? credential.name : null;
List<MenuAction> buildActions() => _ref
List<ActionItem> buildActions() => _ref
.watch(currentDeviceDataProvider)
.maybeWhen(
data: (data) {
@ -61,38 +62,51 @@ class AccountHelper {
credential.touchRequired || credential.oathType == OathType.hotp;
final ready = expired || credential.oathType == OathType.hotp;
final pinned = _ref.watch(favoritesProvider).contains(credential.id);
final l10n = AppLocalizations.of(_context)!;
final shortcut = Platform.isMacOS ? '\u2318 C' : 'Ctrl+C';
final canCopy = code != null && !expired;
return [
MenuAction(
text: l10n.l_copy_to_clipboard,
ActionItem(
key: keys.copyAction,
icon: const Icon(Icons.copy),
intent: code == null || expired ? null : const CopyIntent(),
trailing: shortcut,
title: l10n.l_copy_to_clipboard,
subtitle: l10n.l_copy_code_desc,
shortcut: Platform.isMacOS ? '\u2318 C' : 'Ctrl+C',
actionStyle: canCopy ? ActionStyle.primary : null,
intent: canCopy ? const CopyIntent() : null,
),
if (manual)
MenuAction(
text: l10n.s_calculate,
ActionItem(
key: keys.calculateAction,
actionStyle: !canCopy ? ActionStyle.primary : null,
icon: const Icon(Icons.refresh),
title: l10n.s_calculate,
subtitle: l10n.l_calculate_code_desc,
intent: ready ? const CalculateIntent() : null,
),
MenuAction(
text: pinned ? l10n.s_unpin_account : l10n.s_pin_account,
ActionItem(
key: keys.togglePinAction,
icon: pinned
? pushPinStrokeIcon
: const Icon(Icons.push_pin_outlined),
title: pinned ? l10n.s_unpin_account : l10n.s_pin_account,
subtitle: l10n.l_pin_account_desc,
intent: const TogglePinIntent(),
),
if (data.info.version.isAtLeast(5, 3))
MenuAction(
ActionItem(
key: keys.editAction,
icon: const Icon(Icons.edit_outlined),
text: l10n.s_rename_account,
title: l10n.s_rename_account,
subtitle: l10n.l_rename_account_desc,
intent: const EditIntent(),
),
MenuAction(
text: l10n.s_delete_account,
ActionItem(
key: keys.deleteAction,
actionStyle: ActionStyle.error,
icon: const Icon(Icons.delete_outline),
title: l10n.s_delete_account,
subtitle: l10n.l_delete_account_desc,
intent: const DeleteIntent(),
),
];

View File

@ -22,8 +22,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/shortcuts.dart';
import '../../app/state.dart';
import '../../core/state.dart';
import '../../widgets/menu_list_tile.dart';
import '../../app/views/app_list_item.dart';
import '../models.dart';
import '../state.dart';
import 'account_dialog.dart';
@ -48,15 +47,6 @@ String _a11yCredentialLabel(String? issuer, String name, String? code) {
class _AccountViewState extends ConsumerState<AccountView> {
OathCredential get credential => widget.credential;
final _focusNode = FocusNode();
int _lastTap = 0;
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
Color _iconColor(int shade) {
final colors = [
Colors.red[shade],
@ -87,23 +77,6 @@ class _AccountViewState extends ConsumerState<AccountView> {
return colors[label.hashCode % colors.length]!;
}
List<PopupMenuItem> _buildPopupMenu(
BuildContext context, AccountHelper helper) {
return helper.buildActions().map((e) {
final intent = e.intent;
return buildMenuItem(
leading: e.icon,
title: Text(e.text),
action: intent != null
? () {
Actions.invoke(context, intent);
}
: null,
trailing: e.trailing,
);
}).toList();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@ -165,83 +138,27 @@ class _AccountViewState extends ConsumerState<AccountView> {
child: Semantics(
label: _a11yCredentialLabel(
credential.issuer, credential.name, helper.code?.value),
child: InkWell(
focusNode: _focusNode,
borderRadius: BorderRadius.circular(30),
onSecondaryTapDown: (details) {
showMenu(
context: context,
position: RelativeRect.fromLTRB(
details.globalPosition.dx,
details.globalPosition.dy,
details.globalPosition.dx,
0,
),
items: _buildPopupMenu(context, helper),
);
},
onTap: () {
if (isDesktop) {
final now = DateTime.now().millisecondsSinceEpoch;
if (now - _lastTap < 500) {
setState(() {
_lastTap = 0;
});
Actions.maybeInvoke(context, const CopyIntent());
} else {
_focusNode.requestFocus();
setState(() {
_lastTap = now;
});
}
} else {
Actions.maybeInvoke<OpenIntent>(
context, const OpenIntent());
}
},
onLongPress: () {
Actions.maybeInvoke(context, const CopyIntent());
},
child: ListTile(
child: AppListItem(
leading: showAvatar
? AccountIcon(
issuer: credential.issuer,
defaultWidget: circleAvatar)
: null,
title: Text(
helper.title,
overflow: TextOverflow.fade,
maxLines: 1,
softWrap: false,
),
subtitle: subtitle != null
? Text(
subtitle,
overflow: TextOverflow.fade,
maxLines: 1,
softWrap: false,
)
: null,
trailing: Focus(
skipTraversal: true,
descendantsAreTraversable: false,
child: helper.code != null
title: helper.title,
subtitle: subtitle,
trailing: helper.code != null
? FilledButton.tonalIcon(
icon: helper.buildCodeIcon(),
label: helper.buildCodeLabel(),
onPressed: () {
Actions.maybeInvoke<OpenIntent>(
context, const OpenIntent());
},
onPressed:
Actions.handler(context, const OpenIntent()),
)
: FilledButton.tonal(
onPressed: () {
Actions.maybeInvoke<OpenIntent>(
context, const OpenIntent());
},
onPressed:
Actions.handler(context, const OpenIntent()),
child: helper.buildCodeIcon()),
),
),
activationIntent: const CopyIntent(),
buildPopupActions: (_) => helper.buildActions(),
),
));
});

View File

@ -1,3 +1,19 @@
/*
* 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.
*/
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

View File

@ -22,9 +22,10 @@ import 'package:yubico_authenticator/oath/icon_provider/icon_pack_dialog.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../app/views/fs_dialog.dart';
import '../../app/views/action_list.dart';
import '../../core/state.dart';
import '../../exception/cancellation_exception.dart';
import '../../widgets/list_title.dart';
import '../models.dart';
import '../state.dart';
import '../keys.dart' as keys;
@ -41,21 +42,23 @@ Widget oathBuildActions(
}) {
final l10n = AppLocalizations.of(context)!;
final capacity = oathState.version.isAtLeast(4) ? 32 : null;
final theme = Theme.of(context).colorScheme;
return SimpleDialog(
return FsDialog(
child: Column(
children: [
ListTitle(l10n.s_setup, textStyle: Theme.of(context).textTheme.bodyLarge),
ListTile(
title: Text(l10n.s_add_account),
ActionListSection(l10n.s_setup, children: [
ActionListItem(
key: keys.addAccountAction,
leading:
const CircleAvatar(child: Icon(Icons.person_add_alt_1_outlined)),
subtitle: Text(used == null
actionStyle: ActionStyle.primary,
icon: const Icon(Icons.person_add_alt_1_outlined),
title: l10n.s_add_account,
subtitle: used == null
? l10n.l_unlock_first
: (capacity != null ? l10n.l_accounts_used(used, capacity) : '')),
enabled: used != null && (capacity == null || capacity > used),
: (capacity != null
? l10n.l_accounts_used(used, capacity)
: ''),
onTap: used != null && (capacity == null || capacity > used)
? () async {
? (context) async {
final credentials = ref.read(credentialsProvider);
final withContext = ref.read(withContextProvider);
Navigator.of(context).pop();
@ -88,16 +91,14 @@ Widget oathBuildActions(
}
: null,
),
ListTitle(l10n.s_manage,
textStyle: Theme.of(context).textTheme.bodyLarge),
ListTile(
]),
ActionListSection(l10n.s_manage, children: [
ActionListItem(
key: keys.customIconsAction,
title: Text(l10n.s_custom_icons),
subtitle: Text(l10n.l_set_icons_for_accounts),
leading: const CircleAvatar(
child: Icon(Icons.image_outlined),
),
onTap: () async {
title: l10n.s_custom_icons,
subtitle: l10n.l_set_icons_for_accounts,
icon: const Icon(Icons.image_outlined),
onTap: (context) async {
Navigator.of(context).pop();
await ref.read(withContextProvider)((context) => showBlurDialog(
context: context,
@ -106,35 +107,36 @@ Widget oathBuildActions(
builder: (context) => const IconPackDialog(),
));
}),
ListTile(
ActionListItem(
key: keys.setOrManagePasswordAction,
title: Text(
oathState.hasKey ? l10n.s_manage_password : l10n.s_set_password),
subtitle: Text(l10n.l_optional_password_protection),
leading: const CircleAvatar(child: Icon(Icons.password_outlined)),
onTap: () {
title: oathState.hasKey
? l10n.s_manage_password
: l10n.s_set_password,
subtitle: l10n.l_optional_password_protection,
icon: const Icon(Icons.password_outlined),
onTap: (context) {
Navigator.of(context).pop();
showBlurDialog(
context: context,
builder: (context) => ManagePasswordDialog(devicePath, oathState),
builder: (context) =>
ManagePasswordDialog(devicePath, oathState),
);
}),
ListTile(
ActionListItem(
key: keys.resetAction,
title: Text(l10n.s_reset_oath),
subtitle: Text(l10n.l_factory_reset_this_app),
leading: CircleAvatar(
foregroundColor: theme.onError,
backgroundColor: theme.error,
child: const Icon(Icons.delete_outline),
),
onTap: () {
icon: const Icon(Icons.delete_outline),
actionStyle: ActionStyle.error,
title: l10n.s_reset_oath,
subtitle: l10n.l_factory_reset_this_app,
onTap: (context) {
Navigator.of(context).pop();
showBlurDialog(
context: context,
builder: (context) => ResetDialog(devicePath),
);
}),
]),
],
),
);
}

44
lib/piv/keys.dart Normal file
View File

@ -0,0 +1,44 @@
/*
* Copyright (C) 2022-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.
*/
import 'package:flutter/material.dart';
const _prefix = 'piv.keys';
const _keyAction = '$_prefix.actions';
const _slotAction = '$_prefix.slot.actions';
// Key actions
const managePinAction = Key('$_keyAction.manage_pin');
const managePukAction = Key('$_keyAction.manage_puk');
const manageManagementKeyAction = Key('$_keyAction.manage_management_key');
const resetAction = Key('$_keyAction.reset');
const setupMacOsAction = Key('$_keyAction.setup_macos');
// Slot actions
const generateAction = Key('$_slotAction.generate');
const importAction = Key('$_slotAction.import');
const exportAction = Key('$_slotAction.export');
const deleteAction = Key('$_slotAction.delete');
const saveButton = Key('$_prefix.save');
const deleteButton = Key('$_prefix.delete');
const unlockButton = Key('$_prefix.unlock');
const managementKeyField = Key('$_prefix.management_key');
const pinPukField = Key('$_prefix.pin_puk');
const newPinPukField = Key('$_prefix.new_pin_puk');
const confirmPinPukField = Key('$_prefix.confirm_pin_puk');
const subjectField = Key('$_prefix.subject');

312
lib/piv/models.dart Normal file
View File

@ -0,0 +1,312 @@
/*
* 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.
*/
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../core/models.dart';
part 'models.freezed.dart';
part 'models.g.dart';
const defaultManagementKey = '010203040506070801020304050607080102030405060708';
const defaultManagementKeyType = ManagementKeyType.tdes;
const defaultKeyType = KeyType.rsa2048;
const defaultGenerateType = GenerateType.certificate;
enum GenerateType {
certificate,
csr;
String getDisplayName(AppLocalizations l10n) {
return switch (this) {
GenerateType.certificate => l10n.s_certificate,
GenerateType.csr => l10n.s_csr,
};
}
}
enum SlotId {
authentication(0x9a),
signature(0x9c),
keyManagement(0x9d),
cardAuth(0x9e);
final int id;
const SlotId(this.id);
String get hexId => id.toRadixString(16).padLeft(2, '0');
String getDisplayName(AppLocalizations l10n) {
String nameFor(String name) => l10n.s_slot_display_name(name, hexId);
return switch (this) {
SlotId.authentication => nameFor(l10n.s_slot_9a),
SlotId.signature => nameFor(l10n.s_slot_9c),
SlotId.keyManagement => nameFor(l10n.s_slot_9d),
SlotId.cardAuth => nameFor(l10n.s_slot_9e),
};
}
factory SlotId.fromJson(int value) =>
SlotId.values.firstWhere((e) => e.id == value);
}
@JsonEnum(alwaysCreate: true)
enum PinPolicy {
@JsonValue(0x00)
dfault,
@JsonValue(0x01)
never,
@JsonValue(0x02)
once,
@JsonValue(0x03)
always;
const PinPolicy();
int get value => _$PinPolicyEnumMap[this]!;
String getDisplayName(AppLocalizations l10n) {
return switch (this) {
// TODO:
_ => name
};
}
}
@JsonEnum(alwaysCreate: true)
enum TouchPolicy {
@JsonValue(0x00)
dfault,
@JsonValue(0x01)
never,
@JsonValue(0x02)
always,
@JsonValue(0x03)
cached;
const TouchPolicy();
int get value => _$TouchPolicyEnumMap[this]!;
String getDisplayName(AppLocalizations l10n) {
return switch (this) {
// TODO:
_ => name
};
}
}
@JsonEnum(alwaysCreate: true)
enum KeyType {
@JsonValue(0x06)
rsa1024,
@JsonValue(0x07)
rsa2048,
@JsonValue(0x11)
eccp256,
@JsonValue(0x14)
eccp384;
const KeyType();
int get value => _$KeyTypeEnumMap[this]!;
String getDisplayName(AppLocalizations l10n) {
return switch (this) {
// TODO: Should these be translatable?
_ => name.toUpperCase()
};
}
}
enum ManagementKeyType {
@JsonValue(0x03)
tdes(24),
@JsonValue(0x08)
aes128(16),
@JsonValue(0x0A)
aes192(24),
@JsonValue(0x0C)
aes256(32);
const ManagementKeyType(this.keyLength);
final int keyLength;
int get value => _$ManagementKeyTypeEnumMap[this]!;
String getDisplayName(AppLocalizations l10n) {
return switch (this) {
// TODO: Should these be translatable?
_ => name.toUpperCase()
};
}
}
@freezed
class PinMetadata with _$PinMetadata {
factory PinMetadata(
bool defaultValue,
int totalAttempts,
int attemptsRemaining,
) = _PinMetadata;
factory PinMetadata.fromJson(Map<String, dynamic> json) =>
_$PinMetadataFromJson(json);
}
@freezed
class PinVerificationStatus with _$PinVerificationStatus {
const factory PinVerificationStatus.success() = _PinSuccess;
factory PinVerificationStatus.failure(int attemptsRemaining) = _PinFailure;
}
@freezed
class ManagementKeyMetadata with _$ManagementKeyMetadata {
factory ManagementKeyMetadata(
ManagementKeyType keyType,
bool defaultValue,
TouchPolicy touchPolicy,
) = _ManagementKeyMetadata;
factory ManagementKeyMetadata.fromJson(Map<String, dynamic> json) =>
_$ManagementKeyMetadataFromJson(json);
}
@freezed
class SlotMetadata with _$SlotMetadata {
factory SlotMetadata(
KeyType keyType,
PinPolicy pinPolicy,
TouchPolicy touchPolicy,
bool generated,
String publicKeyEncoded,
) = _SlotMetadata;
factory SlotMetadata.fromJson(Map<String, dynamic> json) =>
_$SlotMetadataFromJson(json);
}
@freezed
class PivStateMetadata with _$PivStateMetadata {
factory PivStateMetadata({
required ManagementKeyMetadata managementKeyMetadata,
required PinMetadata pinMetadata,
required PinMetadata pukMetadata,
}) = _PivStateMetadata;
factory PivStateMetadata.fromJson(Map<String, dynamic> json) =>
_$PivStateMetadataFromJson(json);
}
@freezed
class PivState with _$PivState {
const PivState._();
factory PivState({
required Version version,
required bool authenticated,
required bool derivedKey,
required bool storedKey,
required int pinAttempts,
String? chuid,
String? ccc,
PivStateMetadata? metadata,
}) = _PivState;
bool get protectedKey => derivedKey || storedKey;
bool get needsAuth =>
!authenticated && metadata?.managementKeyMetadata.defaultValue != true;
factory PivState.fromJson(Map<String, dynamic> json) =>
_$PivStateFromJson(json);
}
@freezed
class CertInfo with _$CertInfo {
factory CertInfo({
required String subject,
required String issuer,
required String serial,
required String notValidBefore,
required String notValidAfter,
required String fingerprint,
}) = _CertInfo;
factory CertInfo.fromJson(Map<String, dynamic> json) =>
_$CertInfoFromJson(json);
}
@freezed
class PivSlot with _$PivSlot {
factory PivSlot({
required SlotId slot,
bool? hasKey,
CertInfo? certInfo,
}) = _PivSlot;
factory PivSlot.fromJson(Map<String, dynamic> json) =>
_$PivSlotFromJson(json);
}
@freezed
class PivExamineResult with _$PivExamineResult {
factory PivExamineResult.result({
required bool password,
required bool privateKey,
required int certificates,
}) = _ExamineResult;
factory PivExamineResult.invalidPassword() = _InvalidPassword;
factory PivExamineResult.fromJson(Map<String, dynamic> json) =>
_$PivExamineResultFromJson(json);
}
@freezed
class PivGenerateParameters with _$PivGenerateParameters {
factory PivGenerateParameters.certificate({
required String subject,
required DateTime validFrom,
required DateTime validTo,
}) = _GenerateCertificate;
factory PivGenerateParameters.csr({
required String subject,
}) = _GenerateCsr;
}
@freezed
class PivGenerateResult with _$PivGenerateResult {
factory PivGenerateResult({
required GenerateType generateType,
required String publicKey,
required String result,
}) = _PivGenerateResult;
factory PivGenerateResult.fromJson(Map<String, dynamic> json) =>
_$PivGenerateResultFromJson(json);
}
@freezed
class PivImportResult with _$PivImportResult {
factory PivImportResult({
required SlotMetadata? metadata,
required String? publicKey,
required String? certificate,
}) = _PivImportResult;
factory PivImportResult.fromJson(Map<String, dynamic> json) =>
_$PivImportResultFromJson(json);
}

2984
lib/piv/models.freezed.dart Normal file

File diff suppressed because it is too large Load Diff

228
lib/piv/models.g.dart Normal file
View File

@ -0,0 +1,228 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'models.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$_PinMetadata _$$_PinMetadataFromJson(Map<String, dynamic> json) =>
_$_PinMetadata(
json['default_value'] as bool,
json['total_attempts'] as int,
json['attempts_remaining'] as int,
);
Map<String, dynamic> _$$_PinMetadataToJson(_$_PinMetadata instance) =>
<String, dynamic>{
'default_value': instance.defaultValue,
'total_attempts': instance.totalAttempts,
'attempts_remaining': instance.attemptsRemaining,
};
_$_ManagementKeyMetadata _$$_ManagementKeyMetadataFromJson(
Map<String, dynamic> json) =>
_$_ManagementKeyMetadata(
$enumDecode(_$ManagementKeyTypeEnumMap, json['key_type']),
json['default_value'] as bool,
$enumDecode(_$TouchPolicyEnumMap, json['touch_policy']),
);
Map<String, dynamic> _$$_ManagementKeyMetadataToJson(
_$_ManagementKeyMetadata instance) =>
<String, dynamic>{
'key_type': _$ManagementKeyTypeEnumMap[instance.keyType]!,
'default_value': instance.defaultValue,
'touch_policy': _$TouchPolicyEnumMap[instance.touchPolicy]!,
};
const _$ManagementKeyTypeEnumMap = {
ManagementKeyType.tdes: 3,
ManagementKeyType.aes128: 8,
ManagementKeyType.aes192: 10,
ManagementKeyType.aes256: 12,
};
const _$TouchPolicyEnumMap = {
TouchPolicy.dfault: 0,
TouchPolicy.never: 1,
TouchPolicy.always: 2,
TouchPolicy.cached: 3,
};
_$_SlotMetadata _$$_SlotMetadataFromJson(Map<String, dynamic> json) =>
_$_SlotMetadata(
$enumDecode(_$KeyTypeEnumMap, json['key_type']),
$enumDecode(_$PinPolicyEnumMap, json['pin_policy']),
$enumDecode(_$TouchPolicyEnumMap, json['touch_policy']),
json['generated'] as bool,
json['public_key_encoded'] as String,
);
Map<String, dynamic> _$$_SlotMetadataToJson(_$_SlotMetadata instance) =>
<String, dynamic>{
'key_type': _$KeyTypeEnumMap[instance.keyType]!,
'pin_policy': _$PinPolicyEnumMap[instance.pinPolicy]!,
'touch_policy': _$TouchPolicyEnumMap[instance.touchPolicy]!,
'generated': instance.generated,
'public_key_encoded': instance.publicKeyEncoded,
};
const _$KeyTypeEnumMap = {
KeyType.rsa1024: 6,
KeyType.rsa2048: 7,
KeyType.eccp256: 17,
KeyType.eccp384: 20,
};
const _$PinPolicyEnumMap = {
PinPolicy.dfault: 0,
PinPolicy.never: 1,
PinPolicy.once: 2,
PinPolicy.always: 3,
};
_$_PivStateMetadata _$$_PivStateMetadataFromJson(Map<String, dynamic> json) =>
_$_PivStateMetadata(
managementKeyMetadata: ManagementKeyMetadata.fromJson(
json['management_key_metadata'] as Map<String, dynamic>),
pinMetadata:
PinMetadata.fromJson(json['pin_metadata'] as Map<String, dynamic>),
pukMetadata:
PinMetadata.fromJson(json['puk_metadata'] as Map<String, dynamic>),
);
Map<String, dynamic> _$$_PivStateMetadataToJson(_$_PivStateMetadata instance) =>
<String, dynamic>{
'management_key_metadata': instance.managementKeyMetadata,
'pin_metadata': instance.pinMetadata,
'puk_metadata': instance.pukMetadata,
};
_$_PivState _$$_PivStateFromJson(Map<String, dynamic> json) => _$_PivState(
version: Version.fromJson(json['version'] as List<dynamic>),
authenticated: json['authenticated'] as bool,
derivedKey: json['derived_key'] as bool,
storedKey: json['stored_key'] as bool,
pinAttempts: json['pin_attempts'] as int,
chuid: json['chuid'] as String?,
ccc: json['ccc'] as String?,
metadata: json['metadata'] == null
? null
: PivStateMetadata.fromJson(json['metadata'] as Map<String, dynamic>),
);
Map<String, dynamic> _$$_PivStateToJson(_$_PivState instance) =>
<String, dynamic>{
'version': instance.version,
'authenticated': instance.authenticated,
'derived_key': instance.derivedKey,
'stored_key': instance.storedKey,
'pin_attempts': instance.pinAttempts,
'chuid': instance.chuid,
'ccc': instance.ccc,
'metadata': instance.metadata,
};
_$_CertInfo _$$_CertInfoFromJson(Map<String, dynamic> json) => _$_CertInfo(
subject: json['subject'] as String,
issuer: json['issuer'] as String,
serial: json['serial'] as String,
notValidBefore: json['not_valid_before'] as String,
notValidAfter: json['not_valid_after'] as String,
fingerprint: json['fingerprint'] as String,
);
Map<String, dynamic> _$$_CertInfoToJson(_$_CertInfo instance) =>
<String, dynamic>{
'subject': instance.subject,
'issuer': instance.issuer,
'serial': instance.serial,
'not_valid_before': instance.notValidBefore,
'not_valid_after': instance.notValidAfter,
'fingerprint': instance.fingerprint,
};
_$_PivSlot _$$_PivSlotFromJson(Map<String, dynamic> json) => _$_PivSlot(
slot: SlotId.fromJson(json['slot'] as int),
hasKey: json['has_key'] as bool?,
certInfo: json['cert_info'] == null
? null
: CertInfo.fromJson(json['cert_info'] as Map<String, dynamic>),
);
Map<String, dynamic> _$$_PivSlotToJson(_$_PivSlot instance) =>
<String, dynamic>{
'slot': _$SlotIdEnumMap[instance.slot]!,
'has_key': instance.hasKey,
'cert_info': instance.certInfo,
};
const _$SlotIdEnumMap = {
SlotId.authentication: 'authentication',
SlotId.signature: 'signature',
SlotId.keyManagement: 'keyManagement',
SlotId.cardAuth: 'cardAuth',
};
_$_ExamineResult _$$_ExamineResultFromJson(Map<String, dynamic> json) =>
_$_ExamineResult(
password: json['password'] as bool,
privateKey: json['private_key'] as bool,
certificates: json['certificates'] as int,
$type: json['runtimeType'] as String?,
);
Map<String, dynamic> _$$_ExamineResultToJson(_$_ExamineResult instance) =>
<String, dynamic>{
'password': instance.password,
'private_key': instance.privateKey,
'certificates': instance.certificates,
'runtimeType': instance.$type,
};
_$_InvalidPassword _$$_InvalidPasswordFromJson(Map<String, dynamic> json) =>
_$_InvalidPassword(
$type: json['runtimeType'] as String?,
);
Map<String, dynamic> _$$_InvalidPasswordToJson(_$_InvalidPassword instance) =>
<String, dynamic>{
'runtimeType': instance.$type,
};
_$_PivGenerateResult _$$_PivGenerateResultFromJson(Map<String, dynamic> json) =>
_$_PivGenerateResult(
generateType: $enumDecode(_$GenerateTypeEnumMap, json['generate_type']),
publicKey: json['public_key'] as String,
result: json['result'] as String,
);
Map<String, dynamic> _$$_PivGenerateResultToJson(
_$_PivGenerateResult instance) =>
<String, dynamic>{
'generate_type': _$GenerateTypeEnumMap[instance.generateType]!,
'public_key': instance.publicKey,
'result': instance.result,
};
const _$GenerateTypeEnumMap = {
GenerateType.certificate: 'certificate',
GenerateType.csr: 'csr',
};
_$_PivImportResult _$$_PivImportResultFromJson(Map<String, dynamic> json) =>
_$_PivImportResult(
metadata: json['metadata'] == null
? null
: SlotMetadata.fromJson(json['metadata'] as Map<String, dynamic>),
publicKey: json['public_key'] as String?,
certificate: json['certificate'] as String?,
);
Map<String, dynamic> _$$_PivImportResultToJson(_$_PivImportResult instance) =>
<String, dynamic>{
'metadata': instance.metadata,
'public_key': instance.publicKey,
'certificate': instance.certificate,
};

70
lib/piv/state.dart Normal file
View File

@ -0,0 +1,70 @@
/*
* 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.
*/
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../app/models.dart';
import '../core/state.dart';
import 'models.dart';
final pivStateProvider = AsyncNotifierProvider.autoDispose
.family<PivStateNotifier, PivState, DevicePath>(
() => throw UnimplementedError(),
);
abstract class PivStateNotifier extends ApplicationStateNotifier<PivState> {
Future<void> reset();
Future<bool> authenticate(String managementKey);
Future<void> setManagementKey(
String managementKey, {
ManagementKeyType managementKeyType = defaultManagementKeyType,
bool storeKey = false,
});
Future<PinVerificationStatus> verifyPin(
String pin); //TODO: Maybe return authenticated?
Future<PinVerificationStatus> changePin(String pin, String newPin);
Future<PinVerificationStatus> changePuk(String puk, String newPuk);
Future<PinVerificationStatus> unblockPin(String puk, String newPin);
}
final pivSlotsProvider = AsyncNotifierProvider.autoDispose
.family<PivSlotsNotifier, List<PivSlot>, DevicePath>(
() => throw UnimplementedError(),
);
abstract class PivSlotsNotifier
extends AutoDisposeFamilyAsyncNotifier<List<PivSlot>, DevicePath> {
Future<PivExamineResult> examine(String data, {String? password});
Future<(SlotMetadata?, String?)> read(SlotId slot);
Future<PivGenerateResult> generate(
SlotId slot,
KeyType keyType, {
required PivGenerateParameters parameters,
PinPolicy pinPolicy = PinPolicy.dfault,
TouchPolicy touchPolicy = TouchPolicy.dfault,
String? pin,
});
Future<PivImportResult> import(
SlotId slot,
String data, {
String? password,
PinPolicy pinPolicy = PinPolicy.dfault,
TouchPolicy touchPolicy = TouchPolicy.dfault,
});
Future<void> delete(SlotId slot);
}

256
lib/piv/views/actions.dart Normal file
View File

@ -0,0 +1,256 @@
/*
* 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.
*/
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../../app/message.dart';
import '../../app/shortcuts.dart';
import '../../app/state.dart';
import '../../app/models.dart';
import '../models.dart';
import '../state.dart';
import '../keys.dart' as keys;
import 'authentication_dialog.dart';
import 'delete_certificate_dialog.dart';
import 'generate_key_dialog.dart';
import 'import_file_dialog.dart';
import 'pin_dialog.dart';
class GenerateIntent extends Intent {
const GenerateIntent();
}
class ImportIntent extends Intent {
const ImportIntent();
}
class ExportIntent extends Intent {
const ExportIntent();
}
Future<bool> _authenticate(
WidgetRef ref, DevicePath devicePath, PivState pivState) async {
final withContext = ref.read(withContextProvider);
return await withContext((context) async =>
await showBlurDialog(
context: context,
builder: (context) => AuthenticationDialog(
devicePath,
pivState,
),
) ??
false);
}
Future<bool> _authIfNeeded(
WidgetRef ref, DevicePath devicePath, PivState pivState) async {
if (pivState.needsAuth) {
return await _authenticate(ref, devicePath, pivState);
}
return true;
}
Widget registerPivActions(
DevicePath devicePath,
PivState pivState,
PivSlot pivSlot, {
required WidgetRef ref,
required Widget Function(BuildContext context) builder,
Map<Type, Action<Intent>> actions = const {},
}) =>
Actions(
actions: {
GenerateIntent:
CallbackAction<GenerateIntent>(onInvoke: (intent) async {
if (!pivState.protectedKey &&
!await _authIfNeeded(ref, devicePath, pivState)) {
return false;
}
final withContext = ref.read(withContextProvider);
// TODO: Avoid asking for PIN if not needed?
final verified = await withContext((context) async =>
await showBlurDialog(
context: context,
builder: (context) => PinDialog(devicePath))) ??
false;
if (!verified) {
return false;
}
return await withContext((context) async {
final l10n = AppLocalizations.of(context)!;
final PivGenerateResult? result = await showBlurDialog(
context: context,
builder: (context) => GenerateKeyDialog(
devicePath,
pivState,
pivSlot,
),
);
switch (result?.generateType) {
case GenerateType.csr:
final filePath = await FilePicker.platform.saveFile(
dialogTitle: l10n.l_export_csr_file,
allowedExtensions: ['csr'],
type: FileType.custom,
lockParentWindow: true,
);
if (filePath != null) {
final file = File(filePath);
await file.writeAsString(result!.result, flush: true);
}
break;
default:
break;
}
return result != null;
});
}),
ImportIntent: CallbackAction<ImportIntent>(onInvoke: (intent) async {
if (!await _authIfNeeded(ref, devicePath, pivState)) {
return false;
}
final withContext = ref.read(withContextProvider);
final picked = await withContext(
(context) async {
final l10n = AppLocalizations.of(context)!;
return await FilePicker.platform.pickFiles(
allowedExtensions: ['pem', 'der', 'pfx', 'p12', 'key', 'crt'],
type: FileType.custom,
allowMultiple: false,
lockParentWindow: true,
dialogTitle: l10n.l_select_import_file);
},
);
if (picked == null || picked.files.isEmpty) {
return false;
}
return await withContext((context) async =>
await showBlurDialog(
context: context,
builder: (context) => ImportFileDialog(
devicePath,
pivState,
pivSlot,
File(picked.paths.first!),
),
) ??
false);
}),
ExportIntent: CallbackAction<ExportIntent>(onInvoke: (intent) async {
final (_, cert) = await ref
.read(pivSlotsProvider(devicePath).notifier)
.read(pivSlot.slot);
if (cert == null) {
return false;
}
final withContext = ref.read(withContextProvider);
final filePath = await withContext((context) async {
final l10n = AppLocalizations.of(context)!;
return await FilePicker.platform.saveFile(
dialogTitle: l10n.l_export_certificate_file,
allowedExtensions: ['pem'],
type: FileType.custom,
lockParentWindow: true,
);
});
if (filePath == null) {
return false;
}
final file = File(filePath);
await file.writeAsString(cert, flush: true);
await withContext((context) async {
final l10n = AppLocalizations.of(context)!;
showMessage(context, l10n.l_certificate_exported);
});
return true;
}),
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async {
if (!await _authIfNeeded(ref, devicePath, pivState)) {
return false;
}
final withContext = ref.read(withContextProvider);
final bool? deleted = await withContext((context) async =>
await showBlurDialog(
context: context,
builder: (context) => DeleteCertificateDialog(
devicePath,
pivSlot,
),
) ??
false);
return deleted;
}),
...actions,
},
child: Builder(builder: builder),
);
List<ActionItem> buildSlotActions(bool hasCert, AppLocalizations l10n) {
return [
ActionItem(
key: keys.generateAction,
icon: const Icon(Icons.add_outlined),
actionStyle: ActionStyle.primary,
title: l10n.s_generate_key,
subtitle: l10n.l_generate_desc,
intent: const GenerateIntent(),
),
ActionItem(
key: keys.importAction,
icon: const Icon(Icons.file_download_outlined),
title: l10n.l_import_file,
subtitle: l10n.l_import_desc,
intent: const ImportIntent(),
),
if (hasCert) ...[
ActionItem(
key: keys.exportAction,
icon: const Icon(Icons.file_upload_outlined),
title: l10n.l_export_certificate,
subtitle: l10n.l_export_certificate_desc,
intent: const ExportIntent(),
),
ActionItem(
key: keys.deleteAction,
actionStyle: ActionStyle.error,
icon: const Icon(Icons.delete_outline),
title: l10n.l_delete_certificate,
subtitle: l10n.l_delete_certificate_desc,
intent: const DeleteIntent(),
),
],
];
}

View File

@ -0,0 +1,122 @@
/*
* 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.
*/
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/models.dart';
import '../../exception/cancellation_exception.dart';
import '../../widgets/responsive_dialog.dart';
import '../models.dart';
import '../state.dart';
import '../keys.dart' as keys;
class AuthenticationDialog extends ConsumerStatefulWidget {
final DevicePath devicePath;
final PivState pivState;
const AuthenticationDialog(this.devicePath, this.pivState, {super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() =>
_AuthenticationDialogState();
}
class _AuthenticationDialogState extends ConsumerState<AuthenticationDialog> {
String _managementKey = '';
bool _keyIsWrong = false;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final keyLen = (widget.pivState.metadata?.managementKeyMetadata.keyType ??
ManagementKeyType.tdes)
.keyLength *
2;
return ResponsiveDialog(
title: Text(l10n.l_unlock_piv_management),
actions: [
TextButton(
key: keys.unlockButton,
onPressed: _managementKey.length == keyLen
? () async {
final navigator = Navigator.of(context);
try {
final status = await ref
.read(pivStateProvider(widget.devicePath).notifier)
.authenticate(_managementKey);
if (status) {
navigator.pop(true);
} else {
setState(() {
_keyIsWrong = true;
});
}
} on CancellationException catch (_) {
navigator.pop(false);
} catch (_) {
// TODO: More error cases
setState(() {
_keyIsWrong = true;
});
}
}
: null,
child: Text(l10n.s_unlock),
),
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.p_unlock_piv_management_desc),
TextField(
key: keys.managementKeyField,
autofocus: true,
maxLength: keyLen,
autofillHints: const [AutofillHints.password],
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp('[a-f0-9]', caseSensitive: false))
],
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_management_key,
prefixIcon: const Icon(Icons.key_outlined),
errorText: _keyIsWrong ? l10n.l_wrong_key : null,
errorMaxLines: 3,
),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
_keyIsWrong = false;
_managementKey = value;
});
},
),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
);
}
}

View File

@ -0,0 +1,79 @@
/*
* 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.
*/
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../exception/cancellation_exception.dart';
import '../../widgets/responsive_dialog.dart';
import '../models.dart';
import '../state.dart';
import '../keys.dart' as keys;
class DeleteCertificateDialog extends ConsumerWidget {
final DevicePath devicePath;
final PivSlot pivSlot;
const DeleteCertificateDialog(this.devicePath, this.pivSlot, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
return ResponsiveDialog(
title: Text(l10n.l_delete_certificate),
actions: [
TextButton(
key: keys.deleteButton,
onPressed: () async {
try {
await ref
.read(pivSlotsProvider(devicePath).notifier)
.delete(pivSlot.slot);
await ref.read(withContextProvider)(
(context) async {
Navigator.of(context).pop(true);
showMessage(context, l10n.l_certificate_deleted);
},
);
} on CancellationException catch (_) {
// ignored
}
},
child: Text(l10n.s_delete),
),
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.p_warning_delete_certificate),
Text(l10n.q_delete_certificate_confirm(
pivSlot.slot.getDisplayName(l10n))),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
);
}
}

View File

@ -0,0 +1,206 @@
/*
* 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.
*/
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../core/models.dart';
import '../../widgets/choice_filter_chip.dart';
import '../../widgets/responsive_dialog.dart';
import '../models.dart';
import '../state.dart';
import '../keys.dart' as keys;
class GenerateKeyDialog extends ConsumerStatefulWidget {
final DevicePath devicePath;
final PivState pivState;
final PivSlot pivSlot;
const GenerateKeyDialog(this.devicePath, this.pivState, this.pivSlot,
{super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() =>
_GenerateKeyDialogState();
}
class _GenerateKeyDialogState extends ConsumerState<GenerateKeyDialog> {
String _subject = '';
GenerateType _generateType = defaultGenerateType;
KeyType _keyType = defaultKeyType;
late DateTime _validFrom;
late DateTime _validTo;
late DateTime _validToDefault;
late DateTime _validToMax;
bool _generating = false;
@override
void initState() {
super.initState();
final now = DateTime.now();
_validFrom = DateTime.utc(now.year, now.month, now.day);
_validToDefault = DateTime.utc(now.year + 1, now.month, now.day);
_validTo = _validToDefault;
_validToMax = DateTime.utc(now.year + 10, now.month, now.day);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return ResponsiveDialog(
allowCancel: !_generating,
title: Text(l10n.s_generate_key),
actions: [
TextButton(
key: keys.saveButton,
onPressed: _generating || _subject.isEmpty
? null
: () async {
setState(() {
_generating = true;
});
Function()? close;
final PivGenerateResult result;
try {
close = showMessage(
context,
l10n.l_generating_private_key,
duration: const Duration(seconds: 30),
);
result = await ref
.read(pivSlotsProvider(widget.devicePath).notifier)
.generate(
widget.pivSlot.slot,
_keyType,
parameters: switch (_generateType) {
GenerateType.certificate =>
PivGenerateParameters.certificate(
subject: _subject,
validFrom: _validFrom,
validTo: _validTo),
GenerateType.csr =>
PivGenerateParameters.csr(subject: _subject),
},
);
} finally {
close?.call();
}
await ref.read(withContextProvider)(
(context) async {
Navigator.of(context).pop(result);
showMessage(
context,
l10n.s_private_key_generated,
);
},
);
},
child: Text(l10n.s_save),
),
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
autofocus: true,
key: keys.subjectField,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_subject,
),
textInputAction: TextInputAction.next,
enabled: !_generating,
onChanged: (value) {
setState(() {
if (value.isEmpty) {
_subject = '';
} else {
_subject = value.contains('=') ? value : 'CN=$value';
}
});
},
),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 4.0,
runSpacing: 8.0,
children: [
ChoiceFilterChip<GenerateType>(
items: GenerateType.values,
value: _generateType,
selected: _generateType != defaultGenerateType,
itemBuilder: (value) => Text(value.getDisplayName(l10n)),
onChanged: _generating
? null
: (value) {
setState(() {
_generateType = value;
});
},
),
ChoiceFilterChip<KeyType>(
items: KeyType.values,
value: _keyType,
selected: _keyType != defaultKeyType,
itemBuilder: (value) => Text(value.getDisplayName(l10n)),
onChanged: _generating
? null
: (value) {
setState(() {
_keyType = value;
});
},
),
if (_generateType == GenerateType.certificate)
FilterChip(
label: Text(dateFormatter.format(_validTo)),
onSelected: _generating
? null
: (value) async {
final selected = await showDatePicker(
context: context,
initialDate: _validTo,
firstDate: _validFrom,
lastDate: _validToMax,
);
if (selected != null) {
setState(() {
_validTo = selected;
});
}
},
),
]),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
);
}
}

View File

@ -0,0 +1,187 @@
/*
* 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.
*/
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/models.dart';
import '../../widgets/responsive_dialog.dart';
import '../models.dart';
import '../state.dart';
import '../keys.dart' as keys;
class ImportFileDialog extends ConsumerStatefulWidget {
final DevicePath devicePath;
final PivState pivState;
final PivSlot pivSlot;
final File file;
const ImportFileDialog(
this.devicePath, this.pivState, this.pivSlot, this.file,
{super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() =>
_ImportFileDialogState();
}
class _ImportFileDialogState extends ConsumerState<ImportFileDialog> {
late String _data;
PivExamineResult? _state;
String _password = '';
bool _passwordIsWrong = false;
@override
void initState() {
super.initState();
_init();
}
void _init() async {
final bytes = await widget.file.readAsBytes();
_data = bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join();
_examine();
}
void _examine() async {
setState(() {
_state = null;
});
final result = await ref
.read(pivSlotsProvider(widget.devicePath).notifier)
.examine(_data, password: _password.isNotEmpty ? _password : null);
setState(() {
_state = result;
_passwordIsWrong = result.maybeWhen(
invalidPassword: () => _password.isNotEmpty,
orElse: () => true,
);
});
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final state = _state;
if (state == null) {
return ResponsiveDialog(
title: Text(l10n.l_import_file),
actions: [
TextButton(
key: keys.unlockButton,
onPressed: null,
child: Text(l10n.s_unlock),
),
],
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 18.0),
child: Center(
child: CircularProgressIndicator(),
)),
);
}
return state.when(
invalidPassword: () => ResponsiveDialog(
title: Text(l10n.l_import_file),
actions: [
TextButton(
key: keys.unlockButton,
onPressed: () => _examine(),
child: Text(l10n.s_unlock),
),
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.p_password_protected_file),
TextField(
autofocus: true,
obscureText: true,
autofillHints: const [AutofillHints.password],
key: keys.managementKeyField,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_password,
prefixIcon: const Icon(Icons.password_outlined),
errorText: _passwordIsWrong ? l10n.s_wrong_password : null,
errorMaxLines: 3),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
_passwordIsWrong = false;
_password = value;
});
},
onSubmitted: (_) => _examine(),
),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
),
result: (_, privateKey, certificates) => ResponsiveDialog(
title: Text(l10n.l_import_file),
actions: [
TextButton(
key: keys.unlockButton,
onPressed: () async {
final navigator = Navigator.of(context);
try {
await ref
.read(pivSlotsProvider(widget.devicePath).notifier)
.import(widget.pivSlot.slot, _data,
password: _password.isNotEmpty ? _password : null);
navigator.pop(true);
} catch (_) {
// TODO: More error cases
setState(() {
_passwordIsWrong = true;
});
}
},
child: Text(l10n.s_import),
),
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.p_import_items_desc(
widget.pivSlot.slot.getDisplayName(l10n))),
if (privateKey) Text(l10n.l_bullet(l10n.s_private_key)),
if (certificates > 0) Text(l10n.l_bullet(l10n.s_certificate)),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
),
);
}
}

View File

@ -0,0 +1,137 @@
/*
* 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.
*/
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/views/fs_dialog.dart';
import '../../app/views/action_list.dart';
import '../models.dart';
import '../keys.dart' as keys;
import 'manage_key_dialog.dart';
import 'manage_pin_puk_dialog.dart';
import 'reset_dialog.dart';
Widget pivBuildActions(BuildContext context, DevicePath devicePath,
PivState pivState, WidgetRef ref) {
final colors = Theme.of(context).buttonTheme.colorScheme ??
Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
final usingDefaultMgmtKey =
pivState.metadata?.managementKeyMetadata.defaultValue == true;
final pinBlocked = pivState.pinAttempts == 0;
final pukAttempts = pivState.metadata?.pukMetadata.attemptsRemaining;
return FsDialog(
child: Column(
children: [
ActionListSection(
l10n.s_manage,
children: [
ActionListItem(
key: keys.managePinAction,
title: l10n.s_pin,
subtitle: pinBlocked
? l10n.l_piv_pin_blocked
: l10n.l_attempts_remaining(pivState.pinAttempts),
icon: const Icon(Icons.pin_outlined),
onTap: (context) {
Navigator.of(context).pop();
showBlurDialog(
context: context,
builder: (context) => ManagePinPukDialog(
devicePath,
target:
pinBlocked ? ManageTarget.unblock : ManageTarget.pin,
),
);
}),
ActionListItem(
key: keys.managePukAction,
title: l10n.s_puk,
subtitle: pukAttempts != null
? l10n.l_attempts_remaining(pukAttempts)
: null,
icon: const Icon(Icons.pin_outlined),
onTap: (context) {
Navigator.of(context).pop();
showBlurDialog(
context: context,
builder: (context) => ManagePinPukDialog(devicePath,
target: ManageTarget.puk),
);
}),
ActionListItem(
key: keys.manageManagementKeyAction,
title: l10n.s_management_key,
subtitle: usingDefaultMgmtKey
? l10n.l_warning_default_key
: (pivState.protectedKey
? l10n.l_pin_protected_key
: l10n.l_change_management_key),
icon: const Icon(Icons.key_outlined),
trailing: usingDefaultMgmtKey
? Icon(Icons.warning_amber, color: colors.tertiary)
: null,
onTap: (context) {
Navigator.of(context).pop();
showBlurDialog(
context: context,
builder: (context) => ManageKeyDialog(devicePath, pivState),
);
}),
ActionListItem(
key: keys.resetAction,
icon: const Icon(Icons.delete_outline),
actionStyle: ActionStyle.error,
title: l10n.s_reset_piv,
subtitle: l10n.l_factory_reset_this_app,
onTap: (context) {
Navigator.of(context).pop();
showBlurDialog(
context: context,
builder: (context) => ResetDialog(devicePath),
);
})
],
),
// TODO
/*
if (false == true) ...[
KeyActionTitle(l10n.s_setup),
KeyActionItem(
key: keys.setupMacOsAction,
title: Text('Setup for macOS'),
subtitle: Text('Create certificates for macOS login'),
leading: CircleAvatar(
backgroundColor: theme.secondary,
foregroundColor: theme.onSecondary,
child: const Icon(Icons.laptop),
),
onTap: () async {
Navigator.of(context).pop();
}),
],
*/
],
),
);
}

View File

@ -0,0 +1,278 @@
/*
* Copyright (C) 2022 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:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../widgets/choice_filter_chip.dart';
import '../../widgets/responsive_dialog.dart';
import '../models.dart';
import '../state.dart';
import '../keys.dart' as keys;
import 'pin_dialog.dart';
class ManageKeyDialog extends ConsumerStatefulWidget {
final DevicePath path;
final PivState pivState;
const ManageKeyDialog(this.path, this.pivState, {super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() =>
_ManageKeyDialogState();
}
class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
late bool _defaultKeyUsed;
late bool _usesStoredKey;
late bool _storeKey;
String _currentKeyOrPin = '';
bool _currentIsWrong = false;
int _attemptsRemaining = -1;
ManagementKeyType _keyType = ManagementKeyType.tdes;
final _keyController = TextEditingController();
@override
void initState() {
super.initState();
_defaultKeyUsed =
widget.pivState.metadata?.managementKeyMetadata.defaultValue ?? false;
_usesStoredKey = widget.pivState.protectedKey;
if (!_usesStoredKey && _defaultKeyUsed) {
_currentKeyOrPin = defaultManagementKey;
}
_storeKey = _usesStoredKey;
}
@override
void dispose() {
_keyController.dispose();
super.dispose();
}
_submit() async {
final notifier = ref.read(pivStateProvider(widget.path).notifier);
if (_usesStoredKey) {
final status = (await notifier.verifyPin(_currentKeyOrPin)).when(
success: () => true,
failure: (attemptsRemaining) {
setState(() {
_attemptsRemaining = attemptsRemaining;
_currentIsWrong = true;
});
return false;
},
);
if (!status) {
return;
}
} else {
if (!await notifier.authenticate(_currentKeyOrPin)) {
setState(() {
_currentIsWrong = true;
});
return;
}
}
if (_storeKey && !_usesStoredKey) {
final withContext = ref.read(withContextProvider);
final verified = await withContext((context) async =>
await showBlurDialog(
context: context,
builder: (context) => PinDialog(widget.path))) ??
false;
if (!verified) {
return;
}
}
await notifier.setManagementKey(_keyController.text,
managementKeyType: _keyType, storeKey: _storeKey);
if (!mounted) return;
final l10n = AppLocalizations.of(context)!;
showMessage(context, l10n.l_management_key_changed);
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final currentType =
widget.pivState.metadata?.managementKeyMetadata.keyType ??
ManagementKeyType.tdes;
final hexLength = _keyType.keyLength * 2;
final protected = widget.pivState.protectedKey;
final currentLenOk = protected
? _currentKeyOrPin.length >= 4
: _currentKeyOrPin.length == currentType.keyLength * 2;
final newLenOk = _keyController.text.length == hexLength;
return ResponsiveDialog(
title: Text(l10n.l_change_management_key),
actions: [
TextButton(
onPressed: currentLenOk && newLenOk ? _submit : null,
key: keys.saveButton,
child: Text(l10n.s_save),
)
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.p_change_management_key_desc),
if (protected)
TextField(
autofocus: true,
obscureText: true,
autofillHints: const [AutofillHints.password],
key: keys.pinPukField,
maxLength: 8,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_pin,
prefixIcon: const Icon(Icons.pin_outlined),
errorText: _currentIsWrong
? l10n
.l_wrong_pin_attempts_remaining(_attemptsRemaining)
: null,
errorMaxLines: 3),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
_currentIsWrong = false;
_currentKeyOrPin = value;
});
},
),
if (!protected)
TextFormField(
key: keys.managementKeyField,
autofocus: !_defaultKeyUsed,
autofillHints: const [AutofillHints.password],
initialValue: _defaultKeyUsed ? defaultManagementKey : null,
readOnly: _defaultKeyUsed,
maxLength: !_defaultKeyUsed ? currentType.keyLength * 2 : null,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_current_management_key,
prefixIcon: const Icon(Icons.password_outlined),
errorText: _currentIsWrong ? l10n.l_wrong_key : null,
errorMaxLines: 3,
helperText: _defaultKeyUsed ? l10n.l_default_key_used : null,
),
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.allow(
RegExp('[a-f0-9]', caseSensitive: false))
],
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
_currentIsWrong = false;
_currentKeyOrPin = value;
});
},
),
TextField(
key: keys.newPinPukField,
autofocus: _defaultKeyUsed,
autofillHints: const [AutofillHints.newPassword],
maxLength: hexLength,
controller: _keyController,
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.allow(
RegExp('[a-f0-9]', caseSensitive: false))
],
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_new_management_key,
prefixIcon: const Icon(Icons.password_outlined),
enabled: currentLenOk,
suffixIcon: IconButton(
icon: const Icon(Icons.refresh),
onPressed: currentLenOk
? () {
final random = Random.secure();
final key = List.generate(
_keyType.keyLength,
(_) => random
.nextInt(256)
.toRadixString(16)
.padLeft(2, '0')).join();
setState(() {
_keyController.text = key;
});
}
: null,
),
),
textInputAction: TextInputAction.next,
onSubmitted: (_) {
if (currentLenOk && newLenOk) {
_submit();
}
},
),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 4.0,
runSpacing: 8.0,
children: [
if (widget.pivState.metadata != null)
ChoiceFilterChip<ManagementKeyType>(
items: ManagementKeyType.values,
value: _keyType,
selected: _keyType != defaultManagementKeyType,
itemBuilder: (value) => Text(value.getDisplayName(l10n)),
onChanged: (value) {
setState(() {
_keyType = value;
});
},
),
FilterChip(
label: Text(l10n.s_protect_key),
selected: _storeKey,
onSelected: (value) {
setState(() {
_storeKey = value;
});
},
),
]),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
);
}
}

View File

@ -0,0 +1,191 @@
/*
* Copyright (C) 2022 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 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../widgets/responsive_dialog.dart';
import '../state.dart';
import '../keys.dart' as keys;
enum ManageTarget { pin, puk, unblock }
class ManagePinPukDialog extends ConsumerStatefulWidget {
final DevicePath path;
final ManageTarget target;
const ManagePinPukDialog(this.path,
{super.key, this.target = ManageTarget.pin});
@override
ConsumerState<ConsumerStatefulWidget> createState() =>
_ManagePinPukDialogState();
}
class _ManagePinPukDialogState extends ConsumerState<ManagePinPukDialog> {
String _currentPin = '';
String _newPin = '';
String _confirmPin = '';
bool _currentIsWrong = false;
int _attemptsRemaining = -1;
_submit() async {
final notifier = ref.read(pivStateProvider(widget.path).notifier);
final result = await switch (widget.target) {
ManageTarget.pin => notifier.changePin(_currentPin, _newPin),
ManageTarget.puk => notifier.changePuk(_currentPin, _newPin),
ManageTarget.unblock => notifier.unblockPin(_currentPin, _newPin),
};
result.when(success: () {
if (!mounted) return;
final l10n = AppLocalizations.of(context)!;
Navigator.of(context).pop();
showMessage(
context,
switch (widget.target) {
ManageTarget.puk => l10n.s_puk_set,
_ => l10n.s_pin_set,
});
}, failure: (attemptsRemaining) {
setState(() {
_attemptsRemaining = attemptsRemaining;
_currentIsWrong = true;
_currentPin = '';
});
});
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final isValid =
_newPin.isNotEmpty && _newPin == _confirmPin && _currentPin.isNotEmpty;
final titleText = switch (widget.target) {
ManageTarget.pin => l10n.s_change_pin,
ManageTarget.puk => l10n.s_change_puk,
ManageTarget.unblock => l10n.s_unblock_pin,
};
return ResponsiveDialog(
title: Text(titleText),
actions: [
TextButton(
onPressed: isValid ? _submit : null,
key: keys.saveButton,
child: Text(l10n.s_save),
)
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//TODO fix string
Text(widget.target == ManageTarget.pin
? l10n.p_enter_current_pin_or_reset
: l10n.p_enter_current_puk_or_reset),
TextField(
autofocus: true,
obscureText: true,
maxLength: 8,
autofillHints: const [AutofillHints.password],
key: keys.pinPukField,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: widget.target == ManageTarget.pin
? l10n.s_current_pin
: l10n.s_current_puk,
prefixIcon: const Icon(Icons.password_outlined),
errorText: _currentIsWrong
? l10n.l_wrong_pin_attempts_remaining(_attemptsRemaining)
: null,
errorMaxLines: 3),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
_currentIsWrong = false;
_currentPin = value;
});
},
),
Text(l10n.p_enter_new_piv_pin_puk(
widget.target == ManageTarget.puk ? l10n.s_puk : l10n.s_pin)),
TextField(
key: keys.newPinPukField,
obscureText: true,
maxLength: 8,
autofillHints: const [AutofillHints.newPassword],
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: widget.target == ManageTarget.puk
? l10n.s_new_puk
: l10n.s_new_pin,
prefixIcon: const Icon(Icons.password_outlined),
// Old YubiKeys allowed a 4 digit PIN
enabled: _currentPin.length >= 4,
),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
_newPin = value;
});
},
onSubmitted: (_) {
if (isValid) {
_submit();
}
},
),
TextField(
key: keys.confirmPinPukField,
obscureText: true,
maxLength: 8,
autofillHints: const [AutofillHints.newPassword],
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: widget.target == ManageTarget.puk
? l10n.s_confirm_puk
: l10n.s_confirm_pin,
prefixIcon: const Icon(Icons.password_outlined),
enabled: _currentPin.length >= 4 && _newPin.length >= 6,
),
textInputAction: TextInputAction.done,
onChanged: (value) {
setState(() {
_confirmPin = value;
});
},
onSubmitted: (_) {
if (isValid) {
_submit();
}
},
),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
);
}
}

View File

@ -0,0 +1,113 @@
/*
* 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.
*/
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/models.dart';
import '../../exception/cancellation_exception.dart';
import '../../widgets/responsive_dialog.dart';
import '../state.dart';
import '../keys.dart' as keys;
class PinDialog extends ConsumerStatefulWidget {
final DevicePath devicePath;
const PinDialog(this.devicePath, {super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _PinDialogState();
}
class _PinDialogState extends ConsumerState<PinDialog> {
String _pin = '';
bool _pinIsWrong = false;
int _attemptsRemaining = -1;
Future<void> _submit() async {
final navigator = Navigator.of(context);
try {
final status = await ref
.read(pivStateProvider(widget.devicePath).notifier)
.verifyPin(_pin);
status.when(
success: () {
navigator.pop(true);
},
failure: (attemptsRemaining) {
setState(() {
_attemptsRemaining = attemptsRemaining;
_pinIsWrong = true;
});
},
);
} on CancellationException catch (_) {
navigator.pop(false);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return ResponsiveDialog(
title: Text(l10n.s_pin_required),
actions: [
TextButton(
key: keys.unlockButton,
onPressed: _pin.length >= 4 ? _submit : null,
child: Text(l10n.s_unlock),
),
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.p_pin_required_desc),
TextField(
autofocus: true,
obscureText: true,
maxLength: 8,
autofillHints: const [AutofillHints.password],
key: keys.managementKeyField,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_pin,
prefixIcon: const Icon(Icons.pin_outlined),
errorText: _pinIsWrong
? l10n.l_wrong_pin_attempts_remaining(_attemptsRemaining)
: null,
errorMaxLines: 3),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
_pinIsWrong = false;
_pin = value;
});
},
onSubmitted: (_) => _submit(),
),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
);
}
}

View File

@ -0,0 +1,118 @@
/*
* Copyright (C) 2022 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 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/shortcuts.dart';
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 '../../widgets/list_title.dart';
import '../models.dart';
import '../state.dart';
import 'actions.dart';
import 'key_actions.dart';
import 'slot_dialog.dart';
class PivScreen extends ConsumerWidget {
final DevicePath devicePath;
const PivScreen(this.devicePath, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
return ref.watch(pivStateProvider(devicePath)).when(
loading: () => MessagePage(
title: Text(l10n.s_piv),
graphic: const CircularProgressIndicator(),
delayedContent: true,
),
error: (error, _) => AppFailurePage(
title: Text(l10n.s_piv),
cause: error,
),
data: (pivState) {
final pivSlots = ref.watch(pivSlotsProvider(devicePath)).asData;
return AppPage(
title: Text(l10n.s_piv),
keyActionsBuilder: (context) =>
pivBuildActions(context, devicePath, pivState, ref),
child: Column(
children: [
ListTitle(l10n.s_certificates),
if (pivSlots?.hasValue == true)
...pivSlots!.value.map((e) => registerPivActions(
devicePath,
pivState,
e,
ref: ref,
actions: {
OpenIntent:
CallbackAction<OpenIntent>(onInvoke: (_) async {
await showBlurDialog(
context: context,
builder: (context) => SlotDialog(e.slot),
);
return null;
}),
},
builder: (context) => _CertificateListItem(e),
)),
],
),
);
},
);
}
}
class _CertificateListItem extends StatelessWidget {
final PivSlot pivSlot;
const _CertificateListItem(this.pivSlot);
@override
Widget build(BuildContext context) {
final slot = pivSlot.slot;
final certInfo = pivSlot.certInfo;
final l10n = AppLocalizations.of(context)!;
final colorScheme = Theme.of(context).colorScheme;
return AppListItem(
leading: CircleAvatar(
foregroundColor: colorScheme.onSecondary,
backgroundColor: colorScheme.secondary,
child: const Icon(Icons.approval),
),
title: slot.getDisplayName(l10n),
subtitle: certInfo != null
? l10n.l_subject_issuer(certInfo.subject, certInfo.issuer)
: pivSlot.hasKey == true
? l10n.l_key_no_certificate
: l10n.l_no_certificate,
trailing: OutlinedButton(
onPressed: Actions.handler(context, const OpenIntent()),
child: const Icon(Icons.more_horiz),
),
buildPopupActions: (context) => buildSlotActions(certInfo != null, l10n),
);
}
}

View File

@ -0,0 +1,67 @@
/*
* 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.
*/
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../widgets/responsive_dialog.dart';
import '../state.dart';
import '../../app/models.dart';
import '../../app/state.dart';
class ResetDialog extends ConsumerWidget {
final DevicePath devicePath;
const ResetDialog(this.devicePath, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
return ResponsiveDialog(
title: Text(l10n.s_factory_reset),
actions: [
TextButton(
onPressed: () async {
await ref.read(pivStateProvider(devicePath).notifier).reset();
await ref.read(withContextProvider)((context) async {
Navigator.of(context).pop();
showMessage(context, l10n.l_piv_app_reset);
});
},
child: Text(l10n.s_reset),
),
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
children: [
Text(
l10n.p_warning_piv_reset,
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text(l10n.p_warning_piv_reset_desc),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
);
}
}

View File

@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/state.dart';
import '../../app/views/fs_dialog.dart';
import '../../app/views/action_list.dart';
import '../models.dart';
import '../state.dart';
import 'actions.dart';
class SlotDialog extends ConsumerWidget {
final SlotId pivSlot;
const SlotDialog(this.pivSlot, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// TODO: Solve this in a cleaner way
final node = ref.watch(currentDeviceDataProvider).valueOrNull?.node;
if (node == null) {
// The rest of this method assumes there is a device, and will throw an exception if not.
// This will never be shown, as the dialog will be immediately closed
return const SizedBox();
}
final l10n = AppLocalizations.of(context)!;
final textTheme = Theme.of(context).textTheme;
// This is what ListTile uses for subtitle
final subtitleStyle = textTheme.bodyMedium!.copyWith(
color: textTheme.bodySmall!.color,
);
final pivState = ref.watch(pivStateProvider(node.path)).valueOrNull;
final slotData = ref.watch(pivSlotsProvider(node.path).select((value) =>
value.whenOrNull(
data: (data) =>
data.firstWhere((element) => element.slot == pivSlot))));
if (pivState == null || slotData == null) {
return const FsDialog(child: CircularProgressIndicator());
}
final certInfo = slotData.certInfo;
return registerPivActions(
node.path,
pivState,
slotData,
ref: ref,
builder: (context) => FocusScope(
autofocus: true,
child: FsDialog(
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(top: 48, bottom: 32),
child: Column(
children: [
Text(
pivSlot.getDisplayName(l10n),
style: textTheme.headlineSmall,
softWrap: true,
textAlign: TextAlign.center,
),
if (certInfo != null) ...[
Text(
l10n.l_subject_issuer(
certInfo.subject, certInfo.issuer),
softWrap: true,
textAlign: TextAlign.center,
style: subtitleStyle,
),
Text(
l10n.l_serial(certInfo.serial),
softWrap: true,
textAlign: TextAlign.center,
style: subtitleStyle,
),
Text(
l10n.l_certificate_fingerprint(certInfo.fingerprint),
softWrap: true,
textAlign: TextAlign.center,
style: subtitleStyle,
),
Text(
l10n.l_valid(
certInfo.notValidBefore, certInfo.notValidAfter),
softWrap: true,
textAlign: TextAlign.center,
style: subtitleStyle,
),
] else ...[
Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Text(
l10n.l_no_certificate,
softWrap: true,
textAlign: TextAlign.center,
style: subtitleStyle,
),
),
],
const SizedBox(height: 16),
],
),
),
ActionListSection.fromMenuActions(
context,
l10n.s_actions,
actions: buildSlotActions(certInfo != null, l10n),
),
],
),
),
),
);
}
}

View File

@ -21,6 +21,7 @@ const accentGreen = Color(0xff9aca3c);
const primaryBlue = Color(0xff325f74);
const primaryRed = Color(0xffea4335);
const darkRed = Color(0xffda4d41);
const amber = Color(0xffffca28);
class AppTheme {
static ThemeData get lightTheme => ThemeData(
@ -32,6 +33,7 @@ class AppTheme {
).copyWith(
primary: primaryBlue,
//secondary: accentGreen,
tertiary: amber.withOpacity(0.7),
),
textTheme: TextTheme(
bodySmall: TextStyle(color: Colors.grey.shade900),
@ -57,6 +59,7 @@ class AppTheme {
//onPrimaryContainer: Colors.grey.shade100,
error: darkRed,
onError: Colors.white.withOpacity(0.9),
tertiary: amber.withOpacity(0.7),
),
textTheme: TextTheme(
bodySmall: TextStyle(color: Colors.grey.shade500),

View File

@ -1,47 +0,0 @@
/*
* Copyright (C) 2022 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 'package:flutter/material.dart';
PopupMenuItem buildMenuItem({
required Widget title,
Widget? leading,
String? trailing,
void Function()? action,
}) =>
PopupMenuItem(
enabled: action != null,
onTap: () {
// Wait for popup menu to close before running action.
Timer.run(action!);
},
child: ListTile(
enabled: action != null,
dense: true,
contentPadding: EdgeInsets.zero,
minLeadingWidth: 0,
title: title,
leading: leading,
trailing: trailing != null
? Opacity(
opacity: 0.5,
child: Text(trailing, textScaleFactor: 0.7),
)
: null,
),
);

View File

@ -22,13 +22,16 @@ class ResponsiveDialog extends StatefulWidget {
final Widget child;
final List<Widget> actions;
final Function()? onCancel;
final bool allowCancel;
const ResponsiveDialog(
{super.key,
const ResponsiveDialog({
super.key,
required this.child,
this.title,
this.actions = const [],
this.onCancel});
this.onCancel,
this.allowCancel = true,
});
@override
State<ResponsiveDialog> createState() => _ResponsiveDialogState();
@ -36,31 +39,35 @@ class ResponsiveDialog extends StatefulWidget {
class _ResponsiveDialogState extends State<ResponsiveDialog> {
final Key _childKey = GlobalKey();
final _focus = FocusScopeNode();
@override
Widget build(BuildContext context) =>
LayoutBuilder(builder: ((context, constraints) {
final l10n = AppLocalizations.of(context)!;
if (constraints.maxWidth < 540) {
// Fullscreen
return Scaffold(
void dispose() {
super.dispose();
_focus.dispose();
}
Widget _buildFullscreen(BuildContext context) => Scaffold(
appBar: AppBar(
title: widget.title,
actions: widget.actions,
leading: CloseButton(
onPressed: () {
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: widget.allowCancel
? () {
widget.onCancel?.call();
Navigator.of(context).pop();
},
),
}
: null),
),
body: SingleChildScrollView(
child: SafeArea(
child: Container(key: _childKey, child: widget.child)),
child:
SafeArea(child: Container(key: _childKey, child: widget.child)),
),
);
} else {
// Dialog
Widget _buildDialog(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final cancelText = widget.onCancel == null && widget.actions.isEmpty
? l10n.s_close
: l10n.s_cancel;
@ -85,5 +92,22 @@ class _ResponsiveDialogState extends State<ResponsiveDialog> {
],
);
}
@override
Widget build(BuildContext context) =>
LayoutBuilder(builder: ((context, constraints) {
// This keeps the focus in the dialog, even if the underlying page changes.
return FocusScope(
node: _focus,
autofocus: true,
onFocusChange: (focused) {
if (!focused) {
_focus.requestFocus();
}
},
child: constraints.maxWidth < 540
? _buildFullscreen(context)
: _buildDialog(context),
);
}));
}

Some files were not shown because too many files have changed in this diff Show More