#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::time::{SystemTime, UNIX_EPOCH};
mod otp_pane;
#[cfg(feature = "qr")]
mod qrscan;
#[cfg(feature = "qr")]
mod screengrab;
mod settings;
mod ui;
use otp_pane::OtpState;
use settings::Settings;
use ui::device::{self, CapState, CapTab, Caps, Device, DeviceId, DeviceKind, DeviceView};
use ui::theme::{self, BtnKind, Mode, Palette};
use eframe::egui;
use keyroost_import::parse_otpauth;
use keyroost_proto::commands::{
DisplayTimeout, HmacAlgo, OtpDigits, ProfileConfig, ProfilePublicData, TimeStep,
DEFAULT_CUSTOMER_KEY,
};
use keyroost_transport::Token2ProgSession;
use keyroost_transport::{DeviceInfo, SeedDeleteOutcome, Session, TransportError};
use keyroost_ctap::client_pin::PinUvAuthToken;
use keyroost_ctap::cred_mgmt::{Credential, CredsMetadata, RelyingParty};
use keyroost_ctap::{AuthenticatorInfo, CtapHidDevice, InitResponse};
const VERSION_LABEL: &str = concat!("v", env!("CARGO_PKG_VERSION"));
#[derive(Debug, Clone)]
enum FidoTarget {
Hid(std::path::PathBuf),
Pcsc(String),
}
type OpenFido = (
Box<dyn keyroost_ctap::transport::CtapTransport>,
bool,
Option<InitResponse>,
);
fn open_fido(target: &FidoTarget) -> Result<OpenFido, String> {
match target {
FidoTarget::Hid(path) => {
let (dev, init) = CtapHidDevice::open(path).map_err(|e| e.to_string())?;
let cbor = init.supports_cbor();
Ok((Box::new(dev), cbor, Some(init)))
}
FidoTarget::Pcsc(reader) => {
let dev =
keyroost_transport::CtapPcscDevice::open(reader).map_err(|e| e.to_string())?;
Ok((Box::new(dev), true, None))
}
}
}
use keyroost_hid::HidDevice;
const PROFILES: u8 = 100;
struct SessionOpError {
message: String,
relock: bool,
}
impl SessionOpError {
fn msg(message: impl Into<String>) -> Self {
Self {
message: message.into(),
relock: false,
}
}
fn from_ctap(err: keyroost_ctap::CtapError) -> Self {
Self {
relock: err.is_pin_auth_error(),
message: err.to_string(),
}
}
fn from_boxed(err: Box<dyn std::error::Error>) -> Self {
match err.downcast::<keyroost_ctap::CtapError>() {
Ok(ctap) => Self::from_ctap(*ctap),
Err(other) => Self::msg(other.to_string()),
}
}
}
impl std::fmt::Display for SessionOpError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
#[derive(Default)]
struct SecurityKeysState {
info: Option<AuthenticatorInfo>,
init: Option<InitResponse>,
error: Option<String>,
pin_input: String,
session: Option<UnlockedSession>,
session_device: Option<DeviceId>,
change_pin: ChangePinDialog,
reset: ResetDialog,
fingerprints: Option<Vec<keyroost_ctap::Enrollment>>,
fp_new_name: String,
fp_progress: Option<std::sync::Arc<std::sync::Mutex<EnrollProgress>>>,
fp_confirm_delete: Option<Vec<u8>>,
fp_rename: Option<(Vec<u8>, String)>,
advanced: Option<AdvancedDialog>,
subview: FidoSubview,
large_blobs: Option<keyroost_ctap::large_blobs::LargeBlobArray>,
lb_capacity: Option<keyroost_ctap::large_blobs::BlobCapacity>,
lb_selected: Option<usize>,
lb_confirm_delete: Option<usize>,
lb_confirm_clear: bool,
lb_status: Option<String>,
lb_new_text: String,
lb_show_add: bool,
lb_editing: Option<usize>,
lb_edit_text: String,
lb_autoloaded: bool,
ssh_cert_pending: std::collections::HashMap<u64, PendingSshCert>,
ssh_cert_status: Option<String>,
}
#[derive(Default, Clone, Copy, Debug, PartialEq)]
enum FidoSubview {
#[default]
Passkeys,
Fingerprints,
Settings,
LargeBlobs,
}
#[derive(Default)]
struct AdvancedDialog {
action: AdvancedAction,
pin_input: String,
min_pin_input: String,
force_change: bool,
device: Option<DeviceId>,
}
impl Drop for AdvancedDialog {
fn drop(&mut self) {
wipe(&mut self.pin_input);
}
}
#[derive(Default, Clone, Copy, PartialEq)]
enum AdvancedAction {
#[default]
None,
ToggleAlwaysUv,
SetMinPin,
ForcePinChange,
EnterpriseAttestation,
}
#[derive(Clone)]
struct EnrollProgress {
total: u64,
captured: u64,
last_message: String,
done: Option<Result<(), String>>,
cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
}
impl Default for EnrollProgress {
fn default() -> Self {
EnrollProgress {
total: 0,
captured: 0,
last_message: "Touch the sensor to begin\u{2026}".into(),
done: None,
cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
}
}
}
#[derive(Default)]
struct ResetDialog {
open: bool,
confirm_input: String,
}
struct ResetArm {
target_serial: Option<String>,
target_path: Option<std::path::PathBuf>,
target_effective_serial: Option<String>,
target_ids: Option<(u16, u16)>,
for_device: Option<DeviceId>,
prev_devices: Vec<HidIdentity>,
saw_removal: bool,
}
#[derive(Clone)]
struct HidIdentity {
path: std::path::PathBuf,
usb_bus: Option<u8>,
usb_address: Option<u8>,
}
impl HidIdentity {
fn of(d: &FidoHid) -> Self {
HidIdentity {
path: d.path.clone(),
usb_bus: d.usb_bus,
usb_address: d.usb_address,
}
}
}
fn same_hid_connection(a: &HidIdentity, b: &HidIdentity) -> bool {
match (a.usb_bus, a.usb_address, b.usb_bus, b.usb_address) {
(Some(ab), Some(aa), Some(bb), Some(ba)) => ab == bb && aa == ba,
_ => a.path == b.path,
}
}
#[derive(Clone)]
struct RenameTarget {
serial: String,
name_at_open: Option<String>,
}
struct UnlockedSession {
token: PinUvAuthToken,
metadata: CredsMetadata,
rps: std::sync::Arc<Vec<(RelyingParty, Vec<Credential>)>>,
fingerprints: Option<Vec<keyroost_ctap::Enrollment>>,
pin: zeroize::Zeroizing<String>,
}
struct FidoHid {
path: std::path::PathBuf,
serial: Option<String>,
ids: (u16, u16),
usb_bus: Option<u8>,
usb_address: Option<u8>,
}
#[derive(Default)]
struct ChangePinDialog {
open: bool,
old: String,
new: String,
confirm: String,
}
impl Drop for ChangePinDialog {
fn drop(&mut self) {
wipe(&mut self.old);
wipe(&mut self.new);
wipe(&mut self.confirm);
}
}
#[derive(Default)]
struct OathState {
creds: Vec<OathRow>,
skipped: usize,
locked: bool,
password_input: String,
error: Option<String>,
loaded: bool,
add: OathAddDialog,
confirm_delete: Option<(DeviceId, String)>,
unlocked_password: Option<zeroize::Zeroizing<String>>,
confirm_reset: Option<DeviceId>,
}
struct OathRow {
name: String,
code: Option<String>,
hotp: bool,
}
#[derive(Default)]
struct OathAddDialog {
open: bool,
name: String,
secret: String,
totp: bool,
require_touch: bool,
busy: bool,
result: Option<Result<(), String>>,
}
impl OathAddDialog {
fn opened() -> Self {
OathAddDialog {
open: true,
name: String::new(),
secret: String::new(),
totp: true,
require_touch: false,
busy: false,
result: None,
}
}
fn validate(&self) -> Result<(String, Vec<u8>), String> {
let name = self.name.trim().to_owned();
if name.is_empty() {
return Err("credential name is required".into());
}
match keyroost_proto::codec::base32_decode(self.secret.trim()) {
Ok(s) if !s.is_empty() => Ok((name, s)),
Ok(_) => Err("secret is empty".into()),
Err(e) => Err(format!("invalid base32 secret: {e}")),
}
}
}
impl Drop for OathAddDialog {
fn drop(&mut self) {
wipe(&mut self.secret);
}
}
#[derive(Default)]
struct OpenPgpState {
status: Option<keyroost_transport::OpenPgpStatus>,
error: Option<String>,
notice: Option<String>,
loaded: bool,
admin_pin: String,
name_input: String,
url_input: String,
selected_key: OpenPgpSlotSel,
gen_alg: OpenPgpKeyAlgSel,
supported: Option<keyroost_transport::AlgorithmInformation>,
import_path: String,
user_pin_old: String,
user_pin_new: String,
admin_pin_old: String,
admin_pin_new: String,
unblock_new: String,
use_default_admin: bool,
cred_modal: Option<OpenPgpCredModal>,
}
impl OpenPgpState {
fn wipe_secrets(&mut self) {
wipe(&mut self.admin_pin);
wipe(&mut self.user_pin_old);
wipe(&mut self.user_pin_new);
wipe(&mut self.admin_pin_old);
wipe(&mut self.admin_pin_new);
wipe(&mut self.unblock_new);
self.use_default_admin = false;
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum OpenPgpCredKind {
ChangeUserPin,
ChangeAdminPin,
UnblockUserPin,
SetName,
SetUrl,
GenerateKey,
GenerateImportKey,
ImportKeyFile,
Reset,
}
impl OpenPgpCredKind {
fn title(self) -> &'static str {
match self {
OpenPgpCredKind::ChangeUserPin => "Change user PIN",
OpenPgpCredKind::ChangeAdminPin => "Change admin PIN",
OpenPgpCredKind::UnblockUserPin => "Unblock user PIN",
OpenPgpCredKind::SetName => "Set cardholder name",
OpenPgpCredKind::SetUrl => "Set public-key URL",
OpenPgpCredKind::GenerateKey => "Generate key on-card",
OpenPgpCredKind::GenerateImportKey => "Generate & import key",
OpenPgpCredKind::ImportKeyFile => "Import key from file",
OpenPgpCredKind::Reset => "Reset applet",
}
}
fn submit_label(self) -> &'static str {
match self {
OpenPgpCredKind::ChangeUserPin => "Change user PIN",
OpenPgpCredKind::ChangeAdminPin => "Change admin PIN",
OpenPgpCredKind::UnblockUserPin => "Unblock",
OpenPgpCredKind::SetName => "Set name",
OpenPgpCredKind::SetUrl => "Set URL",
OpenPgpCredKind::GenerateKey => "Generate",
OpenPgpCredKind::GenerateImportKey => "Generate & import",
OpenPgpCredKind::ImportKeyFile => "Import",
OpenPgpCredKind::Reset => "Reset applet",
}
}
fn busy_label(self) -> &'static str {
match self {
OpenPgpCredKind::ChangeUserPin => "Changing user PIN\u{2026}",
OpenPgpCredKind::ChangeAdminPin => "Changing admin PIN\u{2026}",
OpenPgpCredKind::UnblockUserPin => "Unblocking user PIN\u{2026}",
OpenPgpCredKind::SetName => "Setting cardholder name\u{2026}",
OpenPgpCredKind::SetUrl => "Setting public-key URL\u{2026}",
OpenPgpCredKind::GenerateKey => "Generating key\u{2026}",
OpenPgpCredKind::GenerateImportKey => "Generating & importing key\u{2026}",
OpenPgpCredKind::ImportKeyFile => "Importing key from file\u{2026}",
OpenPgpCredKind::Reset => "Resetting OpenPGP applet\u{2026}",
}
}
fn success(self) -> &'static str {
match self {
OpenPgpCredKind::ChangeUserPin => "User PIN changed",
OpenPgpCredKind::ChangeAdminPin => "Admin PIN changed",
OpenPgpCredKind::UnblockUserPin => "User PIN unblocked",
OpenPgpCredKind::SetName => "Cardholder name set",
OpenPgpCredKind::SetUrl => "Public-key URL set",
OpenPgpCredKind::GenerateKey => "Key generated",
OpenPgpCredKind::GenerateImportKey => "Key generated and imported",
OpenPgpCredKind::ImportKeyFile => "Key imported",
OpenPgpCredKind::Reset => "Applet reset",
}
}
fn needs_admin_pin(self) -> bool {
matches!(
self,
OpenPgpCredKind::ChangeAdminPin
| OpenPgpCredKind::UnblockUserPin
| OpenPgpCredKind::SetName
| OpenPgpCredKind::SetUrl
| OpenPgpCredKind::GenerateKey
| OpenPgpCredKind::GenerateImportKey
| OpenPgpCredKind::ImportKeyFile
)
}
}
struct OpenPgpCredModal {
kind: OpenPgpCredKind,
busy: bool,
result: Option<Result<(), String>>,
device: Option<DeviceId>,
}
impl OpenPgpCredModal {
fn new(kind: OpenPgpCredKind, device: Option<DeviceId>) -> Self {
OpenPgpCredModal {
kind,
busy: false,
result: None,
device,
}
}
}
const OPENPGP_DEFAULT_ADMIN_PIN: &str = "12345678";
fn openpgp_cred_mismatch(pgp: &OpenPgpState, kind: OpenPgpCredKind) -> Option<&'static str> {
let _ = (pgp, kind);
None
}
fn wipe(s: &mut String) {
use zeroize::Zeroize;
s.zeroize();
}
fn completion_still_valid(captured: Option<&DeviceId>, current: Option<&DeviceId>) -> bool {
matches!((captured, current), (Some(c), Some(now)) if c == now)
}
fn ssh_cred_offers_cert(rp_id: &str, large_blob_key: Option<&[u8; 32]>) -> bool {
rp_id.starts_with("ssh:") && large_blob_key.is_some()
}
fn ssh_cert_summary(info: &keyroost_ctap::ssh_cert::SshCertInfo) -> String {
let kind = if info.cert_type == keyroost_ctap::ssh_cert::CERT_TYPE_USER {
"user"
} else {
"host"
};
let principals = if info.principals.is_empty() {
"(any)".to_string()
} else {
info.principals.join(", ")
};
format!(
"{} ({kind}) \u{00b7} key id {} \u{00b7} principals {} \u{00b7} {}",
info.key_type,
info.key_id,
principals,
keyroost_ctap::ssh_cert::format_validity(info.valid_after, info.valid_before),
)
}
fn piv_user_overridable(extension: keyroost_piv::compat::PivExtension) -> bool {
use keyroost_piv::compat::{MgmtAlgChoice, PivExtension};
!matches!(
extension,
PivExtension::GetMetadata
| PivExtension::Attest
| PivExtension::GetSlotKeyStatus
| PivExtension::Reset
| PivExtension::ResetGlobal
| PivExtension::ManagementKeyAlgorithm(MgmtAlgChoice::Delete)
)
}
fn piv_relax(
extension: keyroost_piv::compat::PivExtension,
gate: keyroost_piv::compat::FeatureGate,
) -> keyroost_piv::compat::FeatureGate {
use keyroost_piv::compat::FeatureGate;
match gate {
FeatureGate::Unsupported if piv_user_overridable(extension) => FeatureGate::Unverified,
other => other,
}
}
fn piv_compat_unsupported_count(
fingerprint: keyroost_piv::fingerprint::AppletFingerprint,
version: Option<&[u8]>,
version_firmware: Option<&[u8]>,
) -> usize {
use keyroost_piv::compat::{FeatureGate, MgmtAlgChoice, PivExtension};
let mut features = vec![
PivExtension::MoveKey,
PivExtension::DeleteKey,
PivExtension::PinManagementAuth,
PivExtension::SetPinPukRetries,
PivExtension::SetManagementKey,
PivExtension::SlotPinPolicy,
PivExtension::SlotTouchPolicy,
];
features.extend(keyroost_piv::KeyAlg::ALL.map(PivExtension::SlotKeyAlgorithm));
features.extend(
[
MgmtAlgChoice::TripleDes,
MgmtAlgChoice::Aes128,
MgmtAlgChoice::Aes192,
MgmtAlgChoice::Aes256,
]
.map(PivExtension::ManagementKeyAlgorithm),
);
features
.into_iter()
.filter(|&f| piv_user_overridable(f))
.filter(|&f| {
keyroost_piv::compat::resolve(f, fingerprint, version, version_firmware)
== FeatureGate::Unsupported
})
.count()
}
fn fido_settings_available(info: Option<&AuthenticatorInfo>) -> bool {
info.is_some_and(|i| i.versions.iter().any(|v| v.starts_with("FIDO_2_")))
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum FactoryResetPivPreview {
NotOffered,
CheckFailed,
Resolved(keyroost_transport::PivResetPreview),
}
#[must_use]
fn factory_reset_piv_unresettable(preview: FactoryResetPivPreview) -> bool {
matches!(
preview,
FactoryResetPivPreview::Resolved(keyroost_transport::PivResetPreview::Unsupported)
)
}
#[derive(Clone)]
struct FactoryResetConfirmState {
for_device: DeviceId,
piv_preview: FactoryResetPivPreview,
needs_reset_mgmt_auth: bool,
default_mgmt_key: Option<&'static [u8]>,
reset_long_running: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
enum ResetMgmtAuthMode {
#[default]
Manual,
Default,
Pin,
}
#[must_use]
fn reset_mgmt_auth_mode_after_toggle(
checked: bool,
mode_if_checked: ResetMgmtAuthMode,
) -> ResetMgmtAuthMode {
if checked {
mode_if_checked
} else {
ResetMgmtAuthMode::Manual
}
}
enum ResetMgmtAuth {
Key(zeroize::Zeroizing<Vec<u8>>),
Pin(zeroize::Zeroizing<String>),
}
fn factory_reset_confirm_summary(
serial: &str,
model: &str,
plan: &[keyroost_resolve::ResetStep],
piv_preview: FactoryResetPivPreview,
needs_management_auth: bool,
reset_long_running: bool,
) -> String {
use keyroost_resolve::ResetStep;
use keyroost_transport::{FactoryResetPlan, PivResetPreview};
let piv_is_global = matches!(
piv_preview,
FactoryResetPivPreview::Resolved(PivResetPreview::Global)
);
let applets = plan
.iter()
.map(|s| match s {
ResetStep::Piv if piv_is_global => keyroost_resolve::PIV_GLOBAL_RESET_LABEL,
s => s.label(),
})
.collect::<Vec<_>>()
.join(", ");
let mut msg = format!(
"Factory-reset {model} (serial {serial})?\n\nWipes: {applets}.\nEvery \
credential, code, key, and PIN is erased. Each applet that completes \
comes back in factory condition, ready to set up again."
);
if plan.contains(&ResetStep::Piv) {
let piv_note = match piv_preview {
FactoryResetPivPreview::NotOffered | FactoryResetPivPreview::CheckFailed => {
"\n\nPIV: keyroost couldn't confirm this device's RESET support \
ahead of time. It will still attempt the standard path (block the \
PIN and PUK, then wipe) \u{2014} the report below the button says \
what actually happened."
}
FactoryResetPivPreview::Resolved(PivResetPreview::Unsupported) => "",
FactoryResetPivPreview::Resolved(PivResetPreview::Global) => "",
FactoryResetPivPreview::Resolved(PivResetPreview::Piv(
FactoryResetPlan::Unsupported,
)) => "",
FactoryResetPivPreview::Resolved(PivResetPreview::Piv(
FactoryResetPlan::NeedsManagementAuth,
)) => {
"\n\nPIV: this device resets via an authenticated management-key \
session \u{2014} keyroost authenticates with the credential \
entered below, then wipes."
}
FactoryResetPivPreview::Resolved(PivResetPreview::Piv(
FactoryResetPlan::Unverified,
)) => {
"\n\nPIV: keyroost's list has no RESET entry for this key, so it will \
try it without blocking the PIN and PUK first \u{2014} consult \
this device's own documentation for any precondition (commonly: \
the PIN and PUK both already blocked) and complete it manually if \
the attempt fails."
}
FactoryResetPivPreview::Resolved(PivResetPreview::Piv(
FactoryResetPlan::BurnPinPukThenReset,
)) => {
"\n\nPIV: the PIN and PUK are intentionally blocked, then the applet \
is wiped (the standard reset path). If the wipe stops after the \
blocking, PIV stays locked until a reset finishes \u{2014} the \
report below the button says what state it's in."
}
};
msg.push_str(piv_note);
if needs_management_auth {
msg.push_str("\n\nResetting this device requires management auth, entered below.");
}
if reset_long_running {
msg.push_str("\n\n");
msg.push_str(keyroost_piv::compat::PivQuirk::RESET_LONG_RUNNING_HINT);
}
}
if plan.contains(&ResetStep::Fido) {
msg.push_str("\n\nFinishes with a step to unplug the key, plug it back in, and touch it.");
}
msg
}
enum FactoryResetRow {
Step(keyroost_resolve::StepReport),
FidoPending,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum RowTone {
Done,
Waiting,
Warn,
Bad,
Muted,
}
fn factory_reset_row_line(row: &FactoryResetRow) -> (String, RowTone) {
use keyroost_resolve::StepOutcome;
match row {
FactoryResetRow::FidoPending => (
format!(
"{} not wiped yet \u{2014} finish the confirmation in the reset window",
keyroost_resolve::ResetStep::Fido.label()
),
RowTone::Waiting,
),
FactoryResetRow::Step(r) => match &r.outcome {
StepOutcome::Wiped => (format!("{} wiped", r.step.label()), RowTone::Done),
StepOutcome::WipedGlobal => (
format!("{} wiped", keyroost_resolve::PIV_GLOBAL_RESET_LABEL),
RowTone::Done,
),
StepOutcome::WipedWithWarning(e) => (
format!("{} wiped, but: {e}", r.step.label()),
RowTone::Warn,
),
StepOutcome::Failed(e) => (format!("{} failed: {e}", r.step.label()), RowTone::Bad),
StepOutcome::Skipped(reason) => (
format!("{} skipped: {reason}", r.step.label()),
RowTone::Muted,
),
},
}
}
fn factory_reset_report_summary(reports: &[keyroost_resolve::StepReport]) -> String {
use keyroost_resolve::StepOutcome;
let wiped: Vec<&str> = reports
.iter()
.filter(|r| {
matches!(
r.outcome,
StepOutcome::Wiped | StepOutcome::WipedGlobal | StepOutcome::WipedWithWarning(_)
)
})
.map(|r| match r.outcome {
StepOutcome::WipedGlobal => keyroost_resolve::PIV_GLOBAL_RESET_LABEL,
_ => r.step.label(),
})
.collect();
let warnings: Vec<String> = reports
.iter()
.filter_map(|r| match &r.outcome {
StepOutcome::WipedWithWarning(e) => Some(format!("{}: {e}", r.step.label())),
_ => None,
})
.collect();
let failed: Vec<String> = reports
.iter()
.filter_map(|r| match &r.outcome {
StepOutcome::Failed(e) => Some(format!("{}: {e}", r.step.label())),
_ => None,
})
.collect();
let skipped: Vec<String> = reports
.iter()
.filter_map(|r| match &r.outcome {
StepOutcome::Skipped(reason) => Some(format!("{}: {reason}", r.step.label())),
_ => None,
})
.collect();
let mut msg = format!(
"wiped: {}",
if wiped.is_empty() {
"none".to_string()
} else {
wiped.join(", ")
}
);
if !warnings.is_empty() {
msg.push_str(&format!("; warnings: {}", warnings.join("; ")));
}
if !failed.is_empty() {
msg.push_str(&format!("; failed: {}", failed.join("; ")));
}
if !skipped.is_empty() {
msg.push_str(&format!("; skipped: {}", skipped.join("; ")));
}
msg
}
fn resolve_fido_reset_row(
rows: &mut [FactoryResetRow],
outcome: keyroost_resolve::StepOutcome,
) -> bool {
if let Some(row) = rows.iter_mut().find(|r| match r {
FactoryResetRow::FidoPending => true,
FactoryResetRow::Step(r) => r.step == keyroost_resolve::ResetStep::Fido,
}) {
*row = FactoryResetRow::Step(keyroost_resolve::StepReport {
step: keyroost_resolve::ResetStep::Fido,
outcome,
});
true
} else {
false
}
}
fn resolve_pending_fido_reset_row(
rows: &mut [FactoryResetRow],
outcome: keyroost_resolve::StepOutcome,
) -> bool {
if let Some(row) = rows
.iter_mut()
.find(|r| matches!(r, FactoryResetRow::FidoPending))
{
*row = FactoryResetRow::Step(keyroost_resolve::StepReport {
step: keyroost_resolve::ResetStep::Fido,
outcome,
});
true
} else {
false
}
}
fn factory_reset_fido_finale_log_line(
outcome: &keyroost_resolve::StepOutcome,
) -> (Severity, String) {
use keyroost_resolve::{ResetStep, StepOutcome, StepReport};
let severity = match outcome {
StepOutcome::Wiped | StepOutcome::WipedGlobal => Severity::Ok,
StepOutcome::WipedWithWarning(_) | StepOutcome::Skipped(_) => Severity::Warn,
StepOutcome::Failed(_) => Severity::Err,
};
let (text, _tone) = factory_reset_row_line(&FactoryResetRow::Step(StepReport {
step: ResetStep::Fido,
outcome: outcome.clone(),
}));
(severity, format!("factory reset \u{2014} {text}"))
}
fn piv_factory_reset_message(e: TransportError) -> String {
match e {
TransportError::PivResetUnsupported
| TransportError::PivResetNeedsManagementAuth
| TransportError::PivResetUnverifiedFailed(_)
| TransportError::PivResetForcedFailed(_)
| TransportError::PivResetGlobalFailed(_)
| TransportError::PivResetManagementAuthFailed(_)
| TransportError::PivResetIncomplete(_)
| TransportError::PivPukGuessAccepted => e.to_string(),
other => format!(
"{other} (the wipe blocks the PIN and PUK before erasing, so PIV may \
now be locked but not wiped \u{2014} that is not bricked: run the \
factory reset again to finish it)"
),
}
}
fn piv_reset_global_alternative_available(
reset_gate: keyroost_piv::compat::FeatureGate,
reset_global_gate: keyroost_piv::compat::FeatureGate,
) -> bool {
use keyroost_piv::compat::FeatureGate;
reset_global_gate == FeatureGate::Supported && reset_gate != FeatureGate::Supported
}
fn piv_policy_caveat_hint_needed(
pin_policy_gate: keyroost_piv::compat::FeatureGate,
touch_policy_gate: keyroost_piv::compat::FeatureGate,
) -> bool {
use keyroost_piv::compat::FeatureGate;
pin_policy_gate != FeatureGate::Supported || touch_policy_gate != FeatureGate::Supported
}
fn run_card_reset_step(
step: keyroost_resolve::ResetStep,
reader: Option<&str>,
hid_path: Option<&std::path::Path>,
reset_mgmt_auth: Option<&ResetMgmtAuth>,
) -> keyroost_resolve::StepOutcome {
use keyroost_resolve::{ResetStep, StepOutcome};
if step == ResetStep::Piv {
return (|| -> Result<StepOutcome, String> {
let name = reader.ok_or("no PC/SC reader for the PIV applet")?;
let current = reset_mgmt_auth.map(|auth| match auth {
ResetMgmtAuth::Key(key) => keyroost_transport::CurrentMgmtAuth::Key(key),
ResetMgmtAuth::Pin(pin) => keyroost_transport::CurrentMgmtAuth::Pin(pin.as_bytes()),
});
keyroost_transport::PivSession::with_transaction(
name,
|s| -> Result<StepOutcome, TransportError> {
Ok(match s.factory_reset(current) {
Ok(keyroost_transport::FactoryResetOutcome::Wiped) => StepOutcome::Wiped,
Ok(keyroost_transport::FactoryResetOutcome::WipedGlobal) => {
StepOutcome::WipedGlobal
}
Ok(keyroost_transport::FactoryResetOutcome::WipedKeyRestoreFailed) => {
StepOutcome::WipedWithWarning(
"restoring XAUTH key 1 to the factory-delivery value afterward failed \
\u{2014} it's left cleared instead. Set it manually (PIV \u{2192} \
Change management key) if you need it back."
.into(),
)
}
Err(
e @ (TransportError::PivResetUnsupported
| TransportError::PivResetNeedsManagementAuth),
) => StepOutcome::Skipped(e.to_string()),
Err(e) => StepOutcome::Failed(piv_factory_reset_message(e)),
})
},
)
.map_err(|e: TransportError| e.to_string())
})()
.unwrap_or_else(StepOutcome::Failed);
}
let run = || -> Result<(), String> {
match step {
ResetStep::Oath => {
let name = reader.ok_or("no PC/SC reader for the OATH applet")?;
let mut s =
keyroost_transport::OathSession::open(name).map_err(|e| e.to_string())?;
s.factory_reset().map_err(|e| e.to_string())?;
}
ResetStep::OpenPgp => {
let name = reader.ok_or("no PC/SC reader for the OpenPGP applet")?;
let mut s =
keyroost_transport::OpenPgpSession::open(name).map_err(|e| e.to_string())?;
s.factory_reset().map_err(|e| e.to_string())?;
}
ResetStep::Piv => unreachable!("handled above, before this closure"),
ResetStep::Token2Otp => {
let mut s = match (hid_path, reader) {
(Some(p), reader) => {
match keyroost_transport::Token2OtpSession::open_hid_path(p, false) {
Ok(s) => s,
Err(hid_err) => match reader {
Some(name) => {
keyroost_transport::Token2OtpSession::open_pcsc_reader(
name, false,
)
.map_err(|e| {
format!(
"USB-HID failed ({hid_err}); the same key's \
reader also failed ({e})"
)
})?
}
None => return Err(hid_err.to_string()),
},
}
}
(None, Some(name)) => {
keyroost_transport::Token2OtpSession::open_pcsc_reader(name, false)
.map_err(|e| e.to_string())?
}
(None, None) => return Err("no transport for the OTP applet".to_string()),
};
s.erase_all().map_err(|e| e.to_string())?;
}
ResetStep::Fido => {}
}
Ok(())
};
match run() {
Ok(()) => StepOutcome::Wiped,
Err(e) => StepOutcome::Failed(e),
}
}
fn show_standalone_reset(unlocked: bool, info: Option<&AuthenticatorInfo>) -> bool {
!unlocked && fido_settings_available(info)
}
fn snap_subview(current: FidoSubview, tabs: &[(FidoSubview, &str)]) -> FidoSubview {
if tabs.iter().any(|(v, _)| *v == current) {
current
} else {
tabs.first().map(|(v, _)| *v).unwrap_or_default()
}
}
fn snap_cap_tab(current: CapTab, offered: &[CapTab]) -> CapTab {
if offered.contains(¤t) {
current
} else {
offered.first().copied().unwrap_or_default()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PinSupport {
Unknown,
Unsupported,
NotSet,
Set,
}
fn pin_support(info: Option<&AuthenticatorInfo>) -> PinSupport {
match info {
None => PinSupport::Unknown,
Some(i) => match i.option("clientPin") {
None => PinSupport::Unsupported,
Some(false) => PinSupport::NotSet,
Some(true) => PinSupport::Set,
},
}
}
fn reset_reinsert_matches(
target_hid_serial: Option<&str>,
target_effective_serial: Option<&str>,
target_ids: Option<(u16, u16)>,
cand_hid_serial: Option<&str>,
cand_effective_serial: Option<&str>,
cand_ids: (u16, u16),
) -> bool {
if let Some(ids) = target_ids {
if ids != cand_ids {
return false;
}
}
if let (Some(t), Some(c)) = (target_hid_serial, cand_hid_serial) {
return t == c;
}
if let (Some(t), Some(c)) = (target_effective_serial, cand_effective_serial) {
return t == c;
}
false
}
fn oath_effective_password(oath: &OathState) -> zeroize::Zeroizing<String> {
if !oath.password_input.is_empty() {
return zeroize::Zeroizing::new(oath.password_input.clone());
}
oath.unlocked_password
.clone()
.unwrap_or_else(|| zeroize::Zeroizing::new(String::new()))
}
fn clear_edit_undo(ctx: &egui::Context, id: egui::Id) {
if let Some(mut state) = egui::TextEdit::load_state(ctx, id) {
state.clear_undoer();
egui::TextEdit::store_state(ctx, id, state);
}
}
fn edit_state_needs_scrub(state: &egui::text_edit::TextEditState) -> bool {
let undoer = state.undoer();
let probe = (egui::text::CCursorRange::default(), String::new());
undoer.has_undo(&probe) || undoer.has_redo(&probe) || undoer.is_in_flux()
}
fn guard_secret_field(ctx: &egui::Context, resp: &egui::Response) {
if resp.has_focus() {
return;
}
if let Some(state) = egui::TextEdit::load_state(ctx, resp.id) {
if edit_state_needs_scrub(&state) {
clear_edit_undo(ctx, resp.id);
}
}
}
#[cfg(feature = "qr")]
pub(crate) fn scan_qr_button(ui: &mut egui::Ui, p: &Palette) -> bool {
let (resp, icon_center, fg) = theme::button_with_icon(ui, p, BtnKind::Default, "Scan QR", 14.0);
paint_qr_icon(ui, icon_center, fg);
resp.clicked()
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum ImportSource {
Generate,
FromFile,
}
#[derive(Default, Clone, Copy, PartialEq, Eq)]
enum OpenPgpSlotSel {
#[default]
Sign,
Decrypt,
Auth,
}
impl OpenPgpSlotSel {
fn to_crt(self) -> keyroost_transport::KeyCrt {
match self {
OpenPgpSlotSel::Sign => keyroost_transport::KeyCrt::Sign,
OpenPgpSlotSel::Decrypt => keyroost_transport::KeyCrt::Decrypt,
OpenPgpSlotSel::Auth => keyroost_transport::KeyCrt::Auth,
}
}
fn label(self) -> &'static str {
match self {
OpenPgpSlotSel::Sign => "signature",
OpenPgpSlotSel::Decrypt => "decryption",
OpenPgpSlotSel::Auth => "authentication",
}
}
fn tab_label(self) -> &'static str {
match self {
OpenPgpSlotSel::Sign => "Signature",
OpenPgpSlotSel::Decrypt => "Decryption",
OpenPgpSlotSel::Auth => "Authentication",
}
}
fn status_fields(self, st: &keyroost_transport::OpenPgpStatus) -> (&[u8], &[u8; 20]) {
match self {
OpenPgpSlotSel::Sign => (&st.sig_attrs, &st.fingerprint_sig),
OpenPgpSlotSel::Decrypt => (&st.dec_attrs, &st.fingerprint_dec),
OpenPgpSlotSel::Auth => (&st.aut_attrs, &st.fingerprint_aut),
}
}
}
#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)]
enum OpenPgpKeyAlgSel {
#[default]
CardDefault,
Alg(keyroost_transport::OpenPgpKeyAlg),
}
impl OpenPgpKeyAlgSel {
fn to_alg(self) -> Option<keyroost_transport::OpenPgpKeyAlg> {
match self {
OpenPgpKeyAlgSel::CardDefault => None,
OpenPgpKeyAlgSel::Alg(a) => Some(a),
}
}
fn label(self) -> &'static str {
match self {
OpenPgpKeyAlgSel::CardDefault => "Card default",
OpenPgpKeyAlgSel::Alg(a) => a.label(),
}
}
fn choices(
supported: Option<&keyroost_transport::AlgorithmInformation>,
crt: keyroost_transport::KeyCrt,
) -> Vec<OpenPgpKeyAlgSel> {
let mut out = vec![OpenPgpKeyAlgSel::CardDefault];
let listed = supported.map(|i| i.key_algs(crt)).unwrap_or_default();
if !listed.is_empty() {
out.extend(listed.into_iter().map(OpenPgpKeyAlgSel::Alg));
} else {
out.extend(
keyroost_transport::OpenPgpKeyAlg::ALL
.into_iter()
.filter(|a| a.attributes(crt).is_ok())
.map(OpenPgpKeyAlgSel::Alg),
);
}
out
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PivCredKind {
ChangePin,
ChangePuk,
UnblockPin,
GenerateKey,
ImportCert,
SelfSign,
RequestCsr,
SetRetries,
ChangeMgmtKey,
DeleteCert,
DeleteKey,
MoveKey,
NewChuid,
SelfTest,
}
impl PivCredKind {
fn title(self) -> &'static str {
match self {
PivCredKind::ChangePin => "Change PIN",
PivCredKind::ChangePuk => "Change PUK",
PivCredKind::UnblockPin => "Unblock PIN",
PivCredKind::GenerateKey => "Generate key",
PivCredKind::ImportCert => "Import certificate",
PivCredKind::SelfSign => "Self-signed certificate",
PivCredKind::RequestCsr => "Sign certificate request",
PivCredKind::SetRetries => "Set retry counts",
PivCredKind::ChangeMgmtKey => "Change management key",
PivCredKind::DeleteCert => "Delete certificate",
PivCredKind::DeleteKey => "Delete key",
PivCredKind::MoveKey => "Move key",
PivCredKind::NewChuid => "New CHUID",
PivCredKind::SelfTest => "Key self-test",
}
}
fn submit_label(self) -> &'static str {
match self {
PivCredKind::GenerateKey => "Generate",
PivCredKind::ImportCert => "Import",
PivCredKind::SelfSign => "Create",
PivCredKind::RequestCsr => "Sign & save",
PivCredKind::SetRetries => "Set retry counts",
PivCredKind::ChangeMgmtKey => "Change key",
PivCredKind::DeleteCert => "Delete",
PivCredKind::DeleteKey => "Delete",
PivCredKind::MoveKey => "Move",
PivCredKind::NewChuid => "Write",
PivCredKind::SelfTest => "Run",
_ => self.title(),
}
}
fn busy_label(self) -> &'static str {
match self {
PivCredKind::ChangePin => "Changing PIV PIN\u{2026}",
PivCredKind::ChangePuk => "Changing PUK\u{2026}",
PivCredKind::UnblockPin => "Unblocking PIN\u{2026}",
PivCredKind::GenerateKey => "Generating key\u{2026}",
PivCredKind::ImportCert => "Importing certificate\u{2026}",
PivCredKind::SelfSign => "Creating certificate\u{2026}",
PivCredKind::RequestCsr => "Signing request\u{2026}",
PivCredKind::SetRetries => "Setting retry counts\u{2026}",
PivCredKind::ChangeMgmtKey => "Changing management key\u{2026}",
PivCredKind::DeleteCert => "Deleting certificate\u{2026}",
PivCredKind::DeleteKey => "Deleting key\u{2026}",
PivCredKind::MoveKey => "Moving key\u{2026}",
PivCredKind::NewChuid => "Writing a new CHUID\u{2026}",
PivCredKind::SelfTest => "Running self-test\u{2026}",
}
}
fn needs_mgmt_key(self) -> bool {
matches!(
self,
PivCredKind::GenerateKey
| PivCredKind::ImportCert
| PivCredKind::SelfSign
| PivCredKind::SetRetries
| PivCredKind::ChangeMgmtKey
| PivCredKind::DeleteCert
| PivCredKind::DeleteKey
| PivCredKind::MoveKey
| PivCredKind::NewChuid
)
}
fn shares_pin_field(self) -> bool {
matches!(self, PivCredKind::SelfSign | PivCredKind::SetRetries)
}
}
struct PivCredModal {
kind: PivCredKind,
busy: bool,
result: Option<Result<(), String>>,
detail: Option<String>,
}
impl PivCredModal {
fn new(kind: PivCredKind) -> Self {
PivCredModal {
kind,
busy: false,
result: None,
detail: None,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
enum PivMgmtAuthMode {
#[default]
Manual,
Default,
Pin,
}
#[must_use]
fn piv_mgmt_mode_after_toggle(checked: bool, mode_if_checked: PivMgmtAuthMode) -> PivMgmtAuthMode {
if checked {
mode_if_checked
} else {
PivMgmtAuthMode::Manual
}
}
struct PivState {
status: Option<keyroost_transport::PivStatus>,
slot_keys: Vec<(
keyroost_piv::Slot,
Option<keyroost_piv::KeyAlg>,
Option<String>,
)>,
slot_policies: Vec<(
keyroost_piv::Slot,
Option<(keyroost_piv::PinPolicy, keyroost_piv::TouchPolicy)>,
)>,
error: Option<String>,
notice: Option<String>,
loaded: bool,
mgmt_key_input: String,
mgmt_auth_mode: PivMgmtAuthMode,
pin_old: String,
pin_new: String,
pin_confirm: String,
puk_old: String,
puk_new: String,
puk_confirm: String,
unblock_puk: String,
unblock_new_pin: String,
retries_pin: u8,
retries_puk: u8,
retries_pin_auth: String,
selected_slot: PivSlotSel,
gen_alg: keyroost_piv::KeyAlg,
gen_pin_policy: keyroost_piv::PinPolicy,
gen_touch_policy: keyroost_piv::TouchPolicy,
cert_compression: PivCertCompressionSel,
cert_path: String,
export_path: String,
new_mgmt_key_input: String,
new_mgmt_alg: PivMgmtAlgSel,
new_mgmt_allow_pin_unlock: bool,
cert_subject: String,
cert_valid_years: u32,
cert_valid_months: u32,
cert_valid_days: u32,
sign_pin: String,
csr_path: String,
chuid_valid_years: u32,
chuid_valid_months: u32,
chuid_valid_days: u32,
chuid_guid: String,
confirm_reset: Option<String>,
inflight: bool,
cred_modal: Option<PivCredModal>,
move_dest: Option<keyroost_piv::Slot>,
retired_occupancy: Option<Vec<(keyroost_piv::Slot, bool)>>,
}
impl Drop for PivState {
fn drop(&mut self) {
wipe(&mut self.mgmt_key_input);
self.mgmt_auth_mode = PivMgmtAuthMode::default();
wipe(&mut self.pin_old);
wipe(&mut self.pin_new);
wipe(&mut self.pin_confirm);
wipe(&mut self.puk_old);
wipe(&mut self.puk_new);
wipe(&mut self.puk_confirm);
wipe(&mut self.unblock_puk);
wipe(&mut self.unblock_new_pin);
wipe(&mut self.retries_pin_auth);
wipe(&mut self.new_mgmt_key_input);
wipe(&mut self.sign_pin);
}
}
impl Default for PivState {
fn default() -> Self {
PivState {
status: None,
slot_keys: Vec::new(),
slot_policies: Vec::new(),
error: None,
notice: None,
loaded: false,
mgmt_key_input: String::new(),
mgmt_auth_mode: PivMgmtAuthMode::default(),
pin_old: String::new(),
pin_new: String::new(),
pin_confirm: String::new(),
puk_old: String::new(),
puk_new: String::new(),
puk_confirm: String::new(),
unblock_puk: String::new(),
unblock_new_pin: String::new(),
retries_pin: 3,
retries_puk: 3,
retries_pin_auth: String::new(),
selected_slot: PivSlotSel::default(),
gen_alg: keyroost_piv::KeyAlg::EccP256,
gen_pin_policy: keyroost_piv::PinPolicy::Default,
gen_touch_policy: keyroost_piv::TouchPolicy::Default,
cert_compression: PivCertCompressionSel::Auto,
cert_path: String::new(),
export_path: String::new(),
new_mgmt_key_input: String::new(),
new_mgmt_alg: PivMgmtAlgSel::default(),
new_mgmt_allow_pin_unlock: false,
cert_subject: String::new(),
cert_valid_years: 1,
cert_valid_months: 0,
cert_valid_days: 0,
sign_pin: String::new(),
csr_path: String::new(),
chuid_valid_years: 1,
chuid_valid_months: 0,
chuid_valid_days: 0,
chuid_guid: keyroost_transport::random_chuid_guid()
.map(|g| keyroost_piv::format_guid(&g))
.unwrap_or_default(),
confirm_reset: None,
inflight: false,
cred_modal: None,
move_dest: None,
retired_occupancy: None,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
enum PivSlotSel {
#[default]
Auth,
Sign,
KeyMgmt,
CardAuth,
Retired(u8),
}
impl PivSlotSel {
fn to_slot(self) -> keyroost_piv::Slot {
match self {
PivSlotSel::Auth => keyroost_piv::Slot::Authentication,
PivSlotSel::Sign => keyroost_piv::Slot::Signature,
PivSlotSel::KeyMgmt => keyroost_piv::Slot::KeyManagement,
PivSlotSel::CardAuth => keyroost_piv::Slot::CardAuthentication,
PivSlotSel::Retired(n) => {
keyroost_piv::Slot::retired(n).expect("PivSlotSel::Retired always holds 1..=20")
}
}
}
fn label(self) -> String {
self.to_slot().label()
}
}
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
enum PivCertCompressionSel {
#[default]
Auto,
Always,
Never,
}
impl PivCertCompressionSel {
const ALL: [PivCertCompressionSel; 3] = [
PivCertCompressionSel::Auto,
PivCertCompressionSel::Always,
PivCertCompressionSel::Never,
];
fn label(self) -> &'static str {
match self {
PivCertCompressionSel::Auto => "Automatic (only if too large)",
PivCertCompressionSel::Always => "Always",
PivCertCompressionSel::Never => "Never",
}
}
fn to_compression(self) -> keyroost_transport::CertCompression {
use keyroost_transport::CertCompression;
match self {
PivCertCompressionSel::Auto => CertCompression::Auto,
PivCertCompressionSel::Always => CertCompression::Always,
PivCertCompressionSel::Never => CertCompression::Never,
}
}
}
const PIV_AUTO_COMPRESSED_NOTE: &str = "The certificate did not fit on the card \
uncompressed, so it was stored compressed (the PIV standard's gzip form). Most PIV \
software reads compressed certificates, including Windows' built-in smart-card \
driver in a community test; macOS's built-in PIV support has not been verified.";
fn piv_cert_stored_notice(
base: &str,
compressed: bool,
stored_len: usize,
auto_compressed: bool,
) -> String {
let mut s = base.to_owned();
if compressed {
s.push_str(&format!(
" (stored compressed: {stored_len} bytes on the card)"
));
}
s.push('.');
if auto_compressed {
s.push(' ');
s.push_str(PIV_AUTO_COMPRESSED_NOTE);
}
s
}
fn piv_cert_stored_detail(
compressed: bool,
stored_len: usize,
auto_compressed: bool,
) -> Option<String> {
compressed.then(|| {
let mut s = format!("Stored compressed: {stored_len} bytes on the card.");
if auto_compressed {
s.push('\n');
s.push_str(PIV_AUTO_COMPRESSED_NOTE);
}
s
})
}
fn piv_cert_error_hint(e: &TransportError, choice: PivCertCompressionSel) -> Option<&'static str> {
match e {
TransportError::PivCertTooLarge {
compressed_len: None,
..
} if choice == PivCertCompressionSel::Never => {
Some(" (set Compression to Automatic or Always)")
}
_ => None,
}
}
fn piv_slot_state_text(
cert_unreadable: Option<keyroost_transport::CertUnreadable>,
cert_present: bool,
cert_compressed: bool,
alg: Option<keyroost_piv::KeyAlg>,
) -> String {
match (cert_unreadable, cert_present, alg) {
(Some(reason), _, _) => format!("certificate present but unreadable ({reason})"),
(None, true, alg) => {
let parts: Vec<&str> = alg
.map(keyroost_piv::KeyAlg::label)
.into_iter()
.chain(cert_compressed.then_some("compressed"))
.collect();
if parts.is_empty() {
"certificate present".to_string()
} else {
format!("certificate present ({})", parts.join(", "))
}
}
(None, false, Some(a)) => format!("key present ({}), no certificate", a.label()),
(None, false, None) => "empty".to_string(),
}
}
fn piv_cert_compression_combo(ui: &mut egui::Ui, id: &str, sel: &mut PivCertCompressionSel) {
egui::ComboBox::from_id_salt(id)
.selected_text(sel.label())
.show_ui(ui, |ui| {
for opt in PivCertCompressionSel::ALL {
ui.selectable_value(sel, opt, opt.label());
}
});
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
enum PivMgmtAlgSel {
#[default]
Aes192,
Aes128,
Aes256,
TripleDes,
Delete,
}
impl PivMgmtAlgSel {
fn to_alg(self) -> Option<keyroost_piv::MgmtAlg> {
use keyroost_piv::MgmtAlg::*;
match self {
PivMgmtAlgSel::Aes192 => Some(Aes192),
PivMgmtAlgSel::Aes128 => Some(Aes128),
PivMgmtAlgSel::Aes256 => Some(Aes256),
PivMgmtAlgSel::TripleDes => Some(TripleDes),
PivMgmtAlgSel::Delete => None,
}
}
fn to_choice(self) -> keyroost_piv::compat::MgmtAlgChoice {
use keyroost_piv::compat::MgmtAlgChoice;
match self {
PivMgmtAlgSel::Aes192 => MgmtAlgChoice::Aes192,
PivMgmtAlgSel::Aes128 => MgmtAlgChoice::Aes128,
PivMgmtAlgSel::Aes256 => MgmtAlgChoice::Aes256,
PivMgmtAlgSel::TripleDes => MgmtAlgChoice::TripleDes,
PivMgmtAlgSel::Delete => MgmtAlgChoice::Delete,
}
}
fn label(self) -> &'static str {
match self {
PivMgmtAlgSel::Delete => "Delete",
other => other
.to_alg()
.expect("every non-Delete variant has an algorithm")
.label(),
}
}
const ALL: [PivMgmtAlgSel; 5] = [
PivMgmtAlgSel::TripleDes,
PivMgmtAlgSel::Aes128,
PivMgmtAlgSel::Aes192,
PivMgmtAlgSel::Aes256,
PivMgmtAlgSel::Delete,
];
}
type ApplyFn = Box<dyn FnOnce(&mut App) + Send>;
type Job = Box<dyn FnOnce() -> ApplyFn + Send>;
struct Worker {
job_tx: std::sync::mpsc::Sender<Job>,
result_rx: std::sync::mpsc::Receiver<ApplyFn>,
}
impl Worker {
fn spawn(
ctx: egui::Context,
apdu_trace: std::sync::Arc<std::sync::atomic::AtomicBool>,
) -> Self {
let (job_tx, job_rx) = std::sync::mpsc::channel::<Job>();
let (result_tx, result_rx) = std::sync::mpsc::channel::<ApplyFn>();
std::thread::Builder::new()
.name("keyroost-worker".into())
.spawn(move || {
while let Ok(job) = job_rx.recv() {
let debug = apdu_trace.load(std::sync::atomic::Ordering::Relaxed);
if debug {
keyroost_transport::trace::begin();
}
let apply = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(job)) {
Ok(apply) => apply,
Err(_) => Box::new(|app: &mut App| {
app.log(
Severity::Err,
"a background device operation panicked and was aborted; \
re-plug the key if it stops responding",
);
}) as ApplyFn,
};
let apply = match debug.then(keyroost_transport::trace::take).flatten() {
Some(trace) if !trace.is_empty() => Box::new(move |app: &mut App| {
app.pending_trace = Some(trace);
apply(app);
app.pending_trace = None; }) as ApplyFn,
_ => apply,
};
if result_tx.send(apply).is_err() {
break; }
ctx.request_repaint(); }
})
.expect("spawn worker thread");
Worker { job_tx, result_rx }
}
}
fn main() -> eframe::Result<()> {
if std::env::var_os("KEYROOST_X11").is_some() {
std::env::remove_var("WAYLAND_DISPLAY");
}
let apdu_trace = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([1180.0, 760.0])
.with_min_inner_size([900.0, 560.0])
.with_title("keyroost"),
..Default::default()
};
eframe::run_native(
"keyroost",
options,
Box::new(|cc| {
theme::install_fonts(&cc.egui_ctx);
let saved = Settings::load();
let mode: Mode = saved.mode.into();
let accent_idx = saved.accent.min(Palette::ACCENTS.len() - 1);
let colorblind = saved.colorblind;
let zoom = theme::clamp_zoom(saved.zoom);
Palette::new(mode, Palette::ACCENTS[accent_idx], colorblind).apply(&cc.egui_ctx, mode);
let devices_dirty = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let reader_watch = {
let dirty = devices_dirty.clone();
let egui_ctx = cc.egui_ctx.clone();
keyroost_transport::ReaderWatcher::spawn(move || {
dirty.store(true, std::sync::atomic::Ordering::Relaxed);
egui_ctx.request_repaint();
})
};
let app = App {
mode,
accent_idx,
colorblind,
zoom,
last_saved_settings: Some(Settings {
zoom,
mode: mode.into(),
accent: accent_idx,
colorblind,
}),
worker: Some(Worker::spawn(cc.egui_ctx.clone(), apdu_trace.clone())),
egui_ctx: Some(cc.egui_ctx.clone()),
devices_dirty,
reader_watch: Some(reader_watch),
mds: ui::mds::MdsDb::load_bundled(),
apdu_trace,
..Default::default()
};
Ok(Box::new(app))
}),
)
}
#[derive(Default)]
struct App {
session: Option<Session>,
molto_session_device: Option<DeviceId>,
info: Option<DeviceInfo>,
authenticated: bool,
prog_session: Option<keyroost_transport::Token2ProgSession>,
prog_info: Option<keyroost_token2prog::Info>,
prog_authenticated: bool,
prog_seed_input: zeroize::Zeroizing<String>,
prog_seed_hex: bool,
prog_algo_sha256: bool,
prog_step_60: bool,
prog_timeout_idx: usize,
prog_status: Option<(bool, String)>,
secret_reveal: std::collections::HashMap<&'static str, bool>,
customer_key_input: String,
customer_key_hex: bool,
slot: u8,
slot_meta: Option<Vec<ProfilePublicData>>,
draft: Draft,
log: Vec<LogLine>,
apdu_trace: std::sync::Arc<std::sync::atomic::AtomicBool>,
pending_trace: Option<Vec<String>>,
import_dialog: ImportDialog,
bulk_dialog: BulkDialog,
devices: Vec<Device>,
selected_device: Option<DeviceId>,
cap_tab: CapTab,
cap_tab_by_device: std::collections::HashMap<DeviceId, CapTab>,
devices_error: Option<String>,
clipboard_clear_at: Option<(String, f64)>,
scanned: bool,
filter: String,
security_keys: SecurityKeysState,
mds: ui::mds::MdsDb,
mds_icon: Option<(String, egui::TextureHandle)>,
otp_last_window: Option<u64>,
oath: OathState,
openpgp: OpenPgpState,
piv: PivState,
piv_session_state: std::collections::HashMap<DeviceId, keyroost_transport::PivSessionState>,
piv_compat_ignored: std::collections::HashSet<DeviceId>,
otp: OtpState,
mode: Mode,
accent_idx: usize,
colorblind: bool,
log_open: bool,
log_detached: bool,
help_open: Option<&'static str>,
help_anchor: egui::Pos2,
copied: Option<(String, f64)>,
oath_tried: bool,
piv_tried: bool,
otp_tried: bool,
molto_reset_confirm: bool,
molto_delete_confirm: bool,
rename_input: String,
rename_target: Option<RenameTarget>,
worker: Option<Worker>,
busy_jobs: u32,
busy_label: Option<String>,
devices_dirty: std::sync::Arc<std::sync::atomic::AtomicBool>,
reset_arm: Option<ResetArm>,
factory_reset_confirm: Option<FactoryResetConfirmState>,
factory_reset_confirm_error: Option<String>,
factory_reset_confirm_typed: String,
reset_mgmt_auth_mode: ResetMgmtAuthMode,
reset_mgmt_auth_input: String,
factory_reset_report: Vec<FactoryResetRow>,
pending_scans: u8,
next_scan_at: Option<std::time::Instant>,
#[allow(dead_code)]
reader_watch: Option<keyroost_transport::ReaderWatcher>,
import_rx: Option<std::sync::mpsc::Receiver<ApplyFn>>,
import_label: Option<String>,
egui_ctx: Option<egui::Context>,
applied_theme: Option<(Mode, usize, bool)>,
zoom: f32,
applied_zoom: Option<f32>,
zoom_pending: Option<f32>,
zoom_commit_at: Option<std::time::Instant>,
last_saved_settings: Option<Settings>,
pending_files: Vec<(
FileTarget,
std::sync::mpsc::Receiver<Option<std::path::PathBuf>>,
)>,
ssh_cert_next_id: u64,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum FileTarget {
OpenpgpImport,
PivCsr,
PivCert,
PivExport,
LbExport(usize),
SshCertSave(u64),
}
struct PendingSshCert {
cert_pub: String,
summary: String,
}
fn take_pending_cert(
pending: &mut std::collections::HashMap<u64, PendingSshCert>,
id: u64,
) -> Option<PendingSshCert> {
pending.remove(&id)
}
impl App {
fn spawn_file_dialog(
&mut self,
target: FileTarget,
save: bool,
filters: &[(&'static str, &'static [&'static str])],
default_name: Option<&str>,
) {
let (tx, rx) = std::sync::mpsc::channel();
let owned_filters: Vec<(String, Vec<String>)> = filters
.iter()
.map(|(name, exts)| {
(
(*name).to_owned(),
exts.iter().map(|e| (*e).to_owned()).collect(),
)
})
.collect();
let default_name = default_name.map(str::to_owned);
let egui_ctx = self.egui_ctx.clone();
std::thread::spawn(move || {
let result = pollster::block_on(async move {
let mut dialog = rfd::AsyncFileDialog::new();
for (name, exts) in &owned_filters {
let ext_refs: Vec<&str> = exts.iter().map(String::as_str).collect();
dialog = dialog.add_filter(name, &ext_refs);
}
if let Some(name) = &default_name {
dialog = dialog.set_file_name(name);
}
let handle = if save {
dialog.save_file().await
} else {
dialog.pick_file().await
};
handle.map(|h| h.path().to_path_buf())
});
let _ = tx.send(result);
if let Some(ctx) = egui_ctx {
ctx.request_repaint();
}
});
self.pending_files.push((target, rx));
}
fn drain_file_dialogs(&mut self) {
let mut i = 0;
while i < self.pending_files.len() {
match self.pending_files[i].1.try_recv() {
Ok(result) => {
let (target, _) = self.pending_files.remove(i);
if let Some(path) = result {
let text = path.display().to_string();
match target {
FileTarget::OpenpgpImport => self.openpgp.import_path = text,
FileTarget::PivCsr => {
self.piv.csr_path = text;
self.piv_cred_modal_close();
self.piv.cred_modal =
Some(PivCredModal::new(PivCredKind::RequestCsr));
}
FileTarget::PivCert => {
self.piv.cert_path = text;
self.piv_cred_modal_close();
self.piv.cred_modal =
Some(PivCredModal::new(PivCredKind::ImportCert));
}
FileTarget::PivExport => {
self.piv.export_path = text;
self.piv_export_cert();
}
FileTarget::LbExport(idx) => {
use keyroost_ctap::large_blobs::EntryKind;
let entry = self
.security_keys
.large_blobs
.as_ref()
.and_then(|arr| arr.entries.get(idx));
self.security_keys.lb_status = Some(match entry {
None => {
format!("Export failed: entry {idx} no longer exists")
}
Some(entry) => {
let bytes = match entry.classify() {
EntryKind::SshCert { wire, .. } => {
keyroost_ctap::ssh_cert::to_cert_pub(&wire)
.map(String::into_bytes)
}
_ => Some(entry.ciphertext.clone()),
};
match bytes {
None => "Export failed: could not re-encode \
certificate"
.into(),
Some(bytes) => match std::fs::write(&path, &bytes) {
Ok(()) => format!(
"Exported entry {idx} ({} bytes) to {text}",
bytes.len()
),
Err(e) => format!("Export failed: {e}"),
},
}
}
});
}
FileTarget::SshCertSave(id) => {
let pending =
take_pending_cert(&mut self.security_keys.ssh_cert_pending, id);
self.security_keys.ssh_cert_status = Some(match pending {
None => "Save failed: no certificate was ready \
to write"
.to_string(),
Some(pending) => {
match std::fs::write(&path, pending.cert_pub.as_bytes()) {
Ok(()) => format!(
"Saved certificate to {text}\n{}",
pending.summary
),
Err(e) => format!("Save failed: {e}"),
}
}
});
}
}
} else {
self.discard_file_target(target);
}
}
Err(std::sync::mpsc::TryRecvError::Empty) => i += 1,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
let (target, _) = self.pending_files.remove(i);
self.discard_file_target(target);
}
}
}
}
fn discard_file_target(&mut self, target: FileTarget) {
if let FileTarget::SshCertSave(id) = target {
self.security_keys.ssh_cert_pending.remove(&id);
}
}
}
#[derive(Default)]
struct Draft {
title: String,
secret_base32: String,
algorithm: AlgoChoice,
digits: DigitsChoice,
time_step: StepChoice,
display_timeout: TimeoutChoice,
}
#[derive(Default, Clone, Copy, PartialEq, Eq)]
enum AlgoChoice {
#[default]
Sha1,
Sha256,
}
impl AlgoChoice {
fn to_proto(self) -> HmacAlgo {
match self {
AlgoChoice::Sha1 => HmacAlgo::Sha1,
AlgoChoice::Sha256 => HmacAlgo::Sha256,
}
}
}
#[derive(Default, Clone, Copy, PartialEq, Eq)]
enum DigitsChoice {
Four,
#[default]
Six,
Eight,
Ten,
}
impl DigitsChoice {
fn to_proto(self) -> OtpDigits {
match self {
DigitsChoice::Four => OtpDigits::Four,
DigitsChoice::Six => OtpDigits::Six,
DigitsChoice::Eight => OtpDigits::Eight,
DigitsChoice::Ten => OtpDigits::Ten,
}
}
}
#[derive(Default, Clone, Copy, PartialEq, Eq)]
enum StepChoice {
#[default]
S30,
S60,
}
impl StepChoice {
fn to_proto(self) -> TimeStep {
match self {
StepChoice::S30 => TimeStep::Seconds30,
StepChoice::S60 => TimeStep::Seconds60,
}
}
}
#[derive(Default, Clone, Copy, PartialEq, Eq)]
enum TimeoutChoice {
S15,
#[default]
S30,
S60,
S120,
}
impl TimeoutChoice {
fn to_proto(self) -> DisplayTimeout {
match self {
TimeoutChoice::S15 => DisplayTimeout::Sec15,
TimeoutChoice::S30 => DisplayTimeout::Sec30,
TimeoutChoice::S60 => DisplayTimeout::Sec60,
TimeoutChoice::S120 => DisplayTimeout::Sec120,
}
}
}
#[derive(Default)]
struct ImportDialog {
open: bool,
uri: String,
}
#[derive(Default)]
struct BulkDialog {
open: bool,
path: String,
entries: Vec<keyroost_import::BulkEntry>,
error: Option<String>,
start: u8,
display_timeout: TimeoutChoice,
password: String,
needs_password: bool,
}
struct LogLine {
severity: Severity,
kind: LogKind,
text: String,
trace: Option<Vec<String>>,
}
#[derive(Clone, Copy)]
enum Severity {
Info,
Ok,
Warn,
Err,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum LogKind {
User,
Background,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum PivCache {
Reuse,
Bypass,
}
impl App {
fn spawn_job<F>(&mut self, label: impl Into<String>, job: F) -> bool
where
F: FnOnce() -> ApplyFn + Send + 'static,
{
if self.busy() {
return false;
}
match &self.worker {
Some(worker) => {
self.busy_jobs += 1;
self.busy_label = Some(label.into());
if worker.job_tx.send(Box::new(job)).is_err() {
self.busy_jobs -= 1;
self.busy_label = None;
return false;
}
true
}
None => {
let apply = job();
apply(self);
true
}
}
}
fn drain_worker(&mut self) {
use std::sync::mpsc::TryRecvError;
let mut applies: Vec<ApplyFn> = Vec::new();
let mut disconnected = false;
if let Some(w) = &self.worker {
loop {
match w.result_rx.try_recv() {
Ok(apply) => applies.push(apply),
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
disconnected = true;
break;
}
}
}
}
for apply in applies {
self.busy_jobs = self.busy_jobs.saturating_sub(1);
if self.busy_jobs == 0 {
self.busy_label = None;
}
apply(self);
}
if disconnected {
self.busy_jobs = 0;
self.busy_label = None;
let apdu_trace = self.apdu_trace.clone();
self.worker = self
.egui_ctx
.clone()
.map(|ctx| Worker::spawn(ctx, apdu_trace));
self.log(
Severity::Err,
"the background worker stopped unexpectedly and was restarted",
);
}
}
fn busy(&self) -> bool {
self.busy_jobs > 0
}
fn import_busy(&self) -> bool {
self.import_rx.is_some()
}
fn run_import<F>(&mut self, label: impl Into<String>, job: F)
where
F: FnOnce() -> ApplyFn + Send + 'static,
{
if self.import_busy() {
return;
}
let Some(ctx) = self.egui_ctx.clone() else {
let apply = job();
apply(self);
return;
};
let (tx, rx) = std::sync::mpsc::channel::<ApplyFn>();
let spawned = std::thread::Builder::new()
.name("keyroost-import".into())
.spawn(move || {
let apply = job();
if tx.send(apply).is_ok() {
ctx.request_repaint(); }
});
match spawned {
Ok(_) => {
self.import_rx = Some(rx);
self.import_label = Some(label.into());
}
Err(e) => {
self.bulk_dialog.error = Some(format!("could not start import thread: {}", e));
}
}
}
fn drain_import(&mut self) {
let received = match &self.import_rx {
Some(rx) => rx.try_recv(),
None => return,
};
match received {
Ok(apply) => {
self.import_rx = None;
self.import_label = None;
apply(self);
}
Err(std::sync::mpsc::TryRecvError::Empty) => {}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
self.import_rx = None;
self.import_label = None;
self.bulk_dialog.error = Some("import failed unexpectedly".into());
}
}
}
fn log(&mut self, severity: Severity, text: impl Into<String>) {
self.log_kind(severity, LogKind::User, text);
}
fn log_bg(&mut self, severity: Severity, text: impl Into<String>) {
self.log_kind(severity, LogKind::Background, text);
}
fn log_kind(&mut self, severity: Severity, kind: LogKind, text: impl Into<String>) {
let text = match self.selected_device() {
Some(d) => format!("{}: {}", d.title(), text.into()),
None => text.into(),
};
self.push_log(severity, kind, text);
}
fn log_global(&mut self, severity: Severity, kind: LogKind, text: impl Into<String>) {
self.push_log(severity, kind, text.into());
}
fn push_log(&mut self, severity: Severity, kind: LogKind, text: String) {
let trace = self.pending_trace.take().filter(|t| !t.is_empty());
self.log.push(LogLine {
severity,
kind,
text,
trace,
});
if self.log.len() > 200 {
let overflow = self.log.len() - 200;
self.log.drain(0..overflow);
}
}
fn customer_key_bytes(&self) -> Result<Vec<u8>, String> {
if self.customer_key_input.is_empty() {
return Ok(DEFAULT_CUSTOMER_KEY.to_vec());
}
if self.customer_key_hex {
keyroost_proto::codec::hex_decode(&self.customer_key_input)
.map_err(|e| format!("invalid customer key hex: {}", e))
} else {
Ok(self.customer_key_input.as_bytes().to_vec())
}
}
fn take_molto_session(&mut self) -> Option<Session> {
if self.busy() {
return None;
}
if !self.molto_session_usable() {
self.session = None;
self.molto_session_device = None;
self.log(Severity::Warn, "not connected");
return None;
}
match self.session.take() {
Some(s) => Some(s),
None => {
self.log(Severity::Warn, "not connected");
None
}
}
}
fn molto_session_usable(&self) -> bool {
completion_still_valid(
self.molto_session_device.as_ref(),
self.selected_device.as_ref(),
)
}
fn authenticate(&mut self) {
let key = match self.customer_key_bytes() {
Ok(k) => zeroize::Zeroizing::new(k),
Err(e) => {
self.log(Severity::Err, e);
return;
}
};
let Some(mut s) = self.take_molto_session() else {
return;
};
self.spawn_job("Authenticating\u{2026}", move || {
let result = s.authenticate(&key);
Box::new(move |app: &mut App| {
app.session = Some(s);
match result {
Ok(()) => {
app.authenticated = true;
app.log(Severity::Ok, "authenticated");
}
Err(e @ TransportError::AuthFailed { .. }) => {
app.log(Severity::Err, e.to_string());
}
Err(e) => app.log(Severity::Err, format!("auth failed: {}", e)),
}
})
});
}
fn apply_draft(&mut self) {
if !self.ensure_auth() {
return;
}
let secret = match keyroost_proto::codec::base32_decode(&self.draft.secret_base32) {
Ok(s) if !s.is_empty() && s.len() <= 63 => zeroize::Zeroizing::new(s),
Ok(s) => {
self.log(
Severity::Err,
format!("seed must decode to 1..=63 bytes (got {})", s.len()),
);
return;
}
Err(e) => {
self.log(Severity::Err, format!("seed base32 invalid: {}", e));
return;
}
};
let title = self.draft.title.trim().to_owned();
if title.is_empty() || title.len() > 12 {
self.log(Severity::Err, "title must be 1..=12 bytes");
return;
}
let cfg = ProfileConfig {
display_timeout: self.draft.display_timeout.to_proto(),
algorithm: self.draft.algorithm.to_proto(),
digits: self.draft.digits.to_proto(),
time_step: self.draft.time_step.to_proto(),
utc_time: unix_now(),
};
let p = self.slot;
let Some(mut s) = self.take_molto_session() else {
return;
};
self.spawn_job(format!("Writing profile #{p}\u{2026}"), move || {
let result = s
.set_seed(p, &secret)
.map_err(|e| format!("set_seed #{}: {}", p, e))
.and_then(|()| {
s.set_title(p, &title)
.map_err(|e| format!("set_title #{}: {}", p, e))
})
.and_then(|()| {
s.set_config(p, &cfg)
.map_err(|e| format!("set_config #{}: {}", p, e))
});
let refreshed = result.is_ok().then(|| s.read_public_data(p).ok()).flatten();
Box::new(move |app: &mut App| {
app.session = Some(s);
match result {
Ok(()) => {
wipe(&mut app.draft.secret_base32);
if let (Some(meta), Some(block)) = (app.slot_meta.as_mut(), refreshed) {
if let Some(slot) = meta.get_mut(p as usize) {
*slot = block;
}
}
if app.slot_meta.is_none() {
app.resweep_slot_meta(LogKind::Background);
}
app.log(Severity::Ok, format!("profile #{} written", p));
}
Err(e) => app.log(Severity::Err, e),
}
})
});
}
fn apply_title_only(&mut self) {
if !self.ensure_auth() {
return;
}
let title = self.draft.title.trim().to_owned();
if title.is_empty() || title.len() > 12 {
self.log(Severity::Err, "title must be 1..=12 bytes");
return;
}
let p = self.slot;
let Some(mut s) = self.take_molto_session() else {
return;
};
self.spawn_job(format!("Writing title on #{p}\u{2026}"), move || {
let result = s
.set_title(p, &title)
.map_err(|e| format!("set_title #{}: {}", p, e));
let refreshed = result.is_ok().then(|| s.read_public_data(p).ok()).flatten();
Box::new(move |app: &mut App| {
app.session = Some(s);
match result {
Ok(()) => {
if let (Some(meta), Some(block)) = (app.slot_meta.as_mut(), refreshed) {
if let Some(slot) = meta.get_mut(p as usize) {
*slot = block;
}
}
if app.slot_meta.is_none() {
app.resweep_slot_meta(LogKind::Background);
}
app.log(Severity::Ok, format!("title written on slot #{}", p));
}
Err(e) => app.log(Severity::Err, e),
}
})
});
}
fn delete_seed_selected(&mut self) {
let p = self.slot;
let Some(mut s) = self.take_molto_session() else {
return;
};
self.spawn_job(format!("Deleting seed on #{p}\u{2026}"), move || {
let result = s
.delete_seed(p)
.map_err(|e| format!("delete seed #{}: {}", p, e));
let refreshed = result.is_ok().then(|| s.read_public_data(p).ok()).flatten();
Box::new(move |app: &mut App| {
app.session = Some(s);
match result {
Ok(outcome) => {
if let (Some(meta), Some(block)) = (app.slot_meta.as_mut(), refreshed) {
if let Some(slot) = meta.get_mut(p as usize) {
*slot = block;
}
}
if app.slot_meta.is_none() {
app.resweep_slot_meta(LogKind::Background);
}
match outcome {
SeedDeleteOutcome::Deleted => app.log(
Severity::Ok,
format!("seed deleted from slot #{} (title kept)", p),
),
SeedDeleteOutcome::AlreadyEmpty => {
app.log(Severity::Ok, format!("slot #{} was already empty", p))
}
}
}
Err(e) => app.log(Severity::Err, e),
}
})
});
}
fn sync_time_selected(&mut self) {
if !self.ensure_auth() {
return;
}
let p = self.slot;
let Some(mut s) = self.take_molto_session() else {
return;
};
self.spawn_job("Syncing time\u{2026}", move || {
let result = s.sync_time(p, unix_now());
Box::new(move |app: &mut App| {
app.session = Some(s);
match result {
Ok(()) => app.log(Severity::Ok, format!("time synced on #{}", p)),
Err(e) => app.log(Severity::Err, format!("sync_time #{}: {}", p, e)),
}
})
});
}
fn sync_time_all(&mut self) {
if !self.ensure_auth() {
return;
}
let Some(mut s) = self.take_molto_session() else {
return;
};
self.spawn_job("Syncing time on all profiles\u{2026}", move || {
let mut ok = 0;
let mut fail = 0;
for p in 0..PROFILES {
match s.sync_time(p, unix_now()) {
Ok(()) => ok += 1,
Err(_) => fail += 1,
}
}
Box::new(move |app: &mut App| {
app.session = Some(s);
let sev = if fail == 0 {
Severity::Ok
} else {
Severity::Warn
};
app.log(sev, format!("time-sync-all: {} ok, {} failed", ok, fail));
})
});
}
#[cfg(feature = "qr")]
fn molto_scan_qr(&mut self) {
match qrscan::scan_screens_for_otpauth() {
Ok(uri) => {
self.import_dialog.uri = uri;
self.import_otpauth();
}
Err(e) => self.log(Severity::Err, format!("scan QR: {e}")),
}
}
fn import_otpauth(&mut self) {
let uri = self.import_dialog.uri.trim().to_owned();
let parsed = match parse_otpauth(&uri) {
Ok(p) => p,
Err(e) => {
self.log(Severity::Err, format!("import: {}", e));
return;
}
};
self.draft.title = parsed.suggested_title();
self.draft.secret_base32 = uri
.find("secret=")
.map(|i| {
let rest = &uri[i + 7..];
let end = rest.find('&').unwrap_or(rest.len());
rest[..end].to_owned()
})
.unwrap_or_default();
self.draft.algorithm = match parsed.algorithm {
HmacAlgo::Sha1 => AlgoChoice::Sha1,
HmacAlgo::Sha256 => AlgoChoice::Sha256,
};
self.draft.digits = match parsed.digits {
OtpDigits::Four => DigitsChoice::Four,
OtpDigits::Six => DigitsChoice::Six,
OtpDigits::Eight => DigitsChoice::Eight,
OtpDigits::Ten => DigitsChoice::Ten,
};
self.draft.time_step = match parsed.time_step {
TimeStep::Seconds30 => StepChoice::S30,
TimeStep::Seconds60 => StepChoice::S60,
};
self.log(
Severity::Info,
format!(
"imported draft for #{} from URI; review and click Write profile",
self.slot
),
);
self.import_dialog.open = false;
self.import_dialog.uri.clear();
}
fn factory_reset(&mut self) {
let Some(mut s) = self.take_molto_session() else {
return;
};
self.spawn_job("Requesting factory reset\u{2026}", move || {
let result = s.factory_reset();
Box::new(move |app: &mut App| {
app.session = Some(s);
match result {
Ok(()) => {
app.slot_meta = None;
app.log(
Severity::Warn,
"factory-reset requested. Confirm with the \u{25B2} button on the device; reselect it afterward to refresh the slot list.",
);
}
Err(e) => app.log(Severity::Err, format!("factory_reset: {}", e)),
}
})
});
}
fn resweep_slot_meta(&mut self, kind: LogKind) {
let Some(mut s) = self.take_molto_session() else {
return;
};
self.spawn_job("Refreshing slots\u{2026}", move || {
let meta = (0..PROFILES)
.map(|p| s.read_public_data(p))
.collect::<Result<Vec<_>, _>>();
Box::new(move |app: &mut App| {
app.session = Some(s);
match meta {
Ok(m) => {
app.log_kind(Severity::Ok, kind, format!("{} slots read", m.len()));
app.slot_meta = Some(m);
}
Err(e) => app.log_kind(Severity::Warn, kind, format!("slot refresh: {e}")),
}
})
});
}
fn bulk_load(&mut self) {
if self.import_busy() {
return;
}
let path = self.bulk_dialog.path.trim().to_owned();
if path.is_empty() {
self.bulk_dialog.error = Some("enter a file path first".into());
return;
}
let password = zeroize::Zeroizing::new(self.bulk_dialog.password.clone());
self.run_import("Loading import file\u{2026}", move || {
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(e) => {
let msg = format!("read failed: {}", e);
return Box::new(move |app: &mut App| app.bulk_dialog.error = Some(msg));
}
};
if keyroost_qr::looks_like_image(&bytes) {
let result = keyroost_qr::entries_from_image(&bytes);
return Box::new(move |app: &mut App| app.bulk_qr_loaded(result));
}
let text = match String::from_utf8(bytes) {
Ok(t) => zeroize::Zeroizing::new(t),
Err(_) => {
return Box::new(move |app: &mut App| {
app.bulk_dialog.error =
Some("file is neither a text export nor a PNG/JPEG image".into());
})
}
};
let is_encrypted_aegis = keyroost_import::aegis::is_encrypted(&text).unwrap_or(false);
if is_encrypted_aegis {
if password.is_empty() {
return Box::new(move |app: &mut App| {
app.bulk_dialog.needs_password = true;
app.bulk_dialog.entries.clear();
app.bulk_dialog.error = Some(
"encrypted Aegis vault — enter password and click Load again".into(),
);
});
}
let result = match keyroost_import::aegis::decrypt(&text, password.as_bytes()) {
Ok(plaintext) => {
keyroost_import::parse_bulk_any(&plaintext).map_err(|e| e.to_string())
}
Err(e) => Err(format!("decrypt: {}", e)),
};
return Box::new(move |app: &mut App| {
app.bulk_dialog.needs_password = true;
app.bulk_text_loaded(result, path);
});
}
let result = keyroost_import::parse_bulk_any(&text).map_err(|e| e.to_string());
Box::new(move |app: &mut App| {
app.bulk_dialog.needs_password = false;
app.bulk_text_loaded(result, path);
})
});
}
fn bulk_qr_loaded(&mut self, result: Result<keyroost_qr::QrImport, keyroost_qr::QrError>) {
match result {
Ok(import) => {
self.bulk_dialog.error = None;
self.bulk_dialog.needs_password = false;
for s in &import.skipped {
self.log(
Severity::Err,
format!("skipped {:?}: {}", s.label, s.reason),
);
}
if let Some((i, n)) = import.batch {
self.log(
Severity::Info,
format!(
"this is QR {} of {} in the export — load the others too",
i + 1,
n
),
);
}
self.log(
Severity::Info,
format!(
"loaded {} entries from QR image — delete the screenshot after a \
successful import",
import.entries.len()
),
);
self.bulk_dialog.entries = import.entries;
}
Err(e) => {
self.bulk_dialog.entries.clear();
self.bulk_dialog.error = Some(e.to_string());
}
}
}
fn bulk_text_loaded(
&mut self,
result: Result<Vec<keyroost_import::BulkEntry>, String>,
path: String,
) {
match result {
Ok(entries) => {
self.bulk_dialog.entries = entries;
self.bulk_dialog.error = None;
wipe(&mut self.bulk_dialog.password);
self.log(
Severity::Info,
format!(
"loaded {} entries from {}",
self.bulk_dialog.entries.len(),
path
),
);
}
Err(e) => {
self.bulk_dialog.entries.clear();
self.bulk_dialog.error = Some(e);
}
}
}
fn bulk_apply(&mut self) {
if !self.ensure_auth() {
return;
}
if self.bulk_dialog.entries.is_empty() {
self.log(Severity::Warn, "no entries loaded");
return;
}
let start = self.bulk_dialog.start;
let n = self.bulk_dialog.entries.len();
let last = (start as usize).saturating_add(n);
if last > PROFILES as usize {
self.log(
Severity::Err,
format!(
"{} entries starting at #{} would overflow slot 99 (would need #{})",
n,
start,
last - 1
),
);
return;
}
let timeout = self.bulk_dialog.display_timeout.to_proto();
let entries = self.bulk_dialog.entries.clone();
let Some(mut s) = self.take_molto_session() else {
return;
};
self.spawn_job(format!("Programming {n} profiles\u{2026}"), move || {
let mut ok = 0;
let mut fail = 0;
let mut lines: Vec<(Severity, String)> = Vec::new();
for (i, entry) in entries.into_iter().enumerate() {
let p = start + i as u8;
let title = entry.suggested_title();
if title.is_empty() {
lines.push((Severity::Warn, format!("#{}: no title; skipping", p)));
fail += 1;
continue;
}
if let Err(e) = s.set_seed(p, &entry.secret) {
lines.push((Severity::Err, format!("#{} set_seed: {}", p, e)));
fail += 1;
continue;
}
if let Err(e) = s.set_title(p, &title) {
lines.push((Severity::Err, format!("#{} set_title: {}", p, e)));
fail += 1;
continue;
}
if let Err(e) = s.set_config(p, &entry.to_profile_config(unix_now(), timeout)) {
lines.push((Severity::Err, format!("#{} set_config: {}", p, e)));
fail += 1;
continue;
}
ok += 1;
}
let meta = (0..PROFILES)
.map(|p| s.read_public_data(p))
.collect::<Result<Vec<_>, _>>()
.ok();
Box::new(move |app: &mut App| {
app.session = Some(s);
for (sev, line) in lines {
app.log(sev, line);
}
let sev = if fail == 0 {
Severity::Ok
} else {
Severity::Warn
};
app.log(sev, format!("bulk import: {} ok, {} failed", ok, fail));
if let Some(m) = meta {
app.slot_meta = Some(m);
}
if fail == 0 {
app.bulk_dialog.open = false;
}
})
});
}
fn ensure_auth(&mut self) -> bool {
if self.authenticated {
return true;
}
self.log(
Severity::Warn,
"not authenticated; click Authenticate first",
);
false
}
fn fetch_selected_info(&mut self) {
self.security_keys.info = None;
self.security_keys.init = None;
self.security_keys.error = None;
let Some(target) = self.selected_fido_target() else {
return;
};
let for_device = self.selected_device.clone();
self.spawn_job("Reading key info\u{2026}", move || {
let outcome = match open_fido(&target) {
Ok((mut dev, cbor, init)) => {
let info = if cbor {
Some(keyroost_ctap::get_info(&mut dev).map_err(|e| e.to_string()))
} else {
None
};
Ok((init, info))
}
Err(e) => Err(format!("could not open key: {e}")),
};
Box::new(move |app: &mut App| {
if !completion_still_valid(for_device.as_ref(), app.selected_device.as_ref()) {
return; }
match outcome {
Ok((init, info)) => {
if let Some(init) = init {
let fw = format!(
"{}.{}.{}",
init.device_major, init.device_minor, init.device_build
);
if let Some(id) = for_device.clone() {
if let Some(dev) = app.devices.iter_mut().find(|d| d.id == id) {
dev.firmware = fw;
}
}
app.security_keys.init = Some(init);
}
match info {
Some(Ok(info)) => {
if let Some(model) = ui::aaguid::model_for_aaguid(&info.aaguid) {
if let Some(id) = for_device {
if let Some(dev) =
app.devices.iter_mut().find(|d| d.id == id)
{
dev.model = model.to_string();
}
}
}
app.security_keys.info = Some(info);
}
Some(Err(e)) => {
app.security_keys.error = Some(format!("GetInfo failed: {}", e))
}
None => {
app.security_keys.error = Some(
"this key did not present a CTAP2 (FIDO2) interface over \
the reader; only U2F was detected"
.to_string(),
);
}
}
}
Err(e) => app.security_keys.error = Some(e),
}
})
});
}
fn try_unlock(&mut self) {
if self.busy() {
return;
}
let Some(target) = self.selected_fido_target() else {
return;
};
let pin = std::mem::take(&mut self.security_keys.pin_input);
if pin.is_empty() {
self.security_keys.error = Some("PIN is empty".into());
return;
}
let for_device = self.selected_device.clone();
self.spawn_job("Unlocking\u{2026} (enter PIN / touch)", move || {
let result = Self::open_and_unlock(&target, &pin).map_err(|e| e.to_string());
Box::new(move |app: &mut App| {
if !completion_still_valid(for_device.as_ref(), app.selected_device.as_ref()) {
return; }
match result {
Ok(sess) => {
app.security_keys.fingerprints = sess.fingerprints.clone();
app.security_keys.session = Some(sess);
app.security_keys.session_device = for_device.clone();
app.security_keys.error = None;
}
Err(e) => app.security_keys.error = Some(format!("unlock failed: {}", e)),
}
})
});
}
fn open_and_unlock(
target: &FidoTarget,
pin: &str,
) -> Result<UnlockedSession, Box<dyn std::error::Error>> {
let (mut dev, cbor, _init) = open_fido(target)?;
if !cbor {
return Err("device is U2F-only".into());
}
let info = keyroost_ctap::get_info(&mut dev)?;
let mut perms = keyroost_ctap::client_pin::permissions::CREDENTIAL_MANAGEMENT;
if info.option("bioEnroll").is_some()
|| info.option("userVerificationMgmtPreview").is_some()
{
perms |= keyroost_ctap::client_pin::permissions::BIO_ENROLLMENT;
}
let token = keyroost_ctap::client_pin::get_pin_uv_auth_token(&mut dev, pin, &info, perms)?;
let mut mgr =
keyroost_ctap::cred_mgmt::CredentialManager::new(&mut dev, token.clone(), &info)?;
let metadata = mgr.metadata()?;
let parties = mgr.list_relying_parties()?;
let mut rps = Vec::with_capacity(parties.len());
for rp in parties {
let creds = match rp.rp_id_hash {
Some(hash) => mgr.list_credentials(&hash).unwrap_or_default(),
None => Vec::new(),
};
rps.push((rp, creds));
}
let fingerprints = if info.option("bioEnroll").is_some()
|| info.option("userVerificationMgmtPreview").is_some()
{
let cmd_code = if info.option("bioEnroll").is_some() {
keyroost_ctap::bio_enroll::CTAP2_BIO_ENROLLMENT
} else {
keyroost_ctap::bio_enroll::CTAP2_BIO_ENROLLMENT_PREVIEW
};
let mut bio =
keyroost_ctap::bio_enroll::BioEnrollment::new(&mut dev, token.clone(), cmd_code);
bio.enumerate().ok()
} else {
None
};
Ok(UnlockedSession {
token,
metadata,
rps: std::sync::Arc::new(rps),
fingerprints,
pin: zeroize::Zeroizing::new(pin.to_owned()),
})
}
fn fido_session(&self) -> Option<&UnlockedSession> {
if completion_still_valid(
self.security_keys.session_device.as_ref(),
self.selected_device.as_ref(),
) {
self.security_keys.session.as_ref()
} else {
None
}
}
fn lock_session(&mut self) {
self.security_keys.session = None;
self.security_keys.session_device = None;
wipe(&mut self.security_keys.pin_input);
}
fn fail_session_op(&mut self, err: &SessionOpError, context: &str) {
if err.relock {
self.lock_session();
self.security_keys.error = Some(
"the key re-locked (PIN or session changed) \u{2014} \
unlock again to continue."
.into(),
);
} else {
self.security_keys.error = Some(format!("{context}: {err}"));
}
}
fn refresh_credentials(&mut self) {
if self.busy() {
return;
}
let Some(target) = self.selected_fido_target() else {
return;
};
if self.fido_session().is_none() {
return; }
let Some(session) = self.security_keys.session.take() else {
return;
};
let token = session.token;
let pin = session.pin;
let for_device = self.selected_device.clone();
self.spawn_job("Refreshing credentials\u{2026}", move || {
let result =
Self::refresh_with_token(&target, token, pin).map_err(SessionOpError::from_boxed);
Box::new(move |app: &mut App| {
if !completion_still_valid(for_device.as_ref(), app.selected_device.as_ref()) {
return; }
match result {
Ok(fresh) => {
app.security_keys.session = Some(fresh);
app.security_keys.session_device = for_device.clone();
}
Err(e) => app.fail_session_op(&e, "refresh failed"),
}
})
});
}
fn refresh_with_token(
target: &FidoTarget,
token: PinUvAuthToken,
pin: zeroize::Zeroizing<String>,
) -> Result<UnlockedSession, Box<dyn std::error::Error>> {
let (mut dev, _cbor, _init) = open_fido(target)?;
let info = keyroost_ctap::get_info(&mut dev)?;
let mut mgr =
keyroost_ctap::cred_mgmt::CredentialManager::new(&mut dev, token.clone(), &info)?;
let metadata = mgr.metadata()?;
let parties = mgr.list_relying_parties()?;
let mut rps = Vec::with_capacity(parties.len());
for rp in parties {
let creds = match rp.rp_id_hash {
Some(hash) => mgr.list_credentials(&hash).unwrap_or_default(),
None => Vec::new(),
};
rps.push((rp, creds));
}
Ok(UnlockedSession {
token,
metadata,
rps: std::sync::Arc::new(rps),
fingerprints: None,
pin,
})
}
fn bio_cmd_code(&self) -> Option<u8> {
let info = self.security_keys.info.as_ref()?;
if info.option("bioEnroll").is_some() {
Some(keyroost_ctap::bio_enroll::CTAP2_BIO_ENROLLMENT)
} else if info.option("userVerificationMgmtPreview").is_some() {
Some(keyroost_ctap::bio_enroll::CTAP2_BIO_ENROLLMENT_PREVIEW)
} else {
None
}
}
fn with_fresh_bio<T>(
target: &FidoTarget,
pin: &str,
f: impl FnOnce(
&mut keyroost_ctap::bio_enroll::BioEnrollment<
Box<dyn keyroost_ctap::transport::CtapTransport>,
>,
) -> Result<T, SessionOpError>,
) -> Result<T, SessionOpError> {
let (mut dev, cbor, _init) = open_fido(target).map_err(SessionOpError::msg)?;
if !cbor {
return Err(SessionOpError::msg("device is U2F-only"));
}
let info = keyroost_ctap::get_info(&mut dev).map_err(SessionOpError::from_ctap)?;
let cmd_code = if info.option("bioEnroll").is_some() {
keyroost_ctap::bio_enroll::CTAP2_BIO_ENROLLMENT
} else if info.option("userVerificationMgmtPreview").is_some() {
keyroost_ctap::bio_enroll::CTAP2_BIO_ENROLLMENT_PREVIEW
} else {
return Err(SessionOpError::msg(
"key does not support fingerprint enrollment",
));
};
let token = keyroost_ctap::client_pin::get_pin_uv_auth_token(
&mut dev,
pin,
&info,
keyroost_ctap::client_pin::permissions::BIO_ENROLLMENT,
)
.map_err(SessionOpError::from_ctap)?;
let mut bio = keyroost_ctap::bio_enroll::BioEnrollment::new(&mut dev, token, cmd_code);
f(&mut bio)
}
fn refresh_fingerprints(&mut self) {
if self.busy() {
return;
}
let Some(target) = self.selected_fido_target() else {
return;
};
let Some(session) = self.fido_session() else {
self.security_keys.error = Some("unlock the key first".into());
return;
};
let pin = session.pin.clone();
let for_device = self.selected_device.clone();
self.spawn_job("Reading fingerprints\u{2026}", move || {
let result = Self::with_fresh_bio(&target, &pin, |bio| {
bio.enumerate().map_err(SessionOpError::from_ctap)
});
Box::new(move |app: &mut App| {
if !completion_still_valid(for_device.as_ref(), app.selected_device.as_ref()) {
return; }
match result {
Ok(list) => {
app.security_keys.fingerprints = Some(list);
app.security_keys.error = None;
}
Err(e) => app.fail_session_op(&e, "fingerprint list failed"),
}
})
});
}
fn enroll_fingerprint(&mut self) {
if self.busy() {
return;
}
let Some(target) = self.selected_fido_target() else {
return;
};
let Some(session) = self.fido_session() else {
self.security_keys.error = Some("unlock the key first".into());
return;
};
let pin = session.pin.clone();
let name = self.security_keys.fp_new_name.trim().to_owned();
let name = if name.is_empty() { None } else { Some(name) };
let progress = std::sync::Arc::new(std::sync::Mutex::new(EnrollProgress::default()));
let cancel_flag = progress
.lock()
.map(|g| g.cancel.clone())
.unwrap_or_default();
self.security_keys.fp_progress = Some(progress.clone());
let for_device = self.selected_device.clone();
self.spawn_job("Enrolling fingerprint\u{2026}", move || {
use keyroost_ctap::bio_enroll::sample_status_message;
let result = (|| -> Result<Vec<keyroost_ctap::Enrollment>, SessionOpError> {
let (mut dev, cbor, _init) = open_fido(&target).map_err(SessionOpError::msg)?;
if !cbor {
return Err(SessionOpError::msg("device is U2F-only"));
}
dev.set_cancel_flag(cancel_flag.clone());
let info = keyroost_ctap::get_info(&mut dev).map_err(SessionOpError::from_ctap)?;
let cmd_code = if info.option("bioEnroll").is_some() {
keyroost_ctap::bio_enroll::CTAP2_BIO_ENROLLMENT
} else {
keyroost_ctap::bio_enroll::CTAP2_BIO_ENROLLMENT_PREVIEW
};
let token = keyroost_ctap::client_pin::get_pin_uv_auth_token(
&mut dev,
&pin,
&info,
keyroost_ctap::client_pin::permissions::BIO_ENROLLMENT,
)
.map_err(SessionOpError::from_ctap)?;
let mut bio =
keyroost_ctap::bio_enroll::BioEnrollment::new(&mut dev, token, cmd_code);
let total = bio
.sensor_info()
.map(|i| i.max_capture_samples)
.unwrap_or(0);
let (template_id, mut status) =
bio.enroll_begin(None).map_err(SessionOpError::from_ctap)?;
let update = |p: &std::sync::Mutex<EnrollProgress>,
total: u64,
remaining: u64,
msg: &str| {
if let Ok(mut g) = p.lock() {
g.total = total;
g.captured = total.saturating_sub(remaining);
g.last_message = msg.to_string();
}
};
update(
&progress,
total.max(status.remaining_samples + 1),
status.remaining_samples,
sample_status_message(status.last_sample_status),
);
while status.remaining_samples > 0 {
match bio.enroll_capture_next(&template_id, None) {
Ok(s) => status = s,
Err(e) if e.to_string().contains("cancelled") => {
cancel_flag.store(false, std::sync::atomic::Ordering::Relaxed);
let _ = bio.cancel_enrollment();
return Err(SessionOpError::msg("cancelled"));
}
Err(e) => return Err(SessionOpError::from_ctap(e)),
}
let total = total.max(status.remaining_samples + 1);
update(
&progress,
total,
status.remaining_samples,
sample_status_message(status.last_sample_status),
);
}
if let Some(n) = &name {
bio.set_friendly_name(&template_id, n)
.map_err(SessionOpError::from_ctap)?;
}
bio.enumerate().map_err(SessionOpError::from_ctap)
})();
if let Ok(mut g) = progress.lock() {
g.done = Some(result.as_ref().map(|_| ()).map_err(|e| e.message.clone()));
if result.is_ok() {
g.last_message = "Fingerprint enrolled.".into();
g.captured = g.total;
}
}
Box::new(move |app: &mut App| {
if !completion_still_valid(for_device.as_ref(), app.selected_device.as_ref()) {
return; }
match result {
Ok(list) => {
app.security_keys.fingerprints = Some(list);
app.security_keys.fp_new_name.clear();
app.security_keys.error = None;
}
Err(e) if e.message == "cancelled" => {
app.security_keys.fp_progress = None;
app.security_keys.error = None;
app.refresh_fingerprints();
}
Err(e) => app.fail_session_op(&e, "enroll failed"),
}
})
});
}
fn delete_fingerprint(&mut self, template_id: Vec<u8>) {
if self.busy() {
return;
}
let Some(target) = self.selected_fido_target() else {
return;
};
let Some(session) = self.fido_session() else {
return;
};
let pin = session.pin.clone();
let for_device = self.selected_device.clone();
self.spawn_job("Deleting fingerprint\u{2026}", move || {
let result = Self::with_fresh_bio(&target, &pin, |bio| {
bio.remove_enrollment(&template_id)
.map_err(SessionOpError::from_ctap)?;
bio.enumerate().map_err(SessionOpError::from_ctap)
});
Box::new(move |app: &mut App| {
if !completion_still_valid(for_device.as_ref(), app.selected_device.as_ref()) {
return; }
match result {
Ok(list) => app.security_keys.fingerprints = Some(list),
Err(e) => app.fail_session_op(&e, "delete failed"),
}
})
});
}
fn rename_fingerprint(&mut self, template_id: Vec<u8>, new_name: String) {
if self.busy() {
return;
}
let Some(target) = self.selected_fido_target() else {
return;
};
let Some(session) = self.fido_session() else {
return;
};
let pin = session.pin.clone();
let for_device = self.selected_device.clone();
self.spawn_job("Renaming fingerprint\u{2026}", move || {
let result = Self::with_fresh_bio(&target, &pin, |bio| {
bio.set_friendly_name(&template_id, &new_name)
.map_err(SessionOpError::from_ctap)?;
bio.enumerate().map_err(SessionOpError::from_ctap)
});
Box::new(move |app: &mut App| {
if !completion_still_valid(for_device.as_ref(), app.selected_device.as_ref()) {
return; }
match result {
Ok(list) => app.security_keys.fingerprints = Some(list),
Err(e) => app.fail_session_op(&e, "rename failed"),
}
})
});
}
fn with_config<T>(
target: &FidoTarget,
pin: &str,
f: impl FnOnce(
&mut keyroost_ctap::config::Configurator<
Box<dyn keyroost_ctap::transport::CtapTransport>,
>,
) -> Result<T, SessionOpError>,
) -> Result<T, SessionOpError> {
let (mut dev, cbor, _init) = open_fido(target).map_err(SessionOpError::msg)?;
if !cbor {
return Err(SessionOpError::msg("device is U2F-only"));
}
let info = keyroost_ctap::get_info(&mut dev).map_err(SessionOpError::from_ctap)?;
if info.option("authnrCfg") != Some(true) {
return Err(SessionOpError::msg(
"this key does not support authenticatorConfig",
));
}
let token = keyroost_ctap::client_pin::get_pin_uv_auth_token(
&mut dev,
pin,
&info,
keyroost_ctap::client_pin::permissions::AUTHENTICATOR_CONFIGURATION,
)
.map_err(SessionOpError::from_ctap)?;
let mut cfg = keyroost_ctap::config::Configurator::new(&mut dev, token, &info)
.map_err(SessionOpError::from_ctap)?;
f(&mut cfg)
}
fn run_advanced_action(&mut self) {
if self.busy() {
return;
}
let Some(dlg) = self.security_keys.advanced.as_ref() else {
return;
};
let action = dlg.action;
let pin = zeroize::Zeroizing::new(dlg.pin_input.clone());
let force_change = dlg.force_change;
let min_pin = dlg.min_pin_input.trim().parse::<u32>().ok();
let dlg_device = dlg.device.clone();
if !completion_still_valid(dlg_device.as_ref(), self.selected_device.as_ref()) {
self.security_keys.advanced = None;
self.security_keys.error = Some(
"the selected key changed \u{2014} the settings change was cancelled; \
reopen it to retry"
.into(),
);
return;
}
let Some(target) = self.selected_fido_target() else {
return;
};
if action == AdvancedAction::SetMinPin && min_pin.is_none() {
self.security_keys.error = Some("Enter a whole number for the new minimum.".into());
return;
}
if pin.is_empty() {
self.security_keys.error = Some("Enter the device PIN to apply this change.".into());
return;
}
let label = match action {
AdvancedAction::ToggleAlwaysUv => "Updating always-UV\u{2026}",
AdvancedAction::SetMinPin => "Setting minimum PIN length\u{2026}",
AdvancedAction::ForcePinChange => "Requesting PIN change\u{2026}",
AdvancedAction::EnterpriseAttestation => "Enabling enterprise attestation\u{2026}",
AdvancedAction::None => return,
};
let for_device = self.selected_device.clone();
self.spawn_job(label, move || {
let result = Self::with_config(&target, &pin, |cfg| match action {
AdvancedAction::ToggleAlwaysUv => {
cfg.toggle_always_uv().map_err(SessionOpError::from_ctap)
}
AdvancedAction::SetMinPin => cfg
.set_min_pin_length(min_pin, &[], force_change)
.map_err(SessionOpError::from_ctap),
AdvancedAction::ForcePinChange => {
cfg.force_pin_change().map_err(SessionOpError::from_ctap)
}
AdvancedAction::EnterpriseAttestation => cfg
.enable_enterprise_attestation()
.map_err(SessionOpError::from_ctap),
AdvancedAction::None => Ok(()),
});
Box::new(move |app: &mut App| {
if !completion_still_valid(for_device.as_ref(), app.selected_device.as_ref()) {
return; }
match result {
Ok(()) => {
app.security_keys.advanced = None;
app.security_keys.error = None;
app.fetch_selected_info();
}
Err(e) => {
app.security_keys.advanced = None;
app.fail_session_op(&e, "config change failed");
}
}
})
});
}
fn delete_credential(&mut self, cred_id: Vec<u8>) {
let Some(target) = self.selected_fido_target() else {
return;
};
let Some(session) = self.fido_session() else {
return;
};
let token = session.token.clone();
let token_refresh = token.clone();
let pin_refresh = session.pin.clone();
let for_device = self.selected_device.clone();
self.spawn_job("Deleting credential\u{2026}", move || {
let result = Self::try_delete(&target, token, &cred_id)
.and_then(|()| Self::refresh_with_token(&target, token_refresh, pin_refresh))
.map_err(SessionOpError::from_boxed);
Box::new(move |app: &mut App| {
if !completion_still_valid(for_device.as_ref(), app.selected_device.as_ref()) {
return; }
match result {
Ok(fresh) => {
app.security_keys.session = Some(fresh);
app.security_keys.session_device = for_device.clone();
app.security_keys.error = None;
}
Err(e) => app.fail_session_op(&e, "delete failed"),
}
})
});
}
fn try_delete(
target: &FidoTarget,
token: PinUvAuthToken,
cred_id: &[u8],
) -> Result<(), Box<dyn std::error::Error>> {
let (mut dev, _cbor, _init) = open_fido(target)?;
let info = keyroost_ctap::get_info(&mut dev)?;
let mut mgr = keyroost_ctap::cred_mgmt::CredentialManager::new(&mut dev, token, &info)?;
mgr.delete(cred_id)?;
Ok(())
}
fn save_ssh_cert(&mut self, rp_id: String, large_blob_key: zeroize::Zeroizing<[u8; 32]>) {
let Some(target) = self.selected_fido_target() else {
self.security_keys.ssh_cert_status = Some("No FIDO key selected.".into());
return;
};
if self.fido_session().is_none() {
return;
}
let id = self.ssh_cert_next_id;
self.ssh_cert_next_id = self.ssh_cert_next_id.wrapping_add(1);
let for_device = self.selected_device.clone();
self.spawn_job("Extracting certificate\u{2026}", move || {
let result = Self::extract_ssh_cert(&target, &large_blob_key);
Box::new(move |app: &mut App| {
if !completion_still_valid(for_device.as_ref(), app.selected_device.as_ref()) {
return; }
match result {
Ok(pending) => {
app.security_keys.ssh_cert_pending.insert(id, pending);
app.security_keys.ssh_cert_status = None;
app.spawn_file_dialog(
FileTarget::SshCertSave(id),
true,
&[("SSH certificate", &["pub"]), ("All files", &["*"])],
Some(&keyroost_ctap::ssh_cert::default_cert_filename(&rp_id)),
);
}
Err(e) => app.security_keys.ssh_cert_status = Some(e),
}
})
});
}
fn extract_ssh_cert(
target: &FidoTarget,
large_blob_key: &[u8; 32],
) -> Result<PendingSshCert, String> {
let (mut dev, cbor, _init) = open_fido(target)?;
if !cbor {
return Err("device is U2F-only; large blobs are not supported".into());
}
let info = keyroost_ctap::get_info(&mut dev).map_err(|e| e.to_string())?;
if info.option("largeBlobs") != Some(true) {
return Err("this key does not support the FIDO2 large-blob store".into());
}
let array = keyroost_ctap::large_blobs::read(&mut dev, &info).map_err(|e| e.to_string())?;
let wire =
keyroost_ctap::large_blobs::extract_cert_from_entries(large_blob_key, &array.entries)
.ok_or("no certificate is stored for this credential")?;
let cert_pub = keyroost_ctap::ssh_cert::to_cert_pub(&wire)
.ok_or("stored blob is not a valid OpenSSH certificate")?;
let summary = keyroost_ctap::ssh_cert::parse_wire(&wire)
.map(|info| ssh_cert_summary(&info))
.unwrap_or_else(|| "OpenSSH certificate".to_string());
Ok(PendingSshCert { cert_pub, summary })
}
fn submit_change_pin(&mut self) {
if self.busy() {
return;
}
let Some(target) = self.selected_fido_target() else {
return;
};
let old = zeroize::Zeroizing::new(std::mem::take(&mut self.security_keys.change_pin.old));
let new = zeroize::Zeroizing::new(std::mem::take(&mut self.security_keys.change_pin.new));
let confirm =
zeroize::Zeroizing::new(std::mem::take(&mut self.security_keys.change_pin.confirm));
if old.is_empty() || new.is_empty() {
self.security_keys.error = Some("both PIN fields are required".into());
return;
}
if new.chars().count() < 4 {
self.security_keys.error = Some("PIN must be at least 4 characters".into());
return;
}
if *new != *confirm {
self.security_keys.error = Some("the two PINs don't match".into());
return;
}
self.spawn_job("Changing PIN\u{2026}", move || {
let result = Self::try_change_pin(&target, &old, &new).map_err(|e| e.to_string());
Box::new(move |app: &mut App| match result {
Ok(()) => {
app.security_keys.change_pin.open = false;
app.security_keys.error = None;
app.lock_session();
}
Err(e) => app.security_keys.error = Some(format!("change PIN failed: {}", e)),
})
});
}
fn try_change_pin(
target: &FidoTarget,
old: &str,
new: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let (mut dev, _cbor, _init) = open_fido(target)?;
keyroost_ctap::client_pin::change_pin(&mut dev, old, new)?;
Ok(())
}
fn submit_set_pin(&mut self) {
if self.busy() {
return;
}
let Some(target) = self.selected_fido_target() else {
return;
};
let new = zeroize::Zeroizing::new(std::mem::take(&mut self.security_keys.change_pin.new));
let confirm =
zeroize::Zeroizing::new(std::mem::take(&mut self.security_keys.change_pin.confirm));
if new.chars().count() < 4 {
self.security_keys.error = Some("PIN must be at least 4 characters".into());
return;
}
if *new != *confirm {
self.security_keys.error = Some("the two PINs don't match".into());
return;
}
self.spawn_job("Setting PIN\u{2026}", move || {
let result = (|| -> Result<(), String> {
let (mut dev, _cbor, _init) = open_fido(&target)?;
keyroost_ctap::client_pin::set_pin(&mut dev, &new).map_err(|e| e.to_string())
})();
Box::new(move |app: &mut App| match result {
Ok(()) => {
app.security_keys.change_pin = ChangePinDialog::default();
app.security_keys.error = None;
app.fetch_selected_info();
}
Err(e) => app.security_keys.error = Some(format!("set PIN failed: {e}")),
})
});
}
fn selected_fido_target(&self) -> Option<FidoTarget> {
let d = self.selected_device()?;
if let Some(p) = d.hid_path.clone() {
return Some(FidoTarget::Hid(p));
}
if d.caps.has(Caps::FIDO2) {
if let Some(r) = d.reader.clone() {
return Some(FidoTarget::Pcsc(r));
}
}
None
}
fn selected_fido_hid_path(&self) -> Option<std::path::PathBuf> {
self.selected_device().and_then(|d| d.hid_path.clone())
}
fn card_reset_reader(dev: Option<&keyroost_resolve::Device>) -> Option<String> {
let d = dev?;
if d.hid_path.is_none() && d.caps.has(Caps::FIDO2) {
d.reader.clone()
} else {
None
}
}
fn apply_reset_path_outcome(
app: &mut App,
for_device: Option<&DeviceId>,
result: Result<(), String>,
) {
if !completion_still_valid(for_device, app.selected_device.as_ref()) {
let note = match &result {
Ok(()) => "a security key finished its FIDO2 reset after the selection moved on \
\u{2014} its passkeys and PIN are wiped. The report on screen belongs \
to the key selected now, so it does not show that wipe."
.to_string(),
Err(e) => format!(
"a security key's FIDO2 reset failed after the selection moved on \
\u{2014} its passkeys and PIN are still on it: {e}"
),
};
app.log(Severity::Warn, note);
return;
}
match result {
Ok(()) => {
app.security_keys.session = None;
app.security_keys.info = None;
app.security_keys.error = None;
let outcome = keyroost_resolve::StepOutcome::Wiped;
if resolve_fido_reset_row(&mut app.factory_reset_report, outcome.clone()) {
let (severity, line) = factory_reset_fido_finale_log_line(&outcome);
app.log(severity, line);
}
app.fetch_selected_info();
}
Err(e) => {
let msg = if e.contains("NOT_ALLOWED") || e.contains("0x30") {
"This key refused the reset. Most security keys (YubiKey \
included) only allow a FIDO reset within about 10 seconds \
of being plugged in. Unplug the key, plug it back in, then \
click \u{201C}Reset key\u{201D} again right away."
.to_string()
} else {
format!("reset failed: {e}")
};
let outcome = keyroost_resolve::StepOutcome::Failed(msg.clone());
if resolve_fido_reset_row(&mut app.factory_reset_report, outcome.clone()) {
let (severity, line) = factory_reset_fido_finale_log_line(&outcome);
app.log(severity, line);
}
app.security_keys.error = Some(msg);
}
}
}
fn submit_reset_path(&mut self, path: std::path::PathBuf) -> bool {
let for_device = self.selected_device.clone();
self.spawn_job("Resetting key\u{2026} (touch now)", move || {
let result = (|| -> Result<(), String> {
let mut dev = None;
for attempt in 0..10 {
match CtapHidDevice::open(&path) {
Ok((d, _)) => {
dev = Some(d);
break;
}
Err(e) if attempt == 9 => return Err(e.to_string()),
Err(_) => std::thread::sleep(std::time::Duration::from_millis(100)),
}
}
let mut dev = dev.ok_or_else(|| "could not open key".to_string())?;
keyroost_ctap::reset(&mut dev).map_err(|e| e.to_string())
})();
Box::new(move |app: &mut App| {
App::apply_reset_path_outcome(app, for_device.as_ref(), result)
})
})
}
fn submit_card_reset(&mut self, reader: String) -> bool {
let for_device = self.selected_device.clone();
self.spawn_job("Resetting card\u{2026}", move || {
let result = (|| -> Result<(), String> {
let mut dev = keyroost_transport::CtapPcscDevice::open_after_power_cycle(&reader)
.map_err(|e| e.to_string())?;
keyroost_ctap::reset(&mut dev).map_err(|e| {
let s = e.to_string();
if s.contains("NOT_ALLOWED") || s.contains("0x30") {
"the card refused the reset even straight after a power \
cycle. Some cards only accept a FIDO reset over NFC \
(contactless) rather than a contact reader \u{2014} \
try a contactless reader or the vendor's mobile app."
.to_string()
} else {
s
}
})
})();
Box::new(move |app: &mut App| {
App::apply_reset_path_outcome(app, for_device.as_ref(), result)
})
})
}
fn selected_oath_reader(&self) -> Option<String> {
self.selected_device().and_then(|d| d.reader.clone())
}
fn oath_open_unlock(
name: &str,
password: &str,
) -> Result<keyroost_transport::OathSession, TransportError> {
let mut session = keyroost_transport::OathSession::open(name)?;
if session.password_required() {
session.unlock(password)?;
}
Ok(session)
}
fn oath_list_rows(
session: &mut keyroost_transport::OathSession,
) -> Result<(Vec<OathRow>, usize), TransportError> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let listing = session.list()?;
let mut rows = Vec::new();
for c in listing.credentials {
let hotp = matches!(c.oath_type, keyroost_oath::OathType::Hotp);
let code = if hotp {
None
} else {
session
.calculate_totp(&c.name, now, 30)
.ok()
.map(|otp| otp.code)
};
rows.push(OathRow {
name: c.name,
code,
hotp,
});
}
rows.sort_by_key(|r| r.hotp);
Ok((rows, listing.skipped))
}
fn read_hotp_code(&mut self, name: &str) {
self.oath.error = None;
let Some(reader) = self.selected_oath_reader() else {
self.oath.error = Some("no OATH key selected".into());
return;
};
let cred = name.to_owned();
let password = oath_effective_password(&self.oath);
let for_device = self.selected_device.clone();
self.spawn_job("Reading code\u{2026}", move || {
let result = (|| -> Result<String, TransportError> {
let mut session = Self::oath_open_unlock(&reader, &password)?;
Ok(session.calculate_hotp(&cred)?.code)
})();
Box::new(move |app: &mut App| {
if !completion_still_valid(for_device.as_ref(), app.selected_device.as_ref()) {
return; }
wipe(&mut app.oath.password_input);
match result {
Ok(code) => {
app.oath.unlocked_password = Some(password);
if let Some(row) = app.oath.creds.iter_mut().find(|r| r.name == cred) {
row.code = Some(code);
}
}
Err(TransportError::OathPasswordRejected) => {
app.oath.unlocked_password = None;
app.oath.locked = true;
app.oath.error = Some("wrong OATH password".into());
}
Err(e) => app.oath.error = Some(e.to_string()),
}
})
});
}
fn apply_oath_rows(
app: &mut App,
result: Result<(Vec<OathRow>, usize), TransportError>,
password: zeroize::Zeroizing<String>,
) {
wipe(&mut app.oath.password_input);
let modal_busy = app.oath.add.open && app.oath.add.busy;
match &result {
Ok(_) if modal_busy => app.oath.add.result = Some(Ok(())),
Err(TransportError::OathPasswordRejected) if modal_busy => {
app.oath.add.result = Some(Err("wrong OATH password".into()));
}
Err(e) if modal_busy => app.oath.add.result = Some(Err(e.to_string())),
_ => {}
}
if app.oath.add.open {
app.oath.add.busy = false;
}
match result {
Ok((rows, skipped)) => {
app.oath.unlocked_password = Some(password);
app.oath.creds = rows;
app.oath.skipped = skipped;
app.oath.loaded = true;
app.oath.locked = false;
}
Err(TransportError::OathPasswordRejected) => {
app.oath.unlocked_password = None;
app.oath.locked = true;
if !modal_busy {
app.oath.error = Some("wrong OATH password".into());
}
}
Err(e) => {
if !modal_busy {
app.oath.error = Some(e.to_string());
}
}
}
}
fn load_oath_creds(&mut self) {
self.oath.error = None;
let Some(name) = self.selected_oath_reader() else {
self.oath.error = Some("no OATH key selected".into());
return;
};
let password = oath_effective_password(&self.oath);
let for_device = self.selected_device.clone();
self.spawn_job("Reading OATH codes\u{2026}", move || {
let result = Self::oath_open_unlock(&name, &password)
.and_then(|mut session| Self::oath_list_rows(&mut session));
Box::new(move |app: &mut App| {
if completion_still_valid(for_device.as_ref(), app.selected_device.as_ref()) {
Self::apply_oath_rows(app, result, password);
}
})
});
}
fn provision_oath(&mut self) {
self.oath.error = None;
let Some(name) = self.selected_oath_reader() else {
self.oath.error = Some("no OATH key selected".into());
return;
};
let (cred_name, secret) = match self.oath.add.validate() {
Ok((n, s)) => (n, zeroize::Zeroizing::new(s)),
Err(e) => {
self.oath.error = Some(e);
return;
}
};
let oath_type = if self.oath.add.totp {
keyroost_oath::OathType::Totp
} else {
keyroost_oath::OathType::Hotp
};
let require_touch = self.oath.add.require_touch;
let password = oath_effective_password(&self.oath);
let for_device = self.selected_device.clone();
self.spawn_job("Adding credential\u{2026}", move || {
let result = (|| -> Result<(Vec<OathRow>, usize), TransportError> {
let mut session = Self::oath_open_unlock(&name, &password)?;
session.put(&keyroost_oath::PutParams {
name: &cred_name,
secret: &secret,
oath_type,
algorithm: keyroost_oath::Algorithm::Sha1,
digits: 6,
require_touch,
imf: 0,
})?;
Self::oath_list_rows(&mut session)
})();
Box::new(move |app: &mut App| {
if completion_still_valid(for_device.as_ref(), app.selected_device.as_ref()) {
Self::apply_oath_rows(app, result, password);
}
})
});
}
fn delete_oath(&mut self, name: &str) {
self.oath.error = None;
let Some(reader) = self.selected_oath_reader() else {
self.oath.error = Some("no OATH key selected".into());
return;
};
let cred_name = name.to_owned();
let password = oath_effective_password(&self.oath);
let for_device = self.selected_device.clone();
self.spawn_job("Deleting credential\u{2026}", move || {
let result = (|| -> Result<(Vec<OathRow>, usize), TransportError> {
let mut session = Self::oath_open_unlock(&reader, &password)?;
session.delete(&cred_name)?;
Self::oath_list_rows(&mut session)
})();
Box::new(move |app: &mut App| {
if completion_still_valid(for_device.as_ref(), app.selected_device.as_ref()) {
Self::apply_oath_rows(app, result, password);
}
})
});
}
fn oath_add_modal_close(&mut self) {
wipe(&mut self.oath.add.secret);
self.oath.add = OathAddDialog::default();
}
fn render_oath_add_modal(&mut self, ctx: &egui::Context, p: &Palette) {
if !self.oath.add.open {
return;
}
let busy = self.oath.add.busy;
let result = self.oath.add.result.clone();
let mut want_submit = false;
let mut want_close = false;
let closed = Self::modal_window(ctx, p, "oath_add", "New credential", 300.0, |ui| {
match &result {
Some(Ok(())) => {
ui.label(
egui::RichText::new("\u{2713} Credential added")
.font(theme::f_sb(13.0))
.color(p.ok),
);
ui.add_space(16.0);
if theme::button(ui, p, BtnKind::Primary, "Done").clicked() {
want_close = true;
}
}
_ => {
ui.horizontal(|ui| {
ui.add_sized(
[96.0, 22.0],
egui::Label::new(
egui::RichText::new("Name")
.font(theme::f_reg(13.0))
.color(p.txt2),
),
);
ui.add(
egui::TextEdit::singleline(&mut self.oath.add.name)
.hint_text("issuer:account")
.desired_width(300.0),
);
});
ui.add_space(4.0);
secret_field(
ui,
p,
"Secret",
&mut self.oath.add.secret,
"base32 (behind the QR code)",
96.0,
300.0,
);
#[cfg(feature = "qr")]
{
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.add_space(100.0);
if scan_qr_button(ui, p) {
self.oath_scan_qr();
}
});
}
ui.horizontal(|ui| {
ui.add_sized(
[96.0, 22.0],
egui::Label::new(
egui::RichText::new("Type")
.font(theme::f_reg(13.0))
.color(p.txt2),
),
);
ui.selectable_value(&mut self.oath.add.totp, true, "TOTP");
ui.selectable_value(&mut self.oath.add.totp, false, "HOTP");
});
ui.add_space(4.0);
ui.checkbox(&mut self.oath.add.require_touch, "Require touch");
card_note(
ui,
p,
"The secret is sent to the key, not written to disk by keyroost.",
);
if let Some(Err(e)) = &result {
ui.add_space(6.0);
ui.colored_label(p.err, e);
}
ui.add_space(16.0);
ui.horizontal(|ui| {
if busy {
ui.add(egui::Spinner::new());
ui.label(
egui::RichText::new("Adding credential\u{2026}")
.font(theme::f_reg(12.5))
.color(p.txt2),
);
} else {
if theme::button(ui, p, BtnKind::Primary, "Add").clicked() {
want_submit = true;
}
ui.add_space(8.0);
if theme::button(ui, p, BtnKind::Default, "Cancel").clicked() {
want_close = true;
}
}
});
}
}
});
if (closed || want_close) && !busy {
self.oath_add_modal_close();
return;
}
if want_submit && !busy {
self.oath.add.busy = true;
self.oath.add.result = None;
self.oath.error = None;
self.provision_oath();
if !self.busy() {
let guard_err = self.oath.error.take();
self.oath.add.busy = false;
if let Some(e) = guard_err {
self.oath.add.result = Some(Err(e));
}
}
}
}
fn render_oath_delete_confirm(&mut self, ctx: &egui::Context) {
let Some((for_device, name)) = self.oath.confirm_delete.clone() else {
return;
};
if !completion_still_valid(Some(&for_device), self.selected_device.as_ref()) {
self.oath.confirm_delete = None;
return;
}
let mut decision: Option<bool> = None;
egui::Window::new("Delete credential?")
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.show(ctx, |ui| {
ui.label(format!(
"Permanently delete \u{201c}{name}\u{201d} from this key? \
This cannot be undone."
));
ui.add_space(6.0);
ui.horizontal(|ui| {
if ui.button("Delete").clicked() {
decision = Some(true);
}
if ui.button("Cancel").clicked() {
decision = Some(false);
}
});
});
match decision {
Some(true) => {
self.oath.confirm_delete = None;
self.delete_oath(&name);
}
Some(false) => self.oath.confirm_delete = None,
None => {}
}
}
fn render_oath_reset_confirm(&mut self, ctx: &egui::Context) {
let Some(for_device) = self.oath.confirm_reset.clone() else {
return;
};
if !completion_still_valid(Some(&for_device), self.selected_device.as_ref()) {
self.oath.confirm_reset = None;
return;
}
let mut decision: Option<bool> = None;
egui::Window::new("Reset OATH applet?")
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.show(ctx, |ui| {
ui.label(
"Permanently wipe EVERY authenticator credential on this key \
and clear its password? This cannot be undone.",
);
ui.add_space(6.0);
ui.horizontal(|ui| {
if ui.button("Reset applet").clicked() {
decision = Some(true);
}
if ui.button("Cancel").clicked() {
decision = Some(false);
}
});
});
match decision {
Some(true) => {
self.oath.confirm_reset = None;
self.reset_oath_applet();
}
Some(false) => self.oath.confirm_reset = None,
None => {}
}
}
fn reset_oath_applet(&mut self) {
self.oath.error = None;
let Some(reader) = self.selected_oath_reader() else {
self.oath.error = Some("no OATH key selected".into());
return;
};
let for_device = self.selected_device.clone();
self.spawn_job("Resetting OATH applet\u{2026}", move || {
let result = (|| -> Result<(), TransportError> {
let mut session = keyroost_transport::OathSession::open(&reader)?;
session.factory_reset()
})();
Box::new(move |app: &mut App| {
if !completion_still_valid(for_device.as_ref(), app.selected_device.as_ref()) {
return;
}
match result {
Ok(()) => {
app.oath = OathState::default();
app.oath_tried = false;
}
Err(e) => app.oath.error = Some(format!("reset failed: {e}")),
}
})
});
}
fn start_factory_reset_confirm(&mut self) {
let Some(dev) = self.selected_device().cloned() else {
return;
};
self.factory_reset_confirm_typed.clear();
let plan = keyroost_resolve::factory_reset_plan(dev.caps);
if !plan.contains(&keyroost_resolve::ResetStep::Piv) {
self.factory_reset_confirm = Some(FactoryResetConfirmState {
for_device: dev.id,
piv_preview: FactoryResetPivPreview::NotOffered,
needs_reset_mgmt_auth: false,
default_mgmt_key: None,
reset_long_running: false,
});
return;
}
let Some(reader) = dev.reader.clone() else {
self.factory_reset_confirm = Some(FactoryResetConfirmState {
for_device: dev.id,
piv_preview: FactoryResetPivPreview::CheckFailed,
needs_reset_mgmt_auth: false,
default_mgmt_key: None,
reset_long_running: false,
});
return;
};
let for_device = dev.id;
self.spawn_job("Checking PIV reset support\u{2026}", move || {
let (preview, needs_reset_mgmt_auth, default_mgmt_key, reset_long_running) =
keyroost_transport::PivSession::with_transaction(&reader, |s| {
let preview = FactoryResetPivPreview::Resolved(s.preview_factory_reset());
Ok::<_, TransportError>((
preview,
s.global_reset_available(),
s.default_management_key(),
s.quirks()
.contains(&keyroost_piv::compat::PivQuirk::ResetLongRunning),
))
})
.unwrap_or((
FactoryResetPivPreview::CheckFailed,
false,
None,
false,
));
Box::new(move |app: &mut App| {
if !completion_still_valid(Some(&for_device), app.selected_device.as_ref()) {
return;
}
app.factory_reset_confirm = Some(FactoryResetConfirmState {
for_device,
piv_preview: preview,
needs_reset_mgmt_auth,
default_mgmt_key,
reset_long_running,
});
})
});
}
fn render_factory_reset_confirm(&mut self, ctx: &egui::Context, p: &Palette) {
let Some(FactoryResetConfirmState {
for_device,
piv_preview,
needs_reset_mgmt_auth,
default_mgmt_key,
reset_long_running,
}) = self.factory_reset_confirm.clone()
else {
return;
};
if !completion_still_valid(Some(&for_device), self.selected_device.as_ref()) {
self.factory_reset_confirm = None;
self.factory_reset_confirm_error = None;
self.factory_reset_confirm_typed.clear();
return;
}
let summary = match self.selected_device() {
Some(dev) => {
let mut plan = keyroost_resolve::factory_reset_plan(dev.caps);
if factory_reset_piv_unresettable(piv_preview) {
keyroost_resolve::exclude_unresettable_piv(&mut plan);
}
factory_reset_confirm_summary(
&dev.serial,
&dev.model,
&plan,
piv_preview,
needs_reset_mgmt_auth,
reset_long_running,
)
}
None => {
self.factory_reset_confirm = None;
self.factory_reset_confirm_error = None;
self.factory_reset_confirm_typed.clear();
return;
}
};
let mut decision: Option<bool> = None;
egui::Window::new("Factory reset this key?")
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.show(ctx, |ui| {
ui.label(summary);
if needs_reset_mgmt_auth {
ui.add_space(8.0);
self.factory_reset_mgmt_auth_field(ui, p, default_mgmt_key);
}
if let Some(err) = &self.factory_reset_confirm_error {
ui.add_space(6.0);
ui.colored_label(p.err, err);
}
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label("Type \u{201c}reset\u{201d} to confirm:");
ui.add(
egui::TextEdit::singleline(&mut self.factory_reset_confirm_typed)
.desired_width(120.0),
);
});
ui.add_space(8.0);
let armed = self.factory_reset_confirm_typed.trim() == "reset";
ui.horizontal(|ui| {
ui.add_enabled_ui(armed, |ui| {
if theme::button(ui, p, BtnKind::Danger, "Yes, wipe this key").clicked() {
decision = Some(true);
}
});
if ui.button("Cancel").clicked() {
decision = Some(false);
}
});
});
match decision {
Some(true) => {
if self.run_factory_reset_gui() {
self.factory_reset_confirm = None;
self.factory_reset_confirm_error = None;
self.factory_reset_confirm_typed.clear();
}
}
Some(false) => {
self.factory_reset_confirm = None;
self.factory_reset_confirm_error = None;
self.factory_reset_confirm_typed.clear();
wipe(&mut self.reset_mgmt_auth_input);
}
None => {}
}
}
fn factory_reset_mgmt_auth_field(
&mut self,
ui: &mut egui::Ui,
p: &Palette,
default_key: Option<&'static [u8]>,
) {
if default_key.is_none() && self.reset_mgmt_auth_mode == ResetMgmtAuthMode::Default {
self.reset_mgmt_auth_mode = ResetMgmtAuthMode::Manual;
}
ui.horizontal(|ui| {
let mut is_default = self.reset_mgmt_auth_mode == ResetMgmtAuthMode::Default;
let mut resp = ui.add_enabled(
default_key.is_some(),
egui::Checkbox::new(&mut is_default, "Use default management key"),
);
if let Some(key) = default_key {
resp = resp.on_hover_text(format!("Default management key: {}", hex_lower(key)));
}
if resp.changed() {
self.reset_mgmt_auth_mode =
reset_mgmt_auth_mode_after_toggle(is_default, ResetMgmtAuthMode::Default);
}
let mut is_pin = self.reset_mgmt_auth_mode == ResetMgmtAuthMode::Pin;
if ui.checkbox(&mut is_pin, "Use PIN").changed() {
self.reset_mgmt_auth_mode =
reset_mgmt_auth_mode_after_toggle(is_pin, ResetMgmtAuthMode::Pin);
}
});
if self.reset_mgmt_auth_mode == ResetMgmtAuthMode::Default {
return;
}
let use_pin = self.reset_mgmt_auth_mode == ResetMgmtAuthMode::Pin;
let (label, hint) = if use_pin {
("PIN", "PIN")
} else {
("Management key", "hex (16/32/48 chars)")
};
let mut reset_mgmt_rev = self
.secret_reveal
.get("reset-mgmt-auth")
.copied()
.unwrap_or(false);
piv_secret_field(
ui,
p,
label,
&mut self.reset_mgmt_auth_input,
&mut reset_mgmt_rev,
hint,
96.0,
300.0,
);
self.secret_reveal.insert("reset-mgmt-auth", reset_mgmt_rev);
}
fn reset_mgmt_current_auth(&self) -> Result<ResetMgmtAuth, String> {
match self.reset_mgmt_auth_mode {
ResetMgmtAuthMode::Default => self
.factory_reset_confirm
.as_ref()
.and_then(|c| c.default_mgmt_key)
.map(|key| ResetMgmtAuth::Key(zeroize::Zeroizing::new(key.to_vec())))
.ok_or_else(|| "This device has no known default management key.".to_string()),
ResetMgmtAuthMode::Pin => Ok(ResetMgmtAuth::Pin(zeroize::Zeroizing::new(
self.reset_mgmt_auth_input.clone(),
))),
ResetMgmtAuthMode::Manual => {
piv_mgmt_key_bytes(&self.reset_mgmt_auth_input).map(ResetMgmtAuth::Key)
}
}
}
fn apply_factory_reset_sweep(
app: &mut App,
for_device: Option<&DeviceId>,
reports: Vec<keyroost_resolve::StepReport>,
ends_in_fido: bool,
) {
use keyroost_resolve::{ResetStep, StepOutcome};
if !completion_still_valid(for_device, app.selected_device.as_ref()) {
let fido_txt = if ends_in_fido {
format!(
" {} was left un-wiped \u{2014} the replug-and-touch ceremony never started.",
ResetStep::Fido.label()
)
} else {
String::new()
};
app.log_global(
Severity::Warn,
LogKind::User,
format!(
"a factory reset finished after the selection moved on \u{2014} {}.{fido_txt} \
Re-select that key to see its current state and finish the reset.",
factory_reset_report_summary(&reports)
),
);
return;
}
for r in &reports {
if matches!(
r.outcome,
StepOutcome::Wiped | StepOutcome::WipedGlobal | StepOutcome::WipedWithWarning(_)
) {
match r.step {
ResetStep::Oath => {
app.oath = OathState::default();
app.oath_tried = false;
}
ResetStep::OpenPgp => {
app.openpgp = OpenPgpState::default();
}
ResetStep::Piv => {
app.piv = PivState::default();
app.piv_tried = false;
}
ResetStep::Token2Otp => {
app.otp = OtpState::default();
app.otp_tried = false;
}
ResetStep::Fido => {}
}
}
}
let severity = if reports
.iter()
.all(|r| matches!(r.outcome, StepOutcome::Wiped | StepOutcome::WipedGlobal))
{
Severity::Ok
} else {
Severity::Warn
};
app.log(
severity,
format!(
"factory reset finished \u{2014} {}",
factory_reset_report_summary(&reports)
),
);
app.factory_reset_report = reports.into_iter().map(FactoryResetRow::Step).collect();
if ends_in_fido {
app.factory_reset_report.push(FactoryResetRow::FidoPending);
app.security_keys.reset = ResetDialog {
open: true,
..Default::default()
};
}
}
fn run_factory_reset_gui(&mut self) -> bool {
use keyroost_resolve::{ResetStep, StepReport};
let Some(dev) = self.selected_device().cloned() else {
return false;
};
let mut plan = keyroost_resolve::factory_reset_plan(dev.caps);
let piv_preview = self
.factory_reset_confirm
.as_ref()
.map(|c| c.piv_preview)
.unwrap_or(FactoryResetPivPreview::NotOffered);
if factory_reset_piv_unresettable(piv_preview) {
keyroost_resolve::exclude_unresettable_piv(&mut plan);
}
if plan.is_empty() {
return false;
}
let needs_reset_mgmt_auth = self
.factory_reset_confirm
.as_ref()
.is_some_and(|c| c.needs_reset_mgmt_auth);
let reset_mgmt_auth = if needs_reset_mgmt_auth {
match self.reset_mgmt_current_auth() {
Ok(auth) => Some(auth),
Err(e) => {
self.factory_reset_confirm_error = Some(e);
return false;
}
}
} else {
None
};
self.factory_reset_confirm_error = None;
let reader = dev.reader.clone();
let hid_path = dev.hid_path.clone();
let for_device = self.selected_device.clone();
let ends_in_fido = plan.last() == Some(&ResetStep::Fido);
let card_steps: Vec<ResetStep> = plan
.iter()
.copied()
.filter(|s| *s != ResetStep::Fido)
.collect();
self.factory_reset_report.clear();
self.spawn_job("Factory-resetting key\u{2026}", move || {
let mut reports: Vec<StepReport> = Vec::new();
for step in card_steps {
let outcome = run_card_reset_step(
step,
reader.as_deref(),
hid_path.as_deref(),
reset_mgmt_auth.as_ref(),
);
reports.push(StepReport { step, outcome });
}
Box::new(move |app: &mut App| {
wipe(&mut app.reset_mgmt_auth_input);
App::apply_factory_reset_sweep(app, for_device.as_ref(), reports, ends_in_fido)
})
})
}
fn render_reset_dialog(&mut self, ctx: &egui::Context) {
if !self.security_keys.reset.open {
return;
}
let label = self
.selected_device()
.map(|d| match &d.name {
Some(name) => name.clone(),
None => format!("{} {}", d.vendor, d.model).trim().to_string(),
})
.unwrap_or_else(|| "this key".into());
let has_large_blobs = self
.security_keys
.info
.as_ref()
.and_then(|i| i.option("largeBlobs"))
== Some(true);
let mut window_open = true;
let mut arm = false;
let mut cancel = false;
let card_reader = Self::card_reset_reader(self.selected_device());
let waiting = self.reset_arm.as_ref().is_some_and(|arm| {
completion_still_valid(arm.for_device.as_ref(), self.selected_device.as_ref())
});
egui::Window::new("Reset FIDO security key?")
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.open(&mut window_open)
.show(ctx, |ui| {
ui.colored_label(
egui::Color32::from_rgb(220, 110, 110),
format!("This wipes ALL credentials and the PIN on {label}."),
);
ui.label("This cannot be undone.");
if has_large_blobs {
ui.add_space(6.0);
let muted = egui::Color32::from_rgb(150, 150, 150);
ui.colored_label(
muted,
"Note: a reset does not erase large-blob storage. Open the Storage tab",
);
ui.colored_label(
muted,
"and use \u{201c}Clear all storage\u{201d} to wipe every entry there.",
);
}
ui.add_space(6.0);
if waiting {
ui.add_space(2.0);
ui.horizontal(|ui| {
ui.spinner();
ui.label(
egui::RichText::new("Now unplug the key, then plug it back in.")
.strong(),
);
});
ui.label("The reset is sent the instant it reconnects \u{2014} then the");
ui.label("key will blink. Touch it to finish the wipe.");
ui.add_space(6.0);
if ui.button("Cancel").clicked() {
cancel = true;
}
} else {
if card_reader.is_some() {
ui.label("A key only accepts a reset just after power-up. This one");
ui.label("sits in a card reader, so there is nothing to unplug \u{2014}");
ui.label("the card is power-cycled in place and the wipe sent");
ui.label("immediately. Keep the card on the reader.");
} else {
ui.label("A key only accepts a reset within ~10 seconds of being");
ui.label("plugged in, so after you confirm you'll re-insert it.");
}
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label("Type \u{201c}reset\u{201d} to confirm:");
ui.add(
egui::TextEdit::singleline(&mut self.security_keys.reset.confirm_input)
.desired_width(120.0),
);
});
ui.add_space(6.0);
let ready = self.security_keys.reset.confirm_input.trim() == "reset";
let go_label = if card_reader.is_some() {
"Reset card"
} else {
"Arm reset"
};
ui.horizontal(|ui| {
if ui.add_enabled(ready, egui::Button::new(go_label)).clicked() {
arm = true;
}
if ui.button("Cancel").clicked() {
cancel = true;
}
});
}
});
if arm {
if let Some(reader) = card_reader {
self.security_keys.reset = ResetDialog::default();
if !self.submit_card_reset(reader) {
let msg = "not started \u{2014} another device operation is in \
progress. Retry from the FIDO2 pane once it finishes; \
the card was not touched."
.to_string();
let outcome = keyroost_resolve::StepOutcome::Failed(msg.clone());
if resolve_pending_fido_reset_row(
&mut self.factory_reset_report,
outcome.clone(),
) {
let (severity, line) = factory_reset_fido_finale_log_line(&outcome);
self.log(severity, line);
}
self.security_keys.error = Some(msg);
}
} else {
self.arm_fido_reset();
}
} else if cancel || !window_open {
self.security_keys.reset = ResetDialog::default();
self.reset_arm = None;
let outcome = keyroost_resolve::StepOutcome::Failed(
"cancelled \u{2014} the passkeys and PIN are still on this key".into(),
);
if resolve_pending_fido_reset_row(&mut self.factory_reset_report, outcome.clone()) {
let (severity, line) = factory_reset_fido_finale_log_line(&outcome);
self.log(severity, line);
}
}
}
fn arm_fido_reset(&mut self) {
self.arm_fido_reset_with(Self::fido_devices());
}
fn arm_fido_reset_with(&mut self, devices: Vec<FidoHid>) {
let target_path = self.selected_fido_hid_path();
let target = target_path
.as_ref()
.and_then(|p| devices.iter().find(|d| &d.path == p));
let target_serial = target.and_then(|d| d.serial.clone());
let target_ids = target.map(|d| d.ids);
let target_effective_serial = self
.selected_device()
.map(|d| d.serial.clone())
.filter(|s| !s.is_empty());
if target_serial.is_none() && target_effective_serial.is_none() {
self.security_keys.reset = ResetDialog::default();
self.reset_arm = None;
let msg = "not armed \u{2014} this key exposes no serial to re-identify it by after \
a replug, so the armed reset can't safely target it. Run `keyroostctl \
fido reset --yes` within ~10 s of plugging the key in instead. If this \
is a card reached through a virtual FIDO device, skip the bridge: \
select the card's reader row and reset there \u{2014} a card resets in \
place, no replug."
.to_string();
self.log(Severity::Err, msg.clone());
let outcome = keyroost_resolve::StepOutcome::Failed(msg.clone());
if resolve_pending_fido_reset_row(&mut self.factory_reset_report, outcome.clone()) {
let (severity, line) = factory_reset_fido_finale_log_line(&outcome);
self.log(severity, line);
}
self.security_keys.error = Some(msg);
} else {
self.reset_arm = Some(ResetArm {
target_serial,
target_path,
target_ids,
target_effective_serial,
for_device: self.selected_device.clone(),
prev_devices: devices.iter().map(HidIdentity::of).collect(),
saw_removal: false,
});
}
}
fn fido_devices() -> Vec<FidoHid> {
keyroost_hid::enumerate()
.unwrap_or_default()
.into_iter()
.filter(HidDevice::is_fido)
.map(|h| FidoHid {
path: h.path,
serial: h.serial_number,
ids: (h.vendor_id, h.product_id),
usb_bus: h.usb_bus,
usb_address: h.usb_address,
})
.collect()
}
fn fido_effective_serial(path: &std::path::Path) -> Option<String> {
keyroost_resolve::enumerate()
.unwrap_or_default()
.into_iter()
.find(|d| d.hid_path.as_deref() == Some(path))
.map(|d| d.serial)
.filter(|s| !s.is_empty())
}
fn poll_reset_arm(&mut self) {
if !self.reset_arm.as_ref().is_some_and(|arm| {
completion_still_valid(arm.for_device.as_ref(), self.selected_device.as_ref())
}) {
if self.reset_arm.take().is_some() {
self.log_global(
Severity::Warn,
LogKind::User,
"the armed reset was cancelled \u{2014} the key it was armed for is no longer \
the one selected. Re-open \u{201C}Reset key\u{201D} on the key you want to \
wipe.",
);
}
return;
}
let current = Self::fido_devices();
let mut fire: Option<std::path::PathBuf> = None;
if let Some(arm) = self.reset_arm.as_mut() {
if let Some(target_serial) = arm.target_serial.clone() {
let here = current
.iter()
.find(|d| d.serial.as_deref() == Some(target_serial.as_str()));
match here {
None => arm.saw_removal = true, Some(d) if arm.saw_removal => fire = Some(d.path.clone()),
Some(_) => {}
}
} else {
let present = |p: &std::path::PathBuf| current.iter().any(|d| &d.path == p);
match &arm.target_path {
Some(t) if !present(t) => arm.saw_removal = true,
_ => {}
}
if arm.saw_removal {
fire = current
.iter()
.filter(|d| arm.target_ids.is_none() || arm.target_ids == Some(d.ids))
.filter(|d| {
let id = HidIdentity::of(d);
!arm.prev_devices.iter().any(|p| same_hid_connection(p, &id))
})
.find(|d| {
let cand_eff = Self::fido_effective_serial(&d.path);
reset_reinsert_matches(
arm.target_serial.as_deref(),
arm.target_effective_serial.as_deref(),
arm.target_ids,
d.serial.as_deref(),
cand_eff.as_deref(),
d.ids,
)
})
.map(|d| d.path.clone());
}
}
}
match fire {
Some(path) => {
if self.submit_reset_path(path) {
self.reset_arm = None;
self.security_keys.reset = ResetDialog::default();
}
}
None => {
if let Some(arm) = self.reset_arm.as_mut() {
if !arm.saw_removal {
arm.prev_devices = current.iter().map(HidIdentity::of).collect();
}
}
}
}
}
fn selected_openpgp_reader(&self) -> Option<String> {
self.selected_device().and_then(|d| d.reader.clone())
}
fn load_openpgp_status(&mut self) {
self.openpgp.error = None;
let Some(name) = self.selected_openpgp_reader() else {
self.openpgp.error = Some("no OpenPGP card selected".into());
return;
};
let for_device = self.selected_device.clone();
self.spawn_job("Reading OpenPGP status\u{2026}", move || {
let result = (|| -> Result<
(
keyroost_transport::OpenPgpStatus,
Option<keyroost_transport::AlgorithmInformation>,
),
TransportError,
> {
let mut session = keyroost_transport::OpenPgpSession::open(&name)?;
let status = session.status()?;
let supported = session.supported_algorithms().unwrap_or(None);
Ok((status, supported))
})();
Box::new(move |app: &mut App| {
if !completion_still_valid(for_device.as_ref(), app.selected_device.as_ref()) {
return; }
match result {
Ok((status, supported)) => {
app.openpgp.status = Some(status);
app.openpgp.supported = supported;
app.openpgp.loaded = true;
}
Err(e) => app.openpgp.error = Some(e.to_string()),
}
})
});
}
fn openpgp_open_admin(
name: &str,
admin_pin: &str,
) -> Result<keyroost_transport::OpenPgpSession, TransportError> {
let mut session = keyroost_transport::OpenPgpSession::open(name)?;
session.verify_admin_pin(admin_pin.as_bytes())?;
Ok(session)
}
fn apply_openpgp_write(
app: &mut App,
result: Result<keyroost_transport::OpenPgpStatus, TransportError>,
notice: String,
) {
match result {
Ok(status) => {
app.openpgp.status = Some(status);
app.openpgp.loaded = true;
app.openpgp.error = None;
app.openpgp.notice = Some(notice);
wipe(&mut app.openpgp.admin_pin);
}
Err(e) => {
app.openpgp.error = Some(e.to_string());
wipe(&mut app.openpgp.admin_pin);
}
}
Self::apply_openpgp_cred_result(app);
}
fn apply_openpgp_cred_result(app: &mut App) {
if let Some(m) = app.openpgp.cred_modal.as_mut() {
m.busy = false;
m.result = match &app.openpgp.error {
Some(e) => Some(Err(e.clone())),
None => Some(Ok(())),
};
}
}
fn openpgp_admin_pin_value(&self) -> String {
if self.openpgp.use_default_admin {
OPENPGP_DEFAULT_ADMIN_PIN.to_string()
} else {
self.openpgp.admin_pin.clone()
}
}
fn set_openpgp_name(&mut self) {
let Some(name) = self.selected_openpgp_reader() else {
return;
};
let pin = zeroize::Zeroizing::new(self.openpgp_admin_pin_value());
let value = self.openpgp.name_input.clone();
self.openpgp.notice = None;
self.spawn_job("Setting cardholder name…", move || {
let result = (|| -> Result<keyroost_transport::OpenPgpStatus, TransportError> {
let mut s = Self::openpgp_open_admin(&name, &pin)?;
s.set_cardholder_name(value.as_bytes())?;
s.status()
})();
Box::new(move |app: &mut App| {
Self::apply_openpgp_write(app, result, "Cardholder name set.".into())
})
});
}
fn set_openpgp_url(&mut self) {
let Some(name) = self.selected_openpgp_reader() else {
return;
};
let pin = zeroize::Zeroizing::new(self.openpgp_admin_pin_value());
let value = self.openpgp.url_input.clone();
self.openpgp.notice = None;
self.spawn_job("Setting public-key URL…", move || {
let result = (|| -> Result<keyroost_transport::OpenPgpStatus, TransportError> {
let mut s = Self::openpgp_open_admin(&name, &pin)?;
s.set_url(value.as_bytes())?;
s.status()
})();
Box::new(move |app: &mut App| {
Self::apply_openpgp_write(app, result, "Public-key URL set.".into())
})
});
}
fn generate_openpgp_key(&mut self) -> bool {
let Some(name) = self.selected_openpgp_reader() else {
return true; };
let pin = zeroize::Zeroizing::new(self.openpgp_admin_pin_value());
let slot = self.openpgp.selected_key;
let alg = self.openpgp.gen_alg.to_alg();
let creation_time = unix_now();
self.openpgp.notice = None;
self.spawn_job("Generating key… (touch the key if it blinks)", move || {
let result = (|| -> Result<(keyroost_transport::OpenPgpStatus, [u8; 20]), TransportError> {
let mut s = Self::openpgp_open_admin(&name, &pin)?;
let _ = s.generate_key(slot.to_crt(), alg)?;
let fpr = s.register_key(slot.to_crt(), creation_time)?;
Ok((s.status()?, fpr))
})();
Box::new(move |app: &mut App| {
match result {
Ok((status, fpr)) => {
app.openpgp.status = Some(status);
app.openpgp.loaded = true;
app.openpgp.error = None;
app.openpgp.notice =
Some(format!("Generated {} key: {}", slot.label(), hex_lower(&fpr)));
wipe(&mut app.openpgp.admin_pin);
}
Err(e) => {
app.openpgp.error = Some(e.to_string());
wipe(&mut app.openpgp.admin_pin);
}
}
Self::apply_openpgp_cred_result(app);
})
})
}
fn import_openpgp_key(&mut self, source: ImportSource) -> bool {
let Some(name) = self.selected_openpgp_reader() else {
return true; };
let pin = zeroize::Zeroizing::new(self.openpgp_admin_pin_value());
let slot = self.openpgp.selected_key;
let path = self.openpgp.import_path.clone();
let creation_time = unix_now();
self.openpgp.notice = None;
let label = match source {
ImportSource::Generate => "Generating & importing RSA-2048 key… (touch if it blinks)",
ImportSource::FromFile => "Importing RSA key from file… (touch if it blinks)",
};
self.spawn_job(label, move || {
let result = (|| -> Result<(keyroost_transport::OpenPgpStatus, [u8; 20]), String> {
let k = match source {
ImportSource::Generate => keyroost_rsakey::generate_2048(),
ImportSource::FromFile => {
keyroost_rsakey::load_from_file(std::path::Path::new(&path))
}
}
.map_err(|e| e.to_string())?;
let mut s = Self::openpgp_open_admin(&name, &pin).map_err(|e| e.to_string())?;
let parts = keyroost_transport::RsaPrivateKeyParts {
e: &k.e,
p: &k.p,
q: &k.q,
u: &k.u,
dp: &k.dp,
dq: &k.dq,
n: &k.n,
};
s.import_key(slot.to_crt(), &parts)
.map_err(|e| e.to_string())?;
let fpr = s
.register_key(slot.to_crt(), creation_time)
.map_err(|e| e.to_string())?;
let status = s.status().map_err(|e| e.to_string())?;
Ok((status, fpr))
})();
Box::new(move |app: &mut App| {
match result {
Ok((status, fpr)) => {
app.openpgp.status = Some(status);
app.openpgp.loaded = true;
app.openpgp.error = None;
app.openpgp.notice = Some(format!(
"Imported {} key: {}",
slot.label(),
hex_lower(&fpr)
));
wipe(&mut app.openpgp.admin_pin);
app.openpgp.import_path.clear();
}
Err(e) => {
app.openpgp.error = Some(e);
wipe(&mut app.openpgp.admin_pin);
}
}
Self::apply_openpgp_cred_result(app);
})
})
}
fn reset_openpgp(&mut self) -> bool {
let Some(name) = self.selected_openpgp_reader() else {
return true; };
self.openpgp.notice = None;
let for_device = self.selected_device.clone();
self.spawn_job("Resetting OpenPGP applet…", move || {
let result = (|| -> Result<keyroost_transport::OpenPgpStatus, TransportError> {
let mut s = keyroost_transport::OpenPgpSession::open(&name)?;
s.factory_reset()?;
s.status()
})();
Box::new(move |app: &mut App| {
if !completion_still_valid(for_device.as_ref(), app.selected_device.as_ref()) {
return; }
Self::apply_openpgp_write(
app,
result,
"OpenPGP applet reset; keys wiped, PINs back to defaults.".into(),
)
})
})
}
fn change_openpgp_user_pin(&mut self) {
let Some(name) = self.selected_openpgp_reader() else {
return;
};
let old = zeroize::Zeroizing::new(self.openpgp.user_pin_old.clone());
let new = zeroize::Zeroizing::new(self.openpgp.user_pin_new.clone());
self.openpgp.notice = None;
self.spawn_job("Changing user PIN\u{2026}", move || {
let result = (|| -> Result<keyroost_transport::OpenPgpStatus, TransportError> {
let mut s = keyroost_transport::OpenPgpSession::open(&name)?;
s.change_user_pin(old.as_bytes(), new.as_bytes())?;
s.status()
})();
Box::new(move |app: &mut App| {
wipe(&mut app.openpgp.user_pin_old);
wipe(&mut app.openpgp.user_pin_new);
Self::apply_openpgp_write(app, result, "User PIN (PW1) changed.".into());
})
});
}
fn change_openpgp_admin_pin(&mut self) {
let Some(name) = self.selected_openpgp_reader() else {
return;
};
let old = zeroize::Zeroizing::new(self.openpgp.admin_pin_old.clone());
let new = zeroize::Zeroizing::new(self.openpgp.admin_pin_new.clone());
self.openpgp.notice = None;
self.spawn_job("Changing admin PIN\u{2026}", move || {
let result = (|| -> Result<keyroost_transport::OpenPgpStatus, TransportError> {
let mut s = keyroost_transport::OpenPgpSession::open(&name)?;
s.change_admin_pin(old.as_bytes(), new.as_bytes())?;
s.status()
})();
Box::new(move |app: &mut App| {
wipe(&mut app.openpgp.admin_pin_old);
wipe(&mut app.openpgp.admin_pin_new);
Self::apply_openpgp_write(app, result, "Admin PIN (PW3) changed.".into());
})
});
}
fn unblock_openpgp_user_pin(&mut self) {
let Some(name) = self.selected_openpgp_reader() else {
return;
};
let admin = zeroize::Zeroizing::new(self.openpgp_admin_pin_value());
let new = zeroize::Zeroizing::new(self.openpgp.unblock_new.clone());
self.openpgp.notice = None;
self.spawn_job("Unblocking user PIN\u{2026}", move || {
let result = (|| -> Result<keyroost_transport::OpenPgpStatus, TransportError> {
let mut s = keyroost_transport::OpenPgpSession::open(&name)?;
s.reset_retry_counter(admin.as_bytes(), new.as_bytes())?;
s.status()
})();
Box::new(move |app: &mut App| {
wipe(&mut app.openpgp.unblock_new);
Self::apply_openpgp_write(app, result, "User PIN (PW1) unblocked.".into());
})
});
}
fn render_piv_confirms(&mut self, ctx: &egui::Context) {
if typed_reset_modal(
ctx,
"Reset PIV applet?",
"This wipes ALL PIV keys, certificates, and PINs.",
&["Only works when the PIN and PUK are already blocked."],
&mut self.piv.confirm_reset,
) && self.piv_reset()
{
self.piv.confirm_reset = None;
}
}
}
fn typed_reset_modal(
ctx: &egui::Context,
title: &str,
warning: &str,
extra_lines: &[&str],
confirm: &mut Option<String>,
) -> bool {
let Some(typed) = confirm.clone() else {
return false;
};
let mut do_it = false;
let mut cancel = false;
let mut window_open = true;
let mut buf = typed;
egui::Window::new(title)
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.open(&mut window_open)
.show(ctx, |ui| {
ui.colored_label(egui::Color32::from_rgb(220, 110, 110), warning);
for line in extra_lines {
ui.label(*line);
}
ui.label("This cannot be undone.");
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label("Type \u{201c}reset\u{201d} to confirm:");
ui.add(egui::TextEdit::singleline(&mut buf).desired_width(120.0));
});
ui.add_space(6.0);
ui.horizontal(|ui| {
let armed = buf.trim() == "reset";
if ui.add_enabled(armed, egui::Button::new("Reset")).clicked() {
do_it = true;
}
if ui.button("Cancel").clicked() {
cancel = true;
}
});
});
if do_it {
true
} else {
if cancel || !window_open {
*confirm = None;
} else {
*confirm = Some(buf);
}
false
}
}
fn hex_lower(bytes: &[u8]) -> String {
keyroost_proto::codec::hex_encode(bytes)
}
fn piv_mgmt_key_bytes(hex: &str) -> Result<zeroize::Zeroizing<Vec<u8>>, String> {
keyroost_proto::codec::hex_decode(hex.trim())
.map(zeroize::Zeroizing::new)
.map_err(|e| format!("management key is not valid hex: {}", e))
}
fn piv_default_mgmt_key(
fingerprint: keyroost_piv::fingerprint::AppletFingerprint,
version: Option<&[u8]>,
firmware_version: Option<&[u8]>,
) -> Option<&'static [u8]> {
keyroost_piv::compat::default_9b_management_key(&keyroost_piv::compat::resolve_quirks(
fingerprint,
version,
firmware_version,
))
}
enum PivMgmtAuth {
Key(zeroize::Zeroizing<Vec<u8>>),
Pin(zeroize::Zeroizing<String>),
}
fn piv_authenticate(
s: &mut keyroost_transport::PivSession<'_>,
auth: &PivMgmtAuth,
) -> Result<(), TransportError> {
match auth {
PivMgmtAuth::Key(key) => {
let alg = s.resolve_management_key_algorithm(key.len())?;
s.authenticate_management(alg, key)
}
PivMgmtAuth::Pin(pin) => s.authenticate_management_via_pin(pin.as_bytes()),
}
}
pub(crate) fn secret_edit(
ui: &mut egui::Ui,
p: &Palette,
buf: &mut String,
revealed: &mut bool,
hint: &str,
width: f32,
) -> egui::Response {
let mut resp = None;
ui.horizontal(|ui| {
let mut edit = egui::TextEdit::singleline(buf)
.vertical_align(egui::Align::Center)
.desired_width(width);
if !*revealed {
edit = edit.password(true);
}
if !hint.is_empty() {
edit = edit.hint_text(hint);
}
resp = Some(ui.add_sized([width, 32.0], edit));
let (irect, ir) = ui.allocate_exact_size(egui::vec2(26.0, 32.0), egui::Sense::click());
let col = if ir.hovered() { p.txt } else { p.txt2 };
let c = irect.center();
paint_eye_icon(ui, c, col);
if !*revealed {
ui.painter().line_segment(
[
egui::pos2(c.x - 8.0, c.y + 5.0),
egui::pos2(c.x + 8.0, c.y - 5.0),
],
egui::Stroke::new(1.3, col),
);
}
if ir.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
if ir.clicked() {
*revealed = !*revealed;
}
let _ = ir.on_hover_text(if *revealed { "Hide" } else { "Show" });
});
let resp = resp.unwrap();
guard_secret_field(ui.ctx(), &resp);
resp
}
fn hex_full(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{:02X}", b));
}
s
}
fn hex_short(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes.iter().take(8) {
s.push_str(&format!("{:02x}", b));
}
if bytes.len() > 8 {
s.push('…');
}
s
}
fn unix_now() -> u32 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as u32)
.unwrap_or(0)
}
impl App {
fn palette(&self) -> Palette {
Palette::new(
self.mode,
Palette::ACCENTS[self.accent_idx],
self.colorblind,
)
}
fn persist_settings(&mut self) {
let current = Settings {
zoom: theme::clamp_zoom(self.zoom),
mode: self.mode.into(),
accent: self.accent_idx,
colorblind: self.colorblind,
};
if self.last_saved_settings.as_ref() == Some(¤t) {
return;
}
current.save();
self.last_saved_settings = Some(current);
}
fn selected_device(&self) -> Option<&Device> {
let id = self.selected_device.as_ref()?;
self.devices.iter().find(|d| &d.id == id)
}
fn schedule_scan_burst(&mut self) {
self.pending_scans = 4;
self.next_scan_at = None; }
fn refresh_devices(&mut self) {
self.scanned = true;
self.spawn_job("Scanning for devices\u{2026}", move || {
let result = device::enumerate();
Box::new(move |app: &mut App| match result {
Ok(devices) => {
app.devices_error = None;
#[cfg(windows)]
let mut devices = devices;
#[cfg(windows)]
{
let fido_keys =
std::panic::catch_unwind(keyroost_winwebauthn::detect_fido_keys)
.unwrap_or_default();
if !fido_keys.is_empty() {
if devices.iter().any(|d| d.kind == DeviceKind::Key) {
for d in devices.iter_mut() {
if d.kind == DeviceKind::Key && !d.caps.has(Caps::FIDO2) {
d.caps.insert(Caps::FIDO2);
}
}
} else {
for (i, k) in fido_keys.iter().enumerate() {
let mut caps = Caps::default();
caps.insert(Caps::FIDO2);
let vid = k.vendor_id.unwrap_or(0);
let pid = k.product_id.unwrap_or(0);
let model = k
.product
.clone()
.unwrap_or_else(|| "Security key".to_string());
devices.push(Device {
id: format!("winfido:{vid:04x}:{pid:04x}:{i}"),
name: None,
vendor: format!("{vid:04X}"),
model,
serial: String::new(),
transport: "USB · FIDO HID (admin required)".into(),
firmware: String::new(),
caps,
unverified: Caps::default(),
kind: DeviceKind::Key,
hid_path: None,
reader: None,
});
}
}
}
}
let keep = app
.selected_device
.as_ref()
.filter(|id| devices.iter().any(|d| &d.id == *id))
.cloned();
let next = keep.or_else(|| devices.first().map(|d| d.id.clone()));
let changed = next != app.selected_device;
let previous = app.selected_device.clone();
app.selected_device = next;
app.devices_error = duplicate_serial_note(
devices
.iter()
.filter(|d| d.kind == DeviceKind::Key && !d.serial.is_empty())
.map(|d| d.serial.as_str()),
);
app.log_global(
Severity::Info,
LogKind::Background,
format!(
"device scan: {} device{} found",
devices.len(),
if devices.len() == 1 { "" } else { "s" }
),
);
app.devices = devices;
app.piv_session_state
.retain(|id, _| app.devices.iter().any(|d| &d.id == id));
if changed {
app.on_device_selected(previous);
}
}
Err(e) => {
app.log_global(
Severity::Warn,
LogKind::Background,
format!("device scan failed: {e}"),
);
App::apply_device_scan_failure(app, e);
}
})
});
}
fn apply_device_scan_failure(app: &mut App, error: String) {
app.devices_error = Some(error);
app.devices.clear();
if let Some(previous) = app.selected_device.take() {
app.on_device_selected(Some(previous));
}
}
fn select_device(&mut self, id: DeviceId) {
if self.selected_device.as_deref() == Some(id.as_str()) {
return;
}
let previous = self.selected_device.replace(id);
self.on_device_selected(previous);
}
fn on_device_selected(&mut self, previous: Option<DeviceId>) {
if let Some(prev) = previous {
self.cap_tab_by_device.insert(prev, self.cap_tab);
}
self.cap_tab = self
.selected_device
.as_ref()
.and_then(|id| self.cap_tab_by_device.get(id))
.copied()
.unwrap_or_default();
self.security_keys.info = None;
self.security_keys.init = None;
self.security_keys.session = None;
self.security_keys.session_device = None;
self.security_keys.error = None;
self.security_keys.large_blobs = None;
self.security_keys.lb_capacity = None;
self.security_keys.lb_autoloaded = false;
self.security_keys.lb_selected = None;
self.security_keys.lb_editing = None;
self.security_keys.lb_show_add = false;
self.security_keys.lb_status = None;
self.security_keys.lb_confirm_delete = None;
self.security_keys.lb_confirm_clear = false;
self.security_keys.ssh_cert_pending.clear();
self.security_keys.ssh_cert_status = None;
self.security_keys.fingerprints = None;
self.security_keys.fp_new_name.clear();
self.security_keys.fp_confirm_delete = None;
self.security_keys.fp_rename = None;
if let Some(p) = self.security_keys.fp_progress.take() {
if let Ok(g) = p.lock() {
g.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
}
}
self.oath.creds.clear();
self.oath.skipped = 0;
self.oath.loaded = false;
self.oath.locked = false;
self.oath.error = None;
self.oath.unlocked_password = None;
self.openpgp.status = None;
self.openpgp.loaded = false;
self.openpgp.error = None;
self.openpgp.notice = None;
self.openpgp.name_input.clear();
self.openpgp.url_input.clear();
self.openpgp.supported = None;
self.openpgp.gen_alg = OpenPgpKeyAlgSel::CardDefault;
self.piv = PivState::default();
wipe(&mut self.security_keys.pin_input);
wipe(&mut self.security_keys.change_pin.old);
wipe(&mut self.security_keys.change_pin.new);
wipe(&mut self.security_keys.change_pin.confirm);
self.security_keys.change_pin.open = false;
self.security_keys.advanced = None;
wipe(&mut self.oath.password_input);
wipe(&mut self.oath.add.secret);
wipe(&mut self.otp.add.secret);
self.openpgp.wipe_secrets();
self.oath.confirm_delete = None;
self.openpgp.cred_modal = None;
self.security_keys.reset = ResetDialog::default();
if self.reset_arm.take().is_some() {
self.log_global(
Severity::Warn,
LogKind::User,
"the armed reset was cancelled \u{2014} selecting another key ends the ceremony. \
Re-open \u{201C}Reset key\u{201D} on the key you want to wipe.",
);
}
self.factory_reset_report.clear();
self.oath_tried = false;
self.piv_tried = false;
self.otp_tried = false;
self.otp = OtpState::default();
self.molto_reset_confirm = false;
self.molto_delete_confirm = false;
self.close_rename();
self.authenticated = false;
self.session = None;
self.molto_session_device = None;
self.info = None;
self.slot_meta = None;
let Some(dev) = self.selected_device().cloned() else {
return;
};
match dev.kind {
DeviceKind::Token => self.open_molto(),
DeviceKind::ProgToken => self.open_prog(),
DeviceKind::Key if dev.caps.has(Caps::FIDO2) => self.fetch_selected_info(),
DeviceKind::Key => {}
}
}
fn open_molto(&mut self) {
let Some(reader) = self.selected_device().and_then(|d| d.reader.clone()) else {
return;
};
let for_device = self.selected_device.clone();
self.spawn_job("Opening Molto2\u{2026}", move || {
let result = (|| -> Result<(Session, DeviceInfo, Option<Vec<ProfilePublicData>>), TransportError> {
let mut s = Session::open_named(&reader)?;
let info = s.read_info()?;
let meta = (0..PROFILES)
.map(|p| s.read_public_data(p))
.collect::<Result<Vec<_>, _>>()
.ok();
Ok((s, info, meta))
})();
Box::new(move |app: &mut App| {
if !completion_still_valid(for_device.as_ref(), app.selected_device.as_ref()) {
return; }
match result {
Ok((s, info, meta)) => {
app.log_bg(Severity::Ok, format!("opened Molto2 {}", info.serial));
if let Some(id) = for_device.clone() {
let named = keyroost_keyring::Keyring::load_default()
.ok()
.and_then(|k| k.name_for(Some(&info.serial)).map(str::to_owned));
if let Some(dev) = app.devices.iter_mut().find(|d| d.id == id) {
dev.serial = info.serial.clone();
if dev.name.is_none() {
dev.name = named;
}
}
}
app.session = Some(s);
app.molto_session_device = for_device;
app.info = Some(info);
app.slot_meta = meta;
app.authenticated = false;
}
Err(e) => app.log_bg(Severity::Err, format!("open Molto2: {e}")),
}
})
});
}
fn open_prog(&mut self) {
let Some(reader) = self.selected_device().and_then(|d| d.reader.clone()) else {
return;
};
let for_device = self.selected_device.clone();
self.spawn_job("Opening token\u{2026}", move || {
let result =
(|| -> Result<(Token2ProgSession, keyroost_token2prog::Info), TransportError> {
let mut s = Token2ProgSession::open_named(&reader)?;
let info = s.read_info()?;
Ok((s, info))
})();
Box::new(move |app: &mut App| {
if !completion_still_valid(for_device.as_ref(), app.selected_device.as_ref()) {
return; }
match result {
Ok((s, info)) => {
app.log(
Severity::Ok,
format!("opened programmable token {}", info.serial),
);
app.prog_session = Some(s);
app.prog_info = Some(info);
app.prog_authenticated = false;
}
Err(e) => app.log(Severity::Err, format!("open token: {e}")),
}
})
});
}
fn spawn_piv_job<F>(&mut self, label: impl Into<String>, job: F) -> bool
where
F: FnOnce() -> ApplyFn + Send + 'static,
{
let queued = self.spawn_job(label, move || {
let apply = job();
Box::new(move |app: &mut App| {
app.piv.inflight = false;
apply(app);
}) as ApplyFn
});
if queued {
self.piv.inflight = true;
}
queued
}
fn piv_cached_piv_session_state(&self) -> keyroost_transport::PivSessionState {
let mut state: keyroost_transport::PivSessionState = self
.selected_device
.as_ref()
.and_then(|id| self.piv_session_state.get(id))
.cloned()
.unwrap_or_default();
state.set_send_unsupported_reads(self.piv_compat_ignored_selected());
state
}
fn piv_compat_ignored_selected(&self) -> bool {
self.selected_device
.as_ref()
.is_some_and(|id| self.piv_compat_ignored.contains(id))
}
fn piv_gate(
&self,
extension: keyroost_piv::compat::PivExtension,
fingerprint: keyroost_piv::fingerprint::AppletFingerprint,
version: Option<&[u8]>,
version_firmware: Option<&[u8]>,
) -> keyroost_piv::compat::FeatureGate {
let gate = keyroost_piv::compat::resolve(extension, fingerprint, version, version_firmware);
if self.piv_compat_ignored_selected() {
piv_relax(extension, gate)
} else {
gate
}
}
fn store_piv_session_state(
&mut self,
device: Option<DeviceId>,
state: keyroost_transport::PivSessionState,
) {
if let Some(id) = device {
self.piv_session_state.insert(id, state);
}
}
fn store_and_unwrap_piv_write(
&mut self,
device: Option<DeviceId>,
result: Result<
(
keyroost_transport::PivStatus,
keyroost_transport::PivSessionState,
),
TransportError,
>,
) -> Result<keyroost_transport::PivStatus, TransportError> {
match result {
Ok((status, state)) => {
self.store_piv_session_state(device, state);
Ok(status)
}
Err(e) => Err(e),
}
}
fn store_and_unwrap_piv_cert_write(
&mut self,
device: Option<DeviceId>,
result: Result<
(
keyroost_transport::PivStatus,
keyroost_transport::PivSessionState,
keyroost_transport::CertImport,
),
TransportError,
>,
) -> Result<
(
keyroost_transport::PivStatus,
keyroost_transport::CertImport,
),
TransportError,
> {
let (result, stored) = match result {
Ok((status, state, stored)) => (Ok((status, state)), Some(stored)),
Err(e) => (Err(e), None),
};
let status = self.store_and_unwrap_piv_write(device, result)?;
Ok((status, stored.expect("Ok result carries the CertImport")))
}
fn load_piv_status(&mut self, kind: LogKind) {
self.load_piv_status_with_cache(kind, PivCache::Reuse);
}
fn load_piv_status_with_cache(&mut self, kind: LogKind, cache: PivCache) {
self.piv.error = None;
let Some(reader) = self.selected_oath_reader() else {
return;
};
let for_device = self.selected_device.clone();
let cached_state = match cache {
PivCache::Reuse => Some(self.piv_cached_piv_session_state()),
PivCache::Bypass => self.piv_compat_ignored_selected().then(|| {
let mut fresh = keyroost_transport::PivSessionState::default();
fresh.set_send_unsupported_reads(true);
fresh
}),
};
self.spawn_piv_job("Reading PIV status\u{2026}", move || {
let result = match cached_state {
Some(cached) => {
keyroost_transport::PivSession::with_cached_transaction(&reader, cached, |s| {
let detailed = s.status_detailed();
let touched_card = s.touched_card();
Ok::<_, TransportError>((detailed, s.state(), touched_card))
})
}
None => keyroost_transport::PivSession::with_transaction(&reader, |s| {
let detailed = s.status_detailed();
let touched_card = s.touched_card();
Ok::<_, TransportError>((detailed, s.state(), touched_card))
}),
};
Box::new(move |app: &mut App| {
if !completion_still_valid(for_device.as_ref(), app.selected_device.as_ref()) {
return; }
match result {
Ok((Ok(detailed), state, touched_card)) => {
app.store_piv_session_state(for_device.clone(), state);
let keyroost_transport::PivStatusDetailed { status, slots, .. } = detailed;
let message = if touched_card {
format!("PIV status read ({} slots)", slots.len())
} else {
"PIV status cache validated".to_string()
};
app.log_kind(Severity::Ok, kind, message);
app.piv.slot_keys = slots
.iter()
.map(|d| (d.slot, d.algorithm, d.subject.clone()))
.collect();
app.piv.slot_policies = slots.iter().map(|d| (d.slot, d.policy)).collect();
app.piv.status = Some(status);
app.piv.loaded = true;
}
Ok((Err(e), state, _touched_card)) => {
app.store_piv_session_state(for_device.clone(), state);
app.log_kind(Severity::Err, kind, format!("PIV status read: {e}"));
app.piv.error = Some(e.to_string());
}
Err(e) => {
app.log_kind(Severity::Err, kind, format!("PIV status read: {e}"));
app.piv.error = Some(e.to_string());
}
}
})
});
}
fn apply_piv_write(
app: &mut App,
result: Result<keyroost_transport::PivStatus, TransportError>,
notice: String,
) {
match result {
Ok(status) => {
app.log(Severity::Ok, notice.clone());
app.piv.status = Some(status);
app.piv.error = None;
app.piv.notice = Some(notice);
app.load_piv_status(LogKind::Background);
app.piv.retired_occupancy = None;
if matches!(app.piv.selected_slot, PivSlotSel::Retired(_)) {
app.piv.selected_slot = PivSlotSel::Auth;
}
}
Err(e) => {
app.log(Severity::Err, e.to_string());
app.piv.notice = None;
app.piv.error = Some(e.to_string());
}
}
}
fn apply_piv_cred_result(app: &mut App) {
if let Some(m) = app.piv.cred_modal.as_mut() {
m.busy = false;
m.result = match &app.piv.error {
Some(e) => Some(Err(e.clone())),
None => Some(Ok(())),
};
}
}
fn apply_piv_cert_write(
app: &mut App,
result: Result<
(
keyroost_transport::PivStatus,
keyroost_transport::CertImport,
),
TransportError,
>,
base: &str,
choice: PivCertCompressionSel,
) {
match result {
Ok((status, stored)) => {
let notice = piv_cert_stored_notice(
base,
stored.compressed,
stored.stored_len,
stored.auto_compressed,
);
Self::apply_piv_write(app, Ok(status), notice);
if let Some(m) = app.piv.cred_modal.as_mut() {
m.detail = piv_cert_stored_detail(
stored.compressed,
stored.stored_len,
stored.auto_compressed,
);
}
}
Err(e) => {
let hint = piv_cert_error_hint(&e, choice);
Self::apply_piv_write(app, Err(e), String::new());
if let (Some(hint), Some(err)) = (hint, app.piv.error.as_mut()) {
err.push_str(hint);
}
}
}
Self::apply_piv_cred_result(app);
}
fn piv_change_pin(&mut self) {
let Some(name) = self.selected_oath_reader() else {
return;
};
if self.piv.pin_new != self.piv.pin_confirm {
self.piv.notice = None;
self.piv.error = Some("the two new PINs don't match".into());
return;
}
let (old, new) = (self.piv.pin_old.clone(), self.piv.pin_new.clone());
self.piv.notice = None;
let for_device = self.selected_device.clone();
let cached_state = self.piv_cached_piv_session_state();
self.spawn_piv_job("Changing PIV PIN\u{2026}", move || {
let result =
keyroost_transport::PivSession::with_cached_transaction(&name, cached_state, |s| {
s.change_pin(old.as_bytes(), new.as_bytes())?;
let status = s.status()?;
Ok((status, s.state()))
});
Box::new(move |app: &mut App| {
wipe(&mut app.piv.pin_old);
wipe(&mut app.piv.pin_new);
wipe(&mut app.piv.pin_confirm);
let result = app.store_and_unwrap_piv_write(for_device, result);
Self::apply_piv_write(app, result, "PIN changed.".into());
Self::apply_piv_cred_result(app);
})
});
}
fn piv_change_puk(&mut self) {
let Some(name) = self.selected_oath_reader() else {
return;
};
if self.piv.puk_new != self.piv.puk_confirm {
self.piv.notice = None;
self.piv.error = Some("the two new PUKs don't match".into());
return;
}
let (old, new) = (self.piv.puk_old.clone(), self.piv.puk_new.clone());
self.piv.notice = None;
let for_device = self.selected_device.clone();
let cached_state = self.piv_cached_piv_session_state();
self.spawn_piv_job("Changing PUK\u{2026}", move || {
let result =
keyroost_transport::PivSession::with_cached_transaction(&name, cached_state, |s| {
s.change_puk(old.as_bytes(), new.as_bytes())?;
let status = s.status()?;
Ok((status, s.state()))
});
Box::new(move |app: &mut App| {
wipe(&mut app.piv.puk_old);
wipe(&mut app.piv.puk_new);
wipe(&mut app.piv.puk_confirm);
let result = app.store_and_unwrap_piv_write(for_device, result);
Self::apply_piv_write(app, result, "PUK changed.".into());
Self::apply_piv_cred_result(app);
})
});
}
fn piv_unblock_pin(&mut self) {
let Some(name) = self.selected_oath_reader() else {
return;
};
let (puk, new) = (
self.piv.unblock_puk.clone(),
self.piv.unblock_new_pin.clone(),
);
self.piv.notice = None;
let for_device = self.selected_device.clone();
let cached_state = self.piv_cached_piv_session_state();
self.spawn_piv_job("Unblocking PIN\u{2026}", move || {
let result =
keyroost_transport::PivSession::with_cached_transaction(&name, cached_state, |s| {
s.unblock_pin(puk.as_bytes(), new.as_bytes())?;
let status = s.status()?;
Ok((status, s.state()))
});
Box::new(move |app: &mut App| {
wipe(&mut app.piv.unblock_puk);
wipe(&mut app.piv.unblock_new_pin);
let result = app.store_and_unwrap_piv_write(for_device, result);
Self::apply_piv_write(app, result, "PIN unblocked and reset.".into());
Self::apply_piv_cred_result(app);
})
});
}
fn piv_current_default_mgmt_key(&self) -> Option<&'static [u8]> {
let status = self.piv.status.as_ref()?;
piv_default_mgmt_key(
status.applet_fingerprint,
status.version.as_deref(),
status.version_firmware.as_deref(),
)
}
fn piv_current_mgmt_key(&self) -> Result<zeroize::Zeroizing<Vec<u8>>, String> {
if self.piv.mgmt_auth_mode == PivMgmtAuthMode::Default {
self.piv_current_default_mgmt_key()
.map(|key| zeroize::Zeroizing::new(key.to_vec()))
.ok_or_else(|| "This device has no known default management key.".to_string())
} else {
piv_mgmt_key_bytes(&self.piv.mgmt_key_input)
}
}
fn piv_current_mgmt_auth(&self, kind: PivCredKind) -> Result<PivMgmtAuth, String> {
if self.piv.mgmt_auth_mode == PivMgmtAuthMode::Pin {
let pin = if kind.shares_pin_field() {
match kind {
PivCredKind::SelfSign => &self.piv.sign_pin,
PivCredKind::SetRetries => &self.piv.retries_pin_auth,
_ => unreachable!("shares_pin_field() covers exactly these two kinds"),
}
} else {
&self.piv.mgmt_key_input
};
return Ok(PivMgmtAuth::Pin(zeroize::Zeroizing::new(pin.clone())));
}
self.piv_current_mgmt_key().map(PivMgmtAuth::Key)
}
fn piv_set_retries(&mut self) {
let Some(name) = self.selected_oath_reader() else {
return;
};
let mgmt = match self.piv_current_mgmt_auth(PivCredKind::SetRetries) {
Ok(a) => a,
Err(e) => {
self.piv.error = Some(e);
return;
}
};
let pin = zeroize::Zeroizing::new(self.piv.retries_pin_auth.clone());
let (pin_tries, puk_tries) = (self.piv.retries_pin, self.piv.retries_puk);
self.piv.notice = None;
let for_device = self.selected_device.clone();
let cached_state = self.piv_cached_piv_session_state();
self.spawn_piv_job("Setting PIV retry counts\u{2026}", move || {
let result =
keyroost_transport::PivSession::with_cached_transaction(&name, cached_state, |s| {
piv_authenticate(s, &mgmt)?;
s.verify_pin(pin.as_bytes())?;
s.set_pin_retries(pin_tries, puk_tries)?;
let status = s.status()?;
Ok((status, s.state()))
});
Box::new(move |app: &mut App| {
wipe(&mut app.piv.mgmt_key_input);
wipe(&mut app.piv.retries_pin_auth);
let result = app.store_and_unwrap_piv_write(for_device, result);
Self::apply_piv_write(
app,
result,
"Retry counts set; PIN/PUK reset to defaults.".into(),
);
Self::apply_piv_cred_result(app);
})
});
}
fn piv_generate_key(&mut self) {
let Some(name) = self.selected_oath_reader() else {
return;
};
let mgmt = match self.piv_current_mgmt_auth(PivCredKind::GenerateKey) {
Ok(a) => a,
Err(e) => {
self.piv.error = Some(e);
return;
}
};
let slot = self.piv.selected_slot.to_slot();
let alg = self.piv.gen_alg;
let (pin_policy, touch_policy) = (self.piv.gen_pin_policy, self.piv.gen_touch_policy);
self.piv.notice = None;
let for_device = self.selected_device.clone();
let cached_state = self.piv_cached_piv_session_state();
self.spawn_piv_job("Generating key\u{2026} (touch if it blinks)", move || {
let result = keyroost_transport::PivSession::with_cached_transaction(
&name,
cached_state,
|s| -> Result<_, TransportError> {
piv_authenticate(s, &mgmt)?;
let pubkey = s.generate_key(slot, alg, pin_policy, touch_policy)?;
let status = s.status()?;
Ok((pubkey, status, s.state()))
},
);
Box::new(move |app: &mut App| {
wipe(&mut app.piv.mgmt_key_input);
match result {
Ok((pubkey, status, state)) => {
app.log(
Severity::Ok,
format!("generated {} key in {}", alg.label(), slot.label()),
);
app.piv.status = Some(status);
app.piv.error = None;
app.store_piv_session_state(for_device.clone(), state);
match keyroost_piv::spki::subject_public_key_info(&pubkey, alg) {
Ok(_) => {
app.piv.notice = Some(format!("Generated {} key.", alg.label()));
}
Err(e) => {
app.piv.notice = Some(format!(
"Generated {} key (public-key encoding failed: {})",
alg.label(),
e
));
}
}
app.load_piv_status(LogKind::Background);
app.piv.retired_occupancy = None;
}
Err(e) => {
app.log(Severity::Err, e.to_string());
app.piv.notice = None;
app.piv.error = Some(e.to_string());
}
}
Self::apply_piv_cred_result(app);
})
});
}
fn piv_import_cert(&mut self) {
let Some(name) = self.selected_oath_reader() else {
return;
};
let mgmt = match self.piv_current_mgmt_auth(PivCredKind::ImportCert) {
Ok(a) => a,
Err(e) => {
self.piv.error = Some(e);
return;
}
};
let slot = self.piv.selected_slot.to_slot();
let path = self.piv.cert_path.trim().to_owned();
let choice = self.piv.cert_compression;
self.piv.notice = None;
let for_device = self.selected_device.clone();
let cached_state = self.piv_cached_piv_session_state();
self.spawn_piv_job("Importing certificate\u{2026}", move || {
let result = (|| -> Result<
(
keyroost_transport::PivStatus,
keyroost_transport::PivSessionState,
keyroost_transport::CertImport,
),
TransportError,
> {
let bytes = std::fs::read(&path).map_err(|_| {
TransportError::MalformedResponse("cannot read certificate file")
})?;
let der = cert_bytes_to_der(&bytes)
.ok_or(TransportError::MalformedResponse("file is not PEM or DER"))?;
keyroost_transport::PivSession::with_cached_transaction(
&name,
cached_state,
|s| {
piv_authenticate(s, &mgmt)?;
let stored = s.import_certificate(slot, &der, choice.to_compression())?;
let status = s.status()?;
Ok((status, s.state(), stored))
},
)
})();
Box::new(move |app: &mut App| {
wipe(&mut app.piv.mgmt_key_input);
let result = app.store_and_unwrap_piv_cert_write(for_device, result);
Self::apply_piv_cert_write(app, result, "Certificate imported", choice);
})
});
}
fn piv_delete_cert(&mut self) {
let Some(name) = self.selected_oath_reader() else {
return;
};
let mgmt = match self.piv_current_mgmt_auth(PivCredKind::DeleteCert) {
Ok(a) => a,
Err(e) => {
self.piv.error = Some(e);
return;
}
};
let slot = self.piv.selected_slot.to_slot();
self.piv.notice = None;
let for_device = self.selected_device.clone();
let cached_state = self.piv_cached_piv_session_state();
self.spawn_piv_job("Deleting certificate\u{2026}", move || {
let result =
keyroost_transport::PivSession::with_cached_transaction(&name, cached_state, |s| {
piv_authenticate(s, &mgmt)?;
s.clear_certificate(slot)?;
let status = s.status()?;
Ok((status, s.state()))
});
Box::new(move |app: &mut App| {
wipe(&mut app.piv.mgmt_key_input);
let result = app.store_and_unwrap_piv_write(for_device, result);
Self::apply_piv_write(
app,
result,
format!("Certificate removed from {}.", slot.label()),
);
Self::apply_piv_cred_result(app);
})
});
}
fn piv_refresh_chuid_guid(&mut self) {
if let Ok(guid) = keyroost_transport::random_chuid_guid() {
self.piv.chuid_guid = keyroost_piv::format_guid(&guid);
}
}
fn piv_generate_random_mgmt_key(&mut self, ctx: &egui::Context, alg: keyroost_piv::MgmtAlg) {
let Ok(key) = keyroost_transport::random_management_key(alg) else {
return;
};
let hex = hex_lower(&key);
wipe(&mut self.piv.new_mgmt_key_input);
self.piv.new_mgmt_key_input.push_str(&hex);
ctx.copy_text(hex.clone());
self.clipboard_clear_at = Some((hex, now_secs_f64() + 45.0));
self.secret_reveal.insert("piv-new-mgmt-key", true);
}
fn piv_new_chuid(&mut self) {
let Some(name) = self.selected_oath_reader() else {
return;
};
let mgmt = match self.piv_current_mgmt_auth(PivCredKind::NewChuid) {
Ok(a) => a,
Err(e) => {
self.piv.error = Some(e);
return;
}
};
let guid = match keyroost_piv::parse_guid_hex(&self.piv.chuid_guid) {
Some(g) => g,
None => {
self.piv.error = Some(
"GUID must be 16 bytes of hex, dashes optional (e.g. \
aabbccdd-eeff-1122-3344-556677889900)"
.into(),
);
return;
}
};
let expiration = keyroost_piv::yyyymmdd_from_unix_secs(keyroost_piv::add_calendar_period(
u64::from(unix_now()),
self.piv.chuid_valid_years,
self.piv.chuid_valid_months,
self.piv.chuid_valid_days,
));
self.piv.notice = None;
let for_device = self.selected_device.clone();
let cached_state = self.piv_cached_piv_session_state();
self.spawn_piv_job("Writing a new CHUID\u{2026}", move || {
let result =
keyroost_transport::PivSession::with_cached_transaction(&name, cached_state, |s| {
piv_authenticate(s, &mgmt)?;
s.new_chuid(&guid, &expiration)?;
let status = s.status()?;
Ok((status, s.state()))
});
Box::new(move |app: &mut App| {
wipe(&mut app.piv.mgmt_key_input);
let result = app.store_and_unwrap_piv_write(for_device, result);
Self::apply_piv_write(app, result, "New CHUID written.".into());
Self::apply_piv_cred_result(app);
})
});
}
fn piv_delete_key(&mut self) {
let Some(name) = self.selected_oath_reader() else {
return;
};
let mgmt = match self.piv_current_mgmt_auth(PivCredKind::DeleteKey) {
Ok(a) => a,
Err(e) => {
self.piv.error = Some(e);
return;
}
};
let slot = self.piv.selected_slot.to_slot();
self.piv.notice = None;
let for_device = self.selected_device.clone();
let cached_state = self.piv_cached_piv_session_state();
self.spawn_piv_job("Deleting key\u{2026}", move || {
let result =
keyroost_transport::PivSession::with_cached_transaction(&name, cached_state, |s| {
piv_authenticate(s, &mgmt)?;
s.delete_key(slot)?;
let status = s.status()?;
Ok((status, s.state()))
});
Box::new(move |app: &mut App| {
wipe(&mut app.piv.mgmt_key_input);
let result = app.store_and_unwrap_piv_write(for_device, result);
Self::apply_piv_write(app, result, format!("Key erased from {}.", slot.label()));
Self::apply_piv_cred_result(app);
})
});
}
fn piv_move_key(&mut self) {
let Some(name) = self.selected_oath_reader() else {
return;
};
let mgmt = match self.piv_current_mgmt_auth(PivCredKind::MoveKey) {
Ok(a) => a,
Err(e) => {
self.piv.error = Some(e);
return;
}
};
let src = self.piv.selected_slot.to_slot();
let Some(dest) = self.piv.move_dest else {
self.piv.error = Some("choose a destination slot".into());
return;
};
self.piv.notice = None;
let for_device = self.selected_device.clone();
let cached_state = self.piv_cached_piv_session_state();
self.spawn_piv_job("Moving key\u{2026}", move || {
let result =
keyroost_transport::PivSession::with_cached_transaction(&name, cached_state, |s| {
piv_authenticate(s, &mgmt)?;
s.move_key(src, dest)?;
let status = s.status()?;
Ok((status, s.state()))
});
Box::new(move |app: &mut App| {
wipe(&mut app.piv.mgmt_key_input);
let result = app.store_and_unwrap_piv_write(for_device, result);
Self::apply_piv_write(
app,
result,
format!("Key moved from {} to {}.", src.label(), dest.label()),
);
app.piv.retired_occupancy = None;
if matches!(app.piv.selected_slot, PivSlotSel::Retired(_)) {
app.piv.selected_slot = PivSlotSel::Auth;
}
Self::apply_piv_cred_result(app);
})
});
}
fn load_piv_retired_occupancy(&mut self) -> bool {
let Some(reader) = self.selected_oath_reader() else {
return false;
};
let for_device = self.selected_device.clone();
let cached_state = self.piv_cached_piv_session_state();
self.spawn_piv_job("Reading retired slots\u{2026}", move || {
let result = keyroost_transport::PivSession::with_cached_transaction(
&reader,
cached_state,
|s| {
let occupancy: Vec<_> = keyroost_piv::Slot::retired_all()
.into_iter()
.map(|slot| (slot, s.slot_has_key(slot).unwrap_or(false)))
.collect();
Ok::<_, TransportError>((occupancy, s.state()))
},
);
Box::new(move |app: &mut App| {
if !completion_still_valid(for_device.as_ref(), app.selected_device.as_ref()) {
return; }
match result {
Ok((v, state)) => {
app.store_piv_session_state(for_device.clone(), state);
app.log_bg(
Severity::Ok,
format!("retired slot occupancy read ({} slots)", v.len()),
);
app.piv.retired_occupancy = Some(v);
}
Err(e) => {
app.log_bg(Severity::Warn, format!("retired slot occupancy: {e}"));
app.piv.error = Some(e.to_string());
}
}
})
})
}
fn piv_selected_occupied(&self) -> bool {
piv_slot_occupied(
self.piv.selected_slot,
&self.piv.slot_keys,
self.piv.retired_occupancy.as_deref(),
)
}
fn piv_selected_slot_policy(
&self,
) -> Option<(keyroost_piv::PinPolicy, keyroost_piv::TouchPolicy)> {
let sel = self.piv.selected_slot.to_slot();
self.piv
.slot_policies
.iter()
.find(|(s, _)| *s == sel)
.and_then(|(_, pol)| *pol)
}
fn piv_selected_test_target(&self) -> (Option<keyroost_piv::KeyAlg>, bool) {
let sel = self.piv.selected_slot.to_slot();
let entry = self.piv.slot_keys.iter().find(|(s, _, _)| *s == sel);
let alg = entry.and_then(|(_, a, _)| *a);
let has_cert = entry.is_some_and(|(_, _, dn)| dn.is_some())
|| self
.piv
.status
.as_ref()
.is_some_and(|st| st.slots.iter().any(|s| s.slot == sel && s.cert_present));
(alg, has_cert)
}
fn piv_self_test(&mut self) {
let Some(name) = self.selected_oath_reader() else {
return;
};
let pin = zeroize::Zeroizing::new(self.piv.sign_pin.clone());
let slot = self.piv.selected_slot.to_slot();
let want_touch = matches!(
self.piv_selected_slot_policy().map(|(_, t)| t),
Some(keyroost_piv::TouchPolicy::Always | keyroost_piv::TouchPolicy::Cached)
);
self.piv.notice = None;
let label = if want_touch {
"Running self-test\u{2026} \u{2014} touch the key"
} else {
"Running self-test\u{2026}"
};
let for_device = self.selected_device.clone();
let cached_state = self.piv_cached_piv_session_state();
self.spawn_piv_job(label, move || {
let outcome = run_piv_self_test(&name, slot, pin.as_bytes(), cached_state);
Box::new(move |app: &mut App| {
wipe(&mut app.piv.sign_pin);
let (all_ok, text) = match &outcome {
Ok((results, _)) => (
!results.iter().any(|(_, r)| r.is_failure()),
keyroost_pivtest::format_report(results),
),
Err(e) => (false, e.clone()),
};
if let Ok((_, state)) = outcome {
app.store_piv_session_state(for_device, state);
}
if all_ok {
app.log(
Severity::Ok,
format!("PIV self-test on {} passed", slot.label()),
);
app.piv.error = None;
app.piv.notice = Some(text.clone());
} else {
app.log(
Severity::Err,
format!("PIV self-test on {} failed", slot.label()),
);
app.piv.notice = None;
app.piv.error = Some(text.clone());
}
if let Some(m) = app.piv.cred_modal.as_mut() {
m.detail = all_ok.then_some(text);
}
Self::apply_piv_cred_result(app);
})
});
}
fn piv_subject(&self) -> Option<String> {
let s = self.piv.cert_subject.trim();
if s.is_empty() {
return None;
}
Some(if s.contains('=') {
s.to_owned()
} else {
format!("CN={s}")
})
}
fn piv_self_sign(&mut self) {
let Some(name) = self.selected_oath_reader() else {
return;
};
let mgmt = match self.piv_current_mgmt_auth(PivCredKind::SelfSign) {
Ok(a) => a,
Err(e) => {
self.piv.error = Some(e);
return;
}
};
let Some(subject) = self.piv_subject() else {
self.piv.error = Some("enter a name for the certificate".into());
return;
};
let pin = zeroize::Zeroizing::new(self.piv.sign_pin.clone());
let slot = self.piv.selected_slot.to_slot();
let years = self.piv.cert_valid_years;
let months = self.piv.cert_valid_months;
let days = if years == 0 && months == 0 {
self.piv.cert_valid_days.max(1)
} else {
self.piv.cert_valid_days
};
let choice = self.piv.cert_compression;
self.piv.notice = None;
let for_device = self.selected_device.clone();
let cached_state = self.piv_cached_piv_session_state();
self.spawn_piv_job(
"Creating self-signed certificate\u{2026} (touch if it blinks)",
move || {
let result = keyroost_transport::PivSession::with_cached_transaction(
&name,
cached_state,
|s| {
piv_authenticate(s, &mgmt)?;
let now = unix_now();
let (_, stored) = s.self_signed_certificate(
slot,
&subject,
i64::from(now),
keyroost_piv::add_calendar_period(u64::from(now), years, months, days),
pin.as_bytes(),
choice.to_compression(),
)?;
let status = s.status()?;
Ok((status, s.state(), stored))
},
);
Box::new(move |app: &mut App| {
wipe(&mut app.piv.sign_pin);
wipe(&mut app.piv.mgmt_key_input);
let result = app.store_and_unwrap_piv_cert_write(for_device, result);
Self::apply_piv_cert_write(
app,
result,
&format!("Self-signed certificate stored in {}", slot.label()),
choice,
);
})
},
);
}
fn piv_request_csr(&mut self) {
let Some(name) = self.selected_oath_reader() else {
return;
};
let Some(subject) = self.piv_subject() else {
self.piv.error = Some("enter a name for the certificate request".into());
return;
};
let path = self.piv.csr_path.trim().to_owned();
let pin = zeroize::Zeroizing::new(self.piv.sign_pin.clone());
let slot = self.piv.selected_slot.to_slot();
self.piv.notice = None;
let for_device = self.selected_device.clone();
let cached_state = self.piv_cached_piv_session_state();
self.spawn_piv_job(
"Signing certificate request\u{2026} (touch if it blinks)",
move || {
let result = keyroost_transport::PivSession::with_cached_transaction(
&name,
cached_state,
|s| -> Result<_, TransportError> {
let pem = s.generate_csr(slot, &subject, pin.as_bytes())?;
std::fs::write(&path, pem.as_bytes()).map_err(|_| {
TransportError::MalformedResponse("cannot write destination file")
})?;
Ok(s.state())
},
);
Box::new(move |app: &mut App| {
wipe(&mut app.piv.sign_pin);
match result {
Ok(state) => {
app.store_piv_session_state(for_device, state);
app.log(
Severity::Ok,
format!("certificate request signed for {}", slot.label()),
);
app.piv.error = None;
app.piv.notice = Some("Certificate request signed and saved.".into());
}
Err(e) => {
app.log(Severity::Err, e.to_string());
app.piv.notice = None;
app.piv.error = Some(e.to_string());
}
}
Self::apply_piv_cred_result(app);
})
},
);
}
fn piv_export_cert(&mut self) {
let Some(name) = self.selected_oath_reader() else {
return;
};
let slot = self.piv.selected_slot.to_slot();
let path = self.piv.export_path.trim().to_owned();
self.piv.notice = None;
let for_device = self.selected_device.clone();
let cached_state = self.piv_cached_piv_session_state();
self.spawn_piv_job("Exporting certificate\u{2026}", move || {
let result = keyroost_transport::PivSession::with_cached_transaction(
&name,
cached_state,
|s| -> Result<_, TransportError> {
let der =
s.read_certificate(slot)?
.ok_or(TransportError::MalformedResponse(
"slot holds no certificate",
))?;
std::fs::write(&path, &der).map_err(|_| {
TransportError::MalformedResponse("cannot write destination file")
})?;
Ok((der.len(), s.state()))
},
);
Box::new(move |app: &mut App| match result {
Ok((n, state)) => {
app.store_piv_session_state(for_device, state);
app.log(
Severity::Ok,
format!("exported {}-byte certificate from {}", n, slot.label()),
);
app.piv.error = None;
app.piv.notice = Some(format!("Exported {}-byte DER certificate.", n));
}
Err(e) => {
app.log(Severity::Err, e.to_string());
app.piv.notice = None;
app.piv.error = Some(e.to_string());
}
})
});
}
fn piv_change_management_key(&mut self) {
let Some(name) = self.selected_oath_reader() else {
return;
};
let old = match self.piv_current_mgmt_auth(PivCredKind::ChangeMgmtKey) {
Ok(a) => a,
Err(e) => {
self.piv.error = Some(e);
return;
}
};
let new = match self.piv.new_mgmt_alg.to_alg() {
Some(new_alg) => {
let new = match piv_mgmt_key_bytes(&self.piv.new_mgmt_key_input) {
Ok(b) => b,
Err(e) => {
self.piv.error = Some(e);
return;
}
};
if new.len() != new_alg.key_len() {
self.piv.error = Some(format!(
"new management key is {} bytes; {} needs {}",
new.len(),
new_alg.label(),
new_alg.key_len()
));
return;
}
Some((new_alg, new))
}
None => None,
};
let pin_unlock_gate = {
use keyroost_piv::compat::PivExtension;
let (piv_fp, piv_ver, piv_fw_ver) = self.piv.status.as_ref().map_or(
(
keyroost_piv::fingerprint::AppletFingerprint::Generic,
None,
None,
),
|s| {
(
s.applet_fingerprint,
s.version.as_deref(),
s.version_firmware.as_deref(),
)
},
);
self.piv_gate(PivExtension::PinManagementAuth, piv_fp, piv_ver, piv_fw_ver)
};
let maintain_pin_unlock = !matches!(
pin_unlock_gate,
keyroost_piv::compat::FeatureGate::Unsupported
);
let allow_pin_unlock = self.piv.new_mgmt_allow_pin_unlock;
self.piv.notice = None;
let for_device = self.selected_device.clone();
let cached_state = self.piv_cached_piv_session_state();
self.spawn_piv_job("Changing management key\u{2026}", move || {
let result =
keyroost_transport::PivSession::with_cached_transaction(&name, cached_state, |s| {
piv_authenticate(s, &old)?;
let current = match &old {
PivMgmtAuth::Key(key) => keyroost_transport::CurrentMgmtAuth::Key(key),
PivMgmtAuth::Pin(pin) => {
keyroost_transport::CurrentMgmtAuth::Pin(pin.as_bytes())
}
};
let mut pin_unlock_warning = None;
match &new {
Some((new_alg, new_key)) if maintain_pin_unlock => {
match s.set_management_key_pin_protected(
current,
*new_alg,
new_key,
false,
allow_pin_unlock,
)? {
keyroost_transport::PinProtectMaintenance::NotApplicable
| keyroost_transport::PinProtectMaintenance::Ran {
printed_data: Ok(()),
} => {}
keyroost_transport::PinProtectMaintenance::Ran {
printed_data: Err(e),
} => {
if pin_unlock_gate == keyroost_piv::compat::FeatureGate::Supported {
return Err(e);
}
let action = if allow_pin_unlock { "enable" } else { "disable" };
pin_unlock_warning = Some(format!(
"Could not {action} PIN-protected management-key \
storage ({e}). keyroost's list has no entry for \
this on this key."
));
}
}
}
Some((new_alg, new_key)) => {
s.set_management_key(current, *new_alg, new_key, false)?;
}
None => s.delete_management_key_hid_crescendo(current)?,
}
let status = s.status()?;
Ok((status, s.state(), pin_unlock_warning))
});
Box::new(move |app: &mut App| {
wipe(&mut app.piv.mgmt_key_input);
wipe(&mut app.piv.new_mgmt_key_input);
let (result, pin_unlock_warning) = match result {
Ok((status, state, warning)) => (Ok((status, state)), warning),
Err(e) => (Err(e), None),
};
let notice = match &new {
Some((new_alg, _)) => {
let base = format!("Management key changed to {}.", new_alg.label());
if !maintain_pin_unlock || pin_unlock_warning.is_some() {
base
} else if allow_pin_unlock {
format!("{base} PIN-protected management-key storage enabled.")
} else {
format!("{base} PIN-protected management-key storage disabled (if it was set).")
}
}
None => "Management key deleted.".to_string(),
};
let result = app.store_and_unwrap_piv_write(for_device, result);
let succeeded = result.is_ok();
Self::apply_piv_write(app, result, notice);
if succeeded {
if let Some(w) = pin_unlock_warning {
app.log(Severity::Warn, w);
}
}
Self::apply_piv_cred_result(app);
})
});
}
fn piv_reset(&mut self) -> bool {
let Some(name) = self.selected_oath_reader() else {
return true; };
self.piv.notice = None;
let for_device = self.selected_device.clone();
let cached_state = self.piv_cached_piv_session_state();
let queued = self.spawn_piv_job("Resetting PIV applet\u{2026}", move || {
let result =
keyroost_transport::PivSession::with_cached_transaction(&name, cached_state, |s| {
s.force_reset_if_known_supported(None, false)?;
let status = s.status()?;
Ok((status, s.state()))
});
Box::new(move |app: &mut App| {
let result = app.store_and_unwrap_piv_write(for_device, result);
Self::apply_piv_write(
app,
result,
"PIV application reset to factory defaults.".into(),
);
})
});
queued
}
fn apply_rename_actions(&mut self, dev: &Device, open: bool, cancel: bool, save: bool) {
if open {
if dev.serial.is_empty() {
self.log(
Severity::Warn,
"this device exposes no serial, so it can't be named yet",
);
} else {
self.rename_input = dev.name.clone().unwrap_or_default();
self.rename_target = Some(RenameTarget {
serial: dev.serial.clone(),
name_at_open: dev.name.clone(),
});
}
}
if cancel {
self.close_rename();
}
if save {
self.save_device_name();
}
}
fn close_rename(&mut self) {
self.rename_input.clear();
self.rename_target = None;
if let Some(ctx) = &self.egui_ctx {
ctx.request_repaint();
}
}
fn save_device_name(&mut self) {
let Some(target) = self.rename_target.clone() else {
self.close_rename();
return;
};
let name = self.rename_input.trim().to_owned();
if !name.is_empty() {
if let Err(e) = keyroost_keyring::validate_name(&name) {
self.log(Severity::Err, format!("invalid name: {e}"));
return; }
}
let live = self.devices.iter().find(|d| d.serial == target.serial);
let vendor = live.map(|d| d.vendor.to_ascii_lowercase());
let implicit = live
.map(|d| d.model.clone())
.unwrap_or_else(|| "this key".to_string());
let old_display = target
.name_at_open
.clone()
.unwrap_or_else(|| implicit.clone());
let cleared = name.is_empty();
let new_display = if cleared { implicit } else { name.clone() };
let mut keyring = keyroost_keyring::Keyring::load_default().unwrap_or_default();
if let Some(current) = target.name_at_open.clone() {
keyring.remove(¤t);
}
if let Some(existing) = keyring.name_for(Some(&target.serial)) {
let existing = existing.to_owned();
keyring.remove(&existing);
}
if !name.is_empty() {
let entry = keyroost_keyring::KeyEntry {
name,
serial: target.serial.clone(),
source: keyroost_keyring::IdSource::default(),
vendor,
aaguid: None,
note: None,
};
if let Err(e) = keyring.add(entry) {
self.log(Severity::Err, format!("name: {e}"));
return; }
}
match keyring.save_default() {
Ok(_) => self.log_global(
Severity::Ok,
LogKind::User,
format!(
"{new_display}: name {}, was {old_display}",
if cleared { "cleared" } else { "saved" }
),
),
Err(e) => self.log(Severity::Err, format!("save names: {e}")),
}
self.close_rename();
self.refresh_devices();
}
fn toggle_help(&mut self, topic: &'static str, anchor: egui::Pos2) {
self.help_anchor = anchor;
self.help_open = if self.help_open == Some(topic) {
None
} else {
Some(topic)
};
}
}
fn activity_log_viewport_id() -> egui::ViewportId {
egui::ViewportId::from_hash_of("activity-log")
}
fn now_secs_f64() -> f64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0)
}
fn panel_frame(fill: egui::Color32, mx: f32, my: f32) -> egui::Frame {
egui::Frame::NONE
.fill(fill)
.inner_margin(egui::Margin::symmetric(mx as i8, my as i8))
}
fn glyph_tile(
ui: &mut egui::Ui,
size: f32,
fill: egui::Color32,
fg: egui::Color32,
ch: Option<char>,
) {
let (rect, _) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::hover());
ui.painter()
.rect_filled(rect, egui::CornerRadius::same((size * 0.28) as u8), fill);
match ch {
Some(c) => {
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
c,
theme::f_bold(size * 0.5),
fg,
);
}
None => {
let c = rect.center();
let r = size * 0.26;
ui.painter().circle_stroke(c, r, egui::Stroke::new(1.6, fg));
ui.painter().line_segment(
[c, c + egui::vec2(0.0, -r * 0.72)],
egui::Stroke::new(1.6, fg),
);
ui.painter().line_segment(
[c, c + egui::vec2(r * 0.55, 0.0)],
egui::Stroke::new(1.6, fg),
);
}
}
}
fn matches_filter(d: &Device, q: &str) -> bool {
let q = q.trim().to_ascii_lowercase();
if q.is_empty() {
return true;
}
d.vendor.to_ascii_lowercase().contains(&q)
|| d.model.to_ascii_lowercase().contains(&q)
|| d.title().to_ascii_lowercase().contains(&q)
}
fn duplicate_serial_note<'a>(serials: impl Iterator<Item = &'a str>) -> Option<String> {
use std::collections::BTreeMap;
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
for s in serials {
*counts.entry(s).or_insert(0) += 1;
}
counts.into_iter().find(|&(_, n)| n > 1).map(|(serial, n)| {
format!(
"{n} connected keys report the same serial ({serial}); friendly names can't tell \
them apart — pick each by its row in the list."
)
})
}
fn cap_tab_label(t: CapTab) -> &'static str {
match t {
CapTab::Overview => "Overview",
CapTab::Fido2 => "FIDO2",
CapTab::Oath => "Authenticator",
CapTab::Pgp => "OpenPGP",
CapTab::Piv => "PIV",
CapTab::Otp => "On-device OTP",
}
}
fn piv_cred_mismatch(piv: &PivState, kind: PivCredKind) -> Option<&'static str> {
let (new, confirm, msg) = match kind {
PivCredKind::ChangePin => (
&piv.pin_new,
&piv.pin_confirm,
"the two new PINs don't match",
),
PivCredKind::ChangePuk => (
&piv.puk_new,
&piv.puk_confirm,
"the two new PUKs don't match",
),
PivCredKind::UnblockPin
| PivCredKind::GenerateKey
| PivCredKind::ImportCert
| PivCredKind::SelfSign
| PivCredKind::RequestCsr
| PivCredKind::SetRetries
| PivCredKind::ChangeMgmtKey
| PivCredKind::DeleteCert
| PivCredKind::DeleteKey
| PivCredKind::MoveKey
| PivCredKind::NewChuid
| PivCredKind::SelfTest => return None,
};
if confirm.is_empty() || new == confirm {
None
} else {
Some(msg)
}
}
fn piv_cred_success(kind: PivCredKind) -> &'static str {
match kind {
PivCredKind::ChangePin => "PIN changed",
PivCredKind::ChangePuk => "PUK changed",
PivCredKind::UnblockPin => "PIN unblocked and reset",
PivCredKind::GenerateKey => "Key generated",
PivCredKind::ImportCert => "Certificate imported",
PivCredKind::SelfSign => "Certificate created",
PivCredKind::RequestCsr => "Request signed and saved",
PivCredKind::SetRetries => "Retry counts set",
PivCredKind::ChangeMgmtKey => "Management key changed",
PivCredKind::DeleteCert => "Certificate deleted",
PivCredKind::DeleteKey => "Key deleted",
PivCredKind::MoveKey => "Key moved",
PivCredKind::NewChuid => "New CHUID written",
PivCredKind::SelfTest => "Self-test complete",
}
}
fn run_piv_self_test(
reader: &str,
slot: keyroost_piv::Slot,
pin: &[u8],
cached: keyroost_transport::PivSessionState,
) -> Result<
(
Vec<(keyroost_pivtest::SelfTest, keyroost_pivtest::Outcome)>,
keyroost_transport::PivSessionState,
),
String,
> {
keyroost_transport::PivSession::with_cached_transaction(reader, cached, |s| {
let inner = (|| -> Result<_, String> {
let cert = s
.read_certificate(slot)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("{} has no certificate to test against", slot.label()))?;
let (alg, pubkey) = keyroost_piv::x509_parse::parse_certificate_public_key(&cert)
.map_err(|e| format!("could not read the slot certificate's key: {e}"))?;
if !keyroost_pivtest::SelfTest::all()
.into_iter()
.any(|op| keyroost_pivtest::supports(op, alg))
{
return Err(format!("no self-test applies to a {} key", alg.label()));
}
let has_pin = !pin.is_empty();
if has_pin {
s.verify_pin(pin).map_err(|e| e.to_string())?;
}
let mut ran = 0usize;
let results =
keyroost_pivtest::run(alg, &pubkey, |op, input| -> Result<Vec<u8>, String> {
if has_pin && ran > 0 {
s.verify_pin(pin).map_err(|e| e.to_string())?;
}
ran += 1;
if op.is_key_agreement() {
s.key_agree(slot, alg, input)
} else if op == keyroost_pivtest::SelfTest::Decrypt {
s.decrypt(slot, alg, input)
} else {
s.sign(slot, alg, input)
}
.map_err(|e| e.to_string())
});
Ok((results, s.state()))
})();
Ok::<_, TransportError>(inner)
})
.map_err(|e| e.to_string())
.and_then(|inner| inner)
}
fn piv_slot_occupied(
sel: PivSlotSel,
slot_keys: &[(
keyroost_piv::Slot,
Option<keyroost_piv::KeyAlg>,
Option<String>,
)],
retired_occupancy: Option<&[(keyroost_piv::Slot, bool)]>,
) -> bool {
match sel {
PivSlotSel::Retired(n) => retired_occupancy
.and_then(|v| v.iter().find(|(s, _)| *s == keyroost_piv::Slot::Retired(n)))
.map(|(_, has)| *has)
.unwrap_or(false),
_ => {
let sel_slot = sel.to_slot();
slot_keys
.iter()
.find(|(s, _, _)| *s == sel_slot)
.map(|(_, a, _)| a.is_some())
.unwrap_or(false)
}
}
}
#[must_use]
fn slot_confirmed_empty(
get_slot_key_status_gate: keyroost_piv::compat::FeatureGate,
selected_has_key: bool,
) -> bool {
use keyroost_piv::compat::FeatureGate;
get_slot_key_status_gate == FeatureGate::Supported && !selected_has_key
}
fn move_key_eligible_destinations(
src: keyroost_piv::Slot,
occupied: &dyn Fn(keyroost_piv::Slot) -> bool,
) -> Vec<keyroost_piv::Slot> {
keyroost_piv::Slot::all()
.into_iter()
.chain(keyroost_piv::Slot::retired_all())
.filter(|&s| s != src && !occupied(s))
.collect()
}
fn pin_field(ui: &mut egui::Ui, p: &Palette, label: &str, buf: &mut String) {
ui.horizontal(|ui| {
ui.add_sized(
[96.0, 22.0],
egui::Label::new(
egui::RichText::new(label)
.font(theme::f_reg(13.0))
.color(p.txt2),
),
);
let resp = ui.add(
egui::TextEdit::singleline(buf)
.password(true)
.desired_width(200.0),
);
guard_secret_field(ui.ctx(), &resp);
});
ui.add_space(4.0);
}
fn secret_field(
ui: &mut egui::Ui,
p: &Palette,
label: &str,
buf: &mut String,
hint: &str,
label_w: f32,
w: f32,
) {
ui.horizontal(|ui| {
ui.add_sized(
[label_w, 22.0],
egui::Label::new(
egui::RichText::new(label)
.font(theme::f_reg(13.0))
.color(p.txt2),
),
);
let resp = ui.add(
egui::TextEdit::singleline(buf)
.password(true)
.hint_text(hint)
.desired_width(w),
);
guard_secret_field(ui.ctx(), &resp);
});
ui.add_space(4.0);
}
fn inline_secret_edit(
ui: &mut egui::Ui,
p: &Palette,
buf: &mut String,
revealed: &mut bool,
hint: &str,
width: f32,
) -> egui::Response {
let mut edit = egui::TextEdit::singleline(buf)
.desired_width(width)
.margin(egui::Margin {
left: 4,
right: 30,
top: 2,
bottom: 2,
});
if !*revealed {
edit = edit.password(true);
}
if !hint.is_empty() {
edit = edit.hint_text(hint);
}
let resp = ui.add(edit);
let center = egui::pos2(resp.rect.right() - 13.0, resp.rect.center().y);
let icon_rect = egui::Rect::from_center_size(center, egui::vec2(20.0, resp.rect.height()));
let ir = ui.interact(icon_rect, resp.id.with("reveal"), egui::Sense::click());
let col = if ir.hovered() { p.txt } else { p.txt2 };
paint_eye_icon(ui, center, col);
if !*revealed {
ui.painter().line_segment(
[
egui::pos2(center.x - 6.0, center.y + 4.0),
egui::pos2(center.x + 6.0, center.y - 4.0),
],
egui::Stroke::new(1.1, col),
);
}
if ir.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
if ir.clicked() {
*revealed = !*revealed;
}
let _ = ir.on_hover_text(if *revealed { "Hide" } else { "Show" });
guard_secret_field(ui.ctx(), &resp);
resp
}
fn piv_pin_field(
ui: &mut egui::Ui,
p: &Palette,
label: &str,
buf: &mut String,
revealed: &mut bool,
label_w: f32,
) {
ui.horizontal(|ui| {
ui.add_sized(
[label_w, 22.0],
egui::Label::new(
egui::RichText::new(label)
.font(theme::f_reg(13.0))
.color(p.txt2),
),
);
inline_secret_edit(ui, p, buf, revealed, "", 200.0);
});
ui.add_space(4.0);
}
#[allow(clippy::too_many_arguments)]
fn piv_secret_field(
ui: &mut egui::Ui,
p: &Palette,
label: &str,
buf: &mut String,
revealed: &mut bool,
hint: &str,
label_w: f32,
w: f32,
) {
ui.horizontal(|ui| {
ui.add_sized(
[label_w, 22.0],
egui::Label::new(
egui::RichText::new(label)
.font(theme::f_reg(13.0))
.color(p.txt2),
),
);
inline_secret_edit(ui, p, buf, revealed, hint, w);
});
ui.add_space(4.0);
}
fn card_note(ui: &mut egui::Ui, p: &Palette, t: &str) {
ui.label(
egui::RichText::new(t)
.font(theme::f_reg(12.0))
.color(p.txt3),
);
}
fn piv_valid_for_fields(
ui: &mut egui::Ui,
now: u32,
years: &mut u32,
months: &mut u32,
days: &mut u32,
) {
ui.add(
egui::DragValue::new(years)
.range(0..=keyroost_piv::max_valid_years(u64::from(now)))
.suffix(" y"),
);
ui.add(
egui::DragValue::new(months)
.range(0..=keyroost_piv::max_valid_months(u64::from(now)))
.suffix(" mo"),
);
ui.add(
egui::DragValue::new(days)
.range(0..=keyroost_piv::max_valid_days(u64::from(now)))
.suffix(" d"),
);
}
fn mds_kv(ui: &mut egui::Ui, p: &Palette, key: &str, value: &str) {
ui.horizontal(|ui| {
ui.add_sized(
[150.0, 16.0],
egui::Label::new(
egui::RichText::new(key)
.font(theme::f_reg(12.0))
.color(p.txt3),
)
.wrap_mode(egui::TextWrapMode::Extend),
);
ui.label(
egui::RichText::new(value)
.font(theme::f_reg(12.5))
.color(p.txt2),
);
});
ui.add_space(3.0);
}
fn mds_cell(ui: &mut egui::Ui, p: &Palette, key: &str, value: &str, w: f32) {
let label_w = 138.0_f32.min(w * 0.5);
ui.allocate_ui(egui::vec2(w, 18.0), |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 10.0;
ui.add_sized(
[label_w, 16.0],
egui::Label::new(
egui::RichText::new(key)
.font(theme::f_reg(12.0))
.color(p.txt3),
)
.wrap_mode(egui::TextWrapMode::Extend)
.halign(egui::Align::LEFT),
);
ui.add(
egui::Label::new(
egui::RichText::new(value)
.font(theme::f_reg(12.5))
.color(p.txt2),
)
.wrap_mode(egui::TextWrapMode::Truncate),
);
});
});
}
fn text_field(ui: &mut egui::Ui, p: &Palette, label: &str, buf: &mut String, hint: &str, w: f32) {
ui.horizontal(|ui| {
ui.add_sized(
[96.0, 22.0],
egui::Label::new(
egui::RichText::new(label)
.font(theme::f_reg(13.0))
.color(p.txt2),
),
);
ui.add(
egui::TextEdit::singleline(buf)
.hint_text(hint)
.desired_width(w),
);
});
ui.add_space(4.0);
}
fn chuid_guid_field_width(ctx: &egui::Context) -> f32 {
let sample = "00000000-0000-0000-0000-000000000000";
let w = ctx
.fonts_mut(|f| {
f.layout_no_wrap(sample.to_owned(), theme::f_reg(13.0), egui::Color32::WHITE)
})
.size()
.x;
w + 24.0
}
fn label_text_width(ctx: &egui::Context, text: &str) -> f32 {
let w = ctx
.fonts_mut(|f| f.layout_no_wrap(text.to_owned(), theme::f_reg(13.0), egui::Color32::WHITE))
.size()
.x;
w + 8.0
}
fn chuid_label_width(ctx: &egui::Context) -> f32 {
label_text_width(ctx, "Management key")
}
fn stable_selectable_value<'a, T: PartialEq>(
ui: &mut egui::Ui,
current: &mut T,
value: T,
text: impl egui::IntoAtoms<'a>,
) -> egui::Response {
let selected = *current == value;
let resp = ui.add(egui::Button::selectable(selected, text).frame_when_inactive(true));
if resp.clicked() && *current != value {
*current = value;
}
resp
}
const PIV_COMBO_DISABLED_ALPHA: f32 = 0.32;
fn piv_keyalg_combo(
ui: &mut egui::Ui,
id: &str,
sel: &mut keyroost_piv::KeyAlg,
gate_of: impl Fn(keyroost_piv::KeyAlg) -> keyroost_piv::compat::FeatureGate,
) {
egui::ComboBox::from_id_salt(id)
.selected_text(sel.label())
.show_ui(ui, |ui| {
ui.style_mut().visuals.disabled_alpha = PIV_COMBO_DISABLED_ALPHA;
for opt in keyroost_piv::KeyAlg::ALL {
let enabled = gate_of(opt) != keyroost_piv::compat::FeatureGate::Unsupported;
ui.add_enabled_ui(enabled, |ui| {
stable_selectable_value(ui, sel, opt, opt.label());
});
}
});
}
#[must_use]
fn piv_keyalg_unsupported_fallback(
sel: keyroost_piv::KeyAlg,
fallback: keyroost_piv::KeyAlg,
gate_of: impl Fn(keyroost_piv::KeyAlg) -> keyroost_piv::compat::FeatureGate,
) -> keyroost_piv::KeyAlg {
if gate_of(sel) != keyroost_piv::compat::FeatureGate::Unsupported {
return sel;
}
keyroost_piv::KeyAlg::ALL
.into_iter()
.find(|&alg| gate_of(alg) != keyroost_piv::compat::FeatureGate::Unsupported)
.unwrap_or(fallback)
}
fn openpgp_keyalg_combo(
ui: &mut egui::Ui,
id: &str,
sel: &mut OpenPgpKeyAlgSel,
choices: &[OpenPgpKeyAlgSel],
) {
egui::ComboBox::from_id_salt(id)
.selected_text(sel.label())
.show_ui(ui, |ui| {
for &opt in choices {
ui.selectable_value(sel, opt, opt.label());
}
});
}
trait PivPolicyOption: Copy + PartialEq + 'static {
const ALL: &'static [Self];
fn label(self) -> &'static str;
}
impl PivPolicyOption for keyroost_piv::PinPolicy {
const ALL: &'static [Self] = &[Self::Default, Self::Never, Self::Once, Self::Always];
fn label(self) -> &'static str {
match self {
Self::Never => "never",
Self::Once => "once",
Self::Always => "always",
Self::Default => "default",
}
}
}
impl PivPolicyOption for keyroost_piv::TouchPolicy {
const ALL: &'static [Self] = &[Self::Default, Self::Never, Self::Cached, Self::Always];
fn label(self) -> &'static str {
match self {
Self::Never => "never",
Self::Cached => "cached",
Self::Always => "always",
Self::Default => "default",
}
}
}
fn piv_policy_clamp<T: PivPolicyOption>(sel: &mut T, options: &[T]) {
if let Some(&first) = options.first() {
if !options.contains(sel) {
*sel = first;
}
}
}
fn piv_policy_combo<T: PivPolicyOption>(ui: &mut egui::Ui, id: &str, sel: &mut T, options: &[T]) {
piv_policy_clamp(sel, options);
egui::ComboBox::from_id_salt(id)
.selected_text(sel.label())
.show_ui(ui, |ui| {
ui.style_mut().visuals.disabled_alpha = PIV_COMBO_DISABLED_ALPHA;
for &opt in options {
stable_selectable_value(ui, sel, opt, opt.label());
}
});
}
fn piv_mgmtalg_clamp(sel: &mut PivMgmtAlgSel, options: &[PivMgmtAlgSel]) {
if let Some(&first) = options.first() {
if !options.contains(sel) {
*sel = first;
}
}
}
#[must_use]
fn piv_mgmtalg_unsupported_fallback(
sel: PivMgmtAlgSel,
options: &[PivMgmtAlgSel],
gate_of: impl Fn(PivMgmtAlgSel) -> keyroost_piv::compat::FeatureGate,
) -> PivMgmtAlgSel {
if gate_of(sel) != keyroost_piv::compat::FeatureGate::Unsupported {
return sel;
}
options
.iter()
.copied()
.find(|&opt| gate_of(opt) != keyroost_piv::compat::FeatureGate::Unsupported)
.or_else(|| options.first().copied())
.unwrap_or(sel)
}
#[must_use]
fn piv_mgmtalg_unverified_labels(
extension_gate: keyroost_piv::compat::FeatureGate,
options: &[PivMgmtAlgSel],
gate_of: impl Fn(PivMgmtAlgSel) -> keyroost_piv::compat::FeatureGate,
) -> Vec<&'static str> {
if extension_gate == keyroost_piv::compat::FeatureGate::Unsupported {
return Vec::new();
}
options
.iter()
.copied()
.filter(|&opt| gate_of(opt) == keyroost_piv::compat::FeatureGate::Unverified)
.map(PivMgmtAlgSel::label)
.collect()
}
fn piv_mgmtalg_combo(
ui: &mut egui::Ui,
id: &str,
sel: &mut PivMgmtAlgSel,
options: &[PivMgmtAlgSel],
gate_of: impl Fn(PivMgmtAlgSel) -> keyroost_piv::compat::FeatureGate,
) {
piv_mgmtalg_clamp(sel, options);
egui::ComboBox::from_id_salt(id)
.selected_text(sel.label())
.show_ui(ui, |ui| {
ui.style_mut().visuals.disabled_alpha = PIV_COMBO_DISABLED_ALPHA;
for &opt in options {
let enabled = gate_of(opt) != keyroost_piv::compat::FeatureGate::Unsupported;
ui.add_enabled_ui(enabled, |ui| {
stable_selectable_value(ui, sel, opt, opt.label());
});
}
});
}
fn cert_bytes_to_der(bytes: &[u8]) -> Option<Vec<u8>> {
if let Ok(text) = std::str::from_utf8(bytes) {
if let Some(start) = text.find("-----BEGIN CERTIFICATE-----") {
let after = &text[start + "-----BEGIN CERTIFICATE-----".len()..];
let end = after.find("-----END CERTIFICATE-----")?;
let b64: String = after[..end].split_whitespace().collect();
return keyroost_proto::codec::base64_decode(&b64).ok();
}
}
if bytes.first() == Some(&0x30) {
Some(bytes.to_vec())
} else {
None
}
}
fn paint_glyph_tile(
ui: &egui::Ui,
rect: egui::Rect,
fill: egui::Color32,
fg: egui::Color32,
ch: Option<char>,
) {
ui.painter().rect_filled(
rect,
egui::CornerRadius::same((rect.width() * 0.28) as u8),
fill,
);
match ch {
Some(c) => {
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
c,
theme::f_bold(rect.width() * 0.5),
fg,
);
}
None => {
let c = rect.center();
let r = rect.width() * 0.26;
ui.painter().circle_stroke(c, r, egui::Stroke::new(1.6, fg));
ui.painter().line_segment(
[c, c + egui::vec2(0.0, -r * 0.72)],
egui::Stroke::new(1.6, fg),
);
ui.painter().line_segment(
[c, c + egui::vec2(r * 0.55, 0.0)],
egui::Stroke::new(1.6, fg),
);
}
}
}
fn paint_pill(
ui: &egui::Ui,
left_top: egui::Pos2,
text: &str,
fg: egui::Color32,
bg: egui::Color32,
) -> f32 {
let galley = ui.fonts_mut(|f| f.layout_no_wrap(text.to_string(), theme::f_sb(11.0), fg));
let pad_x = 8.0;
let h = 18.0;
let w = galley.size().x + pad_x * 2.0;
let rect = egui::Rect::from_min_size(left_top, egui::vec2(w, h));
ui.painter()
.rect_filled(rect, egui::CornerRadius::same(255), bg);
let pos = egui::pos2(left_top.x + pad_x, rect.center().y - galley.size().y / 2.0);
ui.painter().galley(pos, galley, fg);
w
}
fn paint_theme_icon(ui: &egui::Ui, center: egui::Pos2, r: f32, color: egui::Color32) {
use std::f32::consts::{FRAC_PI_2, PI};
ui.painter()
.circle_stroke(center, r, egui::Stroke::new(1.3, color));
let n = 20;
let pts: Vec<egui::Pos2> = (0..=n)
.map(|i| {
let a = FRAC_PI_2 + PI * (i as f32 / n as f32);
center + r * egui::vec2(a.cos(), a.sin())
})
.collect();
ui.painter()
.add(egui::Shape::convex_polygon(pts, color, egui::Stroke::NONE));
}
fn paint_eye_icon(ui: &egui::Ui, center: egui::Pos2, color: egui::Color32) {
let stroke = egui::Stroke::new(1.3, color);
let (w, h) = (8.0_f32, 4.6_f32);
let n = 14;
let lid = |sign: f32| -> Vec<egui::Pos2> {
(0..=n)
.map(|i| {
let t = -1.0 + 2.0 * (i as f32 / n as f32);
egui::pos2(center.x + t * w, center.y + sign * h * (1.0 - t * t))
})
.collect()
};
ui.painter().add(egui::Shape::line(lid(-1.0), stroke));
ui.painter().add(egui::Shape::line(lid(1.0), stroke));
ui.painter().circle_filled(center, 2.2, color);
}
fn paint_refresh_icon(ui: &egui::Ui, center: egui::Pos2, r: f32, color: egui::Color32) {
let stroke = egui::Stroke::new(1.4, color);
let (a0, a1) = (0.5_f32, 5.6_f32);
let n = 24;
let pts: Vec<egui::Pos2> = (0..=n)
.map(|i| {
let a = a0 + (a1 - a0) * (i as f32 / n as f32);
center + r * egui::vec2(a.cos(), a.sin())
})
.collect();
ui.painter().add(egui::Shape::line(pts, stroke));
let end = center + r * egui::vec2(a1.cos(), a1.sin());
let tangent = egui::vec2(-a1.sin(), a1.cos());
let radial = egui::vec2(a1.cos(), a1.sin());
let tip = end + tangent * 3.5;
let b1 = end + radial * 2.6 - tangent * 0.8;
let b2 = end - radial * 2.6 - tangent * 0.8;
ui.painter().add(egui::Shape::convex_polygon(
vec![tip, b1, b2],
color,
egui::Stroke::NONE,
));
}
fn fmt_unix_utc(secs: u32) -> String {
let days = (secs / 86_400) as i64;
let rem = secs % 86_400;
let (hh, mm, ss) = (rem / 3600, (rem % 3600) / 60, rem % 60);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!("{y:04}-{m:02}-{d:02} {hh:02}:{mm:02}:{ss:02} UTC")
}
fn paint_copy_icon(ui: &egui::Ui, center: egui::Pos2, color: egui::Color32) {
let s = egui::Stroke::new(1.3, color);
let back = egui::Rect::from_min_size(center + egui::vec2(-1.0, -5.0), egui::vec2(7.0, 8.0));
let front = egui::Rect::from_min_size(center + egui::vec2(-5.0, -1.0), egui::vec2(7.0, 8.0));
ui.painter().rect_stroke(
back,
egui::CornerRadius::same(1),
s,
egui::StrokeKind::Inside,
);
ui.painter().rect_stroke(
front,
egui::CornerRadius::same(1),
s,
egui::StrokeKind::Inside,
);
}
#[cfg(feature = "qr")]
pub(crate) fn paint_qr_icon(ui: &egui::Ui, center: egui::Pos2, color: egui::Color32) {
let s = egui::Stroke::new(1.2, color);
let finder = |min: egui::Vec2| {
let r = egui::Rect::from_min_size(center + min, egui::vec2(3.4, 3.4));
ui.painter()
.rect_stroke(r, egui::CornerRadius::same(1), s, egui::StrokeKind::Inside);
};
finder(egui::vec2(-5.0, -5.0));
finder(egui::vec2(1.6, -5.0));
finder(egui::vec2(-5.0, 1.6));
let dot = |off: egui::Vec2| {
ui.painter().rect_filled(
egui::Rect::from_min_size(center + off, egui::vec2(1.2, 1.2)),
egui::CornerRadius::ZERO,
color,
);
};
dot(egui::vec2(2.0, 2.0));
dot(egui::vec2(4.0, 4.0));
dot(egui::vec2(2.0, 4.4));
}
fn paint_check_icon(ui: &egui::Ui, center: egui::Pos2, color: egui::Color32) {
let s = egui::Stroke::new(1.7, color);
ui.painter().line_segment(
[
center + egui::vec2(-4.0, 0.0),
center + egui::vec2(-1.0, 3.0),
],
s,
);
ui.painter().line_segment(
[
center + egui::vec2(-1.0, 3.0),
center + egui::vec2(4.0, -3.5),
],
s,
);
}
fn paint_x_icon(ui: &egui::Ui, center: egui::Pos2, color: egui::Color32) {
let s = egui::Stroke::new(1.4, color);
ui.painter().line_segment(
[
center + egui::vec2(-3.5, -3.5),
center + egui::vec2(3.5, 3.5),
],
s,
);
ui.painter().line_segment(
[
center + egui::vec2(-3.5, 3.5),
center + egui::vec2(3.5, -3.5),
],
s,
);
}
fn paint_window_body(painter: &egui::Painter, center: egui::Pos2, s: egui::Stroke) {
let b = egui::Rect::from_min_size(center + egui::vec2(-6.0, -2.0), egui::vec2(8.0, 8.0));
painter.add(egui::Shape::line(
vec![
egui::pos2(b.right() - 5.0, b.top()),
b.left_top(),
b.left_bottom(),
b.right_bottom(),
egui::pos2(b.right(), b.top() + 5.0),
],
s,
));
}
fn paint_detach_icon(ui: &egui::Ui, center: egui::Pos2, color: egui::Color32) {
let s = egui::Stroke::new(1.3, color);
let painter = ui.painter();
paint_window_body(painter, center, s);
let tip = center + egui::vec2(6.0, -6.0);
painter.line_segment([center + egui::vec2(-1.0, 1.0), tip], s);
painter.add(egui::Shape::line(
vec![tip + egui::vec2(-4.5, 0.0), tip, tip + egui::vec2(0.0, 4.5)],
s,
));
}
fn paint_reattach_icon(ui: &egui::Ui, center: egui::Pos2, color: egui::Color32) {
let s = egui::Stroke::new(1.3, color);
let painter = ui.painter();
paint_window_body(painter, center, s);
let tip = center;
painter.line_segment([center + egui::vec2(6.0, -6.0), tip], s);
painter.add(egui::Shape::line(
vec![tip + egui::vec2(4.5, 0.0), tip, tip + egui::vec2(0.0, -4.5)],
s,
));
}
fn device_row(ui: &mut egui::Ui, p: &Palette, dev: &Device, selected: bool) -> bool {
let w = ui.available_width();
let h = 68.0;
let (rect, resp) = ui.allocate_exact_size(egui::vec2(w, h), egui::Sense::click());
let bg = if selected {
p.raised
} else if resp.hovered() {
p.line_soft
} else {
egui::Color32::TRANSPARENT
};
ui.painter().rect(
rect,
egui::CornerRadius::same(11),
bg,
egui::Stroke::new(
1.0,
if selected {
p.line
} else {
egui::Color32::TRANSPARENT
},
),
egui::StrokeKind::Inside,
);
if selected {
ui.painter().rect_filled(
egui::Rect::from_min_size(
rect.left_top() + egui::vec2(0.0, 13.0),
egui::vec2(3.0, h - 26.0),
),
egui::CornerRadius::same(3),
p.accent,
);
}
let token = dev.kind == DeviceKind::Token || dev.kind == DeviceKind::ProgToken;
let tile = egui::Rect::from_min_size(
rect.left_top() + egui::vec2(14.0, (h - 38.0) / 2.0),
egui::vec2(38.0, 38.0),
);
if token {
paint_glyph_tile(ui, tile, p.brand_soft(), p.brand, None);
} else {
paint_glyph_tile(
ui,
tile,
p.raised2,
p.txt2,
Some(
dev.vendor
.chars()
.next()
.unwrap_or('?')
.to_ascii_uppercase(),
),
);
}
let tx = tile.right() + 11.0;
let right_pad = 16.0;
ui.painter().circle_filled(
egui::pos2(rect.right() - right_pad, rect.top() + 18.0),
3.5,
p.ok,
);
ui.painter().text(
egui::pos2(tx, rect.top() + 13.0),
egui::Align2::LEFT_TOP,
&dev.vendor,
theme::f_sb(11.0),
p.txt3,
);
let avail = (rect.right() - right_pad - 8.0) - tx;
let galley = ui.fonts_mut(|f| {
let mut job = egui::text::LayoutJob::single_section(
dev.title().to_string(),
egui::TextFormat {
font_id: theme::f_sb(13.5),
color: p.txt,
..Default::default()
},
);
job.wrap = egui::text::TextWrapping {
max_width: avail.max(0.0),
max_rows: 1,
break_anywhere: true,
overflow_character: Some('\u{2026}'),
};
f.layout_job(job)
});
ui.painter()
.galley(egui::pos2(tx, rect.top() + 26.0), galley, p.txt);
let py = rect.top() + 46.0;
let (fg, bg) = if token {
(p.brand, p.brand_soft())
} else {
(p.txt2, p.raised2)
};
let mut px = tx;
for (label, state) in dev.cap_badge_states() {
if state == CapState::Unverified {
let text = format!("{label}?");
px += paint_pill(ui, egui::pos2(px, py), &text, p.txt3, bg) + 5.0;
} else {
px += paint_pill(ui, egui::pos2(px, py), label, fg, bg) + 5.0;
}
}
if resp.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
resp.clicked()
}
impl eframe::App for App {
fn ui(&mut self, root_ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let ctx_owned = root_ui.ctx().clone();
let ctx = &ctx_owned;
if let Some((ref code, t)) = self.clipboard_clear_at {
let now = now_secs_f64();
if now >= t {
if let Ok(mut cb) = arboard::Clipboard::new() {
if cb.get_text().is_ok_and(|current| current == *code) {
let _ = cb.clear();
}
}
self.clipboard_clear_at = None;
} else {
ctx.request_repaint_after(std::time::Duration::from_secs_f64((t - now).max(0.1)));
}
}
let dropped = ctx.input(|i| {
i.raw
.dropped_files
.first()
.and_then(|f| f.path.as_ref())
.map(|p| p.display().to_string())
});
if let Some(path) = dropped {
self.bulk_dialog.open = true;
self.bulk_dialog.path = path;
self.bulk_load();
}
self.drain_worker();
if self.busy() {
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
self.drain_import();
self.drain_file_dialogs();
let p = self.palette();
let theme_key = (self.mode, self.accent_idx, self.colorblind);
if self.applied_theme != Some(theme_key) {
p.apply(ctx, self.mode);
self.applied_theme = Some(theme_key);
}
ctx.options_mut(|o| o.zoom_with_keyboard = true);
match self.applied_zoom {
None => {
self.zoom = theme::clamp_zoom(self.zoom);
ctx.set_zoom_factor(self.zoom);
self.applied_zoom = Some(self.zoom);
}
Some(_) => {
if self.zoom_pending.is_none() {
let live = ctx.zoom_factor();
let clamped = theme::clamp_zoom(live);
if (live - clamped).abs() > f32::EPSILON {
ctx.set_zoom_factor(clamped);
}
self.zoom = clamped;
self.applied_zoom = Some(self.zoom);
self.persist_settings();
}
}
}
if !self.scanned {
self.scanned = true;
self.schedule_scan_burst();
}
if self.reset_arm.is_some() {
self.poll_reset_arm();
ctx.request_repaint_after(std::time::Duration::from_millis(200));
}
if self.reset_arm.is_none()
&& self
.devices_dirty
.swap(false, std::sync::atomic::Ordering::Relaxed)
{
self.schedule_scan_burst();
}
if self.reset_arm.is_none() && self.pending_scans > 0 {
let now = std::time::Instant::now();
let due = self.next_scan_at.is_none_or(|t| now >= t);
let renaming = self.rename_target.is_some();
if due && !self.busy() && !renaming {
self.refresh_devices();
self.pending_scans -= 1;
self.next_scan_at = Some(now + std::time::Duration::from_millis(1500));
}
if self.pending_scans > 0 && !renaming {
ctx.request_repaint_after(std::time::Duration::from_millis(500));
}
}
self.top_bar(root_ui, &p);
self.device_sidebar(root_ui, &p);
if self.log_open && !self.log_detached {
self.activity_log(root_ui, &p);
}
self.central(root_ui, &p);
if self.log_open && self.log_detached {
self.activity_log_window(ctx, &p);
}
self.render_reset_dialog(ctx);
self.render_advanced_confirm(ctx, &p);
self.render_enroll_dialog(ctx, &p);
if let Some(id) = self.render_fp_delete_confirm(ctx, &p) {
self.delete_fingerprint(id);
}
self.render_oath_delete_confirm(ctx);
self.render_oath_reset_confirm(ctx);
self.render_factory_reset_confirm(ctx, &p);
self.render_oath_add_modal(ctx, &p);
self.render_openpgp_cred_modal(ctx, &p);
self.render_piv_confirms(ctx);
self.render_piv_cred_modal(ctx, &p);
self.molto_dialogs(ctx, &p);
if let Some(topic) = self.help_open {
if ui::help_popover(ctx, &p, topic, self.help_anchor) {
self.help_open = None;
}
}
if matches!(self.cap_tab, CapTab::Otp) && self.otp.loaded {
let min_period = self
.otp
.rows
.iter()
.filter(|r| r.type_str.eq_ignore_ascii_case("TOTP") && r.code.is_some())
.map(|r| if r.period == 0 { 30 } else { r.period as u64 })
.min();
if let Some(period) = min_period {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let window = now / period;
match self.otp_last_window {
Some(prev) if prev != window => {
self.otp_last_window = Some(window);
self.load_otp_entries();
}
None => self.otp_last_window = Some(window),
_ => {}
}
}
} else {
self.otp_last_window = None;
}
let animating = self.copied.is_some()
|| matches!(self.cap_tab, CapTab::Oath)
|| (matches!(self.cap_tab, CapTab::Overview)
&& self.oath.loaded
&& !self.oath.creds.is_empty())
|| (matches!(self.cap_tab, CapTab::Otp)
&& self
.otp
.rows
.iter()
.any(|r| r.type_str.eq_ignore_ascii_case("TOTP") && r.code.is_some()))
|| self
.selected_device()
.is_some_and(|d| d.kind == DeviceKind::Token);
if animating {
ctx.request_repaint_after(std::time::Duration::from_millis(500));
}
}
}
impl App {
fn help_dot(&mut self, ui: &mut egui::Ui, p: &Palette, topic: &'static str) {
let r = ui::help_button(ui, p, self.help_open == Some(topic));
if r.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
if r.clicked() {
self.toggle_help(topic, r.rect.left_bottom());
}
}
fn top_bar(&mut self, root_ui: &mut egui::Ui, p: &Palette) {
let ctx = root_ui.ctx().clone();
let zoom = theme::clamp_zoom(ctx.zoom_factor());
let bar_h = (52.0 * zoom).max(52.0);
egui::Panel::top("bar")
.exact_size(bar_h)
.frame(panel_frame(p.bar, 16.0, 0.0))
.show(root_ui, |ui| {
ui.horizontal_centered(|ui| {
glyph_tile(ui, 26.0, p.brand, p.accent_ink, Some('k'));
ui.add_space(8.0);
ui.label(
egui::RichText::new("keyroost")
.font(theme::f_bold(14.0))
.color(p.txt),
);
ui.add_space(6.0);
ui.label(
egui::RichText::new(VERSION_LABEL)
.font(theme::f_reg(11.0))
.color(p.txt3),
);
ui.add_space(12.0);
theme::status_dot(ui, p.ok, 7.0);
ui.add_space(5.0);
ui.label(
egui::RichText::new(format!("{} connected", self.devices.len()))
.font(theme::f_reg(12.0))
.color(p.txt2),
);
if self.busy() {
ui.add_space(12.0);
ui.spinner();
if let Some(label) = &self.busy_label {
ui.add(
egui::Label::new(
egui::RichText::new(label.as_str())
.font(theme::f_reg(12.0))
.color(p.txt3),
)
.truncate(),
);
}
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if theme::button(ui, p, BtnKind::Ghost, "Refresh").clicked() {
self.schedule_scan_burst();
}
ui.add_space(4.0);
let log_color = if self.log_open { p.accent } else { p.txt2 };
if ui
.add(
egui::Label::new(
egui::RichText::new("Activity log")
.font(theme::f_sb(12.5))
.color(log_color),
)
.sense(egui::Sense::click()),
)
.on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked()
{
if self.log_detached {
ctx.send_viewport_cmd_to(
activity_log_viewport_id(),
egui::ViewportCommand::Focus,
);
} else {
self.log_open = !self.log_open;
}
}
ui.add_space(10.0);
ui.hyperlink_to(
egui::RichText::new("Learn \u{2197}")
.font(theme::f_sb(12.5))
.color(p.txt2),
ui::help::LEARN_BASE,
);
ui.add_space(10.0);
let (trect, tresp) =
ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::click());
paint_theme_icon(ui, trect.center(), 7.0, p.txt2);
if tresp
.on_hover_text("Toggle light / dark")
.on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked()
{
self.mode = match self.mode {
Mode::Dark => Mode::Light,
Mode::Light => Mode::Dark,
};
self.persist_settings();
}
ui.add_space(10.0);
let (erect, eresp) =
ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::click());
paint_eye_icon(
ui,
erect.center(),
if self.colorblind { p.accent } else { p.txt2 },
);
if eresp
.on_hover_text("Colorblind-safe colors")
.on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked()
{
self.colorblind = !self.colorblind;
self.persist_settings();
}
ui.add_space(10.0);
self.text_size_control(ui, p);
ui.add_space(10.0);
for (i, c) in Palette::ACCENTS.iter().enumerate().rev() {
let (rect, resp) = ui
.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::click());
let on = i == self.accent_idx;
ui.painter().circle_filled(
rect.center(),
if on { 6.0 } else { 5.0 },
*c,
);
if on {
ui.painter().circle_stroke(
rect.center(),
7.5,
egui::Stroke::new(1.5, p.txt),
);
}
if resp.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
if resp.clicked() {
self.accent_idx = i;
self.persist_settings();
}
}
});
});
});
}
fn text_size_control(&mut self, ui: &mut egui::Ui, p: &Palette) {
let mut factor = self
.zoom_pending
.unwrap_or_else(|| theme::clamp_zoom(ui.ctx().zoom_factor()));
let pct = (factor * 100.0).round() as i32;
let cell = ui.fonts_mut(|f| {
f.layout_no_wrap("888%".to_owned(), theme::f_sb(12.0), p.txt2)
.size()
});
let (rrect, _) = ui.allocate_exact_size(
egui::vec2(cell.x.ceil(), cell.y.ceil()),
egui::Sense::hover(),
);
ui.painter().text(
rrect.right_center(),
egui::Align2::RIGHT_CENTER,
format!("{pct}%"),
theme::f_sb(12.0),
p.txt2,
);
ui.add_space(6.0);
let (track, track_hot) = match self.mode {
Mode::Light => (theme::darken(p.bar, 0.16), theme::darken(p.bar, 0.26)),
Mode::Dark => (p.raised2, theme::lighten(p.raised2, 0.08)),
};
let style = ui.style_mut();
style.visuals.widgets.inactive.bg_fill = track;
style.visuals.widgets.hovered.bg_fill = track_hot;
style.visuals.widgets.active.bg_fill = p.accent;
style.visuals.widgets.inactive.fg_stroke = egui::Stroke::new(1.0, p.txt2);
style.visuals.widgets.hovered.fg_stroke = egui::Stroke::new(1.0, p.txt);
style.spacing.slider_width = 92.0;
let step = |ui: &mut egui::Ui, glyph: &str| -> egui::Response {
let (rect, resp) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::click());
let hot = resp.hovered();
ui.painter()
.rect_filled(rect, 3.0, if hot { track_hot } else { track });
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
glyph,
theme::f_sb(13.0),
if hot { p.txt } else { p.txt2 },
);
resp.on_hover_cursor(egui::CursorIcon::PointingHand)
};
let plus = step(ui, "+");
if plus.clicked() {
factor = theme::clamp_zoom(factor + 0.01);
self.zoom_pending = Some(factor);
self.zoom_commit_at =
Some(std::time::Instant::now() + std::time::Duration::from_millis(350));
}
ui.add_space(4.0);
let resp = ui.add(
egui::Slider::new(&mut factor, theme::ZOOM_MIN..=theme::ZOOM_MAX)
.show_value(false)
.trailing_fill(true),
);
if resp.dragged() {
self.zoom_pending = Some(theme::clamp_zoom(factor));
self.zoom_commit_at = None;
} else if resp.drag_stopped() {
ui.ctx().set_zoom_factor(theme::clamp_zoom(factor));
self.zoom_pending = None;
self.zoom_commit_at = None;
} else if resp.changed() {
ui.ctx().set_zoom_factor(theme::clamp_zoom(factor));
self.zoom_pending = None;
self.zoom_commit_at = None;
}
let resp = resp
.on_hover_text("Text size — scales the whole interface (Ctrl + / Ctrl − also work)");
ui.add_space(4.0);
let minus = step(ui, "\u{2212}");
if minus.clicked() {
factor = theme::clamp_zoom(factor - 0.01);
self.zoom_pending = Some(factor);
self.zoom_commit_at =
Some(std::time::Instant::now() + std::time::Duration::from_millis(350));
}
if let Some(at) = self.zoom_commit_at {
let settled = (!plus.hovered() && !minus.hovered()) || std::time::Instant::now() >= at;
if settled && !resp.dragged() {
if let Some(f) = self.zoom_pending.take() {
ui.ctx().set_zoom_factor(f);
}
self.zoom_commit_at = None;
} else {
ui.ctx()
.request_repaint_after(at.saturating_duration_since(std::time::Instant::now()));
}
}
ui.add_space(7.0);
ui.label(
egui::RichText::new("Text size")
.font(theme::f_reg(12.0))
.color(p.txt2),
);
if (factor - theme::ZOOM_DEFAULT).abs() > f32::EPSILON {
ui.add_space(8.0);
if ui
.add(
egui::Label::new(
egui::RichText::new("Reset")
.font(theme::f_sb(12.0))
.color(p.accent),
)
.sense(egui::Sense::click()),
)
.on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked()
{
ui.ctx().set_zoom_factor(theme::ZOOM_DEFAULT);
self.zoom_pending = None;
self.zoom_commit_at = None;
}
}
}
fn device_sidebar(&mut self, root_ui: &mut egui::Ui, p: &Palette) {
egui::Panel::left("devices")
.exact_size(286.0)
.resizable(false)
.frame(panel_frame(p.side, 14.0, 12.0))
.show(root_ui, |ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("DEVICES")
.font(theme::f_bold(11.0))
.color(p.txt3),
);
ui.add_space(6.0);
theme::pill(ui, &self.devices.len().to_string(), p.txt2, p.raised2);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let (rrect, rresp) =
ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::click());
paint_refresh_icon(ui, rrect.center(), 6.5, p.txt2);
if rresp
.on_hover_text("Rescan")
.on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked()
{
self.schedule_scan_burst();
}
});
});
ui.add_space(8.0);
ui.add(
egui::TextEdit::singleline(&mut self.filter)
.hint_text("Filter keys\u{2026}")
.desired_width(f32::INFINITY),
);
ui.add_space(8.0);
if let Some(err) = &self.devices_error {
ui.colored_label(p.err, err);
ui.add_space(6.0);
}
let mut clicked: Option<DeviceId> = None;
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
if self.devices.is_empty() {
ui.add_space(12.0);
ui.vertical_centered(|ui| {
ui.label(
egui::RichText::new("No keys detected yet.")
.font(theme::f_reg(13.0))
.color(p.txt3),
);
});
}
for dev in self
.devices
.iter()
.filter(|d| matches_filter(d, &self.filter))
{
let selected = self.selected_device.as_deref() == Some(dev.id.as_str());
if device_row(ui, p, dev, selected) {
clicked = Some(dev.id.clone());
}
ui.add_space(2.0);
}
});
if let Some(id) = clicked {
self.select_device(id);
}
if !self.devices.is_empty() {
ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| {
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
ui.label(
egui::RichText::new("Several keys plugged in?")
.font(theme::f_reg(12.0))
.color(p.txt3),
);
ui.hyperlink_to(
egui::RichText::new("Give them names")
.font(theme::f_sb(12.0))
.color(p.accent),
ui::help::learn_url("/naming"),
);
});
});
}
});
}
fn activity_log(&mut self, root_ui: &mut egui::Ui, p: &Palette) {
egui::Panel::bottom("log")
.resizable(true)
.default_size(180.0)
.size_range(96.0..=560.0)
.frame(panel_frame(p.bar, 16.0, 10.0))
.show(root_ui, |ui| {
self.activity_log_body(ui, p, false);
});
}
fn activity_log_window(&mut self, ctx: &egui::Context, p: &Palette) {
let mut close_requested = false;
ctx.show_viewport_immediate(
activity_log_viewport_id(),
egui::ViewportBuilder::default()
.with_title("keyroost \u{2014} Activity log")
.with_inner_size([620.0, 340.0])
.with_min_inner_size([340.0, 140.0]),
|ctx, _class| {
egui::CentralPanel::default()
.frame(panel_frame(p.bar, 16.0, 10.0))
.show(ctx, |ui| {
self.activity_log_body(ui, p, true);
});
if ctx.input(|i| i.viewport().close_requested()) {
close_requested = true;
}
},
);
if close_requested {
self.log_open = false;
self.log_detached = false;
}
}
fn activity_log_body(&mut self, ui: &mut egui::Ui, p: &Palette, detached: bool) {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("ACTIVITY LOG")
.font(theme::f_bold(11.0))
.color(p.txt3),
);
ui.add_space(6.0);
theme::pill(ui, "live", p.ok, p.ok_soft());
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let (wrect, wresp) =
ui.allocate_exact_size(egui::vec2(18.0, 32.0), egui::Sense::click());
if detached {
paint_reattach_icon(ui, wrect.center(), p.txt2);
if wresp
.on_hover_text("Dock back into the main window")
.on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked()
{
self.log_detached = false;
}
} else {
paint_detach_icon(ui, wrect.center(), p.txt2);
if wresp
.on_hover_text("Open in a separate window")
.on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked()
{
self.log_detached = true;
}
ui.add_space(6.0);
if theme::button(ui, p, BtnKind::Ghost, "Collapse").clicked() {
self.log_open = false;
}
}
ui.add_space(4.0);
if theme::button(ui, p, BtnKind::Ghost, "Clear").clicked() {
self.log.clear();
}
ui.add_space(4.0);
if theme::button(ui, p, BtnKind::Ghost, "Copy").clicked() {
let all = self
.log
.iter()
.map(|l| match &l.trace {
Some(t) if !t.is_empty() => format!(
"{}\n{}",
l.text,
t.iter()
.map(|apdu| format!(" {apdu}"))
.collect::<Vec<_>>()
.join("\n")
),
_ => l.text.clone(),
})
.collect::<Vec<_>>()
.join("\n");
ui.ctx().copy_text(all);
self.clipboard_clear_at = None;
}
ui.add_space(10.0);
let mut trace_on = self.apdu_trace.load(std::sync::atomic::Ordering::Relaxed);
if ui.checkbox(&mut trace_on, "APDU trace").changed() {
self.apdu_trace
.store(trace_on, std::sync::atomic::Ordering::Relaxed);
}
});
});
ui.add_space(6.0);
egui::ScrollArea::vertical()
.stick_to_bottom(true)
.auto_shrink([false, false])
.show(ui, |ui| {
for line in self.log.iter_mut() {
let base = match line.severity {
Severity::Ok => p.ok,
Severity::Warn => p.warn,
Severity::Err => p.err,
Severity::Info => p.txt2,
};
let (color, prefix, size) = match line.kind {
LogKind::User => (base, "", 12.0),
LogKind::Background => (theme::tint(base, 130), "\u{b7} ", 11.5),
};
ui.label(
egui::RichText::new(format!("{prefix}{}", line.text))
.font(theme::f_mono(size))
.color(color),
);
if let Some(trace) = line.trace.as_ref() {
for apdu in trace {
ui.label(
egui::RichText::new(format!(" {apdu}"))
.font(theme::f_mono(10.5))
.color(p.txt3),
);
}
}
}
});
}
fn central(&mut self, root_ui: &mut egui::Ui, p: &Palette) {
egui::CentralPanel::default()
.frame(panel_frame(p.surface, 0.0, 0.0))
.show(root_ui, |ui| {
match self.selected_device().cloned() {
None => self.empty_state(ui, p),
Some(dev) if dev.kind == DeviceKind::Token => self.molto_view(ui, p, &dev),
Some(dev) if dev.kind == DeviceKind::ProgToken => self.prog_view(ui, p, &dev),
Some(dev) => {
let hmargin = (((ui.available_width() - 920.0) * 0.5).max(26.0)).round();
egui::Frame::NONE
.inner_margin(egui::Margin::symmetric(hmargin as i8, 16))
.show(ui, |ui| {
self.device_hero(ui, p, &dev);
self.cap_tabs(ui, p, &dev);
ui.add_space(16.0);
egui::ScrollArea::vertical()
.id_salt(("cap-pane", self.cap_tab as u8))
.auto_shrink([false, false])
.show(ui, |ui| {
match self.cap_tab {
CapTab::Overview => self.overview(ui, p, &dev),
CapTab::Fido2 => self.cap_fido2(ui, p),
CapTab::Oath => self.cap_oath(ui, p),
CapTab::Pgp => self.cap_pgp(ui, p),
CapTab::Piv => self.cap_piv(ui, p),
CapTab::Otp => self.cap_otp(ui, p),
}
ui.add_space(18.0);
});
});
}
}
});
}
fn empty_state(&mut self, ui: &mut egui::Ui, p: &Palette) {
let col_w = 440.0_f32;
let pad = ((ui.available_width() - col_w) * 0.5).max(12.0);
ui.add_space(90.0);
ui.horizontal(|ui| {
ui.add_space(pad);
ui.allocate_ui_with_layout(
egui::vec2(col_w, ui.available_height()),
egui::Layout::top_down(egui::Align::Center),
|ui| {
let (rect, _) = ui.allocate_exact_size(egui::vec2(64.0, 64.0), egui::Sense::hover());
ui.painter().rect_stroke(
rect,
egui::CornerRadius::same(16),
egui::Stroke::new(1.5, p.line),
egui::StrokeKind::Inside,
);
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
"\u{1F511}",
theme::f_reg(26.0),
p.txt3,
);
ui.add_space(18.0);
ui.label(egui::RichText::new("Plug in a security key to begin").font(theme::f_bold(19.0)).color(p.txt));
ui.add_space(8.0);
ui.label(
egui::RichText::new(
"keyroost manages YubiKeys, Nitrokeys, SoloKeys and Token2 tokens. Connect one over USB and it shows up in the list on the left.",
)
.font(theme::f_reg(13.0))
.color(p.txt2),
);
ui.add_space(22.0);
ui.allocate_ui_with_layout(egui::vec2(360.0, 0.0), egui::Layout::top_down(egui::Align::Min), |ui| {
for (n, step) in [
"Insert your key into a USB port",
"It appears in the Devices list automatically",
"Select it to view and manage everything it can do",
]
.iter()
.enumerate()
{
ui.horizontal(|ui| {
let (badge, _) = ui.allocate_exact_size(egui::vec2(22.0, 22.0), egui::Sense::hover());
ui.painter().circle_filled(badge.center(), 11.0, p.accent_soft());
ui.painter().text(
badge.center(),
egui::Align2::CENTER_CENTER,
format!("{}", n + 1),
theme::f_sb(12.0),
p.accent,
);
ui.add_space(10.0);
ui.label(egui::RichText::new(*step).font(theme::f_reg(13.0)).color(p.txt));
});
ui.add_space(10.0);
}
});
ui.add_space(14.0);
ui.horizontal(|ui| {
if theme::button(ui, p, BtnKind::Primary, "Scan for devices").clicked() {
self.schedule_scan_burst();
}
ui.add_space(8.0);
ui.hyperlink_to(
egui::RichText::new("Supported devices \u{2197}").font(theme::f_sb(12.5)).color(p.accent),
ui::help::learn_url("/devices"),
);
});
if !keyroost_hid::hid_supported() {
ui.add_space(16.0);
ui.label(
egui::RichText::new(
"Note: FIDO / passkey security keys aren't supported on this platform yet. Smart-card features (OATH, OpenPGP, PIV, Token2 Molto2) work over PC/SC.",
)
.font(theme::f_reg(12.5))
.color(p.txt3),
);
}
},
);
});
}
fn device_hero(&mut self, ui: &mut egui::Ui, p: &Palette, dev: &Device) {
let mut open_rename = false;
let mut do_save = false;
let mut do_cancel = false;
ui.horizontal(|ui| {
glyph_tile(ui, 46.0, p.raised2, p.txt2, Some(dev.vendor.chars().next().unwrap_or('?').to_ascii_uppercase()));
ui.add_space(12.0);
ui.vertical(|ui| {
ui.horizontal(|ui| {
if self.rename_target.is_some() {
let resp = ui.add_sized(
[200.0, 32.0],
egui::TextEdit::singleline(&mut self.rename_input)
.vertical_align(egui::Align::Center)
.hint_text("friendly-name"),
);
let enter = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
if theme::button(ui, p, BtnKind::Primary, "Save").clicked() || enter {
do_save = true;
}
ui.add_space(4.0);
if theme::button(ui, p, BtnKind::Ghost, "Cancel").clicked() {
do_cancel = true;
}
} else {
ui.label(egui::RichText::new(dev.title()).font(theme::f_bold(21.0)).color(p.txt));
ui.add_space(5.0);
self.help_dot(ui, p, "device");
ui.add_space(8.0);
let label = if dev.name.is_some() { "Rename" } else { "Name this key" };
if ui
.add(
egui::Label::new(egui::RichText::new(label).font(theme::f_sb(12.0)).color(p.accent))
.sense(egui::Sense::click()),
)
.clicked()
{
open_rename = true;
}
}
});
if self.rename_target.is_some() {
ui.add_space(3.0);
ui.label(
egui::RichText::new(
"Saves this key's serial with the name to keys.json on this computer \u{2014} nothing leaves your machine. Up to 64 characters.",
)
.font(theme::f_reg(11.5))
.color(p.txt3),
);
}
ui.add_space(2.0);
let serial = if dev.serial.is_empty() { "\u{2014}".to_string() } else { dev.serial.clone() };
let mut meta = format!("{} \u{00B7} #{} \u{00B7} {}", dev.vendor, serial, dev.transport);
if !dev.firmware.is_empty() {
meta.push_str(&format!(" \u{00B7} fw {}", dev.firmware));
}
ui.label(egui::RichText::new(meta).font(theme::f_reg(12.5)).color(p.txt2));
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(egui::RichText::new("Connected").font(theme::f_sb(12.5)).color(p.txt2));
ui.add_space(5.0);
theme::status_dot(ui, p.ok, 8.0);
});
});
self.apply_rename_actions(dev, open_rename, do_cancel, do_save);
ui.add_space(14.0);
let y = ui.cursor().top();
ui.painter()
.hline(ui.max_rect().x_range(), y, egui::Stroke::new(1.0, p.line));
}
fn cap_tabs(&mut self, ui: &mut egui::Ui, p: &Palette, dev: &Device) {
self.cap_tab = snap_cap_tab(self.cap_tab, &dev.tabs());
ui.add_space(12.0);
let mut next = None;
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 20.0;
for t in dev.tabs() {
let active = self.cap_tab == t;
let color = if active { p.txt } else { p.txt3 };
let unverified = dev.tab_unverified(t);
let label = if unverified {
format!("{} ?", cap_tab_label(t))
} else {
cap_tab_label(t).to_string()
};
let mut resp = ui
.add(
egui::Label::new(
egui::RichText::new(label)
.font(theme::f_sb(13.5))
.color(color),
)
.sense(egui::Sense::click()),
)
.on_hover_cursor(egui::CursorIcon::PointingHand);
if unverified {
resp = resp.on_hover_text(
"Not verified: no smart-card reader was available to \
confirm this feature on the key (reader missing or the \
smart-card service is not running). It is offered \
anyway — using it gives a definite answer.",
);
}
if active {
let y = resp.rect.bottom() + 6.0;
ui.painter().line_segment(
[
egui::pos2(resp.rect.left(), y),
egui::pos2(resp.rect.right(), y),
],
egui::Stroke::new(2.0, p.accent),
);
}
if resp.clicked() {
next = Some(t);
}
}
});
if let Some(t) = next {
self.cap_tab = t;
}
}
fn card_head(
&mut self,
ui: &mut egui::Ui,
p: &Palette,
title: &str,
topic: &'static str,
) -> bool {
let mut go = false;
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(title)
.font(theme::f_sb(14.5))
.color(p.txt),
);
ui.add_space(6.0);
self.help_dot(ui, p, topic);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(
egui::Label::new(
egui::RichText::new("Manage \u{2192}")
.font(theme::f_sb(12.5))
.color(p.accent),
)
.sense(egui::Sense::click()),
)
.on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked()
{
go = true;
}
});
});
go
}
fn mds_card(&mut self, ui: &mut egui::Ui, p: &Palette, _dev: &Device) {
let Some(aaguid) = self.security_keys.info.as_ref().map(|i| i.aaguid) else {
return;
};
let Some(entry) = self.mds.get(&aaguid).cloned() else {
return;
};
let key = ui::aaguid::format_aaguid_pub(&aaguid);
let device_versions: Vec<String> = self
.security_keys
.info
.as_ref()
.map(|i| i.versions.clone())
.unwrap_or_default();
if let Some(icon_uri) = entry.icon.as_deref() {
let need = self
.mds_icon
.as_ref()
.map(|(k, _)| k != &key)
.unwrap_or(true);
if need {
if let Some(img) = ui::mds::decode_icon(icon_uri) {
let tex = ui.ctx().load_texture(
format!("mds-icon-{key}"),
img,
egui::TextureOptions::LINEAR,
);
self.mds_icon = Some((key.clone(), tex));
} else {
self.mds_icon = None;
}
}
} else {
self.mds_icon = None;
}
theme::card_frame(p).show(ui, |ui| {
ui.set_min_width(ui.available_width());
self.card_head_plain(ui, p, "Device metadata (FIDO MDS)", "mds");
ui.add_space(8.0);
ui.horizontal(|ui| {
if let Some((k, tex)) = &self.mds_icon {
if k == &key {
let side = 44.0;
ui.add(
egui::Image::from_texture(tex)
.fit_to_exact_size(egui::vec2(side, side)),
);
ui.add_space(12.0);
}
}
ui.vertical(|ui| {
if !entry.description.is_empty() {
ui.label(
egui::RichText::new(&entry.description)
.font(theme::f_sb(14.0))
.color(p.txt),
);
ui.add_space(3.0);
}
if let Some(label) = entry.certification_label() {
let (fg, bg) = if entry.is_advisory() {
(p.err, p.warn_soft())
} else {
(p.ok, p.ok_soft())
};
theme::pill(ui, &label, fg, bg);
}
});
});
ui.add_space(12.0);
let mut pairs: Vec<(&str, String)> = Vec::new();
if let Some(lvl) = entry.certification_level() {
pairs.push(("Certification level", lvl.to_string()));
}
if let Some(date) = &entry.effective_date {
let d = date.split(['T', ' ']).next().unwrap_or(date);
pairs.push(("Certified since", d.to_string()));
}
if let Some(fam) = &entry.protocol_family {
pairs.push(("Protocol family", fam.clone()));
}
let avail = ui.available_width();
let cols = if avail >= 760.0 {
3
} else if avail >= 470.0 {
2
} else {
1
};
let cell_w = (avail - 18.0 * (cols as f32 - 1.0)) / cols as f32;
egui::Grid::new("mds-meta-grid")
.num_columns(cols)
.spacing(egui::vec2(18.0, 10.0))
.min_col_width(0.0)
.show(ui, |ui| {
for (i, (k, v)) in pairs.iter().enumerate() {
mds_cell(ui, p, k, v, cell_w);
if (i + 1) % cols == 0 {
ui.end_row();
}
}
if !pairs.len().is_multiple_of(cols) {
ui.end_row();
}
});
let versions = if !device_versions.is_empty() {
device_versions
} else {
entry.mds_versions.clone()
};
ui.add_space(8.0);
if !versions.is_empty() {
mds_kv(ui, p, "Versions", &versions.join(", "));
}
mds_kv(ui, p, "AAGUID", &key);
});
ui.add_space(14.0);
}
fn card_head_plain(
&mut self,
ui: &mut egui::Ui,
p: &Palette,
title: &str,
topic: &'static str,
) {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(title)
.font(theme::f_sb(14.5))
.color(p.txt),
);
ui.add_space(6.0);
self.help_dot(ui, p, topic);
});
}
fn overview(&mut self, ui: &mut egui::Ui, p: &Palette, dev: &Device) {
ui.vertical(|ui| {
self.mds_card(ui, p, dev);
if dev.caps.has(Caps::FIDO2) {
theme::card_frame(p).show(ui, |ui| {
if self.card_head(ui, p, "Passkeys & sign-in (FIDO2)", "fido2") {
self.cap_tab = CapTab::Fido2;
}
ui.add_space(8.0);
#[cfg(windows)]
let non_admin = self.security_keys.info.is_none()
&& keyroost_winwebauthn::fido_key_present();
#[cfg(not(windows))]
let non_admin = false;
if non_admin {
theme::pill(ui, "Administrator rights needed", p.warn, p.warn_soft());
ui.add_space(6.0);
ui.label(
egui::RichText::new(
"Open the FIDO2 tab to manage this key via Windows \
settings or restart as administrator.",
)
.font(theme::f_reg(13.0))
.color(p.txt2),
);
} else {
match self
.security_keys
.info
.as_ref()
.and_then(|i| i.option("clientPin"))
{
Some(true) => {
ui.horizontal(|ui| {
theme::pill(ui, "PIN set", p.ok, p.ok_soft());
ui.add_space(8.0);
ui.label(
egui::RichText::new(
"PIN configured \u{00B7} ready for passkeys",
)
.font(theme::f_reg(13.0))
.color(p.txt2),
);
});
}
Some(false) => {
theme::pill(ui, "No PIN configured", p.warn, p.warn_soft());
}
None => {
ui.label(
egui::RichText::new("Reading key\u{2026}")
.font(theme::f_reg(13.0))
.color(p.txt3),
);
}
}
}
});
ui.add_space(14.0);
}
if dev.caps.has(Caps::OATH) {
theme::card_frame(p).show(ui, |ui| {
if self.card_head(ui, p, "Authenticator codes (OATH)", "oath") {
self.cap_tab = CapTab::Oath;
}
ui.add_space(8.0);
if self.oath.loaded && !self.oath.creds.is_empty() {
let total = self.oath.creds.len();
let mut copy = None;
for row in self.oath.creds.iter().take(2) {
let is_copied =
self.copied.as_ref().is_some_and(|(n, _)| n == &row.name);
if let (Some(code), _, _) = oath_row(
ui,
p,
&row.name,
row.code.as_deref(),
row.hotp,
is_copied,
false,
) {
copy = Some((row.name.clone(), code));
}
ui.add_space(6.0);
}
if total > 2 {
ui.label(
egui::RichText::new(format!("+{} more codes", total - 2))
.font(theme::f_reg(12.5))
.color(p.txt3),
);
}
if let Some((name, code)) = copy {
ui.ctx().copy_text(code.clone());
self.copied = Some((name, now_secs_f64() + 1.2));
self.clipboard_clear_at = Some((code, now_secs_f64() + 45.0));
}
} else {
ui.label(
egui::RichText::new("Open Authenticator to view live codes.")
.font(theme::f_reg(13.0))
.color(p.txt3),
);
}
});
ui.add_space(14.0);
}
if dev.caps.has(Caps::PGP) {
theme::card_frame(p).show(ui, |ui| {
if self.card_head(ui, p, "OpenPGP", "pgp") {
self.cap_tab = CapTab::Pgp;
}
ui.add_space(8.0);
if let Some(st) = &self.openpgp.status {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 5.0;
theme::pill(
ui,
&format!(
"Signature \u{00B7} {}",
slot_summary(&st.sig_attrs, &st.fingerprint_sig)
),
p.txt2,
p.raised2,
);
theme::pill(
ui,
&format!(
"Encryption \u{00B7} {}",
slot_summary(&st.dec_attrs, &st.fingerprint_dec)
),
p.txt2,
p.raised2,
);
theme::pill(
ui,
&format!(
"Auth \u{00B7} {}",
slot_summary(&st.aut_attrs, &st.fingerprint_aut)
),
p.txt2,
p.raised2,
);
});
} else {
ui.label(
egui::RichText::new("Open OpenPGP and Read status to view key slots.")
.font(theme::f_reg(13.0))
.color(p.txt3),
);
}
});
ui.add_space(14.0);
}
if dev.caps.has(Caps::PIV) {
theme::card_frame(p).show(ui, |ui| {
if self.card_head(ui, p, "PIV smart card", "piv") {
self.cap_tab = CapTab::Piv;
}
ui.add_space(8.0);
if let Some(st) = &self.piv.status {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 5.0;
for slot in &st.slots {
let lab = format!(
"{:02X} \u{00B7} {}",
slot.slot.key_ref(),
if slot.cert_unreadable.is_some() {
"unreadable cert"
} else if slot.cert_present {
"cert"
} else {
"empty"
}
);
theme::pill(ui, &lab, p.txt2, p.raised2);
}
});
} else {
ui.label(
egui::RichText::new("Open PIV to read certificate slots.")
.font(theme::f_reg(13.0))
.color(p.txt3),
);
}
});
}
if dev.caps.has(Caps::OTP) {
ui.add_space(14.0);
theme::card_frame(p).show(ui, |ui| {
if self.card_head(ui, p, "On-device OTP", "otp") {
self.cap_tab = CapTab::Otp;
}
ui.add_space(8.0);
if self.otp.loaded && !self.otp.rows.is_empty() {
let total = self.otp.rows.len();
for row in self.otp.rows.iter().take(2) {
let label = if row.account_name.is_empty() {
row.app_name.clone()
} else {
format!("{} \u{00B7} {}", row.app_name, row.account_name)
};
ui.horizontal(|ui| {
theme::pill(ui, row.type_str, p.txt2, p.raised2);
ui.label(
egui::RichText::new(label)
.font(theme::f_reg(13.0))
.color(p.txt2),
);
});
ui.add_space(4.0);
}
if total > 2 {
ui.label(
egui::RichText::new(format!("+{} more entries", total - 2))
.font(theme::f_reg(12.5))
.color(p.txt3),
);
}
} else {
ui.label(
egui::RichText::new("Open On-device OTP to view stored entries.")
.font(theme::f_reg(13.0))
.color(p.txt3),
);
}
});
}
if dev.kind == DeviceKind::Key {
let plan = keyroost_resolve::factory_reset_plan(dev.caps);
if !plan.is_empty() {
ui.add_space(14.0);
let mut arm = false;
theme::card_frame(p)
.stroke(egui::Stroke::new(1.0, theme::tint(p.err, 90)))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Factory reset")
.font(theme::f_sb(14.5))
.color(p.err),
);
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if theme::button(
ui,
p,
BtnKind::Danger,
"Factory reset\u{2026}",
)
.clicked()
{
arm = true;
}
},
);
});
ui.label(
egui::RichText::new(format!(
"Resets every applet on this key ({}) to factory state. \
Each step reports on its own \u{2014} anything that \
doesn't complete is listed below.",
plan.iter()
.map(|s| s.label())
.collect::<Vec<_>>()
.join(", ")
))
.font(theme::f_reg(12.5))
.color(p.txt2),
);
if !self.factory_reset_report.is_empty() {
ui.add_space(8.0);
for row in &self.factory_reset_report {
let (text, tone) = factory_reset_row_line(row);
let color = match tone {
RowTone::Done => p.ok,
RowTone::Waiting | RowTone::Warn => p.warn,
RowTone::Bad => p.err,
RowTone::Muted => p.txt3,
};
ui.label(
egui::RichText::new(text)
.font(theme::f_reg(12.5))
.color(color),
);
}
}
});
if arm {
self.start_factory_reset_confirm();
}
}
}
});
}
#[cfg(windows)]
fn fido2_non_admin_card(&mut self, ui: &mut egui::Ui, p: &Palette) {
theme::card_frame(p).show(ui, |ui| {
ui.label(
egui::RichText::new("Administrator rights needed")
.font(theme::f_sb(14.5))
.color(p.txt),
);
ui.add_space(8.0);
ui.label(
egui::RichText::new(
"A security key is connected, but managing its FIDO2 settings \
(PIN, passkeys, reset, fingerprints) in this app requires \
administrator rights on Windows.",
)
.font(theme::f_reg(13.0))
.color(p.txt2),
);
ui.add_space(4.0);
ui.label(
egui::RichText::new(
"Change the PIN, manage biometrics or reset the key without \
admin rights using Windows' built-in security-key settings, \
or restart this app as administrator for full management here.",
)
.font(theme::f_reg(13.0))
.color(p.txt2),
);
ui.add_space(12.0);
ui.horizontal(|ui| {
if theme::button(
ui,
p,
BtnKind::Primary,
"Open Windows security-key settings",
)
.clicked()
{
if let Err(e) = keyroost_winwebauthn::open_windows_security_key_settings() {
self.security_keys.error =
Some(format!("Couldn't open Windows security-key settings: {e}"));
}
}
if theme::button(ui, p, BtnKind::Default, "Restart as administrator").clicked() {
match keyroost_winwebauthn::relaunch_as_admin() {
Ok(()) => std::process::exit(0),
Err(e) => {
self.security_keys.error =
Some(format!("Couldn't restart as administrator: {e}"));
}
}
}
});
});
}
fn cap_fido2(&mut self, ui: &mut egui::Ui, p: &Palette) {
#[cfg(windows)]
{
let no_access = self.security_keys.info.is_none();
if no_access && keyroost_winwebauthn::fido_key_present() {
self.fido2_non_admin_card(ui, p);
return;
}
}
let pin = pin_support(self.security_keys.info.as_ref());
let mut go_set = false;
let mut go_change = false;
let mut cancel = false;
theme::card_frame(p).show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("PIN & sign-in")
.font(theme::f_sb(14.5))
.color(p.txt),
);
ui.add_space(6.0);
self.help_dot(ui, p, "pin");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let (kind, label) = match pin {
PinSupport::Set => (BtnKind::Default, "Change PIN"),
PinSupport::NotSet => (BtnKind::Primary, "Set a PIN"),
PinSupport::Unknown | PinSupport::Unsupported => return,
};
if theme::button(ui, p, kind, label).clicked() {
let open = !self.security_keys.change_pin.open;
self.security_keys.change_pin = ChangePinDialog::default();
self.security_keys.change_pin.open = open;
self.security_keys.error = None;
}
if self.security_keys.session.is_some() {
ui.add_space(6.0);
if theme::button(ui, p, BtnKind::Ghost, "Lock").clicked() {
self.lock_session();
}
}
});
});
ui.add_space(8.0);
match pin {
PinSupport::Set => {
ui.horizontal(|ui| {
theme::pill(ui, "PIN set", p.ok, p.ok_soft());
ui.add_space(8.0);
let hint = if self.security_keys.session.is_some() {
"This key has a PIN."
} else {
"This key has a PIN. Unlock below to manage it."
};
ui.label(
egui::RichText::new(hint)
.font(theme::f_reg(13.0))
.color(p.txt2),
);
});
}
PinSupport::NotSet => {
ui.horizontal(|ui| {
theme::pill(ui, "No PIN yet", p.warn, p.warn_soft());
ui.add_space(8.0);
ui.label(
egui::RichText::new(
"Set a PIN to protect this key and turn on passkeys.",
)
.font(theme::f_reg(13.0))
.color(p.txt2),
);
});
}
PinSupport::Unsupported => {
ui.horizontal(|ui| {
theme::pill(ui, "No PIN support", p.txt2, p.raised2);
ui.add_space(8.0);
ui.label(
egui::RichText::new("This key does not support a PIN.")
.font(theme::f_reg(13.0))
.color(p.txt2),
);
});
}
PinSupport::Unknown => {
let msg = if self.security_keys.error.is_some() {
"Couldn't read this key."
} else {
if self.security_keys.info.is_none() && !self.busy() {
self.fetch_selected_info();
}
"Reading key\u{2026}"
};
ui.label(
egui::RichText::new(msg)
.font(theme::f_reg(13.0))
.color(p.txt3),
);
}
}
if self.security_keys.change_pin.open {
let setting = pin == PinSupport::NotSet;
ui.add_space(10.0);
egui::Frame::NONE
.fill(p.raised)
.inner_margin(egui::Margin::same(12))
.corner_radius(egui::CornerRadius::same(8))
.show(ui, |ui| {
ui.label(
egui::RichText::new(if setting {
"Create a PIN"
} else {
"Change PIN"
})
.font(theme::f_sb(13.0))
.color(p.txt),
);
ui.add_space(8.0);
if setting {
pin_field(ui, p, "New PIN", &mut self.security_keys.change_pin.new);
pin_field(ui, p, "Confirm", &mut self.security_keys.change_pin.confirm);
} else {
pin_field(ui, p, "Current PIN", &mut self.security_keys.change_pin.old);
pin_field(ui, p, "New PIN", &mut self.security_keys.change_pin.new);
pin_field(
ui,
p,
"Confirm new PIN",
&mut self.security_keys.change_pin.confirm,
);
}
ui.add_space(8.0);
ui.horizontal(|ui| {
if theme::button(
ui,
p,
BtnKind::Primary,
if setting { "Set PIN" } else { "Change PIN" },
)
.clicked()
{
if setting {
go_set = true;
} else {
go_change = true;
}
}
ui.add_space(6.0);
if theme::button(ui, p, BtnKind::Ghost, "Cancel").clicked() {
cancel = true;
}
});
ui.add_space(4.0);
ui.label(
egui::RichText::new("4\u{2013}63 characters.")
.font(theme::f_reg(11.5))
.color(p.txt3),
);
});
}
if let Some(err) = &self.security_keys.error {
ui.add_space(6.0);
ui.colored_label(p.err, err);
}
});
if cancel {
self.security_keys.change_pin = ChangePinDialog::default();
}
if go_set {
self.submit_set_pin();
}
if go_change {
self.submit_change_pin();
}
let unlocked = self.security_keys.session.is_some();
let has_bio = self.bio_cmd_code().is_some();
let supports_cfg = self
.security_keys
.info
.as_ref()
.and_then(|i| i.option("authnrCfg"))
== Some(true);
let settings_available = fido_settings_available(self.security_keys.info.as_ref());
let has_cred_mgmt = self.security_keys.info.as_ref().is_some_and(|i| {
i.option("credMgmt") == Some(true) || i.option("credentialMgmtPreview") == Some(true)
});
let has_large_blobs = self
.security_keys
.info
.as_ref()
.and_then(|i| i.option("largeBlobs"))
== Some(true);
if pin == PinSupport::Set && !unlocked {
ui.add_space(14.0);
let mut unlock = false;
theme::card_frame(p).show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Unlock this key")
.font(theme::f_sb(14.5))
.color(p.txt),
);
ui.add_space(6.0);
self.help_dot(ui, p, "unlock");
});
ui.add_space(8.0);
ui.horizontal(|ui| {
let resp = ui.add_sized(
[220.0, 32.0],
egui::TextEdit::singleline(&mut self.security_keys.pin_input)
.vertical_align(egui::Align::Center)
.password(true)
.hint_text("Enter PIN to unlock this key"),
);
guard_secret_field(ui.ctx(), &resp);
let submit = theme::button(ui, p, BtnKind::Primary, "Unlock").clicked();
if submit
|| (resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)))
{
unlock = true;
}
});
let extras = match (has_bio, settings_available) {
(true, true) => "passkeys, fingerprints, and settings",
(true, false) => "passkeys and fingerprints",
(false, true) => "passkeys and settings",
(false, false) => "passkeys",
};
ui.add_space(4.0);
ui.label(
egui::RichText::new(format!("Unlock to manage {extras}."))
.font(theme::f_reg(11.5))
.color(p.txt3),
);
});
if unlock {
self.try_unlock();
}
if supports_cfg {
let info = self.security_keys.info.as_ref();
let always_uv = info.and_then(|i| i.option("alwaysUv"));
let min_pin = info.and_then(|i| i.min_pin_length);
let force_change = info.and_then(|i| i.force_pin_change);
let ep = info.and_then(|i| i.option("ep"));
ui.add_space(14.0);
theme::card_frame(p).show(ui, |ui| {
ui.label(
egui::RichText::new("Security policy")
.font(theme::f_sb(14.5))
.color(p.txt),
);
ui.add_space(8.0);
let row = |ui: &mut egui::Ui, label: &str, value: String| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(label)
.font(theme::f_reg(12.0))
.color(p.txt3),
);
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
ui.label(
egui::RichText::new(value)
.font(theme::f_sb(12.0))
.color(p.txt),
);
},
);
});
};
row(
ui,
"Always require user verification",
match always_uv {
Some(true) => "On".to_string(),
Some(false) => "Off".to_string(),
None => "\u{2014}".to_string(),
},
);
ui.add_space(4.0);
row(
ui,
"Minimum PIN length",
match min_pin {
Some(n) => n.to_string(),
None => "\u{2014}".to_string(),
},
);
if force_change == Some(true) {
ui.add_space(4.0);
row(ui, "Force PIN change", "Required on next use".to_string());
}
if let Some(ep) = ep {
ui.add_space(4.0);
row(
ui,
"Enterprise attestation",
if ep { "Enabled" } else { "Supported" }.to_string(),
);
}
ui.add_space(8.0);
ui.label(
egui::RichText::new("Unlock to change these.")
.font(theme::f_reg(11.5))
.color(p.txt3),
);
});
}
}
if show_standalone_reset(unlocked, self.security_keys.info.as_ref()) {
ui.add_space(14.0);
self.render_fido_reset_card(ui, p);
}
if unlocked {
let mut tabs: Vec<(FidoSubview, &str)> = Vec::new();
if has_cred_mgmt {
tabs.push((FidoSubview::Passkeys, "Passkeys"));
}
if has_bio {
tabs.push((FidoSubview::Fingerprints, "Fingerprints"));
}
if settings_available {
tabs.push((FidoSubview::Settings, "Settings"));
}
if has_large_blobs {
tabs.push((FidoSubview::LargeBlobs, "Storage"));
}
self.security_keys.subview = snap_subview(self.security_keys.subview, &tabs);
ui.add_space(14.0);
let mut next: Option<FidoSubview> = None;
{
let top = ui.cursor().top();
let strip = egui::Rect::from_min_max(
egui::pos2(ui.max_rect().left(), top - 4.0),
egui::pos2(ui.max_rect().right(), top + 30.0),
);
ui.painter().rect_filled(strip, 0.0, p.surface);
}
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 20.0;
for (view, label) in &tabs {
let active = self.security_keys.subview == *view;
let color = if active { p.txt } else { p.txt3 };
let resp = ui
.add(
egui::Label::new(
egui::RichText::new(*label)
.font(theme::f_sb(13.5))
.color(color),
)
.sense(egui::Sense::click()),
)
.on_hover_cursor(egui::CursorIcon::PointingHand);
if active {
let y = resp.rect.bottom() + 6.0;
ui.painter().line_segment(
[
egui::pos2(resp.rect.left(), y),
egui::pos2(resp.rect.right(), y),
],
egui::Stroke::new(2.0, p.accent),
);
}
if resp.clicked() {
next = Some(*view);
}
}
});
if let Some(v) = next {
self.security_keys.subview = v;
}
} else {
self.security_keys.subview = FidoSubview::Passkeys;
}
let subview = self.security_keys.subview;
if unlocked && subview == FidoSubview::Passkeys {
ui.add_space(14.0);
let mut reload = false;
let mut delete: Option<Vec<u8>> = None;
let mut save_cert: Option<(String, zeroize::Zeroizing<[u8; 32]>)> = None;
theme::card_frame(p).show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Resident passkeys")
.font(theme::f_sb(14.5))
.color(p.txt),
);
ui.add_space(6.0);
self.help_dot(ui, p, "passkeys");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if theme::button(ui, p, BtnKind::Default, "Reload").clicked() {
reload = true;
}
});
});
ui.add_space(8.0);
let session_info = self.security_keys.session.as_ref().map(|s| {
(
s.metadata.existing_count,
s.metadata.max_remaining,
s.rps.clone(),
)
});
if let Some((existing, max_remaining, rps)) = session_info {
ui.label(
egui::RichText::new(format!(
"{existing} stored \u{00B7} room for {max_remaining} more"
))
.font(theme::f_reg(12.5))
.color(p.txt2),
);
ui.add_space(6.0);
if rps.is_empty() {
ui.label(
egui::RichText::new("No passkeys stored on this key yet.")
.font(theme::f_reg(13.0))
.color(p.txt3),
);
}
ui.vertical(|ui| {
for (rp, creds) in rps.iter() {
let header =
if let Some(name) = rp.name.as_ref().filter(|s| !s.is_empty()) {
format!("{} ({})", rp.id, name)
} else {
rp.id.clone()
};
ui.collapsing(header, |ui| {
if creds.is_empty() {
ui.label("(no credentials)");
}
for c in creds {
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Min),
|ui| {
if theme::button(ui, p, BtnKind::Ghost, "Remove")
.clicked()
{
delete = Some(c.credential_id.clone());
}
if ssh_cred_offers_cert(
&rp.id,
c.large_blob_key.as_ref(),
) {
ui.add_space(6.0);
if theme::button(
ui,
p,
BtnKind::Ghost,
"Save certificate\u{2026}",
)
.clicked()
{
if let Some(key) = c.large_blob_key {
save_cert = Some((
rp.id.clone(),
zeroize::Zeroizing::new(key),
));
}
}
}
ui.add_space(8.0);
ui.vertical(|ui| {
let row = |ui: &mut egui::Ui,
label: &str,
val: String,
mono: bool| {
if val.is_empty() {
return;
}
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
ui.label(
egui::RichText::new(label)
.font(theme::f_sb(11.5))
.color(p.txt3),
);
let font = if mono {
theme::f_mono(11.5)
} else {
theme::f_reg(12.0)
};
ui.label(
egui::RichText::new(val)
.font(font)
.color(p.txt2),
);
});
};
row(
ui,
"User Name",
c.user.name.clone().unwrap_or_default(),
false,
);
row(
ui,
"Display Name",
c.user.display_name.clone().unwrap_or_default(),
false,
);
let uid = if c.user.id.is_empty() {
String::new()
} else if c
.user
.id
.iter()
.all(|b| b.is_ascii_graphic() || *b == b' ')
{
String::from_utf8_lossy(&c.user.id).into_owned()
} else {
hex_full(&c.user.id)
};
row(ui, "User ID", uid, true);
row(
ui,
"Credential ID",
hex_full(&c.credential_id),
true,
);
});
},
);
ui.separator();
}
});
}
});
}
});
if reload {
self.refresh_credentials();
}
if let Some(id) = delete {
self.delete_credential(id);
}
if let Some((rp_id, key)) = save_cert {
self.save_ssh_cert(rp_id, key);
}
if let Some(status) = self.security_keys.ssh_cert_status.clone() {
ui.add_space(8.0);
theme::card_frame(p).show(ui, |ui| {
for line in status.lines() {
ui.label(
egui::RichText::new(line)
.font(theme::f_reg(12.5))
.color(p.txt2),
);
}
});
}
}
if subview == FidoSubview::Fingerprints
&& self.security_keys.session.is_some()
&& self.bio_cmd_code().is_some()
{
ui.add_space(14.0);
let mut do_enroll = false;
let mut do_refresh_fp = false;
let mut fp_rename_commit: Option<(Vec<u8>, String)> = None;
theme::card_frame(p).show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Fingerprints")
.font(theme::f_sb(14.5))
.color(p.txt),
);
ui.add_space(6.0);
self.help_dot(ui, p, "fingerprint");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if theme::button(ui, p, BtnKind::Default, "Reload").clicked() {
do_refresh_fp = true;
}
});
});
ui.add_space(8.0);
let enrolling = self.security_keys.fp_progress.is_some();
if !enrolling {
match &self.security_keys.fingerprints {
None => {
ui.label(
egui::RichText::new("Click Reload to read enrolled fingerprints.")
.font(theme::f_reg(13.0))
.color(p.txt3),
);
}
Some(list) if list.is_empty() => {
ui.label(
egui::RichText::new("No fingerprints enrolled yet.")
.font(theme::f_reg(13.0))
.color(p.txt3),
);
}
Some(list) => {
for e in list.iter() {
let id = e.template_id.clone();
ui.horizontal(|ui| {
ui.monospace(hex_short(&id));
let renaming = self
.security_keys
.fp_rename
.as_ref()
.is_some_and(|(rid, _)| rid == &id);
if renaming {
if let Some((_, buf)) =
self.security_keys.fp_rename.as_mut()
{
ui.add_sized(
[160.0, 32.0],
egui::TextEdit::singleline(buf)
.vertical_align(egui::Align::Center),
);
}
if theme::button(ui, p, BtnKind::Primary, "Save").clicked()
{
if let Some((rid, buf)) =
self.security_keys.fp_rename.take()
{
fp_rename_commit = Some((rid, buf));
}
}
if theme::button(ui, p, BtnKind::Ghost, "Cancel").clicked()
{
self.security_keys.fp_rename = None;
}
} else {
let name =
e.friendly_name.as_deref().unwrap_or("(unnamed)");
ui.label(name);
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if theme::button(ui, p, BtnKind::Ghost, "Delete")
.clicked()
{
self.security_keys.fp_confirm_delete =
Some(id.clone());
}
if theme::button(ui, p, BtnKind::Ghost, "Rename")
.clicked()
{
self.security_keys.fp_rename = Some((
id.clone(),
e.friendly_name.clone().unwrap_or_default(),
));
}
},
);
}
});
}
}
}
}
ui.add_space(10.0);
if !enrolling {
ui.horizontal(|ui| {
ui.add_sized(
[160.0, 32.0],
egui::TextEdit::singleline(&mut self.security_keys.fp_new_name)
.vertical_align(egui::Align::Center)
.hint_text("name (optional)"),
);
if theme::button(ui, p, BtnKind::Primary, "Enroll new\u{2026}").clicked() {
do_enroll = true;
}
});
ui.label(
egui::RichText::new(
"Enrolling asks you to touch the sensor several times. \
Keyboard-HID is not needed; this uses the FIDO interface.",
)
.font(theme::f_reg(11.0))
.color(p.txt3),
);
}
});
if do_refresh_fp {
self.refresh_fingerprints();
}
if do_enroll {
self.enroll_fingerprint();
}
if let Some((id, name)) = fp_rename_commit {
self.rename_fingerprint(id, name);
}
}
if subview == FidoSubview::Settings {
if supports_cfg {
self.render_fido_advanced(ui, p);
}
ui.add_space(14.0);
self.render_fido_reset_card(ui, p);
}
if subview == FidoSubview::LargeBlobs {
self.render_large_blobs(ui, p);
}
}
fn render_fido_reset_card(&mut self, ui: &mut egui::Ui, p: &Palette) {
let mut arm_reset = false;
theme::card_frame(p)
.stroke(egui::Stroke::new(1.0, theme::tint(p.err, 90)))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Reset this key")
.font(theme::f_sb(14.5))
.color(p.err),
);
ui.add_space(6.0);
self.help_dot(ui, p, "reset");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if theme::button(ui, p, BtnKind::Danger, "Reset key\u{2026}").clicked() {
arm_reset = true;
}
});
});
ui.label(
egui::RichText::new(
"Wipes every passkey and the PIN on this key. Cannot be undone.",
)
.font(theme::f_reg(12.5))
.color(p.txt2),
);
});
if arm_reset {
self.security_keys.reset = ResetDialog {
open: true,
..Default::default()
};
}
}
fn add_large_blob_note(&mut self, text: String) {
let Some(target) = self.selected_fido_target() else {
self.security_keys.lb_status = Some("No FIDO key selected.".into());
return;
};
let Some(session) = self.fido_session() else {
self.security_keys.lb_status =
Some("Unlock the key with your PIN first to add data.".into());
return;
};
let pin = session.pin.clone();
let loaded = self.security_keys.large_blobs.clone();
let for_device = self.selected_device.clone();
self.spawn_job("Saving to large blobs\u{2026}", move || {
let result = (|| -> Result<
(
keyroost_ctap::large_blobs::LargeBlobArray,
keyroost_ctap::large_blobs::BlobCapacity,
),
String,
> {
let (mut dev, cbor, _init) = open_fido(&target)?;
if !cbor {
return Err("device is U2F-only".into());
}
let info = keyroost_ctap::get_info(&mut dev).map_err(|e| e.to_string())?;
let current =
keyroost_ctap::large_blobs::read(&mut dev, &info).map_err(|e| e.to_string())?;
let _ = loaded;
let token = keyroost_ctap::client_pin::get_pin_uv_auth_token(
&mut dev,
&pin,
&info,
keyroost_ctap::client_pin::permissions::LARGE_BLOB_WRITE,
)
.map_err(|e| e.to_string())?;
let updated = current.with_text_note(&text);
let serialized = updated.serialize_with_checksum();
keyroost_ctap::large_blobs::write(&mut dev, &info, &token, &serialized)
.map_err(|e| e.to_string())?;
let array =
keyroost_ctap::large_blobs::read(&mut dev, &info).map_err(|e| e.to_string())?;
let cap = array.capacity(&info);
Ok((array, cap))
})();
Box::new(move |app: &mut App| {
if !completion_still_valid(for_device.as_ref(), app.selected_device.as_ref()) {
return; }
match result {
Ok((array, cap)) => {
let n = array.entries.len();
app.security_keys.large_blobs = Some(array);
app.security_keys.lb_capacity = Some(cap);
app.security_keys.lb_new_text.clear();
app.security_keys.lb_show_add = false;
app.security_keys.lb_status = Some(format!(
"Saved. {n} entr{} total.",
if n == 1 { "y" } else { "ies" }
));
app.security_keys.error = None;
}
Err(e) => {
app.security_keys.lb_status = Some(format!("Save failed: {e}"));
}
}
})
});
}
fn edit_large_blob_note(&mut self, idx: usize, text: String) {
let Some(target) = self.selected_fido_target() else {
self.security_keys.lb_status = Some("No FIDO key selected.".into());
return;
};
let Some(session) = self.fido_session() else {
self.security_keys.lb_status =
Some("Unlock the key with your PIN first to edit notes.".into());
return;
};
let pin = session.pin.clone();
let for_device = self.selected_device.clone();
self.spawn_job("Saving edit to large blobs\u{2026}", move || {
let result = (|| -> Result<
(
keyroost_ctap::large_blobs::LargeBlobArray,
keyroost_ctap::large_blobs::BlobCapacity,
),
String,
> {
let (mut dev, cbor, _init) = open_fido(&target)?;
if !cbor {
return Err("device is U2F-only".into());
}
let info = keyroost_ctap::get_info(&mut dev).map_err(|e| e.to_string())?;
let current =
keyroost_ctap::large_blobs::read(&mut dev, &info).map_err(|e| e.to_string())?;
let updated = current.with_replaced_note(idx, &text).ok_or(
"that entry is not an editable keyroost note (it may have \
changed on the key — reload and try again)",
)?;
let token = keyroost_ctap::client_pin::get_pin_uv_auth_token(
&mut dev,
&pin,
&info,
keyroost_ctap::client_pin::permissions::LARGE_BLOB_WRITE,
)
.map_err(|e| e.to_string())?;
let serialized = updated.serialize_with_checksum();
keyroost_ctap::large_blobs::write(&mut dev, &info, &token, &serialized)
.map_err(|e| e.to_string())?;
let array =
keyroost_ctap::large_blobs::read(&mut dev, &info).map_err(|e| e.to_string())?;
let cap = array.capacity(&info);
Ok((array, cap))
})();
Box::new(move |app: &mut App| {
if !completion_still_valid(for_device.as_ref(), app.selected_device.as_ref()) {
return; }
match result {
Ok((array, cap)) => {
app.security_keys.large_blobs = Some(array);
app.security_keys.lb_capacity = Some(cap);
app.security_keys.lb_editing = None;
app.security_keys.lb_edit_text.clear();
app.security_keys.lb_status = Some("Note updated.".into());
app.security_keys.error = None;
}
Err(e) => {
app.security_keys.lb_status = Some(format!("Edit failed: {e}"));
}
}
})
});
}
fn load_large_blobs(&mut self) {
let Some(target) = self.selected_fido_target() else {
self.security_keys.lb_status = Some("No FIDO key selected.".into());
return;
};
let result = (|| -> Result<
(
keyroost_ctap::large_blobs::LargeBlobArray,
keyroost_ctap::large_blobs::BlobCapacity,
),
String,
> {
let (mut dev, cbor, _init) = open_fido(&target)?;
if !cbor {
return Err("device is U2F-only".into());
}
let info = keyroost_ctap::get_info(&mut dev).map_err(|e| e.to_string())?;
let array =
keyroost_ctap::large_blobs::read(&mut dev, &info).map_err(|e| e.to_string())?;
let cap = array.capacity(&info);
Ok((array, cap))
})();
match result {
Ok((array, cap)) => {
let n = array.entries.len();
self.security_keys.large_blobs = Some(array);
self.security_keys.lb_capacity = Some(cap);
self.security_keys.lb_selected = None;
self.security_keys.lb_status = Some(format!(
"Loaded {n} entr{}.",
if n == 1 { "y" } else { "ies" }
));
}
Err(e) => {
self.security_keys.lb_status = Some(format!("Load failed: {e}"));
}
}
}
fn delete_large_blob_entry(&mut self, idx: usize) {
let Some(target) = self.selected_fido_target() else {
self.security_keys.lb_status = Some("No FIDO key selected.".into());
return;
};
let Some(array) = self.security_keys.large_blobs.clone() else {
return;
};
let Some(session) = self.fido_session() else {
self.security_keys.lb_status =
Some("Unlock the key with your PIN first to modify large blobs.".into());
return;
};
if idx >= array.entries.len() {
return;
}
let pin = session.pin.clone();
let target_entry = array.entries[idx].clone();
let for_device = self.selected_device.clone();
self.spawn_job("Updating large blobs\u{2026}", move || {
let result = (|| -> Result<
(
keyroost_ctap::large_blobs::LargeBlobArray,
keyroost_ctap::large_blobs::BlobCapacity,
),
String,
> {
let (mut dev, cbor, _init) = open_fido(&target)?;
if !cbor {
return Err("device is U2F-only".into());
}
let info = keyroost_ctap::get_info(&mut dev).map_err(|e| e.to_string())?;
let token = keyroost_ctap::client_pin::get_pin_uv_auth_token(
&mut dev,
&pin,
&info,
keyroost_ctap::client_pin::permissions::LARGE_BLOB_WRITE,
)
.map_err(|e| e.to_string())?;
let live =
keyroost_ctap::large_blobs::read(&mut dev, &info).map_err(|e| e.to_string())?;
let Some(pos) = live.entries.iter().position(|e| *e == target_entry) else {
return Err(
"that entry is no longer on the key (its storage changed since it was \
loaded) \u{2014} nothing was deleted; reload and try again."
.into(),
);
};
let mut entries = live.entries;
entries.remove(pos);
let updated = keyroost_ctap::large_blobs::LargeBlobArray {
entries,
raw_array: Vec::new(),
};
let serialized = updated.serialize_with_checksum();
keyroost_ctap::large_blobs::write(&mut dev, &info, &token, &serialized)
.map_err(|e| e.to_string())?;
let array =
keyroost_ctap::large_blobs::read(&mut dev, &info).map_err(|e| e.to_string())?;
let cap = array.capacity(&info);
Ok((array, cap))
})();
Box::new(move |app: &mut App| {
if !completion_still_valid(for_device.as_ref(), app.selected_device.as_ref()) {
return; }
match result {
Ok((array, cap)) => {
let n = array.entries.len();
app.security_keys.large_blobs = Some(array);
app.security_keys.lb_capacity = Some(cap);
app.security_keys.lb_selected = None;
app.security_keys.lb_status =
Some(format!("Entry deleted. {n} remaining."));
app.security_keys.error = None;
}
Err(e) => {
app.security_keys.lb_status = Some(format!("Update failed: {e}"));
}
}
})
});
}
fn clear_large_blob_storage(&mut self) {
let Some(target) = self.selected_fido_target() else {
self.security_keys.lb_status = Some("No FIDO key selected.".into());
return;
};
if self.security_keys.large_blobs.is_none() {
return;
}
let Some(session) = self.fido_session() else {
self.security_keys.lb_status =
Some("Unlock the key with your PIN first to modify large blobs.".into());
return;
};
let pin = session.pin.clone();
let new_entries = Vec::new();
let for_device = self.selected_device.clone();
self.spawn_job("Clearing large blobs\u{2026}", move || {
let result = (|| -> Result<
(
keyroost_ctap::large_blobs::LargeBlobArray,
keyroost_ctap::large_blobs::BlobCapacity,
),
String,
> {
let (mut dev, cbor, _init) = open_fido(&target)?;
if !cbor {
return Err("device is U2F-only".into());
}
let info = keyroost_ctap::get_info(&mut dev).map_err(|e| e.to_string())?;
let token = keyroost_ctap::client_pin::get_pin_uv_auth_token(
&mut dev,
&pin,
&info,
keyroost_ctap::client_pin::permissions::LARGE_BLOB_WRITE,
)
.map_err(|e| e.to_string())?;
let updated = keyroost_ctap::large_blobs::LargeBlobArray {
entries: new_entries,
raw_array: Vec::new(),
};
let serialized = updated.serialize_with_checksum();
keyroost_ctap::large_blobs::write(&mut dev, &info, &token, &serialized)
.map_err(|e| e.to_string())?;
let array =
keyroost_ctap::large_blobs::read(&mut dev, &info).map_err(|e| e.to_string())?;
let cap = array.capacity(&info);
Ok((array, cap))
})();
Box::new(move |app: &mut App| {
if !completion_still_valid(for_device.as_ref(), app.selected_device.as_ref()) {
return; }
match result {
Ok((array, cap)) => {
let n = array.entries.len();
app.security_keys.large_blobs = Some(array);
app.security_keys.lb_capacity = Some(cap);
app.security_keys.lb_selected = None;
app.security_keys.lb_status =
Some(format!("Storage cleared. {n} entries remaining."));
app.security_keys.error = None;
}
Err(e) => {
app.security_keys.lb_status = Some(format!("Clear failed: {e}"));
}
}
})
});
}
fn render_large_blobs(&mut self, ui: &mut egui::Ui, p: &Palette) {
ui.add_space(14.0);
if !self.security_keys.lb_autoloaded && self.security_keys.large_blobs.is_none() {
self.security_keys.lb_autoloaded = true;
self.load_large_blobs();
}
let mut do_load = false;
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Large blob storage")
.font(theme::f_sb(14.5))
.color(p.txt),
);
ui.add_space(6.0);
self.help_dot(ui, p, "large_blobs");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let label = if self.security_keys.large_blobs.is_some() {
"Reload"
} else {
"Load"
};
if theme::button(ui, p, BtnKind::Ghost, label).clicked() {
do_load = true;
}
ui.add_space(8.0);
let add_label = if self.security_keys.lb_show_add {
"Close"
} else {
"Add"
};
if theme::button(ui, p, BtnKind::Ghost, add_label).clicked() {
self.security_keys.lb_show_add = !self.security_keys.lb_show_add;
}
let entry_count = self
.security_keys
.large_blobs
.as_ref()
.map(|a| a.entries.len())
.unwrap_or(0);
let unlocked = self.security_keys.session.is_some();
if entry_count > 0 && unlocked {
ui.add_space(8.0);
if theme::button(ui, p, BtnKind::Danger, "Clear all storage").clicked() {
self.security_keys.lb_confirm_clear = true;
}
}
});
});
ui.add_space(4.0);
ui.label(
egui::RichText::new(
"This store is readable by anyone holding the key and is meant for \
RP-encrypted data \u{2014} not a place for plaintext secrets.",
)
.font(theme::f_reg(11.5))
.color(p.txt3),
);
if let Some(cap) = self.security_keys.lb_capacity {
ui.add_space(8.0);
let frac = if cap.max_bytes == 0 {
0.0
} else {
(cap.used_bytes as f32 / cap.max_bytes as f32).clamp(0.0, 1.0)
};
ui.horizontal(|ui| {
ui.add(
egui::ProgressBar::new(frac)
.desired_width(160.0)
.desired_height(8.0),
);
ui.add_space(8.0);
ui.label(
egui::RichText::new(format!(
"{} of {} bytes used \u{00b7} {} free \u{00b7} {} {}",
cap.used_bytes,
cap.max_bytes,
cap.free_bytes,
cap.entry_count,
if cap.entry_count == 1 {
"entry"
} else {
"entries"
},
))
.font(theme::f_reg(11.5))
.color(p.txt2),
);
});
}
if let Some(status) = &self.security_keys.lb_status {
ui.add_space(6.0);
ui.label(
egui::RichText::new(status)
.font(theme::f_reg(12.0))
.color(p.txt2),
);
}
if do_load {
self.load_large_blobs();
}
if self.security_keys.lb_show_add {
ui.add_space(12.0);
theme::card_frame(p).show(ui, |ui| {
ui.label(
egui::RichText::new("Add a text note")
.font(theme::f_sb(13.0))
.color(p.txt),
);
ui.add_space(4.0);
ui.label(
egui::RichText::new(
"Stored as a keyroost entry you can read back here. It is NOT \
encrypted and is visible to anyone holding the key.",
)
.font(theme::f_reg(11.0))
.color(p.txt3),
);
ui.add_space(6.0);
ui.add(
egui::TextEdit::multiline(&mut self.security_keys.lb_new_text)
.desired_rows(2)
.desired_width(f32::INFINITY)
.hint_text("Type a note to store on the key\u{2026}"),
);
ui.add_space(6.0);
let unlocked = self.security_keys.session.is_some();
let has_text = !self.security_keys.lb_new_text.trim().is_empty();
ui.horizontal(|ui| {
let add = theme::button(ui, p, BtnKind::Primary, "Add note");
if add.clicked() && unlocked && has_text {
let text = self.security_keys.lb_new_text.clone();
self.add_large_blob_note(text);
}
if !unlocked {
ui.label(
egui::RichText::new("Unlock with your PIN to save.")
.font(theme::f_reg(11.0))
.color(p.txt3),
);
}
});
});
}
let Some(array) = self.security_keys.large_blobs.clone() else {
return;
};
ui.add_space(10.0);
if array.entries.is_empty() {
ui.label(
egui::RichText::new("The large-blob array is empty.")
.font(theme::f_reg(12.5))
.color(p.txt2),
);
return;
}
let mut delete_request: Option<usize> = None;
let mut start_edit: Option<(usize, String)> = None;
let mut save_edit: Option<(usize, String)> = None;
let mut cancel_edit = false;
let selected = self.security_keys.lb_selected;
let editing = self.security_keys.lb_editing;
for (idx, entry) in array.entries.iter().enumerate() {
theme::card_frame(p).show(ui, |ui| {
use keyroost_ctap::large_blobs::EntryKind;
let classification = entry.classify();
let note_text = match &classification {
EntryKind::Note(text) => Some(text.clone()),
_ => None,
};
ui.horizontal(|ui| {
let title = if note_text.is_some() {
format!("Note {}", idx + 1)
} else {
format!("Entry {}", idx + 1)
};
ui.label(
egui::RichText::new(title)
.font(theme::f_sb(13.0))
.color(p.txt),
);
ui.add_space(8.0);
let meta = match &classification {
EntryKind::Note(_) => "keyroost text note".to_string(),
EntryKind::SshCert { .. } => format!(
"ssh-cert \u{00b7} {} bytes \u{00b7} relying-party data",
entry.ciphertext.len(),
),
EntryKind::Opaque => format!(
"{} bytes ciphertext \u{00b7} {}-byte nonce \u{00b7} origSize {} \u{00b7} relying-party data",
entry.ciphertext.len(),
entry.nonce.len(),
entry.orig_size,
),
};
ui.label(
egui::RichText::new(meta)
.font(theme::f_reg(11.5))
.color(p.txt3),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if theme::button(ui, p, BtnKind::Danger, "Delete").clicked() {
delete_request = Some(idx);
}
let is_open = selected == Some(idx);
let toggle = if is_open { "Hide bytes" } else { "View bytes" };
if theme::button(ui, p, BtnKind::Ghost, toggle).clicked() {
self.security_keys.lb_selected =
if is_open { None } else { Some(idx) };
}
if note_text.is_some()
&& editing != Some(idx)
&& theme::button(ui, p, BtnKind::Ghost, "Edit").clicked()
{
start_edit = Some((idx, note_text.clone().unwrap_or_default()));
}
if theme::button(ui, p, BtnKind::Ghost, "Export\u{2026}").clicked() {
let is_cert =
matches!(classification, EntryKind::SshCert { .. });
let default_name = if is_cert {
format!("entry-{idx}-cert.pub")
} else {
format!("large-blob-entry-{idx}.bin")
};
self.spawn_file_dialog(
FileTarget::LbExport(idx),
true,
&[("All files", &["*"])],
Some(&default_name),
);
}
});
});
if editing == Some(idx) {
ui.add_space(6.0);
ui.add(
egui::TextEdit::multiline(&mut self.security_keys.lb_edit_text)
.desired_rows(2)
.desired_width(f32::INFINITY),
);
ui.add_space(6.0);
let unlocked = self.security_keys.session.is_some();
let has_text =
!self.security_keys.lb_edit_text.trim().is_empty();
ui.horizontal(|ui| {
if theme::button(ui, p, BtnKind::Primary, "Save").clicked()
&& unlocked
&& has_text
{
save_edit = Some((idx, self.security_keys.lb_edit_text.clone()));
}
if theme::button(ui, p, BtnKind::Ghost, "Cancel").clicked() {
cancel_edit = true;
}
if !unlocked {
ui.label(
egui::RichText::new("Unlock with your PIN to save.")
.font(theme::f_reg(11.0))
.color(p.txt3),
);
}
});
} else if let Some(text) = ¬e_text {
ui.add_space(6.0);
ui.label(
egui::RichText::new(text)
.font(theme::f_reg(12.5))
.color(p.txt),
);
} else if let EntryKind::SshCert { info, .. } = &classification {
ui.add_space(6.0);
egui::Grid::new(format!("lb_cert_{idx}"))
.num_columns(2)
.spacing([12.0, 2.0])
.show(ui, |ui| {
let row = |ui: &mut egui::Ui, k: &str, v: String| {
ui.label(
egui::RichText::new(k).font(theme::f_reg(11.5)).color(p.txt3),
);
ui.label(
egui::RichText::new(v).font(theme::f_reg(11.5)).color(p.txt),
);
ui.end_row();
};
let kind = if info.cert_type
== keyroost_ctap::ssh_cert::CERT_TYPE_USER
{
"user"
} else {
"host"
};
row(ui, "Type", format!("{} ({kind})", info.key_type));
row(ui, "Key ID", info.key_id.clone());
row(ui, "Serial", info.serial.to_string());
row(
ui,
"Principals",
if info.principals.is_empty() {
"(any)".to_string()
} else {
info.principals.join(", ")
},
);
row(
ui,
"Valid",
keyroost_ctap::ssh_cert::format_validity(
info.valid_after,
info.valid_before,
),
);
for (n, v) in &info.critical_options {
row(
ui,
"Critical",
if v.is_empty() {
n.clone()
} else {
format!("{n}={v}")
},
);
}
for ext in &info.extensions {
row(ui, "Extension", ext.clone());
}
});
}
if selected == Some(idx) {
ui.add_space(8.0);
Self::hex_ascii_view(ui, p, &entry.ciphertext);
}
});
ui.add_space(8.0);
}
if let Some((idx, text)) = start_edit {
self.security_keys.lb_editing = Some(idx);
self.security_keys.lb_edit_text = text;
}
if cancel_edit {
self.security_keys.lb_editing = None;
self.security_keys.lb_edit_text.clear();
}
if let Some((idx, text)) = save_edit {
self.edit_large_blob_note(idx, text);
}
if let Some(idx) = delete_request {
self.security_keys.lb_confirm_delete = Some(idx);
}
if let Some(idx) = self.security_keys.lb_confirm_delete {
ui.add_space(6.0);
theme::card_frame(p)
.stroke(egui::Stroke::new(1.0, theme::tint(p.err, 90)))
.show(ui, |ui| {
ui.label(
egui::RichText::new(format!(
"Delete entry {} and rewrite the array? Requires your PIN.",
idx + 1
))
.font(theme::f_reg(12.5))
.color(p.txt),
);
ui.add_space(6.0);
ui.horizontal(|ui| {
if theme::button(ui, p, BtnKind::Danger, "Delete entry").clicked() {
self.security_keys.lb_confirm_delete = None;
self.delete_large_blob_entry(idx);
}
if theme::button(ui, p, BtnKind::Ghost, "Cancel").clicked() {
self.security_keys.lb_confirm_delete = None;
}
});
});
}
if self.security_keys.lb_confirm_clear {
let n = array.entries.len();
ui.add_space(6.0);
theme::card_frame(p)
.stroke(egui::Stroke::new(1.0, theme::tint(p.err, 90)))
.show(ui, |ui| {
ui.label(
egui::RichText::new(format!(
"Wipe all {n} entr{} from this key? This erases every entry \u{2014} \
including any relying-party data (e.g. SSH certificates, sign-in \
records), not just keyroost notes. Cannot be undone; requires your PIN.",
if n == 1 { "y" } else { "ies" },
))
.font(theme::f_reg(12.5))
.color(p.txt),
);
ui.add_space(6.0);
ui.horizontal(|ui| {
if theme::button(
ui,
p,
BtnKind::Danger,
&format!(
"Confirm \u{2014} wipe all {n} entr{}",
if n == 1 { "y" } else { "ies" }
),
)
.clicked()
{
self.security_keys.lb_confirm_clear = false;
self.clear_large_blob_storage();
}
if theme::button(ui, p, BtnKind::Ghost, "Cancel").clicked() {
self.security_keys.lb_confirm_clear = false;
}
});
});
}
}
fn hex_ascii_view(ui: &mut egui::Ui, p: &Palette, bytes: &[u8]) {
let mut text = String::new();
for (i, row) in bytes.chunks(16).enumerate() {
let mut hex = String::new();
let mut ascii = String::new();
for (j, b) in row.iter().enumerate() {
hex.push_str(&format!("{:02x} ", b));
if j == 7 {
hex.push(' ');
}
ascii.push(if b.is_ascii_graphic() || *b == b' ' {
*b as char
} else {
'.'
});
}
let width = 16 * 3 + 1;
while hex.len() < width {
hex.push(' ');
}
text.push_str(&format!("{:08x} {} |{}|\n", i * 16, hex, ascii));
}
ui.add(
egui::Label::new(
egui::RichText::new(text.trim_end())
.font(theme::f_mono(11.5))
.color(p.txt2),
)
.wrap(),
);
}
fn render_fido_advanced(&mut self, ui: &mut egui::Ui, p: &Palette) {
let always_uv = self
.security_keys
.info
.as_ref()
.and_then(|i| i.option("alwaysUv"));
let ep = self
.security_keys
.info
.as_ref()
.and_then(|i| i.option("ep"));
let supports_ep = ep.is_some();
let ep_enabled = ep == Some(true);
let min_pin_length = self
.security_keys
.info
.as_ref()
.and_then(|i| i.min_pin_length);
ui.add_space(14.0);
theme::card_frame(p).show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Security policy")
.font(theme::f_sb(14.5))
.color(p.txt),
);
ui.add_space(6.0);
self.help_dot(ui, p, "settings");
});
ui.add_space(4.0);
ui.label(
egui::RichText::new(
"These change the key's security policy. Some are irreversible \
without a full reset \u{2014} read each note before applying.",
)
.font(theme::f_reg(12.0))
.color(p.txt3),
);
ui.add_space(10.0);
let mut arm: Option<AdvancedAction> = None;
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.label(
egui::RichText::new("Always require user verification")
.font(theme::f_sb(13.0))
.color(p.txt),
);
let state = match always_uv {
Some(true) => "Currently on \u{2014} every sign-in needs PIN or biometric.",
Some(false) => "Currently off.",
None => "State unknown.",
};
ui.label(
egui::RichText::new(state)
.font(theme::f_reg(11.5))
.color(p.txt3),
);
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if theme::button(ui, p, BtnKind::Default, "Toggle").clicked() {
arm = Some(AdvancedAction::ToggleAlwaysUv);
}
});
});
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.label(
egui::RichText::new("Set minimum PIN length")
.font(theme::f_sb(13.0))
.color(p.txt),
);
ui.label(
egui::RichText::new("Can only be raised, never lowered without a reset.")
.font(theme::f_reg(11.5))
.color(p.txt3),
);
let current = match min_pin_length {
Some(n) => format!("Current minimum: {} characters", n),
None => "Current minimum: \u{2014}".to_string(),
};
ui.label(
egui::RichText::new(current)
.font(theme::f_reg(11.5))
.color(p.txt3),
);
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if theme::button(ui, p, BtnKind::Default, "Set\u{2026}").clicked() {
arm = Some(AdvancedAction::SetMinPin);
}
});
});
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.label(
egui::RichText::new("Force PIN change on next use")
.font(theme::f_sb(13.0))
.color(p.txt),
);
ui.label(
egui::RichText::new("Useful before handing the key to someone else.")
.font(theme::f_reg(11.5))
.color(p.txt3),
);
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if theme::button(ui, p, BtnKind::Default, "Force\u{2026}").clicked() {
arm = Some(AdvancedAction::ForcePinChange);
}
});
});
if supports_ep {
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.label(
egui::RichText::new("Enable enterprise attestation")
.font(theme::f_sb(13.0))
.color(p.txt),
);
let note = if ep_enabled {
"Currently on. Disabling it again requires a device reset."
} else {
"One-way: disabling it again requires a device reset."
};
ui.label(
egui::RichText::new(note)
.font(theme::f_reg(11.5))
.color(p.txt3),
);
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ep_enabled {
theme::pill(ui, "Enabled", p.ok, p.ok_soft());
} else if theme::button(ui, p, BtnKind::Danger, "Enable\u{2026}").clicked()
{
arm = Some(AdvancedAction::EnterpriseAttestation);
}
});
});
}
if let Some(action) = arm {
self.security_keys.advanced = Some(AdvancedDialog {
action,
pin_input: String::new(),
min_pin_input: String::new(),
force_change: false,
device: self.selected_device.clone(),
});
self.security_keys.error = None;
}
});
}
fn modal_window(
ctx: &egui::Context,
p: &Palette,
id: &str,
title: &str,
width: f32,
body: impl FnOnce(&mut egui::Ui),
) -> bool {
let mut closed = ctx.input(|i| i.key_pressed(egui::Key::Escape));
egui::Window::new(id)
.collapsible(false)
.resizable(false)
.title_bar(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.frame(egui::Frame {
inner_margin: egui::Margin::same(20),
corner_radius: egui::CornerRadius::same(13),
fill: p.pop,
stroke: egui::Stroke::new(1.0, p.line),
shadow: egui::epaint::Shadow {
offset: [0, 12],
blur: 40,
spread: 0,
color: egui::Color32::from_black_alpha(115),
},
..Default::default()
})
.show(ctx, |ui| {
ui.set_max_width(width);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(title)
.font(theme::f_sb(13.0))
.color(p.txt),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let (xr, xresp) =
ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::click());
let xcolor = if xresp.hovered() { p.txt } else { p.txt3 };
paint_x_icon(ui, xr.center(), xcolor);
if xresp.clicked() {
closed = true;
}
});
});
ui.add_space(12.0);
body(ui);
});
closed
}
fn render_enroll_dialog(&mut self, ctx: &egui::Context, p: &Palette) {
let Some(prog) = self.security_keys.fp_progress.clone() else {
return;
};
let (captured, total, last_message, done) = {
let Ok(g) = prog.lock() else { return };
(
g.captured,
g.total.max(1),
g.last_message.clone(),
g.done.clone(),
)
};
let frac = (captured as f32 / total as f32).clamp(0.0, 1.0);
let finished = done.is_some();
let mut want_cancel = false;
let mut want_done = false;
let closed =
Self::modal_window(
ctx,
p,
"fp_enroll",
"Enroll fingerprint",
300.0,
|ui| match &done {
None => {
ui.label(
egui::RichText::new(format!(
"Enrolling \u{2014} sample {} of {}",
captured.min(total),
total
))
.font(theme::f_sb(13.0))
.color(p.accent),
);
ui.add_space(8.0);
ui.add(egui::ProgressBar::new(frac).desired_width(280.0));
ui.add_space(6.0);
ui.label(
egui::RichText::new(format!("\u{1F446} {last_message}"))
.font(theme::f_reg(12.0))
.color(p.txt2),
);
ui.add_space(16.0);
if theme::button(ui, p, BtnKind::Default, "Cancel").clicked() {
want_cancel = true;
}
}
Some(Ok(())) => {
ui.label(
egui::RichText::new("\u{2713} Fingerprint enrolled")
.font(theme::f_sb(13.0))
.color(p.ok),
);
ui.add_space(8.0);
ui.add(egui::ProgressBar::new(1.0).desired_width(280.0));
ui.add_space(16.0);
if theme::button(ui, p, BtnKind::Primary, "Done").clicked() {
want_done = true;
}
}
Some(Err(e)) => {
ui.colored_label(p.err, format!("Enrollment failed: {e}"));
ui.add_space(16.0);
if theme::button(ui, p, BtnKind::Default, "Close").clicked() {
want_done = true;
}
}
},
);
if want_cancel {
if let Ok(mut g) = prog.lock() {
g.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
g.last_message = "Cancelling\u{2026}".into();
}
}
if want_done || (closed && finished) {
self.security_keys.fp_progress = None;
} else if closed && !finished {
if let Ok(mut g) = prog.lock() {
g.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
g.last_message = "Cancelling\u{2026}".into();
}
}
}
fn render_fp_delete_confirm(&mut self, ctx: &egui::Context, p: &Palette) -> Option<Vec<u8>> {
let id = self.security_keys.fp_confirm_delete.clone()?;
let label = self
.security_keys
.fingerprints
.as_ref()
.and_then(|l| l.iter().find(|e| e.template_id == id))
.and_then(|e| e.friendly_name.clone())
.unwrap_or_else(|| hex_short(&id));
let mut confirm = false;
let mut cancel = false;
let closed = Self::modal_window(ctx, p, "fp_delete", "Delete fingerprint?", 300.0, |ui| {
ui.label(
egui::RichText::new(format!(
"Delete fingerprint \u{201c}{label}\u{201d}? This cannot be undone."
))
.font(theme::f_reg(12.5))
.color(p.txt),
);
ui.add_space(16.0);
ui.horizontal(|ui| {
if theme::button(ui, p, BtnKind::Danger, "Delete").clicked() {
confirm = true;
}
ui.add_space(8.0);
if theme::button(ui, p, BtnKind::Default, "Cancel").clicked() {
cancel = true;
}
});
});
if cancel || closed {
self.security_keys.fp_confirm_delete = None;
return None;
}
if confirm {
self.security_keys.fp_confirm_delete = None;
return Some(id);
}
None
}
fn render_advanced_confirm(&mut self, ctx: &egui::Context, p: &Palette) {
let Some(dlg) = self.security_keys.advanced.as_ref() else {
return;
};
let action = dlg.action;
if action == AdvancedAction::None {
return;
}
if !completion_still_valid(dlg.device.as_ref(), self.selected_device.as_ref()) {
self.security_keys.advanced = None;
return;
}
let (title, irreversible) = match action {
AdvancedAction::ToggleAlwaysUv => ("Toggle always-UV", false),
AdvancedAction::SetMinPin => ("Set minimum PIN length", true),
AdvancedAction::ForcePinChange => ("Force a PIN change", false),
AdvancedAction::EnterpriseAttestation => ("Enable enterprise attestation", true),
AdvancedAction::None => return,
};
let mut apply = false;
let mut cancel = false;
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
cancel = true;
}
egui::Window::new(title)
.collapsible(false)
.resizable(false)
.title_bar(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.frame(egui::Frame {
inner_margin: egui::Margin::same(20),
corner_radius: egui::CornerRadius::same(13),
fill: p.pop,
stroke: egui::Stroke::new(1.0, p.line),
shadow: egui::epaint::Shadow {
offset: [0, 12],
blur: 40,
spread: 0,
color: egui::Color32::from_black_alpha(115),
},
..Default::default()
})
.show(ctx, |ui| {
ui.set_max_width(300.0);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(title)
.font(theme::f_sb(13.0))
.color(p.txt),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let (xr, xresp) =
ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::click());
let xcolor = if xresp.hovered() { p.txt } else { p.txt3 };
paint_x_icon(ui, xr.center(), xcolor);
if xresp.clicked() {
cancel = true;
}
});
});
ui.add_space(12.0);
if irreversible {
ui.label(
egui::RichText::new(
"This cannot be undone without a full reset of the key.",
)
.font(theme::f_reg(12.0))
.color(p.warn),
);
ui.add_space(12.0);
}
if action == AdvancedAction::SetMinPin {
ui.horizontal(|ui| {
ui.label("New minimum length");
if let Some(d) = self.security_keys.advanced.as_mut() {
ui.add(
egui::TextEdit::singleline(&mut d.min_pin_input)
.desired_width(64.0)
.hint_text("e.g. 6"),
);
}
});
ui.add_space(8.0);
if let Some(d) = self.security_keys.advanced.as_mut() {
ui.checkbox(&mut d.force_change, "Also force a PIN change now");
}
ui.add_space(12.0);
}
ui.horizontal(|ui| {
ui.label("Device PIN");
if let Some(d) = self.security_keys.advanced.as_mut() {
let resp = ui.add(
egui::TextEdit::singleline(&mut d.pin_input)
.password(true)
.desired_width(160.0),
);
guard_secret_field(ui.ctx(), &resp);
}
});
ui.add_space(16.0);
ui.horizontal(|ui| {
let kind = if irreversible {
BtnKind::Danger
} else {
BtnKind::Primary
};
if theme::button(ui, p, kind, "Apply").clicked() {
apply = true;
}
ui.add_space(8.0);
if theme::button(ui, p, BtnKind::Default, "Cancel").clicked() {
cancel = true;
}
});
if let Some(err) = &self.security_keys.error {
ui.add_space(10.0);
ui.label(
egui::RichText::new(err)
.font(theme::f_reg(12.0))
.color(p.err),
);
}
});
if apply {
self.run_advanced_action();
} else if cancel {
self.security_keys.advanced = None;
self.security_keys.error = None;
}
}
fn piv_modal_mgmt_field(&mut self, ui: &mut egui::Ui, p: &Palette, kind: PivCredKind) {
if !kind.needs_mgmt_key() {
return;
}
let pin_auth_gate = {
let (fp, ver, fw) = self.piv.status.as_ref().map_or(
(
keyroost_piv::fingerprint::AppletFingerprint::Generic,
None,
None,
),
|s| {
(
s.applet_fingerprint,
s.version.as_deref(),
s.version_firmware.as_deref(),
)
},
);
self.piv_gate(
keyroost_piv::compat::PivExtension::PinManagementAuth,
fp,
ver,
fw,
)
};
let pin_auth_available = !matches!(
pin_auth_gate,
keyroost_piv::compat::FeatureGate::Unsupported
);
if !pin_auth_available && self.piv.mgmt_auth_mode == PivMgmtAuthMode::Pin {
self.piv.mgmt_auth_mode = PivMgmtAuthMode::Manual;
}
let default_key = self.piv_current_default_mgmt_key();
if default_key.is_none() && self.piv.mgmt_auth_mode == PivMgmtAuthMode::Default {
self.piv.mgmt_auth_mode = PivMgmtAuthMode::Manual;
}
ui.horizontal(|ui| {
let mut is_default = self.piv.mgmt_auth_mode == PivMgmtAuthMode::Default;
let mut resp = ui.add_enabled(
default_key.is_some(),
egui::Checkbox::new(&mut is_default, "Use default management key"),
);
if let Some(key) = default_key {
resp = resp.on_hover_text(format!("Default management key: {}", hex_lower(key)));
}
if resp.changed() {
self.piv.mgmt_auth_mode =
piv_mgmt_mode_after_toggle(is_default, PivMgmtAuthMode::Default);
}
if pin_auth_available {
let mut is_pin = self.piv.mgmt_auth_mode == PivMgmtAuthMode::Pin;
if ui.checkbox(&mut is_pin, "Use PIN").changed() {
self.piv.mgmt_auth_mode =
piv_mgmt_mode_after_toggle(is_pin, PivMgmtAuthMode::Pin);
}
if matches!(pin_auth_gate, keyroost_piv::compat::FeatureGate::Unverified) {
ui.add_space(4.0);
theme::warn_marker(ui, p).on_hover_text(format!(
"{} {}",
keyroost_piv::compat::PivExtension::PinManagementAuth.requirement(),
keyroost_piv::compat::FeatureGate::UNVERIFIED_SUFFIX
));
}
}
});
if self.piv.mgmt_auth_mode == PivMgmtAuthMode::Default {
return;
}
let use_pin = self.piv.mgmt_auth_mode == PivMgmtAuthMode::Pin;
if use_pin && kind.shares_pin_field() {
return;
}
let label_w = if matches!(
kind,
PivCredKind::NewChuid
| PivCredKind::ChangeMgmtKey
| PivCredKind::SelfSign
| PivCredKind::SetRetries
) {
chuid_label_width(ui.ctx())
} else {
96.0
};
let width = if kind == PivCredKind::NewChuid {
chuid_guid_field_width(ui.ctx())
} else {
300.0
};
let (label, hint) = if use_pin {
("PIN", "PIN")
} else {
("Management key", "hex (48/32/64 chars)")
};
let mut mgmt_key_rev = self
.secret_reveal
.get("piv-mgmt-key-input")
.copied()
.unwrap_or(false);
piv_secret_field(
ui,
p,
label,
&mut self.piv.mgmt_key_input,
&mut mgmt_key_rev,
hint,
label_w,
width,
);
self.secret_reveal
.insert("piv-mgmt-key-input", mgmt_key_rev);
}
fn piv_modal_compression_field(&mut self, ui: &mut egui::Ui, p: &Palette, label_w: f32) {
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.add_sized(
[label_w, 22.0],
egui::Label::new(
egui::RichText::new("Compression")
.font(theme::f_reg(13.0))
.color(p.txt2),
),
);
piv_cert_compression_combo(ui, "piv-cert-compression", &mut self.piv.cert_compression);
});
card_note(
ui,
p,
"Compressed is the PIV standard's gzip form; some software may not read \
compressed certificates. Automatic compresses only if the card refuses the \
certificate as too large.",
);
}
fn render_piv_cred_modal(&mut self, ctx: &egui::Context, p: &Palette) {
let Some(kind) = self.piv.cred_modal.as_ref().map(|m| m.kind) else {
return;
};
let busy = self.piv.cred_modal.as_ref().is_some_and(|m| m.busy);
let result = self.piv.cred_modal.as_ref().and_then(|m| m.result.clone());
let detail = self.piv.cred_modal.as_ref().and_then(|m| m.detail.clone());
let busy_text = if kind == PivCredKind::SelfTest
&& matches!(
self.piv_selected_slot_policy().map(|(_, t)| t),
Some(keyroost_piv::TouchPolicy::Always | keyroost_piv::TouchPolicy::Cached)
) {
format!("{} \u{2014} touch the key", kind.busy_label())
} else {
kind.busy_label().to_owned()
};
let mut want_submit = false;
let mut want_close = false;
let mismatch = piv_cred_mismatch(&self.piv, kind);
let selected_occupied = self.piv_selected_occupied();
let move_src = self.piv.selected_slot.to_slot();
let move_dests: Vec<keyroost_piv::Slot> = if kind == PivCredKind::MoveKey {
let piv = &self.piv;
let occupied = |s: keyroost_piv::Slot| -> bool {
if matches!(s, keyroost_piv::Slot::Retired(_)) {
piv.retired_occupancy
.as_ref()
.and_then(|v| v.iter().find(|(rs, _)| *rs == s))
.map(|(_, has)| *has)
.unwrap_or(false)
} else {
piv.slot_keys
.iter()
.find(|(sl, _, _)| *sl == s)
.map(|(_, alg, _)| alg.is_some())
.unwrap_or(false)
}
};
move_key_eligible_destinations(move_src, &occupied)
} else {
Vec::new()
};
let width = if kind == PivCredKind::NewChuid {
chuid_label_width(ctx) + 8.0 + chuid_guid_field_width(ctx) + 6.0 + 22.0 + 12.0
} else {
300.0
};
let closed = Self::modal_window(ctx, p, "piv_cred", kind.title(), width, |ui| {
match &result {
Some(Ok(())) => {
ui.label(
egui::RichText::new(format!("\u{2713} {}", piv_cred_success(kind)))
.font(theme::f_sb(13.0))
.color(p.ok),
);
if let Some(d) = &detail {
ui.add_space(6.0);
for line in d.lines() {
card_note(ui, p, line);
}
}
ui.add_space(16.0);
if theme::button(ui, p, BtnKind::Primary, "Done").clicked() {
want_close = true;
}
}
_ => {
match kind {
PivCredKind::ChangePin => {
let mut old_rev = self
.secret_reveal
.get("piv-pin-old")
.copied()
.unwrap_or(false);
let mut new_rev = self
.secret_reveal
.get("piv-pin-new")
.copied()
.unwrap_or(false);
let mut confirm_rev = self
.secret_reveal
.get("piv-pin-confirm")
.copied()
.unwrap_or(false);
let label_w = label_text_width(ui.ctx(), "Confirm new PIN");
piv_pin_field(
ui,
p,
"Current PIN",
&mut self.piv.pin_old,
&mut old_rev,
label_w,
);
piv_pin_field(
ui,
p,
"New PIN",
&mut self.piv.pin_new,
&mut new_rev,
label_w,
);
piv_pin_field(
ui,
p,
"Confirm new PIN",
&mut self.piv.pin_confirm,
&mut confirm_rev,
label_w,
);
self.secret_reveal.insert("piv-pin-old", old_rev);
self.secret_reveal.insert("piv-pin-new", new_rev);
self.secret_reveal.insert("piv-pin-confirm", confirm_rev);
card_note(ui, p, "6\u{2013}8 characters.");
}
PivCredKind::ChangePuk => {
let mut old_rev = self
.secret_reveal
.get("piv-puk-old")
.copied()
.unwrap_or(false);
let mut new_rev = self
.secret_reveal
.get("piv-puk-new")
.copied()
.unwrap_or(false);
let mut confirm_rev = self
.secret_reveal
.get("piv-puk-confirm")
.copied()
.unwrap_or(false);
let label_w = label_text_width(ui.ctx(), "Confirm new PUK");
piv_pin_field(
ui,
p,
"Current PUK",
&mut self.piv.puk_old,
&mut old_rev,
label_w,
);
piv_pin_field(
ui,
p,
"New PUK",
&mut self.piv.puk_new,
&mut new_rev,
label_w,
);
piv_pin_field(
ui,
p,
"Confirm new PUK",
&mut self.piv.puk_confirm,
&mut confirm_rev,
label_w,
);
self.secret_reveal.insert("piv-puk-old", old_rev);
self.secret_reveal.insert("piv-puk-new", new_rev);
self.secret_reveal.insert("piv-puk-confirm", confirm_rev);
card_note(ui, p, "8 characters.");
}
PivCredKind::UnblockPin => {
let mut puk_rev = self
.secret_reveal
.get("piv-unblock-puk")
.copied()
.unwrap_or(false);
let mut new_pin_rev = self
.secret_reveal
.get("piv-unblock-new-pin")
.copied()
.unwrap_or(false);
piv_pin_field(
ui,
p,
"PUK",
&mut self.piv.unblock_puk,
&mut puk_rev,
96.0,
);
piv_pin_field(
ui,
p,
"New PIN",
&mut self.piv.unblock_new_pin,
&mut new_pin_rev,
96.0,
);
self.secret_reveal.insert("piv-unblock-puk", puk_rev);
self.secret_reveal
.insert("piv-unblock-new-pin", new_pin_rev);
card_note(ui, p, "Recovers a blocked PIN without wiping any keys.");
}
PivCredKind::GenerateKey => {
if selected_occupied {
let slot = self.piv.selected_slot.to_slot().label();
ui.colored_label(
p.err,
egui::RichText::new(format!(
"This OVERWRITES and destroys the existing key in \
{slot}. This cannot be undone."
))
.font(theme::f_sb(12.5)),
);
ui.add_space(6.0);
}
self.piv_modal_mgmt_field(ui, p, kind);
card_note(ui, p, "Authorizes overwriting the slot with a fresh key.");
ui.add_space(8.0);
use keyroost_piv::compat::{FeatureGate, PivExtension};
let (piv_fp, piv_ver, piv_fw_ver) = self.piv.status.as_ref().map_or(
(
keyroost_piv::fingerprint::AppletFingerprint::Generic,
None,
None,
),
|s| {
(
s.applet_fingerprint,
s.version.as_deref(),
s.version_firmware.as_deref(),
)
},
);
let pin_policy_gate = self.piv_gate(
PivExtension::SlotPinPolicy,
piv_fp,
piv_ver,
piv_fw_ver,
);
let touch_policy_gate = self.piv_gate(
PivExtension::SlotTouchPolicy,
piv_fp,
piv_ver,
piv_fw_ver,
);
let policy_quirks =
keyroost_piv::compat::resolve_quirks(piv_fp, piv_ver, piv_fw_ver);
let pin_once_blocked = policy_quirks.contains(
&keyroost_piv::compat::PivQuirk::SlotPinPolicyOnceNotSupported,
);
let touch_cached_blocked = policy_quirks.contains(
&keyroost_piv::compat::PivQuirk::SlotTouchPolicyCachedNotSupported,
);
let pin_policy_unverified_hint = format!(
"{} {}",
PivExtension::SlotPinPolicy.requirement(),
FeatureGate::UNVERIFIED_SUFFIX
);
let pin_policy_blocked_hint = format!(
"{} {}",
PivExtension::SlotPinPolicy.requirement(),
FeatureGate::INCOMPATIBLE_SUFFIX
);
let touch_policy_unverified_hint = format!(
"{} {}",
PivExtension::SlotTouchPolicy.requirement(),
FeatureGate::UNVERIFIED_SUFFIX
);
let touch_policy_blocked_hint = format!(
"{} {}",
PivExtension::SlotTouchPolicy.requirement(),
FeatureGate::INCOMPATIBLE_SUFFIX
);
if matches!(pin_policy_gate, FeatureGate::Unsupported) {
self.piv.gen_pin_policy = keyroost_piv::PinPolicy::Default;
}
if matches!(touch_policy_gate, FeatureGate::Unsupported) {
self.piv.gen_touch_policy = keyroost_piv::TouchPolicy::Default;
}
let pin_policy_options: Vec<keyroost_piv::PinPolicy> =
keyroost_piv::PinPolicy::ALL
.iter()
.copied()
.filter(|&opt| {
!(pin_once_blocked && opt == keyroost_piv::PinPolicy::Once)
})
.collect();
let touch_policy_options: Vec<keyroost_piv::TouchPolicy> =
keyroost_piv::TouchPolicy::ALL
.iter()
.copied()
.filter(|&opt| {
!(touch_cached_blocked
&& opt == keyroost_piv::TouchPolicy::Cached)
})
.collect();
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Pin Policy")
.font(theme::f_reg(13.0))
.color(p.txt2),
);
if matches!(pin_policy_gate, FeatureGate::Unverified) {
ui.add_space(4.0);
theme::warn_marker(ui, p)
.on_hover_text(pin_policy_unverified_hint.as_str());
}
ui.add_space(8.0);
ui.add_enabled_ui(
!matches!(pin_policy_gate, FeatureGate::Unsupported),
|ui| {
piv_policy_combo(
ui,
"piv-gen-pin-policy",
&mut self.piv.gen_pin_policy,
&pin_policy_options,
);
},
)
.response
.on_disabled_hover_text(pin_policy_blocked_hint.as_str());
});
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Touch Policy")
.font(theme::f_reg(13.0))
.color(p.txt2),
);
if matches!(touch_policy_gate, FeatureGate::Unverified) {
ui.add_space(4.0);
theme::warn_marker(ui, p)
.on_hover_text(touch_policy_unverified_hint.as_str());
}
ui.add_space(8.0);
ui.add_enabled_ui(
!matches!(touch_policy_gate, FeatureGate::Unsupported),
|ui| {
piv_policy_combo(
ui,
"piv-gen-touch-policy",
&mut self.piv.gen_touch_policy,
&touch_policy_options,
);
},
)
.response
.on_disabled_hover_text(touch_policy_blocked_hint.as_str());
});
if piv_policy_caveat_hint_needed(pin_policy_gate, touch_policy_gate) {
card_note(
ui,
p,
"Policy other than `default` requires YubiKey or compatible \
token.",
);
}
}
PivCredKind::ImportCert => {
card_note(ui, p, &format!("Reading {}", self.piv.cert_path));
self.piv_modal_mgmt_field(ui, p, kind);
self.piv_modal_compression_field(ui, p, 96.0);
if selected_occupied {
let slot = self.piv.selected_slot.to_slot().label();
card_note(
ui,
p,
&format!("This replaces the certificate currently in {slot}."),
);
}
card_note(ui, p, "Authorizes writing the certificate to the slot.");
}
PivCredKind::SelfSign => {
self.piv_modal_mgmt_field(ui, p, kind);
let mut sign_pin_rev = self
.secret_reveal
.get("piv-sign-pin")
.copied()
.unwrap_or(false);
piv_pin_field(
ui,
p,
"PIN",
&mut self.piv.sign_pin,
&mut sign_pin_rev,
chuid_label_width(ui.ctx()),
);
self.secret_reveal.insert("piv-sign-pin", sign_pin_rev);
self.piv_modal_compression_field(ui, p, chuid_label_width(ui.ctx()));
card_note(
ui,
p,
if self.piv.mgmt_auth_mode == PivMgmtAuthMode::Pin {
"The PIN unlocks management and authorizes the \
on-card signature."
} else {
"Management key authorizes the import; the PIN authorizes \
the on-card signature."
},
);
}
PivCredKind::RequestCsr => {
card_note(ui, p, &format!("Saving to {}", self.piv.csr_path));
let mut sign_pin_rev = self
.secret_reveal
.get("piv-sign-pin")
.copied()
.unwrap_or(false);
piv_pin_field(
ui,
p,
"PIN",
&mut self.piv.sign_pin,
&mut sign_pin_rev,
96.0,
);
self.secret_reveal.insert("piv-sign-pin", sign_pin_rev);
card_note(ui, p, "The PIN authorizes the on-card signature.");
}
PivCredKind::SelfTest => {
let mut sign_pin_rev = self
.secret_reveal
.get("piv-sign-pin")
.copied()
.unwrap_or(false);
piv_pin_field(
ui,
p,
"PIN (optional)",
&mut self.piv.sign_pin,
&mut sign_pin_rev,
96.0,
);
self.secret_reveal.insert("piv-sign-pin", sign_pin_rev);
card_note(
ui,
p,
"PIN optional; device state and policy may still require it.",
);
card_note(
ui,
p,
"Runs every self-test the slot's key supports (decrypt / \
key-agree / sign) and verifies each result against the \
certificate's public key. Nothing on the card changes.",
);
}
PivCredKind::SetRetries => {
self.piv_modal_mgmt_field(ui, p, kind);
let mut retries_pin_rev = self
.secret_reveal
.get("piv-retries-pin")
.copied()
.unwrap_or(false);
piv_pin_field(
ui,
p,
"Current PIN",
&mut self.piv.retries_pin_auth,
&mut retries_pin_rev,
chuid_label_width(ui.ctx()),
);
self.secret_reveal
.insert("piv-retries-pin", retries_pin_rev);
card_note(
ui,
p,
if self.piv.mgmt_auth_mode == PivMgmtAuthMode::Pin {
"Resets PIN and PUK to factory defaults; the PIN both \
unlocks management and authorizes the reset."
} else {
"Resets PIN and PUK to factory defaults; needs the \
management key and the current PIN."
},
);
}
PivCredKind::ChangeMgmtKey => {
self.piv_modal_mgmt_field(ui, p, kind);
let label_w = chuid_label_width(ui.ctx());
match self.piv.new_mgmt_alg.to_alg() {
Some(alg) => {
let new_key_hint = format!("hex ({} chars)", alg.key_len() * 2);
let mut new_key_rev = self
.secret_reveal
.get("piv-new-mgmt-key")
.copied()
.unwrap_or(false);
piv_secret_field(
ui,
p,
"New key",
&mut self.piv.new_mgmt_key_input,
&mut new_key_rev,
&new_key_hint,
label_w,
300.0,
);
self.secret_reveal.insert("piv-new-mgmt-key", new_key_rev);
ui.add_space(2.0);
ui.vertical_centered(|ui| {
if ui
.add(
egui::Label::new(
egui::RichText::new(
"Generate random key & Copy",
)
.font(theme::f_sb(13.0))
.color(p.accent),
)
.sense(egui::Sense::click()),
)
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text(
"Fill in a fresh random key of the selected \
length and copy it to the clipboard.",
)
.clicked()
{
self.piv_generate_random_mgmt_key(ui.ctx(), alg);
}
});
use keyroost_piv::compat::{FeatureGate, PivExtension};
let is_hid_crescendo = self.piv.status.as_ref().is_some_and(|s| {
matches!(
s.applet_fingerprint,
keyroost_piv::fingerprint::AppletFingerprint::HidCrescendo(_)
)
});
ui.add_space(4.0);
if is_hid_crescendo {
self.piv.new_mgmt_allow_pin_unlock = true;
ui.vertical_centered(|ui| {
ui.add_enabled_ui(false, |ui| {
ui.checkbox(
&mut self.piv.new_mgmt_allow_pin_unlock,
"Allow PIN unlock",
);
});
});
} else {
let (piv_fp, piv_ver, piv_fw_ver) =
self.piv.status.as_ref().map_or(
(
keyroost_piv::fingerprint::AppletFingerprint::Generic,
None,
None,
),
|s| {
(
s.applet_fingerprint,
s.version.as_deref(),
s.version_firmware.as_deref(),
)
},
);
let pin_unlock_gate = self.piv_gate(
PivExtension::PinManagementAuth,
piv_fp,
piv_ver,
piv_fw_ver,
);
let pin_unlock_blocked_hint = format!(
"{} {}",
PivExtension::PinManagementAuth.requirement(),
FeatureGate::INCOMPATIBLE_SUFFIX
);
let row_w_id =
egui::Id::new("piv-allow-pin-unlock-row-width");
let last_row_w =
ui.data(|d| d.get_temp::<f32>(row_w_id)).unwrap_or(0.0);
ui.horizontal(|ui| {
ui.add_space(
((ui.available_width() - last_row_w) / 2.0)
.max(0.0),
);
let row = ui.horizontal(|ui| {
ui.add_enabled_ui(
!matches!(
pin_unlock_gate,
FeatureGate::Unsupported
),
|ui| {
ui.checkbox(
&mut self.piv.new_mgmt_allow_pin_unlock,
"Allow PIN unlock",
);
},
)
.response
.on_disabled_hover_text(
pin_unlock_blocked_hint.as_str(),
);
if matches!(
pin_unlock_gate,
FeatureGate::Unverified
) {
ui.add_space(4.0);
theme::warn_marker(ui, p).on_hover_text(
format!(
"{} {}",
PivExtension::PinManagementAuth
.requirement(),
FeatureGate::UNVERIFIED_SUFFIX
)
.as_str(),
);
}
});
ui.data_mut(|d| {
d.insert_temp(row_w_id, row.response.rect.width())
});
});
}
card_note(
ui,
p,
if self.piv.mgmt_auth_mode == PivMgmtAuthMode::Pin {
"Enter the PIN, then the new key. Select \"Allow \
PIN Unlock\" to also unlock management with the \
PIN, in addition to the management key."
} else {
"Enter the current key, then the new key. Select \
\"Allow PIN unlock\" to also unlock management \
with the PIN, in addition to the management key."
},
);
}
None => {
card_note(
ui,
p,
if self.piv.mgmt_auth_mode == PivMgmtAuthMode::Pin {
"Enter the PIN to unlock. This deletes the \
management key rather than replacing it, \
leaving the PIN as the only remaining \
authentication mechanism."
} else {
"Enter the current key to unlock. This deletes \
the management key rather than replacing it, \
leaving the PIN as the only remaining \
authentication mechanism."
},
);
}
}
}
PivCredKind::DeleteCert => {
let slot = self.piv.selected_slot.label();
ui.colored_label(
p.err,
egui::RichText::new(format!(
"Removes the certificate in {slot}. The private key in \
the slot remains. This cannot be undone."
))
.font(theme::f_sb(12.5)),
);
ui.add_space(6.0);
self.piv_modal_mgmt_field(ui, p, kind);
card_note(ui, p, "The management key authorizes the deletion.");
}
PivCredKind::DeleteKey => {
let slot = self.piv.selected_slot.label();
ui.colored_label(
p.err,
egui::RichText::new(format!(
"Permanently erases the private key in {slot}. This \
cannot be undone."
))
.font(theme::f_sb(12.5)),
);
ui.add_space(6.0);
self.piv_modal_mgmt_field(ui, p, kind);
card_note(
ui,
p,
"The management key authorizes the deletion. Needs \
YubiKey 5.7+ or compatible third party device.",
);
}
PivCredKind::MoveKey => {
ui.label(
egui::RichText::new(format!(
"Move the key in {} to another slot.",
move_src.label()
))
.font(theme::f_reg(12.5))
.color(p.txt2),
);
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Destination")
.font(theme::f_reg(13.0))
.color(p.txt2),
);
ui.add_space(8.0);
let sel_text = self
.piv
.move_dest
.map(|d| d.label())
.unwrap_or_else(|| "Choose a slot\u{2026}".to_string());
egui::ComboBox::from_id_salt("piv-move-dest")
.selected_text(sel_text)
.show_ui(ui, |ui| {
for dest in &move_dests {
stable_selectable_value(
ui,
&mut self.piv.move_dest,
Some(*dest),
dest.label(),
);
}
});
});
ui.add_space(6.0);
self.piv_modal_mgmt_field(ui, p, kind);
card_note(
ui,
p,
"Moves only the key; the certificate stays in the \
source slot. Needs YubiKey 5.7+ or compatible third \
party device.",
);
}
PivCredKind::NewChuid => {
self.piv_modal_mgmt_field(ui, p, kind);
ui.horizontal(|ui| {
ui.add_sized(
[chuid_label_width(ui.ctx()), 22.0],
egui::Label::new(
egui::RichText::new("GUID")
.font(theme::f_reg(13.0))
.color(p.txt2),
),
);
ui.add(
egui::TextEdit::singleline(&mut self.piv.chuid_guid)
.hint_text("hex, dashes optional")
.desired_width(chuid_guid_field_width(ui.ctx())),
);
ui.add_space(6.0);
let (rrect, rresp) = ui.allocate_exact_size(
egui::vec2(22.0, 22.0),
egui::Sense::click(),
);
paint_refresh_icon(ui, rrect.center(), 7.0, p.txt2);
if rresp
.on_hover_text("Generate a new random GUID")
.on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked()
{
self.piv_refresh_chuid_guid();
}
});
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.add_sized(
[chuid_label_width(ui.ctx()), 22.0],
egui::Label::new(
egui::RichText::new("Valid for")
.font(theme::f_reg(13.0))
.color(p.txt2),
),
);
piv_valid_for_fields(
ui,
unix_now(),
&mut self.piv.chuid_valid_years,
&mut self.piv.chuid_valid_months,
&mut self.piv.chuid_valid_days,
);
});
card_note(
ui,
p,
"Writes a fresh card ID. Windows' generic smartcard \
minidriver caches PIV data by the CHUID's GUID, so this \
is what makes it notice a key or certificate actually \
changed. The expiration date is informational only — it \
has no technical implications.",
);
}
}
if let Some(msg) = mismatch {
ui.add_space(6.0);
ui.colored_label(p.err, msg);
} else if let Some(Err(e)) = &result {
ui.add_space(6.0);
ui.colored_label(p.err, e);
}
ui.add_space(16.0);
ui.horizontal(|ui| {
if busy {
ui.add(egui::Spinner::new());
ui.label(
egui::RichText::new(busy_text.as_str())
.font(theme::f_reg(12.5))
.color(p.txt2),
);
} else {
let submit = if kind == PivCredKind::GenerateKey && selected_occupied {
"Overwrite key"
} else {
kind.submit_label()
};
if theme::button(ui, p, BtnKind::Primary, submit).clicked()
&& mismatch.is_none()
{
want_submit = true;
}
ui.add_space(8.0);
if theme::button(ui, p, BtnKind::Default, "Cancel").clicked() {
want_close = true;
}
}
});
}
}
});
if (closed || want_close) && !busy {
self.piv_cred_modal_close();
return;
}
if want_submit && !busy {
if let Some(m) = self.piv.cred_modal.as_mut() {
m.busy = true;
m.result = None;
}
self.piv.error = None;
match kind {
PivCredKind::ChangePin => self.piv_change_pin(),
PivCredKind::ChangePuk => self.piv_change_puk(),
PivCredKind::UnblockPin => self.piv_unblock_pin(),
PivCredKind::GenerateKey => self.piv_generate_key(),
PivCredKind::ImportCert => self.piv_import_cert(),
PivCredKind::SelfSign => self.piv_self_sign(),
PivCredKind::RequestCsr => self.piv_request_csr(),
PivCredKind::SetRetries => self.piv_set_retries(),
PivCredKind::ChangeMgmtKey => self.piv_change_management_key(),
PivCredKind::DeleteCert => self.piv_delete_cert(),
PivCredKind::DeleteKey => self.piv_delete_key(),
PivCredKind::MoveKey => self.piv_move_key(),
PivCredKind::NewChuid => self.piv_new_chuid(),
PivCredKind::SelfTest => self.piv_self_test(),
}
if !self.busy() {
let guard_err = self.piv.error.clone();
if let Some(m) = self.piv.cred_modal.as_mut() {
m.busy = false;
if let Some(e) = guard_err {
m.result = Some(Err(e));
}
}
}
}
}
fn piv_cred_modal_close(&mut self) {
wipe(&mut self.piv.pin_old);
wipe(&mut self.piv.pin_new);
wipe(&mut self.piv.pin_confirm);
wipe(&mut self.piv.puk_old);
wipe(&mut self.piv.puk_new);
wipe(&mut self.piv.puk_confirm);
wipe(&mut self.piv.unblock_puk);
wipe(&mut self.piv.unblock_new_pin);
wipe(&mut self.piv.mgmt_key_input);
wipe(&mut self.piv.new_mgmt_key_input);
wipe(&mut self.piv.sign_pin);
wipe(&mut self.piv.retries_pin_auth);
self.piv.mgmt_auth_mode = PivMgmtAuthMode::default();
self.piv.new_mgmt_allow_pin_unlock = false;
self.piv.move_dest = None;
self.piv.gen_pin_policy = keyroost_piv::PinPolicy::Default;
self.piv.gen_touch_policy = keyroost_piv::TouchPolicy::Default;
self.piv.cert_compression = PivCertCompressionSel::Auto;
self.piv.cred_modal = None;
}
fn openpgp_modal_admin_field(&mut self, ui: &mut egui::Ui, p: &Palette, kind: OpenPgpCredKind) {
if !kind.needs_admin_pin() {
return;
}
ui.checkbox(
&mut self.openpgp.use_default_admin,
"Use default admin PIN (PW3)",
);
if !self.openpgp.use_default_admin {
pin_field(ui, p, "Admin PIN", &mut self.openpgp.admin_pin);
}
}
fn render_openpgp_cred_modal(&mut self, ctx: &egui::Context, p: &Palette) {
let Some(kind) = self.openpgp.cred_modal.as_ref().map(|m| m.kind) else {
return;
};
let busy = self.openpgp.cred_modal.as_ref().is_some_and(|m| m.busy);
let result = self
.openpgp
.cred_modal
.as_ref()
.and_then(|m| m.result.clone());
let mut want_submit = false;
let mut want_close = false;
let mismatch = openpgp_cred_mismatch(&self.openpgp, kind);
let closed = Self::modal_window(ctx, p, "openpgp_cred", kind.title(), 300.0, |ui| {
match &result {
Some(Ok(())) => {
ui.label(
egui::RichText::new(format!("\u{2713} {}", kind.success()))
.font(theme::f_sb(13.0))
.color(p.ok),
);
ui.add_space(16.0);
if theme::button(ui, p, BtnKind::Primary, "Done").clicked() {
want_close = true;
}
}
_ => {
match kind {
OpenPgpCredKind::ChangeUserPin => {
pin_field(ui, p, "Current PIN", &mut self.openpgp.user_pin_old);
pin_field(ui, p, "New PIN", &mut self.openpgp.user_pin_new);
card_note(ui, p, "User PIN (PW1): 6\u{2013}127 characters.");
}
OpenPgpCredKind::ChangeAdminPin => {
pin_field(ui, p, "Current PIN", &mut self.openpgp.admin_pin_old);
pin_field(ui, p, "New PIN", &mut self.openpgp.admin_pin_new);
card_note(ui, p, "Admin PIN (PW3): 8\u{2013}127 characters.");
}
OpenPgpCredKind::UnblockUserPin => {
self.openpgp_modal_admin_field(ui, p, kind);
pin_field(ui, p, "New user PIN", &mut self.openpgp.unblock_new);
card_note(
ui,
p,
"Resets a blocked user PIN using the admin PIN (PW3).",
);
}
OpenPgpCredKind::SetName => {
self.openpgp_modal_admin_field(ui, p, kind);
card_note(ui, p, "Authorizes writing the cardholder name.");
}
OpenPgpCredKind::SetUrl => {
self.openpgp_modal_admin_field(ui, p, kind);
card_note(ui, p, "Authorizes writing the public-key URL.");
}
OpenPgpCredKind::GenerateKey => {
let crt = self.openpgp.selected_key.to_crt();
let choices =
OpenPgpKeyAlgSel::choices(self.openpgp.supported.as_ref(), crt);
ui.horizontal(|ui| {
ui.add_sized(
[96.0, 22.0],
egui::Label::new(
egui::RichText::new("Algorithm")
.font(theme::f_reg(13.0))
.color(p.txt2),
),
);
openpgp_keyalg_combo(
ui,
"openpgp-gen-alg",
&mut self.openpgp.gen_alg,
&choices,
);
});
ui.add_space(4.0);
if self.openpgp.supported.is_none() {
card_note(
ui,
p,
"This card does not list its algorithms; the card \
rejects any it cannot do.",
);
}
self.openpgp_modal_admin_field(ui, p, kind);
card_note(
ui,
p,
"OVERWRITES the slot with a fresh on-card key (clearable \
only by a full reset). May need a touch.",
);
}
OpenPgpCredKind::GenerateImportKey | OpenPgpCredKind::ImportKeyFile => {
self.openpgp_modal_admin_field(ui, p, kind);
card_note(
ui,
p,
"OVERWRITES the slot with the imported key (clearable only \
by a full reset). May need a touch.",
);
}
OpenPgpCredKind::Reset => {
card_note(
ui,
p,
"Wipes ALL OpenPGP keys and restores default PINs. Works \
even if the PINs are forgotten. No PIN needed.",
);
}
}
if let Some(msg) = mismatch {
ui.add_space(6.0);
ui.colored_label(p.err, msg);
} else if let Some(Err(e)) = &result {
ui.add_space(6.0);
ui.colored_label(p.err, e);
}
ui.add_space(16.0);
ui.horizontal(|ui| {
if busy {
ui.add(egui::Spinner::new());
ui.label(
egui::RichText::new(kind.busy_label())
.font(theme::f_reg(12.5))
.color(p.txt2),
);
} else {
let btn = if kind == OpenPgpCredKind::Reset {
BtnKind::Danger
} else {
BtnKind::Primary
};
if theme::button(ui, p, btn, kind.submit_label()).clicked()
&& mismatch.is_none()
{
want_submit = true;
}
ui.add_space(8.0);
if theme::button(ui, p, BtnKind::Default, "Cancel").clicked() {
want_close = true;
}
}
});
}
}
});
if (closed || want_close) && !busy {
self.openpgp_cred_modal_close();
return;
}
if want_submit && !busy {
if !completion_still_valid(
self.openpgp
.cred_modal
.as_ref()
.and_then(|m| m.device.as_ref()),
self.selected_device.as_ref(),
) {
self.openpgp_cred_modal_close();
return;
}
if let Some(m) = self.openpgp.cred_modal.as_mut() {
m.busy = true;
m.result = None;
}
self.openpgp.error = None;
match kind {
OpenPgpCredKind::ChangeUserPin => self.change_openpgp_user_pin(),
OpenPgpCredKind::ChangeAdminPin => self.change_openpgp_admin_pin(),
OpenPgpCredKind::UnblockUserPin => self.unblock_openpgp_user_pin(),
OpenPgpCredKind::SetName => self.set_openpgp_name(),
OpenPgpCredKind::SetUrl => self.set_openpgp_url(),
OpenPgpCredKind::GenerateKey => {
self.generate_openpgp_key();
}
OpenPgpCredKind::GenerateImportKey => {
self.import_openpgp_key(ImportSource::Generate);
}
OpenPgpCredKind::ImportKeyFile => {
self.import_openpgp_key(ImportSource::FromFile);
}
OpenPgpCredKind::Reset => {
self.reset_openpgp();
}
}
if !self.busy() {
let guard_err = self.openpgp.error.clone();
if let Some(m) = self.openpgp.cred_modal.as_mut() {
m.busy = false;
if let Some(e) = guard_err {
m.result = Some(Err(e));
}
}
}
}
}
fn openpgp_cred_modal_close(&mut self) {
self.openpgp.wipe_secrets();
self.openpgp.cred_modal = None;
}
fn cap_oath(&mut self, ui: &mut egui::Ui, p: &Palette) {
if !self.oath_tried && !self.busy() && self.oath.error.is_none() && !self.oath.locked {
self.oath_tried = true;
self.load_oath_creds();
}
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Authenticator codes")
.font(theme::f_sb(14.5))
.color(p.txt),
);
ui.add_space(6.0);
self.help_dot(ui, p, "oath");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if theme::button(ui, p, BtnKind::Primary, "+ Add credential").clicked() {
self.oath.add = OathAddDialog::opened();
}
ui.add_space(6.0);
if theme::button(ui, p, BtnKind::Default, "Refresh").clicked() {
self.load_oath_creds();
}
});
});
ui.add_space(12.0);
if let Some(err) = &self.oath.error {
ui.colored_label(p.err, err);
ui.add_space(6.0);
}
if self.oath.locked {
theme::card_frame(p).show(ui, |ui| {
ui.label(
egui::RichText::new("This key's OATH applet is password-protected.")
.font(theme::f_reg(13.0))
.color(p.txt),
);
ui.add_space(8.0);
ui.horizontal(|ui| {
let resp = ui.add_sized(
[220.0, 32.0],
egui::TextEdit::singleline(&mut self.oath.password_input)
.vertical_align(egui::Align::Center)
.password(true),
);
guard_secret_field(ui.ctx(), &resp);
let submit = theme::button(ui, p, BtnKind::Primary, "Unlock").clicked();
if submit
|| (resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)))
{
self.load_oath_creds();
}
});
});
ui.add_space(14.0);
self.render_oath_reset_card(ui, p);
return;
}
if !self.oath.loaded {
ui.label(
egui::RichText::new("Reading codes\u{2026}")
.font(theme::f_reg(13.0))
.color(p.txt3),
);
return;
}
if self.oath.skipped > 0 {
ui.label(
egui::RichText::new(format!(
"\u{26A0} {} entr{} on this key could not be decoded and {} not shown.",
self.oath.skipped,
if self.oath.skipped == 1 { "y" } else { "ies" },
if self.oath.skipped == 1 { "is" } else { "are" },
))
.font(theme::f_reg(13.0))
.color(p.warn),
);
ui.add_space(4.0);
}
if self.oath.creds.is_empty() {
if self.oath.skipped == 0 {
ui.label(
egui::RichText::new("No authenticator codes on this key.")
.font(theme::f_reg(13.0))
.color(p.txt3),
);
}
ui.add_space(14.0);
self.render_oath_reset_card(ui, p);
return;
}
let mut copy: Option<(String, String)> = None;
let mut delete: Option<String> = None;
let mut read: Option<String> = None;
theme::card_frame(p).show(ui, |ui| {
let n = self.oath.creds.len();
for (i, row) in self.oath.creds.iter().enumerate() {
let is_copied = self.copied.as_ref().is_some_and(|(nm, _)| nm == &row.name);
let (c, d, r) = oath_row(
ui,
p,
&row.name,
row.code.as_deref(),
row.hotp,
is_copied,
true,
);
if let Some(code) = c {
copy = Some((row.name.clone(), code));
}
if d {
delete = Some(row.name.clone());
}
if r {
read = Some(row.name.clone());
}
if i + 1 < n {
ui.add_space(5.0);
let y = ui.cursor().top();
ui.painter().hline(
ui.max_rect().x_range(),
y,
egui::Stroke::new(1.0, p.line_soft),
);
ui.add_space(5.0);
}
}
});
if let Some((name, code)) = copy {
ui.ctx().copy_text(code.clone());
self.copied = Some((name, now_secs_f64() + 1.2));
self.clipboard_clear_at = Some((code, now_secs_f64() + 45.0));
}
if let Some(name) = delete {
if let Some(dev) = self.selected_device.clone() {
self.oath.confirm_delete = Some((dev, name));
}
}
if let Some(name) = read {
self.read_hotp_code(&name);
}
ui.add_space(14.0);
self.render_oath_reset_card(ui, p);
}
fn render_oath_reset_card(&mut self, ui: &mut egui::Ui, p: &Palette) {
let mut arm = false;
theme::card_frame(p)
.stroke(egui::Stroke::new(1.0, theme::tint(p.err, 90)))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Reset applet")
.font(theme::f_sb(14.5))
.color(p.err),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if theme::button(ui, p, BtnKind::Danger, "Reset applet\u{2026}").clicked() {
arm = true;
}
});
});
ui.label(
egui::RichText::new(
"Wipes every authenticator credential and clears the password. \
Works without the password — this is the recovery for a \
forgotten one. Cannot be undone.",
)
.font(theme::f_reg(12.5))
.color(p.txt2),
);
});
if arm {
self.oath.confirm_reset = self.selected_device.clone();
}
}
fn cap_pgp(&mut self, ui: &mut egui::Ui, p: &Palette) {
let mut do_refresh = false;
let mut open_modal: Option<OpenPgpCredKind> = None;
let mut browse_import_key = false;
let mut clicked_key: Option<OpenPgpSlotSel> = None;
let selected = self.openpgp.selected_key;
let note = |ui: &mut egui::Ui, t: &str| card_note(ui, p, t);
theme::card_frame(p).show(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("OpenPGP card")
.font(theme::f_sb(14.5))
.color(p.txt),
);
ui.add_space(6.0);
self.help_dot(ui, p, "pgp");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if theme::button(ui, p, BtnKind::Default, "Read status").clicked() {
do_refresh = true;
}
});
});
ui.add_space(8.0);
if let Some(err) = &self.openpgp.error {
ui.colored_label(p.err, err);
ui.add_space(6.0);
}
if let Some(notice) = &self.openpgp.notice {
ui.colored_label(p.ok, notice);
ui.add_space(6.0);
}
if let Some(status) = &self.openpgp.status {
let serial = status
.serial()
.map_or("\u{2014}".to_string(), |s| format!("{s} (0x{s:08X})"));
let sigs = status
.signature_count
.map_or("\u{2014}".to_string(), |n| n.to_string());
ui.label(
egui::RichText::new(format!(
"AID {} \u{00B7} Serial {serial}",
hex_lower(&status.aid)
))
.font(theme::f_reg(12.5))
.color(p.txt2),
);
ui.add_space(2.0);
ui.label(
egui::RichText::new(format!(
"PIN retries PW1={} RC={} PW3={} \u{00B7} Signatures made {sigs}",
status.tries_pw1, status.tries_rc, status.tries_pw3
))
.font(theme::f_reg(12.5))
.color(p.txt2),
);
} else if self.openpgp.error.is_none() {
ui.label(
egui::RichText::new("Click Read status to read this card (no PIN or touch).")
.font(theme::f_reg(13.0))
.color(p.txt3),
);
}
ui.add_space(12.0);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Card details")
.font(theme::f_sb(13.5))
.color(p.txt),
);
ui.add_space(6.0);
self.help_dot(ui, p, "pgp-card-details");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if theme::button(ui, p, BtnKind::Default, "Set URL\u{2026}").clicked() {
open_modal = Some(OpenPgpCredKind::SetUrl);
}
ui.add_space(6.0);
if theme::button(ui, p, BtnKind::Default, "Set name\u{2026}").clicked() {
open_modal = Some(OpenPgpCredKind::SetName);
}
});
});
ui.add_space(6.0);
text_field(
ui,
p,
"Name",
&mut self.openpgp.name_input,
"Surname<<Given",
200.0,
);
ui.add_space(4.0);
text_field(
ui,
p,
"URL",
&mut self.openpgp.url_input,
"https://\u{2026}",
240.0,
);
ui.add_space(10.0);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("PINs")
.font(theme::f_sb(13.5))
.color(p.txt),
);
ui.add_space(6.0);
self.help_dot(ui, p, "pin");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if theme::button(ui, p, BtnKind::Default, "Unblock user PIN\u{2026}").clicked()
{
open_modal = Some(OpenPgpCredKind::UnblockUserPin);
}
ui.add_space(6.0);
if theme::button(ui, p, BtnKind::Default, "Change admin PIN\u{2026}").clicked()
{
open_modal = Some(OpenPgpCredKind::ChangeAdminPin);
}
ui.add_space(6.0);
if theme::button(ui, p, BtnKind::Default, "Change user PIN\u{2026}").clicked() {
open_modal = Some(OpenPgpCredKind::ChangeUserPin);
}
});
});
});
let sel_state: String = match &self.openpgp.status {
Some(st) => {
let (attrs, fpr) = selected.status_fields(st);
if fpr.iter().all(|&b| b == 0) {
"no key".to_string()
} else {
format!(
"{} \u{00B7} fpr {}",
keyroost_transport::describe_algorithm_attributes(attrs),
hex_lower(fpr)
)
}
}
None => "read status to view this key".to_string(),
};
ui.add_space(14.0);
{
let top = ui.cursor().top();
let strip = egui::Rect::from_min_max(
egui::pos2(ui.max_rect().left(), top - 4.0),
egui::pos2(ui.max_rect().right(), top + 30.0),
);
ui.painter().rect_filled(strip, 0.0, p.surface);
}
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 20.0;
for key in [
OpenPgpSlotSel::Sign,
OpenPgpSlotSel::Decrypt,
OpenPgpSlotSel::Auth,
] {
let active = selected == key;
let color = if active { p.txt } else { p.txt3 };
let resp = ui
.add(
egui::Label::new(
egui::RichText::new(key.tab_label())
.font(theme::f_sb(13.5))
.color(color),
)
.sense(egui::Sense::click()),
)
.on_hover_cursor(egui::CursorIcon::PointingHand);
if active {
let y = resp.rect.bottom() + 6.0;
ui.painter().line_segment(
[
egui::pos2(resp.rect.left(), y),
egui::pos2(resp.rect.right(), y),
],
egui::Stroke::new(2.0, p.accent),
);
}
if resp.clicked() {
clicked_key = Some(key);
}
}
});
ui.add_space(14.0);
ui.label(
egui::RichText::new(format!("State: {sel_state}"))
.font(theme::f_reg(12.5))
.color(p.txt2),
);
ui.add_space(10.0);
theme::card_frame(p).show(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Generate on-card")
.font(theme::f_sb(13.5))
.color(p.txt),
);
ui.add_space(6.0);
self.help_dot(ui, p, "pgp-keys");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if theme::button(ui, p, BtnKind::Default, "Generate\u{2026}").clicked() {
open_modal = Some(OpenPgpCredKind::GenerateKey);
}
});
});
note(
ui,
"Generating OVERWRITES this key (clearable only by a full reset). \
Prompts for the admin PIN; touch the key if it blinks.",
);
ui.add_space(12.0);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Import RSA-2048")
.font(theme::f_sb(13.5))
.color(p.txt),
);
ui.add_space(6.0);
self.help_dot(ui, p, "pgp-keys");
});
ui.add_space(6.0);
ui.horizontal(|ui| {
text_field(
ui,
p,
"From file",
&mut self.openpgp.import_path,
"/path/to/key.pem (PKCS#1/8, PEM or DER)",
240.0,
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
browse_import_key =
theme::button(ui, p, BtnKind::Default, "Browse\u{2026}").clicked();
});
});
let have_path = !self.openpgp.import_path.trim().is_empty();
ui.add_space(6.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if theme::button(ui, p, BtnKind::Default, "Import file\u{2026}").clicked()
&& have_path
{
open_modal = Some(OpenPgpCredKind::ImportKeyFile);
}
ui.add_space(6.0);
if theme::button(ui, p, BtnKind::Default, "Generate & import\u{2026}").clicked() {
open_modal = Some(OpenPgpCredKind::GenerateImportKey);
}
});
});
ui.add_space(12.0);
theme::card_frame(p)
.stroke(egui::Stroke::new(1.0, theme::tint(p.err, 90)))
.show(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Reset applet")
.font(theme::f_sb(14.5))
.color(p.err),
);
ui.add_space(6.0);
self.help_dot(ui, p, "reset");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if theme::button(ui, p, BtnKind::Danger, "Reset applet\u{2026}").clicked() {
open_modal = Some(OpenPgpCredKind::Reset);
}
});
});
ui.label(
egui::RichText::new(
"Wipes ALL OpenPGP keys and restores default PINs. Works even if \
the PINs are forgotten.",
)
.font(theme::f_reg(12.5))
.color(p.txt2),
);
});
if let Some(key) = clicked_key {
self.openpgp.selected_key = key;
self.openpgp.gen_alg = OpenPgpKeyAlgSel::CardDefault;
}
if do_refresh {
self.load_openpgp_status();
}
if browse_import_key {
self.spawn_file_dialog(
FileTarget::OpenpgpImport,
false,
&[("Keys", &["pem", "der", "key"]), ("All files", &["*"])],
None,
);
}
if let Some(kind) = open_modal {
self.openpgp.wipe_secrets();
self.openpgp.error = None;
self.openpgp.cred_modal =
Some(OpenPgpCredModal::new(kind, self.selected_device.clone()));
}
}
fn cap_piv(&mut self, ui: &mut egui::Ui, p: &Palette) {
use keyroost_piv::compat::{FeatureGate, PivExtension};
if !self.piv_tried && !self.busy() {
self.piv_tried = true;
self.load_piv_status(LogKind::Background);
}
let mut do_refresh = false;
let mut open_change_pin = false;
let mut open_change_puk = false;
let mut open_unblock = false;
let mut open_generate = false;
let mut open_import = false;
let mut go_export = false;
let mut open_self_sign = false;
let mut open_csr = false;
let mut open_set_retries = false;
let mut open_change_mgmt = false;
let mut open_delete_cert = false;
let mut open_delete_key = false;
let mut open_move_key = false;
let mut open_self_test = false;
let mut open_new_chuid = false;
let mut toggle_compat_table = false;
let mut click_retired_tab = false;
let mut arm_reset = false;
let mut go_to_overview = false;
let mut copy_pem: Option<String> = None;
let mut clicked_slot: Option<PivSlotSel> = None;
let selected = self.piv.selected_slot;
let note = |ui: &mut egui::Ui, t: &str| card_note(ui, p, t);
let (retries_piv_fp, retries_piv_ver, retries_piv_fw_ver) =
self.piv.status.as_ref().map_or(
(
keyroost_piv::fingerprint::AppletFingerprint::Generic,
None,
None,
),
|s| {
(
s.applet_fingerprint,
s.version.as_deref(),
s.version_firmware.as_deref(),
)
},
);
let set_retries_gate = self.piv_gate(
PivExtension::SetPinPukRetries,
retries_piv_fp,
retries_piv_ver,
retries_piv_fw_ver,
);
let set_retries_unverified_hint = format!(
"{} {}",
PivExtension::SetPinPukRetries.requirement(),
FeatureGate::UNVERIFIED_SUFFIX
);
let set_retries_blocked_hint = format!(
"{} {}",
PivExtension::SetPinPukRetries.requirement(),
FeatureGate::INCOMPATIBLE_SUFFIX
);
let change_mgmt_key_gate = self.piv_gate(
PivExtension::SetManagementKey,
retries_piv_fp,
retries_piv_ver,
retries_piv_fw_ver,
);
let change_mgmt_key_unverified_hint = format!(
"{} {}",
PivExtension::SetManagementKey.requirement(),
FeatureGate::UNVERIFIED_SUFFIX
);
let change_mgmt_key_blocked_hint = format!(
"{} {}",
PivExtension::SetManagementKey.requirement(),
FeatureGate::INCOMPATIBLE_SUFFIX
);
let mgmtalg_gates: [(PivMgmtAlgSel, FeatureGate); PivMgmtAlgSel::ALL.len()] =
PivMgmtAlgSel::ALL.map(|alg| {
(
alg,
self.piv_gate(
PivExtension::ManagementKeyAlgorithm(alg.to_choice()),
retries_piv_fp,
retries_piv_ver,
retries_piv_fw_ver,
),
)
});
let mgmtalg_gate = move |alg: PivMgmtAlgSel| {
mgmtalg_gates
.iter()
.find_map(|&(a, gate)| (a == alg).then_some(gate))
.unwrap_or(FeatureGate::Unverified)
};
if matches!(set_retries_gate, FeatureGate::Unsupported) {
self.piv.retries_pin = 3;
self.piv.retries_puk = 3;
}
ui.add_enabled_ui(!self.piv.inflight, |ui| {
theme::card_frame(p).show(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("PIV smart card")
.font(theme::f_sb(14.5))
.color(p.txt),
);
ui.add_space(6.0);
self.help_dot(ui, p, "piv");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if theme::button(ui, p, BtnKind::Default, "Refresh").clicked() {
do_refresh = true;
}
});
});
ui.add_space(8.0);
if let Some(err) = &self.piv.error {
ui.colored_label(p.err, err);
ui.add_space(6.0);
}
if let Some(n) = &self.piv.notice {
ui.colored_label(p.ok, n);
ui.add_space(6.0);
}
if let Some(st) = &self.piv.status {
let applet_ver = st.version.as_deref().map(keyroost_piv::format_version_bytes);
let fw_ver = st
.version_firmware
.as_deref()
.filter(|fw| Some(*fw) != st.version.as_deref())
.map(keyroost_piv::format_version_bytes);
let applet_field = match (applet_ver, fw_ver) {
(Some(av), Some(fw)) => format!("Applet {av} (FW v{fw})"),
(Some(av), None) => format!("Applet {av}"),
(None, Some(fw)) => format!("Applet FW v{fw}"),
(None, None) => "Applet \u{2014}".to_string(),
};
let serial = st
.serial
.map_or("\u{2014}".to_string(), keyroost_piv::format_serial_short);
let retries = st
.pin_retries
.map_or("\u{2014}".to_string(), |n| n.to_string());
let applet_name = if st.applet_name.is_empty() {
st.applet_fingerprint.applet_name().to_string()
} else {
st.applet_name.clone()
};
ui.label(
egui::RichText::new(format!(
"{applet_name} \u{00B7} {applet_field} \u{00B7} Serial {serial} \u{00B7} PIN retries {retries}"
))
.font(theme::f_reg(12.5))
.color(p.txt2),
);
if let Some(chuid) = &st.chuid {
ui.label(
egui::RichText::new(format!(
"CHUID: GUID {} \u{00B7} Expires {}",
chuid.guid_display(),
chuid.expiration_display()
))
.font(theme::f_reg(12.5))
.color(p.txt2),
);
}
} else if self.piv.error.is_none() {
ui.label(
egui::RichText::new("Reading PIV status\u{2026}")
.font(theme::f_reg(13.0))
.color(p.txt3),
);
}
ui.add_space(12.0);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("PIN & PUK")
.font(theme::f_sb(13.5))
.color(p.txt),
);
ui.add_space(6.0);
self.help_dot(ui, p, "pin");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if theme::button(ui, p, BtnKind::Default, "Unblock PIN\u{2026}").clicked() {
open_unblock = true;
}
ui.add_space(6.0);
if theme::button(ui, p, BtnKind::Default, "Change PUK\u{2026}").clicked() {
open_change_puk = true;
}
ui.add_space(6.0);
if theme::button(ui, p, BtnKind::Default, "Change PIN\u{2026}").clicked() {
open_change_pin = true;
}
});
});
ui.add_space(10.0);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Retry counts")
.font(theme::f_sb(13.5))
.color(p.txt),
);
ui.add_space(6.0);
self.help_dot(ui, p, "piv-admin");
if matches!(set_retries_gate, FeatureGate::Unverified) {
ui.add_space(4.0);
theme::warn_marker(ui, p).on_hover_text(set_retries_unverified_hint.as_str());
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if matches!(set_retries_gate, FeatureGate::Unsupported) {
theme::button_disabled(ui, p, "Set retry counts\u{2026}")
.on_hover_text(set_retries_blocked_hint.as_str());
} else if theme::button(ui, p, BtnKind::Default, "Set retry counts\u{2026}")
.clicked()
{
open_set_retries = true;
}
ui.add_space(8.0);
let retries_unsupported = matches!(set_retries_gate, FeatureGate::Unsupported);
let blank_when_unsupported = move |n: f64, _: std::ops::RangeInclusive<usize>| {
if retries_unsupported {
String::new()
} else {
format!("{}", n as u8)
}
};
ui.add_enabled(
!retries_unsupported,
egui::DragValue::new(&mut self.piv.retries_puk)
.range(1..=15u8)
.custom_formatter(blank_when_unsupported),
);
ui.label(
egui::RichText::new("PUK tries")
.font(theme::f_reg(13.0))
.color(p.txt2),
);
ui.add_space(8.0);
ui.add_enabled(
!retries_unsupported,
egui::DragValue::new(&mut self.piv.retries_pin)
.range(1..=15u8)
.custom_formatter(blank_when_unsupported),
);
ui.label(
egui::RichText::new("PIN tries")
.font(theme::f_reg(13.0))
.color(p.txt2),
);
});
});
ui.add_space(10.0);
let mgmt_alg_options = &PivMgmtAlgSel::ALL[..];
self.piv.new_mgmt_alg = piv_mgmtalg_unsupported_fallback(
self.piv.new_mgmt_alg,
mgmt_alg_options,
mgmtalg_gate,
);
let mgmt_key_unsupported = matches!(change_mgmt_key_gate, FeatureGate::Unsupported);
let mgmt_alg_unverified =
piv_mgmtalg_unverified_labels(change_mgmt_key_gate, mgmt_alg_options, mgmtalg_gate);
let mgmt_alg_unverified_hint = format!(
"No entry for this key in keyroost's list: {}.",
mgmt_alg_unverified.join(", ")
);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Management key")
.font(theme::f_sb(13.5))
.color(p.txt),
);
ui.add_space(6.0);
self.help_dot(ui, p, "piv-admin");
let extension_unverified = matches!(change_mgmt_key_gate, FeatureGate::Unverified);
if extension_unverified || !mgmt_alg_unverified.is_empty() {
ui.add_space(4.0);
let combined_hint = match (extension_unverified, mgmt_alg_unverified.is_empty()) {
(true, false) => {
format!("{change_mgmt_key_unverified_hint} {mgmt_alg_unverified_hint}")
}
(true, true) => change_mgmt_key_unverified_hint.clone(),
(false, false) => mgmt_alg_unverified_hint.clone(),
(false, true) => unreachable!("guarded by the `if` above"),
};
theme::warn_marker(ui, p).on_hover_text(combined_hint.as_str());
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if matches!(change_mgmt_key_gate, FeatureGate::Unsupported) {
theme::button_disabled(ui, p, "Change management key\u{2026}")
.on_hover_text(change_mgmt_key_blocked_hint.as_str());
} else if theme::button(ui, p, BtnKind::Default, "Change management key\u{2026}")
.clicked()
{
open_change_mgmt = true;
}
ui.add_space(8.0);
ui.add_enabled_ui(!mgmt_key_unsupported, |ui| {
piv_mgmtalg_combo(
ui,
"piv-new-mgmt-alg",
&mut self.piv.new_mgmt_alg,
mgmt_alg_options,
mgmtalg_gate,
);
})
.response
.on_disabled_hover_text(change_mgmt_key_blocked_hint.as_str());
});
});
ui.add_space(10.0);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("CHUID")
.font(theme::f_sb(13.5))
.color(p.txt),
);
ui.add_space(6.0);
self.help_dot(ui, p, "piv-admin");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if theme::button(ui, p, BtnKind::Default, "New CHUID\u{2026}").clicked() {
open_new_chuid = true;
}
});
});
});
});
let sel_state: String = if let PivSlotSel::Retired(n) = selected {
let has_key = self
.piv
.retired_occupancy
.as_ref()
.and_then(|v| v.iter().find(|(s, _)| *s == keyroost_piv::Slot::Retired(n)))
.map(|(_, has)| *has);
match has_key {
Some(true) => "key present".to_string(),
_ => "empty".to_string(),
}
} else {
let sel_slot = selected.to_slot();
let sel_status = self
.piv
.status
.as_ref()
.and_then(|s| s.slots.iter().find(|sl| sl.slot == sel_slot));
let cert_present = sel_status.is_some_and(|sl| sl.cert_present);
let cert_unreadable = sel_status.and_then(|sl| sl.cert_unreadable);
let cert_compressed = sel_status.is_some_and(|sl| sl.cert_compressed);
let entry = self.piv.slot_keys.iter().find(|(s, _, _)| *s == sel_slot);
let alg = entry.and_then(|(_, a, _)| *a);
let dn = entry.and_then(|(_, _, d)| d.as_deref());
let mut s = piv_slot_state_text(cert_unreadable, cert_present, cert_compressed, alg);
if cert_present || alg.is_some() {
let policy = self
.piv
.slot_policies
.iter()
.find(|(s, _)| *s == sel_slot)
.and_then(|(_, p)| *p);
s.push_str(" \u{00B7} ");
match policy {
Some((pin, touch)) => {
s.push_str("pin: ");
s.push_str(pin.label());
s.push_str(", touch: ");
s.push_str(touch.label());
}
None => s.push_str("pin/touch policy not available"),
}
}
if let Some(dn) = dn {
s.push_str(" \u{00B7} ");
if dn.chars().count() > 64 {
s.extend(dn.chars().take(63));
s.push('\u{2026}');
} else {
s.push_str(dn);
}
}
s
};
let selected_has_key = piv_slot_occupied(
selected,
&self.piv.slot_keys,
self.piv.retired_occupancy.as_deref(),
);
let selected_has_cert = match selected {
PivSlotSel::Retired(_) => true,
_ => {
!self.piv.loaded
|| self.piv.status.as_ref().is_some_and(|s| {
s.slots
.iter()
.any(|sl| sl.slot == selected.to_slot() && sl.cert_present)
})
}
};
let (piv_fp, piv_ver, piv_fw_ver) = self.piv.status.as_ref().map_or(
(
keyroost_piv::fingerprint::AppletFingerprint::Generic,
None,
None,
),
|s| {
(
s.applet_fingerprint,
s.version.as_deref(),
s.version_firmware.as_deref(),
)
},
);
let move_key_gate = self.piv_gate(PivExtension::MoveKey, piv_fp, piv_ver, piv_fw_ver);
let delete_key_gate = self.piv_gate(PivExtension::DeleteKey, piv_fp, piv_ver, piv_fw_ver);
let get_slot_key_status_gate =
self.piv_gate(PivExtension::GetSlotKeyStatus, piv_fp, piv_ver, piv_fw_ver);
let reset_gate = self.piv_gate(PivExtension::Reset, piv_fp, piv_ver, piv_fw_ver);
let reset_global_gate =
self.piv_gate(PivExtension::ResetGlobal, piv_fp, piv_ver, piv_fw_ver);
let show_reset_global_alternative =
piv_reset_global_alternative_available(reset_gate, reset_global_gate);
let reset_long_running = keyroost_piv::compat::resolve_quirks(piv_fp, piv_ver, piv_fw_ver)
.contains(&keyroost_piv::compat::PivQuirk::ResetLongRunning);
let keyalg_gates: [(keyroost_piv::KeyAlg, FeatureGate); keyroost_piv::KeyAlg::ALL.len()] =
keyroost_piv::KeyAlg::ALL.map(|alg| {
(
alg,
self.piv_gate(
PivExtension::SlotKeyAlgorithm(alg),
piv_fp,
piv_ver,
piv_fw_ver,
),
)
});
let keyalg_gate = move |alg: keyroost_piv::KeyAlg| {
keyalg_gates
.iter()
.find_map(|&(a, gate)| (a == alg).then_some(gate))
.unwrap_or(FeatureGate::Unverified)
};
let move_key_unverified_hint = format!(
"{} {}",
PivExtension::MoveKey.requirement(),
FeatureGate::UNVERIFIED_SUFFIX
);
let move_key_blocked_hint = format!(
"{} {}",
PivExtension::MoveKey.requirement(),
FeatureGate::INCOMPATIBLE_SUFFIX
);
let delete_key_unverified_hint = format!(
"{} {}",
PivExtension::DeleteKey.requirement(),
FeatureGate::UNVERIFIED_SUFFIX
);
let delete_key_blocked_hint = format!(
"{} {}",
PivExtension::DeleteKey.requirement(),
FeatureGate::INCOMPATIBLE_SUFFIX
);
let reset_unverified_hint = format!(
"{} {}",
PivExtension::Reset.requirement(),
FeatureGate::UNVERIFIED_SUFFIX
);
let reset_blocked_hint = format!(
"{} {}",
PivExtension::Reset.requirement(),
FeatureGate::INCOMPATIBLE_SUFFIX
);
let no_slot_key_hint = "This slot has no key. Generate a key in this slot first \u{2014} \
a self-signed certificate and a CSR are both signed by it.";
let sel_key_can_sign = self
.piv_selected_test_target()
.0
.is_none_or(|a| keyroost_piv::x509::signature_hash(a).is_ok());
let cant_sign_hint = "This slot's key only supports key agreement, not signing \u{2014} \
it can't produce a self-signed certificate or a CSR. keyroost has no CRMF/CMP-style \
enrollment for that kind of key.";
let no_slot_cert_hint =
"This slot holds no certificate to export. Import one, or create a self-signed \
certificate above.";
let no_move_key_hint =
"This slot has no key to move \u{2014} generate one in this slot first.";
let no_del_cert_hint = "This slot holds no certificate to delete.";
let no_del_key_hint = "This slot holds no key to delete.";
let no_import_cert_hint = "This slot has no key for a certificate to match \u{2014} \
generate or move one into this slot first.";
ui.add_space(14.0);
let strip_bg = ui.painter().add(egui::Shape::Noop);
let strip_resp = ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 20.0;
ui.spacing_mut().item_spacing.y = 22.0;
let tab = |ui: &mut egui::Ui, label: String, active: bool| -> bool {
let color = if active { p.txt } else { p.txt3 };
let resp = ui
.add(
egui::Label::new(
egui::RichText::new(label)
.font(theme::f_sb(13.5))
.color(color),
)
.wrap_mode(egui::TextWrapMode::Extend)
.sense(egui::Sense::click()),
)
.on_hover_cursor(egui::CursorIcon::PointingHand);
if active {
let y = resp.rect.bottom() + 6.0;
ui.painter().line_segment(
[
egui::pos2(resp.rect.left(), y),
egui::pos2(resp.rect.right(), y),
],
egui::Stroke::new(2.0, p.accent),
);
}
resp.clicked()
};
for slot in [
PivSlotSel::Auth,
PivSlotSel::Sign,
PivSlotSel::KeyMgmt,
PivSlotSel::CardAuth,
] {
if tab(ui, slot.label(), selected == slot) {
clicked_slot = Some(slot);
}
}
let retired_active = matches!(selected, PivSlotSel::Retired(_));
if tab(
ui,
"retired keys (82\u{2013}95)".to_string(),
retired_active,
) {
click_retired_tab = true;
}
if retired_active {
ui.add_space(2.0);
self.help_dot(ui, p, "piv-retired");
}
});
{
let row = strip_resp.response.rect;
let strip = egui::Rect::from_min_max(
egui::pos2(ui.max_rect().left(), row.top() - 4.0),
egui::pos2(ui.max_rect().right(), row.bottom() + 6.0),
);
ui.painter()
.set(strip_bg, egui::Shape::rect_filled(strip, 0.0, p.surface));
}
if matches!(selected, PivSlotSel::Retired(_)) {
ui.add_space(14.0);
match self.piv.retired_occupancy.clone() {
Some(occ) => {
let rail_h = (ui.available_height() - 300.0).clamp(150.0, 20.0 * 30.0);
egui::ScrollArea::vertical()
.id_salt("piv-retired-rail")
.auto_shrink([false, false])
.max_height(rail_h)
.show(ui, |ui| {
ui.set_width(300.0);
for (slot, has_key) in occ {
let keyroost_piv::Slot::Retired(n) = slot else {
continue;
};
let sel_variant = PivSlotSel::Retired(n);
let active = selected == sel_variant;
let (rect, resp) = ui.allocate_exact_size(
egui::vec2(ui.available_width(), 30.0),
egui::Sense::click(),
);
let bg = if active {
p.accent_soft()
} else if resp.hovered() {
p.line_soft
} else {
egui::Color32::TRANSPARENT
};
ui.painter().rect(
rect,
egui::CornerRadius::same(8),
bg,
egui::Stroke::NONE,
egui::StrokeKind::Inside,
);
ui.painter().text(
rect.left_center() + egui::vec2(12.0, 0.0),
egui::Align2::LEFT_CENTER,
format!("Slot {:02X} \u{00B7} retired key {n}", 0x81 + n),
theme::f_mono(12.5),
if active {
p.accent
} else if has_key {
p.txt
} else {
p.txt3
},
);
if has_key {
ui.painter().circle_filled(
egui::pos2(rect.right() - 12.0, rect.center().y),
3.0,
if active { p.accent } else { p.ok },
);
}
let resp = resp.on_hover_text(if has_key {
format!("{} \u{00B7} holds a key", slot.label())
} else {
format!("{} \u{00B7} empty", slot.label())
});
if resp
.on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked()
{
clicked_slot = Some(sel_variant);
}
}
});
}
None => note(ui, "Reading retired slots\u{2026}"),
}
}
ui.add_space(14.0);
ui.label(
egui::RichText::new(format!("State: {sel_state}"))
.font(theme::f_reg(12.5))
.color(p.txt2),
);
ui.add_space(10.0);
ui.add_enabled_ui(!self.piv.inflight, |ui| {
theme::card_frame(p).show(ui, |ui| {
ui.set_min_width(ui.available_width());
self.piv.gen_alg = piv_keyalg_unsupported_fallback(
self.piv.gen_alg,
keyroost_piv::KeyAlg::EccP256,
keyalg_gate,
);
let unverified_algs: Vec<&str> = keyroost_piv::KeyAlg::ALL
.into_iter()
.filter(|&alg| keyalg_gate(alg) == FeatureGate::Unverified)
.map(keyroost_piv::KeyAlg::label)
.collect();
let gen_alg_unverified_hint = format!(
"No entry for this key in keyroost's list: {}.",
unverified_algs.join(", ")
);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Generate key")
.font(theme::f_sb(13.5))
.color(p.txt),
);
ui.add_space(6.0);
self.help_dot(ui, p, "piv-generate");
if !unverified_algs.is_empty() {
ui.add_space(4.0);
theme::warn_marker(ui, p).on_hover_text(gen_alg_unverified_hint.as_str());
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if theme::button(ui, p, BtnKind::Default, "Generate\u{2026}").clicked() {
open_generate = true;
}
ui.add_space(8.0);
piv_keyalg_combo(ui, "piv-gen-alg", &mut self.piv.gen_alg, keyalg_gate);
});
});
let gen_pubkey_pem = self
.selected_device
.as_ref()
.and_then(|id| self.piv_session_state.get(id))
.and_then(|state| state.cached_pubkey(self.piv.selected_slot.to_slot()))
.and_then(|(alg, key)| {
keyroost_piv::spki::subject_public_key_info(key, alg).ok()
})
.map(|der| keyroost_piv::spki::to_pem(&der));
if let Some(pem) = &gen_pubkey_pem {
ui.add_space(6.0);
ui.add(
egui::TextEdit::multiline(&mut pem.as_str())
.desired_rows(4)
.desired_width(f32::INFINITY)
.font(egui::TextStyle::Monospace),
);
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if theme::button(ui, p, BtnKind::Ghost, "Copy public key").clicked() {
copy_pem = Some(pem.clone());
}
});
});
}
ui.add_space(12.0);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Certificate")
.font(theme::f_sb(13.5))
.color(p.txt),
);
ui.add_space(6.0);
self.help_dot(ui, p, "piv-certificate");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if selected_has_key && sel_key_can_sign {
if theme::button(ui, p, BtnKind::Default, "Self-signed \u{2192} slot")
.clicked()
{
open_self_sign = true;
}
} else {
theme::button_disabled(ui, p, "Self-signed \u{2192} slot")
.on_hover_text(if selected_has_key {
cant_sign_hint
} else {
no_slot_key_hint
});
}
ui.add_space(8.0);
if selected_has_key && sel_key_can_sign {
if theme::button(ui, p, BtnKind::Default, "Sign & save CSR\u{2026}")
.clicked()
{
open_csr = true;
}
} else {
theme::button_disabled(ui, p, "Sign & save CSR\u{2026}").on_hover_text(
if selected_has_key {
cant_sign_hint
} else {
no_slot_key_hint
},
);
}
});
});
ui.add_space(6.0);
text_field(
ui,
p,
"Name",
&mut self.piv.cert_subject,
"e.g. Alice — or full CN=Alice,O=Example,C=US",
300.0,
);
ui.horizontal(|ui| {
ui.add_sized(
[96.0, 22.0],
egui::Label::new(
egui::RichText::new("Valid for")
.font(theme::f_reg(13.0))
.color(p.txt2),
),
);
piv_valid_for_fields(
ui,
unix_now(),
&mut self.piv.cert_valid_years,
&mut self.piv.cert_valid_months,
&mut self.piv.cert_valid_days,
);
});
ui.add_space(12.0);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Import/Export cert")
.font(theme::f_sb(13.5))
.color(p.txt),
);
ui.add_space(6.0);
self.help_dot(ui, p, "piv-import-export");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if selected_has_cert {
if theme::button(ui, p, BtnKind::Default, "Export certificate\u{2026}")
.clicked()
{
go_export = true;
}
} else {
theme::button_disabled(ui, p, "Export certificate\u{2026}")
.on_hover_text(no_slot_cert_hint);
}
ui.add_space(8.0);
if slot_confirmed_empty(get_slot_key_status_gate, selected_has_key) {
theme::button_disabled(ui, p, "Import certificate\u{2026}")
.on_hover_text(no_import_cert_hint);
} else if theme::button(
ui,
p,
BtnKind::Default,
"Import certificate\u{2026}",
)
.clicked()
{
open_import = true;
}
});
});
ui.add_space(12.0);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Move key")
.font(theme::f_sb(13.5))
.color(p.txt),
);
ui.add_space(6.0);
self.help_dot(ui, p, "piv-move");
if matches!(move_key_gate, FeatureGate::Unverified) {
ui.add_space(4.0);
theme::warn_marker(ui, p).on_hover_text(move_key_unverified_hint.as_str());
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
match move_key_gate {
FeatureGate::Unsupported => {
theme::button_disabled(ui, p, "Move key\u{2026}")
.on_hover_text(move_key_blocked_hint.as_str());
}
FeatureGate::Supported | FeatureGate::Unverified => {
if !selected_has_key {
theme::button_disabled(ui, p, "Move key\u{2026}")
.on_hover_text(no_move_key_hint);
} else if theme::button(ui, p, BtnKind::Default, "Move key\u{2026}")
.clicked()
{
open_move_key = true;
}
}
}
});
});
ui.add_space(12.0);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Delete")
.font(theme::f_sb(13.5))
.color(p.txt),
);
ui.add_space(6.0);
self.help_dot(ui, p, "piv-delete");
if matches!(delete_key_gate, FeatureGate::Unverified) {
ui.add_space(4.0);
theme::warn_marker(ui, p).on_hover_text(format!(
"{delete_key_unverified_hint} \u{201c}Delete certificate\u{201d} is \
standard PIV and unaffected."
));
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
match delete_key_gate {
FeatureGate::Unsupported => {
theme::button_disabled(ui, p, "Delete key\u{2026}")
.on_hover_text(delete_key_blocked_hint.as_str());
}
FeatureGate::Supported | FeatureGate::Unverified => {
if slot_confirmed_empty(get_slot_key_status_gate, selected_has_key)
{
theme::button_disabled(ui, p, "Delete key\u{2026}")
.on_hover_text(no_del_key_hint);
} else if theme::button(
ui,
p,
BtnKind::Danger,
"Delete key\u{2026}",
)
.clicked()
{
open_delete_key = true;
}
}
}
ui.add_space(6.0);
if selected_has_cert {
if theme::button(ui, p, BtnKind::Default, "Delete certificate\u{2026}")
.clicked()
{
open_delete_cert = true;
}
} else {
theme::button_disabled(ui, p, "Delete certificate\u{2026}")
.on_hover_text(no_del_cert_hint);
}
});
});
ui.add_space(12.0);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Test")
.font(theme::f_sb(13.5))
.color(p.txt),
);
ui.add_space(6.0);
self.help_dot(ui, p, "piv-test");
let (test_alg, test_has_cert) = self.piv_selected_test_target();
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if test_has_cert && test_alg.is_some() {
if theme::button(ui, p, BtnKind::Default, "Test\u{2026}").clicked() {
open_self_test = true;
}
} else {
let why = if !test_has_cert {
"Needs a certificate in this slot."
} else {
"The slot's key type is unknown."
};
ui.add_enabled_ui(false, |ui| {
theme::button(ui, p, BtnKind::Default, "Test\u{2026}")
})
.inner
.on_disabled_hover_text(why);
}
});
});
});
ui.add_space(12.0);
theme::card_frame(p)
.stroke(egui::Stroke::new(1.0, theme::tint(p.err, 90)))
.show(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Reset applet")
.font(theme::f_sb(14.5))
.color(p.err),
);
ui.add_space(6.0);
self.help_dot(ui, p, "reset");
if matches!(reset_gate, FeatureGate::Unverified) {
ui.add_space(4.0);
theme::warn_marker(ui, p).on_hover_text(reset_unverified_hint.as_str());
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
match reset_gate {
FeatureGate::Supported | FeatureGate::Unverified => {
if theme::button(ui, p, BtnKind::Danger, "Reset applet\u{2026}")
.clicked()
{
arm_reset = true;
}
}
FeatureGate::Unsupported => {
theme::button_disabled(ui, p, "Reset applet\u{2026}")
.on_hover_text(reset_blocked_hint.as_str());
}
}
});
});
ui.label(
egui::RichText::new(if matches!(reset_gate, FeatureGate::Supported) {
"Wipes ALL PIV keys, certificates, and PINs."
} else {
"Wipes ALL PIV keys, certificates, and PINs. Typically requires both \
the PIN and PUK to already be blocked."
})
.font(theme::f_reg(12.5))
.color(p.txt2),
);
if reset_long_running {
ui.add_space(4.0);
ui.label(
egui::RichText::new(
keyroost_piv::compat::PivQuirk::RESET_LONG_RUNNING_HINT,
)
.font(theme::f_reg(12.5))
.color(p.warn),
);
}
if show_reset_global_alternative {
ui.add_space(6.0);
if ui
.add(
egui::Label::new(
egui::RichText::new("Factory reset supported \u{2192}")
.font(theme::f_sb(12.5))
.color(p.accent),
)
.sense(egui::Sense::click()),
)
.on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked()
{
go_to_overview = true;
}
}
});
let ignored = self.piv_compat_ignored_selected();
let unsupported = self.piv.status.as_ref().map_or(0, |st| {
piv_compat_unsupported_count(
st.applet_fingerprint,
st.version.as_deref(),
st.version_firmware.as_deref(),
)
});
if ignored || unsupported > 0 {
ui.add_space(12.0);
ui.horizontal(|ui| {
let (text, link, tip) = if ignored {
(
"Unsupported PIV features are enabled for this key.".to_string(),
"Undo",
"Grey them out again.",
)
} else {
(
if unsupported == 1 {
"1 PIV feature is marked unsupported for this key.".to_string()
} else {
format!(
"{unsupported} PIV features are marked unsupported for this key."
)
},
"Enable Anyway",
"If this key can't do one of them, it will just refuse.",
)
};
ui.label(
egui::RichText::new(text)
.font(theme::f_reg(12.5))
.color(if ignored { p.warn } else { p.txt2 }),
);
if ui
.add(
egui::Label::new(
egui::RichText::new(link)
.font(theme::f_sb(12.5))
.color(p.accent),
)
.sense(egui::Sense::click()),
)
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text(tip)
.clicked()
{
toggle_compat_table = true;
}
});
}
});
if let Some(slot) = clicked_slot {
self.piv.selected_slot = slot;
}
if do_refresh {
self.load_piv_status_with_cache(LogKind::User, PivCache::Bypass);
}
if open_change_pin {
self.piv_cred_modal_close();
self.piv.cred_modal = Some(PivCredModal::new(PivCredKind::ChangePin));
}
if open_change_puk {
self.piv_cred_modal_close();
self.piv.cred_modal = Some(PivCredModal::new(PivCredKind::ChangePuk));
}
if open_unblock {
self.piv_cred_modal_close();
self.piv.cred_modal = Some(PivCredModal::new(PivCredKind::UnblockPin));
}
if open_generate {
self.piv_cred_modal_close();
self.piv.cred_modal = Some(PivCredModal::new(PivCredKind::GenerateKey));
}
if open_import {
self.spawn_file_dialog(
FileTarget::PivCert,
false,
&[
("Certificates", &["pem", "der", "crt", "cer"]),
("All files", &["*"]),
],
None,
);
}
if go_export {
self.spawn_file_dialog(
FileTarget::PivExport,
true,
&[
("Certificate (DER)", &["der", "cer"]),
("Certificate (PEM)", &["pem", "crt"]),
("All files", &["*"]),
],
Some("cert.der"),
);
}
if open_self_sign {
self.piv_cred_modal_close();
self.piv.cred_modal = Some(PivCredModal::new(PivCredKind::SelfSign));
}
if open_csr {
self.spawn_file_dialog(
FileTarget::PivCsr,
true,
&[("CSR", &["csr", "pem"]), ("All files", &["*"])],
Some("request.csr"),
);
}
if open_set_retries {
self.piv_cred_modal_close();
self.piv.cred_modal = Some(PivCredModal::new(PivCredKind::SetRetries));
}
if open_change_mgmt {
self.piv_cred_modal_close();
self.piv.cred_modal = Some(PivCredModal::new(PivCredKind::ChangeMgmtKey));
}
if open_delete_cert {
self.piv_cred_modal_close();
self.piv.cred_modal = Some(PivCredModal::new(PivCredKind::DeleteCert));
}
if open_delete_key {
self.piv_cred_modal_close();
self.piv.cred_modal = Some(PivCredModal::new(PivCredKind::DeleteKey));
}
if open_self_test {
self.piv_cred_modal_close();
self.piv.cred_modal = Some(PivCredModal::new(PivCredKind::SelfTest));
}
if open_move_key {
self.piv_cred_modal_close();
self.piv.cred_modal = Some(PivCredModal::new(PivCredKind::MoveKey));
if self.piv.retired_occupancy.is_none() {
let _ = self.load_piv_retired_occupancy();
}
}
if toggle_compat_table {
if let Some(id) = self.selected_device.clone() {
if !self.piv_compat_ignored.remove(&id) {
self.piv_compat_ignored.insert(id);
}
}
self.load_piv_status_with_cache(LogKind::Background, PivCache::Bypass);
}
if open_new_chuid {
self.piv_cred_modal_close();
self.piv.cred_modal = Some(PivCredModal::new(PivCredKind::NewChuid));
self.piv_refresh_chuid_guid();
}
if click_retired_tab {
let first_used = self
.piv
.retired_occupancy
.as_ref()
.and_then(|occ| {
occ.iter()
.find(|(_, has)| *has)
.and_then(|(slot, _)| match slot {
keyroost_piv::Slot::Retired(n) => Some(*n),
_ => None,
})
})
.unwrap_or(1);
self.piv.selected_slot = PivSlotSel::Retired(first_used);
if self.piv.retired_occupancy.is_none() {
let _ = self.load_piv_retired_occupancy();
}
}
if arm_reset {
self.piv.confirm_reset = Some(String::new());
}
if go_to_overview {
self.cap_tab = CapTab::Overview;
}
if let Some(pem) = copy_pem {
ui.ctx().copy_text(pem);
self.clipboard_clear_at = None; }
}
fn molto_view(&mut self, ui: &mut egui::Ui, p: &Palette, dev: &Device) {
let mp = Palette {
accent: p.brand,
..*p
};
let p = ∓
let mut open_rename = false;
let mut do_save = false;
let mut do_cancel = false;
egui::Frame::NONE
.inner_margin(egui::Margin::symmetric(26, 16))
.show(ui, |ui| {
ui.horizontal(|ui| {
glyph_tile(ui, 46.0, p.brand, p.accent_ink, None);
ui.add_space(12.0);
ui.vertical(|ui| {
ui.horizontal(|ui| {
if self.rename_target.is_some() {
let resp = ui.add_sized(
[200.0, 32.0],
egui::TextEdit::singleline(&mut self.rename_input)
.vertical_align(egui::Align::Center)
.hint_text("friendly-name"),
);
let enter = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
if theme::button(ui, p, BtnKind::Primary, "Save").clicked() || enter {
do_save = true;
}
ui.add_space(4.0);
if theme::button(ui, p, BtnKind::Ghost, "Cancel").clicked() {
do_cancel = true;
}
} else {
ui.label(egui::RichText::new(dev.title()).font(theme::f_bold(21.0)).color(p.txt));
ui.add_space(6.0);
theme::pill(ui, "Programmable TOTP token", p.brand, p.brand_soft());
ui.add_space(4.0);
self.help_dot(ui, p, "molto");
ui.add_space(6.0);
let label = if dev.name.is_some() { "Rename" } else { "Name this token" };
if ui
.add(
egui::Label::new(egui::RichText::new(label).font(theme::f_sb(12.0)).color(p.accent))
.sense(egui::Sense::click()),
)
.clicked()
{
open_rename = true;
}
}
});
if self.rename_target.is_some() {
ui.add_space(3.0);
ui.label(
egui::RichText::new(
"Saves this token's serial with the name to keys.json on this computer \u{2014} nothing leaves your machine.",
)
.font(theme::f_reg(11.5))
.color(p.txt3),
);
}
ui.add_space(2.0);
let serial = if dev.serial.is_empty() { "\u{2014}".to_string() } else { dev.serial.clone() };
ui.label(
egui::RichText::new(format!("{} \u{00B7} #{} \u{00B7} {} slots", dev.vendor, serial, PROFILES))
.font(theme::f_reg(12.5))
.color(p.txt2),
);
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(egui::RichText::new("Connected").font(theme::f_sb(12.5)).color(p.txt2));
ui.add_space(5.0);
theme::status_dot(ui, p.ok, 8.0);
});
});
});
self.apply_rename_actions(dev, open_rename, do_cancel, do_save);
egui::Frame::NONE
.inner_margin(egui::Margin::symmetric(26, 14))
.show(ui, |ui| {
theme::card_frame(p).show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Device").font(theme::f_sb(14.5)).color(p.txt));
ui.add_space(6.0);
self.help_dot(ui, p, "molto");
});
ui.add_space(10.0);
ui.horizontal(|ui| {
ui.add_sized(
[104.0, 22.0],
egui::Label::new(egui::RichText::new("Customer key").font(theme::f_reg(13.0)).color(p.txt2)),
);
self.help_dot(ui, p, "custkey");
ui.add_space(6.0);
let mut rev =
self.secret_reveal.get("customer-key").copied().unwrap_or(false);
secret_edit(
ui,
p,
&mut self.customer_key_input,
&mut rev,
"default if empty",
200.0,
);
self.secret_reveal.insert("customer-key", rev);
ui.checkbox(&mut self.customer_key_hex, "hex");
if theme::button(ui, p, BtnKind::Default, "Authenticate").clicked() {
self.authenticate();
}
if self.authenticated {
theme::pill(ui, "authed", p.ok, p.ok_soft());
}
});
ui.add_space(4.0);
ui.label(
egui::RichText::new("Programming any slot first needs the token's customer key (blank = factory default).")
.font(theme::f_reg(11.5))
.color(p.txt3),
);
ui.add_space(12.0);
ui.horizontal(|ui| {
if theme::button(ui, p, BtnKind::Default, "Refresh slots").clicked() {
self.resweep_slot_meta(LogKind::User);
}
ui.add_space(6.0);
if theme::button(ui, p, BtnKind::Default, "Sync time on all").clicked() {
self.sync_time_all();
}
ui.add_space(6.0);
if theme::button(ui, p, BtnKind::Default, "Bulk import\u{2026}").clicked() {
self.bulk_dialog.open = true;
self.bulk_dialog.start = self.slot;
}
ui.add_space(6.0);
if theme::button(ui, p, BtnKind::Danger, "Factory reset\u{2026}").clicked() {
self.molto_reset_confirm = true;
}
});
if self.molto_reset_confirm {
ui.add_space(10.0);
egui::Frame::NONE
.fill(p.err_soft())
.inner_margin(egui::Margin::same(12))
.corner_radius(egui::CornerRadius::same(8))
.show(ui, |ui| {
ui.label(
egui::RichText::new("Factory-reset the token? This wipes all slots, then asks you to confirm with the \u{25B2} button on the device itself.")
.font(theme::f_reg(12.5))
.color(p.txt),
);
ui.add_space(8.0);
ui.horizontal(|ui| {
if theme::button(ui, p, BtnKind::Danger, "Yes, factory reset").clicked() {
self.molto_reset_confirm = false;
self.factory_reset();
}
ui.add_space(6.0);
if theme::button(ui, p, BtnKind::Default, "Cancel").clicked() {
self.molto_reset_confirm = false;
}
});
});
}
});
});
egui::Frame::NONE
.inner_margin(egui::Margin::symmetric(26, 4))
.show(ui, |ui| {
theme::card_frame(p).show(ui, |ui| {
ui.label(egui::RichText::new("Program a slot").font(theme::f_sb(14.5)).color(p.txt));
ui.add_space(4.0);
ui.label(
egui::RichText::new("Pick a slot and program it. Slot titles and occupancy are readable by anyone holding the token \u{2014} no key needed \u{2014} so don't put secrets in titles. Seeds and codes can't be read back; codes show on the device's own screen.")
.font(theme::f_reg(11.5))
.color(p.txt3),
);
ui.add_space(10.0);
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(212.0);
let rail_h = (ui.available_height() - 48.0).max(160.0);
let mut pick = None;
egui::ScrollArea::vertical().auto_shrink([false, false]).max_height(rail_h).show(ui, |ui| {
for s in 0..PROFILES {
let selected = s == self.slot;
let (rect, resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 30.0), egui::Sense::click());
let bg = if selected {
p.brand_soft()
} else if resp.hovered() {
p.line_soft
} else {
egui::Color32::TRANSPARENT
};
ui.painter().rect(
rect,
egui::CornerRadius::same(8),
bg,
egui::Stroke::NONE,
egui::StrokeKind::Inside,
);
let meta = self
.slot_meta
.as_ref()
.and_then(|m| m.get(s as usize));
let label = match meta.and_then(|b| b.title.as_deref()) {
Some(t) => format!("Slot {s:02} \u{00B7} {}", sanitize_title(t)),
None => format!("Slot {s:02}"),
};
let text_clip = egui::Rect::from_min_max(
rect.min,
egui::pos2(rect.right() - 20.0, rect.max.y),
);
ui.painter().with_clip_rect(text_clip).text(
rect.left_center() + egui::vec2(12.0, 0.0),
egui::Align2::LEFT_CENTER,
label,
theme::f_mono(12.5),
if selected { p.brand } else { p.txt2 },
);
if meta.is_some_and(|b| b.seed_present) {
ui.painter().circle_filled(
egui::pos2(rect.right() - 12.0, rect.center().y),
3.0,
p.brand,
);
}
if resp.clicked() {
pick = Some(s);
}
}
});
if let Some(s) = pick {
self.slot = s;
self.molto_delete_confirm = false;
}
});
ui.add_space(24.0);
ui.vertical(|ui| {
ui.label(egui::RichText::new(format!("SLOT {:02}", self.slot)).font(theme::f_bold(11.0)).color(p.brand));
ui.add_space(10.0);
editor_row(ui, p, "Title", |ui| {
ui.add(egui::TextEdit::singleline(&mut self.draft.title).hint_text("\u{2264} 12 chars").desired_width(360.0));
});
editor_row(ui, p, "Secret", |ui| {
let mut rev = self
.secret_reveal
.get("molto-secret")
.copied()
.unwrap_or(false);
secret_edit(
ui,
p,
&mut self.draft.secret_base32,
&mut rev,
"base32 secret",
360.0,
);
self.secret_reveal.insert("molto-secret", rev);
});
let field_label = |ui: &mut egui::Ui, w: f32, text: &str| {
ui.add_sized([w, 22.0], egui::Label::new(egui::RichText::new(text).font(theme::f_reg(13.0)).color(p.txt2)));
};
ui.horizontal(|ui| {
field_label(ui, 92.0, "Algorithm");
let cur = match self.draft.algorithm {
AlgoChoice::Sha1 => "SHA1",
AlgoChoice::Sha256 => "SHA256",
};
if let Some(v) = theme::segmented(ui, p, &["SHA1", "SHA256"], cur, p.brand) {
self.draft.algorithm = if v == "SHA256" { AlgoChoice::Sha256 } else { AlgoChoice::Sha1 };
}
ui.add_space(30.0);
field_label(ui, 50.0, "Digits");
let cur = match self.draft.digits {
DigitsChoice::Four => "4",
DigitsChoice::Six => "6",
DigitsChoice::Eight => "8",
DigitsChoice::Ten => "10",
};
if let Some(v) = theme::segmented(ui, p, &["4", "6", "8", "10"], cur, p.brand) {
self.draft.digits = match v.as_str() {
"4" => DigitsChoice::Four,
"8" => DigitsChoice::Eight,
"10" => DigitsChoice::Ten,
_ => DigitsChoice::Six,
};
}
});
ui.add_space(8.0);
ui.horizontal(|ui| {
field_label(ui, 92.0, "Time step");
let cur = match self.draft.time_step {
StepChoice::S30 => "30s",
StepChoice::S60 => "60s",
};
if let Some(v) = theme::segmented(ui, p, &["30s", "60s"], cur, p.brand) {
self.draft.time_step = if v == "60s" { StepChoice::S60 } else { StepChoice::S30 };
}
ui.add_space(30.0);
field_label(ui, 50.0, "Display");
let cur = match self.draft.display_timeout {
TimeoutChoice::S15 => "15s",
TimeoutChoice::S30 => "30s",
TimeoutChoice::S60 => "60s",
TimeoutChoice::S120 => "120s",
};
if let Some(v) = theme::segmented(ui, p, &["15s", "30s", "60s", "120s"], cur, p.brand) {
self.draft.display_timeout = match v.as_str() {
"15s" => TimeoutChoice::S15,
"60s" => TimeoutChoice::S60,
"120s" => TimeoutChoice::S120,
_ => TimeoutChoice::S30,
};
}
});
ui.add_space(8.0);
ui.horizontal(|ui| {
if theme::button(ui, p, BtnKind::Primary, "Write to slot").clicked() {
self.apply_draft();
}
ui.add_space(6.0);
if theme::button(ui, p, BtnKind::Default, "Write title only").clicked() {
self.apply_title_only();
}
ui.add_space(6.0);
if theme::button(ui, p, BtnKind::Danger, "Delete seed\u{2026}").clicked() {
self.molto_delete_confirm = true;
}
ui.add_space(6.0);
if theme::button(ui, p, BtnKind::Default, "Import otpauth\u{2026}").clicked() {
self.import_dialog.open = true;
}
ui.add_space(6.0);
#[cfg(feature = "qr")]
{
if scan_qr_button(ui, p) {
self.molto_scan_qr();
}
}
#[cfg(feature = "qr")]
ui.add_space(6.0);
if theme::button(ui, p, BtnKind::Default, "Sync time").clicked() {
self.sync_time_selected();
}
});
if self.molto_delete_confirm {
ui.add_space(10.0);
egui::Frame::NONE
.fill(p.err_soft())
.inner_margin(egui::Margin::same(12))
.corner_radius(egui::CornerRadius::same(8))
.show(ui, |ui| {
ui.label(
egui::RichText::new(format!(
"Delete slot {:02}'s seed? Only the seed is wiped \u{2014} the title stays. No key is needed for this: anyone holding the token could do the same.",
self.slot
))
.font(theme::f_reg(12.5))
.color(p.txt),
);
ui.add_space(8.0);
ui.horizontal(|ui| {
if theme::button(ui, p, BtnKind::Danger, "Yes, delete seed").clicked() {
self.molto_delete_confirm = false;
self.delete_seed_selected();
}
ui.add_space(6.0);
if theme::button(ui, p, BtnKind::Default, "Cancel").clicked() {
self.molto_delete_confirm = false;
}
});
});
}
});
});
});
});
}
fn prog_view(&mut self, ui: &mut egui::Ui, p: &Palette, dev: &Device) {
let mp = Palette {
accent: p.brand,
..*p
};
let p = ∓
egui::Frame::NONE
.inner_margin(egui::Margin::symmetric(26, 16))
.show(ui, |ui| {
ui.horizontal(|ui| {
glyph_tile(ui, 46.0, p.brand, p.accent_ink, None);
ui.add_space(12.0);
ui.vertical(|ui| {
ui.label(
egui::RichText::new(dev.title())
.font(theme::f_bold(18.0))
.color(p.txt),
);
let serial = self
.prog_info
.as_ref()
.map(|i| i.serial.clone())
.unwrap_or_else(|| dev.serial.clone());
ui.label(
egui::RichText::new(format!("Token2 · serial {serial}"))
.font(theme::f_reg(12.5))
.color(p.txt2),
);
if let Some(info) = &self.prog_info {
ui.label(
egui::RichText::new(format!(
"device clock (UTC): {}",
fmt_unix_utc(info.utc_time)
))
.font(theme::f_reg(12.0))
.color(p.txt3),
);
}
});
});
});
ui.separator();
egui::Frame::NONE
.inner_margin(egui::Margin::symmetric(26, 12))
.show(ui, |ui| {
ui.label(
egui::RichText::new("Program seed & configuration")
.font(theme::f_sb(14.0))
.color(p.txt),
);
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Seed:")
.font(theme::f_reg(12.5))
.color(p.txt2),
);
let hint = if self.prog_seed_hex { "hex" } else { "base32" };
let mut rev = self
.secret_reveal
.get("prog-seed")
.copied()
.unwrap_or(false);
secret_edit(ui, p, &mut self.prog_seed_input, &mut rev, hint, 320.0);
self.secret_reveal.insert("prog-seed", rev);
ui.checkbox(&mut self.prog_seed_hex, "hex");
});
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Algorithm:")
.font(theme::f_reg(12.5))
.color(p.txt2),
);
egui::ComboBox::from_id_salt("prog-algo")
.selected_text(if self.prog_algo_sha256 {
"SHA256"
} else {
"SHA1"
})
.show_ui(ui, |ui| {
ui.selectable_value(&mut self.prog_algo_sha256, false, "SHA1");
ui.selectable_value(&mut self.prog_algo_sha256, true, "SHA256");
});
ui.add_space(12.0);
ui.label(
egui::RichText::new("Time step:")
.font(theme::f_reg(12.5))
.color(p.txt2),
);
egui::ComboBox::from_id_salt("prog-step")
.selected_text(if self.prog_step_60 { "60s" } else { "30s" })
.show_ui(ui, |ui| {
ui.selectable_value(&mut self.prog_step_60, false, "30s");
ui.selectable_value(&mut self.prog_step_60, true, "60s");
});
ui.add_space(12.0);
ui.label(
egui::RichText::new("Sleep:")
.font(theme::f_reg(12.5))
.color(p.txt2),
);
let timeouts = ["15s", "30s", "60s", "120s"];
egui::ComboBox::from_id_salt("prog-timeout")
.selected_text(timeouts[self.prog_timeout_idx.min(3)])
.show_ui(ui, |ui| {
for (i, t) in timeouts.iter().enumerate() {
ui.selectable_value(&mut self.prog_timeout_idx, i, *t);
}
});
});
ui.add_space(12.0);
let busy = self.busy();
let can_burn = !busy && !self.prog_seed_input.trim().is_empty();
ui.horizontal(|ui| {
ui.add_enabled_ui(can_burn, |ui| {
if theme::button(ui, p, BtnKind::Primary, "Burn seed").clicked() {
self.prog_burn();
}
});
#[cfg(feature = "qr")]
{
ui.add_space(6.0);
if scan_qr_button(ui, p) {
self.prog_scan_qr();
}
}
});
ui.add_space(4.0);
ui.label(
egui::RichText::new(
"Sets the configuration (clock + parameters) and writes the seed.",
)
.font(theme::f_reg(11.5))
.color(p.txt3),
);
if let Some((ok, msg)) = &self.prog_status {
ui.add_space(8.0);
ui.label(
egui::RichText::new(msg)
.font(theme::f_sb(12.5))
.color(if *ok { p.ok } else { p.err }),
);
}
});
}
fn prog_burn(&mut self) {
self.prog_status = None;
let Some(reader) = self.selected_device().and_then(|d| d.reader.clone()) else {
return;
};
let raw = self.prog_seed_input.trim().to_string();
let seed = if self.prog_seed_hex {
keyroost_proto::codec::hex_decode(&raw)
} else {
keyroost_proto::codec::base32_decode(&raw)
};
let seed = match seed {
Ok(s) if !s.is_empty() && s.len() <= 63 => s,
Ok(s) => {
self.log(
Severity::Err,
format!("seed length {} out of range (1..=63)", s.len()),
);
return;
}
Err(e) => {
self.log(Severity::Err, format!("invalid seed: {e}"));
return;
}
};
let seed = zeroize::Zeroizing::new(keyroost_token2prog::pad_totp_seed(seed));
let algo = if self.prog_algo_sha256 {
keyroost_token2prog::HmacAlgo::Sha256
} else {
keyroost_token2prog::HmacAlgo::Sha1
};
let step = if self.prog_step_60 {
keyroost_token2prog::TimeStep::Seconds60
} else {
keyroost_token2prog::TimeStep::Seconds30
};
let timeout = match self.prog_timeout_idx {
0 => keyroost_token2prog::DisplayTimeout::Sec15,
2 => keyroost_token2prog::DisplayTimeout::Sec60,
3 => keyroost_token2prog::DisplayTimeout::Sec120,
_ => keyroost_token2prog::DisplayTimeout::Sec30,
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as u32)
.unwrap_or(0);
let for_device = self.selected_device.clone();
self.spawn_job("Programming token\u{2026}", move || {
let result = (|| -> Result<(), String> {
let mut s = Token2ProgSession::open_named(&reader).map_err(|e| e.to_string())?;
let info = s.read_info().map_err(|e| e.to_string())?;
if info.model().is_none() {
return Err(format!(
"serial '{}' does not match any known Token2 programmable-token \
model; refusing to program this device.",
info.serial
));
}
s.authenticate().map_err(|e| e.to_string())?;
let cfg = keyroost_token2prog::Config {
display_timeout: timeout,
algorithm: algo,
time_step: step,
utc_time: now,
};
s.set_config(&cfg).map_err(|e| e.to_string())?;
s.set_seed(&seed).map_err(|e| e.to_string())?;
Ok(())
})();
Box::new(move |app: &mut App| {
if app.selected_device != for_device {
return; }
match result {
Ok(()) => {
app.log(Severity::Ok, "programmed token (config + seed)".to_string());
app.prog_status = Some((
true,
"Seed and configuration programmed successfully.".to_string(),
));
wipe(&mut app.prog_seed_input);
app.secret_reveal.insert("prog-seed", false);
}
Err(e) => {
app.log(Severity::Err, format!("program token: {e}"));
app.prog_status = Some((false, format!("Programming failed: {e}")));
}
}
})
});
}
#[cfg(feature = "qr")]
fn scan_qr_parsed(&mut self) -> Result<(String, keyroost_import::OtpAuth), String> {
let uri = qrscan::scan_screens_for_otpauth()?;
let parsed = parse_otpauth(&uri).map_err(|e| format!("QR parse failed: {e}"))?;
let secret = uri
.find("secret=")
.map(|i| {
let rest = &uri[i + 7..];
let end = rest.find('&').unwrap_or(rest.len());
rest[..end].to_owned()
})
.unwrap_or_default();
if secret.is_empty() {
return Err("scanned QR has no secret".into());
}
Ok((secret, parsed))
}
#[cfg(feature = "qr")]
fn oath_scan_qr(&mut self) {
match self.scan_qr_parsed() {
Ok((secret, parsed)) => {
self.oath.add.secret = secret;
let name = parsed.suggested_title();
if !name.is_empty() {
self.oath.add.name = name;
}
self.oath.add.totp = true; }
Err(e) => self.log(Severity::Err, format!("scan QR: {e}")),
}
}
#[cfg(feature = "qr")]
fn otp_scan_qr(&mut self) {
match self.scan_qr_parsed() {
Ok((secret, parsed)) => {
self.otp.add.secret = secret;
if let Some(issuer) = &parsed.issuer {
self.otp.add.app_name = issuer.clone();
}
if let Some(account) = &parsed.account {
self.otp.add.account_name = account.clone();
}
self.otp.add.sha256 = matches!(parsed.algorithm, HmacAlgo::Sha256);
self.otp.add.digits = parsed.digits as u8;
self.otp.add.period = match parsed.time_step {
TimeStep::Seconds30 => 30,
TimeStep::Seconds60 => 60,
};
self.otp.add.totp = true; }
Err(e) => self.log(Severity::Err, format!("scan QR: {e}")),
}
}
#[cfg(feature = "qr")]
fn prog_scan_qr(&mut self) {
self.prog_status = None;
match self.scan_qr_parsed() {
Ok((secret, parsed)) => {
self.prog_seed_input = zeroize::Zeroizing::new(secret);
self.prog_seed_hex = false;
self.secret_reveal.insert("prog-seed", false);
self.prog_algo_sha256 = matches!(parsed.algorithm, HmacAlgo::Sha256);
self.prog_step_60 = matches!(parsed.time_step, TimeStep::Seconds60);
self.prog_status = Some((
true,
"Filled seed and parameters from the scanned QR.".into(),
));
}
Err(e) => self.prog_status = Some((false, e)),
}
}
fn molto_dialogs(&mut self, ctx: &egui::Context, _p: &Palette) {
if self.bulk_dialog.open {
let mut open = self.bulk_dialog.open;
let mut do_load = false;
let mut do_apply = false;
egui::Window::new("Bulk import")
.open(&mut open)
.collapsible(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.default_width(560.0)
.show(ctx, |ui| {
ui.label("Export file path (Aegis JSON [plain or encrypted], 2FAS JSON, or otpauth:// list):");
ui.add(
egui::TextEdit::singleline(&mut self.bulk_dialog.path)
.desired_width(540.0)
.hint_text("/path/to/export.json"),
);
if self.bulk_dialog.needs_password {
ui.label("Aegis vault password:");
let resp = ui.add(
egui::TextEdit::singleline(&mut self.bulk_dialog.password)
.password(true)
.desired_width(360.0),
);
guard_secret_field(ui.ctx(), &resp);
}
ui.horizontal(|ui| {
let importing = self.import_busy();
if ui
.add_enabled(!importing, egui::Button::new("Load"))
.clicked()
{
do_load = true;
}
if importing {
ui.spinner();
if let Some(label) = &self.import_label {
ui.label(label.as_str());
}
}
ui.label("Start at profile:");
ui.add(
egui::DragValue::new(&mut self.bulk_dialog.start)
.clamp_existing_to_range(true)
.range(0..=99u8),
);
ui.label("Display timeout:");
egui::ComboBox::from_id_salt("bulk-timeout")
.selected_text(match self.bulk_dialog.display_timeout {
TimeoutChoice::S15 => "15s",
TimeoutChoice::S30 => "30s",
TimeoutChoice::S60 => "60s",
TimeoutChoice::S120 => "120s",
})
.show_ui(ui, |ui| {
ui.selectable_value(
&mut self.bulk_dialog.display_timeout,
TimeoutChoice::S15,
"15s",
);
ui.selectable_value(
&mut self.bulk_dialog.display_timeout,
TimeoutChoice::S30,
"30s",
);
ui.selectable_value(
&mut self.bulk_dialog.display_timeout,
TimeoutChoice::S60,
"60s",
);
ui.selectable_value(
&mut self.bulk_dialog.display_timeout,
TimeoutChoice::S120,
"120s",
);
});
});
if let Some(err) = &self.bulk_dialog.error {
ui.colored_label(egui::Color32::from_rgb(220, 100, 100), err);
}
if !self.bulk_dialog.entries.is_empty() {
ui.separator();
ui.label(format!(
"{} entries — will fill slots #{:02}..#{:02}",
self.bulk_dialog.entries.len(),
self.bulk_dialog.start,
self.bulk_dialog.start as usize + self.bulk_dialog.entries.len() - 1,
));
egui::ScrollArea::vertical()
.max_height(220.0)
.show(ui, |ui| {
for (i, e) in self.bulk_dialog.entries.iter().enumerate() {
let slot = self.bulk_dialog.start as usize + i;
ui.label(format!(
"#{:02} {} ({}/{:?}/{}d)",
slot,
e.suggested_title(),
match e.algorithm {
HmacAlgo::Sha1 => "SHA1",
HmacAlgo::Sha256 => "SHA256",
},
e.time_step,
e.digits as u8
));
}
});
ui.horizontal(|ui| {
let can_apply = self.authenticated && !self.import_busy();
if ui
.add_enabled(can_apply, egui::Button::new("Program all"))
.on_hover_text("Write seed, title, and config for every entry")
.clicked()
{
do_apply = true;
}
if ui.button("Close").clicked() {
self.bulk_dialog.open = false;
}
});
}
});
let was_open = self.bulk_dialog.open;
self.bulk_dialog.open = open;
if was_open && !open {
wipe(&mut self.bulk_dialog.password);
}
if do_load {
self.bulk_load();
}
if do_apply {
self.bulk_apply();
}
}
if self.import_dialog.open {
let mut open = self.import_dialog.open;
let mut should_apply = false;
egui::Window::new(format!("Import to profile #{:02}", self.slot))
.open(&mut open)
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.show(ctx, |ui| {
ui.label("Paste an otpauth:// URI:");
ui.add(
egui::TextEdit::multiline(&mut self.import_dialog.uri)
.desired_rows(3)
.desired_width(420.0)
.font(egui::TextStyle::Monospace),
);
ui.add_space(4.0);
ui.horizontal(|ui| {
if ui.button("Parse & populate fields").clicked() {
should_apply = true;
}
if ui.button("Cancel").clicked() {
self.import_dialog.open = false;
self.import_dialog.uri.clear();
}
});
});
self.import_dialog.open = open;
if should_apply {
self.import_otpauth();
}
}
}
}
fn oath_row(
ui: &mut egui::Ui,
p: &Palette,
name: &str,
code: Option<&str>,
hotp: bool,
is_copied: bool,
with_delete: bool,
) -> (Option<String>, bool, bool) {
let (issuer, account) = match name.split_once(':') {
Some((a, b)) => (a.to_string(), b.to_string()),
None => (name.to_string(), String::new()),
};
let (secs, pct) = theme::totp_window(30);
let code_color = if hotp {
p.accent
} else if secs <= 5 {
p.warn
} else {
p.accent
};
let mut copy = None;
let mut delete = false;
let mut read = false;
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(issuer)
.font(theme::f_sb(13.5))
.color(p.txt),
);
if hotp {
theme::pill(ui, "HOTP", p.txt2, p.line_soft);
}
});
if !account.is_empty() {
ui.label(
egui::RichText::new(account)
.font(theme::f_reg(12.0))
.color(p.txt3),
);
}
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if with_delete {
let (xr, xresp) =
ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::click());
paint_x_icon(ui, xr.center(), p.txt3);
if xresp.on_hover_text("Delete").clicked() {
delete = true;
}
ui.add_space(8.0);
}
if hotp && code.is_none() {
if with_delete {
if theme::button(ui, p, BtnKind::Ghost, "Read code").clicked() {
read = true;
}
} else {
ui.label(
egui::RichText::new("on demand")
.font(theme::f_reg(13.0))
.color(p.txt3),
);
}
return;
}
let (cr, cresp) = ui.allocate_exact_size(egui::vec2(20.0, 18.0), egui::Sense::click());
if is_copied {
paint_check_icon(ui, cr.center(), p.ok);
} else {
paint_copy_icon(ui, cr.center(), p.txt3);
}
if cresp.on_hover_text("Copy code").clicked() {
if let Some(c) = code {
copy = Some(c.to_string());
}
}
ui.add_space(10.0);
if !hotp {
ui.label(
egui::RichText::new(format!("{secs}s"))
.font(theme::f_reg(11.0))
.color(p.txt3),
);
ui.add_space(5.0);
theme::ring(ui, pct, 20.0, code_color, p.line);
}
ui.add_space(14.0);
match code {
Some(c) => {
ui.label(
egui::RichText::new(c)
.font(theme::f_mono(19.0))
.color(code_color),
);
}
None => {
ui.label(
egui::RichText::new("touch")
.font(theme::f_reg(13.0))
.color(p.txt3),
);
}
}
});
});
(copy, delete, read)
}
fn sanitize_title(s: &str) -> String {
s.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect()
}
fn editor_row(ui: &mut egui::Ui, p: &Palette, label: &str, add: impl FnOnce(&mut egui::Ui)) {
ui.horizontal(|ui| {
ui.add_sized(
[92.0, 22.0],
egui::Label::new(
egui::RichText::new(label)
.font(theme::f_reg(13.0))
.color(p.txt2),
),
);
add(ui);
});
ui.add_space(8.0);
}
fn slot_summary(attrs: &[u8], fpr: &[u8; 20]) -> String {
if fpr.iter().all(|&b| b == 0) {
"empty".to_string()
} else {
keyroost_transport::describe_algorithm_attributes(attrs)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn piv_status_with(
fingerprint: keyroost_piv::fingerprint::AppletFingerprint,
version: Option<Vec<u8>>,
) -> keyroost_transport::PivStatus {
let mut status = keyroost_transport::PivStatus::default();
status.applet_fingerprint = fingerprint;
status.version = version;
status
}
#[test]
fn stable_selectable_value_row_height_is_hover_invariant() {
let ctx = egui::Context::default();
crate::ui::theme::install_fonts(&ctx);
Palette::new(Mode::Dark, Palette::ACCENTS[0], false).apply(&ctx, Mode::Dark);
let mut current = keyroost_piv::KeyAlg::Rsa2048;
let unselected = keyroost_piv::KeyAlg::Rsa3072;
assert_ne!(current, unselected);
let mut heights = vec![];
let mut row_rect: Option<egui::Rect> = None;
for frame in 0..3 {
let mut input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(1200.0, 1200.0),
)),
..Default::default()
};
if let Some(r) = row_rect.filter(|_| frame >= 1) {
input.events.push(egui::Event::PointerMoved(r.center()));
}
let _ = ctx.run_ui(input, |ctx| {
egui::CentralPanel::default().show(ctx, |ui| {
ui.set_min_width(160.0);
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
let resp =
stable_selectable_value(ui, &mut current, unselected, unselected.label());
heights.push(resp.rect.height());
row_rect = Some(resp.rect);
});
});
}
assert_eq!(
heights[0], heights[2],
"row height must not change once the pointer starts hovering it: {heights:?}"
);
}
#[test]
fn palette_apply_sets_solid_scroll_bars_for_both_themes() {
let ctx = egui::Context::default();
for mode in [Mode::Dark, Mode::Light] {
Palette::new(mode, Palette::ACCENTS[0], false).apply(&ctx, mode);
}
for theme in [egui::Theme::Dark, egui::Theme::Light] {
ctx.style_mut_of(theme, |s| {
assert!(
!s.spacing.scroll.floating,
"{theme:?} scroll style should be solid, not the default floating"
);
});
}
}
#[test]
fn openpgp_algorithm_choices_are_the_cards_list_or_everything() {
use keyroost_transport::{KeyCrt, OpenPgpKeyAlg};
let all = OpenPgpKeyAlgSel::choices(None, KeyCrt::Decrypt);
assert_eq!(all[0], OpenPgpKeyAlgSel::CardDefault);
assert!(all.contains(&OpenPgpKeyAlgSel::Alg(OpenPgpKeyAlg::X25519)));
assert!(!all.contains(&OpenPgpKeyAlgSel::Alg(OpenPgpKeyAlg::Ed25519)));
assert!(OpenPgpKeyAlgSel::choices(None, KeyCrt::Sign)
.contains(&OpenPgpKeyAlgSel::Alg(OpenPgpKeyAlg::Ed25519)));
let info = keyroost_transport::AlgorithmInformation {
sig: vec![
vec![0x16, 0x2B, 0x06, 0x01, 0x04, 0x01, 0xDA, 0x47, 0x0F, 0x01],
vec![0x01, 0x08, 0x00, 0x00, 0x20, 0x00],
],
dec: vec![],
aut: vec![],
};
assert_eq!(
OpenPgpKeyAlgSel::choices(Some(&info), KeyCrt::Sign),
vec![
OpenPgpKeyAlgSel::CardDefault,
OpenPgpKeyAlgSel::Alg(OpenPgpKeyAlg::Ed25519),
OpenPgpKeyAlgSel::Alg(OpenPgpKeyAlg::Rsa2048)
]
);
assert!(OpenPgpKeyAlgSel::choices(Some(&info), KeyCrt::Auth).len() > 1);
}
#[test]
fn version_label_is_v_prefixed_semver() {
let rest = VERSION_LABEL
.strip_prefix('v')
.expect("version label must start with 'v'");
assert_eq!(rest, env!("CARGO_PKG_VERSION"));
assert!(
rest.split('.').count() >= 3
&& rest.split('.').take(3).all(|part| {
!part.is_empty() && part.chars().all(|c| c.is_ascii_digit() || c == '-')
}),
"version label {VERSION_LABEL:?} is not vMAJOR.MINOR.PATCH"
);
}
#[test]
fn factory_reset_summary_lists_applets_and_flags_piv_and_fido() {
use keyroost_resolve::{factory_reset_plan, Caps};
use keyroost_transport::{FactoryResetPlan, PivResetPreview};
let mut caps = Caps::default();
for c in [Caps::OATH, Caps::PIV, Caps::FIDO2] {
caps.insert(c);
}
let msg = factory_reset_confirm_summary(
"SN123",
"Token2 PIN+",
&factory_reset_plan(caps),
FactoryResetPivPreview::Resolved(PivResetPreview::Piv(
FactoryResetPlan::BurnPinPukThenReset,
)),
false,
false,
);
assert!(msg.contains("SN123") && msg.contains("Token2 PIN+"));
assert!(msg.contains("OATH") && msg.contains("PIV") && msg.contains("FIDO2"));
assert!(msg.contains("PIN and PUK"));
assert!(msg.to_lowercase().contains("unplug"));
assert!(!msg.contains("stays fully usable"));
}
#[test]
fn factory_reset_summary_piv_note_matches_the_fingerprinted_plan() {
use keyroost_resolve::{factory_reset_plan, Caps};
use keyroost_transport::{FactoryResetPlan, PivResetPreview};
let plan = factory_reset_plan(Caps::PIV);
let needs_mgmt_auth = factory_reset_confirm_summary(
"SN",
"Model",
&plan,
FactoryResetPivPreview::Resolved(PivResetPreview::Piv(
FactoryResetPlan::NeedsManagementAuth,
)),
false,
false,
);
assert!(
needs_mgmt_auth.contains("management-key"),
"{needs_mgmt_auth}"
);
assert!(
!needs_mgmt_auth.contains("NOT be wiped"),
"{needs_mgmt_auth}"
);
assert!(
!needs_mgmt_auth.contains("the report below"),
"{needs_mgmt_auth}"
);
let unverified = factory_reset_confirm_summary(
"SN",
"Model",
&plan,
FactoryResetPivPreview::Resolved(PivResetPreview::Piv(FactoryResetPlan::Unverified)),
false,
false,
);
assert!(unverified.contains("no RESET entry"), "{unverified}");
assert!(unverified.contains("without blocking"), "{unverified}");
assert!(!unverified.contains("the report below"), "{unverified}");
let burn_then_reset = factory_reset_confirm_summary(
"SN",
"Model",
&plan,
FactoryResetPivPreview::Resolved(PivResetPreview::Piv(
FactoryResetPlan::BurnPinPukThenReset,
)),
false,
false,
);
assert!(burn_then_reset.contains("PIN and PUK"), "{burn_then_reset}");
assert!(
burn_then_reset.contains("the report below the button says what state it's in"),
"{burn_then_reset}"
);
let global = factory_reset_confirm_summary(
"SN",
"Model",
&plan,
FactoryResetPivPreview::Resolved(PivResetPreview::Global),
false,
false,
);
assert!(!global.contains("PIV:"), "{global}");
let check_failed = factory_reset_confirm_summary(
"SN",
"Model",
&plan,
FactoryResetPivPreview::CheckFailed,
false,
false,
);
assert!(check_failed.contains("couldn't confirm"), "{check_failed}");
let not_offered = factory_reset_confirm_summary(
"SN",
"Model",
&plan,
FactoryResetPivPreview::NotOffered,
false,
false,
);
assert_eq!(not_offered, check_failed);
let no_piv = factory_reset_confirm_summary(
"SN",
"Model",
&factory_reset_plan(Caps::OATH),
FactoryResetPivPreview::Resolved(PivResetPreview::Global),
false,
false,
);
assert!(!no_piv.contains("PIV:"), "{no_piv}");
}
#[test]
fn factory_reset_summary_wipes_list_names_the_whole_device_when_global() {
use keyroost_resolve::{factory_reset_plan, Caps};
use keyroost_transport::{FactoryResetPlan, PivResetPreview};
let mut caps = Caps::default();
for c in [Caps::OATH, Caps::PIV] {
caps.insert(c);
}
let plan = factory_reset_plan(caps);
let global = factory_reset_confirm_summary(
"SN",
"Model",
&plan,
FactoryResetPivPreview::Resolved(PivResetPreview::Global),
false,
false,
);
assert!(
global.contains(&format!(
"Wipes: OATH, {}",
keyroost_resolve::PIV_GLOBAL_RESET_LABEL
)),
"{global}"
);
assert!(!global.contains("Wipes: OATH, PIV"), "{global}");
let piv_only = factory_reset_confirm_summary(
"SN",
"Model",
&plan,
FactoryResetPivPreview::Resolved(PivResetPreview::Piv(
FactoryResetPlan::BurnPinPukThenReset,
)),
false,
false,
);
assert!(piv_only.contains("Wipes: OATH, PIV"), "{piv_only}");
}
#[test]
fn factory_reset_summary_unsupported_preview_says_nothing_if_ever_reached() {
use keyroost_resolve::{factory_reset_plan, Caps};
use keyroost_transport::{FactoryResetPlan, PivResetPreview};
let plan = factory_reset_plan(Caps::PIV);
for preview in [
PivResetPreview::Unsupported,
PivResetPreview::Piv(FactoryResetPlan::Unsupported),
] {
let msg = factory_reset_confirm_summary(
"SN",
"Model",
&plan,
FactoryResetPivPreview::Resolved(preview),
false,
false,
);
assert!(!msg.contains("PIV:"), "{preview:?} {msg}");
}
}
#[test]
fn factory_reset_summary_credential_sentence_is_gated_on_needs_management_auth_alone() {
use keyroost_resolve::{factory_reset_plan, Caps};
use keyroost_transport::{FactoryResetPlan, PivResetPreview};
let plan = factory_reset_plan(Caps::PIV);
for preview in [
PivResetPreview::Global,
PivResetPreview::Piv(FactoryResetPlan::NeedsManagementAuth),
] {
let with_credential = factory_reset_confirm_summary(
"SN",
"Model",
&plan,
FactoryResetPivPreview::Resolved(preview),
true,
false,
);
assert_eq!(
with_credential.matches("requires management auth").count(),
1,
"{preview:?} {with_credential}"
);
let without_credential = factory_reset_confirm_summary(
"SN",
"Model",
&plan,
FactoryResetPivPreview::Resolved(preview),
false,
false,
);
assert!(
!without_credential.contains("requires management auth"),
"{preview:?} {without_credential}"
);
}
}
#[test]
fn factory_reset_summary_appends_the_long_running_hint_when_set() {
use keyroost_resolve::{factory_reset_plan, Caps};
use keyroost_transport::{FactoryResetPlan, PivResetPreview};
let plan = factory_reset_plan(Caps::PIV);
let preview = FactoryResetPivPreview::Resolved(PivResetPreview::Piv(
FactoryResetPlan::BurnPinPukThenReset,
));
let warned = factory_reset_confirm_summary("SN", "Model", &plan, preview, false, true);
assert!(
warned.contains(keyroost_piv::compat::PivQuirk::RESET_LONG_RUNNING_HINT),
"{warned}"
);
let not_warned = factory_reset_confirm_summary("SN", "Model", &plan, preview, false, false);
assert!(
!not_warned.contains(keyroost_piv::compat::PivQuirk::RESET_LONG_RUNNING_HINT),
"{not_warned}"
);
let no_piv_plan = factory_reset_plan(Caps::OATH);
let no_piv = factory_reset_confirm_summary(
"SN",
"Model",
&no_piv_plan,
FactoryResetPivPreview::Resolved(PivResetPreview::Global),
false,
true,
);
assert!(
!no_piv.contains(keyroost_piv::compat::PivQuirk::RESET_LONG_RUNNING_HINT),
"{no_piv}"
);
}
#[test]
fn concurrent_save_cert_dialogs_resolve_to_their_own_certificates() {
let mut pending = std::collections::HashMap::new();
pending.insert(
7,
PendingSshCert {
cert_pub: "alice-cert".into(),
summary: "alice".into(),
},
);
pending.insert(
8,
PendingSshCert {
cert_pub: "bob-cert".into(),
summary: "bob".into(),
},
);
let alice = take_pending_cert(&mut pending, 7).expect("alice's cert is parked");
assert_eq!(alice.cert_pub, "alice-cert");
let bob = take_pending_cert(&mut pending, 8).expect("bob's cert is still parked");
assert_eq!(bob.cert_pub, "bob-cert");
assert!(take_pending_cert(&mut pending, 7).is_none());
assert!(pending.is_empty());
}
#[test]
fn cancelled_save_cert_dialog_drops_only_its_own_certificate() {
let mut app = App::default();
for (id, name) in [(1u64, "alice"), (2, "bob")] {
app.security_keys.ssh_cert_pending.insert(
id,
PendingSshCert {
cert_pub: format!("{name}-cert"),
summary: name.into(),
},
);
}
app.discard_file_target(FileTarget::SshCertSave(1));
assert!(!app.security_keys.ssh_cert_pending.contains_key(&1));
assert_eq!(
app.security_keys
.ssh_cert_pending
.get(&2)
.map(|c| c.cert_pub.as_str()),
Some("bob-cert")
);
app.discard_file_target(FileTarget::PivExport);
assert_eq!(app.security_keys.ssh_cert_pending.len(), 1);
}
#[test]
fn factory_reset_fido_row_tracks_the_finale() {
use keyroost_resolve::{ResetStep, StepOutcome, StepReport};
let seeded = || {
vec![
FactoryResetRow::Step(StepReport {
step: ResetStep::Oath,
outcome: StepOutcome::Wiped,
}),
FactoryResetRow::FidoPending,
]
};
let mut rows = seeded();
let (text, tone) = factory_reset_row_line(&rows[1]);
assert!(text.starts_with("FIDO2"), "{text}");
assert!(text.contains("not wiped yet"), "{text}");
assert_eq!(tone, RowTone::Waiting);
resolve_fido_reset_row(&mut rows, StepOutcome::Wiped);
assert_eq!(
factory_reset_row_line(&rows[1]),
("FIDO2 wiped".to_string(), RowTone::Done)
);
assert_eq!(
factory_reset_row_line(&rows[0]),
("OATH wiped".to_string(), RowTone::Done)
);
let mut rows = seeded();
resolve_fido_reset_row(&mut rows, StepOutcome::Failed("no touch".into()));
let (text, tone) = factory_reset_row_line(&rows[1]);
assert_eq!(text, "FIDO2 failed: no touch");
assert_eq!(tone, RowTone::Bad);
let mut plain = vec![FactoryResetRow::Step(StepReport {
step: ResetStep::Oath,
outcome: StepOutcome::Wiped,
})];
resolve_fido_reset_row(&mut plain, StepOutcome::Wiped);
assert_eq!(plain.len(), 1);
assert_eq!(
factory_reset_row_line(&plain[0]),
("OATH wiped".to_string(), RowTone::Done)
);
}
#[test]
fn factory_reset_fido_row_retry_supersedes_the_earlier_outcome() {
use keyroost_resolve::{ResetStep, StepOutcome, StepReport};
let mut rows = vec![
FactoryResetRow::Step(StepReport {
step: ResetStep::Piv,
outcome: StepOutcome::Wiped,
}),
FactoryResetRow::FidoPending,
];
resolve_fido_reset_row(&mut rows, StepOutcome::Failed("no touch".into()));
assert_eq!(factory_reset_row_line(&rows[1]).1, RowTone::Bad);
resolve_fido_reset_row(&mut rows, StepOutcome::Wiped);
assert_eq!(
factory_reset_row_line(&rows[1]),
("FIDO2 wiped".to_string(), RowTone::Done)
);
assert_eq!(rows.len(), 2, "the retry must not append a second FIDO row");
assert_eq!(
factory_reset_row_line(&rows[0]),
("PIV wiped".to_string(), RowTone::Done),
"the card sweep's rows are the finale's business to leave alone"
);
let mut empty: Vec<FactoryResetRow> = Vec::new();
resolve_fido_reset_row(&mut empty, StepOutcome::Wiped);
assert!(empty.is_empty());
let mut cards = vec![FactoryResetRow::Step(StepReport {
step: ResetStep::OpenPgp,
outcome: StepOutcome::Failed("blocked".into()),
})];
resolve_fido_reset_row(&mut cards, StepOutcome::Wiped);
assert_eq!(cards.len(), 1);
assert_eq!(
factory_reset_row_line(&cards[0]),
("OpenPGP failed: blocked".to_string(), RowTone::Bad)
);
resolve_pending_fido_reset_row(
&mut rows,
StepOutcome::Failed(
"cancelled \u{2014} the passkeys and PIN are still on this key".into(),
),
);
assert_eq!(
factory_reset_row_line(&rows[1]),
("FIDO2 wiped".to_string(), RowTone::Done),
"a cancel must not rewrite a finished wipe into a failure"
);
assert_eq!(rows.len(), 2);
}
#[test]
fn cancelling_the_finale_resolves_a_pending_fido_row() {
use keyroost_resolve::{ResetStep, StepOutcome, StepReport};
let mut rows = vec![
FactoryResetRow::Step(StepReport {
step: ResetStep::Oath,
outcome: StepOutcome::Wiped,
}),
FactoryResetRow::FidoPending,
];
resolve_pending_fido_reset_row(&mut rows, StepOutcome::Failed("cancelled".into()));
assert_eq!(
factory_reset_row_line(&rows[1]),
("FIDO2 failed: cancelled".to_string(), RowTone::Bad)
);
resolve_pending_fido_reset_row(&mut rows, StepOutcome::Failed("cancelled again".into()));
assert_eq!(
factory_reset_row_line(&rows[1]),
("FIDO2 failed: cancelled".to_string(), RowTone::Bad)
);
let mut empty: Vec<FactoryResetRow> = Vec::new();
resolve_pending_fido_reset_row(&mut empty, StepOutcome::Failed("cancelled".into()));
assert!(empty.is_empty());
}
#[test]
fn piv_factory_reset_failures_disclose_the_blocked_credentials() {
let unsupported = piv_factory_reset_message(TransportError::PivResetUnsupported);
assert_eq!(unsupported, TransportError::PivResetUnsupported.to_string());
let needs_mgmt_auth =
piv_factory_reset_message(TransportError::PivResetNeedsManagementAuth);
assert_eq!(
needs_mgmt_auth,
TransportError::PivResetNeedsManagementAuth.to_string()
);
let unverified_failed = piv_factory_reset_message(
TransportError::PivResetUnverifiedFailed(Box::new(TransportError::PivResetNotAllowed)),
);
assert_eq!(
unverified_failed,
TransportError::PivResetUnverifiedFailed(Box::new(TransportError::PivResetNotAllowed))
.to_string()
);
let global_failed = piv_factory_reset_message(TransportError::PivResetGlobalFailed(
Box::new(TransportError::PivSecurityNotSatisfied),
));
assert_eq!(
global_failed,
TransportError::PivResetGlobalFailed(Box::new(TransportError::PivSecurityNotSatisfied))
.to_string()
);
let mgmt_auth_failed =
piv_factory_reset_message(TransportError::PivResetManagementAuthFailed(Box::new(
TransportError::PivManagementAuthFailed,
)));
assert_eq!(
mgmt_auth_failed,
TransportError::PivResetManagementAuthFailed(Box::new(
TransportError::PivManagementAuthFailed
))
.to_string()
);
let incomplete =
piv_factory_reset_message(TransportError::PivResetIncomplete("card state here"));
assert_eq!(incomplete, "card state here");
let guessed = piv_factory_reset_message(TransportError::PivPukGuessAccepted);
assert_eq!(guessed, TransportError::PivPukGuessAccepted.to_string());
let bare = piv_factory_reset_message(TransportError::Apdu {
label: "piv pin/puk",
sw1: 0x6a,
sw2: 0x80,
});
assert!(bare.contains("piv pin/puk"), "{bare}");
assert!(
bare.contains("locked but not wiped"),
"the half-wiped state has to be named: {bare}"
);
assert!(
bare.contains("run the factory reset again"),
"the way out has to be named: {bare}"
);
assert!(!bare.contains("piv reset"), "{bare}");
}
#[test]
fn piv_reset_global_alternative_available_matches_spec() {
use keyroost_piv::compat::FeatureGate;
for global in [
FeatureGate::Supported,
FeatureGate::Unverified,
FeatureGate::Unsupported,
] {
assert!(
!piv_reset_global_alternative_available(FeatureGate::Supported, global),
"Reset::Supported must never be redirected, ResetGlobal={global:?}"
);
}
for reset in [FeatureGate::Unsupported, FeatureGate::Unverified] {
for global in [FeatureGate::Unverified, FeatureGate::Unsupported] {
assert!(
!piv_reset_global_alternative_available(reset, global),
"reset={reset:?} global={global:?}"
);
}
}
assert!(piv_reset_global_alternative_available(
FeatureGate::Unsupported,
FeatureGate::Supported
));
assert!(piv_reset_global_alternative_available(
FeatureGate::Unverified,
FeatureGate::Supported
));
}
#[test]
fn piv_policy_caveat_hint_needed_only_hides_when_both_gates_are_supported() {
use keyroost_piv::compat::FeatureGate;
assert!(!piv_policy_caveat_hint_needed(
FeatureGate::Supported,
FeatureGate::Supported
));
for pin in [
FeatureGate::Supported,
FeatureGate::Unverified,
FeatureGate::Unsupported,
] {
for touch in [
FeatureGate::Supported,
FeatureGate::Unverified,
FeatureGate::Unsupported,
] {
if pin == FeatureGate::Supported && touch == FeatureGate::Supported {
continue;
}
assert!(
piv_policy_caveat_hint_needed(pin, touch),
"pin={pin:?} touch={touch:?}"
);
}
}
}
#[test]
fn refusing_to_arm_resolves_the_pending_fido_row() {
use keyroost_resolve::{ResetStep, StepOutcome, StepReport};
let mut app = App::default();
app.security_keys.reset = ResetDialog {
open: true,
..Default::default()
};
app.factory_reset_report = vec![
FactoryResetRow::Step(StepReport {
step: ResetStep::Oath,
outcome: StepOutcome::Wiped,
}),
FactoryResetRow::FidoPending,
];
app.arm_fido_reset_with(Vec::new());
assert!(
app.reset_arm.is_none(),
"a key we can't re-identify must never arm"
);
assert!(!app.security_keys.reset.open, "the dialog is dismissed");
let (text, tone) = factory_reset_row_line(&app.factory_reset_report[1]);
assert_eq!(tone, RowTone::Bad, "{text}");
assert!(
!text.contains("not wiped yet"),
"the row must not stay pending: {text}"
);
assert!(text.contains("not armed"), "{text}");
assert!(
text.contains("keyroostctl fido reset --yes"),
"the row carries the way out: {text}"
);
assert!(
app.security_keys
.error
.as_deref()
.is_some_and(|e| e.contains("not armed")),
"{:?}",
app.security_keys.error
);
assert!(app.log.iter().any(|l| l.text.contains("not armed")));
assert!(
app.log
.iter()
.any(|l| l.text.contains("factory reset") && l.text.contains("FIDO2")),
"the activity log must record the FIDO finale, not just the card \
sweep's earlier summary: {:?}",
app.log.iter().map(|l| &l.text).collect::<Vec<_>>()
);
}
#[test]
fn a_fido_reset_outcome_cannot_land_on_another_key() {
use keyroost_resolve::{ResetStep, StepOutcome, StepReport};
let a: DeviceId = "serial:AAA".into();
let b: DeviceId = "serial:BBB".into();
let seeded = |app: &mut App| {
app.selected_device = Some(b.clone());
app.security_keys.error = None;
app.factory_reset_report = vec![
FactoryResetRow::Step(StepReport {
step: ResetStep::Piv,
outcome: StepOutcome::Wiped,
}),
FactoryResetRow::FidoPending,
];
};
let mut app = App::default();
seeded(&mut app);
App::apply_reset_path_outcome(&mut app, Some(&a), Ok(()));
let (text, tone) = factory_reset_row_line(&app.factory_reset_report[1]);
assert_eq!(
tone,
RowTone::Waiting,
"B's FIDO row must stay pending: {text}"
);
assert!(text.contains("not wiped yet"), "{text}");
seeded(&mut app);
App::apply_reset_path_outcome(&mut app, Some(&a), Err("no touch".into()));
assert_eq!(
factory_reset_row_line(&app.factory_reset_report[1]).1,
RowTone::Waiting
);
assert!(
app.security_keys.error.is_none(),
"{:?}",
app.security_keys.error
);
seeded(&mut app);
App::apply_reset_path_outcome(&mut app, None, Ok(()));
assert_eq!(
factory_reset_row_line(&app.factory_reset_report[1]).1,
RowTone::Waiting
);
app.selected_device = None;
App::apply_reset_path_outcome(&mut app, Some(&a), Ok(()));
assert_eq!(
factory_reset_row_line(&app.factory_reset_report[1]).1,
RowTone::Waiting
);
assert!(
app.log
.iter()
.filter(|l| l.text.contains("passkeys and PIN are wiped"))
.count()
>= 1,
"a wipe that lands off-screen must still be recorded"
);
assert!(app.log.iter().any(|l| l.text.contains("still on it")));
}
#[test]
fn a_same_device_fido_outcome_is_recorded_and_a_retry_supersedes_it() {
use keyroost_resolve::{ResetStep, StepOutcome, StepReport};
let a: DeviceId = "serial:AAA".into();
let mut app = App {
selected_device: Some(a.clone()),
factory_reset_report: vec![
FactoryResetRow::Step(StepReport {
step: ResetStep::Oath,
outcome: StepOutcome::Wiped,
}),
FactoryResetRow::FidoPending,
],
..Default::default()
};
App::apply_reset_path_outcome(&mut app, Some(&a), Err("CTAP2_ERR_NOT_ALLOWED".into()));
let (text, tone) = factory_reset_row_line(&app.factory_reset_report[1]);
assert_eq!(tone, RowTone::Bad, "{text}");
assert!(text.contains("refused the reset"), "{text}");
assert!(
app.security_keys
.error
.as_deref()
.is_some_and(|e| e.contains("refused the reset")),
"{:?}",
app.security_keys.error
);
assert!(
app.log
.iter()
.any(|l| l.text.contains("factory reset") && l.text.contains("refused the reset")),
"{:?}",
app.log.iter().map(|l| &l.text).collect::<Vec<_>>()
);
App::apply_reset_path_outcome(&mut app, Some(&a), Ok(()));
assert_eq!(
factory_reset_row_line(&app.factory_reset_report[1]),
("FIDO2 wiped".to_string(), RowTone::Done)
);
assert_eq!(app.factory_reset_report.len(), 2);
assert_eq!(
factory_reset_row_line(&app.factory_reset_report[0]),
("OATH wiped".to_string(), RowTone::Done),
"the card sweep's rows are the finale's business to leave alone"
);
assert!(
app.security_keys.error.is_none(),
"the retry clears the error"
);
assert!(
app.log
.iter()
.any(|l| l.text.contains("factory reset") && l.text.contains("FIDO2 wiped")),
"{:?}",
app.log.iter().map(|l| &l.text).collect::<Vec<_>>()
);
assert!(!app
.log
.iter()
.any(|l| l.text.contains("selection moved on")));
}
#[test]
fn selecting_another_key_disarms_the_reset() {
let mut app = App::default();
app.selected_device = Some("serial:AAA".into());
app.reset_arm = Some(ResetArm {
target_serial: Some("AAA".into()),
target_path: None,
target_ids: None,
target_effective_serial: Some("AAA".into()),
for_device: app.selected_device.clone(),
prev_devices: Vec::new(),
saw_removal: true,
});
app.select_device("serial:BBB".into());
assert!(
app.reset_arm.is_none(),
"an arm must not outlive the selection it was armed for"
);
assert!(
app.log
.iter()
.any(|l| l.text.contains("the armed reset was cancelled")),
"the cancelled ceremony must be reported"
);
}
fn armed_for(for_device: &str) -> ResetArm {
ResetArm {
target_serial: Some("AAA".into()),
target_path: None,
target_ids: None,
target_effective_serial: Some("AAA".into()),
for_device: Some(for_device.into()),
prev_devices: Vec::new(),
saw_removal: true,
}
}
#[test]
fn an_orphaned_card_sweep_is_logged_and_arms_nothing() {
use keyroost_resolve::{ResetStep, StepOutcome, StepReport};
let a: DeviceId = "serial:AAA".into();
let mut app = App {
selected_device: Some("serial:BBB".into()),
..Default::default()
};
let piv_failure = "PIN and PUK are blocked but the applet was not wiped";
let reports = vec![
StepReport {
step: ResetStep::Oath,
outcome: StepOutcome::Wiped,
},
StepReport {
step: ResetStep::OpenPgp,
outcome: StepOutcome::Wiped,
},
StepReport {
step: ResetStep::Piv,
outcome: StepOutcome::Failed(piv_failure.into()),
},
];
App::apply_factory_reset_sweep(&mut app, Some(&a), reports, true);
assert!(
app.factory_reset_report.is_empty(),
"A's report must not appear under B"
);
assert!(
!app.security_keys.reset.open,
"the FIDO finale must never open under another key"
);
assert!(app.reset_arm.is_none());
let warn = app
.log
.iter()
.find(|l| l.text.contains("selection moved on"))
.unwrap_or_else(|| panic!("a wipe that lands off-screen must still be recorded"));
assert!(matches!(warn.severity, Severity::Warn));
let text = &warn.text;
assert!(text.contains("OATH"), "{text}");
assert!(text.contains("OpenPGP"), "{text}");
assert!(text.contains("PIV"), "{text}");
assert!(
text.contains(piv_failure),
"PIV's exact failure is the only thing that says the card is locked \
but not wiped: {text}"
);
assert!(
text.contains("FIDO2") && text.contains("never started"),
"the un-wiped finale has to be named: {text}"
);
let mut app = App {
selected_device: Some("serial:BBB".into()),
..Default::default()
};
App::apply_factory_reset_sweep(
&mut app,
Some(&a),
vec![StepReport {
step: ResetStep::Oath,
outcome: StepOutcome::Wiped,
}],
false,
);
let text = &app
.log
.iter()
.find(|l| l.text.contains("selection moved on"))
.expect("still logged")
.text;
assert!(!text.contains("FIDO2"), "{text}");
}
#[test]
fn a_same_device_card_sweep_paints_its_report_and_opens_the_finale() {
use keyroost_resolve::{ResetStep, StepOutcome, StepReport};
let a: DeviceId = "serial:AAA".into();
let mut app = App {
selected_device: Some(a.clone()),
oath_tried: true,
piv_tried: true,
..Default::default()
};
App::apply_factory_reset_sweep(
&mut app,
Some(&a),
vec![
StepReport {
step: ResetStep::Oath,
outcome: StepOutcome::Wiped,
},
StepReport {
step: ResetStep::Piv,
outcome: StepOutcome::Failed("card refused".into()),
},
],
true,
);
assert_eq!(app.factory_reset_report.len(), 3);
assert_eq!(
factory_reset_row_line(&app.factory_reset_report[0]),
("OATH wiped".to_string(), RowTone::Done)
);
assert_eq!(
factory_reset_row_line(&app.factory_reset_report[1]).1,
RowTone::Bad
);
assert_eq!(
factory_reset_row_line(&app.factory_reset_report[2]).1,
RowTone::Waiting,
"the finale is seeded pending, not reported as done"
);
assert!(app.security_keys.reset.open, "the finale's dialog opens");
assert!(!app.oath_tried);
assert!(app.piv_tried);
assert!(!app
.log
.iter()
.any(|l| l.text.contains("selection moved on")));
}
#[test]
fn a_same_device_card_sweep_logs_a_summary_and_flushes_any_captured_trace() {
use keyroost_resolve::{ResetStep, StepOutcome, StepReport};
let a: DeviceId = "serial:AAA".into();
let mut app = App {
selected_device: Some(a.clone()),
pending_trace: Some(vec!["> SELECT".into(), "< 9000".into()]),
..Default::default()
};
App::apply_factory_reset_sweep(
&mut app,
Some(&a),
vec![
StepReport {
step: ResetStep::Oath,
outcome: StepOutcome::Wiped,
},
StepReport {
step: ResetStep::Piv,
outcome: StepOutcome::Skipped("PIV does not support RESET".into()),
},
StepReport {
step: ResetStep::Token2Otp,
outcome: StepOutcome::Failed("card refused".into()),
},
],
false,
);
let entry = app
.log
.iter()
.find(|l| l.text.contains("factory reset finished"))
.expect("factory reset must log a summary line");
assert!(matches!(entry.severity, Severity::Warn)); assert!(entry.text.contains("OATH"), "{}", entry.text);
assert!(
entry.text.contains("PIV does not support RESET"),
"{}",
entry.text
);
assert!(entry.text.contains("card refused"), "{}", entry.text);
assert_eq!(
entry.trace.as_deref(),
Some(&["> SELECT".to_string(), "< 9000".to_string()][..])
);
assert!(app.pending_trace.is_none());
}
#[test]
fn a_fully_wiped_sweep_logs_ok_severity() {
use keyroost_resolve::{ResetStep, StepOutcome, StepReport};
let a: DeviceId = "serial:AAA".into();
let mut app = App {
selected_device: Some(a.clone()),
..Default::default()
};
App::apply_factory_reset_sweep(
&mut app,
Some(&a),
vec![StepReport {
step: ResetStep::Oath,
outcome: StepOutcome::Wiped,
}],
false,
);
let entry = app
.log
.iter()
.find(|l| l.text.contains("factory reset finished"))
.expect("factory reset must log a summary line");
assert!(matches!(entry.severity, Severity::Ok));
}
#[test]
fn a_wiped_with_warning_sweep_reinits_the_pane_but_logs_warn_severity() {
use keyroost_resolve::{ResetStep, StepOutcome, StepReport};
let a: DeviceId = "serial:AAA".into();
let mut app = App {
selected_device: Some(a.clone()),
piv_tried: true,
..Default::default()
};
App::apply_factory_reset_sweep(
&mut app,
Some(&a),
vec![StepReport {
step: ResetStep::Piv,
outcome: StepOutcome::WipedWithWarning("restoring XAUTH key 1 failed".into()),
}],
false,
);
assert!(
!app.piv_tried,
"the pane must re-list; PIV really was wiped"
);
assert_eq!(
factory_reset_row_line(&app.factory_reset_report[0]),
(
"PIV wiped, but: restoring XAUTH key 1 failed".to_string(),
RowTone::Warn
)
);
let entry = app
.log
.iter()
.find(|l| l.text.contains("factory reset finished"))
.expect("factory reset must log a summary line");
assert!(matches!(entry.severity, Severity::Warn));
assert!(entry.text.contains("wiped: PIV"), "{}", entry.text);
assert!(
entry
.text
.contains("warnings: PIV: restoring XAUTH key 1 failed"),
"{}",
entry.text
);
}
#[test]
fn factory_reset_report_summary_names_every_bucket() {
use keyroost_resolve::{ResetStep, StepOutcome, StepReport};
let summary = factory_reset_report_summary(&[
StepReport {
step: ResetStep::Oath,
outcome: StepOutcome::Wiped,
},
StepReport {
step: ResetStep::OpenPgp,
outcome: StepOutcome::Failed("card refused".into()),
},
StepReport {
step: ResetStep::Piv,
outcome: StepOutcome::Skipped("not supported".into()),
},
]);
assert!(summary.contains("wiped: OATH"), "{summary}");
assert!(
summary.contains("failed: OpenPGP: card refused"),
"{summary}"
);
assert!(summary.contains("skipped: PIV: not supported"), "{summary}");
}
#[test]
fn factory_reset_report_summary_counts_a_wiped_with_warning_step_as_wiped() {
use keyroost_resolve::{ResetStep, StepOutcome, StepReport};
let summary = factory_reset_report_summary(&[StepReport {
step: ResetStep::Piv,
outcome: StepOutcome::WipedWithWarning("restoring XAUTH key 1 failed".into()),
}]);
assert!(summary.contains("wiped: PIV"), "{summary}");
assert!(!summary.contains("wiped: none"), "{summary}");
assert!(
summary.contains("warnings: PIV: restoring XAUTH key 1 failed"),
"{summary}"
);
assert!(!summary.contains("failed:"), "{summary}");
}
#[test]
fn factory_reset_global_piv_wipe_names_the_whole_device() {
use keyroost_resolve::{ResetStep, StepOutcome, StepReport};
let report = StepReport {
step: ResetStep::Piv,
outcome: StepOutcome::WipedGlobal,
};
let (text, tone) = factory_reset_row_line(&FactoryResetRow::Step(report.clone()));
assert_eq!(tone, RowTone::Done, "{text}");
assert_eq!(text, "Whole device (via PIV) wiped");
assert!(!text.starts_with("PIV"), "{text}");
let summary = factory_reset_report_summary(&[
report,
StepReport {
step: ResetStep::Oath,
outcome: StepOutcome::Wiped,
},
]);
assert!(
summary.contains("wiped: Whole device (via PIV), OATH"),
"{summary}"
);
assert!(!summary.contains("wiped: PIV"), "{summary}");
}
#[test]
fn factory_reset_report_summary_names_none_wiped_when_nothing_wiped() {
use keyroost_resolve::{ResetStep, StepOutcome, StepReport};
let summary = factory_reset_report_summary(&[StepReport {
step: ResetStep::Oath,
outcome: StepOutcome::Failed("card refused".into()),
}]);
assert!(summary.contains("wiped: none"), "{summary}");
assert!(!summary.contains("skipped"), "{summary}");
}
#[test]
fn refusing_to_arm_clears_a_live_arm() {
let mut app = App {
security_keys: SecurityKeysState {
reset: ResetDialog {
open: true,
..Default::default()
},
..Default::default()
},
reset_arm: Some(armed_for("serial:AAA")),
..Default::default()
};
app.arm_fido_reset_with(Vec::new());
assert!(
app.reset_arm.is_none(),
"a refusal that says \"not armed\" must not leave a ceremony polling"
);
assert!(!app.security_keys.reset.open);
}
#[test]
fn a_failed_scan_disarms_the_reset() {
let mut app = App {
selected_device: Some("serial:AAA".into()),
reset_arm: Some(armed_for("serial:AAA")),
security_keys: SecurityKeysState {
reset: ResetDialog {
open: true,
..Default::default()
},
..Default::default()
},
..Default::default()
};
App::apply_device_scan_failure(&mut app, "pcsc: no service".into());
assert_eq!(app.devices_error.as_deref(), Some("pcsc: no service"));
assert!(app.devices.is_empty());
assert!(app.selected_device.is_none());
assert!(
app.reset_arm.is_none(),
"an armed ceremony must not outlive the selection it was armed for"
);
assert!(
!app.security_keys.reset.open,
"the dialog behind the ceremony goes with it"
);
assert!(
app.log
.iter()
.any(|l| l.text.contains("the armed reset was cancelled")),
"the user was told to replug; the ceremony ending has to be visible"
);
let mut app = App::default();
App::apply_device_scan_failure(&mut app, "pcsc: no service".into());
assert_eq!(app.devices_error.as_deref(), Some("pcsc: no service"));
assert!(app.log.is_empty(), "no ceremony existed to report ending");
}
#[test]
fn the_poll_refuses_to_fire_for_a_key_that_is_no_longer_selected() {
let mut app = App {
selected_device: Some("serial:BBB".into()),
reset_arm: Some(armed_for("serial:AAA")),
..Default::default()
};
app.poll_reset_arm();
assert!(
app.reset_arm.is_none(),
"an arm bound to another key must not survive a poll"
);
assert!(app
.log
.iter()
.any(|l| l.text.contains("no longer the one selected")));
}
#[test]
fn settings_tab_exists_for_any_ctap2_key() {
let ctap20_only = AuthenticatorInfo {
versions: vec!["U2F_V2".into(), "FIDO_2_0".into()],
..Default::default()
};
assert!(
fido_settings_available(Some(&ctap20_only)),
"a CTAP 2.0-only key is resettable and must get the Settings tab"
);
let ctap21 = AuthenticatorInfo {
versions: vec!["FIDO_2_1".into()],
options: vec![("authnrCfg".into(), true)],
..Default::default()
};
assert!(fido_settings_available(Some(&ctap21)));
let pre = AuthenticatorInfo {
versions: vec!["FIDO_2_1_PRE".into()],
..Default::default()
};
assert!(fido_settings_available(Some(&pre)));
}
#[test]
fn settings_tab_absent_without_a_ctap2_key() {
assert!(!fido_settings_available(None));
let u2f_only = AuthenticatorInfo {
versions: vec!["U2F_V2".into()],
..Default::default()
};
assert!(!fido_settings_available(Some(&u2f_only)));
}
#[test]
fn ssh_cred_offers_cert_gates_on_ssh_rp_and_key() {
let key = [7u8; 32];
assert!(ssh_cred_offers_cert("ssh:demo", Some(&key)));
assert!(ssh_cred_offers_cert("ssh:", Some(&key)));
assert!(!ssh_cred_offers_cert("ssh:demo", None));
assert!(!ssh_cred_offers_cert("example.com", Some(&key)));
assert!(!ssh_cred_offers_cert("example.com", None));
assert!(!ssh_cred_offers_cert("sshy:demo", Some(&key)));
}
#[test]
fn reset_card_stands_alone_exactly_when_ctap2_but_locked() {
let ctap2 = AuthenticatorInfo {
versions: vec!["FIDO_2_0".into()],
..Default::default()
};
assert!(show_standalone_reset(false, Some(&ctap2)));
assert!(!show_standalone_reset(true, Some(&ctap2)));
assert!(!show_standalone_reset(false, None));
}
#[test]
fn subview_snaps_to_an_offered_tab() {
let tabs = [
(FidoSubview::Settings, "Settings"),
(FidoSubview::LargeBlobs, "Storage"),
];
assert_eq!(
snap_subview(FidoSubview::Settings, &tabs),
FidoSubview::Settings
);
assert_eq!(
snap_subview(FidoSubview::Fingerprints, &tabs),
FidoSubview::Settings
);
assert_eq!(
snap_subview(FidoSubview::Passkeys, &tabs),
FidoSubview::Settings
);
assert_eq!(
snap_subview(FidoSubview::Settings, &[]),
FidoSubview::Passkeys
);
}
#[test]
fn cap_tab_snaps_to_an_offered_tab() {
let tabs = [CapTab::Overview, CapTab::Fido2, CapTab::Oath];
assert_eq!(snap_cap_tab(CapTab::Fido2, &tabs), CapTab::Fido2);
assert_eq!(snap_cap_tab(CapTab::Piv, &tabs), CapTab::Overview);
assert_eq!(snap_cap_tab(CapTab::Fido2, &[]), CapTab::Overview);
}
#[test]
fn device_switch_restores_each_devices_own_cap_tab() {
let mut app = App {
selected_device: Some("serial:AAA".into()),
cap_tab: CapTab::Piv,
..Default::default()
};
let previous = app.selected_device.replace("serial:BBB".into());
app.on_device_selected(previous);
assert_eq!(app.cap_tab, CapTab::Overview);
app.cap_tab = CapTab::Fido2;
let previous = app.selected_device.replace("serial:AAA".into());
app.on_device_selected(previous);
assert_eq!(app.cap_tab, CapTab::Piv);
}
#[test]
fn pin_support_distinguishes_absent_option_from_missing_info() {
assert_eq!(pin_support(None), PinSupport::Unknown);
let no_pin_feature = AuthenticatorInfo {
versions: vec!["FIDO_2_0".into()],
..Default::default()
};
assert_eq!(pin_support(Some(&no_pin_feature)), PinSupport::Unsupported);
let pin_not_set = AuthenticatorInfo {
options: vec![("clientPin".into(), false)],
..Default::default()
};
assert_eq!(pin_support(Some(&pin_not_set)), PinSupport::NotSet);
let pin_set = AuthenticatorInfo {
options: vec![("clientPin".into(), true)],
..Default::default()
};
assert_eq!(pin_support(Some(&pin_set)), PinSupport::Set);
}
#[test]
fn duplicate_serial_note_flags_shared_serial() {
let note = duplicate_serial_note(["ABC", "ABC", "DEF"].into_iter())
.expect("a shared serial must produce an advisory");
assert!(note.contains("ABC"), "advisory names the duplicated serial");
assert!(note.contains('2'), "advisory states how many keys share it");
}
#[test]
fn duplicate_serial_note_silent_when_all_distinct() {
assert_eq!(duplicate_serial_note(["ABC", "DEF"].into_iter()), None);
assert_eq!(duplicate_serial_note(std::iter::empty::<&str>()), None);
}
#[test]
fn duplicate_serial_id_suffix_keeps_ids_distinct() {
let a = "serial:ABC#0";
let b = "serial:ABC#1";
assert_ne!(a, b);
}
#[test]
fn clear_edit_undo_purges_retained_plaintext() {
let ctx = egui::Context::default();
let id = egui::Id::new("pin-field-test");
let empty = (egui::text::CCursorRange::default(), String::new());
let secret = (
egui::text::CCursorRange::default(),
"1234-secret-pin".to_string(),
);
let mut state = egui::TextEdit::load_state(&ctx, id).unwrap_or_default();
let mut undoer = state.undoer();
undoer.feed_state(0.0, &empty);
undoer.feed_state(1.0, &secret);
undoer.feed_state(2.0, &secret);
state.set_undoer(undoer);
egui::TextEdit::store_state(&ctx, id, state);
let before = egui::TextEdit::load_state(&ctx, id).unwrap();
assert!(
before.undoer().has_undo(&empty),
"test setup should have recorded undo history"
);
clear_edit_undo(&ctx, id);
let after = egui::TextEdit::load_state(&ctx, id).unwrap();
assert!(
!after.undoer().has_undo(&empty),
"undo history (and its plaintext) must be gone after clear_edit_undo"
);
}
#[test]
fn edit_state_scrub_check_detects_retained_plaintext() {
let empty = (egui::text::CCursorRange::default(), String::new());
let secret = (egui::text::CCursorRange::default(), "1234-pin".to_string());
let mut state = egui::text_edit::TextEditState::default();
assert!(!edit_state_needs_scrub(&state));
let mut undoer = state.undoer();
undoer.feed_state(0.0, &empty);
undoer.feed_state(1.0, &secret);
undoer.feed_state(2.0, &secret);
state.set_undoer(undoer);
assert!(edit_state_needs_scrub(&state));
state.clear_undoer();
assert!(!edit_state_needs_scrub(&state));
let mut undoer = state.undoer();
undoer.feed_state(3.0, &empty);
undoer.feed_state(3.1, &secret);
state.set_undoer(undoer);
assert!(edit_state_needs_scrub(&state));
}
#[test]
fn panicking_job_clears_busy_and_worker_survives() {
let mut app = App {
worker: Some(Worker::spawn(egui::Context::default(), Default::default())),
..Default::default()
};
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
app.spawn_job("boom", || -> ApplyFn {
panic!("simulated device-op panic")
});
assert!(app.busy(), "busy set right after dispatch");
let apply = app
.worker
.as_ref()
.unwrap()
.result_rx
.recv_timeout(std::time::Duration::from_secs(5))
.expect("worker delivered an apply despite the panicking job");
std::panic::set_hook(prev);
app.busy_jobs -= 1;
apply(&mut app);
assert!(!app.busy(), "busy must clear after a panicking job");
assert!(
app.spawn_job("after", || Box::new(|app: &mut App| app.slot = 7)),
"worker should accept jobs after a panic"
);
let apply = app
.worker
.as_ref()
.unwrap()
.result_rx
.recv_timeout(std::time::Duration::from_secs(5))
.expect("second job ran on the surviving worker");
app.busy_jobs -= 1;
apply(&mut app);
assert_eq!(app.slot, 7);
}
#[test]
fn worker_round_trip_applies_result_and_clears_busy() {
let mut app = App {
worker: Some(Worker::spawn(egui::Context::default(), Default::default())),
..Default::default()
};
app.spawn_job("test", || Box::new(|app: &mut App| app.slot = 42));
assert!(app.busy(), "busy should be set right after dispatch");
let worker = app.worker.as_ref().unwrap();
let apply = worker
.result_rx
.recv_timeout(std::time::Duration::from_secs(5))
.expect("worker produced a result");
app.busy_jobs -= 1;
apply(&mut app);
assert_eq!(app.slot, 42);
assert!(!app.busy(), "busy should clear once the result is applied");
}
#[test]
fn spawn_job_ignored_while_busy() {
let mut app = App {
worker: Some(Worker::spawn(egui::Context::default(), Default::default())),
..Default::default()
};
app.spawn_job("first", || Box::new(|_: &mut App| {}));
assert_eq!(app.busy_jobs, 1);
app.spawn_job("second", || Box::new(|app: &mut App| app.slot = 99));
assert_eq!(
app.busy_jobs, 1,
"second dispatch must be ignored while busy"
);
}
#[test]
fn inline_when_no_worker() {
let mut app = App::default();
assert!(app.worker.is_none());
app.spawn_job("inline", || Box::new(|app: &mut App| app.slot = 7));
assert_eq!(app.slot, 7);
assert!(!app.busy());
}
#[test]
fn piv_cred_mismatch_change_pin() {
let mut piv = PivState::default();
piv.pin_new = "1234".into();
assert_eq!(piv_cred_mismatch(&piv, PivCredKind::ChangePin), None);
piv.pin_confirm = "1234".into();
assert_eq!(piv_cred_mismatch(&piv, PivCredKind::ChangePin), None);
piv.pin_confirm = "9999".into();
assert_eq!(
piv_cred_mismatch(&piv, PivCredKind::ChangePin),
Some("the two new PINs don't match")
);
}
#[test]
fn piv_cred_mismatch_change_puk() {
let mut piv = PivState::default();
piv.puk_new = "12345678".into();
piv.puk_confirm = "00000000".into();
assert_eq!(
piv_cred_mismatch(&piv, PivCredKind::ChangePuk),
Some("the two new PUKs don't match")
);
piv.puk_confirm = "12345678".into();
assert_eq!(piv_cred_mismatch(&piv, PivCredKind::ChangePuk), None);
}
#[test]
fn piv_cred_mismatch_unblock_never_fires() {
let mut piv = PivState::default();
piv.unblock_puk = "12345678".into();
piv.unblock_new_pin = "1234".into();
assert_eq!(piv_cred_mismatch(&piv, PivCredKind::UnblockPin), None);
}
#[test]
fn piv_gen_policy_dropdowns_default_to_default() {
let fresh = PivState::default();
assert_eq!(fresh.gen_pin_policy, keyroost_piv::PinPolicy::Default);
assert_eq!(fresh.gen_touch_policy, keyroost_piv::TouchPolicy::Default);
let mut app = App::default();
app.piv.gen_pin_policy = keyroost_piv::PinPolicy::Never;
app.piv.gen_touch_policy = keyroost_piv::TouchPolicy::Always;
app.piv_cred_modal_close();
assert_eq!(app.piv.gen_pin_policy, keyroost_piv::PinPolicy::Default);
assert_eq!(app.piv.gen_touch_policy, keyroost_piv::TouchPolicy::Default);
}
#[test]
fn piv_mgmt_alg_sel_delete_has_no_algorithm() {
use keyroost_piv::compat::MgmtAlgChoice;
assert_eq!(PivMgmtAlgSel::Delete.to_alg(), None);
assert_eq!(PivMgmtAlgSel::Delete.label(), "Delete");
assert_eq!(PivMgmtAlgSel::Delete.to_choice(), MgmtAlgChoice::Delete);
for alg in [
PivMgmtAlgSel::Aes192,
PivMgmtAlgSel::Aes128,
PivMgmtAlgSel::Aes256,
PivMgmtAlgSel::TripleDes,
] {
assert!(alg.to_alg().is_some());
assert_ne!(alg.to_choice(), MgmtAlgChoice::Delete);
}
}
#[test]
fn piv_mgmtalg_all_lists_delete_last() {
assert_eq!(
PivMgmtAlgSel::ALL,
[
PivMgmtAlgSel::TripleDes,
PivMgmtAlgSel::Aes128,
PivMgmtAlgSel::Aes192,
PivMgmtAlgSel::Aes256,
PivMgmtAlgSel::Delete,
]
);
assert_eq!(PivMgmtAlgSel::ALL.last(), Some(&PivMgmtAlgSel::Delete));
}
#[test]
fn piv_mgmtalg_default_stays_aes192_despite_the_alphabetized_all_order() {
assert_eq!(PivMgmtAlgSel::default(), PivMgmtAlgSel::Aes192);
assert_ne!(PivMgmtAlgSel::ALL[0], PivMgmtAlgSel::default());
}
#[test]
fn piv_mgmtalg_clamp_snaps_an_unoffered_selection_to_the_first_option() {
let mut sel = PivMgmtAlgSel::Delete;
piv_mgmtalg_clamp(&mut sel, &PivMgmtAlgSel::ALL);
assert_eq!(sel, PivMgmtAlgSel::Delete);
let without_delete = [
PivMgmtAlgSel::TripleDes,
PivMgmtAlgSel::Aes128,
PivMgmtAlgSel::Aes192,
PivMgmtAlgSel::Aes256,
];
let mut sel = PivMgmtAlgSel::Delete;
piv_mgmtalg_clamp(&mut sel, &without_delete);
assert_eq!(sel, PivMgmtAlgSel::TripleDes);
}
#[test]
fn piv_mgmtalg_unsupported_fallback_skips_a_disabled_first_option() {
use keyroost_piv::compat::FeatureGate;
let gate = |alg: PivMgmtAlgSel| match alg {
PivMgmtAlgSel::TripleDes => FeatureGate::Unsupported,
_ => FeatureGate::Supported,
};
assert_eq!(
piv_mgmtalg_unsupported_fallback(PivMgmtAlgSel::TripleDes, &PivMgmtAlgSel::ALL, gate),
PivMgmtAlgSel::Aes128
);
assert_eq!(
piv_mgmtalg_unsupported_fallback(PivMgmtAlgSel::Aes128, &PivMgmtAlgSel::ALL, gate),
PivMgmtAlgSel::Aes128
);
let all_unsupported = |_: PivMgmtAlgSel| FeatureGate::Unsupported;
assert_eq!(
piv_mgmtalg_unsupported_fallback(
PivMgmtAlgSel::TripleDes,
&PivMgmtAlgSel::ALL,
all_unsupported
),
PivMgmtAlgSel::TripleDes
);
assert_eq!(
piv_mgmtalg_unsupported_fallback(PivMgmtAlgSel::TripleDes, &[], gate),
PivMgmtAlgSel::TripleDes
);
}
#[test]
fn piv_mgmtalg_unverified_labels_are_suppressed_when_the_extension_is_unsupported() {
use keyroost_piv::compat::FeatureGate;
let all_unverified = |_: PivMgmtAlgSel| FeatureGate::Unverified;
assert!(piv_mgmtalg_unverified_labels(
FeatureGate::Unsupported,
&PivMgmtAlgSel::ALL,
all_unverified
)
.is_empty());
assert_eq!(
piv_mgmtalg_unverified_labels(
FeatureGate::Unverified,
&PivMgmtAlgSel::ALL,
all_unverified
)
.len(),
PivMgmtAlgSel::ALL.len()
);
assert_eq!(
piv_mgmtalg_unverified_labels(
FeatureGate::Supported,
&PivMgmtAlgSel::ALL,
all_unverified
)
.len(),
PivMgmtAlgSel::ALL.len()
);
assert!(
piv_mgmtalg_unverified_labels(FeatureGate::Supported, &PivMgmtAlgSel::ALL, |_| {
FeatureGate::Supported
})
.is_empty()
);
}
#[test]
fn piv_keyalg_unsupported_fallback_skips_a_disabled_fallback_algorithm() {
use keyroost_piv::compat::FeatureGate;
use keyroost_piv::KeyAlg;
let gate = |alg: KeyAlg| match alg {
KeyAlg::EccP256 => FeatureGate::Unsupported,
_ => FeatureGate::Supported,
};
assert_eq!(
piv_keyalg_unsupported_fallback(KeyAlg::EccP256, KeyAlg::EccP256, gate),
KeyAlg::Rsa1024
);
assert_eq!(
piv_keyalg_unsupported_fallback(KeyAlg::Rsa2048, KeyAlg::EccP256, gate),
KeyAlg::Rsa2048
);
assert_eq!(
piv_keyalg_unsupported_fallback(KeyAlg::EccP256, KeyAlg::EccP256, |_| {
FeatureGate::Unsupported
}),
KeyAlg::EccP256
);
}
#[test]
fn piv_mgmtalg_sel_to_choice_resolves_the_expected_gate_per_fingerprint() {
use keyroost_piv::compat::{FeatureGate, PivExtension};
use keyroost_piv::fingerprint::{AppletFingerprint, HidCrescendoVariant};
let gate = |fp: AppletFingerprint, sel: PivMgmtAlgSel| {
keyroost_piv::compat::resolve(
PivExtension::ManagementKeyAlgorithm(sel.to_choice()),
fp,
None,
None,
)
};
let hid = AppletFingerprint::HidCrescendo(HidCrescendoVariant::C2300);
for sel in [
PivMgmtAlgSel::TripleDes,
PivMgmtAlgSel::Aes128,
PivMgmtAlgSel::Delete,
] {
assert_eq!(gate(hid, sel), FeatureGate::Supported, "{sel:?}");
}
for sel in [PivMgmtAlgSel::Aes192, PivMgmtAlgSel::Aes256] {
assert_eq!(gate(hid, sel), FeatureGate::Unsupported, "{sel:?}");
}
for sel in [
PivMgmtAlgSel::Aes192,
PivMgmtAlgSel::Aes128,
PivMgmtAlgSel::Aes256,
PivMgmtAlgSel::TripleDes,
] {
assert_eq!(
gate(AppletFingerprint::YubiKey, sel),
FeatureGate::Supported,
"{sel:?}"
);
}
assert_eq!(
gate(AppletFingerprint::YubiKey, PivMgmtAlgSel::Delete),
FeatureGate::Unsupported
);
}
#[test]
fn piv_policy_clamp_snaps_an_unoffered_selection_to_default() {
let cached_excluded: Vec<keyroost_piv::TouchPolicy> = keyroost_piv::TouchPolicy::ALL
.iter()
.copied()
.filter(|&opt| opt != keyroost_piv::TouchPolicy::Cached)
.collect();
let mut sel = keyroost_piv::TouchPolicy::Cached;
piv_policy_clamp(&mut sel, &cached_excluded);
assert_eq!(sel, keyroost_piv::TouchPolicy::Default);
let mut sel = keyroost_piv::TouchPolicy::Always;
piv_policy_clamp(&mut sel, &cached_excluded);
assert_eq!(sel, keyroost_piv::TouchPolicy::Always);
let mut sel = keyroost_piv::TouchPolicy::Cached;
piv_policy_clamp(&mut sel, keyroost_piv::TouchPolicy::ALL);
assert_eq!(sel, keyroost_piv::TouchPolicy::Cached);
}
#[test]
fn piv_cert_compression_dropdown() {
use keyroost_transport::CertCompression;
let labels: Vec<_> = PivCertCompressionSel::ALL
.iter()
.map(|s| s.label())
.collect();
assert_eq!(labels, ["Automatic (only if too large)", "Always", "Never"]);
assert_eq!(
PivCertCompressionSel::Auto.to_compression(),
CertCompression::Auto
);
assert_eq!(
PivCertCompressionSel::Always.to_compression(),
CertCompression::Always
);
assert_eq!(
PivCertCompressionSel::Never.to_compression(),
CertCompression::Never
);
assert_eq!(
PivState::default().cert_compression,
PivCertCompressionSel::Auto
);
let mut app = App::default();
app.piv.cert_compression = PivCertCompressionSel::Never;
app.piv_cred_modal_close();
assert_eq!(app.piv.cert_compression, PivCertCompressionSel::Auto);
}
#[test]
fn piv_cert_stored_notice_and_detail() {
assert_eq!(
piv_cert_stored_notice("Certificate imported", false, 3087, false),
"Certificate imported."
);
assert_eq!(piv_cert_stored_detail(false, 3087, false), None);
assert_eq!(
piv_cert_stored_notice("Certificate imported", true, 2900, false),
"Certificate imported (stored compressed: 2900 bytes on the card)."
);
assert_eq!(
piv_cert_stored_detail(true, 2900, false).as_deref(),
Some("Stored compressed: 2900 bytes on the card.")
);
let auto = piv_cert_stored_notice("Certificate imported", true, 2900, true);
assert!(
auto.starts_with("Certificate imported (stored compressed: 2900 bytes on the card)."),
"{auto}"
);
assert!(auto.ends_with(PIV_AUTO_COMPRESSED_NOTE), "{auto}");
let detail = piv_cert_stored_detail(true, 2900, true).unwrap();
assert!(detail.contains(PIV_AUTO_COMPRESSED_NOTE), "{detail}");
let note = PIV_AUTO_COMPRESSED_NOTE;
assert!(note.contains("did not fit"), "{note}");
assert!(note.contains("Windows"), "{note}");
assert!(note.contains("macOS"), "{note}");
assert!(note.contains("not been verified"), "{note}");
assert!(!note.to_lowercase().contains("unsupported"), "{note}");
}
#[test]
fn piv_cert_too_large_hint_only_for_never() {
let slot = keyroost_piv::Slot::KeyManagement;
let too_large = TransportError::PivCertTooLarge {
slot,
len: 6164,
compressed_len: None,
};
let hint = piv_cert_error_hint(&too_large, PivCertCompressionSel::Never).unwrap();
assert!(hint.contains("Compression"), "{hint}");
assert!(hint.contains("Automatic"), "{hint}");
assert_eq!(
piv_cert_error_hint(&too_large, PivCertCompressionSel::Auto),
None
);
assert_eq!(
piv_cert_error_hint(
&TransportError::PivCardFull { slot },
PivCertCompressionSel::Never
),
None
);
}
#[test]
fn piv_slot_state_text_marks_compression() {
let p256 = Some(keyroost_piv::KeyAlg::EccP256);
assert_eq!(
piv_slot_state_text(None, true, true, p256),
"certificate present (ECC P-256, compressed)"
);
assert_eq!(
piv_slot_state_text(None, true, false, p256),
"certificate present (ECC P-256)"
);
assert_eq!(
piv_slot_state_text(None, true, true, None),
"certificate present (compressed)"
);
assert_eq!(
piv_slot_state_text(None, true, false, None),
"certificate present"
);
assert_eq!(
piv_slot_state_text(None, false, false, p256),
"key present (ECC P-256), no certificate"
);
assert_eq!(piv_slot_state_text(None, false, false, None), "empty");
assert_eq!(
piv_slot_state_text(
Some(keyroost_transport::CertUnreadable::Damaged),
true,
false,
p256
),
format!(
"certificate present but unreadable ({})",
keyroost_transport::CertUnreadable::Damaged
)
);
}
#[test]
fn piv_cred_kind_strings_are_distinct() {
let kinds = [
PivCredKind::ChangePin,
PivCredKind::ChangePuk,
PivCredKind::UnblockPin,
];
let titles: Vec<_> = kinds.iter().map(|k| k.title()).collect();
assert_eq!(titles, ["Change PIN", "Change PUK", "Unblock PIN"]);
assert_eq!(piv_cred_success(PivCredKind::ChangePin), "PIN changed");
assert_eq!(piv_cred_success(PivCredKind::ChangePuk), "PUK changed");
assert_eq!(
piv_cred_success(PivCredKind::UnblockPin),
"PIN unblocked and reset"
);
let busy: Vec<_> = kinds.iter().map(|k| k.busy_label()).collect();
assert!(busy.iter().all(|s| !s.is_empty()));
assert_eq!(busy[0], "Changing PIV PIN\u{2026}");
}
#[test]
fn apply_piv_write_success_clears_stale_retired_selection() {
let mut app = App::default();
app.piv.selected_slot = PivSlotSel::Retired(3);
app.piv.retired_occupancy = Some(vec![(keyroost_piv::Slot::Retired(3), true)]);
let status = keyroost_transport::PivStatus::default();
App::apply_piv_write(&mut app, Ok(status), "did a thing".into());
assert_eq!(
app.piv.selected_slot,
PivSlotSel::Auth,
"a retired-slot selection must not survive its occupancy cache clearing"
);
assert!(app.piv.retired_occupancy.is_none());
}
#[test]
fn apply_piv_write_success_leaves_standard_selection_alone() {
let mut app = App::default();
app.piv.selected_slot = PivSlotSel::KeyMgmt;
let status = keyroost_transport::PivStatus::default();
App::apply_piv_write(&mut app, Ok(status), "did a thing".into());
assert_eq!(app.piv.selected_slot, PivSlotSel::KeyMgmt);
}
#[test]
fn piv_cred_kind_mgmt_key_mapping() {
assert!(PivCredKind::GenerateKey.needs_mgmt_key());
assert!(PivCredKind::ImportCert.needs_mgmt_key());
assert!(PivCredKind::SelfSign.needs_mgmt_key());
assert!(PivCredKind::SetRetries.needs_mgmt_key());
assert!(PivCredKind::ChangeMgmtKey.needs_mgmt_key());
assert!(PivCredKind::DeleteCert.needs_mgmt_key());
assert!(PivCredKind::DeleteKey.needs_mgmt_key());
assert!(PivCredKind::MoveKey.needs_mgmt_key());
assert!(!PivCredKind::RequestCsr.needs_mgmt_key());
assert!(!PivCredKind::ChangePin.needs_mgmt_key());
assert!(!PivCredKind::ChangePuk.needs_mgmt_key());
assert!(!PivCredKind::UnblockPin.needs_mgmt_key());
}
#[test]
fn piv_cred_kind_gated_strings_are_present() {
let kinds = [
PivCredKind::GenerateKey,
PivCredKind::ImportCert,
PivCredKind::SelfSign,
PivCredKind::RequestCsr,
PivCredKind::SetRetries,
PivCredKind::ChangeMgmtKey,
PivCredKind::DeleteCert,
PivCredKind::DeleteKey,
PivCredKind::MoveKey,
];
for k in kinds {
assert!(!k.title().is_empty());
assert!(!k.submit_label().is_empty());
assert!(k.busy_label().ends_with('\u{2026}'));
assert!(!piv_cred_success(k).is_empty());
}
let piv = PivState::default();
for k in kinds {
assert_eq!(piv_cred_mismatch(&piv, k), None);
}
}
#[test]
fn piv_slot_occupied_reads_the_right_cache() {
use keyroost_piv::{KeyAlg, Slot};
let slot_keys = vec![
(Slot::Authentication, Some(KeyAlg::EccP256), None),
(Slot::Signature, None, None),
];
assert!(piv_slot_occupied(PivSlotSel::Auth, &slot_keys, None));
assert!(!piv_slot_occupied(PivSlotSel::Sign, &slot_keys, None));
assert!(!piv_slot_occupied(PivSlotSel::CardAuth, &slot_keys, None));
let retired = vec![
(Slot::retired(1).unwrap(), true),
(Slot::retired(2).unwrap(), false),
];
assert!(piv_slot_occupied(
PivSlotSel::Retired(1),
&slot_keys,
Some(&retired)
));
assert!(!piv_slot_occupied(
PivSlotSel::Retired(2),
&slot_keys,
Some(&retired)
));
assert!(!piv_slot_occupied(
PivSlotSel::Retired(3),
&slot_keys,
Some(&retired)
));
assert!(!piv_slot_occupied(PivSlotSel::Retired(1), &slot_keys, None));
}
#[test]
fn slot_confirmed_empty_only_fires_on_a_confirmed_empty_slot() {
use keyroost_piv::compat::FeatureGate;
assert!(slot_confirmed_empty(FeatureGate::Supported, false));
assert!(!slot_confirmed_empty(FeatureGate::Supported, true));
assert!(!slot_confirmed_empty(FeatureGate::Unverified, false));
assert!(!slot_confirmed_empty(FeatureGate::Unsupported, false));
}
#[test]
fn move_key_destinations_exclude_occupied_and_source() {
let occupied = |s: keyroost_piv::Slot| {
s == keyroost_piv::Slot::Authentication || s == keyroost_piv::Slot::retired(1).unwrap()
};
let src = keyroost_piv::Slot::KeyManagement;
let dests = move_key_eligible_destinations(src, &occupied);
assert!(!dests.contains(&src));
assert!(!dests.contains(&keyroost_piv::Slot::Authentication)); assert!(!dests.contains(&keyroost_piv::Slot::retired(1).unwrap()));
assert!(dests.contains(&keyroost_piv::Slot::retired(2).unwrap())); assert!(dests.contains(&keyroost_piv::Slot::Signature)); }
#[test]
fn piv_default_mgmt_key_is_well_known() {
let std = piv_default_mgmt_key(
keyroost_piv::fingerprint::AppletFingerprint::YubiKey,
Some(&[0]),
None,
)
.expect("YubiKey has a seeded default");
assert_eq!(&std[..8], &[1, 2, 3, 4, 5, 6, 7, 8]);
assert_eq!(&std[8..16], &std[..8]);
assert_eq!(&std[16..24], &std[..8]);
let t2 = piv_default_mgmt_key(
keyroost_piv::fingerprint::AppletFingerprint::Token2,
Some(&[0]),
None,
)
.expect("Token2 has a seeded default");
assert_ne!(std, t2);
}
#[test]
fn piv_default_mgmt_key_none_for_an_unseeded_fingerprint() {
assert_eq!(
piv_default_mgmt_key(
keyroost_piv::fingerprint::AppletFingerprint::OpenFips201(
keyroost_piv::fingerprint::OpenFips201Variant::Generic
),
Some(&[0]),
None
),
None
);
}
#[test]
fn oath_add_dialog_validate() {
let mut d = OathAddDialog::opened();
d.name = " ".into();
d.secret = "JBSWY3DPEHPK3PXP".into();
assert_eq!(d.validate().unwrap_err(), "credential name is required");
d.name = "acme:alice".into();
d.secret = String::new();
assert_eq!(d.validate().unwrap_err(), "secret is empty");
d.secret = "not base32!!".into();
assert!(d
.validate()
.unwrap_err()
.starts_with("invalid base32 secret"));
d.name = " acme:alice ".into();
d.secret = "JBSWY3DPEHPK3PXP".into();
let (name, secret) = d.validate().expect("valid");
assert_eq!(name, "acme:alice");
assert_eq!(
secret,
keyroost_proto::codec::base32_decode("JBSWY3DPEHPK3PXP").unwrap()
);
assert!(!secret.is_empty());
}
#[test]
fn apply_oath_rows_mirrors_into_add_modal() {
let mut app = App::default();
app.oath.add = OathAddDialog::opened();
app.oath.add.busy = true;
App::apply_oath_rows(
&mut app,
Ok((
vec![OathRow {
name: "x".into(),
code: None,
hotp: false,
}],
0,
)),
zeroize::Zeroizing::new(String::new()),
);
assert!(!app.oath.add.busy);
assert_eq!(app.oath.add.result, Some(Ok(())));
assert_eq!(app.oath.creds.len(), 1);
assert!(app.oath.error.is_none());
let mut app = App::default();
app.oath.add = OathAddDialog::opened();
app.oath.add.busy = true;
App::apply_oath_rows(
&mut app,
Err(TransportError::OathPasswordRejected),
zeroize::Zeroizing::new(String::new()),
);
assert!(!app.oath.add.busy);
assert_eq!(app.oath.add.result, Some(Err("wrong OATH password".into())));
assert!(app.oath.error.is_none());
let mut app = App::default();
App::apply_oath_rows(
&mut app,
Err(TransportError::OathPasswordRejected),
zeroize::Zeroizing::new(String::new()),
);
assert!(!app.oath.add.open);
assert!(app.oath.locked);
assert_eq!(app.oath.error.as_deref(), Some("wrong OATH password"));
}
#[test]
fn piv_current_mgmt_key_honours_use_default() {
let mut app = App::default();
app.piv.status = Some(piv_status_with(
keyroost_piv::fingerprint::AppletFingerprint::YubiKey,
Some(vec![5, 7]),
));
app.piv.mgmt_auth_mode = PivMgmtAuthMode::Default;
app.piv.mgmt_key_input.clear();
let key = app.piv_current_mgmt_key().expect("default fills");
assert_eq!(
&key[..],
piv_default_mgmt_key(
keyroost_piv::fingerprint::AppletFingerprint::YubiKey,
Some(&[5, 7]),
None
)
.unwrap()
);
app.piv.mgmt_auth_mode = PivMgmtAuthMode::Manual;
app.piv.mgmt_key_input = "aabbccddeeff00112233445566778899aabbccddeeff0011".into();
let typed = app.piv_current_mgmt_key().expect("valid hex");
assert_eq!(typed.len(), 24);
assert_eq!(typed[0], 0xaa);
app.piv.mgmt_key_input = "nothex".into();
assert!(app.piv_current_mgmt_key().is_err());
}
#[test]
fn piv_current_mgmt_key_default_errors_without_a_known_default() {
let mut app = App::default();
app.piv.mgmt_auth_mode = PivMgmtAuthMode::Default;
assert!(app.piv_current_mgmt_key().is_err());
app.piv.status = Some(piv_status_with(
keyroost_piv::fingerprint::AppletFingerprint::OpenFips201(
keyroost_piv::fingerprint::OpenFips201Variant::Generic,
),
None,
));
assert!(app.piv_current_mgmt_key().is_err());
}
#[test]
fn piv_current_mgmt_auth_use_pin_reads_mgmt_key_input_by_default() {
let mut app = App::default();
app.piv.mgmt_auth_mode = PivMgmtAuthMode::Pin;
app.piv.mgmt_key_input = "123456".into();
match app.piv_current_mgmt_auth(PivCredKind::GenerateKey) {
Ok(PivMgmtAuth::Pin(pin)) => assert_eq!(&*pin, "123456"),
other => panic!("expected Pin(\"123456\"), got {}", other.is_ok()),
}
}
#[test]
fn piv_current_mgmt_auth_use_pin_shares_the_dedicated_pin_field() {
let mut app = App::default();
app.piv.mgmt_auth_mode = PivMgmtAuthMode::Pin;
app.piv.mgmt_key_input = "should-not-be-used".into();
app.piv.sign_pin = "111111".into();
match app.piv_current_mgmt_auth(PivCredKind::SelfSign) {
Ok(PivMgmtAuth::Pin(pin)) => assert_eq!(&*pin, "111111"),
other => panic!("expected Pin(\"111111\"), got {}", other.is_ok()),
}
app.piv.retries_pin_auth = "222222".into();
match app.piv_current_mgmt_auth(PivCredKind::SetRetries) {
Ok(PivMgmtAuth::Pin(pin)) => assert_eq!(&*pin, "222222"),
other => panic!("expected Pin(\"222222\"), got {}", other.is_ok()),
}
}
#[test]
fn piv_current_mgmt_auth_without_use_pin_falls_back_to_mgmt_key() {
let mut app = App::default();
app.piv.status = Some(piv_status_with(
keyroost_piv::fingerprint::AppletFingerprint::YubiKey,
Some(vec![5, 7]),
));
app.piv.mgmt_auth_mode = PivMgmtAuthMode::Default;
match app.piv_current_mgmt_auth(PivCredKind::GenerateKey) {
Ok(PivMgmtAuth::Key(key)) => assert_eq!(
&key[..],
piv_default_mgmt_key(
keyroost_piv::fingerprint::AppletFingerprint::YubiKey,
Some(&[5, 7]),
None
)
.unwrap()
),
other => panic!("expected Key(default), got {}", other.is_ok()),
}
}
#[test]
fn mgmt_mode_toggle_sequence_ends_manual_not_stuck_on_pin() {
let mut mode = piv_mgmt_mode_after_toggle(true, PivMgmtAuthMode::Pin);
assert_eq!(mode, PivMgmtAuthMode::Pin);
mode = piv_mgmt_mode_after_toggle(true, PivMgmtAuthMode::Default);
assert_eq!(mode, PivMgmtAuthMode::Default);
mode = piv_mgmt_mode_after_toggle(false, PivMgmtAuthMode::Default);
assert_eq!(mode, PivMgmtAuthMode::Manual);
}
#[test]
fn piv_mgmt_mode_after_toggle_ignores_the_other_toggle_entirely() {
assert_eq!(
piv_mgmt_mode_after_toggle(true, PivMgmtAuthMode::Default),
PivMgmtAuthMode::Default
);
assert_eq!(
piv_mgmt_mode_after_toggle(true, PivMgmtAuthMode::Pin),
PivMgmtAuthMode::Pin
);
assert_eq!(
piv_mgmt_mode_after_toggle(false, PivMgmtAuthMode::Default),
PivMgmtAuthMode::Manual
);
assert_eq!(
piv_mgmt_mode_after_toggle(false, PivMgmtAuthMode::Pin),
PivMgmtAuthMode::Manual
);
}
#[test]
fn reset_mgmt_auth_mode_after_toggle_ignores_the_other_toggle_entirely() {
assert_eq!(
reset_mgmt_auth_mode_after_toggle(true, ResetMgmtAuthMode::Default),
ResetMgmtAuthMode::Default
);
assert_eq!(
reset_mgmt_auth_mode_after_toggle(true, ResetMgmtAuthMode::Pin),
ResetMgmtAuthMode::Pin
);
assert_eq!(
reset_mgmt_auth_mode_after_toggle(false, ResetMgmtAuthMode::Default),
ResetMgmtAuthMode::Manual
);
assert_eq!(
reset_mgmt_auth_mode_after_toggle(false, ResetMgmtAuthMode::Pin),
ResetMgmtAuthMode::Manual
);
}
#[test]
fn reset_mgmt_current_auth_default_reads_the_armed_dialogs_resolved_key() {
let app = App {
reset_mgmt_auth_mode: ResetMgmtAuthMode::Default,
factory_reset_confirm: Some(FactoryResetConfirmState {
for_device: "serial:AAA".into(),
piv_preview: FactoryResetPivPreview::NotOffered,
needs_reset_mgmt_auth: true,
default_mgmt_key: Some(
&keyroost_piv::fingerprint::HID_CRESCENDO_ACA_FACTORY_XAUTH_KEY,
),
reset_long_running: false,
}),
..Default::default()
};
match app.reset_mgmt_current_auth() {
Ok(ResetMgmtAuth::Key(key)) => assert_eq!(
&*key,
&keyroost_piv::fingerprint::HID_CRESCENDO_ACA_FACTORY_XAUTH_KEY
),
other => panic!("expected the factory-default key, got {}", other.is_ok()),
}
}
#[test]
fn reset_mgmt_current_auth_default_with_no_known_default_errors() {
let app = App {
reset_mgmt_auth_mode: ResetMgmtAuthMode::Default,
..Default::default()
};
assert!(app.reset_mgmt_current_auth().is_err());
}
#[test]
fn reset_mgmt_current_auth_pin_reads_the_typed_field() {
let app = App {
reset_mgmt_auth_mode: ResetMgmtAuthMode::Pin,
reset_mgmt_auth_input: "123456".into(),
..Default::default()
};
match app.reset_mgmt_current_auth() {
Ok(ResetMgmtAuth::Pin(pin)) => assert_eq!(&*pin, "123456"),
other => panic!("expected Pin(\"123456\"), got {}", other.is_ok()),
}
}
#[test]
fn reset_mgmt_current_auth_manual_decodes_typed_hex() {
let mut app = App {
reset_mgmt_auth_mode: ResetMgmtAuthMode::Manual,
reset_mgmt_auth_input: "00".repeat(24),
..Default::default()
};
match app.reset_mgmt_current_auth() {
Ok(ResetMgmtAuth::Key(key)) => assert_eq!(&*key, &[0u8; 24]),
other => panic!("expected a 24-byte zero key, got {}", other.is_ok()),
}
app.reset_mgmt_auth_input = "not hex".into();
assert!(app.reset_mgmt_current_auth().is_err());
}
#[test]
fn piv_cred_modal_close_resets_mgmt_auth_mode() {
let mut app = App::default();
for mode in [PivMgmtAuthMode::Default, PivMgmtAuthMode::Pin] {
app.piv.mgmt_auth_mode = mode;
app.piv_cred_modal_close();
assert_eq!(app.piv.mgmt_auth_mode, PivMgmtAuthMode::Manual);
}
}
#[test]
fn shares_pin_field_covers_exactly_self_sign_and_set_retries() {
for kind in [
PivCredKind::ChangePin,
PivCredKind::ChangePuk,
PivCredKind::UnblockPin,
PivCredKind::GenerateKey,
PivCredKind::ImportCert,
PivCredKind::RequestCsr,
PivCredKind::ChangeMgmtKey,
PivCredKind::DeleteCert,
PivCredKind::DeleteKey,
PivCredKind::MoveKey,
PivCredKind::NewChuid,
] {
assert!(!kind.shares_pin_field(), "{kind:?} should not share");
}
assert!(PivCredKind::SelfSign.shares_pin_field());
assert!(PivCredKind::SetRetries.shares_pin_field());
}
#[test]
fn apply_piv_cred_result_mirrors_outcome() {
let mut app = App::default();
app.piv.cred_modal = Some(PivCredModal {
kind: PivCredKind::ChangePin,
busy: true,
result: None,
detail: None,
});
app.piv.error = None;
App::apply_piv_cred_result(&mut app);
let m = app.piv.cred_modal.as_ref().unwrap();
assert!(!m.busy);
assert_eq!(m.result, Some(Ok(())));
app.piv.cred_modal = Some(PivCredModal::new(PivCredKind::ChangePuk));
app.piv.cred_modal.as_mut().unwrap().busy = true;
app.piv.error = Some("wrong PUK".into());
App::apply_piv_cred_result(&mut app);
let m = app.piv.cred_modal.as_ref().unwrap();
assert!(!m.busy);
assert_eq!(m.result, Some(Err("wrong PUK".into())));
}
#[test]
fn piv_slot_sel_labels() {
assert_eq!(PivSlotSel::Auth.label(), "authentication (9A)");
assert_eq!(PivSlotSel::Sign.label(), "signature (9C)");
assert_eq!(PivSlotSel::KeyMgmt.label(), "key management (9D)");
assert_eq!(PivSlotSel::CardAuth.label(), "card authentication (9E)");
}
#[test]
fn openpgp_cred_kind_needs_admin_pin_mapping() {
use OpenPgpCredKind::*;
for k in [
ChangeAdminPin,
UnblockUserPin,
SetName,
SetUrl,
GenerateKey,
GenerateImportKey,
ImportKeyFile,
] {
assert!(k.needs_admin_pin(), "{} should need PW3", k.title());
}
for k in [ChangeUserPin, Reset] {
assert!(!k.needs_admin_pin(), "{} should not need PW3", k.title());
}
}
#[test]
fn openpgp_admin_pin_value_honours_use_default() {
let mut app = App::default();
app.openpgp.use_default_admin = true;
app.openpgp.admin_pin.clear();
assert_eq!(app.openpgp_admin_pin_value(), OPENPGP_DEFAULT_ADMIN_PIN);
assert_eq!(app.openpgp_admin_pin_value(), "12345678");
app.openpgp.use_default_admin = false;
app.openpgp.admin_pin = "7654321".into();
assert_eq!(app.openpgp_admin_pin_value(), "7654321");
}
#[test]
fn apply_openpgp_cred_result_mirrors_outcome() {
let mut app = App::default();
app.openpgp.cred_modal = Some(OpenPgpCredModal {
kind: OpenPgpCredKind::SetName,
busy: true,
result: None,
device: None,
});
app.openpgp.error = None;
App::apply_openpgp_cred_result(&mut app);
let m = app.openpgp.cred_modal.as_ref().unwrap();
assert!(!m.busy);
assert_eq!(m.result, Some(Ok(())));
app.openpgp.cred_modal = Some(OpenPgpCredModal::new(OpenPgpCredKind::ChangeAdminPin, None));
app.openpgp.cred_modal.as_mut().unwrap().busy = true;
app.openpgp.error = Some("wrong admin PIN".into());
App::apply_openpgp_cred_result(&mut app);
let m = app.openpgp.cred_modal.as_ref().unwrap();
assert!(!m.busy);
assert_eq!(m.result, Some(Err("wrong admin PIN".into())));
}
fn test_key(id: &str, serial: &str, name: Option<&str>) -> Device {
Device {
id: id.into(),
name: name.map(str::to_owned),
vendor: "Yubico".into(),
model: "YubiKey".into(),
serial: serial.into(),
transport: "USB".into(),
firmware: String::new(),
caps: Caps::default(),
unverified: Caps::default(),
kind: DeviceKind::Key,
hid_path: None,
reader: None,
}
}
#[test]
fn card_reset_route_is_reader_only_devices_exactly() {
let mut card = test_key("reader:acs", "T2CARD01", None);
card.caps.insert(Caps::FIDO2);
card.reader = Some("ACS ACR122U 00 00".into());
assert_eq!(
App::card_reset_reader(Some(&card)).as_deref(),
Some("ACS ACR122U 00 00")
);
let mut usb_both = card.clone();
usb_both.hid_path = Some(std::path::PathBuf::from("/dev/hidraw9"));
assert_eq!(App::card_reset_reader(Some(&usb_both)), None);
let mut usb = test_key("serial:U", "USB01", None);
usb.caps.insert(Caps::FIDO2);
usb.hid_path = Some(std::path::PathBuf::from("/dev/hidraw9"));
assert_eq!(App::card_reset_reader(Some(&usb)), None);
let mut no_fido = test_key("reader:x", "NOFIDO", None);
no_fido.reader = Some("Some Reader 00 00".into());
assert_eq!(App::card_reset_reader(Some(&no_fido)), None);
assert_eq!(App::card_reset_reader(None), None);
}
#[test]
fn rename_pins_target_serial_independent_of_selection() {
let mut app = App::default();
let a = test_key("serial:A", "AAA", Some("old-a"));
app.apply_rename_actions(&a, true, false, false);
assert!(app.rename_target.is_some());
let t = app.rename_target.clone().expect("target pinned on open");
assert_eq!(t.serial, "AAA");
assert_eq!(t.name_at_open.as_deref(), Some("old-a"));
app.devices = vec![test_key("serial:B", "BBB", Some("old-b"))];
app.selected_device = Some("serial:B".into());
let still = app.rename_target.clone().unwrap();
assert_eq!(still.serial, "AAA", "pin must not follow the selection");
app.apply_rename_actions(&a, false, true, false);
assert!(app.rename_target.is_none());
}
#[test]
fn rename_refuses_a_serial_less_device() {
let mut app = App::default();
let no_serial = test_key("winfido:x", "", None);
app.apply_rename_actions(&no_serial, true, false, false);
assert!(app.rename_target.is_none());
}
#[test]
fn openpgp_cred_mismatch_never_fires() {
use OpenPgpCredKind::*;
let pgp = OpenPgpState {
user_pin_new: "111111".into(),
admin_pin_new: "22222222".into(),
..Default::default()
};
for k in [
ChangeUserPin,
ChangeAdminPin,
UnblockUserPin,
SetName,
SetUrl,
GenerateKey,
GenerateImportKey,
ImportKeyFile,
Reset,
] {
assert!(openpgp_cred_mismatch(&pgp, k).is_none());
}
}
#[test]
fn completion_guard_requires_matching_selection() {
let a: DeviceId = "serial:AAA".into();
let b: DeviceId = "serial:BBB".into();
assert!(completion_still_valid(Some(&a), Some(&a)));
assert!(!completion_still_valid(Some(&a), Some(&b)));
}
#[test]
fn completion_guard_fails_closed_without_capture_or_selection() {
let a: DeviceId = "serial:AAA".into();
assert!(!completion_still_valid(None, Some(&a)));
assert!(!completion_still_valid(Some(&a), None));
assert!(!completion_still_valid(None, None));
}
#[test]
fn molto_session_stays_bound_to_the_device_it_opened_for() {
let mut app = App {
selected_device: Some("serial:AAA".into()),
molto_session_device: Some("serial:AAA".into()),
..Default::default()
};
assert!(app.molto_session_usable());
app.selected_device = Some("serial:BBB".into());
assert!(!app.molto_session_usable());
assert!(app.take_molto_session().is_none());
assert!(app.molto_session_device.is_none());
}
#[test]
fn selection_change_clears_the_molto_session_binding() {
let mut app = App {
molto_session_device: Some("serial:AAA".into()),
..Default::default()
};
app.on_device_selected(None);
assert!(app.molto_session_device.is_none());
}
#[test]
fn advanced_dialog_apply_rejected_after_selection_change() {
let mut app = App {
selected_device: Some("serial:AAA".into()),
..Default::default()
};
app.security_keys.advanced = Some(AdvancedDialog {
action: AdvancedAction::ForcePinChange,
pin_input: "123456".into(),
min_pin_input: String::new(),
force_change: false,
device: Some("serial:AAA".into()),
});
app.selected_device = Some("serial:BBB".into());
app.run_advanced_action();
assert!(
app.security_keys.advanced.is_none(),
"a stale dialog must be dropped, not dispatched"
);
assert!(app.security_keys.error.is_some(), "the refusal is surfaced");
}
#[test]
fn selection_change_clears_the_advanced_dialog() {
let mut app = App::default();
app.security_keys.advanced = Some(AdvancedDialog {
action: AdvancedAction::SetMinPin,
pin_input: "123456".into(),
min_pin_input: "6".into(),
force_change: false,
device: Some("serial:AAA".into()),
});
app.on_device_selected(None);
assert!(app.security_keys.advanced.is_none());
}
#[test]
fn selection_change_clears_fido_session_binding_and_fingerprint_state() {
let mut app = App::default();
app.security_keys.session_device = Some("serial:AAA".into());
app.security_keys.fingerprints = Some(Vec::new());
app.security_keys.fp_new_name = "index".into();
app.security_keys.fp_confirm_delete = Some(vec![1, 2]);
app.security_keys.fp_rename = Some((vec![1, 2], "thumb".into()));
let progress = std::sync::Arc::new(std::sync::Mutex::new(EnrollProgress::default()));
let cancel = progress.lock().unwrap().cancel.clone();
app.security_keys.fp_progress = Some(progress);
app.on_device_selected(None);
assert!(app.security_keys.session_device.is_none());
assert!(app.security_keys.fingerprints.is_none());
assert!(app.security_keys.fp_new_name.is_empty());
assert!(app.security_keys.fp_confirm_delete.is_none());
assert!(app.security_keys.fp_rename.is_none());
assert!(app.security_keys.fp_progress.is_none());
assert!(
cancel.load(std::sync::atomic::Ordering::Relaxed),
"an in-flight enroll must be told to cancel"
);
}
#[test]
fn oath_listing_retains_the_accepted_password_for_on_demand_reads() {
let mut app = App::default();
app.oath.password_input = "hunter2".into();
let password = oath_effective_password(&app.oath);
assert_eq!(&*password, "hunter2", "typed entry wins while present");
App::apply_oath_rows(&mut app, Ok((Vec::new(), 0)), password);
assert!(
app.oath.password_input.is_empty(),
"typed entry is consumed"
);
assert_eq!(
app.oath.unlocked_password.as_deref().map(String::as_str),
Some("hunter2"),
"the accepted password is retained for on-demand reads"
);
assert_eq!(&*oath_effective_password(&app.oath), "hunter2");
}
#[test]
fn oath_rejected_password_is_not_retained() {
let mut app = App::default();
app.oath.unlocked_password = Some(zeroize::Zeroizing::new("stale".into()));
App::apply_oath_rows(
&mut app,
Err(TransportError::OathPasswordRejected),
zeroize::Zeroizing::new("wrong".into()),
);
assert!(app.oath.unlocked_password.is_none());
assert!(app.oath.locked);
}
#[test]
fn selection_change_clears_destructive_confirmations() {
let mut app = App::default();
app.oath.confirm_delete = Some(("serial:AAA".into(), "acct".into()));
app.openpgp.cred_modal = Some(OpenPgpCredModal::new(
OpenPgpCredKind::Reset,
Some("serial:AAA".into()),
));
app.security_keys.reset.open = true;
app.on_device_selected(None);
assert!(app.oath.confirm_delete.is_none());
assert!(app.openpgp.cred_modal.is_none());
assert!(!app.security_keys.reset.open);
}
#[test]
fn oath_delete_confirmation_is_device_bound() {
let captured: DeviceId = "serial:AAA".into();
let other: DeviceId = "serial:BBB".into();
assert!(completion_still_valid(Some(&captured), Some(&captured)));
assert!(!completion_still_valid(Some(&captured), Some(&other)));
}
#[test]
fn reset_rearm_never_accepts_a_serial_less_same_model_key() {
assert!(!reset_reinsert_matches(
None,
None,
Some((0x1050, 0x0407)),
None,
None,
(0x1050, 0x0407),
));
}
#[test]
fn reset_rearm_matches_only_the_same_stable_identity() {
let ids = Some((0x1050, 0x0407));
assert!(reset_reinsert_matches(
None,
Some("15731286"),
ids,
None,
Some("15731286"),
(0x1050, 0x0407)
));
assert!(!reset_reinsert_matches(
None,
Some("15731286"),
ids,
None,
Some("9990001"),
(0x1050, 0x0407)
));
assert!(!reset_reinsert_matches(
None,
Some("15731286"),
ids,
None,
None,
(0x1050, 0x0407)
));
assert!(reset_reinsert_matches(
Some("S1"),
None,
ids,
Some("S1"),
None,
(0x1050, 0x0407)
));
assert!(!reset_reinsert_matches(
Some("S1"),
None,
ids,
Some("S2"),
None,
(0x1050, 0x0407)
));
assert!(!reset_reinsert_matches(
None,
Some("15731286"),
ids,
None,
Some("15731286"),
(0x20a0, 0x42b2)
));
}
#[test]
fn same_hid_connection_tells_a_recycled_path_apart_from_the_old_device() {
let before = HidIdentity {
path: "/dev/hidraw0".into(),
usb_bus: Some(1),
usb_address: Some(8),
};
let after_replug = HidIdentity {
path: "/dev/hidraw0".into(), usb_bus: Some(1),
usb_address: Some(9), };
assert!(
!same_hid_connection(&before, &after_replug),
"same path, different device number: must count as a fresh connection"
);
}
#[test]
fn same_hid_connection_recognises_a_device_that_never_left() {
let seen_before = HidIdentity {
path: "/dev/hidraw1".into(),
usb_bus: Some(1),
usb_address: Some(4),
};
let seen_again = HidIdentity {
path: "/dev/hidraw1".into(),
usb_bus: Some(1),
usb_address: Some(4),
};
assert!(same_hid_connection(&seen_before, &seen_again));
}
#[test]
fn same_hid_connection_falls_back_to_path_without_usb_topology() {
let a = HidIdentity {
path: "/dev/hidraw0".into(),
usb_bus: None,
usb_address: None,
};
let b = HidIdentity {
path: "/dev/hidraw0".into(),
usb_bus: None,
usb_address: None,
};
let c = HidIdentity {
path: "/dev/hidraw1".into(),
usb_bus: None,
usb_address: None,
};
assert!(same_hid_connection(&a, &b));
assert!(!same_hid_connection(&a, &c));
}
}
#[cfg(test)]
mod prop_tests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn completion_fails_closed_on_any_unbound_side(id in proptest::option::of(".*")) {
prop_assert!(!completion_still_valid(None, id.as_ref()));
prop_assert!(!completion_still_valid(id.as_ref(), None));
}
#[test]
fn completion_requires_exact_id_match(a in ".*", b in ".*") {
prop_assert_eq!(completion_still_valid(Some(&a), Some(&b)), a == b);
prop_assert!(completion_still_valid(Some(&a), Some(&a)));
}
#[test]
fn reset_never_accepts_a_serial_less_candidate(
t_hid in proptest::option::of("[ -~]{0,12}"),
t_eff in proptest::option::of("[ -~]{0,12}"),
t_ids in proptest::option::of(any::<(u16, u16)>()),
cand_ids in any::<(u16, u16)>(),
) {
prop_assert!(!reset_reinsert_matches(
t_hid.as_deref(),
t_eff.as_deref(),
t_ids,
None,
None,
cand_ids,
));
}
#[test]
fn reset_rejects_model_mismatch_regardless_of_serials(
serial in "[ -~]{0,12}",
t_ids in any::<(u16, u16)>(),
cand_ids in any::<(u16, u16)>(),
) {
prop_assume!(t_ids != cand_ids);
prop_assert!(!reset_reinsert_matches(
Some(&serial),
Some(&serial),
Some(t_ids),
Some(&serial),
Some(&serial),
cand_ids,
));
}
#[test]
fn reset_accepts_only_on_a_stable_serial_match(
t_hid in proptest::option::of("[ -~]{0,4}"),
t_eff in proptest::option::of("[ -~]{0,4}"),
c_hid in proptest::option::of("[ -~]{0,4}"),
c_eff in proptest::option::of("[ -~]{0,4}"),
ids in any::<(u16, u16)>(),
) {
let accepted = reset_reinsert_matches(
t_hid.as_deref(),
t_eff.as_deref(),
Some(ids),
c_hid.as_deref(),
c_eff.as_deref(),
ids,
);
let expected = match (&t_hid, &c_hid) {
(Some(t), Some(c)) => t == c,
_ => matches!((&t_eff, &c_eff), (Some(t), Some(c)) if t == c),
};
prop_assert_eq!(accepted, expected);
}
#[test]
fn duplicate_note_iff_some_serial_repeats(
serials in proptest::collection::vec("[A-Z0-9]{0,4}", 0..8),
) {
let note = duplicate_serial_note(serials.iter().map(|s| s.as_str()));
let mut counts = std::collections::BTreeMap::<&str, usize>::new();
for s in &serials {
*counts.entry(s).or_insert(0) += 1;
}
let dup = counts.iter().find(|&(_, &n)| n > 1).map(|(s, _)| *s);
match (note, dup) {
(None, None) => {}
(Some(note), Some(serial)) => prop_assert!(
note.contains(serial),
"advisory must name a duplicated serial: {note:?}"
),
(note, dup) => prop_assert!(
false,
"advisory presence must track duplication: note={note:?} dup={dup:?}"
),
}
}
}
}
#[cfg(test)]
mod piv_relax_tests {
use super::*;
use keyroost_piv::compat::{FeatureGate, MgmtAlgChoice, PivExtension};
#[test]
fn relax_turns_unsupported_into_unverified_except_reset() {
let u = FeatureGate::Unsupported;
assert_eq!(piv_relax(PivExtension::MoveKey, u), FeatureGate::Unverified);
assert_eq!(piv_relax(PivExtension::Reset, u), FeatureGate::Unsupported);
assert_eq!(
piv_relax(PivExtension::GetMetadata, u),
FeatureGate::Unsupported
);
assert_eq!(piv_relax(PivExtension::Attest, u), FeatureGate::Unsupported);
assert_eq!(
piv_relax(PivExtension::GetSlotKeyStatus, u),
FeatureGate::Unsupported
);
assert_eq!(
piv_relax(PivExtension::ResetGlobal, u),
FeatureGate::Unsupported
);
assert_eq!(
piv_relax(
PivExtension::ManagementKeyAlgorithm(MgmtAlgChoice::Delete),
u
),
FeatureGate::Unsupported
);
assert_eq!(
piv_relax(PivExtension::MoveKey, FeatureGate::Supported),
FeatureGate::Supported
);
}
}