Add basic management screen.

This commit is contained in:
Dain Nilsson 2022-03-04 13:42:10 +01:00
parent 0cf575d7c3
commit d42eb84d04
No known key found for this signature in database
GPG Key ID: F04367096FBA95E8
7 changed files with 418 additions and 2 deletions

View File

@ -10,6 +10,7 @@ import 'device_info_screen.dart';
import '../models.dart';
import '../state.dart';
import '../../oath/views/oath_screen.dart';
import '../../management/views/management_screen.dart';
class MainPage extends ConsumerWidget {
const MainPage({Key? key}) : super(key: key);
@ -22,6 +23,8 @@ class MainPage extends ConsumerWidget {
switch (subPage) {
case SubPage.oath:
return OathScreen(device);
case SubPage.management:
return ManagementScreen(device);
default:
return DeviceInfoScreen(device);
}

View File

@ -6,9 +6,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:window_manager/window_manager.dart';
import 'package:yubico_authenticator/management/state.dart';
import '../oath/state.dart';
import '../app/state.dart';
import 'management/state.dart';
import 'oath/state.dart';
import 'rpc.dart';
import 'devices.dart';
@ -86,5 +88,6 @@ Future<List<Override>> initializeAndGetOverrides(
credentialListProvider
.overrideWithProvider(desktopOathCredentialListProvider),
qrScannerProvider.overrideWithProvider(desktopQrScannerProvider),
managementStateProvider.overrideWithProvider(desktopManagementState),
];
}

View File

@ -0,0 +1,85 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import 'package:yubico_authenticator/management/models.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../management/state.dart';
import '../rpc.dart';
import '../state.dart';
final _log = Logger('desktop.management.state');
final _sessionProvider =
Provider.autoDispose.family<RpcNodeSession, DevicePath>(
(ref, devicePath) {
final protocol = ref.watch(currentDeviceProvider)!.when(
usbYubiKey: (path, name, pid, info) {
final interfaces = UsbInterfaces.forCapabilites(
info.config.enabledCapabilities[Transport.usb] ?? 0);
return [UsbInterface.ccid, UsbInterface.otp, UsbInterface.fido]
.firstWhere((iface) => iface.value & interfaces != 0);
},
nfcReader: (_, __) => UsbInterface.ccid,
);
return RpcNodeSession(
ref.watch(rpcProvider), devicePath, [protocol.name, 'management']);
},
);
final desktopManagementState = StateNotifierProvider.autoDispose
.family<ManagementStateNotifier, DeviceInfo?, DevicePath>(
(ref, devicePath) {
final session = ref.watch(_sessionProvider(devicePath));
final notifier = _DesktopManagementStateNotifier(session);
session.setErrorHandler('state-reset', (_) async {
ref.refresh(_sessionProvider(devicePath));
});
ref.onDispose(() {
session.unserErrorHandler('state-reset');
});
return notifier..refresh();
},
);
class _DesktopManagementStateNotifier extends ManagementStateNotifier {
final RpcNodeSession _session;
_DesktopManagementStateNotifier(this._session) : super();
void refresh() async {
var result = await _session.command('get');
_log.config('application status', jsonEncode(result));
if (mounted) {
state = DeviceInfo.fromJson(result['data']);
}
}
@override
Future<void> setMode(int mode,
{int challengeResponseTimeout = 0, int autoEjectTimeout = 0}) async {
await _session.command('set_mode', params: {
'mode': mode,
'challenge_response_timeout': challengeResponseTimeout,
'auto_eject_timeout': autoEjectTimeout,
});
}
@override
Future<void> writeConfig(DeviceConfig config,
{String currentLockCode = '',
String newLockCode = '',
bool reboot = false}) async {
if (reboot) {
state = null;
}
await _session.command('configure', params: {
...config.toJson(),
'cur_lock_code': currentLockCode,
'new_lock_code': newLockCode,
'reboot': reboot,
});
}
}

View File

@ -26,6 +26,82 @@ enum FormFactor {
usbCBio,
}
enum UsbInterface { otp, fido, ccid }
extension UsbInterfaces on UsbInterface {
int get value {
switch (this) {
case UsbInterface.otp:
return 0x01;
case UsbInterface.fido:
return 0x02;
case UsbInterface.ccid:
return 0x04;
}
}
static int forCapabilites(int capabilities) {
var interfaces = 0;
if (capabilities & Capability.otp.value != 0) {
interfaces |= UsbInterface.otp.value;
}
if (capabilities & (Capability.u2f.value | Capability.fido2.value) != 0) {
interfaces |= UsbInterface.fido.value;
}
if (capabilities &
(Capability.openpgp.value |
Capability.piv.value |
Capability.oath.value |
Capability.hsmauth.value) !=
0) {
interfaces |= UsbInterface.ccid.value;
}
return interfaces;
}
}
enum Capability { otp, u2f, openpgp, piv, oath, hsmauth, fido2 }
extension CapabilityExtension on Capability {
int get value {
switch (this) {
case Capability.otp:
return 0x001;
case Capability.u2f:
return 0x002;
case Capability.openpgp:
return 0x008;
case Capability.piv:
return 0x010;
case Capability.oath:
return 0x020;
case Capability.hsmauth:
return 0x100;
case Capability.fido2:
return 0x200;
}
}
String get name {
switch (this) {
case Capability.otp:
return 'OTP';
case Capability.u2f:
return 'FIDO U2F';
case Capability.openpgp:
return 'OpenPGP';
case Capability.piv:
return 'PIV';
case Capability.oath:
return 'OATH';
case Capability.hsmauth:
return 'YubiHSM Auth';
case Capability.fido2:
return 'FIDO2';
}
}
}
@freezed
class DeviceConfig with _$DeviceConfig {
factory DeviceConfig(

21
lib/management/state.dart Executable file
View File

@ -0,0 +1,21 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:yubico_authenticator/management/models.dart';
import '../app/models.dart';
final managementStateProvider = StateNotifierProvider.autoDispose
.family<ManagementStateNotifier, DeviceInfo?, DevicePath>(
(ref, devicePath) => throw UnimplementedError(),
);
abstract class ManagementStateNotifier extends StateNotifier<DeviceInfo?> {
ManagementStateNotifier() : super(null);
Future<void> writeConfig(DeviceConfig config,
{String currentLockCode = '',
String newLockCode = '',
bool reboot = false});
Future<void> setMode(int mode,
{int challengeResponseTimeout = 0, int autoEjectTimeout = 0});
}

View File

@ -0,0 +1,180 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:collection/collection.dart';
import '../../app/models.dart';
import '../models.dart';
import '../state.dart';
final _mapEquals = const DeepCollectionEquality().equals;
class _CapabilityForm extends StatelessWidget {
final int capabilities;
final int enabled;
final Function(int) onChanged;
const _CapabilityForm(
{required this.capabilities,
required this.enabled,
required this.onChanged,
Key? key})
: super(key: key);
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 6.0,
runSpacing: 6.0,
children: Capability.values
.where((c) => capabilities & c.value != 0)
.map((c) => FilterChip(
showCheckmark: true,
selected: enabled & c.value != 0,
label: Text(c.name),
onSelected: (_) {
onChanged(enabled ^ c.value);
},
))
.toList(),
);
}
}
class _CapabilitiesForm extends StatefulWidget {
final DeviceInfo info;
final Function(Map<Transport, int>) onSubmit;
const _CapabilitiesForm(this.info, {required this.onSubmit, Key? key})
: super(key: key);
@override
State<StatefulWidget> createState() => _CapabilitiesFormState();
}
class _CapabilitiesFormState extends State<_CapabilitiesForm> {
late Map<Transport, int> _enabled;
@override
void initState() {
super.initState();
// Make sure to copy enabledCapabilites, not mutate the original.
_enabled = {...widget.info.config.enabledCapabilities};
}
@override
Widget build(BuildContext context) {
final usbCapabilities =
widget.info.supportedCapabilities[Transport.usb] ?? 0;
final nfcCapabilities =
widget.info.supportedCapabilities[Transport.nfc] ?? 0;
final changed =
!_mapEquals(widget.info.config.enabledCapabilities, _enabled);
final valid = changed && (_enabled[Transport.usb] ?? 0) > 0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (usbCapabilities != 0)
const ListTile(
leading: Icon(Icons.usb),
title: Text('USB applications'),
),
_CapabilityForm(
capabilities: usbCapabilities,
enabled: _enabled[Transport.usb] ?? 0,
onChanged: (enabled) {
setState(() {
_enabled[Transport.usb] = enabled;
});
},
),
if (nfcCapabilities != 0)
const ListTile(
leading: Icon(Icons.wifi),
title: Text('NFC applications'),
),
_CapabilityForm(
capabilities: nfcCapabilities,
enabled: _enabled[Transport.nfc] ?? 0,
onChanged: (enabled) {
setState(() {
_enabled[Transport.nfc] = enabled;
});
},
),
Container(
padding: const EdgeInsets.all(16.0),
alignment: Alignment.centerRight,
child: ElevatedButton(
onPressed: valid
? () {
widget.onSubmit(_enabled);
}
: null,
child: const Text('Apply changes'),
),
)
],
);
}
}
class ManagementScreen extends ConsumerWidget {
final YubiKeyData deviceData;
const ManagementScreen(this.deviceData, {Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(managementStateProvider(deviceData.node.path));
if (state == null) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Center(child: CircularProgressIndicator()),
],
);
}
return ListView(
children: [
_CapabilitiesForm(state, onSubmit: (enabled) async {
final bool reboot;
if (deviceData.node is UsbYubiKeyNode) {
final oldInterfaces = UsbInterfaces.forCapabilites(
state.config.enabledCapabilities[Transport.usb] ?? 0);
final newInterfaces =
UsbInterfaces.forCapabilites(enabled[Transport.usb] ?? 0);
reboot = oldInterfaces != newInterfaces;
} else {
reboot = false;
}
Function()? close;
try {
if (reboot) {
close = ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(
content: Text('Updating configuration...'),
duration: Duration(seconds: 8),
))
.close;
}
await ref
.read(managementStateProvider(deviceData.node.path).notifier)
.writeConfig(
state.config.copyWith(enabledCapabilities: enabled),
reboot: reboot,
);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Configuration updated'),
duration: Duration(seconds: 2),
));
} finally {
close?.call();
}
})
],
);
}
}

View File

@ -27,14 +27,23 @@
from .base import RpcNode, action
from yubikit.core import require_version, NotSupportedError
from yubikit.management import ManagementSession, DeviceConfig
from yubikit.core import require_version, NotSupportedError, TRANSPORT
from yubikit.core.smartcard import SmartCardConnection
from yubikit.core.otp import OtpConnection
from yubikit.core.fido import FidoConnection
from yubikit.management import ManagementSession, DeviceConfig, USB_INTERFACE
from ykman.device import connect_to_device
from dataclasses import asdict
from time import sleep
import logging
logger = logging.getLogger(__name__)
class ManagementNode(RpcNode):
def __init__(self, connection):
super().__init__()
self._connection_type = type(connection)
self.session = ManagementSession(connection)
def get_data(self):
@ -49,6 +58,41 @@ class ManagementNode(RpcNode):
actions.remove("configure")
return actions
def _await_reboot(self, serial, usb_enabled):
# TODO: Clean up once "support" is merged into ykman.
iface = USB_INTERFACE.for_capabilities(usb_enabled)
connection_types = []
# Prefer to use the "same" connection type as before
if iface.supports_connection(self._connection_type):
if issubclass(self._connection_type, SmartCardConnection):
connection_types = [SmartCardConnection]
elif issubclass(self._connection_type, OtpConnection):
connection_types = [OtpConnection]
elif issubclass(self._connection_type, FidoConnection):
connection_types = [FidoConnection]
# Allow any expected connection type
if not connection_types:
connection_types = [
t
for t in [SmartCardConnection, OtpConnection, FidoConnection]
if iface.supports_connection(t)
]
self.session.close()
logger.debug("Waiting for device to re-appear...")
for _ in range(10):
sleep(0.2) # Always sleep initially
try:
conn = connect_to_device(serial, connection_types)[0]
conn.close()
break
except ValueError:
logger.debug("Not found, sleep...")
else:
logger.warning("Timed out waiting for device")
@action
def configure(self, params, event, signal):
reboot = params.pop("reboot", False)
@ -60,7 +104,11 @@ class ManagementNode(RpcNode):
params.pop("challenge_response_timeout", None),
params.pop("device_flags", None),
)
serial = self.session.read_device_info().serial
self.session.write_device_config(config, reboot, cur_lock_code, new_lock_code)
if reboot:
enabled = config.enabled_capabilities.get(TRANSPORT.USB)
self._await_reboot(serial, enabled)
return dict()
@action