This commit is contained in:
Dain Nilsson 2023-08-18 10:45:27 +02:00
commit 142babd5d8
No known key found for this signature in database
GPG Key ID: F04367096FBA95E8
22 changed files with 954 additions and 212 deletions

View File

@ -50,6 +50,7 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="otpauth" />
<data android:scheme="otpauth-migration" />
</intent-filter>
</activity>

View File

@ -193,7 +193,10 @@ class MainActivity : FlutterFragmentActivity() {
// Handle opening through otpauth:// link
val intentData = intent.data
if (intentData != null && intentData.scheme == "otpauth") {
if (intentData != null &&
(intentData.scheme == "otpauth" ||
intentData.scheme == "otpauth-migration")
) {
intent.data = null
appLinkMethodChannel.handleUri(intentData)
}

View File

@ -27,7 +27,8 @@ enum class OathActionDescription(private val value: Int) {
RenameAccount(5),
DeleteAccount(6),
CalculateCode(7),
ActionFailure(8);
ActionFailure(8),
AddMultipleAccounts(9);
val id: Int
get() = value + dialogDescriptionOathIndex

View File

@ -195,6 +195,7 @@ class OathManager(
// OATH methods callable from Flutter:
oathChannel.setHandler(coroutineScope) { method, args ->
@Suppress("UNCHECKED_CAST")
when (method) {
"reset" -> reset()
"unlock" -> unlock(
@ -227,6 +228,11 @@ class OathManager(
args["requireTouch"] as Boolean
)
"addAccountsToAny" -> addAccountsToAny(
args["uris"] as List<String>,
args["requireTouch"] as List<Boolean>
)
else -> throw NotImplementedError()
}
}
@ -383,6 +389,44 @@ class OathManager(
}
}
private suspend fun addAccountsToAny(
uris: List<String>,
requireTouch: List<Boolean>,
): String {
logger.trace("Adding following accounts: {}", uris)
addToAny = true
return useOathSessionNfc(OathActionDescription.AddMultipleAccounts) { session ->
var successCount = 0
for (index in uris.indices) {
val credentialData: CredentialData =
CredentialData.parseUri(URI.create(uris[index]))
if (session.credentials.any { it.id.contentEquals(credentialData.id) }) {
logger.info("A credential with this ID already exists, skipping")
continue
}
val credential = session.putCredential(credentialData, requireTouch[index])
val code =
if (credentialData.oathType == YubiKitOathType.TOTP && !requireTouch[index]) {
// recalculate the code
calculateCode(session, credential)
} else null
oathViewModel.addCredential(
Credential(credential, session.deviceId),
Code.from(code)
)
logger.trace("Added cred {}", credential)
successCount++
}
jsonSerializer.encodeToString(mapOf("succeeded" to successCount))
}
}
private suspend fun reset(): String =
useOathSession(OathActionDescription.Reset) {
// note, it is ok to reset locked session

View File

@ -26,6 +26,7 @@ import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.util.Log
import android.util.Size
import android.view.View
import androidx.camera.core.*
import androidx.camera.lifecycle.ProcessCameraProvider
@ -81,8 +82,6 @@ internal class QRScannerView(
private val stateChangeObserver = StateChangeObserver(context)
private val uiThreadHandler = Handler(Looper.getMainLooper())
private var marginPct: Double? = null
companion object {
const val TAG = "QRScannerView"
@ -93,9 +92,6 @@ internal class QRScannerView(
Manifest.permission.CAMERA,
).toTypedArray()
// view related
private const val QR_SCANNER_ASPECT_RATIO = AspectRatio.RATIO_4_3
// communication channel
private const val CHANNEL_NAME =
"com.yubico.authenticator.flutter_plugins.qr_scanner_channel"
@ -128,10 +124,20 @@ internal class QRScannerView(
private var imageAnalysis: ImageAnalysis? = null
private var preview: Preview? = null
private var barcodeAnalyzer : BarcodeAnalyzer = BarcodeAnalyzer(marginPct) { analyzeResult ->
if (analyzeResult.isSuccess) {
analyzeResult.getOrNull()?.let { result ->
reportCodeFound(result)
private val barcodeAnalyzer = with(creationParams) {
var marginPct : Double? = null
if (this?.get("margin") is Number) {
val marginValue = this["margin"] as Number
if (marginValue.toDouble() > 0.0 && marginValue.toDouble() < 45) {
marginPct = marginValue.toDouble()
}
}
BarcodeAnalyzer(marginPct) { analyzeResult ->
if (analyzeResult.isSuccess) {
analyzeResult.getOrNull()?.let { result ->
reportCodeFound(result)
}
}
}
}
@ -155,19 +161,11 @@ internal class QRScannerView(
private val methodChannel: MethodChannel = MethodChannel(binaryMessenger, CHANNEL_NAME)
private var permissionsGranted = false
private val screenSize = with(context.resources.displayMetrics) {
Size(widthPixels, heightPixels)
}
init {
// read margin parameter
// only use it if it has reasonable value
if (creationParams?.get("margin") is Number) {
val marginValue = creationParams["margin"] as Number
if (marginValue.toDouble() > 0.0 && marginValue.toDouble() < 45) {
marginPct = marginValue.toDouble()
}
}
Log.v(TAG, "marginPct: $marginPct")
if (context is Activity) {
permissionsGranted = allPermissionsGranted(context)
@ -259,14 +257,14 @@ internal class QRScannerView(
imageAnalysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.setTargetAspectRatio(QR_SCANNER_ASPECT_RATIO)
.setTargetResolution(Size(768,1024))
.build()
.also {
it.setAnalyzer(cameraExecutor, barcodeAnalyzer)
}
preview = Preview.Builder()
.setTargetAspectRatio(QR_SCANNER_ASPECT_RATIO)
.setTargetResolution(screenSize)
.build()
.also {
it.setSurfaceProvider(previewView.surfaceProvider)
@ -369,7 +367,7 @@ internal class QRScannerView(
val fullSize = BinaryBitmap(HybridBinarizer(luminanceSource))
val bitmapToProcess = if (marginPct != null) {
val bitmapToProcess = if (marginPct != null && fullSize.isCropSupported) {
val shorterDim = min(imageProxy.width, imageProxy.height)
val cropMargin = marginPct * 0.01 * shorterDim
val cropWH = shorterDim - 2.0 * cropMargin
@ -395,6 +393,10 @@ internal class QRScannerView(
}
val result: com.google.zxing.Result = multiFormatReader.decode(bitmapToProcess)
if (analysisPaused) {
return
}
analysisPaused = true // pause
Log.v(TAG, "Analysis result: ${result.text}")
listener.invoke(Result.success(result.text))

View File

@ -18,10 +18,9 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../../app/message.dart';
import '../../oath/models.dart';
import '../../oath/views/add_account_page.dart';
import '../../oath/views/utils.dart';
const _appLinkMethodsChannel = MethodChannel('app.link.methods');
@ -30,24 +29,11 @@ void setupOtpAuthLinkHandler(BuildContext context) {
final args = jsonDecode(call.arguments);
switch (call.method) {
case 'handleOtpAuthLink':
{
var url = args['link'];
var otpauth = CredentialData.fromUri(Uri.parse(url));
Navigator.popUntil(context, ModalRoute.withName('/'));
await showBlurDialog(
context: context,
routeSettings: const RouteSettings(name: 'oath_add_account'),
builder: (_) {
return OathAddAccountPage(
null,
null,
credentials: null,
credentialData: otpauth,
);
},
);
break;
}
Navigator.popUntil(context, ModalRoute.withName('/'));
final l10n = AppLocalizations.of(context)!;
final uri = args['link'];
await handleUri(context, null, uri, null, null, l10n);
break;
default:
throw PlatformException(
code: 'NotImplemented',

View File

@ -22,6 +22,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:logging/logging.dart';
import 'package:yubico_authenticator/exception/cancellation_exception.dart';
import '../../app/logging.dart';
import '../../app/models.dart';
@ -139,6 +140,35 @@ final addCredentialToAnyProvider =
}
});
final addCredentialsToAnyProvider = Provider(
(ref) => (List<String> credentialUris, List<bool> touchRequired) async {
try {
_log.debug(
'Calling android with ${credentialUris.length} credentials to be added');
String resultString = await _methods.invokeMethod(
'addAccountsToAny',
{
'uris': credentialUris,
'requireTouch': touchRequired,
},
);
_log.debug('Call result: $resultString');
var result = jsonDecode(resultString);
return result['succeeded'] == credentialUris.length;
} on PlatformException catch (pe) {
var decodedException = pe.decode();
if (decodedException is CancellationException) {
_log.debug('User cancelled adding multiple accounts');
} else {
_log.error('Failed to add multiple accounts.', pe);
}
throw decodedException;
}
});
final androidCredentialListProvider = StateNotifierProvider.autoDispose
.family<OathCredentialListNotifier, List<OathPair>?, DevicePath>(
(ref, devicePath) {

View File

@ -38,15 +38,11 @@ GlobalKey<QRScannerZxingViewState> _zxingViewKey = GlobalKey();
class _QrScannerViewState extends State<QrScannerView> {
String? _scannedString;
// will be used later
// ignore: unused_field
CredentialData? _credentialData;
ScanStatus _status = ScanStatus.scanning;
bool _previewInitialized = false;
bool _permissionsGranted = false;
void setError() {
_credentialData = null;
_scannedString = null;
_status = ScanStatus.error;
@ -59,7 +55,6 @@ class _QrScannerViewState extends State<QrScannerView> {
void resetError() {
setState(() {
_credentialData = null;
_scannedString = null;
_status = ScanStatus.scanning;
@ -67,17 +62,17 @@ class _QrScannerViewState extends State<QrScannerView> {
});
}
void handleResult(String barCode) {
void handleResult(String qrCodeData) {
if (_status != ScanStatus.scanning) {
// on success and error ignore reported codes
return;
}
setState(() {
if (barCode.isNotEmpty) {
if (qrCodeData.isNotEmpty) {
try {
var parsedCredential = CredentialData.fromUri(Uri.parse(barCode));
_credentialData = parsedCredential;
_scannedString = barCode;
CredentialData.fromUri(Uri.parse(
qrCodeData)); // throws ArgumentError if validation fails
_scannedString = qrCodeData;
_status = ScanStatus.success;
final navigator = Navigator.of(context);
@ -143,7 +138,7 @@ class _QrScannerViewState extends State<QrScannerView> {
visible: _permissionsGranted,
child: QRScannerZxingView(
key: _zxingViewKey,
marginPct: 50,
marginPct: 10,
onDetect: (scannedData) => handleResult(scannedData),
onViewInitialized: (bool permissionsGranted) {
Future.delayed(const Duration(milliseconds: 50), () {

View File

@ -72,6 +72,7 @@ enum _DDesc {
oathDeleteAccount,
oathCalculateCode,
oathActionFailure,
oathAddMultipleAccounts,
invalid;
static const int dialogDescriptionOathIndex = 100;
@ -86,7 +87,8 @@ enum _DDesc {
dialogDescriptionOathIndex + 5: _DDesc.oathRenameAccount,
dialogDescriptionOathIndex + 6: _DDesc.oathDeleteAccount,
dialogDescriptionOathIndex + 7: _DDesc.oathCalculateCode,
dialogDescriptionOathIndex + 8: _DDesc.oathActionFailure
dialogDescriptionOathIndex + 8: _DDesc.oathActionFailure,
dialogDescriptionOathIndex + 9: _DDesc.oathAddMultipleAccounts
}[id] ??
_DDesc.invalid;
}
@ -158,6 +160,7 @@ class _DialogProvider {
_DDesc.oathDeleteAccount => l10n.s_nfc_dialog_oath_delete_account,
_DDesc.oathCalculateCode => l10n.s_nfc_dialog_oath_calculate_code,
_DDesc.oathActionFailure => l10n.s_nfc_dialog_oath_failure,
_DDesc.oathAddMultipleAccounts => l10n.s_nfc_dialog_oath_add_multiple_accounts,
_ => ''
};
}

View File

@ -17,18 +17,16 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../../android/app_methods.dart';
import '../../android/state.dart';
import '../../exception/cancellation_exception.dart';
import '../../core/state.dart';
import '../../fido/views/fido_screen.dart';
import '../../oath/models.dart';
import '../../oath/views/add_account_page.dart';
import '../../oath/state.dart';
import '../../oath/views/oath_screen.dart';
import '../../oath/views/utils.dart';
import '../../piv/views/piv_screen.dart';
import '../../widgets/custom_icons.dart';
import '../message.dart';
import '../models.dart';
import '../state.dart';
import 'device_error_screen.dart';
@ -100,33 +98,19 @@ class MainPage extends ConsumerWidget {
icon: const Icon(Icons.person_add_alt_1),
tooltip: l10n.s_add_account,
onPressed: () async {
CredentialData? otpauth;
final scanner = ref.read(qrScannerProvider);
if (scanner != null) {
try {
final url = await scanner.scanQr();
if (url != null) {
otpauth = CredentialData.fromUri(Uri.parse(url));
}
final uri = await scanner.scanQr();
final credentials = ref.read(credentialsProvider);
final withContext = ref.read(withContextProvider);
await withContext((context) =>
handleUri(context, credentials, uri, null, null, l10n));
} on CancellationException catch (_) {
// ignored - user cancelled
return;
}
}
await ref.read(withContextProvider)((context) => showBlurDialog(
context: context,
routeSettings:
const RouteSettings(name: 'oath_add_account'),
builder: (context) {
return OathAddAccountPage(
null,
null,
credentials: null,
credentialData: otpauth,
);
},
));
},
),
);

View File

@ -285,6 +285,9 @@
"s_accounts": "Accounts",
"s_no_accounts": "No accounts",
"s_add_account": "Add account",
"s_add_accounts" : "Add account(s)",
"p_add_description" : "To scan a QR code, make sure the full code is visible on screen and press the button below. You can also drag a saved image from a folder onto this dialog. If you have the account credential details in writing, use the manual entry instead.",
"s_add_manually" : "Add manually",
"s_account_added": "Account added",
"l_account_add_failed": "Failed adding account: {message}",
"@l_account_add_failed" : {
@ -294,7 +297,9 @@
},
"l_account_name_required": "Your account must have a name",
"l_name_already_exists": "This name already exists for the issuer",
"l_account_already_exists": "This account already exists on the YubiKey",
"l_invalid_character_issuer": "Invalid character: ':' is not allowed in issuer",
"l_select_accounts" : "Select account(s) to add to the YubiKey",
"s_pinned": "Pinned",
"s_pin_account": "Pin account",
"s_unpin_account": "Unpin account",
@ -597,6 +602,7 @@
"s_nfc_dialog_oath_delete_account": "Action: delete account",
"s_nfc_dialog_oath_calculate_code": "Action: calculate OATH code",
"s_nfc_dialog_oath_failure": "OATH operation failed",
"s_nfc_dialog_oath_add_multiple_accounts": "Action: add multiple accounts",
"@_eof": {}
}

View File

@ -27,6 +27,7 @@ final searchAccountsField = GlobalKey();
const setOrManagePasswordAction =
Key('$_keyAction.action.set_or_manage_password');
const addAccountAction = Key('$_keyAction.add_account');
const migrateAccountAction = Key('$_keyAction.migrate_account');
const resetAction = Key('$_keyAction.reset');
const customIconsAction = Key('$_keyAction.custom_icons');

View File

@ -14,6 +14,9 @@
* limitations under the License.
*/
import 'dart:typed_data';
import 'dart:convert';
import 'package:base32/base32.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
@ -120,10 +123,83 @@ class CredentialData with _$CredentialData {
factory CredentialData.fromJson(Map<String, dynamic> json) =>
_$CredentialDataFromJson(json);
factory CredentialData.fromUri(Uri uri) {
if (uri.scheme.toLowerCase() != 'otpauth') {
throw ArgumentError('Invalid scheme, must be "otpauth://"');
static List<CredentialData> fromUri(Uri uri) {
if (uri.scheme.toLowerCase() == 'otpauth-migration') {
return CredentialData.fromMigration(uri);
} else if (uri.scheme.toLowerCase() == 'otpauth') {
return [CredentialData.fromOtpauth(uri)];
} else {
throw ArgumentError('Invalid scheme');
}
}
static List<CredentialData> fromMigration(Uri uri) {
// Parse single protobuf encoded integer
(int value, Uint8List rem) protoInt(Uint8List data) {
final extras = data.takeWhile((b) => b & 0x80 != 0).length;
int value = 0;
for (int i = extras; i >= 0; i--) {
value = (value << 7) | (data[i] & 0x7F);
}
return (value, data.sublist(1 + extras));
}
// Parse a single protobuf value from a buffer
(int tag, dynamic value, Uint8List rem) protoValue(Uint8List data) {
final first = data[0];
final int len;
(len, data) = protoInt(data.sublist(1));
final index = first >> 3;
switch (first & 0x07) {
case 0:
return (index, len, data);
case 2:
return (index, data.sublist(0, len), data.sublist(len));
}
throw ArgumentError('Unsupported value type!');
}
// Parse a protobuf message into map of tags and values
Map<int, dynamic> protoMap(Uint8List data) {
Map<int, dynamic> values = {};
while (data.isNotEmpty) {
final (tag, value, rem) = protoValue(data);
values[tag] = value;
data = rem;
}
return values;
}
// Parse encoded credentials from data (tag 1) ignoring trailing extra data
Iterable<Map<int, dynamic>> splitCreds(Uint8List rem) sync* {
Uint8List credrem;
while (rem[0] == 0x0a) {
(_, credrem, rem) = protoValue(rem);
yield protoMap(credrem);
}
}
// Convert parsed credential values into CredentialData objects
return splitCreds(base64.decode(uri.queryParameters['data']!))
.map((values) => CredentialData(
secret: base32.encode(values[1]),
name: utf8.decode(values[2], allowMalformed: true),
issuer: values[3] != null
? utf8.decode(values[3], allowMalformed: true)
: null,
hashAlgorithm: switch (values[4]) {
2 => HashAlgorithm.sha256,
3 => HashAlgorithm.sha512,
_ => HashAlgorithm.sha1,
},
digits: values[5] == 2 ? 8 : defaultDigits,
oathType: values[6] == 1 ? OathType.hotp : OathType.totp,
counter: values[7] ?? defaultCounter,
))
.toList();
}
factory CredentialData.fromOtpauth(Uri uri) {
final oathType = OathType.values.byName(uri.host.toLowerCase());
final params = uri.queryParameters;
String? issuer;

View File

@ -0,0 +1,119 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:yubico_authenticator/app/message.dart';
import 'package:yubico_authenticator/app/state.dart';
import 'package:yubico_authenticator/widgets/responsive_dialog.dart';
import '../../app/models.dart';
import '../../widgets/file_drop_target.dart';
import '../models.dart';
import '../state.dart';
import 'add_account_page.dart';
import 'utils.dart';
class AddAccountDialog extends ConsumerStatefulWidget {
final DevicePath? devicePath;
final OathState? state;
const AddAccountDialog(this.devicePath, this.state, {super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() =>
_AddAccountDialogState();
}
class _AddAccountDialogState extends ConsumerState<AddAccountDialog> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final credentials = ref.read(credentialsProvider);
final withContext = ref.read(withContextProvider);
final qrScanner = ref.watch(qrScannerProvider);
return ResponsiveDialog(
title: Text(l10n.s_add_account),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: FileDropTarget(
onFileDropped: (fileData) async {
Navigator.of(context).pop();
if (qrScanner != null) {
final b64Image = base64Encode(fileData);
final uri = await qrScanner.scanQr(b64Image);
await withContext((context) => handleUri(context, credentials,
uri, widget.devicePath, widget.state, l10n));
}
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.p_add_description),
const SizedBox(height: 4),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 4.0,
runSpacing: 8.0,
children: [
ActionChip(
avatar: const Icon(Icons.qr_code_scanner_outlined),
label: Text(l10n.s_qr_scan),
onPressed: () async {
Navigator.of(context).pop();
if (qrScanner != null) {
final uri = await qrScanner.scanQr();
await withContext((context) => handleUri(
context,
credentials,
uri,
widget.devicePath,
widget.state,
l10n));
}
},
),
ActionChip(
avatar: const Icon(Icons.edit_outlined),
label: Text(l10n.s_add_manually),
onPressed: () async {
Navigator.of(context).pop();
await withContext((context) async {
await showBlurDialog(
context: context,
builder: (context) => OathAddAccountPage(
widget.devicePath,
widget.state,
credentials: credentials,
),
);
});
}),
])
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
));
}
}

View File

@ -51,8 +51,6 @@ final _log = Logger('oath.view.add_account_page');
final _secretFormatterPattern =
RegExp('[abcdefghijklmnopqrstuvwxyz234567 ]', caseSensitive: false);
enum _QrScanState { none, scanning, success, failed }
class OathAddAccountPage extends ConsumerStatefulWidget {
final DevicePath? devicePath;
final OathState? state;
@ -82,8 +80,9 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
OathType _oathType = defaultOathType;
HashAlgorithm _hashAlgorithm = defaultHashAlgorithm;
int _digits = defaultDigits;
int _counter = defaultCounter;
bool _validateSecretLength = false;
_QrScanState _qrState = _QrScanState.none;
bool _dataLoaded = false;
bool _isObscure = true;
List<int> _periodValues = [20, 30, 45, 60];
List<int> _digitsValues = [6, 8];
@ -107,55 +106,6 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
}
}
_scanQrCode(QrScanner qrScanner) async {
final l10n = AppLocalizations.of(context)!;
try {
setState(() {
// If we have a previous scan result stored, clear it
if (_qrState == _QrScanState.success) {
_issuerController.text = '';
_accountController.text = '';
_secretController.text = '';
_oathType = defaultOathType;
_hashAlgorithm = defaultHashAlgorithm;
_periodController.text = '$defaultPeriod';
_digits = defaultDigits;
}
_qrState = _QrScanState.scanning;
});
final otpauth = await qrScanner.scanQr();
if (otpauth == null) {
if (!mounted) return;
showMessage(context, l10n.l_qr_not_found);
setState(() {
_qrState = _QrScanState.failed;
});
} else {
final data = CredentialData.fromUri(Uri.parse(otpauth));
_loadCredentialData(data);
}
} catch (e) {
final String errorMessage;
// TODO: Make this cleaner than importing desktop specific RpcError.
if (e is RpcError) {
errorMessage = e.message;
} else {
errorMessage = e.toString();
}
if (e is! CancellationException) {
showMessage(
context,
l10n.l_qr_not_read(errorMessage),
duration: const Duration(seconds: 4),
);
}
setState(() {
_qrState = _QrScanState.failed;
});
}
}
_loadCredentialData(CredentialData data) {
setState(() {
_issuerController.text = data.issuer?.trim() ?? '';
@ -167,8 +117,9 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
_periodController.text = '${data.period}';
_digitsValues = [data.digits];
_digits = data.digits;
_counter = data.counter;
_isObscure = true;
_qrState = _QrScanState.success;
_dataLoaded = true;
});
}
@ -176,7 +127,6 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
{DevicePath? devicePath, required Uri credUri}) async {
final l10n = AppLocalizations.of(context)!;
try {
FocusUtils.unfocus(context);
if (devicePath == null) {
@ -304,8 +254,6 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
nameRemaining >= 0 &&
period > 0;
final qrScanner = ref.watch(qrScannerProvider);
final hashAlgorithms = HashAlgorithm.values
.where((alg) =>
alg != HashAlgorithm.sha512 ||
@ -330,6 +278,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
hashAlgorithm: _hashAlgorithm,
digits: _digits,
period: period,
counter: _counter,
);
final devicePath = deviceNode?.path;
@ -368,6 +317,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
],
child: FileDropTarget(
onFileDropped: (fileData) async {
final qrScanner = ref.read(qrScannerProvider);
if (qrScanner != null) {
final b64Image = base64Encode(fileData);
final otpauth = await qrScanner.scanQr(b64Image);
@ -375,8 +325,20 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
if (!mounted) return;
showMessage(context, l10n.l_qr_not_found);
} else {
final data = CredentialData.fromUri(Uri.parse(otpauth));
_loadCredentialData(data);
try {
final data = CredentialData.fromOtpauth(Uri.parse(otpauth));
_loadCredentialData(data);
} catch (e) {
final String errorMessage;
// TODO: Make this cleaner than importing desktop specific RpcError.
if (e is RpcError) {
errorMessage = e.message;
} else {
errorMessage = e.toString();
}
if (!mounted) return;
showMessage(context, errorMessage);
}
}
}
},
@ -483,7 +445,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
errorText: _validateSecretLength && !secretLengthValid
? l10n.s_invalid_length
: null),
readOnly: _qrState == _QrScanState.success,
readOnly: _dataLoaded,
textInputAction: TextInputAction.done,
onChanged: (value) {
setState(() {
@ -494,25 +456,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
if (isValid) submit();
},
),
if (isDesktop && qrScanner != null)
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: ActionChip(
avatar: _qrState != _QrScanState.scanning
? (_qrState == _QrScanState.success
? const Icon(Icons.qr_code)
: const Icon(
Icons.qr_code_scanner_outlined))
: const CircularProgressIndicator(
strokeWidth: 2.0),
label: _qrState == _QrScanState.success
? Text(l10n.l_qr_scanned)
: Text(l10n.s_qr_scan),
onPressed: () {
_scanQrCode(qrScanner);
}),
),
const Divider(),
const SizedBox(height: 8),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 4.0,
@ -534,7 +478,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
selected: _oathType != defaultOathType,
itemBuilder: (value) =>
Text(value.getDisplayName(l10n)),
onChanged: _qrState != _QrScanState.success
onChanged: !_dataLoaded
? (value) {
setState(() {
_oathType = value;
@ -547,7 +491,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
value: _hashAlgorithm,
selected: _hashAlgorithm != defaultHashAlgorithm,
itemBuilder: (value) => Text(value.displayName),
onChanged: _qrState != _QrScanState.success
onChanged: !_dataLoaded
? (value) {
setState(() {
_hashAlgorithm = value;
@ -564,7 +508,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
defaultPeriod,
itemBuilder: ((value) =>
Text(l10n.s_num_sec(value))),
onChanged: _qrState != _QrScanState.success
onChanged: !_dataLoaded
? (period) {
setState(() {
_periodController.text = '$period';
@ -578,7 +522,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
selected: _digits != defaultDigits,
itemBuilder: (value) =>
Text(l10n.s_num_digits(value)),
onChanged: _qrState != _QrScanState.success
onChanged: !_dataLoaded
? (digits) {
setState(() {
_digits = digits;

View File

@ -0,0 +1,303 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import 'package:yubico_authenticator/app/logging.dart';
import 'package:yubico_authenticator/exception/apdu_exception.dart';
import '../../android/oath/state.dart';
import '../../app/models.dart';
import '../../core/models.dart';
import '../../desktop/models.dart';
import '../../widgets/responsive_dialog.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../models.dart';
import '../../app/state.dart';
import '../../core/state.dart';
import '../state.dart';
import '../../app/message.dart';
import '../../exception/cancellation_exception.dart';
import 'rename_list_account.dart';
final _log = Logger('oath.views.list_screen');
class OathAddMultiAccountPage extends ConsumerStatefulWidget {
final DevicePath? devicePath;
final OathState? state;
final List<CredentialData>? credentialsFromUri;
const OathAddMultiAccountPage(
this.devicePath, this.state, this.credentialsFromUri,
{super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() =>
_OathAddMultiAccountPageState();
}
class _OathAddMultiAccountPageState
extends ConsumerState<OathAddMultiAccountPage> {
int? _numCreds;
late Map<CredentialData, (bool, bool, bool)> _credStates;
List<OathCredential>? _credentials;
@override
void initState() {
super.initState();
_credStates = Map.fromIterable(widget.credentialsFromUri!,
value: (v) => (true, false, false));
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
if (widget.devicePath != null) {
_credentials = ref
.watch(credentialListProvider(widget.devicePath!))
?.map((e) => e.credential)
.toList();
_numCreds = ref.watch(credentialListProvider(widget.devicePath!)
.select((value) => value?.length));
}
// If the credential is not unique, make sure the checkbox is not checked
checkForDuplicates();
return ResponsiveDialog(
title: Text(l10n.s_add_accounts),
actions: [
TextButton(
onPressed: isValid() ? submit : null,
child: Text(l10n.s_save),
)
],
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Text(l10n.l_select_accounts)),
...widget.credentialsFromUri!.map(
(cred) {
final (checked, touch, unique) = _credStates[cred]!;
return CheckboxListTile(
controlAffinity: ListTileControlAffinity.leading,
secondary: Row(mainAxisSize: MainAxisSize.min, children: [
if (isTouchSupported())
IconButton(
color: touch ? colorScheme.primary : null,
onPressed: unique
? () {
setState(() {
_credStates[cred] =
(checked, !touch, unique);
});
}
: null,
icon: const Icon(Icons.touch_app_outlined)),
IconButton(
onPressed: () async {
final node = ref
.read(currentDeviceDataProvider)
.valueOrNull
?.node;
final withContext = ref.read(withContextProvider);
CredentialData renamed = await withContext(
(context) async => await showBlurDialog(
context: context,
builder: (context) => RenameList(node!, cred,
widget.credentialsFromUri, _credentials),
));
setState(() {
int index = widget.credentialsFromUri!.indexWhere(
(element) =>
element.name == cred.name &&
(element.issuer == cred.issuer));
widget.credentialsFromUri![index] = renamed;
_credStates.remove(cred);
_credStates[renamed] = (true, touch, true);
});
},
icon: IconTheme(
data: IconTheme.of(context),
child: const Icon(Icons.edit_outlined)),
),
]),
title: Text(cred.issuer ?? cred.name,
overflow: TextOverflow.fade,
maxLines: 1,
softWrap: false),
value: unique && checked,
enabled: unique,
subtitle: cred.issuer != null || !unique
? Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (cred.issuer != null)
Text(cred.name,
overflow: TextOverflow.fade,
maxLines: 1,
softWrap: false),
if (!unique)
Text(
l10n.l_account_already_exists,
style: TextStyle(
color: colorScheme.error,
fontSize: 12, // TODO: use Theme
),
)
])
: null,
onChanged: (bool? value) {
setState(() {
_credStates[cred] = (value == true, touch, unique);
});
},
);
},
)
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
));
}
bool isTouchSupported() => widget.state?.version.isAtLeast(4, 2) ?? true;
void checkForDuplicates() {
for (final item in _credStates.entries) {
CredentialData cred = item.key;
final (checked, touch, _) = item.value;
final unique = isUnique(cred);
_credStates[cred] = (checked && unique, touch, unique);
}
}
bool isUnique(CredentialData cred) {
String nameText = cred.name;
String? issuerText = cred.issuer ?? '';
bool ans = _credentials
?.where((element) =>
element.name == nameText &&
(element.issuer ?? '') == issuerText)
.isEmpty ??
true;
return ans;
}
bool isValid() {
if (widget.state != null) {
final credsToAdd =
_credStates.values.where((element) => element.$1).length;
final capacity = widget.state!.version.isAtLeast(4) ? 32 : null;
return (credsToAdd > 0) &&
(capacity == null || (_numCreds! + credsToAdd <= capacity));
} else {
return true;
}
}
void submit() async {
final deviceNode = ref.watch(currentDeviceProvider);
if (isAndroid &&
(widget.devicePath == null || deviceNode?.transport == Transport.nfc)) {
var uris = <String>[];
var touchRequired = <bool>[];
// build list of uris and touch required flags for unique credentials
for (final item in _credStates.entries) {
CredentialData cred = item.key;
final (checked, touch, _) = item.value;
if (checked) {
uris.add(cred.toUri().toString());
touchRequired.add(touch);
}
}
await _addCredentials(uris: uris, touchRequired: touchRequired);
} else {
_credStates.forEach((cred, value) {
if (value.$1) {
accept(cred, value.$2);
}
});
Navigator.of(context).pop();
}
}
Future<void> _addCredentials(
{required List<String> uris, required List<bool> touchRequired}) async {
final l10n = AppLocalizations.of(context)!;
try {
await ref.read(addCredentialsToAnyProvider).call(uris, touchRequired);
if (!mounted) return;
Navigator.of(context).pop();
showMessage(context, l10n.s_account_added);
} on CancellationException catch (_) {
// ignored
} catch (e) {
_log.error('Failed to add multiple accounts', e.toString());
final String errorMessage;
if (e is ApduException) {
errorMessage = e.message;
} else {
errorMessage = e.toString();
}
showMessage(
context,
l10n.l_account_add_failed(errorMessage),
duration: const Duration(seconds: 4),
);
}
}
void accept(CredentialData cred, bool touch) async {
final l10n = AppLocalizations.of(context)!;
final devicePath = widget.devicePath;
try {
if (devicePath == null) {
assert(isAndroid, 'devicePath is only optional for Android');
await ref
.read(addCredentialToAnyProvider)
.call(cred.toUri(), requireTouch: touch);
} else {
await ref
.read(credentialListProvider(devicePath).notifier)
.addAccount(cred.toUri(), requireTouch: touch);
}
if (!mounted) return;
//Navigator.of(context).pop();
showMessage(context, l10n.s_account_added);
} on CancellationException catch (_) {
// ignored
} catch (e) {
_log.error('Failed to add account', e);
final String errorMessage;
// TODO: Make this cleaner than importing desktop specific RpcError.
if (e is RpcError) {
errorMessage = e.message;
} else if (e is ApduException) {
errorMessage = e.message;
} else {
errorMessage = e.toString();
}
showMessage(
context,
l10n.l_account_add_failed(errorMessage),
duration: const Duration(seconds: 4),
);
}
}
}

View File

@ -18,6 +18,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:yubico_authenticator/oath/icon_provider/icon_pack_dialog.dart';
import 'package:yubico_authenticator/oath/views/add_account_dialog.dart';
import '../../app/message.dart';
import '../../app/models.dart';
@ -25,13 +26,12 @@ import '../../app/state.dart';
import '../../app/views/fs_dialog.dart';
import '../../app/views/action_list.dart';
import '../../core/state.dart';
import '../../exception/cancellation_exception.dart';
import '../models.dart';
import '../state.dart';
import '../keys.dart' as keys;
import 'add_account_page.dart';
import '../state.dart';
import 'manage_password_dialog.dart';
import 'reset_dialog.dart';
import 'utils.dart';
Widget oathBuildActions(
BuildContext context,
@ -48,49 +48,35 @@ Widget oathBuildActions(
children: [
ActionListSection(l10n.s_setup, children: [
ActionListItem(
key: keys.addAccountAction,
actionStyle: ActionStyle.primary,
icon: const Icon(Icons.person_add_alt_1_outlined),
title: l10n.s_add_account,
subtitle: used == null
? l10n.l_unlock_first
: (capacity != null
? l10n.l_accounts_used(used, capacity)
: ''),
onTap: used != null && (capacity == null || capacity > used)
? (context) async {
final credentials = ref.read(credentialsProvider);
final withContext = ref.read(withContextProvider);
Navigator.of(context).pop();
CredentialData? otpauth;
if (isAndroid) {
final scanner = ref.read(qrScannerProvider);
if (scanner != null) {
try {
final url = await scanner.scanQr();
if (url != null) {
otpauth = CredentialData.fromUri(Uri.parse(url));
}
} on CancellationException catch (_) {
// ignored - user cancelled
return;
title: l10n.s_add_account,
subtitle: used == null
? l10n.l_unlock_first
: (capacity != null
? l10n.l_accounts_used(used, capacity)
: ''),
actionStyle: ActionStyle.primary,
icon: const Icon(Icons.person_add_alt_1_outlined),
onTap: used != null && (capacity == null || capacity > used)
? (context) async {
final credentials = ref.read(credentialsProvider);
final withContext = ref.read(withContextProvider);
Navigator.of(context).pop();
if (isAndroid) {
final qrScanner = ref.read(qrScannerProvider);
if (qrScanner != null) {
final uri = await qrScanner.scanQr();
await withContext((context) => handleUri(context,
credentials, uri, devicePath, oathState, l10n));
}
} else {
await showBlurDialog(
context: context,
builder: (context) =>
AddAccountDialog(devicePath, oathState),
);
}
}
await withContext((context) async {
await showBlurDialog(
context: context,
builder: (context) => OathAddAccountPage(
devicePath,
oathState,
credentials: credentials,
credentialData: otpauth,
),
);
});
}
: null,
),
: null),
]),
ActionListSection(l10n.s_manage, children: [
ActionListItem(

View File

@ -0,0 +1,207 @@
/*
* Copyright (C) 2022 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import '../../app/logging.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../exception/cancellation_exception.dart';
import '../../desktop/models.dart';
import '../../widgets/responsive_dialog.dart';
import '../../widgets/utf8_utils.dart';
import '../models.dart';
import '../keys.dart' as keys;
import 'utils.dart';
final _log = Logger('oath.view.rename_account_dialog');
class RenameList extends ConsumerStatefulWidget {
final DeviceNode device;
final CredentialData credential;
final List<CredentialData>? credentialsFromUri;
final List<OathCredential>? credentials;
const RenameList(
this.device, this.credential, this.credentialsFromUri, this.credentials,
{super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _RenameListState();
}
class _RenameListState extends ConsumerState<RenameList> {
late String _issuer;
late String _account;
@override
void initState() {
super.initState();
_issuer = widget.credential.issuer?.trim() ?? '';
_account = widget.credential.name.trim();
}
void _submit() {
if (!mounted) return;
final l10n = AppLocalizations.of(context)!;
try {
// Rename credentials
final credential = widget.credential.copyWith(
issuer: _issuer == '' ? null : _issuer,
name: _account,
);
Navigator.of(context).pop(credential);
showMessage(context, l10n.s_account_renamed);
} on CancellationException catch (_) {
// ignored
} catch (e) {
_log.error('Failed to add account', e);
final String errorMessage;
// TODO: Make this cleaner than importing desktop specific RpcError.
if (e is RpcError) {
errorMessage = e.message;
} else {
errorMessage = e.toString();
}
showMessage(
context,
l10n.l_account_add_failed(errorMessage),
duration: const Duration(seconds: 4),
);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final credential = widget.credential;
final (issuerRemaining, nameRemaining) = getRemainingKeySpace(
oathType: credential.oathType,
period: credential.period,
issuer: _issuer,
name: _account,
);
// is this credential's name/issuer pair different from all other?
final isUniqueFromUri = widget.credentialsFromUri
?.where((element) =>
element != credential &&
element.name == _account &&
(element.issuer ?? '') == _issuer)
.isEmpty ??
false;
final isUniqueFromDevice = widget.credentials
?.where((element) =>
element.name == _account && (element.issuer ?? '') == _issuer)
.isEmpty ??
false;
// is this credential's name/issuer of valid format
final isValidFormat = _account.isNotEmpty;
// are the name/issuer values different from original
final didChange = (widget.credential.issuer ?? '') != _issuer ||
widget.credential.name != _account;
// can we rename with the new values
final isValid = isUniqueFromUri && isUniqueFromDevice && isValidFormat;
return ResponsiveDialog(
title: Text(l10n.s_rename_account),
actions: [
TextButton(
onPressed: didChange && isValid ? _submit : null,
key: keys.saveButton,
child: Text(l10n.s_save),
),
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
credential.issuer != null
? Text(l10n.q_rename_target(
'${credential.issuer} (${credential.name})'))
: Text(l10n.q_rename_target(credential.name)),
Text(l10n.p_rename_will_change_account_displayed),
TextFormField(
initialValue: _issuer,
enabled: issuerRemaining > 0,
maxLength: issuerRemaining > 0 ? issuerRemaining : null,
buildCounter: buildByteCounterFor(_issuer),
inputFormatters: [limitBytesLength(issuerRemaining)],
key: keys.issuerField,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_issuer_optional,
helperText: '', // Prevents dialog resizing when disabled
prefixIcon: const Icon(Icons.business_outlined),
),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
_issuer = value.trim();
});
},
),
TextFormField(
initialValue: _account,
maxLength: nameRemaining,
inputFormatters: [limitBytesLength(nameRemaining)],
buildCounter: buildByteCounterFor(_account),
key: keys.nameField,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_account_name,
helperText: '', // Prevents dialog resizing when disabled
errorText: !isValidFormat
? l10n.l_account_name_required
: (!isUniqueFromUri || !isUniqueFromDevice)
? l10n.l_name_already_exists
: null,
prefixIcon: const Icon(Icons.people_alt_outlined),
),
textInputAction: TextInputAction.done,
onChanged: (value) {
setState(() {
_account = value.trim();
});
},
onFieldSubmitted: (_) {
if (didChange && isValid) {
_submit();
}
},
),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
);
}
}

View File

@ -16,8 +16,16 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../widgets/utf8_utils.dart';
import '../keys.dart';
import '../models.dart';
import 'add_account_page.dart';
import 'add_multi_account_page.dart';
/// Calculates the available space for issuer and account name.
///
@ -53,3 +61,34 @@ String getTextName(OathCredential credential) {
? '${credential.issuer} (${credential.name})'
: credential.name;
}
Future<void> handleUri(
BuildContext context,
List<OathCredential>? credentials,
String? uri,
DevicePath? devicePath,
OathState? state,
AppLocalizations l10n,
) async {
List<CredentialData> creds =
uri != null ? CredentialData.fromUri(Uri.parse(uri)) : [];
if (creds.isEmpty) {
showMessage(context, l10n.l_qr_not_found);
} else if (creds.length == 1) {
await showBlurDialog(
context: context,
builder: (context) => OathAddAccountPage(
devicePath,
state,
credentials: credentials,
credentialData: creds[0],
),
);
} else {
await showBlurDialog(
context: context,
builder: (context) => OathAddMultiAccountPage(devicePath, state, creds,
key: migrateAccountAction),
);
}
}

View File

@ -40,6 +40,7 @@ class ResponsiveDialog extends StatefulWidget {
class _ResponsiveDialogState extends State<ResponsiveDialog> {
final Key _childKey = GlobalKey();
final _focus = FocusScopeNode();
bool _hasLostFocus = false;
@override
void dispose() {
@ -101,8 +102,9 @@ class _ResponsiveDialogState extends State<ResponsiveDialog> {
node: _focus,
autofocus: true,
onFocusChange: (focused) {
if (!focused) {
if (!focused && !_hasLostFocus) {
_focus.requestFocus();
_hasLostFocus = true;
}
},
child: constraints.maxWidth < 540

View File

@ -41,6 +41,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.11.0"
base32:
dependency: "direct main"
description:
name: base32
sha256: ddad4ebfedf93d4500818ed8e61443b734ffe7cf8a45c668c9b34ef6adde02e2
url: "https://pub.dev"
source: hosted
version: "2.1.3"
boolean_selector:
dependency: transitive
description:
@ -154,7 +162,7 @@ packages:
source: hosted
version: "1.17.1"
convert:
dependency: transitive
dependency: "direct main"
description:
name: convert
sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592"

View File

@ -64,6 +64,8 @@ dependencies:
tray_manager: ^0.2.0
local_notifier: ^0.1.5
io: ^1.0.4
base32: ^2.1.3
convert: ^3.1.1
dev_dependencies:
integration_test: