Merge branch 'main' into adamve/nfc_activity_widget

This commit is contained in:
Adam Velebil 2024-07-31 12:38:39 +02:00
commit 9a7a1e76a3
No known key found for this signature in database
GPG Key ID: C9B1E4A3CBBD2E10
324 changed files with 25061 additions and 7356 deletions

View File

@ -1,6 +1,15 @@
version: 2
enable-beta-ecosystems: true # required for the pub package manager
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
github-actions:
patterns:
- "*"
- package-ecosystem: "pub"
directory: "/"
schedule:

View File

@ -8,12 +8,12 @@ jobs:
steps:
- name: set up JDK 17
uses: actions/setup-java@v3
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
path: 'app'
@ -63,7 +63,7 @@ jobs:
run: android/scripts/collect-artifacts.sh ${GITHUB_REF}
working-directory: ./app
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
with:
name: yubico-authenticator-android
path: app/artifacts/*

View File

@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Read variables from repo
run: cat .github/workflows/env >> $GITHUB_ENV

View File

@ -28,18 +28,18 @@ jobs:
steps:
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
setup-python-dependencies: false
- name: set up JDK 17
uses: actions/setup-java@v3
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
path: 'app'
@ -75,7 +75,7 @@ jobs:
- if: matrix.language == 'python'
name: autobuild
uses: github/codeql-action/autobuild@v2
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
uses: github/codeql-action/analyze@v3

View File

@ -1,2 +1,2 @@
FLUTTER=3.13.4
PYVER=3.12.0
FLUTTER=3.22.2
PYVER=3.12.4

View File

@ -12,7 +12,7 @@ jobs:
DEBIAN_FRONTEND: noninteractive
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
sparse-checkout: .github/workflows/env
@ -30,10 +30,13 @@ jobs:
apt-get install -qq git python$PYVER_MINOR-dev python$PYVER_MINOR-venv
git config --global --add safe.directory "$GITHUB_WORKSPACE"
ln -s `which python$PYVER_MINOR` /usr/local/bin/python
ln -s `which python$PYVER_MINOR` /usr/local/bin/python3
PYVER_TEMP=`/usr/local/bin/python --version`
export PYVERINST=${PYVER_TEMP#* }
echo "PYVERINST=$PYVERINST" >> $GITHUB_ENV
echo "Installed python version: $PYVERINST"
python -m ensurepip --user
python -m pip install -U pip pipx
- name: Verify Python version
if: ${{ env.PYVERINST != env.PYVER }}
@ -43,7 +46,7 @@ jobs:
echo "Expected: $PYVER"
exit 1
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Check app versions
run: |
@ -52,28 +55,19 @@ jobs:
- name: Cache helper
id: cache-helper
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: |
build/linux/helper
assets/licenses/helper.json
key: ${{ runner.os }}-py${{ env.PYVER }}-${{ hashFiles('helper/**') }}
- name: Install helper dependencies
if: steps.cache-helper.outputs.cache-hit != 'true'
run: |
apt-get install -qq swig libpcsclite-dev build-essential cmake
python -m ensurepip --user
python -m pip install -U pip pipx
# pipx ensurepath
echo "export PATH=$PATH:$HOME/.local/bin" >> ~/.bashrc
. ~/.bashrc # Needed to ensure poetry on PATH
pipx install poetry
- name: Build the Helper
if: steps.cache-helper.outputs.cache-hit != 'true'
run: |
. ~/.bashrc # Needed to ensure poetry on PATH
apt-get install -qq swig libpcsclite-dev build-essential cmake
export PATH=$PATH:$HOME/.local/bin # Needed to ensure pipx/poetry on PATH
pipx install poetry
./build-helper.sh
- name: Install Flutter dependencies
@ -87,14 +81,18 @@ jobs:
- name: Configure Flutter
run: |
git config --global --add safe.directory /opt/hostedtoolcache/flutter/stable-$FLUTTER-x64
git config --global --add safe.directory $FLUTTER_ROOT
flutter config --enable-linux-desktop
flutter --version
- name: Run tests
- name: Run lints/tests
env:
SKIP: ${{ steps.cache-helper.outputs.cache-hit == 'true' && 'mypy,flake8,black,bandit' || ''}}
run: |
export PATH=$PATH:$HOME/.local/bin # Needed to ensure pip/pre-commit on PATH
pipx install pre-commit
pre-commit run --all-files
flutter test
flutter analyze
- name: Build the app
run: flutter build linux
@ -123,7 +121,7 @@ jobs:
tar -czf deploy/${BASENAME}.tar.gz -C build "${BASENAME}"
- name: Upload artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: yubioath-desktop-linux
path: deploy

View File

@ -8,7 +8,7 @@ jobs:
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Read variables from repo
run: cat .github/workflows/env >> $GITHUB_ENV
@ -18,21 +18,25 @@ jobs:
python3 set-version.py
git diff --exit-code
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYVER }}
- name: Set up CocoaPods
uses: maxim-lobanov/setup-cocoapods@v1
with:
podfile-path: macos/Podfile.lock
- name: Cache helper
id: cache-helper
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: |
build/macos/helper
assets/licenses/helper.json
key: ${{ runner.os }}-py${{ env.PYVER }}-${{ hashFiles('helper/**') }}
- name: Set up Python
if: steps.cache-helper.outputs.cache-hit != 'true'
uses: actions/setup-python@v4
with:
python-version: ${{ env.PYVER }}
- name: Install dependencies
if: steps.cache-helper.outputs.cache-hit != 'true'
run: |
@ -53,10 +57,13 @@ jobs:
- run: flutter config --enable-macos-desktop
- run: flutter --version
- name: Run tests
- name: Run lints/tests
env:
SKIP: ${{ steps.cache-helper.outputs.cache-hit == 'true' && 'mypy,flake8,black,bandit' || ''}}
run: |
pip install pre-commit
pre-commit run --all-files
flutter test
flutter analyze
- name: Build the app
run: |
@ -88,7 +95,7 @@ jobs:
mv macos/release-macos.sh deploy
- name: Upload artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: yubioath-desktop-macos
path: deploy

View File

@ -8,7 +8,7 @@ jobs:
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Read variables from repo
shell: bash
@ -19,30 +19,28 @@ jobs:
python set-version.py
git diff --exit-code
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYVER }}
- name: Update pip
run: python -m pip install --upgrade pip
- name: Cache helper
id: cache-helper
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: |
build/windows/helper
assets/licenses/helper.json
key: ${{ runner.os }}-py${{ env.PYVER }}-${{ hashFiles('helper/**') }}
- name: Set up Python
if: steps.cache-helper.outputs.cache-hit != 'true'
uses: actions/setup-python@v4
with:
python-version: ${{ env.PYVER }}
- name: Install dependencies
if: steps.cache-helper.outputs.cache-hit != 'true'
run: |
python -m pip install --upgrade pip
pip install poetry
- name: Build the Helper
if: steps.cache-helper.outputs.cache-hit != 'true'
run: .\build-helper.bat
run: |
pip install poetry
.\build-helper.bat
- uses: subosito/flutter-action@v2
with:
@ -51,10 +49,13 @@ jobs:
- run: flutter config --enable-windows-desktop
- run: flutter --version
- name: Run tests
- name: Run lints/tests
env:
SKIP: ${{ steps.cache-helper.outputs.cache-hit == 'true' && 'mypy,flake8,black,bandit' || ''}}
run: |
pip install pre-commit
pre-commit run --all-files
flutter test
flutter analyze
- name: Build the app
run: |
@ -66,7 +67,7 @@ jobs:
- name: Move .dll files
run: |
$dest = "build\windows\runner\Release"
$dest = "build\windows\x64\runner\Release"
cp $dest\helper\_internal/MSVCP140.dll $dest\
cp $dest\helper\_internal/VCRUNTIME140.dll $dest\
cp $dest\helper\_internal/VCRUNTIME140_1.dll $dest\
@ -74,8 +75,8 @@ jobs:
- name: Create an unsigned .msi installer package
run: |
$env:PATH += ";$env:WIX\bin"
$env:SRCDIR = "build\windows\runner\Release\"
heat dir .\build\windows\runner\Release\ -out fragment.wxs -gg -scom -srd -sfrag -dr INSTALLDIR -cg ApplicationFiles -var env.SRCDIR
$env:SRCDIR = "build\windows\x64\runner\Release\"
heat dir .\build\windows\x64\runner\Release\ -out fragment.wxs -gg -scom -srd -sfrag -dr INSTALLDIR -cg ApplicationFiles -var env.SRCDIR
candle .\fragment.wxs .\resources\win\yubioath-desktop.wxs -ext WixUtilExtension -arch x64
light fragment.wixobj yubioath-desktop.wixobj -ext WixUIExtension -ext WixUtilExtension -o yubioath-desktop.msi
@ -85,13 +86,13 @@ jobs:
$branch = $arr[2]
$dest = "deploy\yubioath-desktop-$branch-windows"
mkdir $dest
mv build\windows\runner\Release\* $dest\
mv build\windows\x64\runner\Release\* $dest\
mv yubioath-desktop.msi deploy
mv resources\win\release-win.ps1 deploy
mv resources deploy
- name: Upload artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: yubioath-desktop-windows
path: deploy

47
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,47 @@
repos:
# Flutter
- repo: https://github.com/dluksza/flutter-analyze-pre-commit
rev: "4afcaa82fc368d40d486256bf4edba329bf667bb"
hooks:
- id: dart-format
files: \.dart$
require_serial: true
- id: flutter-analyze
require_serial: true
- repo: local
hooks:
- id: arb-reformatter
name: reformat-strings
files: \.arb$
language: script
entry: arb_reformatter.py
require_serial: true
- id: update-android-strings
name: update-android-strings
files: \.arb$
language: script
entry: update_android_strings.py
require_serial: true
# Python
- repo: local
hooks:
- id: mypy
name: mypy
files: helper/
language: script
entry: run-mypy.sh
require_serial: true
- repo: https://github.com/PyCQA/flake8
rev: 6.1.0
hooks:
- id: flake8
- repo: https://github.com/psf/black
rev: 23.11.0
hooks:
- id: black
- repo: https://github.com/PyCQA/bandit
rev: 1.7.5
hooks:
- id: bandit
files: helper/ # keep in sync with .bandit file

53
NEWS
View File

@ -1,3 +1,50 @@
* Version 7.0.1 (released 2024-05-30) Android only release
** Fix: Opening the app by NFC tap needs another tap to reveal accounts.
** Fix: NFC devices attached to mobile phone prevent usage of USB YubiKeys.
** Fix: Invalid colors shown in customization views for Android Dynamic color.
** Fix: Fingerprints are shown in random order.
* Version 7.0.0 (released 2024-05-06)
** UI: Add home screen with device information, customization options, and factory reset.
** UI: Add search filtering to Passkeys and display more information.
** Localization: Add official support for French and Japanese.
** PIV (desktop): Support managing retired key slots.
** Management (desktop): Support toggling applications when Configuration Lock code is set.
** OTP (desktop): Support managing slots when OTP Access Code is set.
** Android: Support for FIDO (managing PIN, Passkeys, Fingerprints, and factory reset).
** Linux: Add ability to use external program for copying to clipboard (see README).
** Additional features/support for YubiKey 5.7:
*** Handling of PIN complexity.
*** PIV: New key algorithms: RSA3072, RSA4096, Ed25519, and X25519.
*** PIV: The ability to move and delete keys.
* Version 6.4.0 (released 2024-02-20)
** UI: Major UI overhaul, with improvements including:
*** Add new UI layouts for wider windows to better utilize screen space.
*** Add YubiKey personalization through custom naming and theme color.
*** Split FIDO/WebAuthn into multiple sections.
*** Move factory reset functionality into a single dialog, from the individual sections.
** Add support for Yubico OTP provisioning.
** PIV: Display more information about keys and certificates.
** PIV: Add output format for public key when generating keys.
** Desktop: Window hidden/shown state no longer saved when closing the app,
use --hidden to start the app in a hidden to systray state.
** Desktop: Fix FIDO reset over NFC.
** Windows: Add option to launch Windows Settings for FIDO management.
** Android: Increase read timeout for NFC, improving compatibility with older YubiKeys.
* Version 6.3.1 (released 2023-12-12)
** Add command line options: --hidden/--shown, --log-file FILE.
** Disable autocorrect in text fields.
** Improve UI for toggling USB interfaces on YubiKey <= 4.
** OATH: Fix displaying of credentials with empty (but not null) issuer.
** PIV: Fix "Save" button not updating when changing Management Key.
** FIDO: Improved handling of forcePinChange.
** Add keyboard shortcut to calculate new code (Ctrl/Cmd + R).
** Android: Support loading QR code from file.
** Android: Add a "Do nothing" option to NFC tap.
** Android: Improve handing of USB device removal.
* Version 6.3.0 (released 2023-09-04)
** Add support for importing accounts through QR codes from Google Authenticator.
** Add community translations for French, Japanese, German and Polish languages.
@ -66,7 +113,7 @@
** Bugfix: Show firmware version for YubiKey NEO correctly
** Windows: Show correct version number in .msi installers
** macOS: Fix issue with window positioning
** macOS: Fix occacional crashes on startup
** macOS: Fix occasional crashes on startup
** Linux: Fix the app icon and desktop entry for the Snap package.
* Version 5.0.3 (released 2020-04-14)
@ -101,7 +148,7 @@ to uninstall the older version before using the .msi
** Feature: Select favorite credentials, available from the System Tray/Menu Bar
** Show some information about the connected YubiKey, such as firmware version and serial number
** Add experimental support for external smart card readers, enabling the use of a YubiKey over NFC
** Add initial accessability support
** Add initial accessibility support
* Version 4.3.6b (released 2019-06-11)
** Fixes problem where YubiKey was not being detected on macOS.
@ -226,7 +273,7 @@ to uninstall the older version before using the .msi
* Version 4.0.1 (released 2017-03-27)
** Bugfix: Follow color schemes better.
** Removed some spacing thath caused the layout to be slightly off.
** Removed some spacing that caused the layout to be slightly off.
** Improved focus switching between search bar and credentials.
** Added keyboard shortcut (Ctrl/Cmd + F) for focus on the search bar.
** Select the top credential during search.

View File

@ -2,39 +2,28 @@
image:splash.png[]
Store your unique credential on a hardware-backed security key and take it
wherever you go from mobile to desktop. No more storing sensitive secrets on
your mobile phone, leaving your account vulnerable to takeovers. With the
Yubico Authenticator you can raise the bar for security.
* The Yubico Authenticator will work with any USB or NFC-enabled YubiKeys
The Yubico Authenticator securely generates a code used to verify your identity
as you are logging into various services. No connectivity needed!
Manage your YubiKey and access one-time passwords with this full-featured
companion app to the YubiKey.
=== Features include
* Secure - Hardware-backed strong two-factor authentication with secret stored
on the YubiKey, not on your phone or computer
* Portable - Get the same set of codes across our other Yubico Authenticator
apps for desktops as well as for all leading mobile platforms
* Flexible - Support for time-based and counter-based code generation
* USB or NFC usage - Insert the YubiKey into the USB port, or use the YubiKey
with NFC with a mobile phone that is NFC-enabled or a desktop NFC reader to
store your credential on the YubiKey
* Easy Setup - QR codes available from the services you wish to protect with
strong authentication
* User Presence - Require a touch on the YubiKey sensor to generate new codes
for sensitive accounts
* Compatible - Secure all the services currently compatible with other
Authenticator apps
* Versatile - Support for multiple work and personal accounts
* Display information about your YubiKey such as serial number, firmware version,
and supported capabilities
* Manage and access OATH one-time passwords stored securely on your YubiKey
* Configure PIN, fingerprints, and manage passkeys for WebAuthn/FIDO
* Configure PIN/PUK/Management key, and manage private keys and certificates for PIV
* Provision Yubico OTP, static passwords, and other YubiKey slot-based credentials
* Configure enabled features, and factory reset YubiKey data
* Compatible with any USB or NFC-enabled YubiKey
Store your unique credential on a hardware-backed security key and take it
wherever you go from mobile to desktop. No more storing sensitive secrets on
your mobile phone, leaving your accounts vulnerable to takeovers. With the
YubiKey and Yubico Authenticator you can raise the bar for security. No
connectivity needed!
Experience security the modern way with the Yubico Authenticator.
Visit https://yubico.com to learn more.
NOTE: Yubico Authenticator 6 uses a new codebase built using the Flutter
framework. The previous Qt codebase can be found in the `legacy` branch.
=== Supported platforms
*Supported* - these are platforms we build and test on and commit to supporting.
@ -45,8 +34,8 @@ framework. The previous Qt codebase can be found in the `legacy` branch.
||Supported|Best-effort
|Windows
|Windows 10 & above, 64-bit
|Windows 8.1, 64-bit
|Windows 10 & above, x64
|Windows 10 & above, x64
|macOS
|macOS 11 (Big Sur) & above
@ -54,19 +43,34 @@ framework. The previous Qt codebase can be found in the `legacy` branch.
|Linux
|Ubuntu 22.04 & above
|Ubuntu 18.04 (or equivalent)
|Ubuntu 20.04 (or equivalent)
|Android
|Android 11 & above
|Android 5 (Lollipop)
|===
On Linux systems, make sure the `pcscd` service is installed and running.
=== Installation
Downloads for all supported operating systems are available
https://www.yubico.com/products/yubico-authenticator/[here].
==== Linux
On Linux platforms you will need pcscd installed and running to be able to
communicate with a YubiKey over the SmartCard interface. Additionally, you may
need to set permissions for your user to access YubiKeys via the HID
interfaces. The relevant permissions are described
https://developers.yubico.com/yubikey-manager/Device_Permissions.html[here].
For some configurations running Wayland, copying an OTP to clipboard only works
when the app has focus. If you are unable to reliably copy to clipboard from
the systray icon, you can use a separate binary which take the payload to stdin
by defining the environment variable `_YA_TRAY_CLIPBOARD`. Note that this must
be an absolute path to a binary owned by root:root, and should not be
world-writable.
For example: `_YA_TRAY_CLIPBOARD=/usr/bin/wl-copy`.
NOTE: Only use a trusted binary, OTPs will be sent to this when copied to clipboard from the systray!
=== Command line interface
Looking for a command line option? Try our
https://github.com/Yubico/yubikey-manager/[YubiKey Manager CLI] tool.

View File

@ -22,10 +22,10 @@ linter:
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
unawaited_futures: true # Explicitly mark futures which are not being awaited
use_super_parameters: true
- prefer_single_quotes
- unawaited_futures # Explicitly mark futures which are not being awaited
- directives_ordering # Force ordering of imports
- prefer_relative_imports
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
@ -36,3 +36,8 @@ analyzer:
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"
- "build/**/intermediates/**/AndroidManifest.xml"
errors:
invalid_annotation_target: ignore # see https://github.com/rrousselGit/freezed/issues/488
plugins:
- custom_lint

View File

@ -1,3 +1,11 @@
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlinx-serialization'
id 'dev.flutter.flutter-gradle-plugin'
id 'com.google.android.gms.oss-licenses-plugin'
}
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
@ -6,31 +14,16 @@ if (localPropertiesFile.exists()) {
}
}
def flutterRoot = localProperties.getProperty('flutter.sdk')
if (flutterRoot == null) {
throw new FileNotFoundException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
//noinspection GroovyUnusedAssignment
flutterVersionCode = '1'
}
def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) {
//noinspection GroovyUnusedAssignment
flutterVersionName = '1.0'
}
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
apply plugin: 'com.google.android.gms.oss-licenses-plugin'
import com.android.build.OutputFile
android {
namespace 'com.yubico.authenticator'
@ -58,7 +51,6 @@ android {
versionName flutterVersionName
}
buildTypes {
release {
minifyEnabled true
@ -78,7 +70,7 @@ android {
applicationVariants.all { variant ->
variant.outputs.each { output ->
def abiCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, x86: 3, x86_64: 4]
def abiCode = abiCodes.get(output.getFilter(OutputFile.ABI))
def abiCode = abiCodes.get(output.getFilter(com.android.build.OutputFile.ABI))
output.versionCodeOverride = variant.versionCode * 10 + (abiCode != null ? abiCode : 0)
}
}
@ -99,17 +91,16 @@ dependencies {
api "com.yubico.yubikit:fido:$project.yubiKitVersion"
api "com.yubico.yubikit:support:$project.yubiKitVersion"
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-core:1.6.0'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3'
// Lifecycle
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3'
implementation "androidx.core:core-ktx:1.12.0"
implementation 'androidx.fragment:fragment-ktx:1.6.1'
implementation "androidx.core:core-ktx:1.13.1"
implementation 'androidx.fragment:fragment-ktx:1.8.1'
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'com.google.android.material:material:1.10.0'
implementation 'com.google.android.material:material:1.12.0'
implementation 'com.github.tony19:logback-android:3.0.0'

View File

@ -61,14 +61,14 @@ def collectLicenses(File rootDir, File ossPluginResDir, File outDir) {
// add zxing_licenses which are not detected
println "adding zxing licenses"
licenseList.add(PackageName: "ZXing Core (3.3.0)", PackageLicense: "https://www.apache.org/licenses/LICENSE-2.0.txt")
licenseList.add(PackageName: "ZXing Core (3.5.2)", PackageLicense: "https://www.apache.org/licenses/LICENSE-2.0.txt")
licenseList.add(PackageName: "ZXing Android Core (3.3.0)", PackageLicense: "https://www.apache.org/licenses/LICENSE-2.0.txt")
outFile.write(new JsonOutput().toJson(licenseList))
println "Created ${outFile.absolutePath}"
// copy license assets to flutter resources
def licensesDir = new File(rootDir, "licenses/");
def licensesDir = new File(rootDir, "licenses/")
copy {
from(licensesDir.absolutePath) {
include "**/*txt"
@ -82,7 +82,7 @@ def collectLicenses(File rootDir, File ossPluginResDir, File outDir) {
metadata.delete()
}
task collectLicenses() {
tasks.register('collectLicenses') {
dependsOn(":app:releaseOssLicensesTask")
doLast {
def ossPluginResDir = new File(project.buildDir, "generated/third_party_licenses/release/res/raw/")

View File

@ -37,7 +37,7 @@ class AppContext(messenger: BinaryMessenger, coroutineScope: CoroutineScope, pri
}
}
private suspend fun setContext(subPageIndex: Int): String {
private fun setContext(subPageIndex: Int): String {
val appContext = OperationContext.getByValue(subPageIndex)
appViewModel.setAppContext(appContext)
logger.debug("App context is now {}", appContext)

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 Yubico.
* Copyright (C) 2022,2024 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -21,7 +21,10 @@ import com.yubico.yubikit.core.YubiKeyDevice
/**
* Provides behavior to run when a YubiKey is inserted/tapped for a specific view of the app.
*/
interface AppContextManager {
suspend fun processYubiKey(device: YubiKeyDevice)
fun dispose()
abstract class AppContextManager {
abstract suspend fun processYubiKey(device: YubiKeyDevice)
open fun dispose() {}
open fun onPause() {}
}

View File

@ -33,6 +33,9 @@ class AppPreferences(context: Context) {
const val PREF_CLIP_KBD_LAYOUT = "flutter.prefClipKbdLayout"
const val DEFAULT_CLIP_KBD_LAYOUT = "US"
const val PREF_ENABLE_COMMUNITY_TRANSLATIONS =
"flutter.APP_STATE_ENABLE_COMMUNITY_TRANSLATIONS"
}
private val logger = LoggerFactory.getLogger(AppPreferences::class.java)
@ -66,6 +69,9 @@ class AppPreferences(context: Context) {
val openAppOnUsb: Boolean
get() = prefs.getBoolean(PREF_USB_OPEN_APP, false)
val communityTranslationsEnabled: Boolean
get() = prefs.getBoolean(PREF_ENABLE_COMMUNITY_TRANSLATIONS, false)
fun registerListener(listener: OnSharedPreferenceChangeListener) {
logger.debug("registering change listener")
prefs.registerOnSharedPreferenceChangeListener(listener)

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 Yubico.
* Copyright (C) 2022,2024 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -32,6 +32,16 @@ import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
interface JsonSerializable {
fun toJson() : String
}
sealed interface ViewModelData {
data object Empty : ViewModelData
data object Loading : ViewModelData
data class Value<T : JsonSerializable>(val data: T) : ViewModelData
}
/**
* Observes a LiveData value, sending each change to Flutter via an EventChannel.
*/
@ -61,6 +71,48 @@ inline fun <reified T> LiveData<T>.streamTo(lifecycleOwner: LifecycleOwner, mess
}
}
/**
* Observes a ViewModelData LiveData value, sending each change to Flutter via an EventChannel.
*/
@JvmName("streamViewModelData")
inline fun <reified T : ViewModelData> LiveData<T>.streamTo(lifecycleOwner: LifecycleOwner, messenger: BinaryMessenger, channelName: String): Closeable {
val channel = EventChannel(messenger, channelName)
var sink: EventChannel.EventSink? = null
val get: (ViewModelData) -> String = {
when (it) {
is ViewModelData.Empty -> NULL
is ViewModelData.Loading -> LOADING
is ViewModelData.Value<*> -> it.data.toJson()
}
}
channel.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
sink = events
events.success(
value?.let {
get(it)
} ?: NULL
)
}
override fun onCancel(arguments: Any?) {
sink = null
}
})
val observer = Observer<T> {
sink?.success(get(it))
}
observe(lifecycleOwner, observer)
return Closeable {
removeObserver(observer)
channel.setStreamHandler(null)
}
}
typealias MethodHandler = suspend (method: String, args: Map<String, Any?>) -> String
/**

View File

@ -37,7 +37,7 @@ import android.os.Build
* @param sdkVersion the version this instance uses for compatibility checking. The release app
* uses `Build.VERSION.SDK_INT`, tests use appropriate other values.
*/
@Suppress("MemberVisibilityCanBePrivate", "unused")
@Suppress("MemberVisibilityCanBePrivate")
class CompatUtil(private val sdkVersion: Int) {
/**
* Wrapper class holding values computed by [CompatUtil]

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 Yubico.
* Copyright (C) 2022,2024 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -20,6 +20,8 @@ import kotlinx.serialization.json.Json
const val NULL = "null"
const val LOADING = "\"loading\""
val jsonSerializer = Json {
// creates properties for default values
encodeDefaults = true

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2022-2023 Yubico.
* Copyright (C) 2022-2024 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -43,13 +43,19 @@ import androidx.activity.viewModels
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.lifecycle.lifecycleScope
import com.google.android.material.color.DynamicColors
import com.yubico.authenticator.device.DeviceManager
import com.yubico.authenticator.fido.FidoManager
import com.yubico.authenticator.fido.FidoViewModel
import com.yubico.authenticator.logging.FlutterLog
import com.yubico.authenticator.management.ManagementHandler
import com.yubico.authenticator.oath.AppLinkMethodChannel
import com.yubico.authenticator.oath.OathManager
import com.yubico.authenticator.oath.OathViewModel
import com.yubico.authenticator.yubikit.NfcActivityDispatcher
import com.yubico.authenticator.yubikit.NfcActivityListener
import com.yubico.authenticator.yubikit.NfcActivityState
import com.yubico.authenticator.yubikit.getDeviceInfo
import com.yubico.yubikit.android.YubiKitManager
import com.yubico.yubikit.android.transport.nfc.NfcConfiguration
import com.yubico.yubikit.android.transport.nfc.NfcNotAvailable
@ -72,8 +78,9 @@ import java.util.concurrent.Executors
class MainActivity : FlutterFragmentActivity() {
private val viewModel: MainViewModel by viewModels()
private val oathViewModel: OathViewModel by viewModels()
private val fidoViewModel: FidoViewModel by viewModels()
private val nfcConfiguration = NfcConfiguration()
private val nfcConfiguration = NfcConfiguration().timeout(2000)
private var hasNfc: Boolean = false
@ -129,9 +136,13 @@ class MainActivity : FlutterFragmentActivity() {
logger.debug("Starting nfc discovery")
yubikit.startNfcDiscovery(
nfcConfiguration.disableNfcDiscoverySound(appPreferences.silenceNfcSounds),
this,
::processYubiKey
)
this
) { nfcYubiKeyDevice ->
if (!deviceManager.isUsbKeyConnected()) {
launchProcessYubiKey(nfcYubiKeyDevice)
}
}
hasNfc = true
} catch (e: NfcNotAvailable) {
hasNfc = false
@ -152,7 +163,7 @@ class MainActivity : FlutterFragmentActivity() {
logger.debug("YubiKey was disconnected, stopping usb discovery")
stopUsbDiscovery()
}
processYubiKey(device)
launchProcessYubiKey(device)
}
}
@ -186,6 +197,8 @@ class MainActivity : FlutterFragmentActivity() {
override fun onPause() {
contextManager?.onPause()
appPreferences.unregisterListener(sharedPreferencesListener)
if (!preserveConnectionOnPause) {
@ -233,7 +246,7 @@ class MainActivity : FlutterFragmentActivity() {
val device = NfcYubiKeyDevice(tag, nfcConfiguration.timeout, executor)
lifecycleScope.launch {
try {
contextManager?.processYubiKey(device)
processYubiKey(device)
device.remove {
executor.shutdown()
startNfcDiscovery()
@ -246,7 +259,7 @@ class MainActivity : FlutterFragmentActivity() {
startNfcDiscovery()
}
val usbManager = getSystemService(Context.USB_SERVICE) as UsbManager
val usbManager = getSystemService(USB_SERVICE) as UsbManager
if (UsbManager.ACTION_USB_DEVICE_ATTACHED == intent.action) {
val device = intent.parcelableExtra<UsbDevice>(UsbManager.EXTRA_DEVICE)
if (device != null) {
@ -288,24 +301,53 @@ class MainActivity : FlutterFragmentActivity() {
}
}
private fun processYubiKey(device: YubiKeyDevice) {
private suspend fun processYubiKey(device: YubiKeyDevice) {
val deviceInfo = getDeviceInfo(device)
deviceManager.setDeviceInfo(deviceInfo)
if (deviceInfo == null) {
return
}
val supportedContexts = DeviceManager.getSupportedContexts(deviceInfo)
logger.debug("Connected key supports: {}", supportedContexts)
if (!supportedContexts.contains(viewModel.appContext.value)) {
val preferredContext = DeviceManager.getPreferredContext(supportedContexts)
logger.debug(
"Current context ({}) is not supported by the key. Using preferred context {}",
viewModel.appContext.value,
preferredContext
)
switchContext(preferredContext)
}
if (contextManager == null) {
switchContext(DeviceManager.getPreferredContext(supportedContexts))
}
contextManager?.let {
lifecycleScope.launch {
try {
it.processYubiKey(device)
if (device is NfcYubiKeyDevice) {
device.remove {
appMethodChannel.nfcActivityStateChanged(NfcActivityState.READY)
}
try {
it.processYubiKey(device)
if (device is NfcYubiKeyDevice) {
device.remove {
appMethodChannel.nfcActivityStateChanged(NfcActivityState.READY)
}
} catch (e: Throwable) {
logger.error("Error processing YubiKey in AppContextManager", e)
}
} catch (e: Throwable) {
logger.error("Error processing YubiKey in AppContextManager", e)
}
}
}
private fun launchProcessYubiKey(device: YubiKeyDevice) {
lifecycleScope.launch {
processYubiKey(device)
}
}
private var contextManager: AppContextManager? = null
private lateinit var deviceManager: DeviceManager
private lateinit var appContext: AppContext
private lateinit var dialogManager: DialogManager
private lateinit var appPreferences: AppPreferences
@ -313,18 +355,21 @@ class MainActivity : FlutterFragmentActivity() {
private lateinit var flutterStreams: List<Closeable>
private lateinit var appMethodChannel: AppMethodChannel
private lateinit var appLinkMethodChannel: AppLinkMethodChannel
private lateinit var messenger: BinaryMessenger
private lateinit var managementHandler: ManagementHandler
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
val messenger = flutterEngine.dartExecutor.binaryMessenger
messenger = flutterEngine.dartExecutor.binaryMessenger
flutterLog = FlutterLog(messenger)
deviceManager = DeviceManager(this, viewModel)
appContext = AppContext(messenger, this.lifecycleScope, viewModel)
dialogManager = DialogManager(messenger, this.lifecycleScope)
appPreferences = AppPreferences(this)
appMethodChannel = AppMethodChannel(messenger)
appLinkMethodChannel = AppLinkMethodChannel(messenger)
managementHandler = ManagementHandler(messenger, deviceManager, dialogManager)
nfcActivityListener.appMethodChannel = appMethodChannel
@ -332,29 +377,67 @@ class MainActivity : FlutterFragmentActivity() {
viewModel.deviceInfo.streamTo(this, messenger, "android.devices.deviceInfo"),
oathViewModel.sessionState.streamTo(this, messenger, "android.oath.sessionState"),
oathViewModel.credentials.streamTo(this, messenger, "android.oath.credentials"),
fidoViewModel.sessionState.streamTo(this, messenger, "android.fido.sessionState"),
fidoViewModel.credentials.streamTo(this, messenger, "android.fido.credentials"),
fidoViewModel.fingerprints.streamTo(this, messenger, "android.fido.fingerprints"),
fidoViewModel.resetState.streamTo(this, messenger, "android.fido.reset"),
fidoViewModel.registerFingerprint.streamTo(this, messenger, "android.fido.registerFp"),
)
viewModel.appContext.observe(this) {
switchContext(it)
viewModel.connectedYubiKey.value?.let(::launchProcessYubiKey)
}
}
private fun switchContext(appContext: OperationContext) {
// TODO: refactor this when more OperationContext are handled
// only recreate the contextManager object if it cannot be reused
if (appContext == OperationContext.Home ||
(appContext == OperationContext.Oath && contextManager is OathManager) ||
(appContext in listOf(
OperationContext.FidoPasskeys,
OperationContext.FidoFingerprints
) && contextManager is FidoManager)
) {
// no need to dispose this context
} else {
contextManager?.dispose()
contextManager = when (it) {
contextManager = null
}
if (contextManager == null) {
contextManager = when (appContext) {
OperationContext.Oath -> OathManager(
this,
messenger,
viewModel,
deviceManager,
oathViewModel,
dialogManager,
appPreferences,
nfcActivityListener
)
OperationContext.FidoFingerprints,
OperationContext.FidoPasskeys -> FidoManager(
messenger,
this,
deviceManager,
fidoViewModel,
viewModel,
dialogManager
)
else -> null
}
viewModel.connectedYubiKey.value?.let(::processYubiKey)
}
}
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
nfcActivityListener.appMethodChannel = null
flutterStreams.forEach { it.close() }
contextManager?.dispose()
deviceManager.dispose()
super.cleanUpFlutterEngine(flutterEngine)
}
@ -420,6 +503,11 @@ class MainActivity : FlutterFragmentActivity() {
methodCall.arguments as Boolean,
)
)
"getPrimaryColor" -> result.success(
getPrimaryColor(this@MainActivity)
)
"getAndroidSdkVersion" -> result.success(
Build.VERSION.SDK_INT
)
@ -445,7 +533,7 @@ class MainActivity : FlutterFragmentActivity() {
}
"hasCamera" -> {
val cameraService =
getSystemService(Context.CAMERA_SERVICE) as CameraManager
getSystemService(CAMERA_SERVICE) as CameraManager
result.success(
cameraService.cameraIdList.any {
cameraService.getCameraCharacteristics(it)
@ -467,6 +555,7 @@ class MainActivity : FlutterFragmentActivity() {
startActivity(Intent(ACTION_NFC_SETTINGS))
result.success(true)
}
else -> logger.warn("Unknown app method: {}", methodCall.method)
}
}
@ -502,6 +591,30 @@ class MainActivity : FlutterFragmentActivity() {
return FLAG_SECURE != (window.attributes.flags and FLAG_SECURE)
}
private fun getPrimaryColor(context: Context): Int? {
if (DynamicColors.isDynamicColorAvailable()) {
val dynamicColorContext = DynamicColors.wrapContextIfAvailable(
context,
com.google.android.material.R.style.ThemeOverlay_Material3_DynamicColors_DayNight
)
val typedArray = dynamicColorContext.obtainStyledAttributes(
intArrayOf(
android.R.attr.colorPrimary,
)
)
try {
return if (typedArray.hasValue(0))
typedArray.getColor(0, 0)
else
null
} finally {
typedArray.recycle()
}
}
return null
}
@SuppressLint("SourceLockedOrientationActivity")
private fun forcePortraitOrientation() {
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
@ -511,5 +624,5 @@ class MainActivity : FlutterFragmentActivity() {
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
private fun isPortraitOnly() = resources.getBoolean(R.bool.portrait_only);
private fun isPortraitOnly() = resources.getBoolean(R.bool.portrait_only)
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 Yubico.
* Copyright (C) 2022,2024 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -23,10 +23,20 @@ import com.yubico.authenticator.device.Info
import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice
enum class OperationContext(val value: Int) {
Oath(0), Yubikey(1), Invalid(-1);
Home(0),
Oath(1),
FidoU2f(2),
FidoFingerprints(3),
FidoPasskeys(4),
YubiOtp(5),
Piv(6),
OpenPgp(7),
HsmAuth(8),
Management(9),
Invalid(-1);
companion object {
fun getByValue(value: Int) = values().firstOrNull { it.value == value } ?: Invalid
fun getByValue(value: Int) = entries.firstOrNull { it.value == value } ?: Invalid
}
}
@ -35,14 +45,14 @@ class MainViewModel : ViewModel() {
val appContext: LiveData<OperationContext> = _appContext
fun setAppContext(appContext: OperationContext) {
// Don't reset the context unless it actually changes
if(appContext != _appContext.value) {
if (appContext != _appContext.value) {
_appContext.postValue(appContext)
}
}
private val _connectedYubiKey = MutableLiveData<UsbYubiKeyDevice?>()
val connectedYubiKey: LiveData<UsbYubiKeyDevice?> = _connectedYubiKey
fun setConnectedYubiKey(device: UsbYubiKeyDevice, onDisconnect: () -> Unit ) {
fun setConnectedYubiKey(device: UsbYubiKeyDevice, onDisconnect: () -> Unit) {
_connectedYubiKey.postValue(device)
device.setOnClosed {
_connectedYubiKey.postValue(null)

View File

@ -16,6 +16,7 @@
package com.yubico.authenticator
import android.annotation.TargetApi
import android.app.Activity
import android.content.Intent
import android.nfc.NdefMessage
@ -24,13 +25,12 @@ import android.nfc.Tag
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import com.yubico.authenticator.ndef.KeyboardLayout
import com.yubico.yubikit.core.util.NdefUtils
import org.slf4j.LoggerFactory
import java.nio.charset.StandardCharsets
import java.util.Locale
typealias ResourceId = Int
@ -39,6 +39,10 @@ class NdefActivity : Activity() {
private val logger = LoggerFactory.getLogger(NdefActivity::class.java)
companion object {
private val officialLocalization = arrayOf(Locale.JAPAN, Locale.FRANCE, Locale.US)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
appPreferences = AppPreferences(this)
@ -66,8 +70,8 @@ class NdefActivity : Activity() {
compatUtil.until(Build.VERSION_CODES.TIRAMISU) {
showToast(
when (otpSlotContent.type) {
OtpType.Otp -> R.string.otp_success_set_otp_to_clipboard
OtpType.Password -> R.string.otp_success_set_password_to_clipboard
OtpType.Otp -> R.string.p_ndef_set_otp
OtpType.Password -> R.string.p_ndef_set_password
}, Toast.LENGTH_SHORT
)
}
@ -77,16 +81,19 @@ class NdefActivity : Activity() {
illegalArgumentException.message ?: "Failure when handling YubiKey OTP",
illegalArgumentException
)
showToast(R.string.otp_parse_failure, Toast.LENGTH_LONG)
showToast(R.string.p_ndef_parse_failure, Toast.LENGTH_LONG)
} catch (_: UnsupportedOperationException) {
showToast(R.string.otp_set_clip_failure, Toast.LENGTH_LONG)
showToast(R.string.p_ndef_set_clip_failure, Toast.LENGTH_LONG)
}
}
if (appPreferences.openAppOnNfcTap) {
val mainAppIntent = Intent(this, MainActivity::class.java).apply {
// Pass the NFC Tag to the main Activity.
putExtra(NfcAdapter.EXTRA_TAG, intent.parcelableExtra<Tag>(NfcAdapter.EXTRA_TAG))
putExtra(
NfcAdapter.EXTRA_TAG,
intent.parcelableExtra<Tag>(NfcAdapter.EXTRA_TAG)
)
}
startActivity(mainAppIntent)
}
@ -96,9 +103,32 @@ class NdefActivity : Activity() {
}
private fun showToast(value: ResourceId, length: Int) {
Toast.makeText(this, value, length).show()
val context = if (appPreferences.communityTranslationsEnabled)
this
else {
val configuration = resources.configuration
configuration.setLocale(getLocale())
createConfigurationContext(configuration)
}
Toast.makeText(context, value, length).show()
}
private fun getLocale() : Locale =
compatUtil.from(Build.VERSION_CODES.N) {
getLocaleN()
}.otherwise {
@Suppress("deprecation")
officialLocalization.firstOrNull {
it == resources.configuration.locale
} ?: Locale.US
}
@TargetApi(Build.VERSION_CODES.N)
private fun getLocaleN() : Locale =
resources.configuration.locales.getFirstMatch(
officialLocalization.map { it.toLanguageTag() }.toTypedArray()
) ?: Locale.US
private fun parseOtpFromIntent(): OtpSlotValue {
val parcelable = intent.parcelableArrayExtra<NdefMessage>(NfcAdapter.EXTRA_NDEF_MESSAGES)
requireNotNull(parcelable) { "Null NDEF message" }

View File

@ -0,0 +1,175 @@
/*
* Copyright (C) 2024 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.yubico.authenticator.device
import androidx.collection.ArraySet
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
import com.yubico.authenticator.MainViewModel
import com.yubico.authenticator.OperationContext
import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice
import com.yubico.yubikit.core.YubiKeyDevice
import com.yubico.yubikit.management.Capability
import org.slf4j.LoggerFactory
interface DeviceListener {
// a USB device is connected
fun onConnected(device: YubiKeyDevice) {}
// a USB device is disconnected
fun onDisconnected() {}
// the app has been paused for more than DeviceManager.NFC_DATA_CLEANUP_DELAY
fun onTimeout() {}
}
class DeviceManager(
private val lifecycleOwner: LifecycleOwner,
private val appViewModel: MainViewModel
) {
var clearDeviceInfoOnDisconnect: Boolean = true
private val deviceListeners = HashSet<DeviceListener>()
fun addDeviceListener(listener: DeviceListener) {
deviceListeners.add(listener)
}
fun removeDeviceListener(listener: DeviceListener) {
deviceListeners.remove(listener)
}
companion object {
const val NFC_DATA_CLEANUP_DELAY = 30L * 1000 // 30s
private val logger = LoggerFactory.getLogger(DeviceManager::class.java)
private val capabilityContextMap = mapOf(
Capability.OATH to listOf(OperationContext.Oath),
Capability.FIDO2 to listOf(
OperationContext.FidoFingerprints,
OperationContext.FidoPasskeys
)
)
fun getSupportedContexts(deviceInfo: Info): ArraySet<OperationContext> {
val operationContexts = ArraySet<OperationContext>()
val capabilities = (
if (deviceInfo.isNfc)
deviceInfo.config.enabledCapabilities.nfc else
deviceInfo.config.enabledCapabilities.usb
) ?: 0
capabilityContextMap.forEach { entry ->
if (capabilities and entry.key.bit == entry.key.bit) {
operationContexts.addAll(entry.value)
}
}
logger.debug("Device supports following contexts: {}", operationContexts)
return operationContexts
}
fun getPreferredContext(contexts: ArraySet<OperationContext>): OperationContext {
// custom sort
for (context in contexts) {
if (context == OperationContext.Oath) {
return context
} else if (context == OperationContext.FidoPasskeys) {
return context
}
}
return OperationContext.Oath
}
}
private val lifecycleObserver = object : DefaultLifecycleObserver {
private var startTimeMs: Long = -1
override fun onPause(owner: LifecycleOwner) {
startTimeMs = currentTimeMs
super.onPause(owner)
}
override fun onResume(owner: LifecycleOwner) {
super.onResume(owner)
if (canInvoke) {
if (appViewModel.connectedYubiKey.value == null) {
// no USB YubiKey is connected, reset known data on resume
logger.debug("Removing NFC data after resume.")
if (clearDeviceInfoOnDisconnect) {
appViewModel.setDeviceInfo(null)
}
deviceListeners.forEach { listener ->
listener.onTimeout()
}
}
}
}
private val currentTimeMs
get() = System.currentTimeMillis()
private val canInvoke: Boolean
get() = startTimeMs != -1L && currentTimeMs - startTimeMs > NFC_DATA_CLEANUP_DELAY
}
private val usbObserver = Observer<UsbYubiKeyDevice?> { yubiKeyDevice ->
if (yubiKeyDevice == null) {
deviceListeners.forEach { listener ->
listener.onDisconnected()
}
if (clearDeviceInfoOnDisconnect) {
appViewModel.setDeviceInfo(null)
}
} else {
deviceListeners.forEach { listener ->
listener.onConnected(yubiKeyDevice)
}
}
}
init {
appViewModel.connectedYubiKey.observe(lifecycleOwner, usbObserver)
lifecycleOwner.lifecycle.addObserver(lifecycleObserver)
}
fun dispose() {
lifecycleOwner.lifecycle.removeObserver(lifecycleObserver)
appViewModel.connectedYubiKey.removeObserver(usbObserver)
}
fun setDeviceInfo(deviceInfo: Info?) {
appViewModel.setDeviceInfo(deviceInfo)
}
fun isUsbKeyConnected(): Boolean {
return appViewModel.connectedYubiKey.value != null
}
suspend fun <T> withKey(onUsb: suspend (UsbYubiKeyDevice) -> T) =
appViewModel.connectedYubiKey.value?.let {
onUsb(it)
}
suspend fun <T> withKey(onNfc: suspend () -> T, onUsb: suspend (UsbYubiKeyDevice) -> T) =
appViewModel.connectedYubiKey.value?.let {
onUsb(it)
} ?: onNfc()
}

View File

@ -49,8 +49,12 @@ data class Info(
val isNfc: Boolean,
@SerialName("usb_pid")
val usbPid: Int?,
@SerialName("pin_complexity")
val pinComplexity: Boolean,
@SerialName("supported_capabilities")
val supportedCapabilities: Capabilities
val supportedCapabilities: Capabilities,
@SerialName("fips_capable")
val fipsCapable: Int,
) {
constructor(name: String, isNfc: Boolean, usbPid: Int?, deviceInfo: DeviceInfo) : this(
config = Config(deviceInfo.config),
@ -63,9 +67,11 @@ data class Info(
name = name,
isNfc = isNfc,
usbPid = usbPid,
pinComplexity = deviceInfo.pinComplexity,
supportedCapabilities = Capabilities(
nfc = deviceInfo.capabilitiesFor(Transport.NFC),
usb = deviceInfo.capabilitiesFor(Transport.USB),
)
),
fipsCapable = deviceInfo.fipsCapable
)
}

View File

@ -1,5 +1,7 @@
package com.yubico.authenticator.device
import com.yubico.yubikit.core.Transport
import com.yubico.yubikit.management.Capability
import com.yubico.yubikit.management.FormFactor
val UnknownDevice = Info(
@ -18,5 +20,32 @@ val UnknownDevice = Info(
name = "Unrecognized device",
isNfc = false,
usbPid = null,
supportedCapabilities = Capabilities()
)
pinComplexity = false,
supportedCapabilities = Capabilities(),
fipsCapable = 0
)
fun unknownDeviceWithCapability(transport: Transport, bit: Int = 0) : Info {
val isNfc = transport == Transport.NFC
val capabilities = Capabilities(
nfc = if (isNfc) bit else null,
usb = if (!isNfc) bit else null
)
return UnknownDevice.copy(
isNfc = isNfc,
config = UnknownDevice.config.copy(enabledCapabilities = capabilities),
supportedCapabilities = capabilities
)
}
fun unknownOathDeviceInfo(transport: Transport) : Info {
return unknownDeviceWithCapability(transport, Capability.OATH.bit).copy(
name = "OATH device"
)
}
fun unknownFido2DeviceInfo(transport: Transport) : Info {
return unknownDeviceWithCapability(transport, Capability.FIDO2.bit).copy(
name = "FIDO2 device"
)
}

View File

@ -0,0 +1,33 @@
/*
* Copyright (C) 2024 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.yubico.authenticator.fido
const val dialogDescriptionFidoIndex = 200
enum class FidoActionDescription(private val value: Int) {
Reset(0),
Unlock(1),
SetPin(2),
DeleteCredential(3),
DeleteFingerprint(4),
RenameFingerprint(5),
RegisterFingerprint(6),
ActionFailure(7);
val id: Int
get() = value + dialogDescriptionFidoIndex
}

View File

@ -0,0 +1,100 @@
/*
* Copyright (C) 2024 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.yubico.authenticator.fido
import com.yubico.authenticator.DialogManager
import com.yubico.authenticator.DialogTitle
import com.yubico.authenticator.device.DeviceManager
import com.yubico.authenticator.fido.data.YubiKitFidoSession
import com.yubico.authenticator.yubikit.withConnection
import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice
import com.yubico.yubikit.core.fido.FidoConnection
import com.yubico.yubikit.core.util.Result
import org.slf4j.LoggerFactory
import kotlin.coroutines.cancellation.CancellationException
import kotlin.coroutines.suspendCoroutine
class FidoConnectionHelper(
private val deviceManager: DeviceManager,
private val dialogManager: DialogManager
) {
private var pendingAction: FidoAction? = null
fun invokePending(fidoSession: YubiKitFidoSession) {
pendingAction?.let { action ->
action.invoke(Result.success(fidoSession))
pendingAction = null
}
}
fun cancelPending() {
pendingAction?.let { action ->
action.invoke(Result.failure(CancellationException()))
pendingAction = null
}
}
suspend fun <T> useSession(
actionDescription: FidoActionDescription,
action: (YubiKitFidoSession) -> T
): T {
return deviceManager.withKey(
onNfc = { useSessionNfc(actionDescription,action) },
onUsb = { useSessionUsb(it, action) })
}
suspend fun <T> useSessionUsb(
device: UsbYubiKeyDevice,
block: (YubiKitFidoSession) -> T
): T = device.withConnection<FidoConnection, T> {
block(YubiKitFidoSession(it))
}
suspend fun <T> useSessionNfc(
actionDescription: FidoActionDescription,
block: (YubiKitFidoSession) -> T
): T {
try {
val result = suspendCoroutine { outer ->
pendingAction = {
outer.resumeWith(runCatching {
block.invoke(it.value)
})
}
dialogManager.showDialog(
DialogTitle.TapKey,
actionDescription.id
) {
logger.debug("Cancelled Dialog {}", actionDescription.name)
pendingAction?.invoke(Result.failure(CancellationException()))
pendingAction = null
}
}
return result
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: Throwable) {
throw error
} finally {
dialogManager.closeDialog()
}
}
companion object {
private val logger = LoggerFactory.getLogger(FidoConnectionHelper::class.java)
}
}

View File

@ -0,0 +1,616 @@
/*
* Copyright (C) 2024 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.yubico.authenticator.fido
import androidx.lifecycle.LifecycleOwner
import com.yubico.authenticator.AppContextManager
import com.yubico.authenticator.DialogManager
import com.yubico.authenticator.MainViewModel
import com.yubico.authenticator.NULL
import com.yubico.authenticator.asString
import com.yubico.authenticator.device.DeviceListener
import com.yubico.authenticator.device.DeviceManager
import com.yubico.authenticator.fido.data.FidoCredential
import com.yubico.authenticator.fido.data.FidoFingerprint
import com.yubico.authenticator.fido.data.Session
import com.yubico.authenticator.fido.data.SessionInfo
import com.yubico.authenticator.fido.data.YubiKitFidoSession
import com.yubico.authenticator.setHandler
import com.yubico.authenticator.yubikit.withConnection
import com.yubico.yubikit.android.transport.nfc.NfcYubiKeyDevice
import com.yubico.yubikit.core.YubiKeyConnection
import com.yubico.yubikit.core.YubiKeyDevice
import com.yubico.yubikit.core.application.CommandState
import com.yubico.yubikit.core.fido.CtapException
import com.yubico.yubikit.core.fido.FidoConnection
import com.yubico.yubikit.core.internal.Logger
import com.yubico.yubikit.core.smartcard.SmartCardConnection
import com.yubico.yubikit.core.util.Result
import com.yubico.yubikit.fido.ctap.BioEnrollment
import com.yubico.yubikit.fido.ctap.ClientPin
import com.yubico.yubikit.fido.ctap.CredentialManagement
import com.yubico.yubikit.fido.ctap.Ctap2Session.InfoData
import com.yubico.yubikit.fido.ctap.FingerprintBioEnrollment
import com.yubico.yubikit.fido.ctap.PinUvAuthDummyProtocol
import com.yubico.yubikit.fido.ctap.PinUvAuthProtocol
import com.yubico.yubikit.fido.ctap.PinUvAuthProtocolV1
import com.yubico.yubikit.fido.ctap.PinUvAuthProtocolV2
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancel
import org.json.JSONObject
import org.slf4j.LoggerFactory
import java.io.IOException
import java.util.Arrays
import java.util.concurrent.Executors
typealias FidoAction = (Result<YubiKitFidoSession, Exception>) -> Unit
class FidoManager(
messenger: BinaryMessenger,
lifecycleOwner: LifecycleOwner,
private val deviceManager: DeviceManager,
private val fidoViewModel: FidoViewModel,
mainViewModel: MainViewModel,
dialogManager: DialogManager,
) : AppContextManager(), DeviceListener {
@OptIn(ExperimentalStdlibApi::class)
private object HexCodec {
fun bytesToHexString(bytes: ByteArray) : String = bytes.toHexString()
fun hexStringToBytes(hex: String) : ByteArray = hex.hexToByteArray()
}
companion object {
fun getPreferredPinUvAuthProtocol(infoData: InfoData): PinUvAuthProtocol {
val pinUvAuthProtocols = infoData.pinUvAuthProtocols
val pinSupported = infoData.options["clientPin"] != null
if (pinSupported) {
for (protocol in pinUvAuthProtocols) {
if (protocol == PinUvAuthProtocolV1.VERSION) {
return PinUvAuthProtocolV1()
}
if (protocol == PinUvAuthProtocolV2.VERSION) {
return PinUvAuthProtocolV2()
}
}
}
return PinUvAuthDummyProtocol()
}
}
private val connectionHelper = FidoConnectionHelper(deviceManager, dialogManager)
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private val coroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
private val fidoChannel = MethodChannel(messenger, "android.fido.methods")
private val logger = LoggerFactory.getLogger(FidoManager::class.java)
private val pinStore = FidoPinStore()
private var pinRetries : Int? = null
private val resetHelper =
FidoResetHelper(
lifecycleOwner,
deviceManager,
fidoViewModel,
mainViewModel,
connectionHelper,
pinStore
)
init {
pinRetries = null
deviceManager.addDeviceListener(this)
fidoChannel.setHandler(coroutineScope) { method, args ->
when (method) {
"reset" -> resetHelper.reset()
"cancelReset" -> resetHelper.cancelReset()
"unlock" -> unlock(
(args["pin"] as String).toCharArray()
)
"setPin" -> setPin(
(args["pin"] as String?)?.toCharArray(),
(args["newPin"] as String).toCharArray(),
)
"deleteCredential" -> deleteCredential(
args["rpId"] as String,
args["credentialId"] as String
)
"deleteFingerprint" -> deleteFingerprint(
args["templateId"] as String
)
"renameFingerprint" -> renameFingerprint(
args["templateId"] as String,
args["name"] as String
)
"registerFingerprint" -> registerFingerprint(
args["name"] as String?,
)
"cancelRegisterFingerprint" -> cancelRegisterFingerprint()
else -> throw NotImplementedError()
}
}
}
override fun dispose() {
super.dispose()
deviceManager.removeDeviceListener(this)
fidoChannel.setMethodCallHandler(null)
fidoViewModel.clearSessionState()
fidoViewModel.updateCredentials(null)
coroutineScope.cancel()
}
override suspend fun processYubiKey(device: YubiKeyDevice) {
try {
if (device.supportsConnection(FidoConnection::class.java)) {
device.withConnection<FidoConnection, Unit> { connection ->
processYubiKey(connection, device)
}
} else {
device.withConnection<SmartCardConnection, Unit> { connection ->
processYubiKey(connection, device)
}
}
} catch (e: Exception) {
// something went wrong, try to get DeviceInfo from any available connection type
logger.error("Failure when processing YubiKey: ", e)
// Clear any cached FIDO state
fidoViewModel.clearSessionState()
}
}
private fun processYubiKey(connection: YubiKeyConnection, device: YubiKeyDevice) {
val fidoSession =
if (connection is FidoConnection) {
YubiKitFidoSession(connection)
} else {
YubiKitFidoSession(connection as SmartCardConnection)
}
val previousSession = fidoViewModel.currentSession()?.info
val currentSession = SessionInfo(fidoSession.cachedInfo)
logger.debug(
"Previous session: {}, current session: {}",
previousSession,
currentSession
)
val sameDevice = currentSession == previousSession
if (device is NfcYubiKeyDevice && (sameDevice || resetHelper.inProgress)) {
connectionHelper.invokePending(fidoSession)
} else {
if (!sameDevice) {
// different key
logger.debug("This is a different key than previous, invalidating the PIN token")
pinStore.setPin(null)
connectionHelper.cancelPending()
if (resetHelper.inProgress) {
logger.debug("Cannot reset this key")
resetHelper.cancelReset()
}
}
val infoData = fidoSession.cachedInfo
val clientPin =
ClientPin(fidoSession, getPreferredPinUvAuthProtocol(infoData))
pinRetries = if (infoData.options["clientPin"] == true) clientPin.pinRetries.count else null
fidoViewModel.setSessionState(
Session(infoData, pinStore.hasPin(), pinRetries)
)
}
}
private fun getPinPermissionsCM(fidoSession: YubiKitFidoSession): Int {
return if (CredentialManagement.isSupported(fidoSession.cachedInfo))
ClientPin.PIN_PERMISSION_CM else 0
}
private fun getPinPermissionsBE(fidoSession: YubiKitFidoSession): Int {
return if (BioEnrollment.isSupported(fidoSession.cachedInfo))
ClientPin.PIN_PERMISSION_BE else 0
}
private fun unlockSession(
fidoSession: YubiKitFidoSession,
clientPin: ClientPin,
pin: CharArray
): String {
val pinPermissionsCM = getPinPermissionsCM(fidoSession)
val pinPermissionsBE = getPinPermissionsBE(fidoSession)
val permissions = pinPermissionsCM or pinPermissionsBE
val token = if (permissions != 0) {
clientPin.getPinToken(pin, permissions, null)
} else {
clientPin.getPinToken(pin, permissions, "yubico-authenticator.example.com")
null
}
pinStore.setPin(pin)
pinRetries = clientPin.pinRetries.count
fidoViewModel.setSessionState(
Session(
fidoSession.info,
pinStore.hasPin(),
pinRetries
)
)
token?.let {
val credentials = getCredentials(fidoSession, clientPin, token)
logger.debug("Creds: {}", credentials)
fidoViewModel.updateCredentials(credentials)
if (pinPermissionsBE != 0) {
val fingerprints = getFingerprints(fidoSession, clientPin, token)
logger.debug("Fingerprints: {}", fingerprints)
fidoViewModel.updateFingerprints(fingerprints)
}
}
return JSONObject(mapOf("success" to true)).toString()
}
private fun catchPinErrors(
fidoSession: YubiKitFidoSession,
clientPin: ClientPin,
block: () -> String
): String =
try {
block()
} catch (ctapException: CtapException) {
if (ctapException.ctapError == CtapException.ERR_PIN_INVALID ||
ctapException.ctapError == CtapException.ERR_PIN_BLOCKED ||
ctapException.ctapError == CtapException.ERR_PIN_AUTH_BLOCKED ||
ctapException.ctapError == CtapException.ERR_PIN_POLICY_VIOLATION
) {
pinStore.setPin(null)
fidoViewModel.updateCredentials(null)
pinRetries = clientPin.pinRetries.count
fidoViewModel.setSessionState(
Session(
fidoSession.info,
pinStore.hasPin(),
pinRetries
)
)
if (ctapException.ctapError == CtapException.ERR_PIN_POLICY_VIOLATION) {
JSONObject(
mapOf(
"success" to false,
"pinViolation" to true
)
).toString()
} else {
JSONObject(
mapOf(
"success" to false,
"pinRetries" to pinRetries,
"authBlocked" to (ctapException.ctapError == CtapException.ERR_PIN_AUTH_BLOCKED),
)
).toString()
}
} else {
throw ctapException
}
}
private suspend fun unlock(pin: CharArray): String =
connectionHelper.useSession(FidoActionDescription.Unlock) { fidoSession ->
try {
val clientPin =
ClientPin(fidoSession, getPreferredPinUvAuthProtocol(fidoSession.cachedInfo))
catchPinErrors(fidoSession, clientPin) {
unlockSession(fidoSession, clientPin, pin)
}
} catch (e: IOException) {
// something failed, keep the session locked
fidoViewModel.currentSession()?.let {
fidoViewModel.setSessionState(it.copy(info = it.info, unlocked = false))
}
throw e
} finally {
Arrays.fill(pin, 0.toChar())
}
}
private fun setOrChangePin(
fidoSession: YubiKitFidoSession,
clientPin: ClientPin,
pin: CharArray?,
newPin: CharArray
) {
val infoData = fidoSession.cachedInfo
val hasPin = infoData.options["clientPin"] == true
if (hasPin) {
clientPin.changePin(pin!!, newPin)
} else {
clientPin.setPin(newPin)
}
}
private suspend fun setPin(pin: CharArray?, newPin: CharArray): String =
connectionHelper.useSession(FidoActionDescription.SetPin) { fidoSession ->
try {
val clientPin =
ClientPin(fidoSession, getPreferredPinUvAuthProtocol(fidoSession.cachedInfo))
catchPinErrors(fidoSession, clientPin) {
setOrChangePin(fidoSession, clientPin, pin, newPin)
unlockSession(fidoSession, clientPin, newPin)
}
} finally {
Arrays.fill(newPin, 0.toChar())
pin?.let {
Arrays.fill(it, 0.toChar())
}
}
}
private fun getCredentials(
fidoSession: YubiKitFidoSession,
clientPin: ClientPin,
pinUvAuthToken: ByteArray
): List<FidoCredential> =
try {
fidoViewModel.updateCredentials(null)
val credMan = CredentialManagement(fidoSession, clientPin.pinUvAuth, pinUvAuthToken)
val rpIds = credMan.enumerateRps()
val credentials = rpIds.map { rpData ->
credMan.enumerateCredentials(rpData.rpIdHash).map { credentialData ->
FidoCredential(
rpData.rp["id"] as String,
(credentialData.credentialId["id"] as ByteArray).asString(),
(credentialData.user["id"] as ByteArray).asString(),
credentialData.user["name"] as String,
publicKeyCredentialDescriptor = credentialData.credentialId,
displayName = credentialData.user["displayName"] as String?,
)
}
}.reduceOrNull { credentials, credentialList ->
credentials + credentialList
}
credentials ?: emptyList()
} finally {
}
private suspend fun deleteCredential(rpId: String, credentialId: String): String =
connectionHelper.useSession(FidoActionDescription.DeleteCredential) { fidoSession ->
val clientPin =
ClientPin(fidoSession, getPreferredPinUvAuthProtocol(fidoSession.cachedInfo))
val permissions = getPinPermissionsCM(fidoSession)
val token = clientPin.getPinToken(pinStore.getPin(), permissions, null)
val credMan = CredentialManagement(fidoSession, clientPin.pinUvAuth, token)
val credentialDescriptor =
fidoViewModel.credentials.value?.firstOrNull {
it.credentialId == credentialId && it.rpId == rpId
}?.publicKeyCredentialDescriptor
credentialDescriptor?.let {
credMan.deleteCredential(credentialDescriptor)
fidoViewModel.removeCredential(rpId, credentialId)
return@useSession JSONObject(
mapOf(
"success" to true,
)
).toString()
}
// could not find the credential to delete
JSONObject(
mapOf(
"success" to false,
)
).toString()
}
private fun getFingerprints(
fidoSession: YubiKitFidoSession,
clientPin: ClientPin,
pinUvAuthToken: ByteArray
): List<FidoFingerprint> {
val bioEnrollment =
FingerprintBioEnrollment(fidoSession, clientPin.pinUvAuth, pinUvAuthToken)
val enrollments: Map<ByteArray, String?> = bioEnrollment.enumerateEnrollments()
return enrollments.map { enrollment ->
FidoFingerprint(HexCodec.bytesToHexString(enrollment.key), enrollment.value)
}
}
private suspend fun deleteFingerprint(templateId: String): String =
connectionHelper.useSession(FidoActionDescription.DeleteFingerprint) { fidoSession ->
val clientPin =
ClientPin(fidoSession, getPreferredPinUvAuthProtocol(fidoSession.cachedInfo))
val token =
clientPin.getPinToken(
pinStore.getPin(),
getPinPermissionsBE(fidoSession),
null
)
val bioEnrollment = FingerprintBioEnrollment(fidoSession, clientPin.pinUvAuth, token)
bioEnrollment.removeEnrollment(HexCodec.hexStringToBytes(templateId))
fidoViewModel.removeFingerprint(templateId)
fidoViewModel.setSessionState(Session(fidoSession.info, pinStore.hasPin(), pinRetries))
return@useSession JSONObject(
mapOf(
"success" to true,
)
).toString()
}
private suspend fun renameFingerprint(templateId: String, name: String): String =
connectionHelper.useSession(FidoActionDescription.RenameFingerprint) { fidoSession ->
val clientPin =
ClientPin(fidoSession, getPreferredPinUvAuthProtocol(fidoSession.cachedInfo))
val token =
clientPin.getPinToken(
pinStore.getPin(),
getPinPermissionsBE(fidoSession),
null
)
val bioEnrollment = FingerprintBioEnrollment(fidoSession, clientPin.pinUvAuth, token)
bioEnrollment.setName(HexCodec.hexStringToBytes(templateId), name)
fidoViewModel.renameFingerprint(templateId, name)
fidoViewModel.setSessionState(Session(fidoSession.info, pinStore.hasPin(), pinRetries))
return@useSession JSONObject(
mapOf(
"success" to true,
)
).toString()
}
private var state : CommandState? = null
private fun cancelRegisterFingerprint(): String {
state?.cancel()
return NULL
}
private suspend fun registerFingerprint(name: String?): String =
connectionHelper.useSession(FidoActionDescription.RegisterFingerprint) { fidoSession ->
state?.cancel()
state = CommandState()
val clientPin =
ClientPin(fidoSession, getPreferredPinUvAuthProtocol(fidoSession.cachedInfo))
val token =
clientPin.getPinToken(
pinStore.getPin(),
getPinPermissionsBE(fidoSession),
null
)
val bioEnrollment = FingerprintBioEnrollment(fidoSession, clientPin.pinUvAuth, token)
val fingerprintEnrollmentContext = bioEnrollment.enroll(null)
var templateId: ByteArray? = null
while (templateId == null) {
try {
templateId = fingerprintEnrollmentContext.capture(state)
fidoViewModel.updateRegisterFpState(
createCaptureEvent(fingerprintEnrollmentContext.remaining!!)
)
} catch (captureError: FingerprintBioEnrollment.CaptureError) {
fidoViewModel.updateRegisterFpState(createCaptureErrorEvent(captureError.code))
} catch (ctapException: CtapException) {
when (ctapException.ctapError) {
CtapException.ERR_KEEPALIVE_CANCEL -> {
fingerprintEnrollmentContext.cancel()
return@useSession JSONObject(
mapOf(
"success" to false,
"status" to "user-cancelled"
)
).toString()
}
CtapException.ERR_USER_ACTION_TIMEOUT -> {
fingerprintEnrollmentContext.cancel()
return@useSession JSONObject(
mapOf(
"success" to false,
"status" to "user-action-timeout"
)
).toString()
}
else -> throw ctapException
}
} catch (io: IOException) {
return@useSession JSONObject(
mapOf(
"success" to false,
"status" to "connection-error"
)
).toString()
}
}
if (!name.isNullOrBlank()) {
bioEnrollment.setName(templateId, name)
Logger.debug(logger, "Set name to {}", name)
}
val templateIdHexString = HexCodec.bytesToHexString(templateId)
fidoViewModel.addFingerprint(FidoFingerprint(templateIdHexString, name))
fidoViewModel.setSessionState(Session(fidoSession.info, pinStore.hasPin(), pinRetries))
return@useSession JSONObject(
mapOf(
"success" to true,
"template_id" to templateIdHexString,
"name" to name
)
).toString()
}
override fun onDisconnected() {
if (!resetHelper.inProgress) {
fidoViewModel.clearSessionState()
}
}
override fun onTimeout() {
fidoViewModel.clearSessionState()
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 Yubico.
* Copyright (C) 2024 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -14,11 +14,21 @@
* limitations under the License.
*/
import 'package:flutter/material.dart';
package com.yubico.authenticator.fido
final Image noAccounts = _graphic('no-accounts');
final Image noFingerprints = _graphic('no-fingerprints');
final Image noPermission = _graphic('no-permission');
final Image manageAccounts = _graphic('manage-accounts');
class FidoPinStore {
private var pin: CharArray? = null
Image _graphic(String name) => Image.asset('assets/graphics/$name.png');
fun hasPin(): Boolean {
return pin != null
}
fun getPin(): CharArray {
return pin!!
}
fun setPin(newPin: CharArray?) {
pin?.fill(0.toChar())
pin = newPin?.clone()
}
}

View File

@ -0,0 +1,237 @@
/*
* Copyright (C) 2024 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.yubico.authenticator.fido
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.yubico.authenticator.MainViewModel
import com.yubico.authenticator.NULL
import com.yubico.authenticator.device.DeviceManager
import com.yubico.authenticator.fido.data.Session
import com.yubico.authenticator.fido.data.YubiKitFidoSession
import com.yubico.yubikit.core.application.CommandState
import com.yubico.yubikit.core.fido.CtapException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import org.slf4j.LoggerFactory
import java.io.IOException
import java.util.concurrent.Executors
import kotlin.coroutines.cancellation.CancellationException
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
enum class FidoResetState(val value: String) {
Remove("remove"),
Insert("insert"),
Touch("touch")
}
@Serializable
sealed class FidoRegisterFpEvent(val status: String)
@Serializable
data class FidoRegisterFpCaptureEvent(private val remaining: Int) : FidoRegisterFpEvent("capture")
@Serializable
data class FidoRegisterFpCaptureErrorEvent(val code: Int) : FidoRegisterFpEvent("capture-error")
fun createCaptureEvent(remaining: Int): FidoRegisterFpCaptureEvent {
return FidoRegisterFpCaptureEvent(remaining)
}
fun createCaptureErrorEvent(code: Int) : FidoRegisterFpCaptureErrorEvent {
return FidoRegisterFpCaptureErrorEvent(code)
}
class FidoResetHelper(
private val lifecycleOwner: LifecycleOwner,
private val deviceManager: DeviceManager,
private val fidoViewModel: FidoViewModel,
private val mainViewModel: MainViewModel,
private val connectionHelper: FidoConnectionHelper,
private val pinStore: FidoPinStore
) {
var inProgress = false
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private val coroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
private var resetCommandState: CommandState? = null
private var cancelReset: Boolean = false
private val lifecycleObserver = object : DefaultLifecycleObserver {
override fun onStop(owner: LifecycleOwner) {
super.onStop(owner)
if (inProgress) {
logger.debug("Cancelling ongoing reset")
cancelReset()
}
}
}
suspend fun reset(): String {
try {
withContext(Dispatchers.Main) {
lifecycleOwner.lifecycle.addObserver(lifecycleObserver)
}
deviceManager.clearDeviceInfoOnDisconnect = false
inProgress = true
fidoViewModel.updateResetState(FidoResetState.Remove)
val usb = deviceManager.isUsbKeyConnected()
if (usb) {
resetOverUSB()
} else {
resetOverNfc()
}
logger.info("FIDO reset complete")
} catch (e: CancellationException) {
logger.debug("FIDO reset cancelled")
} finally {
withContext(Dispatchers.Main) {
lifecycleOwner.lifecycle.removeObserver(lifecycleObserver)
}
inProgress = false
deviceManager.clearDeviceInfoOnDisconnect = true
}
return NULL
}
fun cancelReset(): String {
cancelReset = true
resetCommandState?.cancel()
inProgress = false
return NULL
}
private suspend fun waitForUsbDisconnect() = suspendCoroutine { continuation ->
coroutineScope.launch {
cancelReset = false
while (deviceManager.isUsbKeyConnected()) {
if (cancelReset) {
logger.debug("Reset was cancelled while waiting for YubiKey to be disconnected")
continuation.resumeWithException(CancellationException())
return@launch
}
logger.debug("Waiting for YubiKey to be disconnected")
delay(300)
}
continuation.resume(Unit)
}
}
private suspend fun waitForConnection() = suspendCoroutine { continuation ->
coroutineScope.launch {
fidoViewModel.updateResetState(FidoResetState.Insert)
while (!deviceManager.isUsbKeyConnected()) {
if (cancelReset) {
// the key is not connected, clean device info
mainViewModel.setDeviceInfo(null)
continuation.resumeWithException(CancellationException())
return@launch
}
logger.debug("Waiting for YubiKey to be connected")
delay(300)
}
continuation.resume(Unit)
}
}
private suspend fun resetAfterTouch() = suspendCoroutine { continuation ->
coroutineScope.launch(Dispatchers.Main) {
fidoViewModel.updateResetState(FidoResetState.Touch)
logger.debug("Waiting for touch")
deviceManager.withKey { usbYubiKeyDevice ->
connectionHelper.useSessionUsb(usbYubiKeyDevice) { fidoSession ->
resetCommandState = CommandState()
try {
if (cancelReset) {
continuation.resumeWithException(CancellationException())
} else {
doReset(fidoSession)
continuation.resume(Unit)
}
} catch (e: CtapException) {
when (e.ctapError) {
CtapException.ERR_KEEPALIVE_CANCEL -> {
logger.debug("Received ERR_KEEPALIVE_CANCEL during FIDO reset")
}
CtapException.ERR_ACTION_TIMEOUT -> {
logger.debug("Received ERR_ACTION_TIMEOUT during FIDO reset")
}
else -> {
logger.error("Received CtapException during FIDO reset: ", e)
}
}
continuation.resumeWithException(CancellationException())
} catch (e: IOException) {
// communication error, key was removed?
logger.error("IOException during FIDO reset: ", e)
// treat it as cancellation
continuation.resumeWithException(CancellationException())
} finally {
resetCommandState = null
}
}
}
}
}
private suspend fun resetOverUSB() {
waitForUsbDisconnect()
waitForConnection()
resetAfterTouch()
}
private suspend fun resetOverNfc() = suspendCoroutine { continuation ->
coroutineScope.launch {
fidoViewModel.updateResetState(FidoResetState.Touch)
try {
connectionHelper.useSessionNfc(FidoActionDescription.Reset) { fidoSession ->
doReset(fidoSession)
continuation.resume(Unit)
}
} catch (e: Throwable) {
// on NFC, clean device info in this situation
mainViewModel.setDeviceInfo(null)
continuation.resumeWithException(e)
}
}
}
private fun doReset(fidoSession: YubiKitFidoSession) {
logger.debug("Calling FIDO reset")
fidoSession.reset(resetCommandState)
fidoViewModel.setSessionState(Session(fidoSession.info, true, null))
fidoViewModel.updateCredentials(emptyList())
pinStore.setPin(null)
}
companion object {
private val logger = LoggerFactory.getLogger(FidoResetHelper::class.java)
}
}

View File

@ -0,0 +1,92 @@
/*
* Copyright (C) 2024 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.yubico.authenticator.fido
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.yubico.authenticator.ViewModelData
import com.yubico.authenticator.fido.data.FidoCredential
import com.yubico.authenticator.fido.data.FidoFingerprint
import com.yubico.authenticator.fido.data.Session
class FidoViewModel : ViewModel() {
private val _sessionState = MutableLiveData<ViewModelData>()
val sessionState: LiveData<ViewModelData> = _sessionState
fun currentSession() : Session? = (_sessionState.value as? ViewModelData.Value<*>)?.data as? Session?
fun setSessionState(sessionState: Session) {
_sessionState.postValue(ViewModelData.Value(sessionState))
}
fun clearSessionState() {
_sessionState.postValue(ViewModelData.Empty)
}
private val _credentials = MutableLiveData<List<FidoCredential>?>()
val credentials: LiveData<List<FidoCredential>?> = _credentials
fun updateCredentials(credentials: List<FidoCredential>?) {
_credentials.postValue(credentials)
}
fun removeCredential(rpId: String, credentialId: String) {
_credentials.postValue(_credentials.value?.filter {
it.credentialId != credentialId || it.rpId != rpId
})
}
private val _resetState = MutableLiveData(FidoResetState.Remove.value)
val resetState: LiveData<String> = _resetState
fun updateResetState(resetState: FidoResetState) {
_resetState.postValue(resetState.value)
}
private val _fingerprints = MutableLiveData<List<FidoFingerprint>>()
val fingerprints: LiveData<List<FidoFingerprint>> = _fingerprints
fun updateFingerprints(fingerprints: List<FidoFingerprint>) {
_fingerprints.postValue(fingerprints)
}
fun addFingerprint(fingerprint: FidoFingerprint) {
_fingerprints.postValue(_fingerprints.value?.plus(fingerprint))
}
fun removeFingerprint(templateId: String) {
_fingerprints.postValue(_fingerprints.value?.filter {
it.templateId != templateId
})
}
fun renameFingerprint(templateId: String, name: String) {
_fingerprints.postValue(_fingerprints.value?.map {
if (it.templateId == templateId) {
FidoFingerprint(templateId, name)
} else it
})
}
private val _registerFingerprint = MutableLiveData<FidoRegisterFpEvent>()
val registerFingerprint: LiveData<FidoRegisterFpEvent> = _registerFingerprint
fun updateRegisterFpState(registerFpState: FidoRegisterFpEvent) {
_registerFingerprint.postValue(registerFpState)
}
}

View File

@ -0,0 +1,37 @@
/*
* Copyright (C) 2024 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.yubico.authenticator.fido.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
@Serializable
data class FidoCredential(
@SerialName("rp_id")
val rpId: String,
@SerialName("credential_id")
val credentialId: String,
@SerialName("user_id")
val userId: String,
@SerialName("user_name")
val userName: String,
@SerialName("display_name")
val displayName: String?,
@Transient
val publicKeyCredentialDescriptor: Map<String, Any?> = emptyMap()
)

View File

@ -0,0 +1,28 @@
/*
* Copyright (C) 2024 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.yubico.authenticator.fido.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class FidoFingerprint(
@SerialName("template_id")
val templateId: String,
@SerialName("name")
val name: String?
)

View File

@ -0,0 +1,108 @@
/*
* Copyright (C) 2024 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.yubico.authenticator.fido.data
import com.yubico.authenticator.JsonSerializable
import com.yubico.authenticator.jsonSerializer
import com.yubico.yubikit.fido.ctap.Ctap2Session.InfoData
import kotlinx.serialization.*
typealias YubiKitFidoSession = com.yubico.yubikit.fido.ctap.Ctap2Session
@Serializable
data class Options(
val clientPin: Boolean,
val credMgmt: Boolean,
val credentialMgmtPreview: Boolean,
val bioEnroll: Boolean?,
val alwaysUv: Boolean
) {
constructor(infoData: InfoData) : this(
infoData.getOptionsBoolean("clientPin") == true,
infoData.getOptionsBoolean("credMgmt") == true,
infoData.getOptionsBoolean("credentialMgmtPreview") == true,
infoData.getOptionsBoolean("bioEnroll"),
infoData.getOptionsBoolean("alwaysUv") == true,
)
companion object {
private fun InfoData.getOptionsBoolean(
key: String
): Boolean? = options[key] as? Boolean?
}
}
@Serializable
data class SessionInfo(
val options: Options,
val aaguid: ByteArray,
@SerialName("min_pin_length")
val minPinLength: Int,
@SerialName("force_pin_change")
val forcePinChange: Boolean,
@SerialName("remaining_disc_creds")
val remainingDiscoverableCredentials: Int?
) {
constructor(infoData: InfoData) : this(
Options(infoData),
infoData.aaguid,
infoData.minPinLength,
infoData.forcePinChange,
infoData.remainingDiscoverableCredentials
)
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as SessionInfo
if (options != other.options) return false
if (!aaguid.contentEquals(other.aaguid)) return false
if (minPinLength != other.minPinLength) return false
if (forcePinChange != other.forcePinChange) return false
if (remainingDiscoverableCredentials != other.remainingDiscoverableCredentials) return false
return true
}
override fun hashCode(): Int {
var result = options.hashCode()
result = 31 * result + aaguid.contentHashCode()
result = 31 * result + minPinLength
result = 31 * result + forcePinChange.hashCode()
result = 31 * result + (remainingDiscoverableCredentials ?: 0)
return result
}
}
@Serializable
data class Session(
val info: SessionInfo,
val unlocked: Boolean,
@SerialName("pin_retries")
val pinRetries: Int?
) : JsonSerializable {
constructor(infoData: InfoData, unlocked: Boolean, pinRetries: Int?) : this(
SessionInfo(infoData), unlocked, pinRetries
)
override fun toJson(): String {
return jsonSerializer.encodeToString(this)
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2022-2023 Yubico.
* Copyright (C) 2022-2024 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -70,7 +70,7 @@ class FlutterLog(messenger: BinaryMessenger) {
}
private fun logLevelFromArgument(argValue: String?): Log.LogLevel? =
Log.LogLevel.values().firstOrNull { it.name == argValue?.uppercase() }
Log.LogLevel.entries.firstOrNull { it.name == argValue?.uppercase() }
private fun loggerError(message: String) {
log(Log.LogLevel.ERROR,"FlutterLog", message, null)

View File

@ -0,0 +1,88 @@
/*
* Copyright (C) 2024 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.yubico.authenticator.management
import com.yubico.authenticator.DialogManager
import com.yubico.authenticator.DialogTitle
import com.yubico.authenticator.device.DeviceManager
import com.yubico.authenticator.yubikit.withConnection
import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice
import com.yubico.yubikit.core.smartcard.SmartCardConnection
import com.yubico.yubikit.core.util.Result
import org.slf4j.LoggerFactory
import kotlin.coroutines.cancellation.CancellationException
import kotlin.coroutines.suspendCoroutine
typealias YubiKitManagementSession = com.yubico.yubikit.management.ManagementSession
typealias ManagementAction = (Result<YubiKitManagementSession, Exception>) -> Unit
class ManagementConnectionHelper(
private val deviceManager: DeviceManager,
private val dialogManager: DialogManager
) {
private var action: ManagementAction? = null
suspend fun <T> useSession(
actionDescription: ManagementActionDescription,
action: (YubiKitManagementSession) -> T
): T {
return deviceManager.withKey(
onNfc = { useSessionNfc(actionDescription, action) },
onUsb = { useSessionUsb(it, action) })
}
private suspend fun <T> useSessionUsb(
device: UsbYubiKeyDevice,
block: (YubiKitManagementSession) -> T
): T = device.withConnection<SmartCardConnection, T> {
block(YubiKitManagementSession(it))
}
private suspend fun <T> useSessionNfc(
actionDescription: ManagementActionDescription,
block: (YubiKitManagementSession) -> T
): T {
try {
val result = suspendCoroutine { outer ->
action = {
outer.resumeWith(runCatching {
block.invoke(it.value)
})
}
dialogManager.showDialog(
DialogTitle.TapKey,
actionDescription.id
) {
logger.debug("Cancelled Dialog {}", actionDescription.name)
action?.invoke(Result.failure(CancellationException()))
action = null
}
}
return result
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: Throwable) {
throw error
} finally {
dialogManager.closeDialog()
}
}
companion object {
private val logger = LoggerFactory.getLogger(ManagementConnectionHelper::class.java)
}
}

View File

@ -0,0 +1,65 @@
/*
* Copyright (C) 2024 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.yubico.authenticator.management
import com.yubico.authenticator.DialogManager
import com.yubico.authenticator.NULL
import com.yubico.authenticator.device.DeviceManager
import com.yubico.authenticator.setHandler
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import java.util.concurrent.Executors
const val dialogDescriptionManagementIndex = 300
enum class ManagementActionDescription(private val value: Int) {
DeviceReset(0), ActionFailure(1);
val id: Int
get() = value + dialogDescriptionManagementIndex
}
class ManagementHandler(
messenger: BinaryMessenger,
deviceManager: DeviceManager,
dialogManager: DialogManager
) {
private val channel = MethodChannel(messenger, "android.management.methods")
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private val coroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
private val connectionHelper = ManagementConnectionHelper(deviceManager, dialogManager)
init {
channel.setHandler(coroutineScope) { method, _ ->
when (method) {
"deviceReset" -> deviceReset()
else -> throw NotImplementedError()
}
}
}
private suspend fun deviceReset(): String =
connectionHelper.useSession(ManagementActionDescription.DeviceReset) { managementSession ->
managementSession.deviceReset()
NULL
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2022-2023 Yubico.
* Copyright (C) 2022-2024 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -19,13 +19,13 @@ package com.yubico.authenticator.oath
import android.annotation.TargetApi
import android.content.Context
import android.os.Build
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
import com.yubico.authenticator.*
import com.yubico.authenticator.device.Capabilities
import com.yubico.authenticator.device.Info
import com.yubico.authenticator.device.DeviceListener
import com.yubico.authenticator.device.DeviceManager
import com.yubico.authenticator.device.UnknownDevice
import com.yubico.authenticator.oath.data.Code
import com.yubico.authenticator.oath.data.CodeType
@ -50,14 +50,13 @@ import com.yubico.yubikit.android.transport.nfc.NfcYubiKeyDevice
import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice
import com.yubico.yubikit.core.Transport
import com.yubico.yubikit.core.YubiKeyDevice
import com.yubico.yubikit.core.application.ApplicationNotAvailableException
import com.yubico.yubikit.core.smartcard.ApduException
import com.yubico.yubikit.core.smartcard.AppId
import com.yubico.yubikit.core.smartcard.SW
import com.yubico.yubikit.core.smartcard.SmartCardConnection
import com.yubico.yubikit.core.smartcard.SmartCardProtocol
import com.yubico.yubikit.core.util.Result
import com.yubico.yubikit.oath.CredentialData
import com.yubico.yubikit.support.DeviceUtil
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.*
@ -74,15 +73,15 @@ typealias OathAction = (Result<YubiKitOathSession, Exception>) -> Unit
class OathManager(
private val lifecycleOwner: LifecycleOwner,
messenger: BinaryMessenger,
private val appViewModel: MainViewModel,
private val deviceManager: DeviceManager,
private val oathViewModel: OathViewModel,
private val dialogManager: DialogManager,
private val appPreferences: AppPreferences,
private val nfcActivityListener: NfcActivityListener
) : AppContextManager {
) : AppContextManager(), DeviceListener {
companion object {
const val NFC_DATA_CLEANUP_DELAY = 30L * 1000 // 30s
val OTP_AID = byteArrayOf(0xa0.toByte(), 0x00, 0x00, 0x05, 0x27, 0x20, 0x01, 0x01)
private val memoryKeyProvider = ClearingMemProvider()
}
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
@ -90,7 +89,6 @@ class OathManager(
private val oathChannel = MethodChannel(messenger, "android.oath.methods")
private val memoryKeyProvider = ClearingMemProvider()
private val keyManager by lazy {
KeyManager(
compatUtil.from(Build.VERSION_CODES.M) {
@ -111,60 +109,23 @@ class OathManager(
private var refreshJob: Job? = null
private var addToAny = false
// provides actions for lifecycle events
private val lifecycleObserver = object : DefaultLifecycleObserver {
private var startTimeMs: Long = -1
override fun onPause(owner: LifecycleOwner) {
startTimeMs = currentTimeMs
// cancel any pending actions, except for addToAny
if (!addToAny) {
pendingAction?.let {
logger.debug("Cancelling pending action/closing nfc dialog.")
it.invoke(Result.failure(CancellationException()))
coroutineScope.launch {
dialogManager.closeDialog()
}
pendingAction = null
override fun onPause() {
// cancel any pending actions, except for addToAny
if (!addToAny) {
pendingAction?.let {
logger.debug("Cancelling pending action/closing nfc dialog.")
it.invoke(Result.failure(CancellationException()))
coroutineScope.launch {
dialogManager.closeDialog()
}
pendingAction = null
}
super.onPause(owner)
}
override fun onResume(owner: LifecycleOwner) {
super.onResume(owner)
if (canInvoke) {
if (appViewModel.connectedYubiKey.value == null) {
// no USB YubiKey is connected, reset known data on resume
logger.debug("Removing NFC data after resume.")
appViewModel.setDeviceInfo(null)
oathViewModel.setSessionState(null)
}
}
}
private val currentTimeMs
get() = System.currentTimeMillis()
private val canInvoke: Boolean
get() = startTimeMs != -1L && currentTimeMs - startTimeMs > NFC_DATA_CLEANUP_DELAY
}
private val usbObserver = Observer<UsbYubiKeyDevice?> {
refreshJob?.cancel()
if (it == null) {
appViewModel.setDeviceInfo(null)
oathViewModel.setSessionState(null)
}
}
private val credentialObserver = Observer<List<CredentialWithCode>?> { codes ->
refreshJob?.cancel()
if (codes != null && appViewModel.connectedYubiKey.value != null) {
if (codes != null && deviceManager.isUsbKeyConnected()) {
val expirations = codes
.filter { it.credential.codeType == CodeType.TOTP && !it.credential.touchRequired }
.mapNotNull { it.code?.validTo }
@ -193,7 +154,7 @@ class OathManager(
}
init {
appViewModel.connectedYubiKey.observe(lifecycleOwner, usbObserver)
deviceManager.addDeviceListener(this)
oathViewModel.credentials.observe(lifecycleOwner, credentialObserver)
// OATH methods callable from Flutter:
@ -239,15 +200,15 @@ class OathManager(
else -> throw NotImplementedError()
}
}
lifecycleOwner.lifecycle.addObserver(lifecycleObserver)
}
override fun dispose() {
lifecycleOwner.lifecycle.removeObserver(lifecycleObserver)
appViewModel.connectedYubiKey.removeObserver(usbObserver)
super.dispose()
deviceManager.removeDeviceListener(this)
oathViewModel.credentials.removeObserver(credentialObserver)
oathChannel.setMethodCallHandler(null)
oathViewModel.clearSession()
oathViewModel.updateCredentials(mapOf())
coroutineScope.cancel()
}
@ -255,7 +216,7 @@ class OathManager(
try {
device.withConnection<SmartCardConnection, Unit> { connection ->
val session = getOathSession(connection)
val previousId = oathViewModel.sessionState.value?.deviceId
val previousId = oathViewModel.currentSession()?.deviceId
if (session.deviceId == previousId && device is NfcYubiKeyDevice) {
// Run any pending action
pendingAction?.let { action ->
@ -305,12 +266,12 @@ class OathManager(
if (session.version.isLessThan(4, 0, 0) && connection.transport == Transport.NFC) {
// NEO over NFC, select OTP applet before reading info
try {
SmartCardProtocol(connection).select(OTP_AID)
SmartCardProtocol(connection).select(AppId.OTP)
} catch (e: Exception) {
logger.error("Failed to recognize this OATH device.")
logger.error("Failed to recognize this OATH device.", e)
// we know this is NFC device and it supports OATH
val oathCapabilities = Capabilities(nfc = 0x20)
appViewModel.setDeviceInfo(
deviceManager.setDeviceInfo(
UnknownDevice.copy(
config = UnknownDevice.config.copy(enabledCapabilities = oathCapabilities),
name = "Unknown OATH device",
@ -321,19 +282,6 @@ class OathManager(
return@withConnection
}
}
// Update deviceInfo since the deviceId has changed
val pid = (device as? UsbYubiKeyDevice)?.pid
val deviceInfo = DeviceUtil.readInfo(connection, pid)
appViewModel.setDeviceInfo(
Info(
name = DeviceUtil.getName(deviceInfo, pid?.type),
isNfc = device.transport == Transport.NFC,
usbPid = pid?.value,
deviceInfo = deviceInfo
)
)
}
}
logger.debug(
@ -341,25 +289,9 @@ class OathManager(
)
} catch (e: Exception) {
// OATH not enabled/supported, try to get DeviceInfo over other USB interfaces
logger.error("Failed to connect to CCID", e)
if (device.transport == Transport.USB || e is ApplicationNotAvailableException) {
val deviceInfo = try {
getDeviceInfo(device)
} catch (e: IllegalArgumentException) {
logger.debug("Device was not recognized")
UnknownDevice.copy(isNfc = device.transport == Transport.NFC)
} catch (e: Exception) {
logger.error("Failure getting device info", e)
null
}
logger.debug("Setting device info: {}", deviceInfo)
appViewModel.setDeviceInfo(deviceInfo)
}
logger.error("Failed to connect to CCID: ", e)
// Clear any cached OATH state
oathViewModel.setSessionState(null)
oathViewModel.clearSession()
}
}
@ -373,7 +305,7 @@ class OathManager(
return useOathSessionNfc(OathActionDescription.AddAccount) { session ->
// We need to check for duplicates here since we haven't yet read the credentials
if (session.credentials.any { it.id.contentEquals(credentialData.id) }) {
throw Exception("A credential with this ID already exists!")
throw IllegalArgumentException()
}
val credential = session.putCredential(credentialData, requireTouch)
@ -400,7 +332,7 @@ class OathManager(
logger.trace("Adding following accounts: {}", uris)
addToAny = true
return useOathSessionNfc(OathActionDescription.AddMultipleAccounts) { session ->
return useOathSession(OathActionDescription.AddMultipleAccounts) { session ->
var successCount = 0
for (index in uris.indices) {
@ -454,7 +386,7 @@ class OathManager(
oathViewModel.setSessionState(Session(it, remembered))
// fetch credentials after unlocking only if the YubiKey is connected over USB
if (appViewModel.connectedYubiKey.value != null) {
if (deviceManager.isUsbKeyConnected()) {
oathViewModel.updateCredentials(calculateOathCodes(it))
}
}
@ -499,10 +431,10 @@ class OathManager(
throw Exception("Unset password failed")
}
private suspend fun forgetPassword(): String {
private fun forgetPassword(): String {
keyManager.clearAll()
logger.debug("Cleared all keys.")
oathViewModel.sessionState.value?.let {
oathViewModel.currentSession()?.let {
oathViewModel.setSessionState(
it.copy(
isLocked = it.isAccessKeySet,
@ -567,7 +499,7 @@ class OathManager(
} ?: emptyMap())
}
appViewModel.connectedYubiKey.value?.let { usbYubiKeyDevice ->
deviceManager.withKey { usbYubiKeyDevice ->
try {
useOathSessionUsb(usbYubiKeyDevice) { session ->
try {
@ -695,7 +627,7 @@ class OathManager(
private fun calculateOathCodes(session: YubiKitOathSession): Map<Credential, Code?> {
val isUsbKey = appViewModel.connectedYubiKey.value != null
val isUsbKey = deviceManager.isUsbKeyConnected()
var timestamp = System.currentTimeMillis()
if (!isUsbKey) {
// NFC, need to pad timer to avoid immediate expiration
@ -724,9 +656,10 @@ class OathManager(
// callers can decide whether the session should be unlocked first
unlockOnConnect.set(unlock)
return appViewModel.connectedYubiKey.value?.let {
useOathSessionUsb(it, action)
} ?: useOathSessionNfc(oathActionDescription, action)
return deviceManager.withKey(
onUsb = { useOathSessionUsb(it, action) },
onNfc = { useOathSessionNfc(oathActionDescription, action) }
)
}
private suspend fun <T> useOathSessionUsb(
@ -782,5 +715,16 @@ class OathManager(
(credential != null) && credential.id.asString() == credentialId
} ?: throw Exception("Failed to find account")
override fun onConnected(device: YubiKeyDevice) {
refreshJob?.cancel()
}
override fun onDisconnected() {
refreshJob?.cancel()
oathViewModel.clearSession()
}
override fun onTimeout() {
oathViewModel.clearSession()
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2022-2023 Yubico.
* Copyright (C) 2022-2024 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -19,30 +19,39 @@ package com.yubico.authenticator.oath
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.yubico.authenticator.ViewModelData
import com.yubico.authenticator.oath.data.Code
import com.yubico.authenticator.oath.data.Credential
import com.yubico.authenticator.oath.data.CredentialWithCode
import com.yubico.authenticator.oath.data.Session
class OathViewModel: ViewModel() {
private val _sessionState = MutableLiveData<Session?>()
val sessionState: LiveData<Session?> = _sessionState
private val _sessionState = MutableLiveData<ViewModelData>()
val sessionState: LiveData<ViewModelData> = _sessionState
fun currentSession() : Session? = (_sessionState.value as? ViewModelData.Value<*>)?.data as? Session?
// Sets session and credentials after performing OATH reset
// Note: we cannot use [setSessionState] because resetting OATH changes deviceId
fun resetOathSession(sessionState: Session, credentials: Map<Credential, Code?>) {
_sessionState.postValue(sessionState)
_sessionState.postValue(ViewModelData.Value(sessionState))
updateCredentials(credentials)
}
fun setSessionState(sessionState: Session?) {
val oldDeviceId = _sessionState.value?.deviceId
_sessionState.postValue(sessionState)
if(oldDeviceId != sessionState?.deviceId) {
fun setSessionState(sessionState: Session) {
val oldDeviceId = currentSession()?.deviceId
_sessionState.postValue(ViewModelData.Value(sessionState))
if(oldDeviceId != sessionState.deviceId) {
_credentials.postValue(null)
}
}
fun clearSession() {
_sessionState.postValue(ViewModelData.Empty)
_credentials.postValue(null)
}
private val _credentials = MutableLiveData<List<CredentialWithCode>?>()
val credentials: LiveData<List<CredentialWithCode>?> = _credentials
@ -59,7 +68,7 @@ class OathViewModel: ViewModel() {
}
fun addCredential(credential: Credential, code: Code?): CredentialWithCode {
require(credential.deviceId == _sessionState.value?.deviceId) {
require(credential.deviceId == currentSession()?.deviceId) {
"Cannot add credential for different deviceId"
}
return CredentialWithCode(credential, code).also {

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Yubico.
* Copyright (C) 2023-2024 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -25,10 +25,8 @@ typealias YubiKitCode = com.yubico.yubikit.oath.Code
data class Code(
val value: String? = null,
@SerialName("valid_from")
@Suppress("unused")
val validFrom: Long,
@SerialName("valid_to")
@Suppress("unused")
val validTo: Long
) {

View File

@ -16,10 +16,13 @@
package com.yubico.authenticator.oath.data
import com.yubico.authenticator.JsonSerializable
import com.yubico.authenticator.device.Version
import com.yubico.authenticator.jsonSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
typealias YubiKitOathSession = com.yubico.yubikit.oath.OathSession
@ -35,7 +38,7 @@ data class Session(
val isRemembered: Boolean,
@SerialName("locked")
val isLocked: Boolean
) {
) : JsonSerializable {
@SerialName("keystore")
@Suppress("unused")
val keystoreState: String = "unknown"
@ -52,4 +55,8 @@ data class Session(
isRemembered,
oathSession.isLocked
)
override fun toJson(): String {
return jsonSerializer.encodeToString(this)
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 Yubico.
* Copyright (C) 2022,2024 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -16,7 +16,6 @@
package com.yubico.authenticator.oath.keystore
import android.security.keystore.KeyProperties
import com.yubico.yubikit.oath.AccessKey
import java.util.*
import javax.crypto.Mac

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2022-2023 Yubico.
* Copyright (C) 2022-2024 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -18,18 +18,24 @@ package com.yubico.authenticator.yubikit
import com.yubico.authenticator.device.Info
import com.yubico.authenticator.compatUtil
import com.yubico.authenticator.device.unknownDeviceWithCapability
import com.yubico.authenticator.device.unknownFido2DeviceInfo
import com.yubico.authenticator.device.unknownOathDeviceInfo
import com.yubico.yubikit.android.transport.nfc.NfcYubiKeyDevice
import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice
import com.yubico.yubikit.core.YubiKeyDevice
import com.yubico.yubikit.core.application.ApplicationNotAvailableException
import com.yubico.yubikit.core.fido.FidoConnection
import com.yubico.yubikit.core.otp.OtpConnection
import com.yubico.yubikit.core.smartcard.SmartCardConnection
import com.yubico.yubikit.fido.ctap.Ctap2Session
import com.yubico.yubikit.management.DeviceInfo
import com.yubico.yubikit.oath.OathSession
import com.yubico.yubikit.support.DeviceUtil
import org.slf4j.LoggerFactory
suspend fun getDeviceInfo(device: YubiKeyDevice): Info {
suspend fun getDeviceInfo(device: YubiKeyDevice): Info? {
val pid = (device as? UsbYubiKeyDevice)?.pid
val logger = LoggerFactory.getLogger("getDeviceInfo")
@ -45,10 +51,34 @@ suspend fun getDeviceInfo(device: YubiKeyDevice): Info {
logger.debug("FIDO connection not available: {}", t.message)
return SkyHelper(compatUtil).getDeviceInfo(device)
}.getOrElse {
logger.debug("Failed to recognize device: {}", it.message)
throw it
// this is not a YubiKey
logger.debug("Probing unknown device")
try {
device.openConnection(SmartCardConnection::class.java).use { smartCardConnection ->
try {
// if OATH session is available use it
OathSession(smartCardConnection)
logger.debug("Device supports OATH")
return unknownOathDeviceInfo(device.transport)
} catch (applicationNotAvailable: ApplicationNotAvailableException) {
try {
// probe for CTAP2 availability
Ctap2Session(smartCardConnection)
logger.debug("Device supports FIDO2")
return unknownFido2DeviceInfo(device.transport)
} catch (applicationNotAvailable: ApplicationNotAvailableException) {
logger.debug("Device not recognized")
return unknownDeviceWithCapability(device.transport)
}
}
}
} catch (e: Exception) {
// no smart card connectivity
logger.error("Failure getting device info", e)
return null
}
}
val name = DeviceUtil.getName(deviceInfo, pid?.type)
return Info(name, device is NfcYubiKeyDevice, pid?.value, deviceInfo)
}
}

View File

@ -74,7 +74,9 @@ class SkyHelper(private val compatUtil: CompatUtil) {
name = (device.usbDevice.productName ?: "Yubico Security Key"),
isNfc = false,
usbPid = pid.value,
supportedCapabilities = Capabilities(usb = 0)
pinComplexity = false,
supportedCapabilities = Capabilities(usb = 0),
fipsCapable = 0
)
}

View File

@ -0,0 +1,2 @@
<?xml version='1.0' encoding='utf-8'?>
<resources />

View File

@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="p_ndef_set_otp">Code OTP copié de la YubiKey dans le presse-papiers.</string>
<string name="p_ndef_set_password">Mot de passe copié de la YubiKey dans le presse-papiers.</string>
<string name="p_ndef_parse_failure">Impossible d\'analyser le code OTP de la YubiKey.</string>
<string name="p_ndef_set_clip_failure">Presse-papiers inaccessible lors de la tentative de copie du code OTP depuis la YubiKey.</string>
</resources>

View File

@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="p_ndef_set_otp">OTPコードがYubiKeyからクリップボードに正常にコピーされました。</string>
<string name="p_ndef_set_password">パスワードがYubiKeyからクリップボードに正常にコピーされました。</string>
<string name="p_ndef_parse_failure">YubiKeyからのOTPコードを解析できませんでした。</string>
<string name="p_ndef_set_clip_failure">YubiKeyからのOTPコードのコピー試行時にクリップボードにアクセスできませんでした。</string>
</resources>

View File

@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="p_ndef_set_otp">OTP zostało skopiowane do schowka.</string>
<string name="p_ndef_set_password">Hasło statyczne zostało skopiowane do schowka.</string>
<string name="p_ndef_parse_failure">Błąd czytania OTP z YubiKey.</string>
<string name="p_ndef_set_clip_failure">Błąd kopiowania OTP do schowka.</string>
</resources>

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_label">Yubico Authenticator</string>
<string name="otp_success_set_otp_to_clipboard">Successfully copied OTP code from YubiKey to clipboard.</string>
<string name="otp_success_set_password_to_clipboard">Successfully copied password from YubiKey to clipboard.</string>
<string name="otp_parse_failure">Failed to parse OTP code from YubiKey.</string>
<string name="otp_set_clip_failure">Failed to access clipboard when trying to copy OTP code from YubiKey.</string>
<string name="app_label" translatable="false">Yubico Authenticator</string>
<string name="p_ndef_set_otp">Successfully copied OTP code from YubiKey to clipboard.</string>
<string name="p_ndef_set_password">Successfully copied password from YubiKey to clipboard.</string>
<string name="p_ndef_parse_failure">Failed to parse OTP code from YubiKey.</string>
<string name="p_ndef_set_clip_failure">Failed to access clipboard when trying to copy OTP code from YubiKey.</string>
</resources>

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Yubico.
* Copyright (C) 2023-2024 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -17,7 +17,6 @@
package com.yubico.authenticator.device
import com.yubico.authenticator.jsonSerializer
import com.yubico.yubikit.management.DeviceConfig
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject

View File

@ -17,6 +17,7 @@
package com.yubico.authenticator.oath
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.yubico.authenticator.ViewModelData
import com.yubico.authenticator.device.Version
import com.yubico.authenticator.oath.OathTestHelper.code
import com.yubico.authenticator.oath.OathTestHelper.emptyCredentials
@ -40,12 +41,12 @@ class ModelTest {
private fun connectDevice(deviceId: String) {
viewModel.setSessionState(
Session(
deviceId,
Version(1, 2, 3),
isAccessKeySet = false,
isRemembered = false,
isLocked = false
)
deviceId,
Version(1, 2, 3),
isAccessKeySet = false,
isRemembered = false,
isLocked = false
)
)
}
@ -116,7 +117,7 @@ class ModelTest {
viewModel.updateCredentials(m2)
assertEquals("device1", viewModel.sessionState.value?.deviceId)
assertEquals("device1", viewModel.currentSession()?.deviceId)
assertEquals(3, viewModel.credentials.value!!.size)
assertTrue(viewModel.credentials.value!!.find { it.credential == cred1 } != null)
assertTrue(viewModel.credentials.value!!.find { it.credential == cred2 } != null)
@ -387,9 +388,9 @@ class ModelTest {
val deviceId = "device"
connectDevice(deviceId)
viewModel.updateCredentials(mapOf(totp() to code()))
viewModel.setSessionState(null)
viewModel.clearSession()
assertNull(viewModel.sessionState.value)
assertEquals(ViewModelData.Empty, viewModel.sessionState.value)
assertNull(viewModel.credentials.value)
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2022-2023 Yubico.
* Copyright (C) 2022-2024 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -42,7 +42,7 @@ class SkyHelperTest {
fun `supports three specific UsbPids`() {
val skyHelper = SkyHelper(CompatUtil(33))
for (pid in UsbPid.values()) {
for (pid in UsbPid.entries) {
val ykDevice = getUsbYubiKeyDeviceMock().also {
`when`(it.pid).thenReturn(pid)
}

View File

@ -1,18 +1,3 @@
buildscript {
ext.kotlin_version = '1.9.10'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.1.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
classpath 'com.google.android.gms:oss-licenses-plugin:0.10.6'
}
}
allprojects {
repositories {
google()
@ -24,9 +9,9 @@ allprojects {
targetSdkVersion = 34
compileSdkVersion = 34
yubiKitVersion = "2.4.0-beta01"
yubiKitVersion = "2.6.0"
junitVersion = "4.13.2"
mockitoVersion = "5.6.0"
mockitoVersion = "5.12.0"
}
}

View File

@ -2,14 +2,14 @@ group 'com.yubico.authenticator.flutter_plugins.qrscanner_zxing'
version '1.0'
buildscript {
ext.kotlin_version = '1.9.10'
ext.kotlin_version = '2.0.0'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.1.2'
classpath 'com.android.tools.build:gradle:8.5.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
@ -50,7 +50,7 @@ android {
}
dependencies {
def camerax_version = "1.3.0"
def camerax_version = "1.3.4"
implementation "androidx.camera:camera-lifecycle:${camerax_version}"
implementation "androidx.camera:camera-view:${camerax_version}"
implementation "androidx.camera:camera-camera2:${camerax_version}"

View File

@ -1,6 +1,6 @@
#Mon Oct 16 08:48:17 CEST 2023
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@ -107,17 +107,15 @@ internal class QRScannerView(
}
private fun requestPermissions(activity: Activity) {
coroutineScope.launch {
methodChannel.invokeMethod(
"beforePermissionsRequest", null
)
methodChannel.invokeMethod(
"beforePermissionsRequest", null
)
ActivityCompat.requestPermissions(
activity,
PERMISSIONS_TO_REQUEST,
PERMISSION_REQUEST_CODE
)
}
ActivityCompat.requestPermissions(
activity,
PERMISSIONS_TO_REQUEST,
PERMISSION_REQUEST_CODE
)
}
private val qrScannerView = View.inflate(context, R.layout.qr_scanner_view, null)

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 Yubico.
* Copyright (C) 2022,2024 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -42,7 +42,7 @@ class PermissionsResultRegistrar {
requestCode,
permissions,
grantResults
) ?: false
) == true
}
}

View File

@ -1,12 +1,12 @@
buildscript {
ext.kotlin_version = '1.9.10'
ext.kotlin_version = '2.0.0'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.1.2'
classpath 'com.android.tools.build:gradle:8.5.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}

View File

@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip

View File

@ -20,7 +20,7 @@ import 'package:flutter/material.dart';
class CutoutOverlay extends StatelessWidget {
final int marginPct;
const CutoutOverlay({Key? key, required this.marginPct}) : super(key: key);
const CutoutOverlay({super.key, required this.marginPct});
@override
Widget build(BuildContext context) {

View File

@ -26,7 +26,7 @@ void main() {
}
class QRCodeScannerExampleApp extends StatelessWidget {
const QRCodeScannerExampleApp({Key? key}) : super(key: key);
const QRCodeScannerExampleApp({super.key});
@override
Widget build(BuildContext context) {
@ -41,7 +41,7 @@ class QRCodeScannerExampleApp extends StatelessWidget {
}
class AppHomePage extends StatelessWidget {
const AppHomePage({Key? key, required this.title}) : super(key: key);
const AppHomePage({super.key, required this.title});
final String title;
@override
@ -104,7 +104,7 @@ class AppHomePage extends StatelessWidget {
}
class QRScannerPage extends StatefulWidget {
const QRScannerPage({Key? key}) : super(key: key);
const QRScannerPage({super.key});
@override
QRScannerPageState createState() => QRScannerPageState();

View File

@ -37,18 +37,18 @@ packages:
dependency: transitive
description:
name: collection
sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
url: "https://pub.dev"
source: hosted
version: "1.17.2"
version: "1.18.0"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
sha256: e35129dc44c9118cee2a5603506d823bab99c68393879edb440e0090d07586be
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
url: "https://pub.dev"
source: hosted
version: "1.0.5"
version: "1.0.8"
fake_async:
dependency: transitive
description:
@ -61,18 +61,18 @@ packages:
dependency: transitive
description:
name: ffi
sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878"
sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
version: "2.1.2"
file_picker:
dependency: "direct main"
description:
name: file_picker
sha256: be325344c1f3070354a1d84a231a1ba75ea85d413774ec4bdf444c023342e030
sha256: "1bbf65dd997458a08b531042ec3794112a6c39c07c37ff22113d2e7e4f81d4e4"
url: "https://pub.dev"
source: hosted
version: "5.5.0"
version: "6.2.1"
flutter:
dependency: "direct main"
description: flutter
@ -82,18 +82,18 @@ packages:
dependency: "direct dev"
description:
name: flutter_lints
sha256: aeb0b80a8b3709709c9cc496cdc027c5b3216796bc0af0ce1007eaf24464fd4c
sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
version: "3.0.2"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: f185ac890306b5779ecbd611f52502d8d4d63d27703ef73161ca0407e815f02c
sha256: c6b0b4c05c458e1c01ad9bcc14041dd7b1f6783d487be4386f793f47a8a4d03e
url: "https://pub.dev"
source: hosted
version: "2.0.16"
version: "2.0.20"
flutter_test:
dependency: "direct dev"
description: flutter
@ -104,54 +104,78 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a"
url: "https://pub.dev"
source: hosted
version: "10.0.4"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8"
url: "https://pub.dev"
source: hosted
version: "3.0.3"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
lints:
dependency: transitive
description:
name: lints
sha256: "5e4a9cd06d447758280a8ac2405101e0e2094d2a1dbdd3756aec3fe7775ba593"
sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290
url: "https://pub.dev"
source: hosted
version: "2.0.1"
version: "3.0.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e"
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
url: "https://pub.dev"
source: hosted
version: "0.12.16"
version: "0.12.16+1"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41"
sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a"
url: "https://pub.dev"
source: hosted
version: "0.5.0"
version: "0.8.0"
meta:
dependency: transitive
description:
name: meta
sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3"
sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
version: "1.12.0"
path:
dependency: transitive
description:
name: path
sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917"
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
url: "https://pub.dev"
source: hosted
version: "1.8.3"
version: "1.9.0"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: da3fdfeccc4d4ff2da8f8c556704c08f912542c5fb3cf2233ed75372384a034d
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.6"
version: "2.1.8"
qrscanner_zxing:
dependency: "direct main"
description:
@ -176,18 +200,18 @@ packages:
dependency: transitive
description:
name: stack_trace
sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5
sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
url: "https://pub.dev"
source: hosted
version: "1.11.0"
version: "1.11.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8"
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
url: "https://pub.dev"
source: hosted
version: "2.1.1"
version: "2.1.2"
string_scanner:
dependency: transitive
description:
@ -208,10 +232,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8"
sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
version: "0.7.0"
vector_math:
dependency: transitive
description:
@ -220,22 +244,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.4"
web:
vm_service:
dependency: transitive
description:
name: web
sha256: dc8ccd225a2005c1be616fe02951e2e342092edf968cf0844220383757ef8f10
name: vm_service
sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec"
url: "https://pub.dev"
source: hosted
version: "0.1.4-beta"
version: "14.2.1"
win32:
dependency: transitive
description:
name: win32
sha256: "350a11abd2d1d97e0cc7a28a81b781c08002aa2864d9e3f192ca0ffa18b06ed3"
sha256: a79dbe579cb51ecd6d30b17e0cae4e0ea15e2c0e66f69ad4198f22a6789e94f4
url: "https://pub.dev"
source: hosted
version: "5.0.9"
version: "5.5.1"
sdks:
dart: ">=3.1.0-185.0.dev <4.0.0"
flutter: ">=3.7.0"
dart: ">=3.4.0 <4.0.0"
flutter: ">=3.22.0"

View File

@ -13,13 +13,13 @@ dependencies:
qrscanner_zxing:
path: ../
cupertino_icons: ^1.0.2
file_picker: ^5.3.2
cupertino_icons: ^1.0.6
file_picker: ^6.1.1
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^2.0.0
flutter_lints: ^3.0.1
flutter:
uses-material-design: true

View File

@ -24,23 +24,25 @@ import 'package:flutter/services.dart';
class QRScannerZxingView extends StatefulWidget {
final int marginPct;
/// Called when a code has been detected.
final Function(String rawData) onDetect;
/// Called before the system UI with runtime permissions request is
/// displayed.
final Function()? beforePermissionsRequest;
/// Called after the view is completely initialized.
///
/// permissionsGranted is true if the user granted camera permissions.
final Function(bool permissionsGranted) onViewInitialized;
const QRScannerZxingView(
{Key? key,
{super.key,
required this.marginPct,
required this.onDetect,
this.beforePermissionsRequest,
required this.onViewInitialized})
: super(key: key);
required this.onViewInitialized});
@override
QRScannerZxingViewState createState() => QRScannerZxingViewState();

View File

@ -1,21 +1,20 @@
name: qrscanner_zxing
description: Android-only QR Scanner plugin based on zxing and CameraX API's
version: 1.0.0
homepage:
environment:
sdk: ">=2.17.0-266.1.beta <3.0.0"
flutter: ">=2.5.0"
sdk: '>=3.0.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
plugin_platform_interface: ^2.0.2
plugin_platform_interface: ^2.1.6
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^2.0.0
flutter_lints: ^3.0.1
flutter:
plugin:

View File

@ -1,6 +1,6 @@
#Mon Aug 15 14:34:17 CEST 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME

View File

@ -7,7 +7,7 @@ fi
export REF=$(echo ${GITHUB_REF} | cut -d '/' -f 3,4,5,6,7 | sed -r 's/\//_/g')
export FLUTTER_APK=build/app/outputs/flutter-apk
export NATIVE_LIBS=build/app/intermediates/merged_native_libs/release/out/lib
export NATIVE_LIBS=build/app/intermediates/merged_native_libs/release/mergeReleaseNativeLibs/out/lib
rm -rf artifacts
mkdir artifacts

View File

@ -1,11 +1,35 @@
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}()
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
resolutionStrategy {
eachPlugin {
// https://github.com/google/play-services-plugins/issues/223
if (requested.id.id == "com.google.android.gms.oss-licenses-plugin") {
useModule("com.google.android.gms:oss-licenses-plugin:${requested.version}")
}
}
}
}
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.5.0" apply false
id "org.jetbrains.kotlin.android" version "2.0.0" apply false
id "org.jetbrains.kotlin.plugin.serialization" version "2.0.0" apply false
id "com.google.android.gms.oss-licenses-plugin" version "0.10.6" apply false
}
include ':app'
def localPropertiesFile = new File(rootProject.projectDir, "local.properties")
def properties = new Properties()
assert localPropertiesFile.exists()
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"

View File

@ -5,24 +5,33 @@ import os
def read_file_lines(file_path):
with open(file_path, 'r', encoding='utf-8') as file:
with open(file_path, "r", encoding="utf-8") as file:
return file.readlines()
def read_file_json(file_path):
with open(file_path, 'r', encoding='utf-8') as file:
with open(file_path, "r", encoding="utf-8") as file:
return json.load(file)
def write_to_file(file_path, text):
with open(file_path, 'w', encoding='utf-8') as file:
with open(file_path, "r", encoding="utf-8") as file:
if file.read() == text:
return False
with open(file_path, "w", encoding="utf-8") as file:
file.write(text)
return True
# Translation table for unicode characters we want to keep in escaped form.
trans = str.maketrans({
'\u00a0': r"\u00a0", # No-Break Space (NBSP)
'\u2026': r"\u2026" # Horizontal Ellipsis
})
trans = str.maketrans(
{
"\u00a0": r"\u00a0", # No-Break Space (NBSP)
"\u2026": r"\u2026", # Horizontal Ellipsis
}
)
# Move keys in target into same order as in source.
# Keys not present in source are removed from target.
@ -68,16 +77,16 @@ def update_arb_file(source_path, target_path, language_code):
if line.strip() == "":
target_lines.insert(i, "")
write_to_file(target_path, "\n".join(target_lines).strip() + "\n")
return write_to_file(target_path, "\n".join(target_lines).strip() + "\n")
if __name__ == "__main__":
source_file_path = 'lib/l10n/app_en.arb'
target_directory = 'lib/l10n'
source_file_path = "lib/l10n/app_en.arb"
target_directory = "lib/l10n"
for file_name in os.listdir(target_directory):
if file_name.startswith('app_') and file_name.endswith('.arb'):
if file_name.startswith("app_") and file_name.endswith(".arb"):
target_file_path = os.path.join(target_directory, file_name)
language_code = file_name.split('_')[1].split('.')[0]
update_arb_file(source_file_path, target_file_path, language_code)
print(f'File updated: {file_name}')
language_code = file_name.split("_")[1].split(".")[0]
if update_arb_file(source_file_path, target_file_path, language_code):
print(f"File updated: {file_name}")

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -34,21 +34,15 @@ if [ "$OS" = "macos" ]; then
rm -rf $HELPER
mkdir -p $HELPER
# Needed to build zxing-cpp properly
export CMAKE_OSX_ARCHITECTURES="arm64;x86_64"
# Export exact versions
poetry export --without-hashes > $HELPER/requirements.txt
grep cryptography $HELPER/requirements.txt > $HELPER/cryptography.txt
grep cffi $HELPER/requirements.txt > $HELPER/cffi.txt
grep pillow $HELPER/requirements.txt > $HELPER/pillow.txt
grep zxing-cpp $HELPER/requirements.txt > $HELPER/zxing-cpp.txt
# Remove non-universal packages
poetry run pip uninstall -y cryptography cffi pillow zxing-cpp
poetry run pip uninstall -y cryptography cffi pillow
# Build cffi from source to get universal build
poetry run pip install --upgrade -r $HELPER/cffi.txt --no-binary cffi
# Build zxing-cpp from source to get universal build
poetry run pip install --upgrade -r $HELPER/zxing-cpp.txt --no-binary zxing-cpp
# Explicitly install pre-build universal build of cryptography
poetry run pip download -r $HELPER/cryptography.txt --platform macosx_10_12_universal2 --only-binary :all: --no-deps --dest $HELPER
poetry run pip install -r $HELPER/cryptography.txt --no-cache-dir --no-index --find-links $HELPER
@ -56,8 +50,8 @@ if [ "$OS" = "macos" ]; then
poetry run pip download -r $HELPER/pillow.txt --platform macosx_10_10_x86_64 --only-binary :all: --no-deps --dest $HELPER
poetry run pip download -r $HELPER/pillow.txt --platform macosx_11_0_arm64 --only-binary :all: --no-deps --dest $HELPER
poetry run pip install delocate
poetry run delocate-fuse $HELPER/Pillow*.whl
WHL=$(ls $HELPER/Pillow*x86_64.whl)
poetry run delocate-fuse $HELPER/pillow*.whl
WHL=$(ls $HELPER/pillow*x86_64.whl)
UNIVERSAL_WHL=${WHL//x86_64/universal2}
mv $WHL $UNIVERSAL_WHL
poetry run pip install --upgrade $UNIVERSAL_WHL

View File

@ -19,6 +19,7 @@ import json
import os
import sys
non_words = (":",)
errors = []
@ -46,8 +47,9 @@ def check_prefixes(k, v, s_max_words, s_max_len, p_ending_chars, q_ending_chars)
if k.startswith("s_"):
if len(v) > s_max_len:
errs.append(f"Too long ({len(v)} chars)")
if len(v.split()) > s_max_words:
errs.append(f"Too many words ({len(v.split())})")
n_words = len([w for w in v.split() if w not in non_words])
if n_words > s_max_words:
errs.append(f"Too many words ({n_words})")
if k.startswith("l_") or k.startswith("s_"):
if v.endswith("."):
errs.append("Ends with '.'")
@ -89,7 +91,7 @@ def lint_strings(strings, rules):
k,
v,
rules.get("s_max_words", 4),
rules.get("s_max_len", 32),
rules.get("s_max_length", 32),
rules.get("p_ending_chars", ".!"),
rules.get("q_ending_chars", "?"),
)
@ -106,7 +108,7 @@ if len(sys.argv) != 2:
target = sys.argv[1]
with open(target, encoding='utf-8') 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("@")}
@ -115,7 +117,7 @@ print(target, f"- checking {len(strings)} strings")
lint_strings(strings, values.get("@_lint_rules", {}))
check_duplicate_values(strings)
with open(os.path.join(os.path.dirname(target), 'app_en.arb'), encoding='utf-8') as f:
with open(os.path.join(os.path.dirname(target), "app_en.arb"), encoding="utf-8") as f:
reference_values = json.load(f)
errors.extend(check_keys_exist_in_reference(reference_values, values))

127
crowdin.yaml Normal file
View File

@ -0,0 +1,127 @@
#
# Your Crowdin credentials
#
"project_id_env": "CROWDIN_PROJECT_ID"
"api_token_env": "CROWDIN_PERSONAL_TOKEN"
"base_path": "."
"base_url": "https://api.crowdin.com"
#
# Choose file structure in Crowdin
# e.g. true or false
#
"preserve_hierarchy": false
#
# Files configuration
#
files: [
{
#
# Source files filter
# e.g. "/resources/en/*.json"
#
"source": "/lib/l10n/app_en.arb",
#
# Where translations will be placed
# e.g. "/resources/%two_letters_code%/%original_file_name%"
#
"translation": "/lib/l10n/app_%two_letters_code%.arb",
#
# Files or directories for ignore
# e.g. ["/**/?.txt", "/**/[0-9].txt", "/**/*\?*.txt"]
#
# "ignore": [],
#
# The dest allows you to specify a file name in Crowdin
# e.g. "/messages.json"
#
# "dest": "",
#
# File type
# e.g. "json"
#
# "type": "",
#
# The parameter "update_option" is optional. If it is not set, after the files update the translations for changed strings will be removed. Use to fix typos and for minor changes in the source strings
# e.g. "update_as_unapproved" or "update_without_changes"
#
# "update_option": "",
#
# Start block (for XML only)
#
#
# Defines whether to translate tags attributes.
# e.g. 0 or 1 (Default is 1)
#
# "translate_attributes": 1,
#
# Defines whether to translate texts placed inside the tags.
# e.g. 0 or 1 (Default is 1)
#
# "translate_content": 1,
#
# This is an array of strings, where each item is the XPaths to DOM element that should be imported
# e.g. ["/content/text", "/content/text[@value]"]
#
# "translatable_elements": [],
#
# Defines whether to split long texts into smaller text segments
# e.g. 0 or 1 (Default is 1)
#
# "content_segmentation": 1,
#
# End block (for XML only)
#
#
# Start .properties block
#
#
# Defines whether single quote should be escaped by another single quote or backslash in exported translations
# e.g. 0 or 1 or 2 or 3 (Default is 3)
# 0 - do not escape single quote;
# 1 - escape single quote by another single quote;
# 2 - escape single quote by backslash;
# 3 - escape single quote by another single quote only in strings containing variables ( {0} ).
#
# "escape_quotes": 3,
#
# Defines whether any special characters (=, :, ! and #) should be escaped by backslash in exported translations.
# e.g. 0 or 1 (Default is 0)
# 0 - do not escape special characters
# 1 - escape special characters by a backslash
#
# "escape_special_characters": 0
#
#
# End .properties block
#
#
# Does the first line contain header?
# e.g. true or false
#
# "first_line_contains_header": true,
#
# for spreadsheets
# e.g. "identifier,source_phrase,context,uk,ru,fr"
#
# "scheme": "",
}
]

38
dart_test.yaml Normal file
View File

@ -0,0 +1,38 @@
# define available tags
tags:
# Tests which we want to run on desktop
desktop:
timeout: none
# Tests which we want to run on Android
android:
timeout: none
# Tests consuming quiet a lot of time
slow:
timeout: none
# Minimal tests
# quick verification that the framework is working
minimal:
timeout: none
# OATH tests
oath:
timeout: none
# OTP tests
otp:
timeout: none
# PIV tests
piv:
timeout: none
# Management tests
management:
timeout: none
# Passkey tests
passkey:
timeout: none

View File

@ -12,7 +12,7 @@ Once you have an icon pack on your computer or Android device and wish to use
it, follow these steps:
1. Open the Yubico Authenticator app and insert or tap your OATH-enabled YubiKey.
2. Make sure the `Authenticator` section is chosen, where you can see your list of accounts.
2. Make sure the `Accounts` section is chosen, where you can see your list of accounts.
3. Press the `Configure YubiKey` button, and select `Custom icons` from the menu.
4. Press the `Load icon pack` button, select the icon pack zip-file, and wait for it to load.

View File

@ -2,7 +2,7 @@
This document describes how to build and package Yubico Authenticator from
source.
NOTE: Yubico Authenticator 6 uses a new codebase built using the Flutter
NOTE: Yubico Authenticator 6+ uses a new codebase built using the Flutter
framework. The previous Qt codebase can be found in the `legacy` branch.
=== Requirements
@ -45,7 +45,7 @@ Make sure the http://www.swig.org/[swig] executable is in your PATH.
$ sudo apt install swig libu2f-udev pcscd libpcsclite-dev
==== Linux (RPM-based distributons)
==== Linux (RPM-based distributions)
# Tested on Fedora 34
$ sudo dnf install pcsc-lite-devel python3-devel swig
@ -90,7 +90,7 @@ These do not require a YubiKey, and are relatively quick to run.
The integration tests are slower but cover more end-to-end functionality. The
require an attached YubiKey to run, and will make modifications to the data
stored on that YubiKey. For instructions on running these tests, see
link:Integration_Tests.adoc[these instructions].
link:../integration_test/testdoc.adoc[these instructions].
=== Packaging for MacOS

View File

@ -1,46 +0,0 @@
== Integration tests
The `integration_test` directory contains a set of semi-automated tests which are to be run on a physical device with a real YubiKey. The tests are divided by test area into several files:
|===
|Test file | Test area | Notes
|`management_test.dart`
|Test turning OTP app off and on
|Currently only Desktop
|`oath_test.dart`
|Test oath app functionality: add, update and remove account, set, change and remove password
|
|===
To run the tests:
1. connect your YubiKey via USB to your host
2. pass a specific test file into the flutter test framework. The command needs to be run in the repository root. Example:
flutter test integration_test/oath_test.dart
=== Notes
==== USB only
Currently the tests work only with USB connected keys on both desktop and Android.
==== Avoiding data loss
To avoid data loss, only approved YubiKeys will be accepted by the framework. Update `approved_yubikeys.dart` file with serial numbers of your testing YubiKeys:
var approvedYubiKeys = <String>['12345678', '98765432'];
==== Preparing YubiKeys for testing
Before running the tests, remove passwords from the YubiKeys used for testing.
==== Android permission
On Android, you have to confirm USB permissions when the first test is executed and Camera permission first time we are adding account. We implemented a custom test driver which can be used to avoid the Camera permission grant dialog. Use this command to run with the test driver:
flutter drive --driver=integration_test/android/test_driver.dart --target=integration_test/oath_test.dart -d DEVICE
where DEVICE is obtained with
flutter devices

View File

@ -15,9 +15,11 @@
# limitations under the License.
from helper import run_rpc_pipes, run_rpc_socket
from typing import cast
import socket
import sys
import io
if __name__ == "__main__":
@ -32,8 +34,8 @@ if __name__ == "__main__":
run_rpc_socket(sock)
else:
sys.stdin.reconfigure(encoding="utf-8")
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
cast(io.TextIOWrapper, sys.stdin).reconfigure(encoding="utf-8")
cast(io.TextIOWrapper, sys.stdout).reconfigure(encoding="utf-8")
cast(io.TextIOWrapper, sys.stderr).reconfigure(encoding="utf-8")
run_rpc_pipes(sys.stdout, sys.stdin)

View File

@ -24,7 +24,7 @@ a = Analysis(
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
target_arch = None
# MacOS: If the running Python process is "universal", build a univeral2 binary.
# MacOS: If the running Python process is "universal", build a universal2 binary.
if sys.platform == "darwin":
r = subprocess.run(['lipo', '-archs', sys.executable], capture_output=True).stdout
if b"x86_64" in r and b"arm64" in r:

View File

@ -75,6 +75,11 @@ class AuthRequiredException(RpcException):
super().__init__("auth-required", "Authentication is required")
class PinComplexityException(RpcException):
def __init__(self):
super().__init__("pin-complexity", "PIN does not meet complexity requirements")
class ChildResetException(Exception):
def __init__(self, message):
self.message = message

View File

@ -42,6 +42,7 @@ from yubikit.logging import LOG_LEVEL
from ykman.pcsc import list_devices, YK_READER_NAME
from smartcard.Exceptions import SmartcardException, NoCardException
from smartcard.pcsc.PCSCExceptions import EstablishContextException
from smartcard.CardMonitoring import CardObserver, CardMonitor
from hashlib import sha256
from dataclasses import asdict
from typing import Mapping, Tuple
@ -61,11 +62,15 @@ def _is_admin():
class ConnectionException(RpcException):
def __init__(self, connection, exc_type):
def __init__(self, device, connection, exc_type):
super().__init__(
"connection-error",
f"Error connecting to {connection} interface",
dict(connection=connection, exc_type=type(exc_type).__name__),
dict(
device=device,
connection=connection,
exc_type=type(exc_type).__name__,
),
)
@ -181,10 +186,28 @@ class DevicesNode(RpcNode):
self._list_state = 0
self._devices = {}
self._device_mapping = {}
self._failing_connection = {}
self._retries = 0
def __call__(self, *args, **kwargs):
with self._get_state:
return super().__call__(*args, **kwargs)
try:
return super().__call__(*args, **kwargs)
except ConnectionException as e:
if self._failing_connection == e.body:
self._retries += 1
else:
self._failing_connection = e.body
self._retries = 0
if self._retries > 2:
raise
logger.debug("Connection failed, attempt to recover", exc_info=True)
raise ChildResetException(f"{e}")
def close(self):
self._list_state = 0
self._device_mapping = {}
super().close()
@action(closes_child=False)
def scan(self, *ignored):
@ -263,9 +286,6 @@ class AbstractDeviceNode(RpcNode):
class UsbDeviceNode(AbstractDeviceNode):
def __init__(self, device, info):
super().__init__(device, info)
def _supports_connection(self, conn_type):
return self._device.pid.supports_connection(conn_type)
@ -289,7 +309,7 @@ class UsbDeviceNode(AbstractDeviceNode):
return self._create_connection(SmartCardConnection)
except (ValueError, SmartcardException, EstablishContextException) as e:
logger.warning("Error opening connection", exc_info=True)
raise ConnectionException("ccid", e)
raise ConnectionException(self._device.fingerprint, "ccid", e)
@child(condition=lambda self: self._supports_connection(OtpConnection))
def otp(self):
@ -297,7 +317,7 @@ class UsbDeviceNode(AbstractDeviceNode):
return self._create_connection(OtpConnection)
except (ValueError, OSError) as e:
logger.warning("Error opening connection", exc_info=True)
raise ConnectionException("otp", e)
raise ConnectionException(self._device.fingerprint, "otp", e)
@child(condition=lambda self: self._supports_connection(FidoConnection))
def fido(self):
@ -305,18 +325,56 @@ class UsbDeviceNode(AbstractDeviceNode):
return self._create_connection(FidoConnection)
except (ValueError, OSError) as e:
logger.warning("Error opening connection", exc_info=True)
raise ConnectionException("fido", e)
raise ConnectionException(self._device.fingerprint, "fido", e)
class _ReaderObserver(CardObserver):
def __init__(self, device):
self.device = device
self.card = None
self.data = None
def update(self, observable, actions):
added, removed = actions
for card in added:
if card.reader == self.device.reader.name:
if card != self.card:
self.card = card
break
else:
self.card = None
self.data = None
logger.debug(f"NFC card: {self.card}")
class ReaderDeviceNode(AbstractDeviceNode):
def __init__(self, device, info):
super().__init__(device, info)
self._observer = _ReaderObserver(device)
self._monitor = CardMonitor()
self._monitor.addObserver(self._observer)
def close(self):
self._monitor.deleteObserver(self._observer)
super().close()
def get_data(self):
try:
with self._device.open_connection(SmartCardConnection) as conn:
return dict(self._read_data(conn), present=True)
except NoCardException:
return dict(present=False, status="no-card")
except ValueError:
return dict(present=False, status="unknown-device")
if self._observer.data is None:
card = self._observer.card
if card is None:
return dict(present=False, status="no-card")
try:
with self._device.open_connection(SmartCardConnection) as conn:
self._observer.data = dict(self._read_data(conn), present=True)
except NoCardException:
return dict(present=False, status="no-card")
except ValueError:
self._observer.data = dict(present=False, status="unknown-device")
return self._observer.data
@action(closes_child=False)
def get(self, params, event, signal):
return super().get(params, event, signal)
@child
def ccid(self):
@ -326,7 +384,7 @@ class ReaderDeviceNode(AbstractDeviceNode):
return ConnectionNode(self._device, connection, info)
except (ValueError, SmartcardException, EstablishContextException) as e:
logger.warning("Error opening connection", exc_info=True)
raise ConnectionException("ccid", e)
raise ConnectionException(self._device.fingerprint, "ccid", e)
@child
def fido(self):
@ -337,7 +395,7 @@ class ReaderDeviceNode(AbstractDeviceNode):
return ConnectionNode(self._device, connection, info)
except (ValueError, SmartcardException, EstablishContextException) as e:
logger.warning("Error opening connection", exc_info=True)
raise ConnectionException("fido", e)
raise ConnectionException(self._device.fingerprint, "fido", e)
class ConnectionNode(RpcNode):
@ -413,7 +471,9 @@ class ConnectionNode(RpcNode):
or ( # SmartCardConnection can be used over NFC, or on 5.3 and later.
isinstance(self._connection, SmartCardConnection)
and (
self._transport == TRANSPORT.NFC or self._info.version >= (5, 3, 0)
self._transport == TRANSPORT.NFC
or self._info.version >= (5, 3, 0)
or self._info.version[0] == 3
)
)
)

View File

@ -19,12 +19,14 @@ from .base import (
RpcException,
TimeoutException,
AuthRequiredException,
PinComplexityException,
)
from fido2.ctap import CtapError
from fido2.ctap2 import Ctap2, ClientPin
from fido2.ctap2.credman import CredentialManagement
from fido2.ctap2.bio import BioEnrollment, FPBioEnrollment, CaptureError
from fido2.pcsc import CtapPcscDevice
from fido2.hid import CtapHidDevice
from yubikit.core.fido import FidoConnection
from ykman.hid import list_ctap_devices as list_ctap
from ykman.pcsc import list_devices as list_ccid
@ -46,6 +48,21 @@ class PinValidationException(RpcException):
)
class InactivityException(RpcException):
def __init__(self):
super().__init__(
"user-action-timeout",
"Failed to add fingerprint due to user inactivity.",
)
class KeyMismatchException(RpcException):
def __init__(self):
super().__init__(
"key-mismatch", "Re-inserted YubiKey does not match initial device"
)
def _ctap_id(ctap):
return (ctap.info.aaguid, ctap.info.firmware_version)
@ -60,6 +77,8 @@ def _handle_pin_error(e, client_pin):
raise PinValidationException(
pin_retries, e.code == CtapError.ERR.PIN_AUTH_BLOCKED
)
if e.code == CtapError.ERR.PIN_POLICY_VIOLATION:
raise PinComplexityException()
raise e
@ -69,7 +88,6 @@ class Ctap2Node(RpcNode):
self.ctap = Ctap2(connection)
self._info = self.ctap.info
self.client_pin = ClientPin(self.ctap)
self._auth_blocked = False
self._token = None
def get_data(self):
@ -77,7 +95,6 @@ class Ctap2Node(RpcNode):
logger.debug(f"Info: {self._info}")
data = dict(
info=asdict(self._info),
auth_blocked=self._auth_blocked,
unlocked=self._token is not None,
)
if self._info.options.get("clientPin"):
@ -94,8 +111,10 @@ class Ctap2Node(RpcNode):
data.update(uv_retries=uv_retries)
return data
def _prepare_reset_nfc(self, event, signal):
reader_name = self.ctap.device._name
@staticmethod
def _prepare_reset_nfc(device, event, signal):
# TODO: Don't use private member _name.
reader_name = device._name
devices = list_ccid(reader_name)
if not devices or devices[0].reader.name != reader_name:
raise ValueError("Unable to isolate NFC reader")
@ -106,10 +125,12 @@ class Ctap2Node(RpcNode):
removed = False
while not event.wait(0.5):
try:
with dev.open_connection(FidoConnection):
if removed:
sleep(1.0) # Wait for the device to settle
return dev.open_connection(FidoConnection)
conn = dev.open_connection(FidoConnection)
if removed:
conn.close()
sleep(1.0) # Wait for the device to settle
return dev.open_connection(FidoConnection)
conn.close()
except CardConnectionException:
pass # Expected, ignore
except NoCardException:
@ -119,8 +140,9 @@ class Ctap2Node(RpcNode):
raise TimeoutException()
def _prepare_reset_usb(self, event, signal):
dev_path = self.ctap.device.descriptor.path
@staticmethod
def _prepare_reset_usb(device, event, signal):
dev_path = device.descriptor.path
logger.debug(f"Reset over USB: {dev_path}")
signal("reset", dict(state="remove"))
@ -148,25 +170,31 @@ class Ctap2Node(RpcNode):
@action
def reset(self, params, event, signal):
target = _ctap_id(self.ctap)
if isinstance(self.ctap.device, CtapPcscDevice):
connection = self._prepare_reset_nfc(event, signal)
device = self.ctap.device
if isinstance(device, CtapPcscDevice):
connection = self._prepare_reset_nfc(device, event, signal)
elif isinstance(device, CtapHidDevice):
connection = self._prepare_reset_usb(device, event, signal)
else:
connection = self._prepare_reset_usb(event, signal)
raise TypeError("Unsupported connection type")
logger.debug("Performing reset...")
self.ctap = Ctap2(connection)
if target != _ctap_id(self.ctap):
raise ValueError("Re-inserted YubiKey does not match initial device")
self.ctap.reset(event=event)
raise KeyMismatchException()
try:
self.ctap.reset(event=event)
except CtapError as e:
if e.code == CtapError.ERR.USER_ACTION_TIMEOUT:
raise InactivityException()
self._info = self.ctap.get_info()
self._auth_blocked = False
self._token = None
return dict()
@action(condition=lambda self: self._info.options["clientPin"])
def unlock(self, params, event, signal):
pin = params.pop("pin")
permissions = 0
permissions = ClientPin.PERMISSION(0)
if CredentialManagement.is_supported(self._info):
permissions |= ClientPin.PERMISSION.CREDENTIAL_MGMT
if BioEnrollment.is_supported(self._info):
@ -255,12 +283,14 @@ class CredentialsRpNode(RpcNode):
self.refresh()
def refresh(self):
self.refresh_rps()
self._creds = {
cred[CredentialManagement.RESULT.CREDENTIAL_ID]["id"].hex(): dict(
credential_id=cred[CredentialManagement.RESULT.CREDENTIAL_ID],
user_id=cred[CredentialManagement.RESULT.USER]["id"],
user_name=cred[CredentialManagement.RESULT.USER]["name"],
display_name=cred[CredentialManagement.RESULT.USER].get(
"displayName", None
),
)
for cred in self.credman.enumerate_creds(self.data["rp_id_hash"])
}
@ -273,17 +303,17 @@ class CredentialsRpNode(RpcNode):
return CredentialNode(
self.credman,
self._creds[name],
self.refresh,
self.refresh_rps,
)
return super().create_child(name)
class CredentialNode(RpcNode):
def __init__(self, credman, credential_data, refresh):
def __init__(self, credman, credential_data, refresh_rps):
super().__init__()
self.credman = credman
self.data = credential_data
self.refresh = refresh
self.refresh_rps = refresh_rps
def get_data(self):
return self.data
@ -291,7 +321,7 @@ class CredentialNode(RpcNode):
@action
def delete(self, params, event, signal):
self.credman.delete_cred(self.data["credential_id"])
self.refresh()
self.refresh_rps()
class FingerprintsNode(RpcNode):
@ -333,6 +363,10 @@ class FingerprintsNode(RpcNode):
signal("capture", dict(remaining=enroller.remaining))
except CaptureError as e:
signal("capture-error", dict(code=e.code))
except CtapError as e:
if e.code == CtapError.ERR.USER_ACTION_TIMEOUT:
raise InactivityException()
raise
if name:
self.bio.set_name(template_id, name)
self._templates[template_id] = name

View File

@ -13,7 +13,7 @@
# limitations under the License.
from .base import RpcNode, action
from yubikit.core import require_version, NotSupportedError, TRANSPORT
from yubikit.core import require_version, NotSupportedError, TRANSPORT, Connection
from yubikit.core.smartcard import SmartCardConnection
from yubikit.core.otp import OtpConnection
from yubikit.core.fido import FidoConnection
@ -21,6 +21,7 @@ from yubikit.management import ManagementSession, DeviceConfig, Mode, CAPABILITY
from ykman.device import list_all_devices
from dataclasses import asdict
from time import sleep
from typing import Type
import logging
logger = logging.getLogger(__name__)
@ -29,7 +30,7 @@ logger = logging.getLogger(__name__)
class ManagementNode(RpcNode):
def __init__(self, connection):
super().__init__()
self._connection_type = type(connection)
self._connection_type: Type[Connection] = type(connection)
self.session = ManagementSession(connection)
def get_data(self):
@ -54,11 +55,13 @@ class ManagementNode(RpcNode):
if self._connection_type.usb_interface in ifaces:
connection_types = [self._connection_type]
else:
connection_types = [
t
for t in [SmartCardConnection, OtpConnection, FidoConnection]
if t.usb_interface in ifaces
types: list[Type[Connection]] = [
SmartCardConnection,
OtpConnection,
# mypy doesn't support ABC.register()
FidoConnection, # type: ignore
]
connection_types = [t for t in types if t.usb_interface in ifaces]
self.session.close()
logger.debug("Waiting for device to re-appear...")
@ -97,3 +100,10 @@ class ManagementNode(RpcNode):
params.pop("auto_eject_timeout"),
)
return dict()
@action(
condition=lambda self: issubclass(self._connection_type, SmartCardConnection)
)
def device_reset(self, params, event, signal):
self.session.device_reset()
return dict()

View File

@ -20,6 +20,7 @@ from .base import (
ChildResetException,
TimeoutException,
AuthRequiredException,
PinComplexityException,
)
from yubikit.core import NotSupportedError, BadResponseError, InvalidPinError
from yubikit.core.smartcard import ApduError, SW
@ -75,10 +76,20 @@ class InvalidPinException(RpcException):
@unique
class GENERATE_TYPE(str, Enum):
PUBLIC_KEY = "publicKey"
CSR = "csr"
CERTIFICATE = "certificate"
def _handle_pin_puk_error(e):
if isinstance(e, ApduError):
if e.sw == SW.CONDITIONS_NOT_SATISFIED:
raise PinComplexityException()
if isinstance(e, InvalidPinError):
raise InvalidPinException(cause=e)
raise e
class PivNode(RpcNode):
def __init__(self, connection):
super().__init__()
@ -166,6 +177,7 @@ class PivNode(RpcNode):
key = None
if self._pivman_data.has_derived_key:
assert self._pivman_data.salt # nosec
key = derive_management_key(pin, self._pivman_data.salt)
elif self._pivman_data.has_stored_key:
pivman_prot = get_pivman_protected_data(self.session)
@ -206,21 +218,30 @@ class PivNode(RpcNode):
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)
try:
pivman_change_pin(self.session, old_pin, new_pin)
except Exception as e:
_handle_pin_puk_error(e)
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)
try:
self.session.change_puk(old_puk, new_puk)
except Exception as e:
_handle_pin_puk_error(e)
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)
try:
self.session.unblock_pin(puk, new_pin)
except Exception as e:
_handle_pin_puk_error(e)
return dict()
@action
@ -296,12 +317,25 @@ def _choose_cert(certs):
def _get_cert_info(cert):
if cert is None:
return None
try: # Prefer timezone-aware variant (cryptography >= 42)
not_before = cert.not_valid_before_utc
not_after = cert.not_valid_after_utc
except AttributeError:
not_before = cert.not_valid_before
not_after = cert.not_valid_after
try:
key_type = KEY_TYPE.from_public_key(cert.public_key())
except ValueError:
key_type = None
return dict(
key_type=key_type,
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(),
not_valid_before=not_before.isoformat(),
not_valid_after=not_after.isoformat(),
fingerprint=cert.fingerprint(hashes.SHA256()),
)
@ -340,7 +374,7 @@ class SlotsNode(RpcNode):
f"{int(slot):02x}": dict(
slot=int(slot),
name=slot.name,
has_key=metadata is not None if self._has_metadata else None,
metadata=_metadata_dict(metadata),
cert_info=_get_cert_info(cert),
)
for slot, (metadata, cert) in self._slots.items()
@ -354,6 +388,17 @@ class SlotsNode(RpcNode):
return super().create_child(name)
def _metadata_dict(metadata):
if not metadata:
return None
data = asdict(metadata)
data["public_key"] = metadata.public_key.public_bytes(
encoding=Encoding.PEM, format=PublicFormat.SubjectPublicKeyInfo
).decode()
del data["public_key_encoded"]
return data
class SlotNode(RpcNode):
def __init__(self, session, slot, metadata, certificate, refresh):
super().__init__()
@ -367,18 +412,47 @@ class SlotNode(RpcNode):
return dict(
id=f"{int(self.slot):02x}",
name=self.slot.name,
metadata=asdict(self.metadata) if self.metadata else None,
metadata=_metadata_dict(self.metadata),
certificate=self.certificate.public_bytes(encoding=Encoding.PEM).decode()
if self.certificate
else None,
)
@action(condition=lambda self: self.certificate)
@action(condition=lambda self: self.certificate or self.metadata)
def delete(self, params, event, signal):
self.session.delete_certificate(self.slot)
self.session.put_object(OBJECT_ID.CHUID, generate_chuid())
delete_cert = params.pop("delete_cert", False)
delete_key = params.pop("delete_key", False)
if not delete_cert and not delete_key:
raise ValueError("Missing delete option")
if delete_cert:
self.session.delete_certificate(self.slot)
self.session.put_object(OBJECT_ID.CHUID, generate_chuid())
self.certificate = None
if delete_key:
self.session.delete_key(self.slot)
self._refresh()
return dict()
@action(condition=lambda self: self.metadata)
def move_key(self, params, event, signal):
destination = params.pop("destination")
overwrite_key = params.pop("overwrite_key")
include_certificate = params.pop("include_certificate")
if include_certificate:
source_object = self.session.get_object(OBJECT_ID.from_slot(self.slot))
destination = SLOT(int(destination, base=16))
if overwrite_key:
self.session.delete_key(destination)
self.session.move_key(self.slot, destination)
if include_certificate:
self.session.put_object(OBJECT_ID.from_slot(destination), source_object)
self.session.delete_certificate(self.slot)
self.session.put_object(OBJECT_ID.CHUID, generate_chuid())
self.certificate = None
self._refresh()
self.certificate = None
return dict()
@action
@ -417,7 +491,7 @@ class SlotNode(RpcNode):
self._refresh()
return dict(
metadata=asdict(metadata) if metadata else None,
metadata=_metadata_dict(metadata),
public_key=private_key.public_key()
.public_bytes(
encoding=Encoding.PEM, format=PublicFormat.SubjectPublicKeyInfo
@ -442,6 +516,9 @@ class SlotNode(RpcNode):
public_key = self.session.generate_key(
self.slot, key_type, pin_policy, touch_policy
)
public_key_pem = public_key.public_bytes(
encoding=Encoding.PEM, format=PublicFormat.SubjectPublicKeyInfo
).decode()
if pin_policy != PIN_POLICY.NEVER:
# TODO: Check if verified?
@ -451,14 +528,17 @@ class SlotNode(RpcNode):
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)
if generate_type == GENERATE_TYPE.PUBLIC_KEY:
result = public_key_pem
elif generate_type == GENERATE_TYPE.CSR:
csr = generate_csr(self.session, self.slot, public_key, subject)
result = csr.public_bytes(encoding=Encoding.PEM).decode()
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(
cert = generate_self_signed_certificate(
self.session,
self.slot,
public_key,
@ -466,16 +546,12 @@ class SlotNode(RpcNode):
datetime.datetime.strptime(valid_from, _date_format),
datetime.datetime.strptime(valid_to, _date_format),
)
self.session.put_certificate(self.slot, result)
result = cert.public_bytes(encoding=Encoding.PEM).decode()
self.session.put_certificate(self.slot, cert)
self.session.put_object(OBJECT_ID.CHUID, generate_chuid())
else:
raise ValueError("Unsupported GENERATE_TYPE")
raise ValueError(f"Unsupported GENERATE_TYPE: {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(),
)
return dict(public_key=public_key_pem, result=result)

View File

@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from .base import RpcException
import mss
import zxingcpp
import base64
@ -21,7 +22,7 @@ import sys
import subprocess # nosec
import tempfile
from mss.exception import ScreenShotError
from PIL import Image
from PIL import Image, UnidentifiedImageError
def _capture_screen():
@ -64,11 +65,22 @@ def _capture_screen():
raise ValueError("Unable to capture screenshot")
class InvalidImageException(RpcException):
def __init__(self):
super().__init__(
"invalid-image",
"The provided file is not a valid image",
)
def scan_qr(image_data=None):
if image_data:
msg = base64.b64decode(image_data)
buf = io.BytesIO(msg)
img = Image.open(buf)
try:
msg = base64.b64decode(image_data)
buf = io.BytesIO(msg)
img = Image.open(buf)
except UnidentifiedImageError:
raise InvalidImageException()
else:
img = _capture_screen()

View File

@ -14,7 +14,8 @@
from .base import RpcNode, action, child
from yubikit.core import NotSupportedError
from yubikit.core import NotSupportedError, CommandError
from yubikit.core.otp import modhex_encode, modhex_decode
from yubikit.yubiotp import (
YubiOtpSession,
SLOT,
@ -25,7 +26,17 @@ from yubikit.yubiotp import (
YubiOtpSlotConfiguration,
StaticTicketSlotConfiguration,
)
from ykman.otp import generate_static_pw, format_csv
from yubikit.oath import parse_b32_key
from ykman.scancodes import KEYBOARD_LAYOUT, encode
from typing import Dict
import struct
_FAIL_MSG = (
"Failed to write to the YubiKey. Make sure the device does not "
"have restricted access"
)
class YubiOtpNode(RpcNode):
@ -54,7 +65,10 @@ class YubiOtpNode(RpcNode):
@action
def swap(self, params, event, signal):
self.session.swap_slots()
try:
self.session.swap_slots()
except CommandError:
raise ValueError(_FAIL_MSG)
return dict()
@child
@ -65,6 +79,29 @@ class YubiOtpNode(RpcNode):
def two(self):
return SlotNode(self.session, SLOT.TWO)
@action(closes_child=False)
def serial_modhex(self, params, event, signal):
serial = params["serial"]
return dict(encoded=modhex_encode(b"\xff\x00" + struct.pack(b">I", serial)))
@action(closes_child=False)
def generate_static(self, params, event, signal):
layout, length = params["layout"], int(params["length"])
return dict(password=generate_static_pw(length, KEYBOARD_LAYOUT[layout]))
@action(closes_child=False)
def keyboard_layouts(self, params, event, signal):
return {layout.name: [sc for sc in layout.value] for layout in KEYBOARD_LAYOUT}
@action(closes_child=False)
def format_yubiotp_csv(self, params, even, signal):
serial = params["serial"]
public_id = modhex_decode(params["public_id"])
private_id = bytes.fromhex(params["private_id"])
key = bytes.fromhex(params["key"])
return dict(csv=format_csv(serial, public_id, private_id, key))
_CONFIG_TYPES = dict(
hmac_sha1=HmacSha1SlotConfiguration,
@ -113,7 +150,12 @@ class SlotNode(RpcNode):
@action(condition=lambda self: self._maybe_configured(self.slot))
def delete(self, params, event, signal):
self.session.delete_slot(self.slot, params.pop("cur_acc_code", None))
try:
access_code = params.pop("curr_acc_code", None)
access_code = bytes.fromhex(access_code) if access_code else None
self.session.delete_slot(self.slot, access_code)
except CommandError:
raise ValueError(_FAIL_MSG)
@action(condition=lambda self: self._can_calculate(self.slot))
def calculate(self, params, event, signal):
@ -121,7 +163,7 @@ class SlotNode(RpcNode):
response = self.session.calculate_hmac_sha1(self.slot, challenge, event)
return dict(response=response)
def _apply_config(self, config, params):
def _apply_options(self, config, options):
for option in (
"serial_api_visible",
"serial_usb_visible",
@ -140,39 +182,63 @@ class SlotNode(RpcNode):
"short_ticket",
"manual_update",
):
if option in params:
getattr(config, option)(params.pop(option))
if option in options:
getattr(config, option)(options.pop(option))
for option in ("tabs", "delay", "pacing", "strong_password"):
if option in params:
getattr(config, option)(*params.pop(option))
if option in options:
getattr(config, option)(*options.pop(option))
if "token_id" in params:
token_id, *args = params.pop("token_id")
if "token_id" in options:
token_id, *args = options.pop("token_id")
config.token_id(bytes.fromhex(token_id), *args)
return config
def _get_config(self, type, **kwargs):
config = None
if type in _CONFIG_TYPES:
if type == "hmac_sha1":
config = _CONFIG_TYPES[type](bytes.fromhex(kwargs["key"]))
elif type == "hotp":
config = _CONFIG_TYPES[type](parse_b32_key(kwargs["key"]))
elif type == "static_password":
config = _CONFIG_TYPES[type](
encode(
kwargs["password"], KEYBOARD_LAYOUT[kwargs["keyboard_layout"]]
)
)
elif type == "yubiotp":
config = _CONFIG_TYPES[type](
fixed=modhex_decode(kwargs["public_id"]),
uid=bytes.fromhex(kwargs["private_id"]),
key=bytes.fromhex(kwargs["key"]),
)
else:
raise ValueError("No supported configuration type provided.")
return config
@action
def put(self, params, event, signal):
config = None
for key in _CONFIG_TYPES:
if key in params:
if config is not None:
raise ValueError("Only one configuration type can be provided.")
config = _CONFIG_TYPES[key](
*(bytes.fromhex(arg) for arg in params.pop(key))
)
if config is None:
raise ValueError("No supported configuration type provided.")
self._apply_config(config, params)
self.session.put_configuration(
self.slot,
config,
params.pop("acc_code", None),
params.pop("cur_acc_code", None),
)
return dict()
type = params.pop("type")
options = params.pop("options", {})
access_code = params.pop("curr_acc_code", None)
access_code = bytes.fromhex(access_code) if access_code else None
args = params
config = self._get_config(type, **args)
self._apply_options(config, options)
try:
self.session.put_configuration(
self.slot,
config,
access_code,
access_code,
)
return dict()
except CommandError:
raise ValueError(_FAIL_MSG)
@action(
condition=lambda self: self._state.version >= (2, 2, 0)
@ -180,7 +246,7 @@ class SlotNode(RpcNode):
)
def update(self, params, event, signal):
config = UpdateConfiguration()
self._apply_config(config, params)
self._apply_options(config, params)
self.session.update_configuration(
self.slot,
config,

594
helper/poetry.lock generated Executable file → Normal file
View File

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand.
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
[[package]]
name = "altgraph"
@ -11,6 +11,21 @@ files = [
{file = "altgraph-0.17.4.tar.gz", hash = "sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406"},
]
[[package]]
name = "backports-tarfile"
version = "1.2.0"
description = "Backport of CPython tarfile module"
optional = false
python-versions = ">=3.8"
files = [
{file = "backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34"},
{file = "backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991"},
]
[package.extras]
docs = ["furo", "jaraco.packaging (>=9.3)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
testing = ["jaraco.test", "pytest (!=8.0.*)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)"]
[[package]]
name = "cffi"
version = "1.16.0"
@ -102,58 +117,67 @@ files = [
[[package]]
name = "cryptography"
version = "41.0.4"
version = "42.0.8"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
optional = false
python-versions = ">=3.7"
files = [
{file = "cryptography-41.0.4-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:80907d3faa55dc5434a16579952ac6da800935cd98d14dbd62f6f042c7f5e839"},
{file = "cryptography-41.0.4-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:35c00f637cd0b9d5b6c6bd11b6c3359194a8eba9c46d4e875a3660e3b400005f"},
{file = "cryptography-41.0.4-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cecfefa17042941f94ab54f769c8ce0fe14beff2694e9ac684176a2535bf9714"},
{file = "cryptography-41.0.4-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e40211b4923ba5a6dc9769eab704bdb3fbb58d56c5b336d30996c24fcf12aadb"},
{file = "cryptography-41.0.4-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:23a25c09dfd0d9f28da2352503b23e086f8e78096b9fd585d1d14eca01613e13"},
{file = "cryptography-41.0.4-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2ed09183922d66c4ec5fdaa59b4d14e105c084dd0febd27452de8f6f74704143"},
{file = "cryptography-41.0.4-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:5a0f09cefded00e648a127048119f77bc2b2ec61e736660b5789e638f43cc397"},
{file = "cryptography-41.0.4-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:9eeb77214afae972a00dee47382d2591abe77bdae166bda672fb1e24702a3860"},
{file = "cryptography-41.0.4-cp37-abi3-win32.whl", hash = "sha256:3b224890962a2d7b57cf5eeb16ccaafba6083f7b811829f00476309bce2fe0fd"},
{file = "cryptography-41.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:c880eba5175f4307129784eca96f4e70b88e57aa3f680aeba3bab0e980b0f37d"},
{file = "cryptography-41.0.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:004b6ccc95943f6a9ad3142cfabcc769d7ee38a3f60fb0dddbfb431f818c3a67"},
{file = "cryptography-41.0.4-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:86defa8d248c3fa029da68ce61fe735432b047e32179883bdb1e79ed9bb8195e"},
{file = "cryptography-41.0.4-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:37480760ae08065437e6573d14be973112c9e6dcaf5f11d00147ee74f37a3829"},
{file = "cryptography-41.0.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b5f4dfe950ff0479f1f00eda09c18798d4f49b98f4e2006d644b3301682ebdca"},
{file = "cryptography-41.0.4-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7e53db173370dea832190870e975a1e09c86a879b613948f09eb49324218c14d"},
{file = "cryptography-41.0.4-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5b72205a360f3b6176485a333256b9bcd48700fc755fef51c8e7e67c4b63e3ac"},
{file = "cryptography-41.0.4-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:93530900d14c37a46ce3d6c9e6fd35dbe5f5601bf6b3a5c325c7bffc030344d9"},
{file = "cryptography-41.0.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:efc8ad4e6fc4f1752ebfb58aefece8b4e3c4cae940b0994d43649bdfce8d0d4f"},
{file = "cryptography-41.0.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c3391bd8e6de35f6f1140e50aaeb3e2b3d6a9012536ca23ab0d9c35ec18c8a91"},
{file = "cryptography-41.0.4-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:0d9409894f495d465fe6fda92cb70e8323e9648af912d5b9141d616df40a87b8"},
{file = "cryptography-41.0.4-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8ac4f9ead4bbd0bc8ab2d318f97d85147167a488be0e08814a37eb2f439d5cf6"},
{file = "cryptography-41.0.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:047c4603aeb4bbd8db2756e38f5b8bd7e94318c047cfe4efeb5d715e08b49311"},
{file = "cryptography-41.0.4.tar.gz", hash = "sha256:7febc3094125fc126a7f6fb1f420d0da639f3f32cb15c8ff0dc3997c4549f51a"},
{file = "cryptography-42.0.8-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:81d8a521705787afe7a18d5bfb47ea9d9cc068206270aad0b96a725022e18d2e"},
{file = "cryptography-42.0.8-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:961e61cefdcb06e0c6d7e3a1b22ebe8b996eb2bf50614e89384be54c48c6b63d"},
{file = "cryptography-42.0.8-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3ec3672626e1b9e55afd0df6d774ff0e953452886e06e0f1eb7eb0c832e8902"},
{file = "cryptography-42.0.8-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e599b53fd95357d92304510fb7bda8523ed1f79ca98dce2f43c115950aa78801"},
{file = "cryptography-42.0.8-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5226d5d21ab681f432a9c1cf8b658c0cb02533eece706b155e5fbd8a0cdd3949"},
{file = "cryptography-42.0.8-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6b7c4f03ce01afd3b76cf69a5455caa9cfa3de8c8f493e0d3ab7d20611c8dae9"},
{file = "cryptography-42.0.8-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:2346b911eb349ab547076f47f2e035fc8ff2c02380a7cbbf8d87114fa0f1c583"},
{file = "cryptography-42.0.8-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:ad803773e9df0b92e0a817d22fd8a3675493f690b96130a5e24f1b8fabbea9c7"},
{file = "cryptography-42.0.8-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2f66d9cd9147ee495a8374a45ca445819f8929a3efcd2e3df6428e46c3cbb10b"},
{file = "cryptography-42.0.8-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d45b940883a03e19e944456a558b67a41160e367a719833c53de6911cabba2b7"},
{file = "cryptography-42.0.8-cp37-abi3-win32.whl", hash = "sha256:a0c5b2b0585b6af82d7e385f55a8bc568abff8923af147ee3c07bd8b42cda8b2"},
{file = "cryptography-42.0.8-cp37-abi3-win_amd64.whl", hash = "sha256:57080dee41209e556a9a4ce60d229244f7a66ef52750f813bfbe18959770cfba"},
{file = "cryptography-42.0.8-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:dea567d1b0e8bc5764b9443858b673b734100c2871dc93163f58c46a97a83d28"},
{file = "cryptography-42.0.8-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4783183f7cb757b73b2ae9aed6599b96338eb957233c58ca8f49a49cc32fd5e"},
{file = "cryptography-42.0.8-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0608251135d0e03111152e41f0cc2392d1e74e35703960d4190b2e0f4ca9c70"},
{file = "cryptography-42.0.8-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dc0fdf6787f37b1c6b08e6dfc892d9d068b5bdb671198c72072828b80bd5fe4c"},
{file = "cryptography-42.0.8-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9c0c1716c8447ee7dbf08d6db2e5c41c688544c61074b54fc4564196f55c25a7"},
{file = "cryptography-42.0.8-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:fff12c88a672ab9c9c1cf7b0c80e3ad9e2ebd9d828d955c126be4fd3e5578c9e"},
{file = "cryptography-42.0.8-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:cafb92b2bc622cd1aa6a1dce4b93307792633f4c5fe1f46c6b97cf67073ec961"},
{file = "cryptography-42.0.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:31f721658a29331f895a5a54e7e82075554ccfb8b163a18719d342f5ffe5ecb1"},
{file = "cryptography-42.0.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b297f90c5723d04bcc8265fc2a0f86d4ea2e0f7ab4b6994459548d3a6b992a14"},
{file = "cryptography-42.0.8-cp39-abi3-win32.whl", hash = "sha256:2f88d197e66c65be5e42cd72e5c18afbfae3f741742070e3019ac8f4ac57262c"},
{file = "cryptography-42.0.8-cp39-abi3-win_amd64.whl", hash = "sha256:fa76fbb7596cc5839320000cdd5d0955313696d9511debab7ee7278fc8b5c84a"},
{file = "cryptography-42.0.8-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ba4f0a211697362e89ad822e667d8d340b4d8d55fae72cdd619389fb5912eefe"},
{file = "cryptography-42.0.8-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:81884c4d096c272f00aeb1f11cf62ccd39763581645b0812e99a91505fa48e0c"},
{file = "cryptography-42.0.8-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c9bb2ae11bfbab395bdd072985abde58ea9860ed84e59dbc0463a5d0159f5b71"},
{file = "cryptography-42.0.8-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7016f837e15b0a1c119d27ecd89b3515f01f90a8615ed5e9427e30d9cdbfed3d"},
{file = "cryptography-42.0.8-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5a94eccb2a81a309806027e1670a358b99b8fe8bfe9f8d329f27d72c094dde8c"},
{file = "cryptography-42.0.8-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dec9b018df185f08483f294cae6ccac29e7a6e0678996587363dc352dc65c842"},
{file = "cryptography-42.0.8-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:343728aac38decfdeecf55ecab3264b015be68fc2816ca800db649607aeee648"},
{file = "cryptography-42.0.8-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:013629ae70b40af70c9a7a5db40abe5d9054e6f4380e50ce769947b73bf3caad"},
{file = "cryptography-42.0.8.tar.gz", hash = "sha256:8d09d05439ce7baa8e9e95b07ec5b6c886f548deb7e0f69ef25f64b3bce842f2"},
]
[package.dependencies]
cffi = ">=1.12"
cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""}
[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)"]
docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"]
nox = ["nox"]
pep8test = ["black", "check-sdist", "mypy", "ruff"]
pep8test = ["check-sdist", "click", "mypy", "ruff"]
sdist = ["build"]
ssh = ["bcrypt (>=3.1.5)"]
test = ["pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"]
test = ["certifi", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"]
test-randomorder = ["pytest-randomly"]
[[package]]
name = "exceptiongroup"
version = "1.1.3"
version = "1.2.1"
description = "Backport of PEP 654 (exception groups)"
optional = false
python-versions = ">=3.7"
files = [
{file = "exceptiongroup-1.1.3-py3-none-any.whl", hash = "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"},
{file = "exceptiongroup-1.1.3.tar.gz", hash = "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9"},
{file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"},
{file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"},
]
[package.extras]
@ -161,49 +185,49 @@ test = ["pytest (>=6)"]
[[package]]
name = "fido2"
version = "1.1.2"
version = "1.1.3"
description = "FIDO2/WebAuthn library for implementing clients and servers."
optional = false
python-versions = ">=3.7,<4.0"
python-versions = ">=3.8,<4.0"
files = [
{file = "fido2-1.1.2-py3-none-any.whl", hash = "sha256:a3b7d7d233dec3a4fa0d6178fc34d1cce17b820005a824f6ab96917a1e3be8d8"},
{file = "fido2-1.1.2.tar.gz", hash = "sha256:6110d913106f76199201b32d262b2857562cc46ba1d0b9c51fbce30dc936c573"},
{file = "fido2-1.1.3-py3-none-any.whl", hash = "sha256:6be34c0b9fe85e4911fd2d103cce7ae8ce2f064384a7a2a3bd970b3ef7702931"},
{file = "fido2-1.1.3.tar.gz", hash = "sha256:26100f226d12ced621ca6198528ce17edf67b78df4287aee1285fee3cd5aa9fc"},
]
[package.dependencies]
cryptography = ">=2.6,<35 || >35,<44"
cryptography = ">=2.6,<35 || >35,<45"
[package.extras]
pcsc = ["pyscard (>=1.9,<3)"]
[[package]]
name = "importlib-metadata"
version = "6.8.0"
version = "8.0.0"
description = "Read metadata from Python packages"
optional = false
python-versions = ">=3.8"
files = [
{file = "importlib_metadata-6.8.0-py3-none-any.whl", hash = "sha256:3ebb78df84a805d7698245025b975d9d67053cd94c79245ba4b3eb694abe68bb"},
{file = "importlib_metadata-6.8.0.tar.gz", hash = "sha256:dbace7892d8c0c4ac1ad096662232f831d4e64f4c4545bd53016a3e9d4654743"},
{file = "importlib_metadata-8.0.0-py3-none-any.whl", hash = "sha256:15584cf2b1bf449d98ff8a6ff1abef57bf20f3ac6454f431736cd3e660921b2f"},
{file = "importlib_metadata-8.0.0.tar.gz", hash = "sha256:188bd24e4c346d3f0a933f275c2fec67050326a856b9a359881d7c2a697e8812"},
]
[package.dependencies]
zipp = ">=0.5"
[package.extras]
docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
perf = ["ipython"]
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"]
test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-perf (>=0.9.2)", "pytest-ruff (>=0.2.1)"]
[[package]]
name = "importlib-resources"
version = "6.1.0"
version = "6.4.0"
description = "Read resources from Python packages"
optional = false
python-versions = ">=3.8"
files = [
{file = "importlib_resources-6.1.0-py3-none-any.whl", hash = "sha256:aa50258bbfa56d4e33fbd8aa3ef48ded10d1735f11532b8df95388cc6bdb7e83"},
{file = "importlib_resources-6.1.0.tar.gz", hash = "sha256:9d48dcccc213325e810fd723e7fbb45ccb39f6cf5c31f00cf2b965f5f10f3cb9"},
{file = "importlib_resources-6.4.0-py3-none-any.whl", hash = "sha256:50d10f043df931902d4194ea07ec57960f66a80449ff867bfe782b4c486ba78c"},
{file = "importlib_resources-6.4.0.tar.gz", hash = "sha256:cdb2b453b8046ca4e3798eb1d84f3cce1446a0e8e7b5ef4efb600f19fc398145"},
]
[package.dependencies]
@ -211,7 +235,7 @@ zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""}
[package.extras]
docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "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", "zipp (>=3.17)"]
testing = ["jaraco.test (>=5.4)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)", "zipp (>=3.17)"]
[[package]]
name = "iniconfig"
@ -226,13 +250,13 @@ files = [
[[package]]
name = "jaraco-classes"
version = "3.3.0"
version = "3.4.0"
description = "Utility functions for Python class constructs"
optional = false
python-versions = ">=3.8"
files = [
{file = "jaraco.classes-3.3.0-py3-none-any.whl", hash = "sha256:10afa92b6743f25c0cf5f37c6bb6e18e2c5bb84a16527ccfc0040ea377e7aaeb"},
{file = "jaraco.classes-3.3.0.tar.gz", hash = "sha256:c063dd08e89217cee02c8d5e5ec560f2c8ce6cdc2fcdc2e68f7b2e5547ed3621"},
{file = "jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790"},
{file = "jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd"},
]
[package.dependencies]
@ -240,7 +264,43 @@ more-itertools = "*"
[package.extras]
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"]
testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"]
[[package]]
name = "jaraco-context"
version = "5.3.0"
description = "Useful decorators and context managers"
optional = false
python-versions = ">=3.8"
files = [
{file = "jaraco.context-5.3.0-py3-none-any.whl", hash = "sha256:3e16388f7da43d384a1a7cd3452e72e14732ac9fe459678773a3608a812bf266"},
{file = "jaraco.context-5.3.0.tar.gz", hash = "sha256:c2f67165ce1f9be20f32f650f25d8edfc1646a8aeee48ae06fb35f90763576d2"},
]
[package.dependencies]
"backports.tarfile" = {version = "*", markers = "python_version < \"3.12\""}
[package.extras]
docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
testing = ["portend", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"]
[[package]]
name = "jaraco-functools"
version = "4.0.1"
description = "Functools like those found in stdlib"
optional = false
python-versions = ">=3.8"
files = [
{file = "jaraco.functools-4.0.1-py3-none-any.whl", hash = "sha256:3b24ccb921d6b593bdceb56ce14799204f473976e2a9d4b15b04d0f2c2326664"},
{file = "jaraco_functools-4.0.1.tar.gz", hash = "sha256:d33fa765374c0611b52f8b3a795f8900869aa88c84769d4d1746cd68fb28c3e8"},
]
[package.dependencies]
more-itertools = "*"
[package.extras]
docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"]
testing = ["jaraco.classes", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"]
[[package]]
name = "jeepney"
@ -259,27 +319,29 @@ trio = ["async_generator", "trio"]
[[package]]
name = "keyring"
version = "23.13.1"
version = "25.2.1"
description = "Store and access your passwords safely."
optional = false
python-versions = ">=3.7"
python-versions = ">=3.8"
files = [
{file = "keyring-23.13.1-py3-none-any.whl", hash = "sha256:771ed2a91909389ed6148631de678f82ddc73737d85a927f382a8a1b157898cd"},
{file = "keyring-23.13.1.tar.gz", hash = "sha256:ba2e15a9b35e21908d0aaf4e0a47acc52d6ae33444df0da2b49d41a46ef6d678"},
{file = "keyring-25.2.1-py3-none-any.whl", hash = "sha256:2458681cdefc0dbc0b7eb6cf75d0b98e59f9ad9b2d4edd319d18f68bdca95e50"},
{file = "keyring-25.2.1.tar.gz", hash = "sha256:daaffd42dbda25ddafb1ad5fec4024e5bbcfe424597ca1ca452b299861e49f1b"},
]
[package.dependencies]
importlib-metadata = {version = ">=4.11.4", markers = "python_version < \"3.12\""}
importlib-resources = {version = "*", markers = "python_version < \"3.9\""}
"jaraco.classes" = "*"
"jaraco.context" = "*"
"jaraco.functools" = "*"
jeepney = {version = ">=0.4.2", markers = "sys_platform == \"linux\""}
pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""}
SecretStorage = {version = ">=3.2", markers = "sys_platform == \"linux\""}
[package.extras]
completion = ["shtab"]
docs = ["furo", "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)"]
completion = ["shtab (>=1.1.0)"]
docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
testing = ["pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"]
[[package]]
name = "macholib"
@ -297,13 +359,13 @@ altgraph = ">=0.17"
[[package]]
name = "more-itertools"
version = "10.1.0"
version = "10.3.0"
description = "More routines for operating on iterables, beyond itertools"
optional = false
python-versions = ">=3.8"
files = [
{file = "more-itertools-10.1.0.tar.gz", hash = "sha256:626c369fa0eb37bac0291bce8259b332fd59ac792fa5497b59837309cd5b114a"},
{file = "more_itertools-10.1.0-py3-none-any.whl", hash = "sha256:64e0735fcfdc6f3464ea133afe8ea4483b1c5fe3a3d69852e6503b43a0b222e6"},
{file = "more-itertools-10.3.0.tar.gz", hash = "sha256:e5d93ef411224fbcef366a6e8ddc4c5781bc6359d43412a65dd5964e46111463"},
{file = "more_itertools-10.3.0-py3-none-any.whl", hash = "sha256:ea6a02e24a9161e51faad17a8782b92a0df82c12c1c8886fec7f0c3fa1a1b320"},
]
[[package]]
@ -317,15 +379,73 @@ files = [
{file = "mss-9.0.1.tar.gz", hash = "sha256:6eb7b9008cf27428811fa33aeb35f3334db81e3f7cc2dd49ec7c6e5a94b39f12"},
]
[[package]]
name = "mypy"
version = "1.10.1"
description = "Optional static typing for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "mypy-1.10.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e36f229acfe250dc660790840916eb49726c928e8ce10fbdf90715090fe4ae02"},
{file = "mypy-1.10.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:51a46974340baaa4145363b9e051812a2446cf583dfaeba124af966fa44593f7"},
{file = "mypy-1.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:901c89c2d67bba57aaaca91ccdb659aa3a312de67f23b9dfb059727cce2e2e0a"},
{file = "mypy-1.10.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0cd62192a4a32b77ceb31272d9e74d23cd88c8060c34d1d3622db3267679a5d9"},
{file = "mypy-1.10.1-cp310-cp310-win_amd64.whl", hash = "sha256:a2cbc68cb9e943ac0814c13e2452d2046c2f2b23ff0278e26599224cf164e78d"},
{file = "mypy-1.10.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bd6f629b67bb43dc0d9211ee98b96d8dabc97b1ad38b9b25f5e4c4d7569a0c6a"},
{file = "mypy-1.10.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1bbb3a6f5ff319d2b9d40b4080d46cd639abe3516d5a62c070cf0114a457d84"},
{file = "mypy-1.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8edd4e9bbbc9d7b79502eb9592cab808585516ae1bcc1446eb9122656c6066f"},
{file = "mypy-1.10.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6166a88b15f1759f94a46fa474c7b1b05d134b1b61fca627dd7335454cc9aa6b"},
{file = "mypy-1.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:5bb9cd11c01c8606a9d0b83ffa91d0b236a0e91bc4126d9ba9ce62906ada868e"},
{file = "mypy-1.10.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d8681909f7b44d0b7b86e653ca152d6dff0eb5eb41694e163c6092124f8246d7"},
{file = "mypy-1.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:378c03f53f10bbdd55ca94e46ec3ba255279706a6aacaecac52ad248f98205d3"},
{file = "mypy-1.10.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bacf8f3a3d7d849f40ca6caea5c055122efe70e81480c8328ad29c55c69e93e"},
{file = "mypy-1.10.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:701b5f71413f1e9855566a34d6e9d12624e9e0a8818a5704d74d6b0402e66c04"},
{file = "mypy-1.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c4c2992f6ea46ff7fce0072642cfb62af7a2484efe69017ed8b095f7b39ef31"},
{file = "mypy-1.10.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:604282c886497645ffb87b8f35a57ec773a4a2721161e709a4422c1636ddde5c"},
{file = "mypy-1.10.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37fd87cab83f09842653f08de066ee68f1182b9b5282e4634cdb4b407266bade"},
{file = "mypy-1.10.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8addf6313777dbb92e9564c5d32ec122bf2c6c39d683ea64de6a1fd98b90fe37"},
{file = "mypy-1.10.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5cc3ca0a244eb9a5249c7c583ad9a7e881aa5d7b73c35652296ddcdb33b2b9c7"},
{file = "mypy-1.10.1-cp38-cp38-win_amd64.whl", hash = "sha256:1b3a2ffce52cc4dbaeee4df762f20a2905aa171ef157b82192f2e2f368eec05d"},
{file = "mypy-1.10.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fe85ed6836165d52ae8b88f99527d3d1b2362e0cb90b005409b8bed90e9059b3"},
{file = "mypy-1.10.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c2ae450d60d7d020d67ab440c6e3fae375809988119817214440033f26ddf7bf"},
{file = "mypy-1.10.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6be84c06e6abd72f960ba9a71561c14137a583093ffcf9bbfaf5e613d63fa531"},
{file = "mypy-1.10.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2189ff1e39db399f08205e22a797383613ce1cb0cb3b13d8bcf0170e45b96cc3"},
{file = "mypy-1.10.1-cp39-cp39-win_amd64.whl", hash = "sha256:97a131ee36ac37ce9581f4220311247ab6cba896b4395b9c87af0675a13a755f"},
{file = "mypy-1.10.1-py3-none-any.whl", hash = "sha256:71d8ac0b906354ebda8ef1673e5fde785936ac1f29ff6987c7483cfbd5a4235a"},
{file = "mypy-1.10.1.tar.gz", hash = "sha256:1f8f492d7db9e3593ef42d4f115f04e556130f2819ad33ab84551403e97dd4c0"},
]
[package.dependencies]
mypy-extensions = ">=1.0.0"
tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
typing-extensions = ">=4.1.0"
[package.extras]
dmypy = ["psutil (>=4.0)"]
install-types = ["pip"]
mypyc = ["setuptools (>=50)"]
reports = ["lxml"]
[[package]]
name = "mypy-extensions"
version = "1.0.0"
description = "Type system extensions for programs checked with the mypy type checker."
optional = false
python-versions = ">=3.5"
files = [
{file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
{file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
]
[[package]]
name = "packaging"
version = "23.2"
version = "24.1"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.7"
python-versions = ">=3.8"
files = [
{file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"},
{file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"},
{file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"},
{file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"},
]
[[package]]
@ -341,80 +461,110 @@ files = [
[[package]]
name = "pillow"
version = "10.0.1"
version = "10.4.0"
description = "Python Imaging Library (Fork)"
optional = false
python-versions = ">=3.8"
files = [
{file = "Pillow-10.0.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:8f06be50669087250f319b706decf69ca71fdecd829091a37cc89398ca4dc17a"},
{file = "Pillow-10.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:50bd5f1ebafe9362ad622072a1d2f5850ecfa44303531ff14353a4059113b12d"},
{file = "Pillow-10.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e6a90167bcca1216606223a05e2cf991bb25b14695c518bc65639463d7db722d"},
{file = "Pillow-10.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f11c9102c56ffb9ca87134bd025a43d2aba3f1155f508eff88f694b33a9c6d19"},
{file = "Pillow-10.0.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:186f7e04248103482ea6354af6d5bcedb62941ee08f7f788a1c7707bc720c66f"},
{file = "Pillow-10.0.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:0462b1496505a3462d0f35dc1c4d7b54069747d65d00ef48e736acda2c8cbdff"},
{file = "Pillow-10.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d889b53ae2f030f756e61a7bff13684dcd77e9af8b10c6048fb2c559d6ed6eaf"},
{file = "Pillow-10.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:552912dbca585b74d75279a7570dd29fa43b6d93594abb494ebb31ac19ace6bd"},
{file = "Pillow-10.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:787bb0169d2385a798888e1122c980c6eff26bf941a8ea79747d35d8f9210ca0"},
{file = "Pillow-10.0.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:fd2a5403a75b54661182b75ec6132437a181209b901446ee5724b589af8edef1"},
{file = "Pillow-10.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2d7e91b4379f7a76b31c2dda84ab9e20c6220488e50f7822e59dac36b0cd92b1"},
{file = "Pillow-10.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19e9adb3f22d4c416e7cd79b01375b17159d6990003633ff1d8377e21b7f1b21"},
{file = "Pillow-10.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93139acd8109edcdeffd85e3af8ae7d88b258b3a1e13a038f542b79b6d255c54"},
{file = "Pillow-10.0.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:92a23b0431941a33242b1f0ce6c88a952e09feeea9af4e8be48236a68ffe2205"},
{file = "Pillow-10.0.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:cbe68deb8580462ca0d9eb56a81912f59eb4542e1ef8f987405e35a0179f4ea2"},
{file = "Pillow-10.0.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:522ff4ac3aaf839242c6f4e5b406634bfea002469656ae8358644fc6c4856a3b"},
{file = "Pillow-10.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:84efb46e8d881bb06b35d1d541aa87f574b58e87f781cbba8d200daa835b42e1"},
{file = "Pillow-10.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:898f1d306298ff40dc1b9ca24824f0488f6f039bc0e25cfb549d3195ffa17088"},
{file = "Pillow-10.0.1-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:bcf1207e2f2385a576832af02702de104be71301c2696d0012b1b93fe34aaa5b"},
{file = "Pillow-10.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5d6c9049c6274c1bb565021367431ad04481ebb54872edecfcd6088d27edd6ed"},
{file = "Pillow-10.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28444cb6ad49726127d6b340217f0627abc8732f1194fd5352dec5e6a0105635"},
{file = "Pillow-10.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de596695a75496deb3b499c8c4f8e60376e0516e1a774e7bc046f0f48cd620ad"},
{file = "Pillow-10.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:2872f2d7846cf39b3dbff64bc1104cc48c76145854256451d33c5faa55c04d1a"},
{file = "Pillow-10.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:4ce90f8a24e1c15465048959f1e94309dfef93af272633e8f37361b824532e91"},
{file = "Pillow-10.0.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ee7810cf7c83fa227ba9125de6084e5e8b08c59038a7b2c9045ef4dde61663b4"},
{file = "Pillow-10.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b1be1c872b9b5fcc229adeadbeb51422a9633abd847c0ff87dc4ef9bb184ae08"},
{file = "Pillow-10.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:98533fd7fa764e5f85eebe56c8e4094db912ccbe6fbf3a58778d543cadd0db08"},
{file = "Pillow-10.0.1-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:764d2c0daf9c4d40ad12fbc0abd5da3af7f8aa11daf87e4fa1b834000f4b6b0a"},
{file = "Pillow-10.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:fcb59711009b0168d6ee0bd8fb5eb259c4ab1717b2f538bbf36bacf207ef7a68"},
{file = "Pillow-10.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:697a06bdcedd473b35e50a7e7506b1d8ceb832dc238a336bd6f4f5aa91a4b500"},
{file = "Pillow-10.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f665d1e6474af9f9da5e86c2a3a2d2d6204e04d5af9c06b9d42afa6ebde3f21"},
{file = "Pillow-10.0.1-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:2fa6dd2661838c66f1a5473f3b49ab610c98a128fc08afbe81b91a1f0bf8c51d"},
{file = "Pillow-10.0.1-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:3a04359f308ebee571a3127fdb1bd01f88ba6f6fb6d087f8dd2e0d9bff43f2a7"},
{file = "Pillow-10.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:723bd25051454cea9990203405fa6b74e043ea76d4968166dfd2569b0210886a"},
{file = "Pillow-10.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:71671503e3015da1b50bd18951e2f9daf5b6ffe36d16f1eb2c45711a301521a7"},
{file = "Pillow-10.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:44e7e4587392953e5e251190a964675f61e4dae88d1e6edbe9f36d6243547ff3"},
{file = "Pillow-10.0.1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:3855447d98cced8670aaa63683808df905e956f00348732448b5a6df67ee5849"},
{file = "Pillow-10.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ed2d9c0704f2dc4fa980b99d565c0c9a543fe5101c25b3d60488b8ba80f0cce1"},
{file = "Pillow-10.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5bb289bb835f9fe1a1e9300d011eef4d69661bb9b34d5e196e5e82c4cb09b37"},
{file = "Pillow-10.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a0d3e54ab1df9df51b914b2233cf779a5a10dfd1ce339d0421748232cea9876"},
{file = "Pillow-10.0.1-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:2cc6b86ece42a11f16f55fe8903595eff2b25e0358dec635d0a701ac9586588f"},
{file = "Pillow-10.0.1-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:ca26ba5767888c84bf5a0c1a32f069e8204ce8c21d00a49c90dabeba00ce0145"},
{file = "Pillow-10.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f0b4b06da13275bc02adfeb82643c4a6385bd08d26f03068c2796f60d125f6f2"},
{file = "Pillow-10.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bc2e3069569ea9dbe88d6b8ea38f439a6aad8f6e7a6283a38edf61ddefb3a9bf"},
{file = "Pillow-10.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:8b451d6ead6e3500b6ce5c7916a43d8d8d25ad74b9102a629baccc0808c54971"},
{file = "Pillow-10.0.1-pp310-pypy310_pp73-macosx_10_10_x86_64.whl", hash = "sha256:32bec7423cdf25c9038fef614a853c9d25c07590e1a870ed471f47fb80b244db"},
{file = "Pillow-10.0.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7cf63d2c6928b51d35dfdbda6f2c1fddbe51a6bc4a9d4ee6ea0e11670dd981e"},
{file = "Pillow-10.0.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f6d3d4c905e26354e8f9d82548475c46d8e0889538cb0657aa9c6f0872a37aa4"},
{file = "Pillow-10.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:847e8d1017c741c735d3cd1883fa7b03ded4f825a6e5fcb9378fd813edee995f"},
{file = "Pillow-10.0.1-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:7f771e7219ff04b79e231d099c0a28ed83aa82af91fd5fa9fdb28f5b8d5addaf"},
{file = "Pillow-10.0.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:459307cacdd4138edee3875bbe22a2492519e060660eaf378ba3b405d1c66317"},
{file = "Pillow-10.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b059ac2c4c7a97daafa7dc850b43b2d3667def858a4f112d1aa082e5c3d6cf7d"},
{file = "Pillow-10.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:d6caf3cd38449ec3cd8a68b375e0c6fe4b6fd04edb6c9766b55ef84a6e8ddf2d"},
{file = "Pillow-10.0.1.tar.gz", hash = "sha256:d72967b06be9300fed5cfbc8b5bafceec48bf7cdc7dab66b1d2549035287191d"},
{file = "pillow-10.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e"},
{file = "pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d"},
{file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7928ecbf1ece13956b95d9cbcfc77137652b02763ba384d9ab508099a2eca856"},
{file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4d49b85c4348ea0b31ea63bc75a9f3857869174e2bf17e7aba02945cd218e6f"},
{file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b"},
{file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc"},
{file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:812f7342b0eee081eaec84d91423d1b4650bb9828eb53d8511bcef8ce5aecf1e"},
{file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ac1452d2fbe4978c2eec89fb5a23b8387aba707ac72810d9490118817d9c0b46"},
{file = "pillow-10.4.0-cp310-cp310-win32.whl", hash = "sha256:bcd5e41a859bf2e84fdc42f4edb7d9aba0a13d29a2abadccafad99de3feff984"},
{file = "pillow-10.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:ecd85a8d3e79cd7158dec1c9e5808e821feea088e2f69a974db5edf84dc53141"},
{file = "pillow-10.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:ff337c552345e95702c5fde3158acb0625111017d0e5f24bf3acdb9cc16b90d1"},
{file = "pillow-10.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c"},
{file = "pillow-10.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be"},
{file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3"},
{file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6"},
{file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe"},
{file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319"},
{file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d"},
{file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696"},
{file = "pillow-10.4.0-cp311-cp311-win32.whl", hash = "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496"},
{file = "pillow-10.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91"},
{file = "pillow-10.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22"},
{file = "pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94"},
{file = "pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597"},
{file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80"},
{file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca"},
{file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef"},
{file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a"},
{file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b"},
{file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9"},
{file = "pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42"},
{file = "pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a"},
{file = "pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9"},
{file = "pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3"},
{file = "pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb"},
{file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70"},
{file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be"},
{file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0"},
{file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc"},
{file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a"},
{file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309"},
{file = "pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060"},
{file = "pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea"},
{file = "pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d"},
{file = "pillow-10.4.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:8d4d5063501b6dd4024b8ac2f04962d661222d120381272deea52e3fc52d3736"},
{file = "pillow-10.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7c1ee6f42250df403c5f103cbd2768a28fe1a0ea1f0f03fe151c8741e1469c8b"},
{file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15e02e9bb4c21e39876698abf233c8c579127986f8207200bc8a8f6bb27acf2"},
{file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a8d4bade9952ea9a77d0c3e49cbd8b2890a399422258a77f357b9cc9be8d680"},
{file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:43efea75eb06b95d1631cb784aa40156177bf9dd5b4b03ff38979e048258bc6b"},
{file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:950be4d8ba92aca4b2bb0741285a46bfae3ca699ef913ec8416c1b78eadd64cd"},
{file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d7480af14364494365e89d6fddc510a13e5a2c3584cb19ef65415ca57252fb84"},
{file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:73664fe514b34c8f02452ffb73b7a92c6774e39a647087f83d67f010eb9a0cf0"},
{file = "pillow-10.4.0-cp38-cp38-win32.whl", hash = "sha256:e88d5e6ad0d026fba7bdab8c3f225a69f063f116462c49892b0149e21b6c0a0e"},
{file = "pillow-10.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:5161eef006d335e46895297f642341111945e2c1c899eb406882a6c61a4357ab"},
{file = "pillow-10.4.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:0ae24a547e8b711ccaaf99c9ae3cd975470e1a30caa80a6aaee9a2f19c05701d"},
{file = "pillow-10.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:298478fe4f77a4408895605f3482b6cc6222c018b2ce565c2b6b9c354ac3229b"},
{file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:134ace6dc392116566980ee7436477d844520a26a4b1bd4053f6f47d096997fd"},
{file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:930044bb7679ab003b14023138b50181899da3f25de50e9dbee23b61b4de2126"},
{file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:c76e5786951e72ed3686e122d14c5d7012f16c8303a674d18cdcd6d89557fc5b"},
{file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:b2724fdb354a868ddf9a880cb84d102da914e99119211ef7ecbdc613b8c96b3c"},
{file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dbc6ae66518ab3c5847659e9988c3b60dc94ffb48ef9168656e0019a93dbf8a1"},
{file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:06b2f7898047ae93fad74467ec3d28fe84f7831370e3c258afa533f81ef7f3df"},
{file = "pillow-10.4.0-cp39-cp39-win32.whl", hash = "sha256:7970285ab628a3779aecc35823296a7869f889b8329c16ad5a71e4901a3dc4ef"},
{file = "pillow-10.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:961a7293b2457b405967af9c77dcaa43cc1a8cd50d23c532e62d48ab6cdd56f5"},
{file = "pillow-10.4.0-cp39-cp39-win_arm64.whl", hash = "sha256:32cda9e3d601a52baccb2856b8ea1fc213c90b340c542dcef77140dfa3278a9e"},
{file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5b4815f2e65b30f5fbae9dfffa8636d992d49705723fe86a3661806e069352d4"},
{file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8f0aef4ef59694b12cadee839e2ba6afeab89c0f39a3adc02ed51d109117b8da"},
{file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4727572e2918acaa9077c919cbbeb73bd2b3ebcfe033b72f858fc9fbef0026"},
{file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff25afb18123cea58a591ea0244b92eb1e61a1fd497bf6d6384f09bc3262ec3e"},
{file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dc3e2db6ba09ffd7d02ae9141cfa0ae23393ee7687248d46a7507b75d610f4f5"},
{file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02a2be69f9c9b8c1e97cf2713e789d4e398c751ecfd9967c18d0ce304efbf885"},
{file = "pillow-10.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0755ffd4a0c6f267cccbae2e9903d95477ca2f77c4fcf3a3a09570001856c8a5"},
{file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:a02364621fe369e06200d4a16558e056fe2805d3468350df3aef21e00d26214b"},
{file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:1b5dea9831a90e9d0721ec417a80d4cbd7022093ac38a568db2dd78363b00908"},
{file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b885f89040bb8c4a1573566bbb2f44f5c505ef6e74cec7ab9068c900047f04b"},
{file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87dd88ded2e6d74d31e1e0a99a726a6765cda32d00ba72dc37f0651f306daaa8"},
{file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:2db98790afc70118bd0255c2eeb465e9767ecf1f3c25f9a1abb8ffc8cfd1fe0a"},
{file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f7baece4ce06bade126fb84b8af1c33439a76d8a6fd818970215e0560ca28c27"},
{file = "pillow-10.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:cfdd747216947628af7b259d274771d84db2268ca062dd5faf373639d00113a3"},
{file = "pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06"},
]
[package.extras]
docs = ["furo", "olefile", "sphinx (>=2.4)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinx-removed-in", "sphinxext-opengraph"]
docs = ["furo", "olefile", "sphinx (>=7.3)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"]
fpx = ["olefile"]
mic = ["olefile"]
tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"]
typing = ["typing-extensions"]
xmp = ["defusedxml"]
[[package]]
name = "pluggy"
version = "1.3.0"
version = "1.5.0"
description = "plugin and hook calling mechanisms for python"
optional = false
python-versions = ">=3.8"
files = [
{file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"},
{file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"},
{file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
{file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"},
]
[package.extras]
@ -423,79 +573,87 @@ testing = ["pytest", "pytest-benchmark"]
[[package]]
name = "pycparser"
version = "2.21"
version = "2.22"
description = "C parser in Python"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
python-versions = ">=3.8"
files = [
{file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"},
{file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"},
{file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"},
{file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"},
]
[[package]]
name = "pyinstaller"
version = "6.0.0"
version = "6.8.0"
description = "PyInstaller bundles a Python application and all its dependencies into a single package."
optional = false
python-versions = "<3.13,>=3.8"
files = [
{file = "pyinstaller-6.0.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:d84b06fb9002109bfc542e76860b81459a8585af0bbdabcfc5dcf272ef230de7"},
{file = "pyinstaller-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:aa922d1d73881d0820a341d2c406a571cc94630bdcdc275427c844a12e6e376e"},
{file = "pyinstaller-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:52e5b3a2371d7231de17515c7c78d8d4a39d70c8c095e71d55b3b83434a193a8"},
{file = "pyinstaller-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:4a75bde5cda259bb31f2294960d75b9d5c148001b2b0bd20a91f9c2116675a6c"},
{file = "pyinstaller-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:5314f6f08d2bcbc031778618ba97d9098d106119c2e616b3b081171fe42f5415"},
{file = "pyinstaller-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:0ad7cc3776ca17d0bededcc352cba2b1c89eb4817bfabaf05972b9da8c424935"},
{file = "pyinstaller-6.0.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:cccdad6cfe7a5db7d7eb8df2e5678f8375268739d5933214e180da300aa54e37"},
{file = "pyinstaller-6.0.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:fb6af82989dac7c58bd25ed9ba3323bc443f8c1f03804f69c9f5e363bf4a021c"},
{file = "pyinstaller-6.0.0-py3-none-win32.whl", hash = "sha256:68769f5e6722474bb1038e35560444659db8b951388bfe0c669bb52a640cd0eb"},
{file = "pyinstaller-6.0.0-py3-none-win_amd64.whl", hash = "sha256:438a9e0d72a57d5bba4f112d256e39ea4033c76c65414c0693d8311faa14b090"},
{file = "pyinstaller-6.0.0-py3-none-win_arm64.whl", hash = "sha256:16a473065291dd7879bf596fa20e65bd9d1e8aafc2cef1bffa3e42e707e2e68e"},
{file = "pyinstaller-6.0.0.tar.gz", hash = "sha256:d702cff041f30e7a53500b630e07b081e5328d4655023319253d73935e75ade2"},
{file = "pyinstaller-6.8.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:5ff6bc2784c1026f8e2f04aa3760cbed41408e108a9d4cf1dd52ee8351a3f6e1"},
{file = "pyinstaller-6.8.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:39ac424d2ee2457d2ab11a5091436e75a0cccae207d460d180aa1fcbbafdd528"},
{file = "pyinstaller-6.8.0-py3-none-manylinux2014_i686.whl", hash = "sha256:355832a3acc7de90a255ecacd4b9f9e166a547a79c8905d49f14e3a75c1acdb9"},
{file = "pyinstaller-6.8.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:6303c7a009f47e6a96ef65aed49f41e36ece8d079b9193ca92fe807403e5fe80"},
{file = "pyinstaller-6.8.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2b71509468c811968c0b5decb5bbe85b6292ea52d7b1f26313d2aabb673fa9a5"},
{file = "pyinstaller-6.8.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:ff31c5b99e05a4384bbe2071df67ec8b2b347640a375eae9b40218be2f1754c6"},
{file = "pyinstaller-6.8.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:000c36b13fe4cd8d0d8c2bc855b1ddcf39867b5adf389e6b5ca45b25fa3e619d"},
{file = "pyinstaller-6.8.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:fe0af018d7d5077180e3144ada89a4da5df8d07716eb7e9482834a56dc57a4e8"},
{file = "pyinstaller-6.8.0-py3-none-win32.whl", hash = "sha256:d257f6645c7334cbd66f38a4fac62c3ad614cc46302b2b5d9f8cc48c563bce0e"},
{file = "pyinstaller-6.8.0-py3-none-win_amd64.whl", hash = "sha256:81cccfa9b16699b457f4788c5cc119b50f3cd4d0db924955f15c33f2ad27a50d"},
{file = "pyinstaller-6.8.0-py3-none-win_arm64.whl", hash = "sha256:1c3060a263758cf7f0144ab4c016097b20451b2469d468763414665db1bb743d"},
{file = "pyinstaller-6.8.0.tar.gz", hash = "sha256:3f4b6520f4423fe19bcc2fd63ab7238851ae2bdcbc98f25bc5d2f97cc62012e9"},
]
[package.dependencies]
altgraph = "*"
importlib-metadata = {version = ">=4.6", markers = "python_version < \"3.10\""}
macholib = {version = ">=1.8", markers = "sys_platform == \"darwin\""}
packaging = ">=20.0"
packaging = ">=22.0"
pefile = {version = ">=2022.5.30", markers = "sys_platform == \"win32\""}
pyinstaller-hooks-contrib = ">=2021.4"
pyinstaller-hooks-contrib = ">=2024.6"
pywin32-ctypes = {version = ">=0.2.1", markers = "sys_platform == \"win32\""}
setuptools = ">=42.0.0"
[package.extras]
completion = ["argcomplete"]
hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"]
[[package]]
name = "pyinstaller-hooks-contrib"
version = "2023.9"
version = "2024.7"
description = "Community maintained hooks for PyInstaller"
optional = false
python-versions = ">=3.7"
files = [
{file = "pyinstaller-hooks-contrib-2023.9.tar.gz", hash = "sha256:76084b5988e3957a9df169d2a935d65500136967e710ddebf57263f1a909cd80"},
{file = "pyinstaller_hooks_contrib-2023.9-py2.py3-none-any.whl", hash = "sha256:f34f4c6807210025c8073ebe665f422a3aa2ac5f4c7ebf4c2a26cc77bebf63b5"},
{file = "pyinstaller_hooks_contrib-2024.7-py2.py3-none-any.whl", hash = "sha256:8bf0775771fbaf96bcd2f4dfd6f7ae6c1dd1b1efe254c7e50477b3c08e7841d8"},
{file = "pyinstaller_hooks_contrib-2024.7.tar.gz", hash = "sha256:fd5f37dcf99bece184e40642af88be16a9b89613ecb958a8bd1136634fc9fac5"},
]
[package.dependencies]
importlib-metadata = {version = ">=4.6", markers = "python_version < \"3.10\""}
packaging = ">=22.0"
setuptools = ">=42.0.0"
[[package]]
name = "pyscard"
version = "2.0.7"
version = "2.0.10"
description = "Smartcard module for Python."
optional = false
python-versions = "*"
files = [
{file = "pyscard-2.0.7-cp310-cp310-win32.whl", hash = "sha256:06666a597e1293421fa90e0d4fc2418add447b10b7dc85f49b3cafc23480f046"},
{file = "pyscard-2.0.7-cp310-cp310-win_amd64.whl", hash = "sha256:a2266345bd387854298153264bff8b74f494581880a76e3e8679460c1b090fab"},
{file = "pyscard-2.0.7-cp311-cp311-win32.whl", hash = "sha256:beacdcdc3d1516e195f7a38ec3966c5d4df7390c8f036cb41f6fef72bc5cc646"},
{file = "pyscard-2.0.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e37b697327e8dc4848c481428d1cbd10b7ae2ce037bc799e5b8bbd2fc3ab5ed"},
{file = "pyscard-2.0.7-cp37-cp37m-win32.whl", hash = "sha256:a0c5edbedafba62c68160884f878d9f53996d7219a3fc11b1cea6bab59c7f34a"},
{file = "pyscard-2.0.7-cp37-cp37m-win_amd64.whl", hash = "sha256:f704ad40dc40306e1c0981941789518ab16aa1f84443b1d52ec0264884092b3b"},
{file = "pyscard-2.0.7-cp38-cp38-win32.whl", hash = "sha256:59a466ab7ae20188dd197664b9ca1ea9524d115a5aa5b16b575a6b772cdcb73c"},
{file = "pyscard-2.0.7-cp38-cp38-win_amd64.whl", hash = "sha256:da70aa5b7be5868b88cdb6d4a419d2791b6165beeb90cd01d2748033302a0f43"},
{file = "pyscard-2.0.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2d4bdc1f4e0e6c46e417ac1bc9d5990f7cfb24a080e890d453781405f7bd29dc"},
{file = "pyscard-2.0.7-cp39-cp39-win32.whl", hash = "sha256:39e030c47878b37ae08038a917959357be6468da52e8b144e84ffc659f50e6e2"},
{file = "pyscard-2.0.7-cp39-cp39-win_amd64.whl", hash = "sha256:5a5865675be294c8d91f22dc91e7d897c4138881e5295fb6b2cd821f7c0389d9"},
{file = "pyscard-2.0.7.tar.gz", hash = "sha256:278054525fa75fbe8b10460d87edcd03a70ad94d688b11345e4739987f85c1bf"},
{file = "pyscard-2.0.10-cp310-cp310-win32.whl", hash = "sha256:2ae1ece465ccd060e0a268cad1a213414ce8f7a8346bdb00b8470cf4b7826915"},
{file = "pyscard-2.0.10-cp310-cp310-win_amd64.whl", hash = "sha256:c7197af995768e522665c3d01099224a268e1791b0dd5b8762364063a07503fa"},
{file = "pyscard-2.0.10-cp311-cp311-win32.whl", hash = "sha256:af334ecff0a9415e4baa6c6b0c476148dfc81490671dad8eec5eff091bcdfdde"},
{file = "pyscard-2.0.10-cp311-cp311-win_amd64.whl", hash = "sha256:c5281b4a124ac27e854b3630eb026e9ac6c4b984e7958586a3cb2b6403c2d11b"},
{file = "pyscard-2.0.10-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:3d66ac3c7f6014351847b8efdbc684e9ac0a4ebb60fcb8a573c182e0f13b6fa4"},
{file = "pyscard-2.0.10-cp312-cp312-win32.whl", hash = "sha256:eea9aad08d3baa6c5542d7c5c86e4975873c3260379ed2205c1ede713cf3ffb9"},
{file = "pyscard-2.0.10-cp312-cp312-win_amd64.whl", hash = "sha256:5fbbc848c93641677bab855ef313b4c4153e63c82bcdf2aba4d79f99699398b5"},
{file = "pyscard-2.0.10-cp37-cp37m-win32.whl", hash = "sha256:ebdd8ad859a2df2c9c919932bfd862076345e95e14027123a261bd814e327fb4"},
{file = "pyscard-2.0.10-cp37-cp37m-win_amd64.whl", hash = "sha256:1349a5f2113090d9f58947158bcd6ab94d4c8287c461fe86909cd766631a55d8"},
{file = "pyscard-2.0.10-cp38-cp38-win32.whl", hash = "sha256:f9f54e3a5b15cd825119f056c517f7cb34da76c934819548a38f77d8f4eea978"},
{file = "pyscard-2.0.10-cp38-cp38-win_amd64.whl", hash = "sha256:3de4e46ebfb5f6ae9e9ce225e62c784dceb83dd304b9caad3dd4a00447224c71"},
{file = "pyscard-2.0.10-cp39-cp39-win32.whl", hash = "sha256:6f79249bf169ab5f0c5272a0d36576d153a6132ad3e9728c5275a93e99de0f61"},
{file = "pyscard-2.0.10-cp39-cp39-win_amd64.whl", hash = "sha256:b4acd0deb624cd931572be306aab3fee7a0e527d2daa8a8bf943e291bc043315"},
{file = "pyscard-2.0.10.tar.gz", hash = "sha256:4b9b865df03b29522e80ebae17790a8b3a096a9d885cda19363b44b1a6bf5c1c"},
]
[package.extras]
@ -504,13 +662,13 @@ pyro = ["Pyro"]
[[package]]
name = "pytest"
version = "7.4.2"
version = "8.2.2"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.7"
python-versions = ">=3.8"
files = [
{file = "pytest-7.4.2-py3-none-any.whl", hash = "sha256:1d881c6124e08ff0a1bb75ba3ec0bfd8b5354a01c194ddd5a0a870a48d99b002"},
{file = "pytest-7.4.2.tar.gz", hash = "sha256:a766259cfab564a2ad52cb1aae1b881a75c3eb7e34ca3779697c23ed47c47069"},
{file = "pytest-8.2.2-py3-none-any.whl", hash = "sha256:c434598117762e2bd304e526244f67bf66bbd7b5d6cf22138be51ff661980343"},
{file = "pytest-8.2.2.tar.gz", hash = "sha256:de4bb8104e201939ccdc688b27a89a7be2079b22e2bd2b07f806b6ba71117977"},
]
[package.dependencies]
@ -518,11 +676,11 @@ colorama = {version = "*", markers = "sys_platform == \"win32\""}
exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
iniconfig = "*"
packaging = "*"
pluggy = ">=0.12,<2.0"
tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
pluggy = ">=1.5,<2.0"
tomli = {version = ">=1", markers = "python_version < \"3.11\""}
[package.extras]
testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
[[package]]
name = "pywin32"
@ -575,19 +733,18 @@ jeepney = ">=0.6"
[[package]]
name = "setuptools"
version = "68.2.2"
version = "70.2.0"
description = "Easily download, build, install, upgrade, and uninstall Python packages"
optional = false
python-versions = ">=3.8"
files = [
{file = "setuptools-68.2.2-py3-none-any.whl", hash = "sha256:b454a35605876da60632df1a60f736524eb73cc47bbc9f3f1ef1b644de74fd2a"},
{file = "setuptools-68.2.2.tar.gz", hash = "sha256:4ac1475276d2f1c48684874089fefcd83bd7162ddaafb81fac866ba0db282a87"},
{file = "setuptools-70.2.0-py3-none-any.whl", hash = "sha256:b8b8060bb426838fbe942479c90296ce976249451118ef566a5a0b7d8b78fb05"},
{file = "setuptools-70.2.0.tar.gz", hash = "sha256:bd63e505105011b25c3c11f753f7e3b8465ea739efddaccef8f0efac2137bac1"},
]
[package.extras]
docs = ["furo", "jaraco.packaging (>=9.3)", "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 (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "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] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.1)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.10.0)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.3.2)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
[[package]]
name = "tomli"
@ -600,71 +757,98 @@ files = [
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
]
[[package]]
name = "types-pillow"
version = "10.2.0.20240520"
description = "Typing stubs for Pillow"
optional = false
python-versions = ">=3.8"
files = [
{file = "types-Pillow-10.2.0.20240520.tar.gz", hash = "sha256:130b979195465fa1e1676d8e81c9c7c30319e8e95b12fae945e8f0d525213107"},
{file = "types_Pillow-10.2.0.20240520-py3-none-any.whl", hash = "sha256:33c36494b380e2a269bb742181bea5d9b00820367822dbd3760f07210a1da23d"},
]
[[package]]
name = "typing-extensions"
version = "4.12.2"
description = "Backported and Experimental Type Hints for Python 3.8+"
optional = false
python-versions = ">=3.8"
files = [
{file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
{file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
]
[[package]]
name = "yubikey-manager"
version = "5.2.0"
version = "5.5.1"
description = "Tool for managing your YubiKey configuration."
optional = false
python-versions = ">=3.7,<4.0"
python-versions = "<4.0,>=3.8"
files = [
{file = "yubikey_manager-5.2.0-py3-none-any.whl", hash = "sha256:6e0c82605f92012363ae3d69673eec6c7876e2e366aa049cff66cc6734049165"},
{file = "yubikey_manager-5.2.0.tar.gz", hash = "sha256:45e0f09e3cee2375b6f930dd5d89c1d3a7ca5d5cccb599b16a12f8f7d989fd36"},
{file = "yubikey_manager-5.5.1-py3-none-any.whl", hash = "sha256:611a6cd088bb18f1ee8d11dfb2c4218ac086d5e40dc718f4a4183b9c4d0e932f"},
{file = "yubikey_manager-5.5.1.tar.gz", hash = "sha256:2b1f4e70813973c646eb301c8f2513faf5e4736dd3c564422efdce0349c02afd"},
]
[package.dependencies]
click = ">=8.0,<9.0"
cryptography = ">=3.0,<44"
cryptography = ">=3.0,<45"
fido2 = ">=1.0,<2.0"
keyring = ">=23.4,<24.0"
keyring = ">=23.4,<26"
pyscard = ">=2.0,<3.0"
pywin32 = {version = ">=223", markers = "sys_platform == \"win32\""}
[[package]]
name = "zipp"
version = "3.17.0"
version = "3.19.2"
description = "Backport of pathlib-compatible object wrapper for zip files"
optional = false
python-versions = ">=3.8"
files = [
{file = "zipp-3.17.0-py3-none-any.whl", hash = "sha256:0e923e726174922dce09c53c59ad483ff7bbb8e572e00c7f7c46b88556409f31"},
{file = "zipp-3.17.0.tar.gz", hash = "sha256:84e64a1c28cf7e91ed2078bb8cc8c259cb19b76942096c8d7b84947690cabaf0"},
{file = "zipp-3.19.2-py3-none-any.whl", hash = "sha256:f091755f667055f2d02b32c53771a7a6c8b47e1fdbc4b72a8b9072b3eef8015c"},
{file = "zipp-3.19.2.tar.gz", hash = "sha256:bf1dcf6450f873a13e952a29504887c89e6de7506209e5b1bcc3460135d4de19"},
]
[package.extras]
docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "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"]
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"]
[[package]]
name = "zxing-cpp"
version = "2.1.0"
version = "2.2.0"
description = "Python bindings for the zxing-cpp barcode library"
optional = false
python-versions = ">=3.6"
files = [
{file = "zxing_cpp-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:fb18dcd86fcc26c8d86e7de03645827141f0f7194dcec3e09d46f0a9dda64cc2"},
{file = "zxing-cpp-2.2.0.tar.gz", hash = "sha256:11884ef9d1a61e47ad89836339da9e1040cb28b083fb37462bc58e8d46f135bc"},
{file = "zxing_cpp-2.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:eb8ede507dad76d0ed606c17be943cfe554909cdb517f7650da076e8dc9648c0"},
{file = "zxing_cpp-2.2.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5e8bae00edea7f6350ced4f954ca2c3386afbf6d85d3303126f9cf8584cec454"},
{file = "zxing_cpp-2.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8256ed05d0978e87847e91e2aff03b21c19cf4256dcb8af8a335de8361f027be"},
{file = "zxing_cpp-2.2.0-cp310-cp310-win32.whl", hash = "sha256:5e788ec26d10ac057f5027357ab404b1de2dd3ce216c1d0be20437dcd37f1afc"},
{file = "zxing_cpp-2.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:b69dcee874b59201a586e8fc77a7f83a92cd09d029d22a8b7b4f7112db2c675f"},
{file = "zxing_cpp-2.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f7d63385278ed2c674c756fe3448769b813351584e111cb61e6901a5e3dbf646"},
{file = "zxing_cpp-2.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d9e7369bba46727e4a7fa1b5bd2b630696ec042df60063c13f2d844887950b1"},
{file = "zxing_cpp-2.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8602d7cad833007df497b8db7c1216fc7d21e077eb02206b45f4fc061b082913"},
{file = "zxing_cpp-2.2.0-cp311-cp311-win32.whl", hash = "sha256:e2059fb6d47eb80122856d1524611339bffe27ad7e4cf59bdb6d642989e238b4"},
{file = "zxing_cpp-2.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:70f9f13c4c91cb0747c0dc7ef247b39e19d38d07efd695fac17d11832f93d44a"},
{file = "zxing_cpp-2.2.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:177d5ed00c505e1728a90dc4742c6f0a4d9c2db56101809762f10f98fb822fa2"},
{file = "zxing_cpp-2.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9868fbb9770ef2a2b72b6fe67d7528b9e4858ba6a130e59969ecb2e71271cd1"},
{file = "zxing_cpp-2.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0b35274af536ac9091d446ba0f69840feba62525feddf24b7b8991d924d6543"},
{file = "zxing_cpp-2.2.0-cp312-cp312-win32.whl", hash = "sha256:c2ff0059eef121ae7769ba4baeee52172438ca770cb4054e1d114beeda66226d"},
{file = "zxing_cpp-2.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:e3cdd28dd42176f59aa7fcbd052ec1d60c1fa29363b3fa5a8e41ecf8ce6e9be7"},
{file = "zxing_cpp-2.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:7a7a616f6c8e02a92f8ab38d91bc14fecfbb3bc6bf2b69c4e5d7bb54eeaea141"},
{file = "zxing_cpp-2.2.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a16148c7a7926f2f897c69c695f4b334cd1a777bbfa0269204245fad288b47cf"},
{file = "zxing_cpp-2.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:25d0dae34bdd8745103cd93c0cb315a0de4bb2876e564a35704be971ac07fcd4"},
{file = "zxing_cpp-2.2.0-cp38-cp38-win32.whl", hash = "sha256:3297daded419c87b2720ce9620817db4742814d7e31bfddeeb6329266a61f54f"},
{file = "zxing_cpp-2.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:7d620de9309d6f8d79dcb87b5c32bc8d72a1dbdc54369c31552a5ab277e25e78"},
{file = "zxing_cpp-2.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:55a3f24aeb3e71a2090f3ded10d3b44865e60b640cebcb9c5f10e188158314ea"},
{file = "zxing_cpp-2.2.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4303e904a174df3c3f7f1817cf42a10521514865d9cdfcc9205e5ff0243c16fa"},
{file = "zxing_cpp-2.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70026b8370fc34c257fa76d84a07374a78a8a46cb00d36f285d32c60e9fbf6dd"},
{file = "zxing_cpp-2.2.0-cp39-cp39-win32.whl", hash = "sha256:92acc610eb6100cc5dd4c0f583de22e137c051a0f6e653dba6aef07e8c32810f"},
{file = "zxing_cpp-2.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:0bcd8855da4a9ef9e92799446353b5b8fa3eb5da21346089f0f09dcbb8bef1b6"},
]
[package.source]
type = "file"
url = "zxing_cpp-2.1.0-cp312-cp312-win_amd64.whl"
[[package]]
name = "zxing-cpp"
version = "2.1.0"
description = "Python bindings for the zxing-cpp barcode library"
optional = false
python-versions = ">=3.6"
files = []
develop = false
[package.source]
type = "git"
url = "https://github.com/zxing-cpp/zxing-cpp.git"
reference = "18a722a"
resolved_reference = "18a722a443855063a00b27d03d33794fa573a61f"
subdirectory = "wrappers/python"
[metadata]
lock-version = "2.0"
python-versions = "^3.8"
content-hash = "1c10a85b3420fb1057c6371465b7007ff63f562e5cfed9178f25eee2185c45c3"
content-hash = "123356b2b1ed4b00f453721795be4c7c10a3583c7d7b42368127f4c46461e50c"

View File

@ -10,17 +10,16 @@ packages = [
[tool.poetry.dependencies]
python = "^3.8"
yubikey-manager = "5.2.0"
yubikey-manager = "^5.5"
mss = "^9.0.1"
zxing-cpp = [
{git = "https://github.com/zxing-cpp/zxing-cpp.git", rev="18a722a", subdirectory = "wrappers/python", markers = "sys_platform != 'win32'"},
{path = "zxing_cpp-2.1.0-cp312-cp312-win_amd64.whl", markers = "sys_platform == 'win32'"}
]
Pillow = "^10.0.0"
Pillow = "^10.2.0"
zxing-cpp = "^2.2.0"
[tool.poetry.dev-dependencies]
pyinstaller = {version = "^6.0", python = "<3.13"}
pytest = "^7.4.0"
pytest = "^8.0.0"
mypy = "^1.7.1"
types-Pillow = "^10.2.0.0"
[build-system]
requires = ["poetry-core>=1.0.0"]
@ -28,3 +27,11 @@ build-backend = "poetry.core.masonry.api"
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.mypy]
files = "."
check_untyped_defs = true
[[tool.mypy.overrides]]
module = ["smartcard.*", "zxingcpp"]
ignore_missing_imports = true

View File

@ -6,8 +6,8 @@ VSVersionInfo(
ffi=FixedFileInfo(
# filevers and prodvers should be always a tuple with four items: (1, 2, 3, 4)
# Set not needed items to zero 0.
filevers=(6, 3, 1, 0),
prodvers=(6, 3, 1, 0),
filevers=(7, 0, 2, 0),
prodvers=(7, 0, 2, 0),
# Contains a bitmask that specifies the valid bits 'flags'r
mask=0x3f,
# Contains a bitmask that specifies the Boolean attributes of the file.
@ -31,11 +31,11 @@ VSVersionInfo(
'040904b0',
[StringStruct('CompanyName', 'Yubico'),
StringStruct('FileDescription', 'Yubico Authenticator Helper'),
StringStruct('FileVersion', '6.3.1-dev.0'),
StringStruct('FileVersion', '7.0.2-dev.0'),
StringStruct('LegalCopyright', 'Copyright (c) Yubico'),
StringStruct('OriginalFilename', 'authenticator-helper.exe'),
StringStruct('ProductName', 'Yubico Authenticator'),
StringStruct('ProductVersion', '6.3.1-dev.0')])
StringStruct('ProductVersion', '7.0.2-dev.0')])
]),
VarFileInfo([VarStruct('Translation', [1033, 1200])])
]

View File

@ -0,0 +1,138 @@
/*
* 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.
*/
@Tags(['desktop', 'android'])
library;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:yubico_authenticator/app/views/keys.dart';
import 'package:yubico_authenticator/core/state.dart';
import 'utils/keyless_test_util.dart';
import 'utils/test_util.dart';
void main() {
var binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
group('Startup', () {
appTestKeyless('App starts', (WidgetTester tester) async {},
tags: 'minimal');
});
group('Settings', () {
appTestKeyless('Click through all Themes', (WidgetTester tester) async {
var settingDrawerButton = find.byKey(settingDrawerIcon).hitTestable();
await tester.tap(settingDrawerButton);
await tester.longWait();
await tester.tap(find.byKey(themeModeSetting));
await tester.longWait();
await tester
.tap(find.byKey(themeModeOption(ThemeMode.light)).hitTestable());
await tester.longWait();
await tester.tap(find.byKey(themeModeSetting));
await tester.longWait();
await tester
.tap(find.byKey(themeModeOption(ThemeMode.dark)).hitTestable());
await tester.longWait();
await tester.tap(find.byKey(themeModeSetting));
await tester.longWait();
await tester
.tap(find.byKey(themeModeOption(ThemeMode.system)).hitTestable());
await tester.longWait();
});
});
group('Help and about', () {
var helpDrawerButton = find.byKey(helpDrawerIcon).hitTestable();
appTestKeyless('Check Licenses view', (WidgetTester tester) async {
await tester.tap(helpDrawerButton);
await tester.shortWait();
var licensesButtonText = find.byKey(licensesButton).hitTestable();
await tester.tap(licensesButtonText);
await tester.shortWait();
/// TODO: do want to click all licenses and see that they show?
});
group('Opening of URLs', () {
appTestKeyless('TOS link', (WidgetTester tester) async {
await tester.tap(helpDrawerButton);
await tester.longWait();
if (isAndroid) {
expect(find.byKey(tosButton).hitTestable(), findsOneWidget);
} else {
await tester.tap(find.byKey(tosButton).hitTestable());
await tester.longWait();
}
});
appTestKeyless('Privacy link', (WidgetTester tester) async {
await tester.tap(helpDrawerButton);
await tester.longWait();
if (isAndroid) {
expect(find.byKey(privacyButton).hitTestable(), findsOneWidget);
} else {
await tester.tap(find.byKey(privacyButton).hitTestable());
await tester.longWait();
}
});
appTestKeyless('Feedback link', (WidgetTester tester) async {
await tester.tap(helpDrawerButton);
await tester.longWait();
if (isAndroid) {
expect(find.byKey(userGuideButton).hitTestable(), findsOneWidget);
} else {
await tester.tap(find.byKey(userGuideButton).hitTestable());
await tester.longWait();
}
});
appTestKeyless('Help link', (WidgetTester tester) async {
await tester.tap(helpDrawerButton);
await tester.longWait();
if (isAndroid) {
expect(find.byKey(helpButton).hitTestable(), findsOneWidget);
} else {
await tester.tap(find.byKey(helpButton).hitTestable());
await tester.longWait();
}
});
});
group('Troubleshooting', () {
appTestKeyless('Diagnostics Button', skip: isAndroid,
(WidgetTester tester) async {
await tester.tap(helpDrawerButton);
await tester.longWait();
await tester.tap(find.byKey(diagnosticsChip).hitTestable());
await tester.longWait();
});
appTestKeyless('Log button', (WidgetTester tester) async {
await tester.tap(helpDrawerButton);
await tester.longWait();
await tester.tap(find.byKey(logChip).hitTestable());
await tester.longWait();
});
// appTestKeyless('Allow screenshots', (WidgetTester tester) async {
// /// Pausing test until we have Android CI.
// await tester.tap(helpDrawerButton);
// await tester.shortWait();
// await tester.tap(find.byKey(screenshotChip).hitTestable());
// await tester.longWait();
// await tester.tap(find.byKey(screenshotChip).hitTestable());
// await tester.shortWait();
// });
});
});
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 Yubico.
* 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.
@ -14,8 +14,11 @@
* limitations under the License.
*/
import 'package:flutter_test/flutter_test.dart';
@Tags(['desktop', 'management'])
library;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.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'
@ -42,8 +45,8 @@ void main() {
});
});
group('Change OTP', () {
appTest('Disable OTP', (WidgetTester tester) async {
group('Toggle Applications on key', () {
appTest('Toggle OTP', (WidgetTester tester) async {
await tester.openManagementScreen();
// find USB OTP capability
@ -53,10 +56,50 @@ void main() {
// we expect OTP to be enabled on the Key for this test
expect(otpChip.selected, equals(true));
await tester.tap(find.byKey(usbOtpKey));
await tester.pump(const Duration(milliseconds: 500));
await tester.shortWait();
await tester.tap(find.byKey(management_keys.saveButtonKey));
// long wait
await tester.pump(const Duration(milliseconds: 2500));
await tester.ultraLongWait();
expect(find.byKey(management_keys.screenKey), findsNothing);
await tester.shortWait();
}
await tester.openManagementScreen();
if (otpChip != null) {
await tester.tap(find.byKey(usbOtpKey));
await tester.shortWait();
await tester.tap(find.byKey(management_keys.saveButtonKey));
// long wait
await tester.ultraLongWait();
// no management screen visible now
expect(find.byKey(management_keys.screenKey), findsNothing);
await tester.longWait();
}
});
appTest('Toggle PIV', (WidgetTester tester) async {
await tester.openManagementScreen();
var usbPivKey = _getCapabilityWidgetKey(true, 'PIV');
var pivChip = await _getCapabilityWidget(usbPivKey);
// find USB PIV capability
if (pivChip != null) {
expect(pivChip.selected, equals(true));
await tester.tap(find.byKey(usbPivKey));
await tester.shortWait();
await tester.tap(find.byKey(management_keys.saveButtonKey));
// long wait
await tester.ultraLongWait();
expect(find.byKey(management_keys.screenKey), findsNothing);
await tester.shortWait();
}
await tester.openManagementScreen();
if (pivChip != null) {
// we expect PIV to be enabled on the Key for this test
await tester.tap(find.byKey(usbPivKey));
await tester.shortWait();
await tester.tap(find.byKey(management_keys.saveButtonKey));
// long wait
await tester.ultraLongWait();
// no management screen visible now
expect(find.byKey(management_keys.screenKey), findsNothing);
@ -64,19 +107,90 @@ void main() {
}
});
appTest('Enable OTP', (WidgetTester tester) async {
appTest('Toggle OATH', (WidgetTester tester) async {
await tester.openManagementScreen();
// find USB OTP capability
var usbOtpKey = _getCapabilityWidgetKey(true, 'OTP');
var otpChip = await _getCapabilityWidget(usbOtpKey);
if (otpChip != null) {
expect(otpChip.selected, equals(false));
await tester.tap(find.byKey(usbOtpKey));
await tester.pump(const Duration(milliseconds: 500));
// find USB OATH capability
var usbOathKey = _getCapabilityWidgetKey(true, 'OATH');
var oathChip = await _getCapabilityWidget(usbOathKey);
if (oathChip != null) {
// we expect OATH to be enabled on the Key for this test
expect(oathChip.selected, equals(true));
await tester.tap(find.byKey(usbOathKey));
await tester.shortWait();
await tester.tap(find.byKey(management_keys.saveButtonKey));
// long wait
await tester.pump(const Duration(milliseconds: 2500));
await tester.ultraLongWait();
expect(find.byKey(management_keys.screenKey), findsNothing);
await tester.shortWait();
}
await tester.openManagementScreen();
if (oathChip != null) {
await tester.tap(find.byKey(usbOathKey));
await tester.shortWait();
await tester.tap(find.byKey(management_keys.saveButtonKey));
// long wait
await tester.ultraLongWait();
// no management screen visible now
expect(find.byKey(management_keys.screenKey), findsNothing);
await tester.longWait();
}
});
appTest('Toggle OpenPGP', (WidgetTester tester) async {
await tester.openManagementScreen();
// find USB OPENPGP capability
var usbPgpKey = _getCapabilityWidgetKey(true, 'OpenPGP');
var pgpChip = await _getCapabilityWidget(usbPgpKey);
if (pgpChip != null) {
// we expect OPENPGP to be enabled on the Key for this test
expect(pgpChip.selected, equals(true));
await tester.tap(find.byKey(usbPgpKey));
await tester.shortWait();
await tester.tap(find.byKey(management_keys.saveButtonKey));
// long wait
await tester.ultraLongWait();
expect(find.byKey(management_keys.screenKey), findsNothing);
await tester.shortWait();
}
await tester.openManagementScreen();
if (pgpChip != null) {
await tester.tap(find.byKey(usbPgpKey));
await tester.shortWait();
await tester.tap(find.byKey(management_keys.saveButtonKey));
// long wait
await tester.ultraLongWait();
// no management screen visible now
expect(find.byKey(management_keys.screenKey), findsNothing);
await tester.longWait();
}
});
appTest('Toggle YubiHSM Auth', (WidgetTester tester) async {
await tester.openManagementScreen();
// find USB YubiHSM Auth capability
var usbHsmKey = _getCapabilityWidgetKey(true, 'YubiHSM Auth');
var hsmChip = await _getCapabilityWidget(usbHsmKey);
if (hsmChip != null) {
// we expect YubiHSM Auth to be enabled on the Key for this test
expect(hsmChip.selected, equals(true));
await tester.tap(find.byKey(usbHsmKey));
await tester.shortWait();
await tester.tap(find.byKey(management_keys.saveButtonKey));
// long wait
await tester.ultraLongWait();
expect(find.byKey(management_keys.screenKey), findsNothing);
await tester.shortWait();
}
await tester.openManagementScreen();
if (hsmChip != null) {
await tester.tap(find.byKey(usbHsmKey));
await tester.shortWait();
await tester.tap(find.byKey(management_keys.saveButtonKey));
// long wait
await tester.ultraLongWait();
// no management screen visible now
expect(find.byKey(management_keys.screenKey), findsNothing);
@ -84,4 +198,64 @@ void main() {
}
});
});
appTest('Toggle FIDO U2F', (WidgetTester tester) async {
await tester.openManagementScreen();
// find USB FIDO U2F capability
var usbU2fKey = _getCapabilityWidgetKey(true, 'FIDO U2F');
var u2fChip = await _getCapabilityWidget(usbU2fKey);
if (u2fChip != null) {
// we expect FIDO U2F to be enabled on the Key for this test
expect(u2fChip.selected, equals(true));
await tester.tap(find.byKey(usbU2fKey));
await tester.shortWait();
await tester.tap(find.byKey(management_keys.saveButtonKey));
// long wait
await tester.ultraLongWait();
expect(find.byKey(management_keys.screenKey), findsNothing);
await tester.shortWait();
}
await tester.openManagementScreen();
if (u2fChip != null) {
await tester.tap(find.byKey(usbU2fKey));
await tester.shortWait();
await tester.tap(find.byKey(management_keys.saveButtonKey));
// long wait
await tester.ultraLongWait();
// no management screen visible now
expect(find.byKey(management_keys.screenKey), findsNothing);
await tester.longWait();
}
});
appTest('Toggle FIDO2', (WidgetTester tester) async {
await tester.openManagementScreen();
// find USB FIDO2 capability
var usbFido2Key = _getCapabilityWidgetKey(true, 'FIDO2');
var fido2Chip = await _getCapabilityWidget(usbFido2Key);
if (fido2Chip != null) {
// we expect FIDO2 to be enabled on the Key for this test
expect(fido2Chip.selected, equals(true));
await tester.tap(find.byKey(usbFido2Key));
await tester.shortWait();
await tester.tap(find.byKey(management_keys.saveButtonKey));
// long wait
await tester.ultraLongWait();
expect(find.byKey(management_keys.screenKey), findsNothing);
await tester.shortWait();
}
await tester.openManagementScreen();
if (fido2Chip != null) {
await tester.tap(find.byKey(usbFido2Key));
await tester.shortWait();
await tester.tap(find.byKey(management_keys.saveButtonKey));
// long wait
await tester.ultraLongWait();
// no management screen visible now
expect(find.byKey(management_keys.screenKey), findsNothing);
await tester.longWait();
}
});
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 Yubico.
* 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.
@ -14,10 +14,16 @@
* limitations under the License.
*/
@Tags(['android', 'desktop', 'oath'])
library;
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:yubico_authenticator/app/views/keys.dart';
import 'package:yubico_authenticator/core/state.dart';
import 'package:yubico_authenticator/oath/keys.dart' as keys;
import 'package:yubico_authenticator/oath/models.dart';
import 'package:yubico_authenticator/oath/views/account_list.dart';
import 'utils/oath_test_util.dart';
import 'utils/test_util.dart';
@ -29,48 +35,210 @@ void main() {
group('OATH UI tests', () {
appTest('Menu items exist', (WidgetTester tester) async {
await tester.tapActionIconButton();
await tester.shortWait();
expect(find.byKey(keys.addAccountAction), findsOneWidget);
expect(find.byKey(keys.setOrManagePasswordAction), findsOneWidget);
expect(find.byKey(keys.resetAction), findsOneWidget);
// close dialog
await tester.tapTopLeftCorner();
await tester.longWait();
});
});
group('Account tests', () {
appTest('Create OATH account', (WidgetTester tester) async {
// account with issuer
var testAccount = const Account(
issuer: 'IssuerForTests',
name: 'NameForTests',
secret: 'aaaaaaaaaaaaaaaa',
);
await tester.deleteAccount(testAccount);
await tester.addAccount(testAccount);
// account without issuer
testAccount = const Account(
name: 'NoIssuerName',
secret: 'bbbbbbbbbbbbbbbb',
);
await tester.deleteAccount(testAccount);
await tester.addAccount(testAccount);
group('Account creation', () {
appTest('Initial reset OATH', (WidgetTester tester) async {
/// reset OATH application
//await tester.tapAppDrawerButton(oathAppDrawer);
await tester.resetOATH();
await tester.shortWait();
});
appTest('Create 32 Accounts', (WidgetTester tester) async {
await tester.tapAppDrawerButton(oathAppDrawer);
/// deletes accounts created in previous test
appTest('Delete OATH account', (WidgetTester tester) async {
var testAccount =
const Account(issuer: 'IssuerForTests', name: 'NameForTests');
for (var i = 0; i < 32; i += 1) {
// just now merely 32 accounts
var testAccount = Account(
issuer: 'MaxAccount_issuer_$i',
name: 'MaxAccount_name_$i',
secret: 'abbaabba',
);
await tester.addAccount(testAccount);
await tester.shortWait();
await tester.deleteAccount(testAccount);
expect(await tester.findAccount(testAccount), isNull);
expect(
find.descendant(
of: find.byType(AccountList),
matching: find.textContaining(testAccount.name)),
findsOneWidget);
testAccount = const Account(issuer: null, name: 'NoIssuerName');
await tester.deleteAccount(testAccount);
expect(await tester.findAccount(testAccount), isNull);
await tester.shortWait();
}
// TODO: verify one more addAccount() is not possible
await tester.resetOATH();
await tester.shortWait();
}, tags: ['slow']);
// appTest('Create weird character-accounts and check byte count',
// (WidgetTester tester) async {});
group('TOTP account tests', () {
appTest('TOTP: sha-1', (WidgetTester tester) async {
await tester.tapAppDrawerButton(oathAppDrawer);
const testAccount = Account(
issuer: 'i_totp_sha1',
name: 'n__totp_sha1',
secret: 'abbaabba',
touch: false,
oathType: OathType.totp,
hashAlgorithm: HashAlgorithm.sha1);
await tester.addAccount(testAccount);
expect(
find.descendant(
of: find.byType(AccountList),
matching: find.textContaining(testAccount.name)),
findsOneWidget);
await tester.shortWait();
});
appTest('TOTP: sha-256', (WidgetTester tester) async {
await tester.tapAppDrawerButton(oathAppDrawer);
const testAccount = Account(
issuer: 'i_totp_sha256',
name: 'n__totp_sha256',
secret: 'abbaabba',
touch: false,
oathType: OathType.totp,
hashAlgorithm: HashAlgorithm.sha256);
await tester.addAccount(testAccount);
expect(
find.descendant(
of: find.byType(AccountList),
matching: find.textContaining(testAccount.name)),
findsOneWidget);
await tester.shortWait();
});
appTest('TOTP: sha-512', (WidgetTester tester) async {
await tester.tapAppDrawerButton(oathAppDrawer);
const testAccount = Account(
issuer: 'i_totp_sha512',
name: 'n__totp_sha512',
secret: 'abbaabba',
touch: false,
oathType: OathType.totp,
hashAlgorithm: HashAlgorithm.sha512);
await tester.addAccount(testAccount);
expect(
find.descendant(
of: find.byType(AccountList),
matching: find.textContaining(testAccount.name)),
findsOneWidget);
await tester.shortWait();
});
// appTest('TOTP: period-20',
// (WidgetTester tester) async {});
// appTest('TOTP: period-45',
// (WidgetTester tester) async {});
// appTest('TOTP: period-60',
// (WidgetTester tester) async {});
// appTest('TOTP: digits-8',
// (WidgetTester tester) async {});
appTest('TOTP: touch', (WidgetTester tester) async {
await tester.tapAppDrawerButton(oathAppDrawer);
const testAccount = Account(
issuer: 'i_totp_touch',
name: 'n_totp_touch',
secret: 'abbaabba',
touch: true,
oathType: OathType.totp,
hashAlgorithm: HashAlgorithm.sha1);
await tester.addAccount(testAccount);
expect(
find.descendant(
of: find.byType(AccountList),
matching: find.textContaining(testAccount.name)),
findsOneWidget);
await tester.shortWait();
});
});
// group('HOTP account tests', () {
appTest('HOTP: sha-1', (WidgetTester tester) async {
await tester.tapAppDrawerButton(oathAppDrawer);
const testAccount = Account(
issuer: 'i_hotp_sha1',
name: 'n__hotp_sha1',
secret: 'abbaabba',
touch: false,
oathType: OathType.hotp,
hashAlgorithm: HashAlgorithm.sha1);
await tester.addAccount(testAccount);
expect(
find.descendant(
of: find.byType(AccountList),
matching: find.textContaining(testAccount.name)),
findsOneWidget);
await tester.shortWait();
});
appTest('HOTP: sha-256', (WidgetTester tester) async {
await tester.tapAppDrawerButton(oathAppDrawer);
const testAccount = Account(
issuer: 'i_hotp_sha256',
name: 'n__hotp_sha256',
secret: 'abbaabba',
touch: false,
oathType: OathType.hotp,
hashAlgorithm: HashAlgorithm.sha256);
await tester.addAccount(testAccount);
expect(
find.descendant(
of: find.byType(AccountList),
matching: find.textContaining(testAccount.name)),
findsOneWidget);
await tester.shortWait();
});
appTest('HOTP: sha-512', (WidgetTester tester) async {
await tester.tapAppDrawerButton(oathAppDrawer);
const testAccount = Account(
issuer: 'i_hotp_sha512',
name: 'n__hotp_sha512',
secret: 'abbaabba',
touch: false,
oathType: OathType.hotp,
hashAlgorithm: HashAlgorithm.sha512);
await tester.addAccount(testAccount);
expect(
find.descendant(
of: find.byType(AccountList),
matching: find.textContaining(testAccount.name)),
findsOneWidget);
await tester.shortWait();
});
// appTest('TOTP: digits-8',
// (WidgetTester tester) async {});
appTest('HOTP: touch', (WidgetTester tester) async {
await tester.tapAppDrawerButton(oathAppDrawer);
const testAccount = Account(
issuer: 'i_hotp_touch',
name: 'n_hotp_touch',
secret: 'abbaabba',
touch: true,
oathType: OathType.hotp,
hashAlgorithm: HashAlgorithm.sha1);
await tester.addAccount(testAccount);
expect(
find.descendant(
of: find.byType(AccountList),
matching: find.textContaining(testAccount.name)),
findsOneWidget);
await tester.shortWait();
});
// group('QR Code scanning', () {});
appTest('Final reset OATH', (WidgetTester tester) async {
/// reset OATH application
await tester.tapAppDrawerButton(oathAppDrawer);
await tester.resetOATH();
await tester.longWait();
});
/// adds an account, renames, verifies
@ -82,43 +250,44 @@ void main() {
await tester.deleteAccount(testAccount);
await tester.deleteAccount(
const Account(issuer: 'RenamedIssuer', name: 'RenamedName'));
await tester.longWait();
await tester.addAccount(testAccount);
await tester.longWait();
await tester.renameAccount(testAccount, 'RenamedIssuer', 'RenamedName');
});
});
group('Password tests', () {
/// note that the password groups should be run as whole
/// TODO implement test for password replacement
/// appTest('OATH: replace oath password', (WidgetTester tester) async {
/// await tester.replaceOathPassword('aaa111', 'bbb222');
/// });
// cannot restart the app on Android to be able to unlock
// NOTE: that the password groups should be run as whole
// NOTE: cannot restart the app on Android to be able to unlock: skip
group('Desktop password tests', skip: isAndroid, () {
var testPassword = 'testPassword';
var firstPassword = 'firstPassword';
var secondPassword = 'secondPassword';
var thirdPassword = 'thirdPassword';
appTest('Reset OATH', (WidgetTester tester) async {
await tester.resetOATH();
});
appTest('Set first OATH password', (WidgetTester tester) async {
// Sets a password for OATH
await tester.setOathPassword(firstPassword);
});
appTest('Set OATH password', (WidgetTester tester) async {
await tester.setOathPassword(testPassword);
appTest('Set second OATH password', (WidgetTester tester) async {
// Without removing the first, change to a second password
await tester.unlockOathSession(firstPassword);
await tester.replaceOathPassword(firstPassword, secondPassword);
});
appTest('Set third OATH password', (WidgetTester tester) async {
// Without removing the second, set a third password
await tester.unlockOathSession(secondPassword);
await tester.replaceOathPassword(secondPassword, thirdPassword);
});
appTest('Remove OATH password', (WidgetTester tester) async {
await tester.unlockOathSession(testPassword);
await tester.removeOathPassword(testPassword);
});
});
group('All password tests', () {
var testPassword = 'testPasswordX';
appTest('Set OATH password', (WidgetTester tester) async {
await tester.setOathPassword(testPassword);
});
appTest('Remove OATH password', (WidgetTester tester) async {
await tester.removeOathPassword(testPassword);
// restarts the app, unlocks with password, removes password req.
await tester.unlockOathSession(thirdPassword);
await tester.removeOathPassword(thirdPassword);
});
});
});

View File

@ -0,0 +1,165 @@
/*
* 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.
*/
@Tags(['desktop', 'otp'])
library;
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:yubico_authenticator/app/views/keys.dart';
import 'package:yubico_authenticator/otp/keys.dart';
import 'package:yubico_authenticator/otp/models.dart';
import 'utils/otp_test_util.dart';
import 'utils/test_util.dart';
void main() {
var binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
group('OTP UI tests', () {
appTest('Yubico OTP slot 1', (WidgetTester tester) async {
await tester.tap(find.byKey(otpAppDrawer).hitTestable());
await tester.shortWait();
/// TODO: verify "Slot 1 is empty"
await tester.openSlotMenu(SlotId.one);
await tester.tap(find.byKey(configureYubiOtp).hitTestable());
await tester.shortWait();
// this generates all the fields and saves yubiotp
await tester.tap(find.byKey(useSerial).hitTestable());
await tester.shortWait();
await tester.tap(find.byKey(generatePrivateId).hitTestable());
await tester.shortWait();
await tester.tap(find.byKey(generateSecretKey).hitTestable());
await tester.shortWait();
await tester.tap(find.byKey(saveButton).hitTestable());
await tester.shortWait();
/// TODO: verify "Slot 1 is configured"
});
appTest('Challenge-Response slot 1', (WidgetTester tester) async {
await tester.tap(find.byKey(otpAppDrawer).hitTestable());
await tester.shortWait();
/// TODO: verify "Slot 1 is configured"
await tester.openSlotMenu(SlotId.one);
await tester.tap(find.byKey(configureChalResp).hitTestable());
await tester.shortWait();
// this generates and saves chall-resp
await tester.tap(find.byKey(generateSecretKey).hitTestable());
await tester.shortWait();
await tester.tap(find.byKey(saveButton).hitTestable());
await tester.shortWait();
/// TODO: verify "Slot 1 is configured"
});
appTest('Static Password slot 2', (WidgetTester tester) async {
await tester.tap(find.byKey(otpAppDrawer).hitTestable());
await tester.shortWait();
/// TODO: verify "Slot 2 is empty"
await tester.openSlotMenu(SlotId.two);
await tester.tap(find.byKey(configureStatic).hitTestable());
await tester.shortWait();
// this generates and saves static password
await tester.tap(find.byKey(generateSecretKey).hitTestable());
await tester.shortWait();
await tester.tap(find.byKey(saveButton).hitTestable());
await tester.shortWait();
/// TODO: verify "Slot 2 is configured"
});
appTest('OATH-HOTP slot 2', (WidgetTester tester) async {
await tester.tap(find.byKey(otpAppDrawer).hitTestable());
await tester.shortWait();
/// TODO: verify "Slot 2 is configured"
await tester.openSlotMenu(SlotId.two);
await tester.tap(find.byKey(configureHotp).hitTestable());
await tester.shortWait();
// this writes and saves oath secret
await tester.enterText(find.byKey(secretField), 'asdfasdf');
await tester.shortWait();
await tester.tap(find.byKey(saveButton).hitTestable());
await tester.shortWait();
/// TODO: verify "Slot 2 is configured"
});
appTest('Swap slots', (WidgetTester tester) async {
await tester.tap(find.byKey(otpAppDrawer).hitTestable());
await tester.shortWait();
/// TODO: verify "Slot 1 is configured"
/// TODO: verify "Slot 2 is configured"
// taps swap
await tester.tapSwapSlotsButton();
await tester.tap(find.byKey(swapButton).hitTestable());
await tester.shortWait();
/// TODO: verify "Slot 1 is configured"
/// TODO: verify "Slot 2 is configured"
});
appTest('Delete Credentials', (WidgetTester tester) async {
await tester.tap(find.byKey(otpAppDrawer).hitTestable());
await tester.shortWait();
/// TODO: verify "Slot 1 is configured"
/// TODO: verify "Slot 2 is configured"
await tester.openSlotMenu(SlotId.one);
await tester.tap(find.byKey(deleteAction).hitTestable());
await tester.shortWait();
await tester.tap(find.byKey(deleteButton).hitTestable());
/// TODO: wait for any toasts to be gone
await tester.ultraLongWait();
var closeFinder = find.byKey(closeButton);
if (closeFinder.evaluate().isNotEmpty) {
// close the view
await tester.tap(closeFinder);
await tester.shortWait();
}
// we need to right click on slot 2
await tester.openSlotMenu(SlotId.two);
await tester.tap(find.byKey(deleteAction).hitTestable());
await tester.shortWait();
await tester.tap(find.byKey(deleteButton).hitTestable());
await tester.shortWait();
/// TODO: verify "Slot 1 is empty"
/// TODO: verify "Slot 2 is empty"
});
});
}

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