Add PIV to helper.

This commit is contained in:
Dain Nilsson 2023-04-27 09:13:38 +02:00
parent 9eeb44f3ac
commit efa8f35e05
No known key found for this signature in database
GPG Key ID: F04367096FBA95E8
27 changed files with 6389 additions and 9 deletions

View File

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

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

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

View File

@ -53,6 +53,7 @@ enum Application {
String getDisplayName(AppLocalizations l10n) => switch (this) {
Application.oath => l10n.s_authenticator,
Application.fido => l10n.s_webauthn,
Application.piv => "PIV", //TODO
_ => name.substring(0, 1).toUpperCase() + name.substring(1),
};

View File

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

View File

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

View File

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

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

@ -0,0 +1,425 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:logging/logging.dart';
import 'package:yubico_authenticator/desktop/models.dart';
import '../../app/logging.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../app/views/user_interaction.dart';
import '../../core/models.dart';
import '../../piv/models.dart';
import '../../piv/state.dart';
import '../rpc.dart';
import '../state.dart';
final _log = Logger('desktop.piv.state');
final _managementKeyProvider =
StateProvider.autoDispose.family<String?, DevicePath>(
(ref, _) => null,
);
final _pinProvider = StateProvider.autoDispose.family<String?, DevicePath>(
(ref, _) => null,
);
final _sessionProvider =
Provider.autoDispose.family<RpcNodeSession, DevicePath>(
(ref, devicePath) {
// Make sure the managementKey and PIN are held for the duration of the session.
ref.watch(_managementKeyProvider(devicePath));
ref.watch(_pinProvider(devicePath));
return RpcNodeSession(
ref.watch(rpcProvider).requireValue, devicePath, ['ccid', 'piv']);
},
);
final desktopPivState = AsyncNotifierProvider.autoDispose
.family<PivStateNotifier, PivState, DevicePath>(
_DesktopPivStateNotifier.new);
class _DesktopPivStateNotifier extends PivStateNotifier {
late RpcNodeSession _session;
late DevicePath _devicePath;
@override
FutureOr<PivState> build(DevicePath devicePath) async {
_session = ref.watch(_sessionProvider(devicePath));
_session
..setErrorHandler('state-reset', (_) async {
ref.invalidate(_sessionProvider(devicePath));
})
..setErrorHandler('auth-required', (_) async {
final String? mgmtKey;
if (state.valueOrNull?.metadata?.managementKeyMetadata.defaultValue ==
true) {
mgmtKey = defaultManagementKey;
} else {
mgmtKey = ref.read(_managementKeyProvider(devicePath));
}
if (mgmtKey != null) {
if (await authenticate(mgmtKey)) {
ref.invalidateSelf();
} else {
ref.read(_managementKeyProvider(devicePath).notifier).state = null;
}
}
});
ref.onDispose(() {
_session
..unsetErrorHandler('state-reset')
..unsetErrorHandler('auth-required');
});
_devicePath = devicePath;
final result = await _session.command('get');
_log.debug('application status', jsonEncode(result));
final pivState = PivState.fromJson(result['data']);
return pivState;
}
@override
Future<void> reset() async {
await _session.command('reset');
ref.invalidate(_sessionProvider(_session.devicePath));
}
@override
Future<bool> authenticate(String managementKey) async {
final withContext = ref.watch(withContextProvider);
final signaler = Signaler();
UserInteractionController? controller;
try {
signaler.signals.listen((signal) async {
if (signal.status == 'touch') {
controller = await withContext(
(context) async {
final l10n = AppLocalizations.of(context)!;
return promptUserInteraction(
context,
icon: const Icon(Icons.touch_app),
title: l10n.s_touch_required,
description: l10n.l_touch_button_now,
);
},
);
}
});
final result = await _session.command(
'authenticate',
params: {'key': managementKey},
signal: signaler,
);
if (result['status']) {
ref.read(_managementKeyProvider(_devicePath).notifier).state =
managementKey;
final oldState = state.valueOrNull;
if (oldState != null) {
state = AsyncData(oldState.copyWith(authenticated: true));
}
return true;
} else {
return false;
}
} finally {
controller?.close();
}
}
@override
Future<PinVerificationStatus> verifyPin(String pin) async {
final pivState = state.valueOrNull;
final signaler = Signaler();
UserInteractionController? controller;
try {
if (pivState?.protectedKey == true) {
// Might require touch as this will also authenticate
final withContext = ref.watch(withContextProvider);
signaler.signals.listen((signal) async {
if (signal.status == 'touch') {
controller = await withContext(
(context) async {
final l10n = AppLocalizations.of(context)!;
return promptUserInteraction(
context,
icon: const Icon(Icons.touch_app),
title: l10n.s_touch_required,
description: l10n.l_touch_button_now,
);
},
);
}
});
}
await _session.command(
'verify_pin',
params: {'pin': pin},
signal: signaler,
);
ref.read(_pinProvider(_devicePath).notifier).state = pin;
return const PinVerificationStatus.success();
} on RpcError catch (e) {
if (e.status == 'invalid-pin') {
return PinVerificationStatus.failure(e.body['attempts_remaining']);
}
rethrow;
} finally {
controller?.close();
ref.invalidateSelf();
}
}
@override
Future<PinVerificationStatus> changePin(String pin, String newPin) async {
try {
await _session.command(
'change_pin',
params: {'pin': pin, 'new_pin': newPin},
);
ref.read(_pinProvider(_devicePath).notifier).state = null;
return const PinVerificationStatus.success();
} on RpcError catch (e) {
if (e.status == 'invalid-pin') {
return PinVerificationStatus.failure(e.body['attempts_remaining']);
}
rethrow;
} finally {
ref.invalidateSelf();
}
}
@override
Future<PinVerificationStatus> changePuk(String puk, String newPuk) async {
try {
await _session.command(
'change_puk',
params: {'puk': puk, 'new_puk': newPuk},
);
return const PinVerificationStatus.success();
} on RpcError catch (e) {
if (e.status == 'invalid-pin') {
return PinVerificationStatus.failure(e.body['attempts_remaining']);
}
rethrow;
} finally {
ref.invalidateSelf();
}
}
@override
Future<void> setManagementKey(String managementKey,
{ManagementKeyType managementKeyType = defaultManagementKeyType,
bool storeKey = false}) async {
await _session.command(
'set_key',
params: {
'key': managementKey,
'key_type': managementKeyType.value,
'store_key': storeKey,
},
);
ref.invalidateSelf();
}
@override
Future<PinVerificationStatus> unblockPin(String puk, String newPin) async {
try {
await _session.command(
'unblock_pin',
params: {'puk': puk, 'new_pin': newPin},
);
return const PinVerificationStatus.success();
} on RpcError catch (e) {
if (e.status == 'invalid-pin') {
return PinVerificationStatus.failure(e.body['attempts_remaining']);
}
rethrow;
} finally {
ref.invalidateSelf();
}
}
}
final _shownSlots = SlotId.values.map((slot) => slot.id).toList();
extension on SlotId {
String get node => id.toRadixString(16).padLeft(2, '0');
}
final desktopPivSlots = AsyncNotifierProvider.autoDispose
.family<PivSlotsNotifier, List<PivSlot>, DevicePath>(
_DesktopPivSlotsNotifier.new);
class _DesktopPivSlotsNotifier extends PivSlotsNotifier {
late RpcNodeSession _session;
@override
FutureOr<List<PivSlot>> build(DevicePath devicePath) async {
_session = ref.watch(_sessionProvider(devicePath));
final result = await _session.command('get', target: ['slots']);
return (result['children'] as Map<String, dynamic>)
.values
.where((e) => _shownSlots.contains(e['slot']))
.map((e) => PivSlot.fromJson(e))
.toList();
}
@override
Future<void> delete(SlotId slot) async {
await _session.command('delete', target: ['slots', slot.node]);
ref.invalidateSelf();
}
@override
Future<PivGenerateResult> generate(
SlotId slot,
KeyType keyType, {
required PivGenerateParameters parameters,
PinPolicy pinPolicy = PinPolicy.dfault,
TouchPolicy touchPolicy = TouchPolicy.dfault,
String? pin,
}) async {
final withContext = ref.watch(withContextProvider);
final signaler = Signaler();
UserInteractionController? controller;
try {
signaler.signals.listen((signal) async {
if (signal.status == 'touch') {
controller = await withContext(
(context) async {
final l10n = AppLocalizations.of(context)!;
return promptUserInteraction(
context,
icon: const Icon(Icons.touch_app),
title: l10n.s_touch_required,
description: l10n.l_touch_button_now,
);
},
);
}
});
final (type, subject, validFrom, validTo) = parameters.when(
certificate: (subject, validFrom, validTo) => (
GenerateType.certificate,
subject,
dateFormatter.format(validFrom),
dateFormatter.format(validTo),
),
csr: (subject) => (
GenerateType.csr,
subject,
null,
null,
),
);
final pin = ref.read(_pinProvider(_session.devicePath));
final result = await _session.command(
'generate',
target: [
'slots',
slot.node,
],
params: {
'key_type': keyType.value,
'pin_policy': pinPolicy.value,
'touch_policy': touchPolicy.value,
'subject': subject,
'generate_type': type.name,
'valid_from': validFrom,
'valid_to': validTo,
'pin': pin,
},
signal: signaler,
);
ref.invalidateSelf();
return PivGenerateResult.fromJson(
{'generate_type': type.name, ...result});
} finally {
controller?.close();
}
}
@override
Future<PivExamineResult> examine(String data, {String? password}) async {
final result = await _session.command('examine_file', target: [
'slots',
], params: {
'data': data,
'password': password,
});
if (result['status']) {
return PivExamineResult.fromJson({'runtimeType': 'result', ...result});
} else {
return PivExamineResult.invalidPassword();
}
}
@override
Future<PivImportResult> import(SlotId slot, String data,
{String? password,
PinPolicy pinPolicy = PinPolicy.dfault,
TouchPolicy touchPolicy = TouchPolicy.dfault}) async {
final result = await _session.command('import_file', target: [
'slots',
slot.node,
], params: {
'data': data,
'password': password,
'pin_policy': pinPolicy.value,
'touch_policy': touchPolicy.value,
});
ref.invalidateSelf();
return PivImportResult.fromJson(result);
}
@override
Future<(SlotMetadata?, String?)> read(SlotId slot) async {
final result = await _session.command('get', target: [
'slots',
slot.node,
]);
final data = result['data'];
final metadata = data['metadata'];
return (
metadata != null ? SlotMetadata.fromJson(metadata) : null,
data['certificate'] as String?,
);
}
}

View File

@ -1,3 +1,19 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

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

@ -0,0 +1,35 @@
/*
* Copyright (C) 2022-2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter/material.dart';
const _prefix = 'piv.keys';
const managePinAction = Key('$_prefix.manage_pin');
const managePukAction = Key('$_prefix.manage_puk');
const manageManagementKeyAction = Key('$_prefix.manage_management_key');
const resetAction = Key('$_prefix.reset');
const setupMacOsAction = Key('$_prefix.setup_macos');
const saveButton = Key('$_prefix.save');
const deleteButton = Key('$_prefix.delete');
const unlockButton = Key('$_prefix.unlock');
const managementKeyField = Key('$_prefix.management_key');
const pinPukField = Key('$_prefix.pin_puk');
const newPinPukField = Key('$_prefix.new_pin_puk');
const confirmPinPukField = Key('$_prefix.confirm_pin_puk');
const subjectField = Key('$_prefix.subject');

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

@ -0,0 +1,313 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../core/models.dart';
part 'models.freezed.dart';
part 'models.g.dart';
const defaultManagementKey = '010203040506070801020304050607080102030405060708';
const defaultManagementKeyType = ManagementKeyType.tdes;
const defaultKeyType = KeyType.rsa2048;
const defaultGenerateType = GenerateType.certificate;
enum GenerateType {
certificate,
csr;
String getDisplayName(AppLocalizations l10n) {
return switch (this) {
// TODO:
_ => name
};
}
}
enum SlotId {
authentication(0x9a),
signature(0x9c),
keyManagement(0x9d),
cardAuth(0x9e);
final int id;
const SlotId(this.id);
String getDisplayName(AppLocalizations l10n) {
return switch (this) {
// TODO:
_ => name
};
}
factory SlotId.fromJson(int value) =>
SlotId.values.firstWhere((e) => e.id == value);
}
@JsonEnum(alwaysCreate: true)
enum PinPolicy {
@JsonValue(0x00)
dfault,
@JsonValue(0x01)
never,
@JsonValue(0x02)
once,
@JsonValue(0x03)
always;
const PinPolicy();
int get value => _$PinPolicyEnumMap[this]!;
String getDisplayName(AppLocalizations l10n) {
return switch (this) {
// TODO:
_ => name
};
}
}
@JsonEnum(alwaysCreate: true)
enum TouchPolicy {
@JsonValue(0x00)
dfault,
@JsonValue(0x01)
never,
@JsonValue(0x02)
always,
@JsonValue(0x03)
cached;
const TouchPolicy();
int get value => _$TouchPolicyEnumMap[this]!;
String getDisplayName(AppLocalizations l10n) {
return switch (this) {
// TODO:
_ => name
};
}
}
@JsonEnum(alwaysCreate: true)
enum KeyType {
@JsonValue(0x06)
rsa1024,
@JsonValue(0x07)
rsa2048,
@JsonValue(0x11)
eccp256,
@JsonValue(0x14)
eccp384;
const KeyType();
int get value => _$KeyTypeEnumMap[this]!;
String getDisplayName(AppLocalizations l10n) {
return switch (this) {
// TODO:
_ => name
};
}
}
enum ManagementKeyType {
@JsonValue(0x03)
tdes,
@JsonValue(0x08)
aes128,
@JsonValue(0x0A)
aes192,
@JsonValue(0x0C)
aes256;
const ManagementKeyType();
int get value => _$ManagementKeyTypeEnumMap[this]!;
int get keyLength => switch (this) {
ManagementKeyType.tdes => 24,
ManagementKeyType.aes128 => 16,
ManagementKeyType.aes192 => 24,
ManagementKeyType.aes256 => 32,
};
String getDisplayName(AppLocalizations l10n) {
return switch (this) {
// TODO:
_ => name
};
}
}
@freezed
class PinMetadata with _$PinMetadata {
factory PinMetadata(
bool defaultValue,
int totalAttempts,
int attemptsRemaining,
) = _PinMetadata;
factory PinMetadata.fromJson(Map<String, dynamic> json) =>
_$PinMetadataFromJson(json);
}
@freezed
class PinVerificationStatus with _$PinVerificationStatus {
const factory PinVerificationStatus.success() = _PinSuccess;
factory PinVerificationStatus.failure(int attemptsRemaining) = _PinFailure;
}
@freezed
class ManagementKeyMetadata with _$ManagementKeyMetadata {
factory ManagementKeyMetadata(
ManagementKeyType keyType,
bool defaultValue,
TouchPolicy touchPolicy,
) = _ManagementKeyMetadata;
factory ManagementKeyMetadata.fromJson(Map<String, dynamic> json) =>
_$ManagementKeyMetadataFromJson(json);
}
@freezed
class SlotMetadata with _$SlotMetadata {
factory SlotMetadata(
KeyType keyType,
PinPolicy pinPolicy,
TouchPolicy touchPolicy,
bool generated,
String publicKeyEncoded,
) = _SlotMetadata;
factory SlotMetadata.fromJson(Map<String, dynamic> json) =>
_$SlotMetadataFromJson(json);
}
@freezed
class PivStateMetadata with _$PivStateMetadata {
factory PivStateMetadata({
required ManagementKeyMetadata managementKeyMetadata,
required PinMetadata pinMetadata,
required PinMetadata pukMetadata,
}) = _PivStateMetadata;
factory PivStateMetadata.fromJson(Map<String, dynamic> json) =>
_$PivStateMetadataFromJson(json);
}
@freezed
class PivState with _$PivState {
const PivState._();
factory PivState({
required Version version,
required bool authenticated,
required bool derivedKey,
required bool storedKey,
required int pinAttempts,
String? chuid,
String? ccc,
PivStateMetadata? metadata,
}) = _PivState;
bool get protectedKey => derivedKey || storedKey;
bool get needsAuth =>
!authenticated && metadata?.managementKeyMetadata.defaultValue != true;
factory PivState.fromJson(Map<String, dynamic> json) =>
_$PivStateFromJson(json);
}
@freezed
class CertInfo with _$CertInfo {
factory CertInfo({
required String subject,
required String issuer,
required String serial,
required String notValidBefore,
required String notValidAfter,
required String fingerprint,
}) = _CertInfo;
factory CertInfo.fromJson(Map<String, dynamic> json) =>
_$CertInfoFromJson(json);
}
@freezed
class PivSlot with _$PivSlot {
factory PivSlot({
required SlotId slot,
bool? hasKey,
CertInfo? certInfo,
}) = _PivSlot;
factory PivSlot.fromJson(Map<String, dynamic> json) =>
_$PivSlotFromJson(json);
}
@freezed
class PivExamineResult with _$PivExamineResult {
factory PivExamineResult.result({
required bool password,
required bool privateKey,
required int certificates,
}) = _ExamineResult;
factory PivExamineResult.invalidPassword() = _InvalidPassword;
factory PivExamineResult.fromJson(Map<String, dynamic> json) =>
_$PivExamineResultFromJson(json);
}
@freezed
class PivGenerateParameters with _$PivGenerateParameters {
factory PivGenerateParameters.certificate({
required String subject,
required DateTime validFrom,
required DateTime validTo,
}) = _GenerateCertificate;
factory PivGenerateParameters.csr({
required String subject,
}) = _GenerateCsr;
}
@freezed
class PivGenerateResult with _$PivGenerateResult {
factory PivGenerateResult({
required GenerateType generateType,
required String publicKey,
required String result,
}) = _PivGenerateResult;
factory PivGenerateResult.fromJson(Map<String, dynamic> json) =>
_$PivGenerateResultFromJson(json);
}
@freezed
class PivImportResult with _$PivImportResult {
factory PivImportResult({
required SlotMetadata? metadata,
required String? publicKey,
required String? certificate,
}) = _PivImportResult;
factory PivImportResult.fromJson(Map<String, dynamic> json) =>
_$PivImportResultFromJson(json);
}

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

File diff suppressed because it is too large Load Diff

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

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

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

@ -0,0 +1,70 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../app/models.dart';
import '../core/state.dart';
import 'models.dart';
final pivStateProvider = AsyncNotifierProvider.autoDispose
.family<PivStateNotifier, PivState, DevicePath>(
() => throw UnimplementedError(),
);
abstract class PivStateNotifier extends ApplicationStateNotifier<PivState> {
Future<void> reset();
Future<bool> authenticate(String managementKey);
Future<void> setManagementKey(
String managementKey, {
ManagementKeyType managementKeyType = defaultManagementKeyType,
bool storeKey = false,
});
Future<PinVerificationStatus> verifyPin(
String pin); //TODO: Maybe return authenticated?
Future<PinVerificationStatus> changePin(String pin, String newPin);
Future<PinVerificationStatus> changePuk(String puk, String newPuk);
Future<PinVerificationStatus> unblockPin(String puk, String newPin);
}
final pivSlotsProvider = AsyncNotifierProvider.autoDispose
.family<PivSlotsNotifier, List<PivSlot>, DevicePath>(
() => throw UnimplementedError(),
);
abstract class PivSlotsNotifier
extends AutoDisposeFamilyAsyncNotifier<List<PivSlot>, DevicePath> {
Future<PivExamineResult> examine(String data, {String? password});
Future<(SlotMetadata?, String?)> read(SlotId slot);
Future<PivGenerateResult> generate(
SlotId slot,
KeyType keyType, {
required PivGenerateParameters parameters,
PinPolicy pinPolicy = PinPolicy.dfault,
TouchPolicy touchPolicy = TouchPolicy.dfault,
String? pin,
});
Future<PivImportResult> import(
SlotId slot,
String data, {
String? password,
PinPolicy pinPolicy = PinPolicy.dfault,
TouchPolicy touchPolicy = TouchPolicy.dfault,
});
Future<void> delete(SlotId slot);
}

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

@ -0,0 +1,221 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:yubico_authenticator/app/models.dart';
import '../../app/message.dart';
import '../../app/shortcuts.dart';
import '../../app/state.dart';
import '../models.dart';
import '../state.dart';
import 'authentication_dialog.dart';
import 'delete_certificate_dialog.dart';
import 'generate_key_dialog.dart';
import 'import_file_dialog.dart';
import 'pin_dialog.dart';
class AuthenticateIntent extends Intent {
const AuthenticateIntent();
}
class VerifyPinIntent extends Intent {
const VerifyPinIntent();
}
class GenerateIntent extends Intent {
const GenerateIntent();
}
class ImportIntent extends Intent {
const ImportIntent();
}
class ExportIntent extends Intent {
const ExportIntent();
}
Future<bool> _authenticate(
WidgetRef ref, DevicePath devicePath, PivState pivState) async {
final withContext = ref.read(withContextProvider);
return await withContext((context) async =>
await showBlurDialog(
context: context,
builder: (context) => AuthenticationDialog(
devicePath,
pivState,
),
) ??
false);
}
Future<bool> _authIfNeeded(
WidgetRef ref, DevicePath devicePath, PivState pivState) async {
if (pivState.needsAuth) {
return await _authenticate(ref, devicePath, pivState);
}
return true;
}
Widget registerPivActions(
DevicePath devicePath,
PivState pivState,
PivSlot pivSlot, {
required WidgetRef ref,
required Widget Function(BuildContext context) builder,
Map<Type, Action<Intent>> actions = const {},
}) =>
Actions(
actions: {
AuthenticateIntent: CallbackAction<AuthenticateIntent>(
onInvoke: (intent) => _authenticate(ref, devicePath, pivState),
),
GenerateIntent:
CallbackAction<GenerateIntent>(onInvoke: (intent) async {
if (!await _authIfNeeded(ref, devicePath, pivState)) {
return false;
}
final withContext = ref.read(withContextProvider);
// TODO: Avoid asking for PIN if not needed?
final verified = await withContext((context) async =>
await showBlurDialog(
context: context,
builder: (context) => PinDialog(devicePath))) ??
false;
if (!verified) {
return false;
}
return await withContext((context) async {
final PivGenerateResult? result = await showBlurDialog(
context: context,
builder: (context) => GenerateKeyDialog(
devicePath,
pivState,
pivSlot,
),
);
switch (result?.generateType) {
case GenerateType.csr:
final filePath = await FilePicker.platform.saveFile(
dialogTitle: 'Save CSR to file',
allowedExtensions: ['csr'],
type: FileType.custom,
lockParentWindow: true,
);
if (filePath != null) {
final file = File(filePath);
await file.writeAsString(result!.result, flush: true);
}
break;
default:
break;
}
return result != null;
});
}),
ImportIntent: CallbackAction<ImportIntent>(onInvoke: (intent) async {
if (!await _authIfNeeded(ref, devicePath, pivState)) {
return false;
}
final picked = await FilePicker.platform.pickFiles(
allowedExtensions: ['pem', 'der', 'pfx', 'p12', 'key', 'crt'],
type: FileType.custom,
allowMultiple: false,
lockParentWindow: true,
dialogTitle: 'Select file to import');
if (picked == null || picked.files.isEmpty) {
return false;
}
final withContext = ref.read(withContextProvider);
return await withContext((context) async =>
await showBlurDialog(
context: context,
builder: (context) => ImportFileDialog(
devicePath,
pivState,
pivSlot,
File(picked.paths.first!),
),
) ??
false);
}),
ExportIntent: CallbackAction<ExportIntent>(onInvoke: (intent) async {
final (_, cert) = await ref
.read(pivSlotsProvider(devicePath).notifier)
.read(pivSlot.slot);
if (cert == null) {
return false;
}
final filePath = await FilePicker.platform.saveFile(
dialogTitle: 'Export certificate to file',
allowedExtensions: ['pem'],
type: FileType.custom,
lockParentWindow: true,
);
if (filePath == null) {
return false;
}
final file = File(filePath);
await file.writeAsString(cert, flush: true);
await ref.read(withContextProvider)((context) async {
showMessage(context, 'Certificate exported');
});
return true;
}),
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async {
if (!await _authIfNeeded(ref, devicePath, pivState)) {
return false;
}
final withContext = ref.read(withContextProvider);
final bool? deleted = await withContext((context) async =>
await showBlurDialog(
context: context,
builder: (context) => DeleteCertificateDialog(
devicePath,
pivSlot,
),
) ??
false);
// Needs to move to slot dialog(?) or react to state change
// Pop the slot dialog if deleted
if (deleted == true) {
await withContext((context) async {
Navigator.of(context).pop();
});
}
return deleted;
}), //TODO
...actions,
},
child: Builder(builder: builder),
);

View File

@ -0,0 +1,109 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/models.dart';
import '../../exception/cancellation_exception.dart';
import '../../widgets/responsive_dialog.dart';
import '../models.dart';
import '../state.dart';
import '../keys.dart' as keys;
class AuthenticationDialog extends ConsumerStatefulWidget {
final DevicePath devicePath;
final PivState pivState;
const AuthenticationDialog(this.devicePath, this.pivState, {super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() =>
_AuthenticationDialogState();
}
class _AuthenticationDialogState extends ConsumerState<AuthenticationDialog> {
String _managementKey = '';
bool _keyIsWrong = false;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return ResponsiveDialog(
title: Text("Unlock management functions"),
actions: [
TextButton(
key: keys.unlockButton,
onPressed: () async {
final navigator = Navigator.of(context);
try {
final status = await ref
.read(pivStateProvider(widget.devicePath).notifier)
.authenticate(_managementKey);
if (status) {
navigator.pop(true);
} else {
setState(() {
_keyIsWrong = true;
});
}
} on CancellationException catch (_) {
navigator.pop(false);
} catch (_) {
// TODO: More error cases
setState(() {
_keyIsWrong = true;
});
}
},
child: Text(l10n.s_unlock),
),
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
autofocus: true,
obscureText: true,
autofillHints: const [AutofillHints.password],
key: keys.managementKeyField,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: "Management key",
prefixIcon: const Icon(Icons.key_outlined),
errorText: _keyIsWrong ? l10n.s_wrong_password : null,
errorMaxLines: 3),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
_keyIsWrong = false;
_managementKey = value;
});
},
),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
);
}
}

View File

@ -0,0 +1,83 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../exception/cancellation_exception.dart';
import '../../widgets/responsive_dialog.dart';
import '../models.dart';
import '../state.dart';
import '../keys.dart' as keys;
class DeleteCertificateDialog extends ConsumerWidget {
final DevicePath devicePath;
final PivSlot pivSlot;
const DeleteCertificateDialog(this.devicePath, this.pivSlot, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
return ResponsiveDialog(
title: Text(l10n.s_delete_account),
actions: [
TextButton(
key: keys.deleteButton,
onPressed: () async {
try {
await ref
.read(pivSlotsProvider(devicePath).notifier)
.delete(pivSlot.slot);
await ref.read(withContextProvider)(
(context) async {
Navigator.of(context).pop(true);
showMessage(context, l10n.s_account_deleted);
},
);
} on CancellationException catch (_) {
// ignored
}
},
child: Text(l10n.s_delete),
),
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.p_warning_delete_account),
Text(
l10n.p_warning_disable_credential,
style: Theme.of(context).textTheme.bodyLarge,
),
Text(// TODO
'Delete certificate in ${pivSlot.slot.getDisplayName(l10n)} (Slot ${pivSlot.slot.id.toRadixString(16).padLeft(2, '0')})?'),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
);
}
}

View File

@ -0,0 +1,166 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/models.dart';
import '../../core/models.dart';
import '../../widgets/choice_filter_chip.dart';
import '../../widgets/responsive_dialog.dart';
import '../models.dart';
import '../state.dart';
import '../keys.dart' as keys;
class GenerateKeyDialog extends ConsumerStatefulWidget {
final DevicePath devicePath;
final PivState pivState;
final PivSlot pivSlot;
const GenerateKeyDialog(this.devicePath, this.pivState, this.pivSlot,
{super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() =>
_GenerateKeyDialogState();
}
class _GenerateKeyDialogState extends ConsumerState<GenerateKeyDialog> {
String _subject = '';
GenerateType _generateType = defaultGenerateType;
KeyType _keyType = defaultKeyType;
late DateTime _validFrom;
late DateTime _validTo;
late DateTime _validToDefault;
late DateTime _validToMax;
@override
void initState() {
super.initState();
final now = DateTime.now();
_validFrom = DateTime.utc(now.year, now.month, now.day);
_validToDefault = DateTime.utc(now.year + 1, now.month, now.day);
_validTo = _validToDefault;
_validToMax = DateTime.utc(now.year + 10, now.month, now.day);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final navigator = Navigator.of(context);
return ResponsiveDialog(
title: Text("Generate key"),
actions: [
TextButton(
key: keys.saveButton,
onPressed: () async {
final result = await ref
.read(pivSlotsProvider(widget.devicePath).notifier)
.generate(
widget.pivSlot.slot,
_keyType,
parameters: switch (_generateType) {
GenerateType.certificate =>
PivGenerateParameters.certificate(
subject: _subject,
validFrom: _validFrom,
validTo: _validTo),
GenerateType.csr =>
PivGenerateParameters.csr(subject: _subject),
},
);
navigator.pop(result);
},
child: Text(l10n.s_save),
),
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
autofocus: true,
key: keys.subjectField,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: "Subject",
),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
_subject = value;
});
},
),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 4.0,
runSpacing: 8.0,
children: [
ChoiceFilterChip<GenerateType>(
items: GenerateType.values,
value: _generateType,
selected: _generateType != defaultGenerateType,
itemBuilder: (value) => Text(value.getDisplayName(l10n)),
onChanged: (value) {
setState(() {
_generateType = value;
});
},
),
ChoiceFilterChip<KeyType>(
items: KeyType.values,
value: _keyType,
selected: _keyType != defaultKeyType,
itemBuilder: (value) => Text(value.getDisplayName(l10n)),
onChanged: (value) {
setState(() {
_keyType = value;
});
},
),
if (_generateType == GenerateType.certificate)
FilterChip(
label: Text(dateFormatter.format(_validTo)),
onSelected: (value) async {
final selected = await showDatePicker(
context: context,
initialDate: _validTo,
firstDate: _validFrom,
lastDate: _validToMax,
);
if (selected != null) {
setState(() {
_validTo = selected;
});
}
},
),
]),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
);
}
}

View File

@ -0,0 +1,186 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/models.dart';
import '../../widgets/responsive_dialog.dart';
import '../models.dart';
import '../state.dart';
import '../keys.dart' as keys;
class ImportFileDialog extends ConsumerStatefulWidget {
final DevicePath devicePath;
final PivState pivState;
final PivSlot pivSlot;
final File file;
const ImportFileDialog(
this.devicePath, this.pivState, this.pivSlot, this.file,
{super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() =>
_ImportFileDialogState();
}
class _ImportFileDialogState extends ConsumerState<ImportFileDialog> {
late String _data;
PivExamineResult? _state;
String _password = '';
bool _passwordIsWrong = false;
@override
void initState() {
super.initState();
_init();
}
void _init() async {
final bytes = await widget.file.readAsBytes();
_data = bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join();
_examine();
}
void _examine() async {
setState(() {
_state = null;
});
final result = await ref
.read(pivSlotsProvider(widget.devicePath).notifier)
.examine(_data, password: _password.isNotEmpty ? _password : null);
setState(() {
_state = result;
_passwordIsWrong = result.maybeWhen(
invalidPassword: () => _password.isNotEmpty,
orElse: () => true,
);
});
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final state = _state;
if (state == null) {
return ResponsiveDialog(
title: Text("Import file"),
actions: [
TextButton(
key: keys.unlockButton,
onPressed: null,
child: Text(l10n.s_unlock),
),
],
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 18.0),
child: Center(
child: CircularProgressIndicator(),
)),
);
}
return state.when(
invalidPassword: () => ResponsiveDialog(
title: Text("Import file"),
actions: [
TextButton(
key: keys.unlockButton,
onPressed: () => _examine(),
child: Text(l10n.s_unlock),
),
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
autofocus: true,
obscureText: true,
autofillHints: const [AutofillHints.password],
key: keys.managementKeyField,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: "Password",
prefixIcon: const Icon(Icons.password_outlined),
errorText: _passwordIsWrong ? l10n.s_wrong_password : null,
errorMaxLines: 3),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
_passwordIsWrong = false;
_password = value;
});
},
onSubmitted: (_) => _examine(),
),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
),
result: (_, privateKey, certificates) => ResponsiveDialog(
title: Text("Import file"),
actions: [
TextButton(
key: keys.unlockButton,
onPressed: () async {
final navigator = Navigator.of(context);
try {
await ref
.read(pivSlotsProvider(widget.devicePath).notifier)
.import(widget.pivSlot.slot, _data,
password: _password.isNotEmpty ? _password : null);
navigator.pop(true);
} catch (_) {
// TODO: More error cases
setState(() {
_passwordIsWrong = true;
});
}
},
child: Text(l10n.s_save),
),
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Import the following into slot ${widget.pivSlot.slot.getDisplayName(l10n)}?"),
if (privateKey) Text("- Private key"),
if (certificates > 0) Text("- Certificate"),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
),
);
}
}

View File

@ -0,0 +1,141 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/views/fs_dialog.dart';
import '../../widgets/list_title.dart';
import '../models.dart';
import '../keys.dart' as keys;
import 'manage_key_dialog.dart';
import 'manage_pin_puk_dialog.dart';
import 'reset_dialog.dart';
Widget pivBuildActions(BuildContext context, DevicePath devicePath,
PivState pivState, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final theme =
ButtonTheme.of(context).colorScheme ?? Theme.of(context).colorScheme;
final usingDefaultMgmtKey =
pivState.metadata?.managementKeyMetadata.defaultValue == true;
final pinBlocked = pivState.pinAttempts == 0;
return FsDialog(
child: Column(
children: [
ListTitle(l10n.s_manage,
textStyle: Theme.of(context).textTheme.bodyLarge),
ListTile(
key: keys.managePinAction,
title: Text(l10n.s_pin),
subtitle: Text(pinBlocked
? 'Blocked, use PUK to reset'
: '${pivState.pinAttempts} attempts remaining'),
leading: CircleAvatar(
foregroundColor: theme.onSecondary,
backgroundColor: theme.secondary,
child: const Icon(Icons.pin_outlined),
),
onTap: () {
Navigator.of(context).pop();
showBlurDialog(
context: context,
builder: (context) => ManagePinPukDialog(
devicePath,
target: pinBlocked ? ManageTarget.unblock : ManageTarget.pin,
),
);
}),
ListTile(
key: keys.managePukAction,
title: Text('PUK'), // TODO
subtitle: Text(
'${pivState.metadata?.pukMetadata.attemptsRemaining ?? '?'} attempts remaining'),
leading: CircleAvatar(
foregroundColor: theme.onSecondary,
backgroundColor: theme.secondary,
child: const Icon(Icons.pin_outlined),
),
onTap: () {
Navigator.of(context).pop();
showBlurDialog(
context: context,
builder: (context) =>
ManagePinPukDialog(devicePath, target: ManageTarget.puk),
);
}),
ListTile(
key: keys.manageManagementKeyAction,
title: Text('Management Key'), // TODO
subtitle: Text(usingDefaultMgmtKey
? 'Warning: Default key used'
: (pivState.protectedKey
? 'PIN can be used instead'
: 'Change your management key')),
leading: CircleAvatar(
foregroundColor: theme.onSecondary,
backgroundColor: theme.secondary,
child: const Icon(Icons.key_outlined),
),
trailing:
usingDefaultMgmtKey ? const Icon(Icons.warning_amber) : null,
onTap: () {
Navigator.of(context).pop();
showBlurDialog(
context: context,
builder: (context) => ManageKeyDialog(devicePath, pivState),
);
}),
ListTile(
key: keys.resetAction,
title: Text('Reset PIV'), //TODO
subtitle: Text(l10n.l_factory_reset_this_app),
leading: CircleAvatar(
foregroundColor: theme.onError,
backgroundColor: theme.error,
child: const Icon(Icons.delete_outline),
),
onTap: () {
Navigator.of(context).pop();
showBlurDialog(
context: context,
builder: (context) => ResetDialog(devicePath),
);
}),
ListTitle(l10n.s_setup,
textStyle: Theme.of(context).textTheme.bodyLarge),
ListTile(
key: keys.setupMacOsAction,
title: Text('Setup for macOS'), //TODO
subtitle: Text('Create certificates for macOS login'), //TODO
leading: CircleAvatar(
backgroundColor: theme.secondary,
foregroundColor: theme.onSecondary,
child: const Icon(Icons.laptop),
),
onTap: () async {
Navigator.of(context).pop();
}),
],
),
);
}

View File

@ -0,0 +1,247 @@
/*
* 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/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../widgets/choice_filter_chip.dart';
import '../../widgets/responsive_dialog.dart';
import '../models.dart';
import '../state.dart';
import '../keys.dart' as keys;
import 'pin_dialog.dart';
class ManageKeyDialog extends ConsumerStatefulWidget {
final DevicePath path;
final PivState pivState;
const ManageKeyDialog(this.path, this.pivState, {super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() =>
_ManageKeyDialogState();
}
class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
late bool _defaultKeyUsed;
late bool _usesStoredKey;
late bool _storeKey;
String _currentKeyOrPin = '';
bool _currentIsWrong = false;
int _attemptsRemaining = -1;
String _newKey = '';
ManagementKeyType _keyType = ManagementKeyType.tdes;
@override
void initState() {
super.initState();
_defaultKeyUsed =
widget.pivState.metadata?.managementKeyMetadata.defaultValue ?? false;
_usesStoredKey = widget.pivState.protectedKey;
if (!_usesStoredKey && _defaultKeyUsed) {
_currentKeyOrPin = defaultManagementKey;
}
_storeKey = _usesStoredKey;
}
_submit() async {
final notifier = ref.read(pivStateProvider(widget.path).notifier);
if (_usesStoredKey) {
final status = (await notifier.verifyPin(_currentKeyOrPin)).when(
success: () => true,
failure: (attemptsRemaining) {
setState(() {
_attemptsRemaining = attemptsRemaining;
_currentIsWrong = true;
});
return false;
},
);
if (!status) {
return;
}
} else {
if (!await notifier.authenticate(_currentKeyOrPin)) {
setState(() {
_currentIsWrong = true;
});
return;
}
}
if (_storeKey && !_usesStoredKey) {
final withContext = ref.read(withContextProvider);
final verified = await withContext((context) async =>
await showBlurDialog(
context: context,
builder: (context) => PinDialog(widget.path))) ??
false;
if (!verified) {
return;
}
}
print("Set new key: $_newKey");
await notifier.setManagementKey(_newKey,
managementKeyType: _keyType, storeKey: _storeKey);
if (!mounted) return;
showMessage(context, "Management key changed");
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final currentType = widget.pivState.metadata?.managementKeyMetadata.keyType;
final hexLength = _keyType.keyLength * 2;
return ResponsiveDialog(
title: Text('Change Management Key'),
actions: [
TextButton(
onPressed: _submit,
key: keys.saveButton,
child: Text(l10n.s_save),
)
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.p_enter_current_password_or_reset),
if (widget.pivState.protectedKey)
TextField(
autofocus: true,
obscureText: true,
autofillHints: const [AutofillHints.password],
key: keys.managementKeyField,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: "PIN",
prefixIcon: const Icon(Icons.pin_outlined),
errorText: _currentIsWrong
? "Wrong PIN ($_attemptsRemaining attempts left)"
: null,
errorMaxLines: 3),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
_currentIsWrong = false;
_currentKeyOrPin = value;
});
},
),
if (!widget.pivState.protectedKey)
TextFormField(
key: keys.pinPukField,
autofocus: !_defaultKeyUsed,
autofillHints: const [AutofillHints.password],
initialValue: _defaultKeyUsed ? defaultManagementKey : null,
readOnly: _defaultKeyUsed,
maxLength: !_defaultKeyUsed && currentType != null
? currentType.keyLength * 2
: null,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: 'Current management key',
prefixIcon: const Icon(Icons.password_outlined),
errorText: _currentIsWrong ? 'Wrong key' : null,
errorMaxLines: 3,
helperText:
_defaultKeyUsed ? "Default management key used" : null,
),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
_currentIsWrong = false;
_currentKeyOrPin = value;
});
},
),
Text("Enter your new management key."),
TextField(
key: keys.newPinPukField,
autofocus: _defaultKeyUsed,
autofillHints: const [AutofillHints.newPassword],
maxLength: hexLength,
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.allow(
RegExp('[a-f0-9]', caseSensitive: false))
],
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: "New management key",
prefixIcon: const Icon(Icons.password_outlined),
enabled: _currentKeyOrPin.isNotEmpty,
),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
_newKey = value;
});
},
onSubmitted: (_) {
if (_newKey.length == hexLength) {
_submit();
}
},
),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 4.0,
runSpacing: 8.0,
children: [
if (currentType != null)
ChoiceFilterChip<ManagementKeyType>(
items: ManagementKeyType.values,
value: _keyType,
selected: _keyType != defaultManagementKeyType,
itemBuilder: (value) => Text(value.getDisplayName(l10n)),
onChanged: (value) {
setState(() {
_keyType = value;
});
},
),
FilterChip(
label: Text("Protect with PIN"),
selected: _storeKey,
onSelected: (value) {
setState(() {
_storeKey = value;
});
},
),
]),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
);
}
}

View File

@ -0,0 +1,192 @@
/*
* Copyright (C) 2022 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../widgets/responsive_dialog.dart';
import '../models.dart';
import '../state.dart';
import '../keys.dart' as keys;
enum ManageTarget { pin, puk, unblock }
class ManagePinPukDialog extends ConsumerStatefulWidget {
final DevicePath path;
final ManageTarget target;
const ManagePinPukDialog(this.path,
{super.key, this.target = ManageTarget.pin});
@override
ConsumerState<ConsumerStatefulWidget> createState() =>
_ManagePinPukDialogState();
}
//TODO: Use switch expressions in Dart 3
class _ManagePinPukDialogState extends ConsumerState<ManagePinPukDialog> {
String _currentPin = '';
String _newPin = '';
String _confirmPin = '';
bool _currentIsWrong = false;
int _attemptsRemaining = -1;
_submit() async {
final notifier = ref.read(pivStateProvider(widget.path).notifier);
final PinVerificationStatus result;
switch (widget.target) {
case ManageTarget.pin:
result = await notifier.changePin(_currentPin, _newPin);
break;
case ManageTarget.puk:
result = await notifier.changePuk(_currentPin, _newPin);
break;
case ManageTarget.unblock:
result = await notifier.unblockPin(_currentPin, _newPin);
break;
}
result.when(success: () {
if (!mounted) return;
Navigator.of(context).pop();
showMessage(context, AppLocalizations.of(context)!.s_password_set);
}, failure: (attemptsRemaining) {
setState(() {
_attemptsRemaining = attemptsRemaining;
_currentIsWrong = true;
_currentPin = '';
});
});
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final isValid =
_newPin.isNotEmpty && _newPin == _confirmPin && _currentPin.isNotEmpty;
final String titleText;
switch (widget.target) {
case ManageTarget.pin:
titleText = "Change PIN";
break;
case ManageTarget.puk:
titleText = l10n.s_manage_password;
break;
case ManageTarget.unblock:
titleText = "Unblock PIN";
break;
}
return ResponsiveDialog(
title: Text(titleText),
actions: [
TextButton(
onPressed: isValid ? _submit : null,
key: keys.saveButton,
child: Text(l10n.s_save),
)
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.p_enter_current_password_or_reset),
TextField(
autofocus: true,
obscureText: true,
autofillHints: const [AutofillHints.password],
key: keys.pinPukField,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: widget.target == ManageTarget.pin
? 'Current PIN'
: 'Current PUK',
prefixIcon: const Icon(Icons.password_outlined),
errorText: _currentIsWrong
? l10n.l_wrong_pin_attempts_remaining(_attemptsRemaining)
: null,
errorMaxLines: 3),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
_currentIsWrong = false;
_currentPin = value;
});
},
),
Text(
"Enter your new ${widget.target == ManageTarget.puk ? 'PUK' : 'PIN'}. Must be 6-8 characters."),
TextField(
key: keys.newPinPukField,
obscureText: true,
autofillHints: const [AutofillHints.newPassword],
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: widget.target == ManageTarget.puk
? "New PUK"
: l10n.s_new_pin,
prefixIcon: const Icon(Icons.password_outlined),
enabled: _currentPin.isNotEmpty,
),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
_newPin = value;
});
},
onSubmitted: (_) {
if (isValid) {
_submit();
}
},
),
TextField(
key: keys.confirmPinPukField,
obscureText: true,
autofillHints: const [AutofillHints.newPassword],
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_confirm_pin,
prefixIcon: const Icon(Icons.password_outlined),
enabled: _currentPin.isNotEmpty && _newPin.isNotEmpty,
),
textInputAction: TextInputAction.done,
onChanged: (value) {
setState(() {
_confirmPin = value;
});
},
onSubmitted: (_) {
if (isValid) {
_submit();
}
},
),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
);
}
}

View File

@ -0,0 +1,111 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/models.dart';
import '../../exception/cancellation_exception.dart';
import '../../widgets/responsive_dialog.dart';
import '../state.dart';
import '../keys.dart' as keys;
class PinDialog extends ConsumerStatefulWidget {
final DevicePath devicePath;
const PinDialog(this.devicePath, {super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _PinDialogState();
}
class _PinDialogState extends ConsumerState<PinDialog> {
String _pin = '';
bool _pinIsWrong = false;
int _attemptsRemaining = -1;
Future<void> _submit() async {
final navigator = Navigator.of(context);
try {
final status = await ref
.read(pivStateProvider(widget.devicePath).notifier)
.verifyPin(_pin);
status.when(
success: () {
navigator.pop(true);
},
failure: (attemptsRemaining) {
setState(() {
_attemptsRemaining = attemptsRemaining;
_pinIsWrong = true;
});
},
);
} on CancellationException catch (_) {
navigator.pop(false);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return ResponsiveDialog(
title: Text("PIN required"),
actions: [
TextButton(
key: keys.unlockButton,
onPressed: _submit,
child: Text(l10n.s_unlock),
),
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
autofocus: true,
obscureText: true,
autofillHints: const [AutofillHints.password],
key: keys.managementKeyField,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: "PIN",
prefixIcon: const Icon(Icons.pin_outlined),
errorText: _pinIsWrong
? "Wrong PIN ($_attemptsRemaining attempts left)"
: null,
errorMaxLines: 3),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
_pinIsWrong = false;
_pin = value;
});
},
onSubmitted: (_) => _submit(),
),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
);
}
}

View File

@ -0,0 +1,119 @@
/*
* Copyright (C) 2022 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/shortcuts.dart';
import '../../app/views/app_failure_page.dart';
import '../../app/views/app_page.dart';
import '../../app/views/message_page.dart';
import '../models.dart';
import '../state.dart';
import 'key_actions.dart';
import 'slot_dialog.dart';
class PivScreen extends ConsumerWidget {
final DevicePath devicePath;
const PivScreen(this.devicePath, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
return ref.watch(pivStateProvider(devicePath)).when(
loading: () => MessagePage(
title: Text(l10n.s_authenticator),
graphic: const CircularProgressIndicator(),
delayedContent: true,
),
error: (error, _) => AppFailurePage(
title: Text(l10n.s_authenticator),
cause: error,
),
data: (pivState) {
final pivSlots = ref.watch(pivSlotsProvider(devicePath)).asData;
return AppPage(
title: const Text('PIV'),
keyActionsBuilder: (context) =>
pivBuildActions(context, devicePath, pivState, ref),
child: Column(
children: [
if (pivSlots?.hasValue == true)
...pivSlots!.value.map((e) => Actions(
actions: {
OpenIntent:
CallbackAction<OpenIntent>(onInvoke: (_) async {
await showBlurDialog(
context: context,
builder: (context) =>
SlotDialog(pivState, e.slot),
);
return null;
}),
},
child: _CertificateListItem(e),
))
],
),
);
},
);
}
}
class _CertificateListItem extends StatelessWidget {
final PivSlot pivSlot;
const _CertificateListItem(this.pivSlot);
@override
Widget build(BuildContext context) {
final slot = pivSlot.slot;
final certInfo = pivSlot.certInfo;
final l10n = AppLocalizations.of(context)!;
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
leading: CircleAvatar(
foregroundColor: colorScheme.onSecondary,
backgroundColor: colorScheme.secondary,
child: const Icon(Icons.approval),
),
title: Text(
'${slot.getDisplayName(l10n)} (Slot ${slot.id.toRadixString(16).padLeft(2, '0')})',
softWrap: false,
overflow: TextOverflow.fade,
),
subtitle: certInfo != null
? Text(
'Subject: ${certInfo.subject}, Issuer: ${certInfo.issuer}',
softWrap: false,
overflow: TextOverflow.fade,
)
: Text(pivSlot.hasKey == true
? 'Key without certificate loaded'
: 'No certificate loaded'),
trailing: OutlinedButton(
onPressed: () {
Actions.maybeInvoke<OpenIntent>(context, const OpenIntent());
},
child: const Icon(Icons.more_horiz),
),
);
}
}

View File

@ -0,0 +1,67 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../widgets/responsive_dialog.dart';
import '../state.dart';
import '../../app/models.dart';
import '../../app/state.dart';
class ResetDialog extends ConsumerWidget {
final DevicePath devicePath;
const ResetDialog(this.devicePath, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
return ResponsiveDialog(
title: Text(l10n.s_factory_reset),
actions: [
TextButton(
onPressed: () async {
await ref.read(pivStateProvider(devicePath).notifier).reset();
await ref.read(withContextProvider)((context) async {
Navigator.of(context).pop();
showMessage(context, l10n.l_oath_application_reset); //TODO
});
},
child: Text(l10n.s_reset),
),
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
children: [
Text(
l10n.p_warning_factory_reset, // TODO
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text(l10n.p_warning_disable_credentials), //TODO
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
);
}
}

View File

@ -0,0 +1,191 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/shortcuts.dart';
import '../../app/state.dart';
import '../../app/views/fs_dialog.dart';
import '../../widgets/list_title.dart';
import '../models.dart';
import '../state.dart';
import 'actions.dart';
class SlotDialog extends ConsumerWidget {
final PivState pivState;
final SlotId pivSlot;
const SlotDialog(this.pivState, this.pivSlot, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// TODO: Solve this in a cleaner way
final node = ref.watch(currentDeviceDataProvider).valueOrNull?.node;
if (node == null) {
// The rest of this method assumes there is a device, and will throw an exception if not.
// This will never be shown, as the dialog will be immediately closed
return const SizedBox();
}
final l10n = AppLocalizations.of(context)!;
final textTheme = Theme.of(context).textTheme;
final slotData = ref.watch(pivSlotsProvider(node.path).select((value) =>
value.whenOrNull(
data: (data) =>
data.firstWhere((element) => element.slot == pivSlot))));
if (slotData == null) {
return const FsDialog(child: CircularProgressIndicator());
}
final certInfo = slotData.certInfo;
return registerPivActions(
node.path,
pivState,
slotData,
ref: ref,
builder: (context) => FocusScope(
autofocus: true,
child: FsDialog(
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(top: 48, bottom: 32),
child: Column(
children: [
Text(
'${pivSlot.getDisplayName(l10n)} (Slot ${pivSlot.id.toRadixString(16).padLeft(2, '0')})',
style: textTheme.headlineSmall,
softWrap: true,
textAlign: TextAlign.center,
),
if (certInfo != null) ...[
Text(
'Subject: ${certInfo.subject}, Issuer: ${certInfo.issuer}',
softWrap: true,
textAlign: TextAlign.center,
// This is what ListTile uses for subtitle
style: textTheme.bodyMedium!.copyWith(
color: textTheme.bodySmall!.color,
),
),
Text(
'Serial: ${certInfo.serial}',
softWrap: true,
textAlign: TextAlign.center,
// This is what ListTile uses for subtitle
style: textTheme.bodyMedium!.copyWith(
color: textTheme.bodySmall!.color,
),
),
Text(
'Fingerprint: ${certInfo.fingerprint}',
softWrap: true,
textAlign: TextAlign.center,
// This is what ListTile uses for subtitle
style: textTheme.bodyMedium!.copyWith(
color: textTheme.bodySmall!.color,
),
),
Text(
'Not before: ${certInfo.notValidBefore}, Not after: ${certInfo.notValidAfter}',
softWrap: true,
textAlign: TextAlign.center,
// This is what ListTile uses for subtitle
style: textTheme.bodyMedium!.copyWith(
color: textTheme.bodySmall!.color,
),
),
] else ...[
Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Text(
'No certificate loaded',
softWrap: true,
textAlign: TextAlign.center,
// This is what ListTile uses for subtitle
style: textTheme.bodyMedium!.copyWith(
color: textTheme.bodySmall!.color,
),
),
),
],
const SizedBox(height: 16),
],
),
),
ListTitle(AppLocalizations.of(context)!.s_actions,
textStyle: textTheme.bodyLarge),
_SlotDialogActions(certInfo),
],
),
),
),
);
}
}
class _SlotDialogActions extends StatelessWidget {
final CertInfo? certInfo;
const _SlotDialogActions(this.certInfo);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme =
ButtonTheme.of(context).colorScheme ?? Theme.of(context).colorScheme;
return Column(
children: [
ListTile(
leading: CircleAvatar(
backgroundColor: theme.primary,
foregroundColor: theme.onPrimary,
child: const Icon(Icons.add_outlined),
),
title: Text('Generate key'),
subtitle: Text('Generate a new certificate or CSR'),
onTap: () {
Actions.invoke(context, const GenerateIntent());
},
),
ListTile(
leading: CircleAvatar(
backgroundColor: theme.secondary,
foregroundColor: theme.onSecondary,
child: const Icon(Icons.file_download_outlined),
),
title: Text('Import file'),
subtitle: Text('Import a key and/or certificate from file'),
onTap: () {
Actions.invoke(context, const ImportIntent());
},
),
if (certInfo != null) ...[
ListTile(
leading: CircleAvatar(
backgroundColor: theme.secondary,
foregroundColor: theme.onSecondary,
child: const Icon(Icons.file_upload_outlined),
),
title: Text('Export certificate'),
subtitle: Text('Export the certificate to file'),
onTap: () {
Actions.invoke(context, const ExportIntent());
},
),
ListTile(
leading: CircleAvatar(
backgroundColor: theme.error,
foregroundColor: theme.onError,
child: const Icon(Icons.delete_outline),
),
title: Text('Delete certificate'),
subtitle: Text('Remove the certificate from the YubiKey'),
onTap: () {
Actions.invoke(context, const DeleteIntent());
},
),
],
],
);
}
}

View File

@ -384,10 +384,10 @@ packages:
dependency: "direct dev"
description:
name: json_serializable
sha256: "61a60716544392a82726dd0fa1dd6f5f1fd32aec66422b6e229e7b90d52325c4"
sha256: aa1f5a8912615733e0fdc7a02af03308933c93235bdc8d50d0b0c8a8ccb0b969
url: "https://pub.dev"
source: hosted
version: "6.7.0"
version: "6.7.1"
lints:
dependency: transitive
description:

View File

@ -80,7 +80,7 @@ dev_dependencies:
build_runner: ^2.4.5
freezed: ^2.3.5
json_serializable: ^6.5.4
json_serializable: ^6.7.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec