Implement OTP clipboard and QR TUI

This commit is contained in:
Hermes Agent
2026-08-10 11:11:53 +00:00
parent 1850846696
commit 71bbff934e
8 changed files with 1021 additions and 28 deletions

View File

@@ -4,13 +4,14 @@ use std::collections::BTreeSet;
use ironstorage::{
command::{
CommandRequest, OtpRequest, Presentation, help_text, otp_version_text, version_text,
CommandRequest, OtpCodeRequest, OtpRequest, OtpUriPresentation, OtpUriRequest,
Presentation, help_text, otp_version_text, version_text,
},
config::Config,
crypto::KeyInfo,
document::{DocumentError, EntryDocument, EntryFieldId},
git::{GitConflict, GitProgressPhase, GitSnapshot},
presentation::ClipboardDisposition,
presentation::{ClipboardDisposition, QrMatrix},
read::{FindResults, GrepResults, TreeModel},
repository::SecretBytes,
write::WriteOutcome,
@@ -23,7 +24,7 @@ use crate::{
search::GrepView,
sidebar::{Sidebar, SidebarIntent},
viewer::EntryViewer,
workflow::{WorkflowForm, WorkflowInput, WorkflowSubmission},
workflow::{OtpFormKind, WorkflowForm, WorkflowInput, WorkflowSubmission},
};
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
@@ -88,6 +89,47 @@ pub struct GitView {
details: Option<SecretBytes>,
}
#[derive(Debug)]
pub struct OtpDisplay {
entry: String,
field: Option<EntryFieldId>,
code: SecretBytes,
remaining_seconds: Option<u64>,
counter: Option<u64>,
}
impl OtpDisplay {
pub fn entry(&self) -> &str {
&self.entry
}
pub fn field(&self) -> Option<EntryFieldId> {
self.field
}
pub fn code(&self) -> &SecretBytes {
&self.code
}
pub fn remaining_seconds(&self) -> Option<u64> {
self.remaining_seconds
}
pub fn counter(&self) -> Option<u64> {
self.counter
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OtpPresentationTarget {
Terminal,
Clipboard,
Qr,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct OtpUiRequest {
pub request: OtpRequest,
pub confirmed_hotp: bool,
pub field: Option<EntryFieldId>,
}
impl GitView {
pub fn snapshot(&self) -> &GitSnapshot {
&self.snapshot
@@ -138,6 +180,22 @@ pub enum AsyncPayload {
conflicts: Vec<GitConflict>,
details: Option<SecretBytes>,
},
OtpCodeFinished {
entry: String,
field: Option<EntryFieldId>,
code: SecretBytes,
remaining_seconds: Option<u64>,
counter: Option<u64>,
clipboard: bool,
tree: Option<TreeModel>,
},
OtpUriFinished {
entry: String,
presentation: OtpPresentationTarget,
payload: SecretBytes,
qr: Option<QrMatrix>,
},
OtpValidated,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -195,6 +253,7 @@ pub enum AppEffect {
AuthenticateGit(ironstorage::command::GitRequest),
CancelGit,
ResolveGit(Vec<ironstorage::git::GitConflictResolution>),
AuthenticateOtp(OtpUiRequest),
RunCommand(CommandRequest),
ManualLock,
}
@@ -229,6 +288,12 @@ pub struct App {
grep_view: Option<GrepView>,
git_view: Option<GitView>,
git_pending: bool,
otp_display: Option<OtpDisplay>,
qr_popup: Option<QrMatrix>,
uri_popup: Option<SecretBytes>,
otp_pending: bool,
hotp_confirmation: Option<OtpUiRequest>,
clipboard_request: Option<SecretBytes>,
remaining_lease: Option<std::time::Duration>,
terminal_size: (u16, u16),
ticks: u64,
@@ -269,6 +334,12 @@ impl App {
grep_view: None,
git_view: None,
git_pending: false,
otp_display: None,
qr_popup: None,
uri_popup: None,
otp_pending: false,
hotp_confirmation: None,
clipboard_request: None,
remaining_lease: None,
terminal_size: (0, 0),
ticks: 0,
@@ -367,6 +438,29 @@ impl App {
self.git_pending
}
pub fn otp_display(&self) -> Option<&OtpDisplay> {
self.otp_display.as_ref().filter(|display| {
self.selected_entry
.as_deref()
.is_some_and(|entry| entry == display.entry())
})
}
pub fn qr_popup(&self) -> Option<&QrMatrix> {
self.qr_popup.as_ref()
}
pub fn uri_popup(&self) -> Option<&SecretBytes> {
self.uri_popup.as_ref()
}
pub fn otp_pending(&self) -> bool {
self.otp_pending
}
pub fn hotp_confirmation(&self) -> bool {
self.hotp_confirmation.is_some()
}
pub fn take_clipboard_request(&mut self) -> Option<SecretBytes> {
self.clipboard_request.take()
}
pub fn begin_git_operation(&mut self, label: &str) {
self.git_pending = true;
self.status = format!("{label} queued for secure-storage authentication…");
@@ -406,6 +500,36 @@ impl App {
pub fn tick(&mut self) {
self.ticks = self.ticks.wrapping_add(1);
if self.ticks.is_multiple_of(4)
&& let Some(remaining) = self
.otp_display
.as_mut()
.and_then(|display| display.remaining_seconds.as_mut())
{
*remaining = remaining.saturating_sub(1);
}
}
pub fn begin_totp_refresh(&mut self) -> Option<(String, EntryFieldId)> {
if self.otp_pending || !self.ticks.is_multiple_of(4) || self.mode != Mode::Viewer {
return None;
}
let viewer = self.viewer.as_ref()?;
let field = viewer.focused_field()?;
let otp = field.metadata().otp()?;
if otp.kind() != ironstorage::otp::OtpKind::Totp {
return None;
}
let entry = self.selected_entry.clone()?;
if self.otp_display.as_ref().is_some_and(|display| {
display.entry == entry
&& display.field == Some(field.id())
&& display.remaining_seconds != Some(0)
}) {
return None;
}
self.otp_pending = true;
Some((entry, field.id()))
}
pub fn begin_request(&mut self) -> RequestToken {
@@ -478,6 +602,9 @@ impl App {
self.focus = PaneFocus::Main;
}
Ok(AsyncPayload::ClipboardFinished(disposition)) => {
if self.mode == Mode::Locked {
return ResultDisposition::Applied;
}
self.status = match disposition {
ClipboardDisposition::RestoredPrevious => {
"Clipboard restored to its previous value".to_owned()
@@ -625,8 +752,66 @@ impl App {
self.focus = PaneFocus::Main;
self.status = message;
}
Ok(AsyncPayload::OtpCodeFinished {
entry,
field,
code,
remaining_seconds,
counter,
clipboard,
tree,
}) => {
self.otp_pending = false;
if let Some(tree) = tree {
self.sidebar.replace_tree(&tree);
}
if clipboard {
self.clipboard_request = Some(SecretBytes::new(code.expose().to_vec()));
}
self.status = if let Some(counter) = counter {
format!("Generated and committed HOTP counter {counter}")
} else {
"TOTP code refreshed".to_owned()
};
self.otp_display = Some(OtpDisplay {
entry,
field,
code,
remaining_seconds,
counter,
});
self.hotp_confirmation = None;
}
Ok(AsyncPayload::OtpUriFinished {
entry,
presentation,
payload,
qr,
}) => {
self.otp_pending = false;
self.status = format!("Presented OTP URI for {entry}");
match presentation {
OtpPresentationTarget::Terminal => {
self.qr_popup = None;
self.otp_display = None;
self.uri_popup = Some(payload);
}
OtpPresentationTarget::Clipboard => {
self.clipboard_request = Some(payload);
}
OtpPresentationTarget::Qr => {
self.uri_popup = None;
self.qr_popup = qr;
}
}
}
Ok(AsyncPayload::OtpValidated) => {
self.otp_pending = false;
self.status = "OTP URI is valid".to_owned();
}
Err(error) => {
self.git_pending = false;
self.otp_pending = false;
self.status = error;
self.editor_generation_pending = None;
self.command_open_target = None;
@@ -671,7 +856,15 @@ impl App {
self.transition(Transition::OpenCommand);
}
Action::Cancel => {
if self.git_pending {
if self.qr_popup.is_some() || self.uri_popup.is_some() {
self.qr_popup = None;
self.uri_popup = None;
self.status = "OTP presentation closed".to_owned();
} else if self.hotp_confirmation.is_some() {
self.hotp_confirmation = None;
self.status = "HOTP generation cancelled; counter unchanged".to_owned();
self.transition(Transition::Dismiss);
} else if self.git_pending {
self.status = "Cancelling Git operation…".to_owned();
return AppEffect::CancelGit;
} else if self.git_view.is_some() && self.mode == Mode::Browser {
@@ -765,11 +958,13 @@ impl App {
Action::FocusNext if self.mode == Mode::Viewer => {
if let Some(viewer) = self.viewer.as_mut() {
viewer.focus_next();
self.otp_display = None;
}
}
Action::FocusPrevious if self.mode == Mode::Viewer => {
if let Some(viewer) = self.viewer.as_mut() {
viewer.focus_previous();
self.otp_display = None;
}
}
Action::FocusNext if self.mode == Mode::Editor => {
@@ -890,12 +1085,20 @@ impl App {
}
}
Action::ConfirmDiscard => {
if self.discard_confirmation {
if let Some(mut request) = self.hotp_confirmation.take() {
request.confirmed_hotp = true;
self.transition(Transition::Dismiss);
self.otp_pending = true;
return AppEffect::AuthenticateOtp(request);
} else if self.discard_confirmation {
self.discard_editor();
}
}
Action::KeepEditing => {
if self.discard_confirmation {
if self.hotp_confirmation.take().is_some() {
self.transition(Transition::Dismiss);
self.status = "HOTP generation cancelled; counter unchanged".to_owned();
} else if self.discard_confirmation {
self.keep_editing();
}
}
@@ -937,6 +1140,71 @@ impl App {
});
}
Action::Quit => {}
Action::OtpCode
| Action::OtpCopyCode
| Action::OtpUri
| Action::OtpCopyUri
| Action::OtpQr => {
let Some(entry) = self.selected_entry.clone() else {
self.status = "Select an OTP entry first".to_owned();
return AppEffect::None;
};
let Some((field, kind)) = self
.viewer
.as_ref()
.and_then(EntryViewer::focused_field)
.and_then(|field| field.metadata().otp().map(|otp| (field.id(), otp.kind())))
else {
self.status = "Focus an OTP field first".to_owned();
return AppEffect::None;
};
let request = match action {
Action::OtpCode | Action::OtpCopyCode => OtpRequest::Code(OtpCodeRequest {
entry,
clipboard: action == Action::OtpCopyCode,
}),
Action::OtpUri | Action::OtpCopyUri | Action::OtpQr => {
OtpRequest::Uri(OtpUriRequest {
entry,
presentation: match action {
Action::OtpUri => OtpUriPresentation::Terminal,
Action::OtpCopyUri => OtpUriPresentation::Clipboard,
Action::OtpQr => OtpUriPresentation::QrCode,
_ => unreachable!(),
},
})
}
_ => unreachable!(),
};
let ui_request = OtpUiRequest {
request,
confirmed_hotp: false,
field: Some(field),
};
if matches!(ui_request.request, OtpRequest::Code(_))
&& kind == ironstorage::otp::OtpKind::Hotp
{
self.hotp_confirmation = Some(ui_request);
self.transition(Transition::OpenDialog);
self.status = "Generate HOTP and commit the advanced counter? y/n".to_owned();
return AppEffect::None;
} else {
self.otp_pending = true;
return AppEffect::AuthenticateOtp(ui_request);
}
}
Action::OtpInsert | Action::OtpAppend | Action::OtpValidate => {
let (kind, entry) = match action {
Action::OtpInsert => (OtpFormKind::Insert, None),
Action::OtpAppend => (OtpFormKind::Append, self.selected_entry.clone()),
Action::OtpValidate => (OtpFormKind::Validate, None),
_ => unreachable!(),
};
self.workflow = Some(WorkflowForm::otp(kind, entry, false));
self.transition(Transition::OpenDialog);
self.status = "OTP form: Tab navigates, C-s submits, Esc cancels".to_owned();
return AppEffect::None;
}
}
AppEffect::None
}
@@ -1112,6 +1380,49 @@ impl App {
);
AppEffect::None
}
CommandInvocation::Storage(CommandRequest::Otp(request @ OtpRequest::Code(_))) => {
self.transition(Transition::Dismiss);
self.hotp_confirmation = Some(OtpUiRequest {
request,
confirmed_hotp: false,
field: None,
});
self.transition(Transition::OpenDialog);
self.status =
"Confirm OTP generation; HOTP advances and commits its counter".to_owned();
AppEffect::None
}
CommandInvocation::Storage(CommandRequest::Otp(request @ OtpRequest::Uri(_))) => {
self.transition(Transition::Dismiss);
self.otp_pending = true;
AppEffect::AuthenticateOtp(OtpUiRequest {
request,
confirmed_hotp: false,
field: None,
})
}
CommandInvocation::Storage(CommandRequest::Otp(OtpRequest::Insert(request))) => {
self.transition(Transition::Dismiss);
self.workflow = Some(WorkflowForm::otp(
OtpFormKind::Insert,
request.entry,
request.force,
));
self.transition(Transition::OpenDialog);
self.status = "OTP insert form: enter a URI, confirm, then C-s".to_owned();
AppEffect::None
}
CommandInvocation::Storage(CommandRequest::Otp(OtpRequest::Append(request))) => {
self.transition(Transition::Dismiss);
self.workflow = Some(WorkflowForm::otp(
OtpFormKind::Append,
Some(request.entry),
request.force,
));
self.transition(Transition::OpenDialog);
self.status = "OTP append form: enter a URI, confirm, then C-s".to_owned();
AppEffect::None
}
CommandInvocation::Storage(CommandRequest::Show(request))
if request.presentation == Presentation::Terminal =>
{
@@ -1496,6 +1807,11 @@ impl App {
pub fn authentication_failed(&mut self, message: String) {
self.git_pending = false;
self.otp_pending = false;
self.otp_display = None;
self.qr_popup = None;
self.uri_popup = None;
self.clipboard_request = None;
self.authentication_pending = None;
self.selected_entry = None;
self.viewer = None;
@@ -1524,6 +1840,11 @@ impl App {
self.authentication_pending = None;
self.git_pending = false;
self.git_view = None;
self.otp_pending = false;
self.otp_display = None;
self.qr_popup = None;
self.uri_popup = None;
self.clipboard_request = None;
self.remaining_lease = None;
self.status = if discarded_edit {
format!("Locked: {reason}; unsaved edits were discarded")
@@ -1600,6 +1921,12 @@ impl App {
self.workflow = None;
self.workflow_pending = false;
self.grep_view = None;
self.otp_pending = false;
self.otp_display = None;
self.hotp_confirmation = None;
self.qr_popup = None;
self.uri_popup = None;
self.clipboard_request = None;
self.status = "Locked".to_owned();
} else if current == Mode::Locked {
self.status = "Authentication required".to_owned();
@@ -2069,4 +2396,40 @@ mod tests {
assert!(app.command_line().history().is_empty());
assert!(!app.status().contains(secret));
}
#[test]
fn hotp_requires_confirmation_and_uri_forms_remain_masked() {
let mut app = App::new();
assert!(matches!(
enter_command(&mut app, "otp code otp/hotp"),
AppEffect::None
));
assert_eq!(app.mode(), Mode::Dialog);
assert!(app.hotp_confirmation());
assert!(matches!(
app.dispatch(Action::ConfirmDiscard),
AppEffect::AuthenticateOtp(OtpUiRequest {
confirmed_hotp: true,
..
})
));
app.workflow_pending = false;
app.mode = Mode::Browser;
app.dispatch(Action::OtpInsert);
let secret = "otpauth://totp/test?secret=NEVER-RENDER";
app.workflow.as_mut().expect("OTP form").handle_key(
crossterm::event::KeyCode::Tab,
crossterm::event::KeyModifiers::NONE,
);
for character in secret.chars() {
app.workflow.as_mut().expect("OTP form").handle_key(
crossterm::event::KeyCode::Char(character),
crossterm::event::KeyModifiers::NONE,
);
}
let rows = app.workflow().expect("OTP form").rows().join("\n");
assert!(rows.contains("••••••••"));
assert!(!rows.contains("NEVER-RENDER"));
}
}