mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2024-11-22 16:32:01 +03:00
Add PIV to helper.
This commit is contained in:
parent
9eeb44f3ac
commit
efa8f35e05
@ -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
456
helper/helper/piv.py
Normal 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(),
|
||||
)
|
@ -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),
|
||||
};
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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');
|
||||
|
@ -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
425
lib/desktop/piv/state.dart
Normal 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?,
|
||||
);
|
||||
}
|
||||
}
|
@ -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
35
lib/piv/keys.dart
Normal 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
313
lib/piv/models.dart
Normal 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
2984
lib/piv/models.freezed.dart
Normal file
File diff suppressed because it is too large
Load Diff
228
lib/piv/models.g.dart
Normal file
228
lib/piv/models.g.dart
Normal file
@ -0,0 +1,228 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'models.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$_PinMetadata _$$_PinMetadataFromJson(Map<String, dynamic> json) =>
|
||||
_$_PinMetadata(
|
||||
json['default_value'] as bool,
|
||||
json['total_attempts'] as int,
|
||||
json['attempts_remaining'] as int,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$_PinMetadataToJson(_$_PinMetadata instance) =>
|
||||
<String, dynamic>{
|
||||
'default_value': instance.defaultValue,
|
||||
'total_attempts': instance.totalAttempts,
|
||||
'attempts_remaining': instance.attemptsRemaining,
|
||||
};
|
||||
|
||||
_$_ManagementKeyMetadata _$$_ManagementKeyMetadataFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$_ManagementKeyMetadata(
|
||||
$enumDecode(_$ManagementKeyTypeEnumMap, json['key_type']),
|
||||
json['default_value'] as bool,
|
||||
$enumDecode(_$TouchPolicyEnumMap, json['touch_policy']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$_ManagementKeyMetadataToJson(
|
||||
_$_ManagementKeyMetadata instance) =>
|
||||
<String, dynamic>{
|
||||
'key_type': _$ManagementKeyTypeEnumMap[instance.keyType]!,
|
||||
'default_value': instance.defaultValue,
|
||||
'touch_policy': _$TouchPolicyEnumMap[instance.touchPolicy]!,
|
||||
};
|
||||
|
||||
const _$ManagementKeyTypeEnumMap = {
|
||||
ManagementKeyType.tdes: 3,
|
||||
ManagementKeyType.aes128: 8,
|
||||
ManagementKeyType.aes192: 10,
|
||||
ManagementKeyType.aes256: 12,
|
||||
};
|
||||
|
||||
const _$TouchPolicyEnumMap = {
|
||||
TouchPolicy.dfault: 0,
|
||||
TouchPolicy.never: 1,
|
||||
TouchPolicy.always: 2,
|
||||
TouchPolicy.cached: 3,
|
||||
};
|
||||
|
||||
_$_SlotMetadata _$$_SlotMetadataFromJson(Map<String, dynamic> json) =>
|
||||
_$_SlotMetadata(
|
||||
$enumDecode(_$KeyTypeEnumMap, json['key_type']),
|
||||
$enumDecode(_$PinPolicyEnumMap, json['pin_policy']),
|
||||
$enumDecode(_$TouchPolicyEnumMap, json['touch_policy']),
|
||||
json['generated'] as bool,
|
||||
json['public_key_encoded'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$_SlotMetadataToJson(_$_SlotMetadata instance) =>
|
||||
<String, dynamic>{
|
||||
'key_type': _$KeyTypeEnumMap[instance.keyType]!,
|
||||
'pin_policy': _$PinPolicyEnumMap[instance.pinPolicy]!,
|
||||
'touch_policy': _$TouchPolicyEnumMap[instance.touchPolicy]!,
|
||||
'generated': instance.generated,
|
||||
'public_key_encoded': instance.publicKeyEncoded,
|
||||
};
|
||||
|
||||
const _$KeyTypeEnumMap = {
|
||||
KeyType.rsa1024: 6,
|
||||
KeyType.rsa2048: 7,
|
||||
KeyType.eccp256: 17,
|
||||
KeyType.eccp384: 20,
|
||||
};
|
||||
|
||||
const _$PinPolicyEnumMap = {
|
||||
PinPolicy.dfault: 0,
|
||||
PinPolicy.never: 1,
|
||||
PinPolicy.once: 2,
|
||||
PinPolicy.always: 3,
|
||||
};
|
||||
|
||||
_$_PivStateMetadata _$$_PivStateMetadataFromJson(Map<String, dynamic> json) =>
|
||||
_$_PivStateMetadata(
|
||||
managementKeyMetadata: ManagementKeyMetadata.fromJson(
|
||||
json['management_key_metadata'] as Map<String, dynamic>),
|
||||
pinMetadata:
|
||||
PinMetadata.fromJson(json['pin_metadata'] as Map<String, dynamic>),
|
||||
pukMetadata:
|
||||
PinMetadata.fromJson(json['puk_metadata'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$_PivStateMetadataToJson(_$_PivStateMetadata instance) =>
|
||||
<String, dynamic>{
|
||||
'management_key_metadata': instance.managementKeyMetadata,
|
||||
'pin_metadata': instance.pinMetadata,
|
||||
'puk_metadata': instance.pukMetadata,
|
||||
};
|
||||
|
||||
_$_PivState _$$_PivStateFromJson(Map<String, dynamic> json) => _$_PivState(
|
||||
version: Version.fromJson(json['version'] as List<dynamic>),
|
||||
authenticated: json['authenticated'] as bool,
|
||||
derivedKey: json['derived_key'] as bool,
|
||||
storedKey: json['stored_key'] as bool,
|
||||
pinAttempts: json['pin_attempts'] as int,
|
||||
chuid: json['chuid'] as String?,
|
||||
ccc: json['ccc'] as String?,
|
||||
metadata: json['metadata'] == null
|
||||
? null
|
||||
: PivStateMetadata.fromJson(json['metadata'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$_PivStateToJson(_$_PivState instance) =>
|
||||
<String, dynamic>{
|
||||
'version': instance.version,
|
||||
'authenticated': instance.authenticated,
|
||||
'derived_key': instance.derivedKey,
|
||||
'stored_key': instance.storedKey,
|
||||
'pin_attempts': instance.pinAttempts,
|
||||
'chuid': instance.chuid,
|
||||
'ccc': instance.ccc,
|
||||
'metadata': instance.metadata,
|
||||
};
|
||||
|
||||
_$_CertInfo _$$_CertInfoFromJson(Map<String, dynamic> json) => _$_CertInfo(
|
||||
subject: json['subject'] as String,
|
||||
issuer: json['issuer'] as String,
|
||||
serial: json['serial'] as String,
|
||||
notValidBefore: json['not_valid_before'] as String,
|
||||
notValidAfter: json['not_valid_after'] as String,
|
||||
fingerprint: json['fingerprint'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$_CertInfoToJson(_$_CertInfo instance) =>
|
||||
<String, dynamic>{
|
||||
'subject': instance.subject,
|
||||
'issuer': instance.issuer,
|
||||
'serial': instance.serial,
|
||||
'not_valid_before': instance.notValidBefore,
|
||||
'not_valid_after': instance.notValidAfter,
|
||||
'fingerprint': instance.fingerprint,
|
||||
};
|
||||
|
||||
_$_PivSlot _$$_PivSlotFromJson(Map<String, dynamic> json) => _$_PivSlot(
|
||||
slot: SlotId.fromJson(json['slot'] as int),
|
||||
hasKey: json['has_key'] as bool?,
|
||||
certInfo: json['cert_info'] == null
|
||||
? null
|
||||
: CertInfo.fromJson(json['cert_info'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$_PivSlotToJson(_$_PivSlot instance) =>
|
||||
<String, dynamic>{
|
||||
'slot': _$SlotIdEnumMap[instance.slot]!,
|
||||
'has_key': instance.hasKey,
|
||||
'cert_info': instance.certInfo,
|
||||
};
|
||||
|
||||
const _$SlotIdEnumMap = {
|
||||
SlotId.authentication: 'authentication',
|
||||
SlotId.signature: 'signature',
|
||||
SlotId.keyManagement: 'keyManagement',
|
||||
SlotId.cardAuth: 'cardAuth',
|
||||
};
|
||||
|
||||
_$_ExamineResult _$$_ExamineResultFromJson(Map<String, dynamic> json) =>
|
||||
_$_ExamineResult(
|
||||
password: json['password'] as bool,
|
||||
privateKey: json['private_key'] as bool,
|
||||
certificates: json['certificates'] as int,
|
||||
$type: json['runtimeType'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$_ExamineResultToJson(_$_ExamineResult instance) =>
|
||||
<String, dynamic>{
|
||||
'password': instance.password,
|
||||
'private_key': instance.privateKey,
|
||||
'certificates': instance.certificates,
|
||||
'runtimeType': instance.$type,
|
||||
};
|
||||
|
||||
_$_InvalidPassword _$$_InvalidPasswordFromJson(Map<String, dynamic> json) =>
|
||||
_$_InvalidPassword(
|
||||
$type: json['runtimeType'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$_InvalidPasswordToJson(_$_InvalidPassword instance) =>
|
||||
<String, dynamic>{
|
||||
'runtimeType': instance.$type,
|
||||
};
|
||||
|
||||
_$_PivGenerateResult _$$_PivGenerateResultFromJson(Map<String, dynamic> json) =>
|
||||
_$_PivGenerateResult(
|
||||
generateType: $enumDecode(_$GenerateTypeEnumMap, json['generate_type']),
|
||||
publicKey: json['public_key'] as String,
|
||||
result: json['result'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$_PivGenerateResultToJson(
|
||||
_$_PivGenerateResult instance) =>
|
||||
<String, dynamic>{
|
||||
'generate_type': _$GenerateTypeEnumMap[instance.generateType]!,
|
||||
'public_key': instance.publicKey,
|
||||
'result': instance.result,
|
||||
};
|
||||
|
||||
const _$GenerateTypeEnumMap = {
|
||||
GenerateType.certificate: 'certificate',
|
||||
GenerateType.csr: 'csr',
|
||||
};
|
||||
|
||||
_$_PivImportResult _$$_PivImportResultFromJson(Map<String, dynamic> json) =>
|
||||
_$_PivImportResult(
|
||||
metadata: json['metadata'] == null
|
||||
? null
|
||||
: SlotMetadata.fromJson(json['metadata'] as Map<String, dynamic>),
|
||||
publicKey: json['public_key'] as String?,
|
||||
certificate: json['certificate'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$_PivImportResultToJson(_$_PivImportResult instance) =>
|
||||
<String, dynamic>{
|
||||
'metadata': instance.metadata,
|
||||
'public_key': instance.publicKey,
|
||||
'certificate': instance.certificate,
|
||||
};
|
70
lib/piv/state.dart
Normal file
70
lib/piv/state.dart
Normal file
@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../app/models.dart';
|
||||
import '../core/state.dart';
|
||||
import 'models.dart';
|
||||
|
||||
final pivStateProvider = AsyncNotifierProvider.autoDispose
|
||||
.family<PivStateNotifier, PivState, DevicePath>(
|
||||
() => throw UnimplementedError(),
|
||||
);
|
||||
|
||||
abstract class PivStateNotifier extends ApplicationStateNotifier<PivState> {
|
||||
Future<void> reset();
|
||||
|
||||
Future<bool> authenticate(String managementKey);
|
||||
Future<void> setManagementKey(
|
||||
String managementKey, {
|
||||
ManagementKeyType managementKeyType = defaultManagementKeyType,
|
||||
bool storeKey = false,
|
||||
});
|
||||
|
||||
Future<PinVerificationStatus> verifyPin(
|
||||
String pin); //TODO: Maybe return authenticated?
|
||||
Future<PinVerificationStatus> changePin(String pin, String newPin);
|
||||
Future<PinVerificationStatus> changePuk(String puk, String newPuk);
|
||||
Future<PinVerificationStatus> unblockPin(String puk, String newPin);
|
||||
}
|
||||
|
||||
final pivSlotsProvider = AsyncNotifierProvider.autoDispose
|
||||
.family<PivSlotsNotifier, List<PivSlot>, DevicePath>(
|
||||
() => throw UnimplementedError(),
|
||||
);
|
||||
|
||||
abstract class PivSlotsNotifier
|
||||
extends AutoDisposeFamilyAsyncNotifier<List<PivSlot>, DevicePath> {
|
||||
Future<PivExamineResult> examine(String data, {String? password});
|
||||
Future<(SlotMetadata?, String?)> read(SlotId slot);
|
||||
Future<PivGenerateResult> generate(
|
||||
SlotId slot,
|
||||
KeyType keyType, {
|
||||
required PivGenerateParameters parameters,
|
||||
PinPolicy pinPolicy = PinPolicy.dfault,
|
||||
TouchPolicy touchPolicy = TouchPolicy.dfault,
|
||||
String? pin,
|
||||
});
|
||||
Future<PivImportResult> import(
|
||||
SlotId slot,
|
||||
String data, {
|
||||
String? password,
|
||||
PinPolicy pinPolicy = PinPolicy.dfault,
|
||||
TouchPolicy touchPolicy = TouchPolicy.dfault,
|
||||
});
|
||||
Future<void> delete(SlotId slot);
|
||||
}
|
221
lib/piv/views/actions.dart
Normal file
221
lib/piv/views/actions.dart
Normal 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),
|
||||
);
|
109
lib/piv/views/authentication_dialog.dart
Normal file
109
lib/piv/views/authentication_dialog.dart
Normal file
@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
83
lib/piv/views/delete_certificate_dialog.dart
Normal file
83
lib/piv/views/delete_certificate_dialog.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
166
lib/piv/views/generate_key_dialog.dart
Normal file
166
lib/piv/views/generate_key_dialog.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
186
lib/piv/views/import_file_dialog.dart
Normal file
186
lib/piv/views/import_file_dialog.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
141
lib/piv/views/key_actions.dart
Normal file
141
lib/piv/views/key_actions.dart
Normal 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();
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
247
lib/piv/views/manage_key_dialog.dart
Normal file
247
lib/piv/views/manage_key_dialog.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
192
lib/piv/views/manage_pin_puk_dialog.dart
Normal file
192
lib/piv/views/manage_pin_puk_dialog.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
111
lib/piv/views/pin_dialog.dart
Normal file
111
lib/piv/views/pin_dialog.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
119
lib/piv/views/piv_screen.dart
Normal file
119
lib/piv/views/piv_screen.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
67
lib/piv/views/reset_dialog.dart
Normal file
67
lib/piv/views/reset_dialog.dart
Normal file
@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../app/message.dart';
|
||||
import '../../widgets/responsive_dialog.dart';
|
||||
import '../state.dart';
|
||||
import '../../app/models.dart';
|
||||
import '../../app/state.dart';
|
||||
|
||||
class ResetDialog extends ConsumerWidget {
|
||||
final DevicePath devicePath;
|
||||
const ResetDialog(this.devicePath, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return ResponsiveDialog(
|
||||
title: Text(l10n.s_factory_reset),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await ref.read(pivStateProvider(devicePath).notifier).reset();
|
||||
await ref.read(withContextProvider)((context) async {
|
||||
Navigator.of(context).pop();
|
||||
showMessage(context, l10n.l_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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
191
lib/piv/views/slot_dialog.dart
Normal file
191
lib/piv/views/slot_dialog.dart
Normal 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());
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -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:
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user