mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2024-12-22 17:51:29 +03:00
Merge branch 'main' into adamve/fix/android_bitesize
This commit is contained in:
commit
4f866a89ab
2
.github/workflows/android.yml
vendored
2
.github/workflows/android.yml
vendored
@ -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
|
||||
|
2
.github/workflows/check-strings.yml
vendored
2
.github/workflows/check-strings.yml
vendored
@ -7,7 +7,7 @@ jobs:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
FLUTTER: '3.10.1'
|
||||
FLUTTER: '3.10.6'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
2
.github/workflows/codeql-analysis.yml
vendored
2
.github/workflows/codeql-analysis.yml
vendored
@ -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
|
||||
|
12
.github/workflows/linux.yml
vendored
12
.github/workflows/linux.yml
vendored
@ -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
|
||||
|
4
.github/workflows/macos.yml
vendored
4
.github/workflows/macos.yml
vendored
@ -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
|
||||
|
||||
|
4
.github/workflows/windows.yml
vendored
4
.github/workflows/windows.yml
vendored
@ -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
|
||||
|
||||
|
@ -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"
|
||||
|
31
android/app/src/main/assets/logback.xml
Normal file
31
android/app/src/main/assets/logback.xml
Normal 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>
|
@ -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"
|
||||
}
|
||||
}
|
@ -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")
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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 {
|
||||
buffer.add(it)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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}"
|
||||
|
@ -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("@")}
|
||||
|
@ -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
457
helper/helper/piv.py
Normal 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
306
helper/poetry.lock
generated
@ -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"
|
||||
|
@ -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"]
|
||||
|
@ -15,4 +15,6 @@
|
||||
*/
|
||||
|
||||
/// list of YubiKey serial numbers which are approved to be used with integration tests
|
||||
var approvedYubiKeys = <String>[];
|
||||
var approvedYubiKeys = <String>[
|
||||
'',
|
||||
];
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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)));
|
||||
}
|
@ -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
|
@ -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');
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
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
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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>()
|
||||
|
109
lib/app/views/action_list.dart
Normal file
109
lib/app/views/action_list.dart
Normal 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,
|
||||
]),
|
||||
);
|
||||
}
|
66
lib/app/views/action_popup_menu.dart
Normal file
66
lib/app/views/action_popup_menu.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
137
lib/app/views/app_list_item.dart
Normal file
137
lib/app/views/app_list_item.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: actionButtonBuilder?.call(context) ?? const DeviceButton(),
|
||||
),
|
||||
if (actionButtonBuilder != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: actionButtonBuilder!.call(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
drawer: hasDrawer ? const MainPageDrawer() : null,
|
||||
body: centered ? Center(child: _buildScrollView()) : _buildScrollView(),
|
||||
drawer: hasDrawer ? _buildDrawer(context) : null,
|
||||
body: body,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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'),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
405
lib/app/views/device_picker.dart
Normal file
405
lib/app/views/device_picker.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
51
lib/app/views/fs_dialog.dart
Normal file
51
lib/app/views/fs_dialog.dart
Normal 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();
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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');
|
||||
|
@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
@ -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(
|
||||
|
213
lib/app/views/navigation.dart
Normal file
213
lib/app/views/navigation.dart
Normal 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());
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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');
|
||||
|
@ -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);
|
||||
}
|
||||
state = AsyncValue.data(value);
|
||||
}
|
||||
}
|
||||
|
@ -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),
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
session.setErrorHandler('state-reset', (_) async {
|
||||
|
||||
_pinController = ref.watch(_pinProvider(devicePath).notifier);
|
||||
_session.setErrorHandler('state-reset', (_) async {
|
||||
ref.invalidate(_sessionProvider(devicePath));
|
||||
});
|
||||
session.setErrorHandler('auth-required', (_) async {
|
||||
_session.setErrorHandler('auth-required', (_) async {
|
||||
final pin = ref.read(_pinProvider(devicePath));
|
||||
if (pin != null) {
|
||||
await notifier.unlock(pin);
|
||||
await unlock(pin);
|
||||
}
|
||||
});
|
||||
ref.onDispose(() {
|
||||
session.unsetErrorHandler('auth-required');
|
||||
_session.unsetErrorHandler('auth-required');
|
||||
});
|
||||
ref.onDispose(() {
|
||||
session.unsetErrorHandler('state-reset');
|
||||
_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']);
|
||||
});
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -36,53 +36,51 @@ 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();
|
||||
for (final iface in [
|
||||
// This is the preferred order
|
||||
UsbInterface.ccid,
|
||||
UsbInterface.otp,
|
||||
UsbInterface.fido,
|
||||
]) {
|
||||
if (interfaces.contains(iface.name)) {
|
||||
final path = [iface.name, 'management'];
|
||||
try {
|
||||
await _session.command('get', target: path);
|
||||
_subpath = path;
|
||||
_log.debug('Using transport $iface for management');
|
||||
return info;
|
||||
} catch (e) {
|
||||
_log.warning('Failed connecting to management via $iface');
|
||||
}
|
||||
}
|
||||
final result = await _session.command('get');
|
||||
final info = DeviceInfo.fromJson(result['data']['info']);
|
||||
final interfaces = (result['children'] as Map).keys.toSet();
|
||||
for (final iface in [
|
||||
// This is the preferred order
|
||||
UsbInterface.ccid,
|
||||
UsbInterface.otp,
|
||||
UsbInterface.fido,
|
||||
]) {
|
||||
if (interfaces.contains(iface.name)) {
|
||||
final path = [iface.name, 'management'];
|
||||
try {
|
||||
await _session.command('get', target: path);
|
||||
_subpath = path;
|
||||
_log.debug('Using transport $iface for management');
|
||||
return info;
|
||||
} catch (e) {
|
||||
_log.warning('Failed connecting to management via $iface');
|
||||
}
|
||||
throw 'Failed connection over all interfaces';
|
||||
});
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
@ -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));
|
||||
if (oathState.locked && key != null) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
return oathState;
|
||||
});
|
||||
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));
|
||||
if (oathState.locked && key != null) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
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
429
lib/desktop/piv/state.dart
Normal 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
45
lib/fido/keys.dart
Normal 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');
|
@ -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
|
||||
|
@ -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);
|
||||
}
|
||||
|
55
lib/fido/views/actions.dart
Normal file
55
lib/fido/views/actions.dart
Normal 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(),
|
||||
),
|
||||
];
|
||||
}
|
93
lib/fido/views/credential_dialog.dart
Normal file
93
lib/fido/views/credential_dialog.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
109
lib/fido/views/fingerprint_dialog.dart
Normal file
109
lib/fido/views/fingerprint_dialog.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -19,72 +19,90 @@ 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(
|
||||
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),
|
||||
subtitle: state.unlocked
|
||||
? Text(l10n.l_fingerprints_used(fingerprints))
|
||||
: Text(state.hasPin
|
||||
? l10n.l_unlock_pin_first
|
||||
: l10n.l_set_pin_first),
|
||||
enabled: state.unlocked && fingerprints < 5,
|
||||
onTap: state.unlocked && fingerprints < 5
|
||||
? () {
|
||||
|
||||
return FsDialog(
|
||||
child: Column(
|
||||
children: [
|
||||
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
|
||||
? l10n.l_fingerprints_used(fingerprints)
|
||||
: state.hasPin
|
||||
? l10n.l_unlock_pin_first
|
||||
: 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,
|
||||
builder: (context) => AddFingerprintDialog(node.path),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
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,
|
||||
trailing: state.alwaysUv && !state.hasPin
|
||||
? const Icon(Icons.warning_amber)
|
||||
: null,
|
||||
onTap: (context) {
|
||||
Navigator.of(context).pop();
|
||||
showBlurDialog(
|
||||
context: context,
|
||||
builder: (context) => AddFingerprintDialog(node.path),
|
||||
builder: (context) => FidoPinDialog(node.path, state),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
}),
|
||||
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,
|
||||
builder: (context) => ResetDialog(node),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
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
|
||||
? l10n.s_fido_pin_protection
|
||||
: l10n.l_fido_pin_protection_optional),
|
||||
onTap: () {
|
||||
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: () {
|
||||
Navigator.of(context).pop();
|
||||
showBlurDialog(
|
||||
context: context,
|
||||
builder: (context) => ResetDialog(node),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.delete_outline)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
builder: (context) => CredentialDialog(cred),
|
||||
)),
|
||||
DeleteIntent: CallbackAction<DeleteIntent>(
|
||||
onInvoke: (_) => showBlurDialog(
|
||||
context: context,
|
||||
builder: (context) => DeleteCredentialDialog(
|
||||
node.path,
|
||||
cred,
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
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
445
lib/l10n/app_de.arb
Normal 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": {}
|
||||
}
|
@ -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",
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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');
|
||||
|
@ -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> {
|
||||
|
@ -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,20 +116,33 @@ 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: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 48, bottom: 16),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
IconTheme(
|
||||
data: IconTheme.of(context).copyWith(size: 24),
|
||||
child: helper.buildCodeIcon(),
|
||||
),
|
||||
const SizedBox(width: 8.0),
|
||||
DefaultTextStyle.merge(
|
||||
style: const TextStyle(fontSize: 28),
|
||||
child: helper.buildCodeLabel(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
helper.title,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
softWrap: true,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (subtitle != null)
|
||||
Text(
|
||||
subtitle,
|
||||
@ -192,48 +153,14 @@ class AccountDialog extends ConsumerWidget {
|
||||
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),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
IconTheme(
|
||||
data: IconTheme.of(context).copyWith(size: 24),
|
||||
child: helper.buildCodeIcon(),
|
||||
),
|
||||
const SizedBox(width: 8.0),
|
||||
DefaultTextStyle.merge(
|
||||
style: const TextStyle(fontSize: 28),
|
||||
child: helper.buildCodeLabel(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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)),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
|
@ -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(),
|
||||
),
|
||||
];
|
||||
|
@ -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(
|
||||
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
|
||||
? FilledButton.tonalIcon(
|
||||
icon: helper.buildCodeIcon(),
|
||||
label: helper.buildCodeLabel(),
|
||||
onPressed: () {
|
||||
Actions.maybeInvoke<OpenIntent>(
|
||||
context, const OpenIntent());
|
||||
},
|
||||
)
|
||||
: FilledButton.tonal(
|
||||
onPressed: () {
|
||||
Actions.maybeInvoke<OpenIntent>(
|
||||
context, const OpenIntent());
|
||||
},
|
||||
child: helper.buildCodeIcon()),
|
||||
),
|
||||
),
|
||||
child: AppListItem(
|
||||
leading: showAvatar
|
||||
? AccountIcon(
|
||||
issuer: credential.issuer,
|
||||
defaultWidget: circleAvatar)
|
||||
: null,
|
||||
title: helper.title,
|
||||
subtitle: subtitle,
|
||||
trailing: helper.code != null
|
||||
? FilledButton.tonalIcon(
|
||||
icon: helper.buildCodeIcon(),
|
||||
label: helper.buildCodeLabel(),
|
||||
onPressed:
|
||||
Actions.handler(context, const OpenIntent()),
|
||||
)
|
||||
: FilledButton.tonal(
|
||||
onPressed:
|
||||
Actions.handler(context, const OpenIntent()),
|
||||
child: helper.buildCodeIcon()),
|
||||
activationIntent: const CopyIntent(),
|
||||
buildPopupActions: (_) => helper.buildActions(),
|
||||
),
|
||||
));
|
||||
});
|
||||
|
@ -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';
|
||||
|
@ -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,100 +42,101 @@ Widget oathBuildActions(
|
||||
}) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final capacity = oathState.version.isAtLeast(4) ? 32 : null;
|
||||
final theme = Theme.of(context).colorScheme;
|
||||
return SimpleDialog(
|
||||
children: [
|
||||
ListTitle(l10n.s_setup, textStyle: Theme.of(context).textTheme.bodyLarge),
|
||||
ListTile(
|
||||
title: Text(l10n.s_add_account),
|
||||
key: keys.addAccountAction,
|
||||
leading:
|
||||
const CircleAvatar(child: Icon(Icons.person_add_alt_1_outlined)),
|
||||
subtitle: Text(used == null
|
||||
? l10n.l_unlock_first
|
||||
: (capacity != null ? l10n.l_accounts_used(used, capacity) : '')),
|
||||
enabled: used != null && (capacity == null || capacity > used),
|
||||
onTap: used != null && (capacity == null || capacity > used)
|
||||
? () async {
|
||||
final credentials = ref.read(credentialsProvider);
|
||||
final withContext = ref.read(withContextProvider);
|
||||
Navigator.of(context).pop();
|
||||
CredentialData? otpauth;
|
||||
if (isAndroid) {
|
||||
final scanner = ref.read(qrScannerProvider);
|
||||
if (scanner != null) {
|
||||
try {
|
||||
final url = await scanner.scanQr();
|
||||
if (url != null) {
|
||||
otpauth = CredentialData.fromUri(Uri.parse(url));
|
||||
|
||||
return FsDialog(
|
||||
child: Column(
|
||||
children: [
|
||||
ActionListSection(l10n.s_setup, children: [
|
||||
ActionListItem(
|
||||
key: keys.addAccountAction,
|
||||
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)
|
||||
: ''),
|
||||
onTap: used != null && (capacity == null || capacity > used)
|
||||
? (context) async {
|
||||
final credentials = ref.read(credentialsProvider);
|
||||
final withContext = ref.read(withContextProvider);
|
||||
Navigator.of(context).pop();
|
||||
CredentialData? otpauth;
|
||||
if (isAndroid) {
|
||||
final scanner = ref.read(qrScannerProvider);
|
||||
if (scanner != null) {
|
||||
try {
|
||||
final url = await scanner.scanQr();
|
||||
if (url != null) {
|
||||
otpauth = CredentialData.fromUri(Uri.parse(url));
|
||||
}
|
||||
} on CancellationException catch (_) {
|
||||
// ignored - user cancelled
|
||||
return;
|
||||
}
|
||||
}
|
||||
} on CancellationException catch (_) {
|
||||
// ignored - user cancelled
|
||||
return;
|
||||
}
|
||||
await withContext((context) async {
|
||||
await showBlurDialog(
|
||||
context: context,
|
||||
builder: (context) => OathAddAccountPage(
|
||||
devicePath,
|
||||
oathState,
|
||||
credentials: credentials,
|
||||
credentialData: otpauth,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
await withContext((context) async {
|
||||
await showBlurDialog(
|
||||
context: context,
|
||||
builder: (context) => OathAddAccountPage(
|
||||
devicePath,
|
||||
oathState,
|
||||
credentials: credentials,
|
||||
credentialData: otpauth,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
ListTitle(l10n.s_manage,
|
||||
textStyle: Theme.of(context).textTheme.bodyLarge),
|
||||
ListTile(
|
||||
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),
|
||||
: null,
|
||||
),
|
||||
onTap: () async {
|
||||
Navigator.of(context).pop();
|
||||
await ref.read(withContextProvider)((context) => showBlurDialog(
|
||||
]),
|
||||
ActionListSection(l10n.s_manage, children: [
|
||||
ActionListItem(
|
||||
key: keys.customIconsAction,
|
||||
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,
|
||||
routeSettings:
|
||||
const RouteSettings(name: 'oath_icon_pack_dialog'),
|
||||
builder: (context) => const IconPackDialog(),
|
||||
));
|
||||
}),
|
||||
ActionListItem(
|
||||
key: keys.setOrManagePasswordAction,
|
||||
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,
|
||||
routeSettings:
|
||||
const RouteSettings(name: 'oath_icon_pack_dialog'),
|
||||
builder: (context) => const IconPackDialog(),
|
||||
));
|
||||
}),
|
||||
ListTile(
|
||||
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: () {
|
||||
Navigator.of(context).pop();
|
||||
showBlurDialog(
|
||||
context: context,
|
||||
builder: (context) => ManagePasswordDialog(devicePath, oathState),
|
||||
);
|
||||
}),
|
||||
ListTile(
|
||||
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: () {
|
||||
Navigator.of(context).pop();
|
||||
showBlurDialog(
|
||||
context: context,
|
||||
builder: (context) => ResetDialog(devicePath),
|
||||
);
|
||||
}),
|
||||
],
|
||||
builder: (context) =>
|
||||
ManagePasswordDialog(devicePath, oathState),
|
||||
);
|
||||
}),
|
||||
ActionListItem(
|
||||
key: keys.resetAction,
|
||||
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
44
lib/piv/keys.dart
Normal 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
312
lib/piv/models.dart
Normal 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
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
228
lib/piv/models.g.dart
Normal 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
70
lib/piv/state.dart
Normal 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
256
lib/piv/views/actions.dart
Normal 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(),
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
122
lib/piv/views/authentication_dialog.dart
Normal file
122
lib/piv/views/authentication_dialog.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
79
lib/piv/views/delete_certificate_dialog.dart
Normal file
79
lib/piv/views/delete_certificate_dialog.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
206
lib/piv/views/generate_key_dialog.dart
Normal file
206
lib/piv/views/generate_key_dialog.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
187
lib/piv/views/import_file_dialog.dart
Normal file
187
lib/piv/views/import_file_dialog.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
137
lib/piv/views/key_actions.dart
Normal file
137
lib/piv/views/key_actions.dart
Normal 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();
|
||||
}),
|
||||
],
|
||||
*/
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
278
lib/piv/views/manage_key_dialog.dart
Normal file
278
lib/piv/views/manage_key_dialog.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
191
lib/piv/views/manage_pin_puk_dialog.dart
Normal file
191
lib/piv/views/manage_pin_puk_dialog.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
113
lib/piv/views/pin_dialog.dart
Normal file
113
lib/piv/views/pin_dialog.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
118
lib/piv/views/piv_screen.dart
Normal file
118
lib/piv/views/piv_screen.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
67
lib/piv/views/reset_dialog.dart
Normal file
67
lib/piv/views/reset_dialog.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
117
lib/piv/views/slot_dialog.dart
Normal file
117
lib/piv/views/slot_dialog.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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),
|
||||
|
@ -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,
|
||||
),
|
||||
);
|
@ -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,
|
||||
required this.child,
|
||||
this.title,
|
||||
this.actions = const [],
|
||||
this.onCancel});
|
||||
const ResponsiveDialog({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.title,
|
||||
this.actions = const [],
|
||||
this.onCancel,
|
||||
this.allowCancel = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ResponsiveDialog> createState() => _ResponsiveDialogState();
|
||||
@ -36,54 +39,75 @@ class ResponsiveDialog extends StatefulWidget {
|
||||
|
||||
class _ResponsiveDialogState extends State<ResponsiveDialog> {
|
||||
final Key _childKey = GlobalKey();
|
||||
final _focus = FocusScopeNode();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
_focus.dispose();
|
||||
}
|
||||
|
||||
Widget _buildFullscreen(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: widget.title,
|
||||
actions: widget.actions,
|
||||
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)),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _buildDialog(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final cancelText = widget.onCancel == null && widget.actions.isEmpty
|
||||
? l10n.s_close
|
||||
: l10n.s_cancel;
|
||||
return AlertDialog(
|
||||
title: widget.title,
|
||||
titlePadding: const EdgeInsets.only(top: 24, left: 18, right: 18),
|
||||
scrollable: true,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
||||
content: SizedBox(
|
||||
width: 380,
|
||||
child: Container(key: _childKey, child: widget.child),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: Text(cancelText),
|
||||
onPressed: () {
|
||||
widget.onCancel?.call();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
...widget.actions
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
LayoutBuilder(builder: ((context, constraints) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
if (constraints.maxWidth < 540) {
|
||||
// Fullscreen
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: widget.title,
|
||||
actions: widget.actions,
|
||||
leading: CloseButton(
|
||||
onPressed: () {
|
||||
widget.onCancel?.call();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: SafeArea(
|
||||
child: Container(key: _childKey, child: widget.child)),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Dialog
|
||||
final cancelText = widget.onCancel == null && widget.actions.isEmpty
|
||||
? l10n.s_close
|
||||
: l10n.s_cancel;
|
||||
return AlertDialog(
|
||||
title: widget.title,
|
||||
titlePadding: const EdgeInsets.only(top: 24, left: 18, right: 18),
|
||||
scrollable: true,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
||||
content: SizedBox(
|
||||
width: 380,
|
||||
child: Container(key: _childKey, child: widget.child),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: Text(cancelText),
|
||||
onPressed: () {
|
||||
widget.onCancel?.call();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
...widget.actions
|
||||
],
|
||||
);
|
||||
}
|
||||
// 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
Loading…
Reference in New Issue
Block a user